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)
+
+ 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
+ ), 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 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/")
+...
+
+
+
+
+
+ th_state_ttl (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 th_state_ttl parameter
+
+...
+# hide the topology of calls which may last up to 12 hours
+modparam("topology_hiding", "th_state_ttl", 43200)
+...
+
+
+
+
+
+ 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 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 th_state_ttl_short 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)