From 7e807a36f1fbbb785feb22bc4ee31a989e1bce91 Mon Sep 17 00:00:00 2001 From: Yury Kirsanov Date: Fri, 17 Jul 2026 16:56:04 +1000 Subject: [PATCH] topology_hiding: keep the dialog-less state in a shared store 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 ":" 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. --- modules/topology_hiding/README | 222 +++++- .../doc/topology_hiding_admin.xml | 267 ++++++++ modules/topology_hiding/th_store.c | 452 +++++++++++++ modules/topology_hiding/th_store.h | 140 ++++ modules/topology_hiding/topo_hiding_logic.c | 636 ++++++++++++++++-- modules/topology_hiding/topology_hiding.c | 25 +- 6 files changed, 1686 insertions(+), 56 deletions(-) create mode 100644 modules/topology_hiding/th_store.c create mode 100644 modules/topology_hiding/th_store.h diff --git a/modules/topology_hiding/README b/modules/topology_hiding/README index 51afa8b6ff8..de785cfa40b 100644 --- a/modules/topology_hiding/README +++ b/modules/topology_hiding/README @@ -24,6 +24,9 @@ topology_hiding Module 1.3.9. th_contact_caller_username_var (string) 1.3.10. th_contact_callee_username_var (string) 1.3.11. th_callid_loop_protection (int) + 1.3.12. th_state_url (string) + 1.3.13. th_state_ttl (int) + 1.3.14. th_state_ttl_short (int) 1.4. Exported Functions @@ -63,11 +66,14 @@ topology_hiding Module 1.9. Set th_contact_caller_username_var parameter 1.10. Set th_contact_callee_username_var parameter 1.11. Set th_callid_loop_protection parameter - 1.12. topology_hiding usage - 1.13. Calling topology_hiding_match() function for topology + 1.12. Set th_state_url parameter + 1.13. Set th_state_ttl parameter + 1.14. Set th_state_ttl_short parameter + 1.15. topology_hiding usage + 1.16. Calling topology_hiding_match() function for topology hiding sequential requests - 1.14. topology_hiding_match_dialog() usage + 1.17. topology_hiding_match_dialog() usage Chapter 1. Admin Guide @@ -257,6 +263,210 @@ ding_username_var__") modparam("topology_hiding", "th_callid_loop_protection", 1) ... +1.3.12. th_state_url (string) + + The URL of a cachedb backend where the topology hiding state of + dialog-less calls is to be kept. + + When topology hiding runs without a dialog, the whole encoded + state normally travels inside the Contact URI parameter (see + th_contact_encode_param), which makes that URI long. Some user + agents cannot cope with it and truncate the parameter, after + which topology_hiding_match() can no longer decode it and the + sequential requests of those calls are lost. This is most + visible for non-INVITE dialogs, such as SUBSCRIBE, as those can + never be given a dialog to hold the state - the dialog module + only handles INVITE based dialogs. + + With this parameter set, the state is stored in the given + backend under a short key, and only that key travels in the + Contact URI, keeping it short regardless of how large the + hidden topology is. Off the wire the obfuscation and URI-safe + encoding a Contact-borne state carries serve no purpose, so the + stored copy holds neither: it is kept as plain, length-prefixed + text. + + A state is only ever kept on the server side when the message + it belongs to tells how long its dialog is going to live, since + a state which expires from under a dialog which is still up + cannot be matched anymore, and takes down everything that was + still to be routed through it - the BYE of a call, most + notably. A subscription is bounded by its expires, and the + messages which open no dialog only live as long as their + transaction, so those are stored. A call is not bounded by + anything, unless the session timers are in use, so the state of + a call which has none keeps travelling in its Contact, where it + cannot expire - exactly as it does without this parameter. Both + may well be in use at the same time, as this is decided for + each dialog as its Contact is encoded. + + Note that a call which is given a dialog never gets here at + all: the dialog module holds its state and bounds it for + exactly as long as the call lives, and its Contact carries just + the dialog id, so it is already both short and correct. + Engaging a dialog for the calls (see force_dialog) is therefore + a better answer for them than this parameter could ever be, + which is left to serve what a dialog cannot: everything that is + not INVITE based. + + The state must be readable by whichever node receives the + sequential requests, so when the traffic is distributed or + fails over between several OpenSIPS instances, the backend must + be shared between them (e.g. a common cachedb_redis instance or + cluster). For a single instance, a local backend such as + cachedb_local is enough - it is kept in shared memory, so all + the OpenSIPS workers do see it, but its contents are lost on + restart and are not visible to the other nodes. + + Security: the state kept in the backend is stored in the clear. + It is not encrypted, and - unlike the copy that travels inside + a Contact - it is not obfuscated either, so anyone able to read + the backend can see the topology it hides: the real Contact, + the route set and the receiving socket of each call. This is + intentional. The store is meant to be a trusted, internal + backend reachable only by the OpenSIPS nodes that share it: + deploy it on a private or DMZ network, bind it to the internal + interface, enable the backend's own authentication, and keep it + off any untrusted network. Do not point th_state_url at a + backend that is reachable beyond that trust boundary. + + The state is immutable, so re-encoding a Contact on a + sequential request only pushes its expiration further out. To + spare the backend a write on every such request, each node + keeps a small in-memory record of what it last stored and skips + the write while the state still has enough of its lifetime left + - a write that would actually shorten a state (a subscription + being torn down) is never skipped. This is transparent and + needs no configuration. + + A state is only ever removed from the backend when it expires + (see th_state_ttl), therefore the backend must implement the + expiration of the values it stores. All the cachedb backends + do, except for cachedb_mongodb, which silently ignores it - + using it here would pile up the states forever, so it is + rejected at startup. + + By default this parameter is not set, and the state travels in + the Contact URI. + + Example 1.12. Set th_state_url parameter +... +# a single instance - state kept in its own shared memory +loadmodule "cachedb_local.so" +modparam("topology_hiding", "th_state_url", "local://topohiding") + +# several instances sharing a Redis server +loadmodule "cachedb_redis.so" +modparam("topology_hiding", "th_state_url", "redis://127.0.0.1:6379/0") + +# a password protected Redis cluster - one node is enough, +# the rest of the cluster is discovered from it +modparam("topology_hiding", "th_state_url", + "redis://:secretpass@10.0.0.20:6379/0") + +# a Memcached server +loadmodule "cachedb_memcached.so" +modparam("topology_hiding", "th_state_url", + "memcached://127.0.0.1:11211/") +... + +1.3.13. th_state_ttl (int) + + For how long, in seconds, a topology hiding state is kept in + the backend configured through th_state_url. It should be at + least as long as the calls whose topology is hidden are + expected to last, as a call whose state has expired can no + longer have its sequential requests matched. + + Whenever a message which keeps its dialog alive is matched, the + state it was sent to has its expiration pushed further, for as + long as that message says the dialog is going to live. This + matters because the party which sent it only learns of a new + state if the reply it gets back carries a Contact of its own - + otherwise it goes on using the state it already knows, which + therefore has to outlive its original expiration. Only the + messages which do carry the lifetime of their dialog may + refresh it - a SUBSCRIBE or NOTIFY through its expires, an + INVITE or UPDATE through its Session-Expires - so that, say, an + in-dialog OPTIONS cannot cut the state of the call it runs into + down to th_state_ttl_short. + + A state is stored each time a Contact is encoded, and is + otherwise only removed when it expires, so the module tries not + to keep any state for longer than it may be needed: + * for SUBSCRIBE and NOTIFY, the state has to outlive the + subscription, so its duration is taken from the very + message being encoded (plus a small margin, to cover the + refresh). A NOTIFY reports how long the subscription + actually has left in its Subscription-State header (RFC + 6665 4.1.3), which is what the notifier granted rather than + what the subscriber asked for, so it is preferred. Failing + that, it is looked for as an “expires” parameter of the + Contact first and as the Expires header afterwards, the + parameter taking precedence over the header. This matters + because a notifier is free to grant a shorter subscription + than the one asked for, and its NOTIFYs are what bring the + state of a subscription back in line with what was really + granted. Note that a subscription may last much longer than + this parameter, in which case its state is kept for longer + as well. A message which ends a subscription (an expires of + “0”, or a NOTIFY reporting it as “terminated”) drops the + state it was sent to right away, instead of leaving it + behind for as long as the subscription was going to last - + the Contact it gets encoded with still takes + th_state_ttl_short, so that the final NOTIFY may be routed + back; + * for INVITE and UPDATE, when the session timers are in use, + the session gets refreshed every Session-Expires seconds + (RFC 4028) and each refresh encodes the Contact anew, so + the state only has to survive one interval - that value + (plus the margin) is used then. Should a refresh not come, + the session is torn down anyway, so this can never expire + the state of a live call. Note that the Expires header is + deliberately not used here: on an INVITE it limits the + validity of the invitation, not the duration of the call it + may lead to; + * OPTIONS, MESSAGE, INFO and PUBLISH neither open a dialog + nor refresh the target of one, so their Contact is only of + interest while their transaction is running - they get + th_state_ttl_short; + * everything else, and any of the above whose expiry could + not be told (no session timers, no Expires), may last for + any amount of time, so it gets this parameter. + + This parameter is only used when th_state_url is set. + + Default value is “3600” (one hour). + + Example 1.13. Set th_state_ttl parameter +... +# hide the topology of calls which may last up to 12 hours +modparam("topology_hiding", "th_state_ttl", 43200) +... + +1.3.14. th_state_ttl_short (int) + + For how long, in seconds, to keep the topology hiding state of + the messages which do not open a dialog nor refresh the target + of one - see th_state_ttl for the exact list. Their Contact is + only used while their transaction is alive, so keeping their + state for as long as a call's would needlessly fill up the + storage. + + It only needs to cover the lifetime of a transaction. Raise it + to the value of th_state_ttl if you have user agents which + wrongly latch onto the Contact of such a request and keep using + it afterwards. + + This parameter is only used when th_state_url is set. + + Default value is “60”. + + Example 1.14. Set th_state_ttl_short parameter +... +modparam("topology_hiding", "th_state_ttl_short", 120) +... + 1.4. Exported Functions 1.4.1. topology_hiding() @@ -322,7 +532,7 @@ modparam("topology_hiding", "th_callid_loop_protection", 1) is advertised on both legs. If the separator is being used, you can control the username put in contact per leg. - Example 1.12. topology_hiding usage + Example 1.15. topology_hiding usage ... if(!has_totag() && is_method("INVITE")) { topology_hiding(); @@ -355,7 +565,7 @@ if(!has_totag() && is_method("INVITE")) { } ... - Example 1.13. Calling topology_hiding_match() function for + Example 1.16. Calling topology_hiding_match() function for topology hiding sequential requests ... if (has_totag()) @@ -384,7 +594,7 @@ ogy hiding dialog\n"); This function can be used from REQUEST_ROUTE. - Example 1.14. topology_hiding_match_dialog() usage + Example 1.17. topology_hiding_match_dialog() usage ... if (has_totag()) { if (!topology_hiding_match() ) { diff --git a/modules/topology_hiding/doc/topology_hiding_admin.xml b/modules/topology_hiding/doc/topology_hiding_admin.xml index a713c789e1f..48f4d9e72fc 100644 --- a/modules/topology_hiding/doc/topology_hiding_admin.xml +++ b/modules/topology_hiding/doc/topology_hiding_admin.xml @@ -281,6 +281,273 @@ modparam("topology_hiding", "th_callid_loop_protection", 1) +
+ <varname>th_state_url</varname> (string) + + The URL of a cachedb backend where the + topology hiding state of dialog-less calls is to be kept. + + + When topology hiding runs without a dialog, the whole encoded + state normally travels inside the Contact URI parameter (see + ), which makes + that URI long. Some user agents cannot cope with it and truncate + the parameter, after which + can no longer decode + it and the sequential requests of those calls are lost. This is + most visible for non-INVITE dialogs, such as SUBSCRIBE, as those + can never be given a dialog to hold the state - the + dialog module only handles INVITE based + dialogs. + + + With this parameter set, the state is stored in the given backend + under a short key, and only that key travels in the Contact URI, + keeping it short regardless of how large the hidden topology is. + Off the wire the obfuscation and URI-safe encoding a Contact-borne + state carries serve no purpose, so the stored copy holds neither: + it is kept as plain, length-prefixed text. + + + A state is only ever kept on the server side when the message it + belongs to tells how long its dialog is going to live, since a + state which expires from under a dialog which is still up cannot + be matched anymore, and takes down everything that was still to be + routed through it - the BYE of a call, most notably. A + subscription is bounded by its expires, and the messages which + open no dialog only live as long as their transaction, so those + are stored. A call is not bounded by anything, unless the session + timers are in use, so the state of a call which has none keeps + travelling in its Contact, where it cannot expire - exactly as it + does without this parameter. Both may well be in use at the same + time, as this is decided for each dialog as its Contact is + encoded. + + + Note that a call which is given a dialog never gets here at all: + the dialog module holds its state and bounds + it for exactly as long as the call lives, and its Contact carries + just the dialog id, so it is already both short and correct. + Engaging a dialog for the calls (see + ) is therefore a better answer + for them than this parameter could ever be, which is left to serve + what a dialog cannot: everything that is not INVITE based. + + + The state must be readable by whichever node receives the + sequential requests, so when the traffic is distributed or fails + over between several &osips; instances, the backend must be shared + between them (e.g. a common cachedb_redis + instance or cluster). For a single instance, a local backend such + as cachedb_local is enough - it is kept in + shared memory, so all the &osips; workers do see it, but its + contents are lost on restart and are not visible to the other + nodes. + + + Security: the state kept in the + backend is stored in the clear. It is not encrypted, and - unlike + the copy that travels inside a Contact - it is not obfuscated + either, so anyone able to read the backend can see the topology it + hides: the real Contact, the route set and the receiving socket of + each call. This is intentional. The store is meant to be a trusted, + internal backend reachable only by the &osips; nodes that share it: + deploy it on a private or DMZ network, bind it to the internal + interface, enable the backend's own authentication, and keep it off + any untrusted network. Do not point + th_state_url at a backend that is reachable + beyond that trust boundary. + + + The state is immutable, so re-encoding a Contact on a sequential + request only pushes its expiration further out. To spare the + backend a write on every such request, each node keeps a small + in-memory record of what it last stored and skips the write while + the state still has enough of its lifetime left - a write that + would actually shorten a state (a subscription being torn down) + is never skipped. This is transparent and needs no configuration. + + + A state is only ever removed from the backend when it expires + (see ), therefore the backend + must implement the expiration of the values it stores. All the + cachedb backends do, except for + cachedb_mongodb, which silently ignores it - + using it here would pile up the states forever, so it is rejected + at startup. + + + + By default this parameter is not set, and the state travels + in the Contact URI. + + + + Set <varname>th_state_url</varname> parameter + +... +# a single instance - state kept in its own shared memory +loadmodule "cachedb_local.so" +modparam("topology_hiding", "th_state_url", "local://topohiding") + +# several instances sharing a Redis server +loadmodule "cachedb_redis.so" +modparam("topology_hiding", "th_state_url", "redis://127.0.0.1:6379/0") + +# a password protected Redis cluster - one node is enough, +# the rest of the cluster is discovered from it +modparam("topology_hiding", "th_state_url", + "redis://:secretpass@10.0.0.20:6379/0") + +# a Memcached server +loadmodule "cachedb_memcached.so" +modparam("topology_hiding", "th_state_url", + "memcached://127.0.0.1:11211/") +... + + +
+ +
+ <varname>th_state_ttl</varname> (int) + + For how long, in seconds, a topology hiding state is kept in the + backend configured through . + It should be at least as long as the calls whose topology is + hidden are expected to last, as a call whose state has expired can + no longer have its sequential requests matched. + + + Whenever a message which keeps its dialog alive is matched, the + state it was sent to has its expiration pushed further, for as + long as that message says the dialog is going to live. This + matters because the party which sent it only learns of a new state + if the reply it gets back carries a Contact of its own - otherwise + it goes on using the state it already knows, which therefore has + to outlive its original expiration. Only the messages which do + carry the lifetime of their dialog may refresh it - a + SUBSCRIBE or NOTIFY + through its expires, an INVITE or + UPDATE through its + Session-Expires - so that, say, an in-dialog + OPTIONS cannot cut the state of the call it runs into down to + . + + + A state is stored each time a Contact is encoded, and is otherwise + only removed when it expires, so the module tries not to keep any + state for longer than it may be needed: + + + + for SUBSCRIBE and + NOTIFY, the state has to outlive the + subscription, so its duration is taken from the very message + being encoded (plus a small margin, to cover the refresh). A + NOTIFY reports how long the subscription actually has left in + its Subscription-State header (RFC 6665 + 4.1.3), which is what the notifier granted rather than what + the subscriber asked for, so it is preferred. Failing that, it + is looked for as an expires parameter of the + Contact first and as the Expires header + afterwards, the parameter taking precedence over the header. + This matters because a notifier is free to grant a shorter + subscription than the one asked for, and its NOTIFYs are what + bring the state of a subscription back in line with what was + really granted. + Note that a subscription may last much longer than this + parameter, in which case its state is kept for longer as + well. A message which ends a subscription (an expires of + 0, or a NOTIFY reporting it as + terminated) drops the state it was sent to + right away, + instead of leaving it behind for as long as the subscription + was going to last - the Contact it gets encoded with still + takes , so that the + final NOTIFY may be routed back; + + + for INVITE and + UPDATE, when the session timers are in + use, the session gets refreshed every + Session-Expires seconds (RFC 4028) and + each refresh encodes the Contact anew, so the state only has + to survive one interval - that value (plus the margin) is used + then. Should a refresh not come, the session is torn down + anyway, so this can never expire the state of a live call. + Note that the Expires header is + deliberately not used here: on an INVITE + it limits the validity of the invitation, not the duration of + the call it may lead to; + + + OPTIONS, MESSAGE, + INFO and PUBLISH + neither open a dialog nor refresh the target of one, so their + Contact is only of interest while their transaction is + running - they get + ; + + + everything else, and any of the above whose expiry could not + be told (no session timers, no Expires), may last for any + amount of time, so it gets this parameter. + + + + This parameter is only used when + is set. + + + + Default value is 3600 (one hour). + + + + Set <varname>th_state_ttl</varname> parameter + +... +# hide the topology of calls which may last up to 12 hours +modparam("topology_hiding", "th_state_ttl", 43200) +... + + +
+ +
+ <varname>th_state_ttl_short</varname> (int) + + For how long, in seconds, to keep the topology hiding state of the + messages which do not open a dialog nor refresh the target of one + - see for the exact list. + Their Contact is only used while their transaction is alive, so + keeping their state for as long as a call's would needlessly fill + up the storage. + + + It only needs to cover the lifetime of a transaction. Raise it to + the value of if you have user + agents which wrongly latch onto the Contact of such a request and + keep using it afterwards. + + + This parameter is only used when + is set. + + + + Default value is 60. + + + + Set <varname>th_state_ttl_short</varname> parameter + +... +modparam("topology_hiding", "th_state_ttl_short", 120) +... + + +
diff --git a/modules/topology_hiding/th_store.c b/modules/topology_hiding/th_store.c new file mode 100644 index 00000000000..c637f52d006 --- /dev/null +++ b/modules/topology_hiding/th_store.c @@ -0,0 +1,452 @@ +/* + * Copyright (C) 2026 OpenSIPS Solutions + * + * This file is part of opensips, a free SIP server. + * + * opensips is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version + * + * opensips is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include + +#include "../../dprint.h" +#include "../../mem/mem.h" +#include "../../mem/shm_mem.h" +#include "../../locking.h" +#include "../../ut.h" +#include "../../md5utils.h" +#include "../../cachedb/cachedb.h" +#include "th_store.h" + +str th_state_url = {NULL, 0}; +int th_state_ttl = 3600; +int th_state_ttl_short = 60; + +static enum th_store_type th_store_be = TH_STORE_NONE; + +static cachedb_funcs th_cdbf; +static cachedb_con *th_cdbc; + +/* the stored keys are prefixed, so that the storage may be shared with + * other users without clashing over the key names */ +#define TH_KEY_PREFIX "th:" +#define TH_KEY_PREFIX_LEN (sizeof(TH_KEY_PREFIX)-1) + +int th_store_enabled(void) +{ + return th_store_be != TH_STORE_NONE; +} + + +/* + * Write-coalescing cache (shared memory, one per node). + * + * The stored state is immutable, so re-storing it on every sequential + * request of a dialog only ever pushes its expiration further out. This + * cache remembers, per key, the expiry this node last wrote, and lets + * th_store_put()/th_store_refresh() skip the backend write while the state + * still has plenty of life left - turning a busy dialog's stream of + * identical writes into an occasional TTL bump. With anycast/ECMP a + * dialog's requests reach the same node, whose workers share this cache, + * so the repeats are seen and collapsed. + * + * Skipping only ever drops a *redundant* write: a request that asks for a + * shorter life than is stored (a teardown lowering the TTL) still writes, + * and a stale or evicted entry just causes one extra write. The only + * residual effect is that, across a node change, a state might expire + * slightly early - which the user agent recovers from by re-establishing. + * It is a plain best-effort hint, so a single small lock guards it. + */ +#define TH_WC_BITS 12 +#define TH_WC_SIZE (1 << TH_WC_BITS) +#define TH_WC_MASK (TH_WC_SIZE - 1) + +struct th_wc_entry { + char key[TH_KEY_LEN]; + int used; + time_t deadline; /* absolute expiry this node last stored */ +}; + +static struct th_wc_entry *th_wc; /* direct-mapped, in shm */ +static gen_lock_t *th_wc_lock; + +static unsigned int th_wc_slot(const char *s) +{ + unsigned int h = 2166136261u; /* FNV-1a over the wire key */ + int i; + + for (i = 0; i < TH_KEY_LEN; i++) + h = (h ^ (unsigned char)s[i]) * 16777619u; + + return h & TH_WC_MASK; +} + +/* + * Whether the state under @key still has to be written to last @ttl more + * seconds. Returns 1 to write (and records the new expiry), 0 to skip + * because a write this node already made covers it. + */ +static int th_wc_need_write(str *key, int ttl) +{ + struct th_wc_entry *e; + time_t now; + int write; + + if (!th_wc || key->len != TH_KEY_LEN) + return 1; + + now = time(NULL); + e = &th_wc[th_wc_slot(key->s)]; + + lock_get(th_wc_lock); + if (e->used && memcmp(e->key, key->s, TH_KEY_LEN) == 0 && + now + ttl >= e->deadline && /* not asking for a shorter life */ + e->deadline - now >= ttl / 2) { /* still at least half of it left */ + write = 0; + } else { + memcpy(e->key, key->s, TH_KEY_LEN); + e->used = 1; + e->deadline = now + ttl; + write = 1; + } + lock_release(th_wc_lock); + + return write; +} + +static void th_wc_forget(str *key) +{ + struct th_wc_entry *e; + + if (!th_wc || key->len != TH_KEY_LEN) + return; + + e = &th_wc[th_wc_slot(key->s)]; + lock_get(th_wc_lock); + if (e->used && memcmp(e->key, key->s, TH_KEY_LEN) == 0) + e->used = 0; + lock_release(th_wc_lock); +} + + +/* + * The states are only ever removed by the expiration of the stored value, + * so a backend which does not implement it would pile them up forever. + * The cachedb interface has no way of telling whether a backend honours + * the expire argument of its set(), hence the check on the URL scheme. + */ +static int th_store_check_scheme(void) +{ + str scheme; + char *p; + + p = memchr(th_state_url.s, ':', th_state_url.len); + if (!p) { + LM_ERR("cannot extract the backend out of th_state_url %s\n", + db_url_escape(&th_state_url)); + return -1; + } + + scheme.s = th_state_url.s; + scheme.len = p - th_state_url.s; + + if (str_casematch_nt(&scheme, "mongodb")) { + LM_ERR("the mongodb backend ignores the expiration of the values " + "it stores, so the topology hiding states would never be " + "removed from it - use a backend which expires its values, " + "such as redis, memcached or local\n"); + return -1; + } + + return 0; +} + + +int th_store_init(void) +{ + if (!th_state_url.s) + return 0; + + th_state_url.len = strlen(th_state_url.s); + + if (th_store_check_scheme() < 0) + return -1; + + if (cachedb_bind_mod(&th_state_url, &th_cdbf) < 0) { + LM_ERR("cannot bind functions for th_state_url %s\n", + db_url_escape(&th_state_url)); + return -1; + } + + if (!CACHEDB_CAPABILITY(&th_cdbf, + CACHEDB_CAP_GET|CACHEDB_CAP_SET|CACHEDB_CAP_REMOVE)) { + LM_ERR("the cachedb backend of th_state_url does not provide " + "the needed get/set/remove support\n"); + return -1; + } + + if (th_state_ttl <= 0) { + LM_ERR("th_state_ttl must be a positive value\n"); + return -1; + } + if (th_state_ttl_short <= 0) { + LM_ERR("th_state_ttl_short must be a positive value\n"); + return -1; + } + + th_store_be = TH_STORE_CACHEDB; + + /* Best-effort write-coalescing cache. If it cannot be set up, leave it + * off (th_wc == NULL) and simply write on every request - a slower but + * equally correct fallback, so this never fails mod_init. */ + th_wc_lock = lock_alloc(); + if (th_wc_lock && lock_init(th_wc_lock)) { + th_wc = shm_malloc(TH_WC_SIZE * sizeof *th_wc); + if (th_wc) { + memset(th_wc, 0, TH_WC_SIZE * sizeof *th_wc); + } else { + lock_destroy(th_wc_lock); + lock_dealloc(th_wc_lock); + th_wc_lock = NULL; + LM_WARN("no shm for the write-coalescing cache - will store on " + "every request\n"); + } + } else { + if (th_wc_lock) + lock_dealloc(th_wc_lock); + th_wc_lock = NULL; + LM_WARN("cannot init the write-coalescing lock - will store on " + "every request\n"); + } + + LM_INFO("topology hiding state kept in the shared store, " + "ttl %d s (%d s for the dialog-less methods)\n", + th_state_ttl, th_state_ttl_short); + + return 0; +} + + +int th_store_child_init(void) +{ + if (!th_store_enabled()) + return 0; + + th_cdbc = th_cdbf.init(&th_state_url); + if (!th_cdbc) { + LM_ERR("cannot connect to th_state_url %s\n", + db_url_escape(&th_state_url)); + return -1; + } + + return 0; +} + + +void th_store_destroy(void) +{ + if (th_cdbc) { + th_cdbf.destroy(th_cdbc); + th_cdbc = NULL; + } + if (th_wc) { + shm_free(th_wc); + th_wc = NULL; + } + if (th_wc_lock) { + lock_destroy(th_wc_lock); + lock_dealloc(th_wc_lock); + th_wc_lock = NULL; + } +} + + +/* + * Derive the wire key of a state from the given seeds, straight into + * @out (which must hold TH_KEY_LEN bytes). + * + * The key is deterministic on purpose: the seeds identify the dialog leg + * whose state this is (its stable identifiers - the call and the tag of + * the party the state belongs to), so every refresh of that same leg + * derives the very same key. Its stored value is then simply overwritten + * and its expiration pushed further, instead of a fresh random key being + * piled up next to the previous one on each refresh - those would then + * linger in the store until they expired, with no dialog to ever clean + * them up (this mode exists precisely because there is no dialog). + * + * One of the seeds is expected to be a secret (the contact-encoding + * password), which is what keeps the key impossible to guess from one + * dialog to another: without it, knowing a dialog's call-id and tags - + * which travel in the clear - would hand out its hidden topology. + */ +void th_store_make_key(str seeds[], int n, char *out) +{ + char md5[MD5_LEN]; + + MD5StringArray(md5, seeds, n); + /* MD5StringArray emits MD5_LEN(32) hex chars; TH_KEY_LEN of them + * make for a 64-bit key, as wide as the former random one */ + memcpy(out, md5, TH_KEY_LEN); +} + + +/* build the full storage key ("th:" + wire key) into @buf */ +static inline void th_store_key(str *key, char *buf, str *out) +{ + memcpy(buf, TH_KEY_PREFIX, TH_KEY_PREFIX_LEN); + memcpy(buf + TH_KEY_PREFIX_LEN, key->s, key->len); + out->s = buf; + out->len = TH_KEY_PREFIX_LEN + key->len; +} + + +int th_store_put(str *blob, str *key, int ttl) +{ + char buf[TH_KEY_PREFIX_LEN + TH_KEY_LEN]; + str full_key; + + if (!th_store_enabled()) { + LM_BUG("no topology hiding storage configured\n"); + return -1; + } + if (!th_cdbc) { + LM_ERR("not connected to the topology hiding storage\n"); + return -1; + } + if (ttl <= 0) { + LM_BUG("bad ttl %d for the topology hiding state\n", ttl); + return -1; + } + + if (!th_wc_need_write(key, ttl)) { + LM_DBG("topology hiding state under the key is already stored with " + "enough TTL, skipping the write\n"); + return 0; + } + + th_store_key(key, buf, &full_key); + + if (th_cdbf.set(th_cdbc, &full_key, blob, ttl) < 0) { + LM_ERR("failed to store the topology hiding state under <%.*s>\n", + full_key.len, full_key.s); + return -1; + } + + LM_DBG("stored %d bytes of topology hiding state under <%.*s>, " + "expiring in %d s\n", blob->len, full_key.len, full_key.s, ttl); + return 0; +} + + +int th_store_get(str *key, str *blob) +{ + char buf[TH_KEY_PREFIX_LEN + TH_KEY_LEN]; + str full_key; + + if (!th_store_enabled()) { + LM_BUG("no topology hiding storage configured\n"); + return -1; + } + if (!th_cdbc) { + LM_ERR("not connected to the topology hiding storage\n"); + return -1; + } + + if (key->len != TH_KEY_LEN) { + LM_DBG("bad topology hiding key length %d, expected %d - the " + "user agent may have truncated it\n", key->len, TH_KEY_LEN); + return -1; + } + + th_store_key(key, buf, &full_key); + + blob->s = NULL; + blob->len = 0; + + if (th_cdbf.get(th_cdbc, &full_key, blob) < 0) { + LM_ERR("failed to fetch the topology hiding state of <%.*s>\n", + full_key.len, full_key.s); + return -1; + } + if (!blob->s || !blob->len) { + LM_WARN("no topology hiding state found for <%.*s> - it may have " + "expired, check the th_state_ttl* parameters against the " + "lifetime of the hidden calls\n", full_key.len, full_key.s); + if (blob->s) { + pkg_free(blob->s); + blob->s = NULL; + } + return -1; + } + + LM_DBG("fetched %d bytes of topology hiding state for <%.*s>\n", + blob->len, full_key.len, full_key.s); + return 0; +} + + +void th_store_refresh(str *key, str *blob, int ttl) +{ + char buf[TH_KEY_PREFIX_LEN + TH_KEY_LEN]; + str full_key; + + if (!th_store_enabled() || !th_cdbc) + return; + if (key->len != TH_KEY_LEN || !blob->s || !blob->len || ttl <= 0) + return; + + if (!th_wc_need_write(key, ttl)) { + LM_DBG("topology hiding state already refreshed with enough TTL, " + "skipping\n"); + return; + } + + th_store_key(key, buf, &full_key); + + /* there is no way of just pushing the expiration of a value further + * through the cachedb interface, so store it again as it is */ + if (th_cdbf.set(th_cdbc, &full_key, blob, ttl) < 0) + LM_WARN("failed to refresh the topology hiding state of <%.*s>, " + "it may expire while still in use\n", + full_key.len, full_key.s); + else + LM_DBG("refreshed the topology hiding state of <%.*s> for " + "another %d s\n", full_key.len, full_key.s, ttl); +} + + +void th_store_del(str *key) +{ + char buf[TH_KEY_PREFIX_LEN + TH_KEY_LEN]; + str full_key; + + if (!th_store_enabled() || !th_cdbc) + return; + if (key->len != TH_KEY_LEN) + return; + + /* forget any cached expiry, so a later re-create of this key writes */ + th_wc_forget(key); + + th_store_key(key, buf, &full_key); + + /* the state expires on its own anyway, so a failure here is not + * worth failing the request over */ + if (th_cdbf.remove(th_cdbc, &full_key) < 0) + LM_WARN("failed to drop the topology hiding state of <%.*s>, " + "leaving it to expire\n", full_key.len, full_key.s); + else + LM_DBG("dropped the topology hiding state of <%.*s>\n", + full_key.len, full_key.s); +} diff --git a/modules/topology_hiding/th_store.h b/modules/topology_hiding/th_store.h new file mode 100644 index 00000000000..a5aa153ed70 --- /dev/null +++ b/modules/topology_hiding/th_store.h @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2026 OpenSIPS Solutions + * + * This file is part of opensips, a free SIP server. + * + * opensips is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version + * + * opensips is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef TH_STORE_H +#define TH_STORE_H + +#include "../../str.h" + +/* + * Server-side storage for the topology hiding state of dialog-less calls. + * + * In the default (stateless) mode, the whole encoded state travels in the + * Contact URI parameter, which makes that URI long. Some user agents cannot + * cope with it and truncate the parameter, so the state can no longer be + * decoded when they send a sequential request. + * + * In this mode, the state is kept on the server side under a short key, and + * only that key travels in the Contact URI. Off the wire the obfuscation and + * URI-safe encoding serve no purpose, so the stored copy carries neither: its + * fields are simply length-prefixed in a printable ":" form, + * which keeps it inspectable in the store (only the state that still travels + * inline in a Contact is obfuscated and encoded). The state must be readable + * by whichever node receives the sequential request, so the storage is + * expected to be shared between all the nodes handling the same traffic. + */ + +/* length of the key travelling on the wire (hex chars => 64 bits) */ +#define TH_KEY_LEN 16 + +/* + * A key travels in the Contact URI parameter prefixed by this marker, so + * that it can never be taken for an encoded state travelling in that same + * parameter instead - both are in use at once, as it is decided per dialog + * which of the two it gets (see th_state_storable()). + * + * The marker has to satisfy two things, and '_' is picked because it + * provably does: + * + * 1) No encoded state may ever start with it, or a state would be looked + * up in the storage as if it were a key. The state is emitted by + * word64encode() or word32encode(), whose alphabets are word64digits + * "A-Za-z0-9+." and base32digits "A-Z2-7" (see ut.c), and both pad + * with '-'. So an encoded state is always within [A-Za-z0-9+.-], which + * '_' is not part of. Note this holds whatever its length: telling the + * two apart by length instead would rest on nothing more than the + * minimum size of what gets packed, which no rule keeps true. + * + * Mind that word64 is not the usual base64: it ends in "+." where + * base64 ends in "+/", so '.' - the tempting choice - is one of the + * characters a state can be made of, and would not do here. + * + * 2) It must be legal, unescaped, in the parameter of a SIP URI. Per the + * grammar of RFC 3261 25.1: + * + * pvalue = 1*paramchar + * paramchar = param-unreserved / unreserved / escaped + * unreserved = alphanum / mark + * mark = "-" / "_" / "." / "!" / "~" / "*" / "'" / "(" / ")" + * + * which puts '_' in mark, hence in unreserved, hence in paramchar. + * + * The marker only ever has to be unambiguous inside the value of our own + * parameter: whatever else the URI carries is matched by parameter name + * (see topology_hiding_match()), so no other parameter can shadow it. + */ +#define TH_KEY_MARKER '_' + +/* what a key takes up in the Contact, marker included */ +#define TH_KEY_WIRE_LEN (1 + TH_KEY_LEN) + +/* storage backends */ +enum th_store_type { + TH_STORE_NONE = 0, /* stateless - state travels in the Contact */ + TH_STORE_CACHEDB, /* shared key-value store */ +}; + +extern str th_state_url; +extern int th_state_ttl; +extern int th_state_ttl_short; + +/* added on top of a subscription's Expires, to cover the refresh */ +#define TH_STATE_TTL_MARGIN 30 + +/* is a server-side storage configured? */ +int th_store_enabled(void); + +/* bind the storage backend - to be called from mod_init */ +int th_store_init(void); +/* connect to the storage backend - to be called from child_init */ +int th_store_child_init(void); +void th_store_destroy(void); + +/* + * Derive the wire key of a state from @seeds into @out, which must hold + * TH_KEY_LEN bytes. The key is deterministic, so that every refresh of the + * same dialog leg lands on it again - see the definition for the rationale. + */ +void th_store_make_key(str seeds[], int n, char *out); + +/* + * Store @blob for @ttl seconds under @key. The caller provides the key, + * already filled in (TH_KEY_LEN bytes, e.g. via th_store_make_key), so + * that refreshing a leg overwrites its state in place rather than piling + * up a new one. + */ +int th_store_put(str *blob, str *key, int ttl); + +/* + * Fetch the blob stored under @key. On success, @blob->s is allocated in + * pkg memory and must be freed by the caller. + */ +int th_store_get(str *key, str *blob); + +/* Drop the blob stored under @key, once it is known to be of no use. */ +void th_store_del(str *key); + +/* + * Keep the blob already stored under @key around for another @ttl + * seconds. @blob must be the value it currently holds. + */ +void th_store_refresh(str *key, str *blob, int ttl); + +#endif /* TH_STORE_H */ diff --git a/modules/topology_hiding/topo_hiding_logic.c b/modules/topology_hiding/topo_hiding_logic.c index 5b9d8c58db6..f9f86ccf5e6 100644 --- a/modules/topology_hiding/topo_hiding_logic.c +++ b/modules/topology_hiding/topo_hiding_logic.c @@ -25,7 +25,10 @@ */ #include "../../ut.h" +#include "../../parser/parse_expires.h" +#include "../../parser/parse_sst.h" #include "topo_hiding_logic.h" +#include "th_store.h" extern int force_dialog; extern struct tm_binds tm_api; @@ -63,8 +66,11 @@ static void th_down_onreply(struct cell* t, int type,struct tmcb_params *param); static void th_up_onreply(struct cell* t, int type, struct tmcb_params *param); static void th_no_dlg_onreply(struct cell* t, int type, struct tmcb_params *param); static void th_no_dlg_user_onreply(struct cell* t, int type, struct tmcb_params *param); -static int topo_no_dlg_encode_contact(struct sip_msg *req,int flags,str *routes,str *ct_user); +static int topo_no_dlg_encode_contact(struct sip_msg *req,int flags,str *routes,str *ct_user,int store_state); static int topo_no_dlg_seq_handling(struct sip_msg *msg,str *info); +static int th_state_msg_ttl(struct sip_msg *msg); +static int th_state_storable(struct sip_msg *msg); +static str *th_msg_key_tag(struct sip_msg *msg); static int dlg_th_onreply(struct dlg_cell *dlg, struct sip_msg *rpl, struct sip_msg *req, int init_req, int dir, int dst_leg); @@ -895,7 +901,8 @@ static void _th_no_dlg_onreply(struct cell* t, int type, struct tmcb_params *par if ( !(rpl->REPLY_STATUS>=300 && rpl->REPLY_STATUS<400) ) { if (topo_no_dlg_encode_contact(rpl,flags, - (p?&p->routes:NULL),(p?&p->username:NULL)) < 0) { + (p?&p->routes:NULL),(p?&p->username:NULL), + -1/*decide from the reply*/) < 0) { LM_ERR("Failed to encode contact header \n"); return; } @@ -986,7 +993,8 @@ static int topo_hiding_no_dlg(struct sip_msg *req, return -1; } - if (topo_no_dlg_encode_contact(req,extra_flags,NULL, ¶ms->ct_caller_user) < 0) { + if (topo_no_dlg_encode_contact(req,extra_flags,NULL, ¶ms->ct_caller_user, + -1/*decide from the request*/) < 0) { LM_ERR("Failed to encode contact header \n"); return -1; } @@ -1758,14 +1766,16 @@ int topo_callid_post_raw(str *data, struct sip_msg* foo) /* We encode the RR headers, the actual Contact and the socket str for this leg */ /* Via headers will be restored using the TM module, no need to save anything for them */ -static char* build_encoded_contact_suffix(struct sip_msg* msg, str *routes, int *suffix_len, int flags) +static char* build_encoded_contact_suffix(struct sip_msg* msg, str *routes, int *suffix_len, int flags, int store_state) { short rr_len,ct_len,addr_len,flags_len; - char *suffix_plain,*suffix_enc,*p,*s; + char *suffix_plain = NULL,*suffix_enc = NULL,*p,*s; str rr_set = {NULL, 0}; str contact; str flags_str; - int i,total_len,enc_len; + int i,total_len,enc_len,wire_len; + char *blob_buf = NULL; + str blob, key; struct sip_uri ctu; struct th_ct_params* el; param_t *it; @@ -1817,14 +1827,20 @@ static char* build_encoded_contact_suffix(struct sip_msg* msg, str *routes, int if (topo_ct_short_len(msg->rcv.bind_address->sock_str.len, &addr_len, "bind address") < 0) goto error; - local_len += rr_len + ct_len + flags_len + addr_len; + local_len += rr_len + ct_len + flags_len + addr_len; enc_len = th_ct_enc_scheme == ENC_BASE64 ? calc_word64_encode_len(local_len) : calc_word32_encode_len(local_len); - total_len = enc_len + - 1 /* ; */ + - th_contact_encode_param.len + - 1 /* = */ + - 1 /* > */; + /* a sequential request keeps whatever its dialog is already using, + * anything else is decided upon here */ + if (store_state < 0) + store_state = th_state_storable(msg); + /* with a server-side storage, only the key travels in the Contact */ + wire_len = store_state ? TH_KEY_WIRE_LEN : enc_len; + total_len = wire_len + + 1 /* ; */ + + th_contact_encode_param.len + + 1 /* = */ + + 1 /* > */; if (th_param_list) { if ( parse_contact(msg->contact)<0 || @@ -1894,20 +1910,103 @@ static char* build_encoded_contact_suffix(struct sip_msg* msg, str *routes, int p+= sizeof(short); memcpy(p,msg->rcv.bind_address->sock_str.s,msg->rcv.bind_address->sock_str.len); p+= msg->rcv.bind_address->sock_str.len; - for (i=0;i<(int)(p-suffix_plain);i++) - suffix_plain[i] ^= topo_hiding_ct_encode_pw.s[i%topo_hiding_ct_encode_pw.len]; s = suffix_enc; *s++ = ';'; memcpy(s,th_contact_encode_param.s,th_contact_encode_param.len); s+= th_contact_encode_param.len; *s++ = '='; - if (th_ct_enc_scheme == ENC_BASE64) - word64encode((unsigned char*)s,(unsigned char *)suffix_plain,p-suffix_plain); - else - word32encode((unsigned char*)s,(unsigned char *)suffix_plain,p-suffix_plain); - s = s+enc_len; - + if (store_state) { + /* the state is kept on the server side under a key. It never + * travels on the wire, so it needs neither the XOR obfuscation + * nor the URI-safe word encoding a Contact-borne state does - + * keep it printable and inspectable in the store, framed as + * length-prefixed ":" fields in the same + * order (route set, contact, flags, receiving socket). */ + char *b, *ls; + int ll; + + blob_buf = pkg_malloc(local_len + 4*6); + if (!blob_buf) { + LM_ERR("no more pkg\n"); + goto error; + } + b = blob_buf; + #define __put_ascii_field(_f) \ + do { \ + ls = int2str((unsigned long)(_f).len, &ll); \ + memcpy(b, ls, ll); b += ll; \ + *b++ = ':'; \ + if ((_f).len) { memcpy(b, (_f).s, (_f).len); b += (_f).len; } \ + } while (0) + __put_ascii_field(rr_set); + __put_ascii_field(contact); + __put_ascii_field(flags_str); + __put_ascii_field(msg->rcv.bind_address->sock_str); + #undef __put_ascii_field + blob.s = blob_buf; + blob.len = b - blob_buf; + + /* mark the key, so that it can never be taken for an encoded + * state travelling in the Contact instead */ + *s = TH_KEY_MARKER; + + /* the key is derived straight into the Contact buffer */ + key.s = s + 1; + key.len = TH_KEY_LEN; + + /* + * Key the state on the dialog leg it belongs to, so that a + * refresh of that leg derives the very same key and just + * overwrites its state, instead of leaving a new one behind on + * every request (no dialog holds these, so nothing else would + * ever reclaim them). The leg is the party whose Contact we are + * hiding: its call and its own tag - the From tag of a request + * it sends, the To tag of a reply it sends - both stable for as + * long as the dialog lives. The encoding password is mixed in to + * keep the key from being guessable out of the clear-text call-id + * and tags. + */ + { + str seeds[3]; + str *own_tag = th_msg_key_tag(msg); + + if (!own_tag) { + /* th_state_storable() only lets a message reach here + * once it can be keyed (has both a call-id and its + * leg's tag); the sole caller which forces the state + * to be stored - a sequential request being re-encoded + * - is in-dialog and thus always has both */ + LM_BUG("no call-id or tag to key the topology hiding " + "state on\n"); + goto error; + } + + seeds[0] = topo_hiding_ct_encode_pw; + seeds[1] = msg->callid->body; + seeds[2] = *own_tag; + th_store_make_key(seeds, 3, key.s); + } + + if (th_store_put(&blob, &key, th_state_msg_ttl(msg)) < 0) { + LM_ERR("failed to store the topology hiding state\n"); + goto error; + } + pkg_free(blob_buf); + blob_buf = NULL; + } else { + /* the state travels inline in the Contact - obfuscate it and + * then URI-safe encode it onto the wire */ + for (i=0;i<(int)(p-suffix_plain);i++) + suffix_plain[i] ^= topo_hiding_ct_encode_pw.s[i%topo_hiding_ct_encode_pw.len]; + if (th_ct_enc_scheme == ENC_BASE64) + word64encode((unsigned char*)s,(unsigned char *)suffix_plain,p-suffix_plain); + else + word32encode((unsigned char*)s,(unsigned char *)suffix_plain,p-suffix_plain); + } + s = s+wire_len; + + if (th_param_list) { for (el=th_param_list;el;el=el->next) { /* we just iterate over the unknown params */ @@ -1941,10 +2040,364 @@ static char* build_encoded_contact_suffix(struct sip_msg* msg, str *routes, int error: if (rr_set.s && free_rr_set) pkg_free(rr_set.s); + if (blob_buf) + pkg_free(blob_buf); + if (suffix_plain) + pkg_free(suffix_plain); + if (suffix_enc) + pkg_free(suffix_enc); return NULL; } -static int topo_no_dlg_encode_contact(struct sip_msg *msg,int flags, str *routes, str *ct_user) +/* + * How long the state of @msg has to outlive it. + * + * The states are only removed when they expire, and a new one is stored + * for each request whose Contact gets encoded, so keeping them for longer + * than needed just piles them up in the storage. + */ +/* the method @msg is about, be it a request or the reply to one */ +static int th_msg_method(struct sip_msg *msg) +{ + if (msg->first_line.type == SIP_REQUEST) + return msg->first_line.u.request.method_value; + + return (msg->cseq && get_cseq(msg)) ? + get_cseq(msg)->method_id : METHOD_UNDEF; +} + + +/* + * The expires @msg is subject to, or -1 if it carries none. It may be + * given either as an "expires" Contact parameter or as the Expires + * header, the parameter taking precedence (RFC 3261 10.2.1.1). + */ +static int th_msg_expires(struct sip_msg *msg) +{ + exp_body_t *exp; + contact_t *ct; + unsigned int ct_exp; + + if (msg->contact || + (parse_headers(msg, HDR_CONTACT_F, 0) == 0 && msg->contact)) { + if (msg->contact->parsed || parse_contact(msg->contact) == 0) { + ct = ((contact_body_t *)msg->contact->parsed)->contacts; + if (ct && ct->expires && ct->expires->body.len && + str2int(&ct->expires->body, &ct_exp) == 0) { + LM_DBG("expires in %us, per the Contact\n", ct_exp); + return (int)ct_exp; + } + } + } + + if (parse_headers(msg, HDR_EXPIRES_F, 0) == 0 && msg->expires) { + if (msg->expires->parsed || parse_expires(msg->expires) == 0) { + exp = (exp_body_t *)msg->expires->parsed; + if (exp && exp->valid) { + LM_DBG("expires in %ds, per the Expires header\n", + exp->val); + return exp->val; + } + } + LM_DBG("unparsable Expires header\n"); + } + + return -1; +} + + +#define SUB_STATE_HDR "Subscription-State" +#define SUB_STATE_HDR_LEN (sizeof(SUB_STATE_HDR)-1) + +/* + * What a NOTIFY has to say about the lifetime of its subscription. + * + * A NOTIFY carries it in its Subscription-State header rather than in an + * Expires one (RFC 6665 4.1.3), and the value is the one the notifier + * actually granted, which may well be shorter than what the subscriber + * had asked for. + * + * Returns the seconds the subscription still has, 0 if it is over, or -1 + * if the message says nothing about it. + */ +static int th_notify_sub_expires(struct sip_msg *msg) +{ + struct hdr_field *hf; + param_hooks_t hooks; + param_t *params = NULL, *p; + str body, state; + unsigned int val; + int ret = -1; + char *sep; + + if (parse_headers(msg, HDR_EOH_F, 0) < 0) + return -1; + + /* not one of the headers the parser knows about */ + for (hf = msg->headers; hf; hf = hf->next) + if (hf->type == HDR_OTHER_T && + hf->name.len == SUB_STATE_HDR_LEN && + strncasecmp(hf->name.s, SUB_STATE_HDR, SUB_STATE_HDR_LEN) == 0) + break; + if (!hf) + return -1; + + body = hf->body; + trim(&body); + if (!body.len) + return -1; + + sep = memchr(body.s, ';', body.len); + + state = body; + if (sep) + state.len = sep - body.s; + trim(&state); + + /* a subscription which is over is not going to come back */ + if (state.len == 10 && strncasecmp(state.s, "terminated", 10) == 0) + return 0; + + if (!sep) + return -1; + + /* the parameters start past the separator */ + body.len -= sep - body.s + 1; + body.s = sep + 1; + if (body.len <= 0) + return -1; + + if (parse_params(&body, CLASS_ANY, &hooks, ¶ms) < 0) { + LM_DBG("unparsable " SUB_STATE_HDR " parameters\n"); + return -1; + } + + for (p = params; p; p = p->next) + if (p->name.len == 7 && + strncasecmp(p->name.s, "expires", 7) == 0) { + if (str2int(&p->body, &val) == 0) { + LM_DBG("subscription has %us left, per " SUB_STATE_HDR "\n", + val); + ret = (int)val; + } + break; + } + + free_params(params); + return ret; +} + + +/* + * The lifetime the subscription @msg belongs to still has, or -1 if it + * carries nothing about it. A NOTIFY reports the granted value, so it is + * preferred over anything the subscriber may have merely asked for. + */ +static int th_sub_expires(struct sip_msg *msg, int method) +{ + int expires = -1; + + if (method == METHOD_NOTIFY) + expires = th_notify_sub_expires(msg); + + if (expires < 0) + expires = th_msg_expires(msg); + + return expires; +} + + +/* does @msg tear down the subscription it belongs to? */ +static int th_msg_ends_subscription(struct sip_msg *msg) +{ + int method = th_msg_method(msg); + + if (method != METHOD_SUBSCRIBE && method != METHOD_NOTIFY) + return 0; + + return th_sub_expires(msg, method) == 0; +} + + +/* + * Does @msg push the lifetime of the dialog it belongs to further, and + * by how much? Returns 0 if it does not say anything about it. + * + * Only the messages which do carry the lifetime of their dialog may + * answer here: refreshing a state off, say, an in-dialog OPTIONS would + * cut the state of the call it runs into down to a minute. + */ +static int th_msg_refreshes_state(struct sip_msg *msg) +{ + struct session_expires se; + int expires, method; + + method = th_msg_method(msg); + + switch (method) { + case METHOD_SUBSCRIBE: + case METHOD_NOTIFY: + expires = th_sub_expires(msg, method); + /* an ending subscription drops its state instead */ + if (expires <= 0) + return 0; + return expires + TH_STATE_TTL_MARGIN; + + case METHOD_INVITE: + case METHOD_UPDATE: + if (parse_session_expires(msg, &se) == parse_sst_success && + se.interval > 0) + return (int)se.interval + TH_STATE_TTL_MARGIN; + return 0; + + default: + return 0; + } +} + + +/* + * May the state of @msg be kept on the server side? + * + * Only if @msg tells us how long its dialog is going to live: a state + * which expires from under a dialog which is still up cannot be matched + * anymore, and takes down everything that was still to be routed through + * it - the BYE of a call, most notably. Whatever cannot be bounded keeps + * travelling in the Contact instead, where it never expires. + * + * Note that a call which does get a dialog never reaches here: the + * dialog holds its state and bounds it for exactly as long as it lives, + * which is a better answer than this mode could ever give it. + */ +/* + * The stable identifier of the dialog leg whose Contact @msg carries - + * the tag the party owns: its From tag when it sends a request, its To + * tag when it sends a reply. Both last as long as the dialog, so every + * message of that leg derives the same storage key off it. + * + * Returns NULL when the state cannot be keyed - either the call-id or the + * tag is missing. The latter is most notably the reply of an + * out-of-dialog transaction (a 200 OK to an OPTIONS keepalive, say), + * which carries a Contact but never a To tag. Such a state is not stored: + * it travels in the Contact instead (there is no sequential traffic to + * match it against anyway). + */ +static str *th_msg_key_tag(struct sip_msg *msg) +{ + str *tag; + + /* the key is derived from the call-id and the leg's tag, so both + * have to be there - the call-id of any message, and the tag its + * leg owns */ + if ((!msg->callid && parse_headers(msg, HDR_CALLID_F, 0) < 0) || + !msg->callid) + return NULL; + + if (msg->first_line.type == SIP_REQUEST) { + if (parse_from_header(msg) < 0 || !msg->from || !get_from(msg)) + return NULL; + tag = &get_from(msg)->tag_value; + } else { + if (parse_to_header(msg) < 0 || !msg->to || !get_to(msg)) + return NULL; + tag = &get_to(msg)->tag_value; + } + + return tag->len ? tag : NULL; +} + + +static int th_state_storable(struct sip_msg *msg) +{ + struct session_expires se; + + if (!th_store_enabled()) + return 0; + + /* the state is keyed on the call-id and the leg's tag; with no way + * to key it, it cannot be stored and stays in the Contact instead */ + if (!th_msg_key_tag(msg)) + return 0; + + switch (th_msg_method(msg)) { + case METHOD_INVITE: + case METHOD_UPDATE: + /* a call lasts for as long as it pleases, unless the session + * timers bound it */ + if (parse_session_expires(msg, &se) == parse_sst_success && + se.interval > 0) + return 1; + LM_DBG("no session timers on this call - keeping its state in " + "the Contact, as it may outlive any expiration\n"); + return 0; + + default: + /* a subscription is bounded by its expires, and the rest only + * lives as long as its transaction */ + return 1; + } +} + + +static int th_state_msg_ttl(struct sip_msg *msg) +{ + struct session_expires se; + int method, expires; + + method = th_msg_method(msg); + + switch (method) { + case METHOD_SUBSCRIBE: + case METHOD_NOTIFY: + /* The state is needed for as long as the subscription lives, + * and a subscription may very well outlive the generic + * th_state_ttl. */ + expires = th_sub_expires(msg, method); + if (expires < 0) + return th_state_ttl; + /* a subscription being torn down only needs its transaction to + * complete, and the final NOTIFY to be routed back */ + if (expires == 0) + return th_state_ttl_short; + return expires + TH_STATE_TTL_MARGIN; + + case METHOD_INVITE: + case METHOD_UPDATE: + /* When the session timers are in use, the session is 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, hence the state of a live call can never be + * expired from under it. Without the header, the call may last + * for as long as it wants to. + * + * Note the value is negotiated, but each message carries the + * one it is subject to, and both the request and the reply are + * encoded from their own message. */ + if (parse_session_expires(msg, &se) == parse_sst_success && + se.interval > 0) { + LM_DBG("session refreshed every %us, per Session-Expires\n", + se.interval); + return (int)se.interval + TH_STATE_TTL_MARGIN; + } + return th_state_ttl; + + case METHOD_OPTIONS: + case METHOD_MESSAGE: + case METHOD_INFO: + case METHOD_PUBLISH: + /* these neither open a dialog nor refresh the target of one + * (RFC 3261 12.2.1.2), so their Contact is only of interest + * while their transaction is running */ + return th_state_ttl_short; + + default: + return th_state_ttl; + } +} + + +static int topo_no_dlg_encode_contact(struct sip_msg *msg,int flags, str *routes, str *ct_user, int store_state) { struct lump* lump; char *prefix=NULL,*suffix=NULL,*ct_username=NULL; @@ -2011,7 +2464,8 @@ static int topo_no_dlg_encode_contact(struct sip_msg *msg,int flags, str *routes /* make sure we do not free this string in case of a further error */ prefix = NULL; - if (!(suffix = build_encoded_contact_suffix(msg, routes, &suffix_len, flags))) { + if (!(suffix = build_encoded_contact_suffix(msg, routes, &suffix_len, flags, + store_state))) { LM_ERR("Failed to build suffix \n"); goto error; } @@ -2057,6 +2511,10 @@ static int topo_no_dlg_seq_handling(struct sip_msg *msg,str *info) str route_buf = {0, 0}; struct th_no_dlg_param *param = NULL; transaction_cb* used_cb; + str stored_blob = {NULL, 0}; + str consumed_key = {NULL, 0}; + int ttl; + int from_storage = 0; /* parse all headers to be sure that all RR and Contact hdrs are found */ if (parse_headers(msg, HDR_EOH_F, 0)< 0) { @@ -2064,48 +2522,115 @@ static int topo_no_dlg_seq_handling(struct sip_msg *msg,str *info) return -1; } + /* + * The state either travels in the Contact or was left in the + * storage, and both may be in use at the same time, as it is + * decided per dialog when its Contact is encoded. A key is told + * apart by its marker, which none of the encodings of an actual + * state can produce. + */ + if (th_store_enabled() && info->len == TH_KEY_WIRE_LEN && + info->s[0] == TH_KEY_MARKER) { + /* the Contact only carried the key of the state - fetch the + * actual encoded state and go on decoding it as usual. Keep + * the key around, it still points into the request */ + consumed_key.s = info->s + 1; + consumed_key.len = TH_KEY_LEN; + if (th_store_get(&consumed_key, &stored_blob) < 0) + return -1; + info = &stored_blob; + from_storage = 1; + } + /* delete vias */ if(topo_delete_vias(msg) < 0) { LM_ERR("Failed to remove via headers\n"); - return -1; + goto err_free_blob; } /* delete record route */ for (it=msg->record_route;it;it=it->sibling) { if (del_lump(msg, it->name.s - buf, it->len, 0) == 0) { LM_ERR("del_lump failed\n"); - return -1; + goto err_free_blob; } } - max_size = th_ct_enc_scheme == ENC_BASE64 ? - calc_max_word64_decode_len(info->len) : - calc_max_word32_decode_len(info->len); - dec_buf = pkg_malloc(max_size); - if (dec_buf==NULL) { - LM_ERR("No more pkg\n"); - return -1; - } - - if (th_ct_enc_scheme == ENC_BASE64) - dec_len = word64decode((unsigned char *)dec_buf, - (unsigned char *)info->s,info->len); - else - dec_len = word32decode((unsigned char *)dec_buf, - (unsigned char *)info->s,info->len); - for (i=0;ilen) : + calc_max_word32_decode_len(info->len); + dec_buf = pkg_malloc(max_size); + if (dec_buf==NULL) { + LM_ERR("No more pkg\n"); + goto err_free_blob; + } + + if (th_ct_enc_scheme == ENC_BASE64) + dec_len = word64decode((unsigned char *)dec_buf, + (unsigned char *)info->s,info->len); + else + dec_len = word32decode((unsigned char *)dec_buf, + (unsigned char *)info->s,info->len); + for (i=0;i 0) + th_store_refresh(&consumed_key, &stored_blob, ttl); + + /* dec_buf owns this buffer now - hand it over instead of + * freeing it, and drop our reference so the error path (which + * frees dec_buf and then stored_blob.s) cannot free it twice. */ + stored_blob.s = NULL; + info = NULL; + } + + /* A field is length-prefixed: a raw 2-byte short for a state that + * came inline in the Contact, or a printable ":" for one + * read back from the store (see build_encoded_contact_suffix). Parse + * in place either way - the field just points into the decode buffer. */ #define __extract_len_and_buf(_p, _len, _s) \ do { \ - (_s).len = *(short *)p;\ - if ((_s).len<0 || (_s).len>_len) {\ + if (from_storage) { \ + int _n = 0; \ + while ((_len) > 0 && *(_p) >= '0' && *(_p) <= '9') { \ + _n = _n*10 + (*(_p) - '0'); (_p)++; (_len)--; \ + } \ + if ((_len) <= 0 || *(_p) != ':') { \ + LM_ERR("bad length framing in stored contact\n"); \ + goto err_free_buf; \ + } \ + (_p)++; (_len)--; \ + (_s).len = _n; \ + } else { \ + (_s).len = *(short *)(_p); \ + (_p) += sizeof(short); \ + (_len) -= sizeof(short); \ + } \ + if ((_s).len<0 || (_s).len>(_len)) {\ LM_ERR("bad length %d in encoded contact\n", (_s).len);\ goto err_free_buf;\ }\ - (_s).s = _p + sizeof(short);\ - _p += sizeof(short) + (_s).len;\ - _len -= sizeof(short) + (_s).len;\ + (_s).s = (_p);\ + (_p) += (_s).len;\ + (_len) -= (_s).len;\ } while(0) p = dec_buf; @@ -2342,7 +2867,19 @@ static int topo_no_dlg_seq_handling(struct sip_msg *msg,str *info) free_rr(&head); pkg_free(dec_buf); - if (topo_no_dlg_encode_contact(msg,flags,NULL,NULL) < 0) { + /* + * A subscription being torn down will not be sending anything to + * this state again, so drop it now rather than leaving it behind + * for as long as the subscription it belonged to was going to last. + * The Contact encoded below gets a state of its own, which is what + * routes the final NOTIFY back. + */ + if (consumed_key.s && th_msg_ends_subscription(msg)) + th_store_del(&consumed_key); + + /* keep this dialog on whichever of the two it came in with */ + if (topo_no_dlg_encode_contact(msg,flags,NULL,NULL, + consumed_key.s ? 1 : 0) < 0) { LM_ERR("Failed to encode contact header \n"); return -1; } @@ -2357,5 +2894,8 @@ static int topo_no_dlg_seq_handling(struct sip_msg *msg,str *info) free_rr(&head); err_free_buf: pkg_free(dec_buf); +err_free_blob: + if (stored_blob.s) + pkg_free(stored_blob.s); return -1; } diff --git a/modules/topology_hiding/topology_hiding.c b/modules/topology_hiding/topology_hiding.c index 5a540fc7c14..f50c3496e42 100644 --- a/modules/topology_hiding/topology_hiding.c +++ b/modules/topology_hiding/topology_hiding.c @@ -29,6 +29,7 @@ #include "topo_hiding_logic.h" +#include "th_store.h" struct tm_binds tm_api; struct dlg_binds dlg_api; @@ -48,6 +49,7 @@ str th_contact_callee_var = str_init("_th_contact_callee_username_var_"); int th_ct_enc_scheme; static int mod_init(void); +static int child_init(int rank); static void mod_destroy(void); static int fixup_mmode(void **param); static int fixup_th_params(void **param); @@ -79,6 +81,9 @@ static const param_export_t params[] = { { "th_contact_caller_username_var", STR_PARAM, &th_contact_caller_var.s }, { "th_contact_callee_username_var", STR_PARAM, &th_contact_callee_var.s }, { "th_callid_loop_protection", INT_PARAM, &th_loop_protection }, + { "th_state_url", STR_PARAM, &th_state_url.s }, + { "th_state_ttl", INT_PARAM, &th_state_ttl }, + { "th_state_ttl_short", INT_PARAM, &th_state_ttl_short }, {0, 0, 0} }; @@ -106,6 +111,7 @@ static const dep_export_t deps = { }, { /* modparam dependencies */ { "force_dialog", get_deps_dialog }, + { "th_state_url", get_deps_cachedb_url }, { NULL, NULL }, }, }; @@ -129,7 +135,7 @@ struct module_exports exports= { mod_init, /* module initialization function */ (response_function) 0, mod_destroy, - 0, /* per-child init function */ + child_init, /* per-child init function */ 0 /* reload confirm function */ }; @@ -164,6 +170,11 @@ static int mod_init(void) } + if (th_store_init() < 0) { + LM_ERR("failed to initialize the topology hiding state storage\n"); + goto error; + } + /* loading dependencies */ if (load_tm_api(&tm_api)!=0) { LM_ERR("can't load TM API\n"); @@ -202,9 +213,19 @@ static int mod_init(void) return -1; } +static int child_init(int rank) +{ + if (th_store_child_init() < 0) { + LM_ERR("failed to connect to the topology hiding state storage\n"); + return -1; + } + + return 0; +} + static void mod_destroy(void) { - return; + th_store_destroy(); } static int fixup_mmode(void **param)