Skip to content

topology_hiding: keep the dialog-less state in a shared store (fixes long Contact / SUBSCRIBE)#4114

Open
Lt-Flash wants to merge 1 commit into
OpenSIPS:masterfrom
Lt-Flash:feature/topo-hiding-stateful-devel
Open

topology_hiding: keep the dialog-less state in a shared store (fixes long Contact / SUBSCRIBE)#4114
Lt-Flash wants to merge 1 commit into
OpenSIPS:masterfrom
Lt-Flash:feature/topo-hiding-stateful-devel

Conversation

@Lt-Flash

@Lt-Flash Lt-Flash commented Jul 17, 2026

Copy link
Copy Markdown

Problem

When topology hiding runs without a dialog, the whole encoded state (route set, original Contact, flags, bind address) rides in the Contact URI parameter — making it long. Some UAs (e.g. Cisco CP-88xx) truncate it, after which topology_hiding_match() can't decode it and every sequential request of that call is lost. This is unavoidable for non-INVITE dialogs like SUBSCRIBE: dlg_create_dialog() is INVITE-only, so a SUBSCRIBE can never get a dialog to hold its state server-side, and the payload is dominated by the route set.

What this adds

An opt-in mode for the dialog-less path: when th_state_url points at a cachedb backend, the state is stored there under a short key and only the key travels in the Contact:

stateless:  ;thinfo=VG9JbzAdIFsyPz0MNS9yRmdPY31kQWFVd0djUGtSVF9CbzYQIFtiYWNBYEFzWmFbZmZiWQ--   (72 chars)
stateful:   ;thinfo=_9ea80d5443fe5c5a                                                           (17 chars)

The Contact stays short regardless of topology size, making SUBSCRIBE (and long route sets) workable on constrained endpoints. When th_state_url is unset, the encoding path is byte-for-byte unchanged. A call that does get a dialog never reaches this mode — the dialog holds its state and puts only the dialog id in the Contact, so force_dialog=1 is the better answer for calls; this mode serves what a dialog cannot.

The full detail — the marker-safety proof, the notifier-granted-expires handling, the measured TTLs, and the per-backend matrix — is in the module docs (admin.xml / README). This is the summary.

Design

  • In-Contact path unchanged. A state that still rides in a Contact is XOR-obfuscated + word-encoded as before; a stored state skips both (pointless off the wire) and is kept as plain length-prefixed text. Decode tells them apart by a marker on the key; the restore logic is shared, not duplicated.
  • The state is immutable (route set / contact / socket are fixed at call creation), so a plain KV store with a TTL is the right fit — no update or conflict path.
  • One key per dialog leg, not per request. The key is derived (not random) from the leg it belongs to — the call-id + that party's own tag, keyed with the contact-encoding password — so every re-encode overwrites the same key. A live dialog holds exactly two states (one per leg) however often it refreshes; the password keeps the key unguessable from the clear-text call-id/tags. A message that cannot be keyed — no call-id, or no tag, as with the reply of an out-of-dialog transaction (a 200 OK to an OPTIONS keepalive carries a Contact but no To tag) — is simply not stored: its state stays inline in the Contact, and it has no sequential traffic to be matched against anyway.
  • Coalesced writes. Since the state is immutable, a re-store only bumps the TTL. Each node keeps a small shm record of the expiry it last wrote per key and skips the backend write while the state still has enough life left — collapsing a busy dialog's identical writes into occasional refreshes. A write that would shorten a state (teardown) is never skipped.
  • Availability. A sequential request may land on any node, so the backend must be shared (e.g. cachedb_redis); cachedb_local is fine for a single instance. The storage sits behind a small th_store.h interface (backend enum), so a clusterer-replication backend can be added later without touching the encode/decode sites.

State lifetime

A state is stored every time a Contact is encoded, but the deterministic key overwrites in place (no pile-up), and each write takes its TTL from the message, not a flat value:

