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
Open
Conversation
Lt-Flash
force-pushed
the
feature/topo-hiding-stateful-devel
branch
5 times, most recently
from
July 17, 2026 07:23
3e75be8 to
86257b6
Compare
Lt-Flash
marked this pull request as ready for review
July 17, 2026 07:24
Lt-Flash
marked this pull request as draft
July 18, 2026 05:18
Lt-Flash
force-pushed
the
feature/topo-hiding-stateful-devel
branch
6 times, most recently
from
July 18, 2026 10:01
b96df38 to
c4b121c
Compare
Lt-Flash
marked this pull request as ready for review
July 19, 2026 00:12
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
force-pushed
the
feature/topo-hiding-stateful-devel
branch
from
July 20, 2026 04:30
c4b121c to
7e807a3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_urlpoints at acachedbbackend, the state is stored there under a short key and only the key travels in the Contact:The Contact stays short regardless of topology size, making SUBSCRIBE (and long route sets) workable on constrained endpoints. When
th_state_urlis 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, soforce_dialog=1is 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
cachedb_redis);cachedb_localis fine for a single instance. The storage sits behind a smallth_store.hinterface (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:
SUBSCRIBE/NOTIFYSubscription-Statewins, else Contact;expires, elseExpires)0/Subscription-State: terminatedth_state_ttl_short(routes the final NOTIFY)OPTIONS/MESSAGE/INFO/PUBLISHth_state_ttl_short(default 60) — transaction-only, no target refreshINVITE/UPDATEwith session timersSession-Expires+ 30 s (theExpiresring timeout is deliberately not used)INVITE/UPDATEwithout session timersth_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_urlat anything reachable beyond that trust boundary.Backend compatibility
Any
cachedbbackend that honours theexpiresargument ofset()(and providesremove()) works — verified againstcachedb_redisandcachedb_local, and applies to memcached / cassandra / couchbase / dynamodb / sql.cachedb_mongodbsilently 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
cachedb_local://(single instance) and a password-protectedredis://:pass@host/dbcluster are also supported.Testing
Against
cachedb_redis(andcachedb_local, confirming backend-agnostic):_marker); a sequential request carrying only the key is matched, decoded, and the R-URI restored to the original endpoint.mongodb://guard refuses startup.Roadmap (not in this PR)
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).CACHEDB_CAP_EXPIREScapability so TTL-ignoring backends are rejected through the interface rather than by a URL-scheme match (touches the core plus every backend).Notes
th_store.c/th_store.h; docs + regenerated README included.th_state_urldeclares acachedb_urldependency so the backend module loads first.