Message State lifetime
SUBSCRIBE / NOTIFY subscription expires + 30 s (NOTIFY's granted Subscription-State wins, else Contact ;expires, else Expires)
expires 0 / Subscription-State: terminated matched state dropped now; the outgoing Contact gets th_state_ttl_short (routes the final NOTIFY)
OPTIONS / MESSAGE / INFO / PUBLISH th_state_ttl_short (default 60) — transaction-only, no target refresh
INVITE / UPDATE with session timers Session-Expires + 30 s (the Expires ring timeout is deliberately not used)
INVITE / UPDATE without session timers not stored — stays inline in the Contact (a call bounded by nothing must not be able to expire)
any message that cannot be keyed (no call-id/tag, e.g. an OPTIONS 200 OK with no To tag) not stored — stays inline in the Contact
anything else th_state_ttl (default 3600)

A message that carries its dialog's lifetime also refreshes the state it was sent to, in place — so a long-lived subscription that only ever re-SUBSCRIBEs doesn't lose the state it keeps using. An in-dialog OPTIONS does not refresh (its short TTL would otherwise cut a call to a minute). All of this is measured end-to-end against Redis; the full TTL / refresh / notifier-granted tables and the _-marker safety proof are in the docs.

Security

The stored state is in the clear — not encrypted, and (unlike the in-Contact copy) not obfuscated — so anyone who can read the backend sees the topology this hides (real Contact, route set, receiving socket). This is intentional: off the wire the obfuscation/encoding serve no purpose, and plain text is inspectable. The store is expected to be a trusted, internal backend reachable only by the OpenSIPS nodes that share it — deploy it on a private / DMZ network, bind it to the internal interface, enable the backend's own auth, and do not point th_state_url at anything reachable beyond that trust boundary.

Backend compatibility

Any cachedb backend that honours the expires argument of set() (and provides remove()) works — verified against cachedb_redis and cachedb_local, and applies to memcached / cassandra / couchbase / dynamodb / sql. cachedb_mongodb silently ignores the TTL (states would never expire), so it is rejected at startup by a URL-scheme check (the cachedb interface has no "supports expiration" capability — a general gap left out of this PR).

Configuration

loadmodule "cachedb_redis.so"
loadmodule "topology_hiding.so"
modparam("topology_hiding", "th_state_url", "redis://127.0.0.1:6379/0")  # shared, TTL-expiring backend
modparam("topology_hiding", "th_state_ttl", 3600)        # calls with no bound + fallback
modparam("topology_hiding", "th_state_ttl_short", 60)    # OPTIONS/MESSAGE/INFO/PUBLISH

route {
    if (has_totag()) {
        if (!topology_hiding_match())
            xlog("L_WARN", "no topology hiding state for $ru\n");
    } else {
        record_route();
        topology_hiding();   # works for SUBSCRIBE, no dialog needed
    }
}

cachedb_local:// (single instance) and a password-protected redis://:pass@host/db cluster are also supported.

Testing

Against cachedb_redis (and cachedb_local, confirming backend-agnostic):

  • A dialog-less SUBSCRIBE gets its state stored; the Contact param shrinks 72 → 17 chars (a 16-hex key plus its _ marker); a sequential request carrying only the key is matched, decoded, and the R-URI restored to the original endpoint.
  • Stateless mode unchanged — same 72-char Contact, nothing written.
  • Per-message TTLs measured correct (subscription expires, session timers, short-TTL for OPTIONS, teardown removal, notifier-granted correction); the mongodb:// guard refuses startup.

Roadmap (not in this PR)

  • Clusterer replication backend — keep states in shm and replicate to peers (mirroring dlg_replication.c), for deployments that don't want an external store in the signalling path; the immutable state makes it simpler (create / read / expire only).
  • A CACHEDB_CAP_EXPIRES capability so TTL-ignoring backends are rejected through the interface rather than by a URL-scheme match (touches the core plus every backend).

Notes

  • New files th_store.c / th_store.h; docs + regenerated README included. th_state_url declares a cachedb_url dependency so the backend module loads first.

@Lt-Flash
Lt-Flash force-pushed the feature/topo-hiding-stateful-devel branch 5 times, most recently from 3e75be8 to 86257b6 Compare July 17, 2026 07:23
@Lt-Flash
Lt-Flash marked this pull request as ready for review July 17, 2026 07:24
@Lt-Flash
Lt-Flash marked this pull request as draft July 18, 2026 05:18
@Lt-Flash
Lt-Flash force-pushed the feature/topo-hiding-stateful-devel branch 6 times, most recently from b96df38 to c4b121c Compare July 18, 2026 10:01
@Lt-Flash
Lt-Flash marked this pull request as ready for review July 19, 2026 00:12
@Lt-Flash

Copy link
Copy Markdown
Author

Hi, whenever you have a moment, could I kindly ask for a review of this PR? I'd really appreciate it.

I'll hold off on any further commits until the review is complete, so the branch stays stable for you to look at.

Thanks very much for your time!

Without a dialog, the whole encoded topology hiding state travels in the
Contact URI parameter. This makes the URI long, and some user agents
(e.g. Cisco CP-88xx) truncate it, after which topology_hiding_match()
can no longer decode it and the sequential requests are lost. This
mainly hurts non-INVITE dialogs, such as SUBSCRIBE, since those can
never get a dialog to hold the state - the dialog module only handles
INVITEs.

Add an alternative mode: when 'th_state_url' points to a cachedb
backend, the state is stored there under a short key
and only that key travels in the Contact, which shrinks the parameter
from 72 to 16 chars regardless of how large the hidden topology is. A
shared backend keeps the state readable by any node handling the
traffic, as needed when calls are distributed or fail over between
nodes. The storage sits behind a small internal interface, so other
backends (e.g. clusterer replication) can be added later.

The XOR obfuscation and the URI-safe word encoding a state needs to
ride inside a Contact serve no purpose once it is held server-side under
a key, so the stored copy carries neither: its fields are simply
length-prefixed in a printable "<length>:<bytes>" form, which also keeps
it readable straight out of the store. Only the state still travelling
inline in a Contact is obfuscated and word-encoded, exactly as before;
the marker keeps the two apart, an inline encoded state never being able
to produce it.

Being in the clear, the stored copy exposes the hidden topology to anyone
who can read the backend, so the backend is expected to be a trusted,
internal store reachable only by the nodes that share it - deploy it on a
private or DMZ network and restrict access to it accordingly.

A state is only kept on the server side when the message it belongs to
tells how long its dialog is going to live, as a state expiring from
under a dialog which is still up cannot be matched anymore and takes
down whatever was still to be routed through it - the BYE of a call,
most notably. A subscription is bounded by its expires, and the messages
opening no dialog only live as long as their transaction. A call is not
bounded by anything unless the session timers are in use, so the state
of a call which has none goes on travelling in its Contact, where it
cannot expire, just as it does without this mode. The two are told apart on the way back by the marker a key
carries: an encoded state comes out of word64encode() or word32encode(),
whose alphabets are "A-Za-z0-9+." and "A-Z2-7" and which both pad with
'-', so it can only ever be made of [A-Za-z0-9+.-] - which the marker is
picked out of reach of, and stays so whatever the length of a state,
where telling the two apart by length would have rested on nothing more
than the minimum size of what gets packed. The marker is also legal
unescaped in the parameter of a SIP URI, being one of the marks of
RFC 3261 25.1. So both may be in use at once. A call which is given a dialog never gets
here at all: the dialog bounds its state for exactly as long as the call
lives and its Contact only carries the dialog id, which is a better
answer than this mode could give it.

A state is stored each time a Contact is encoded, and is otherwise only
removed when it expires, so its lifetime is taken from the message being
encoded rather than from a flat value:

- SUBSCRIBE and NOTIFY carry the duration of their subscription. A
  NOTIFY reports what the notifier really granted in its
  Subscription-State header (RFC 6665 4.1.3), so it is preferred;
  otherwise it is taken from an 'expires' Contact parameter, or from the
  Expires header, the parameter taking precedence (RFC 3261 10.2.1.1). A
  subscription may outlive the configured th_state_ttl and is no longer
  cut short by it, while a short lived one no longer lingers. A notifier
  is free to grant less than was asked for, and since the state facing
  it is stored while only the asked for value is known, its NOTIFYs are
  what bring that state back in line with what was really granted;
- INVITE and UPDATE may last for any amount of time, but when the
  session timers are in use they are refreshed every Session-Expires
  seconds (RFC 4028) and each refresh encodes the Contact anew, so the
  state only has to survive one interval. Should a refresh not come, the
  session is torn down anyway. The Expires header is deliberately not
  used for these: on an INVITE it limits the validity of the invitation,
  not the duration of the answered call;
- OPTIONS, MESSAGE, INFO and PUBLISH neither open a dialog nor refresh
  the target of one (RFC 3261 12.2.1.2), so their Contact only matters
  while their transaction runs - they get th_state_ttl_short (60s);
- everything else keeps using th_state_ttl.

A message which keeps its dialog alive also pushes the expiration of the
state it was sent to further: the party which sent it only learns of a
new state if the reply it gets carries a Contact of its own, so it may
well go on using the one it already knows, which therefore has to
outlive its original expiration. Only the messages which carry the
lifetime of their dialog may refresh it, so that an in-dialog OPTIONS
cannot cut the state of the call it runs into down to a minute.

The key is derived from the dialog leg the state belongs to - the call
and the tag of the party whose Contact it hides - rather than drawn at
random, so that every refresh of that leg lands on the same key and
overwrites its state in place, its expiration pushed further. A key
minted afresh on each refresh would instead leave the previous one
behind in the store, and with no dialog over these to ever reclaim it,
it would sit there until it expired. The derivation is keyed with the
contact-encoding password, so a key cannot be guessed out of the
call-id and tags, which travel in the clear.

A SUBSCRIBE or NOTIFY with an expires of 0, or a NOTIFY reporting its
subscription as terminated, tears that subscription down,
so the state it was sent to is dropped right away instead of lingering
for as long as the subscription had asked for. The Contact encoded on
the way out still gets a state of its own, so that the final NOTIFY may
be routed back.

The backend must expire the values it stores, as that is what removes
the states, and must support removing them. All the cachedb backends do,
except for cachedb_mongodb, which accepts the expire argument of its
set() but silently ignores it - it is refused at startup rather than
piling up the states unnoticed.

Because the state is immutable, storing it again on every sequential
request only pushes its expiration further out. Each node keeps a small
in-memory (shared-memory) record of the expiry it last wrote per key and
skips the backend write while the state still has enough of its lifetime
left, turning a busy dialog's stream of identical writes into an
occasional refresh. A write that would shorten a state (a teardown) is
never skipped, and a stale or evicted record only ever costs one extra
write - the worst a cross-node race can do is expire a state slightly
early, which the endpoint recovers from by re-establishing.

When 'th_state_url' is not set, the encoding is left exactly as it was.
@Lt-Flash
Lt-Flash force-pushed the feature/topo-hiding-stateful-devel branch from c4b121c to 7e807a3 Compare July 20, 2026 04:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant