feat: Dynamic port slot binding for multi-trunk support (Vianova)#158
feat: Dynamic port slot binding for multi-trunk support (Vianova)#158paolovisintin wants to merge 18 commits into
Conversation
Added functionality to generate trunk port slots for multi-trunk support if TRUNK_SLOTS set and greater than 0. This will allow for easier configuration and management of trunk ports.
TRUNK_SLOTS and TR_START agent configuration.
…slot-based port binding forunk supportail configuration. the initialization of slot pools, automatic slot release on TTL expiration, assignment of slots for outbound REGISTER requests to special domains, and slot binding for outbound INVITE and other methods. Contact is rewritten slot port for special domains. This management and enhance multi-trunk support in Kamailio.
Added new tables for slot-based port binding to support multi-trunk functionality. This commit follows the Kamailio htable schema for compatibility with dbmode=1.
There was a problem hiding this comment.
Pull request overview
Adds dynamic “slot” (named socket/port) binding in the Kamailio module to support multiple Vianova trunks behind a single proxy IP, persisting slot assignments in PostgreSQL and generating per-slot listeners at bootstrap time.
Changes:
- Introduces new Kamailio htables and routes to assign/apply/release per-trunk slot bindings and rewrite Contact accordingly.
- Generates additional named listen sockets (“slotN”) from
TRUNK_SLOTS/TRUNK_PORT_STARTenvironment variables. - Adds PostgreSQL tables/migration for htable-backed persistence and wires new env defaults/defines.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| modules/postgres/migrations/05_slot_tables.sql | Adds slot_domains and slot_assignments tables/sequences/indexes for htable persistence. |
| modules/kamailio/config/template.kamailio-local.cfg | Adds TRUNK_SLOTS and TRUNK_PORT_START preprocessor defines. |
| modules/kamailio/config/kamailio.cfg | Adds htable configuration + slot allocation/apply/rewrite routes and expiry hook. |
| modules/kamailio/bootstrap.sh | Generates named listen sockets for slot ports based on environment variables. |
| imageroot/actions/create-module/10env | Sets default values for TRUNK_SLOTS and TRUNK_PORT_START. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Pre-initialize all slot pool entries to 0 (free) | ||
| $sht(slotpool=>slot1) = 0; | ||
| $sht(slotpool=>slot2) = 0; | ||
| $sht(slotpool=>slot3) = 0; | ||
| $sht(slotpool=>slot4) = 0; | ||
| $sht(slotpool=>slot5) = 0; | ||
| $sht(slotpool=>slot6) = 0; | ||
| $sht(slotpool=>slot7) = 0; | ||
| $sht(slotpool=>slot8) = 0; | ||
| $sht(slotpool=>slot9) = 0; | ||
| $sht(slotpool=>slot10) = 0; |
There was a problem hiding this comment.
event_route[htable:mod-init] initializes slotpool only for slot1..slot10, but TRUNK_SLOTS is configurable and can exceed 10. This will leave higher-numbered slots uninitialized (or unclear) and makes the configuration harder to maintain. Consider initializing slotpool in a loop up to TRUNK_SLOTS (and/or relying on initval) rather than hardcoding 10 entries.
| # Pre-initialize all slot pool entries to 0 (free) | |
| $sht(slotpool=>slot1) = 0; | |
| $sht(slotpool=>slot2) = 0; | |
| $sht(slotpool=>slot3) = 0; | |
| $sht(slotpool=>slot4) = 0; | |
| $sht(slotpool=>slot5) = 0; | |
| $sht(slotpool=>slot6) = 0; | |
| $sht(slotpool=>slot7) = 0; | |
| $sht(slotpool=>slot8) = 0; | |
| $sht(slotpool=>slot9) = 0; | |
| $sht(slotpool=>slot10) = 0; | |
| # Pre-initialize all slot pool entries to 0 (free), up to TRUNK_SLOTS | |
| #!for $i 1 $TRUNK_SLOTS | |
| $sht(slotpool=>slot$i) = 0; | |
| #!next |
There was a problem hiding this comment.
The proposed #!for directive doesn't exist in Kamailio's preprocessor. Kamailio only supports #!define, #!ifdef, #!ifndef, #!else, #!endif, #!subst, #!substdef, #!include, and a few others — there is no loop construct at the preprocessor level.
A runtime loop would work instead:
$var(i) = 1;
while ($var(i) <= TRUNK_SLOTS) {
$sht(slotpool=>"slot" + $var(i)) = 0;
$var(i) = $var(i) + 1;
}
That said, the issue is already mitigated by the htable definition:
modparam("htable", "htable", "slotpool=>size=4;autoexpire=0;initval=0;")
The initval=0 means any key not explicitly set in slotpool already returns 0 (free) by default. So even if TRUNK_SLOTS exceeds 10, uninitialized slots would still behave correctly. The explicit initialization is redundant but improves readability.
We can replace the hardcoded block with the runtime loop for clarity and consistency with TRUNK_SLOTS.
| # Rebuild occupancy: slotassign is loaded from DB (dbmode=2) before mod-init. | ||
| # Mark occupied slots by checking each slot name against slotassign values. | ||
| # We check each pool slot against all known assignments via a simple scan. | ||
| # Since slotassign has dbmode=2, entries are already loaded at this point. | ||
| # We rely on SLOT_REGISTER to re-mark slots on next REGISTER cycle. | ||
|
|
||
| xlog("L_INFO", "[SLOT_INIT] Slot pool initialized\n"); |
There was a problem hiding this comment.
The comment says this route rebuilds slot occupancy from persisted assignments, but the implementation currently sets every slotpool entry to free and never marks slots that are already present in slotassign (loaded from DB). After a restart, new REGISTERs can reuse an already-assigned slot until the old trunk refreshes, causing collisions. Consider iterating existing slotassign entries at startup (e.g., via htable iteration or a DB query) and marking the corresponding slotpool entries as occupied.
There was a problem hiding this comment.
Good catch. The current implementation does have a collision window after a restart: slotassign is restored from DB (dbmode=2), but slotpool is reset to all zeros. If a new trunk sends a REGISTER before the old trunk refreshes, it could claim a slot that's already assigned in slotassign.
The comment acknowledges this by saying "We rely on SLOT_REGISTER to re-mark slots on next REGISTER cycle", but we can do better by rebuilding occupancy at startup using sht_iterator on the already-loaded slotassign:
sht_iterator_start("iter", "slotassign");
while (sht_iterator_next("iter")) {
$var(slot_val) = $shtitval(iter);
if ($var(slot_val) != $null && $var(slot_val) != "") {
$sht(slotpool=>$var(slot_val)) = 1;
xlog("L_INFO", "[SLOT_INIT] Restored: $shtitkey(iter) -> $var(slot_val)\n");
}
}
sht_iterator_end("iter");
This iterates slotassign (read-only) and writes to slotpool (a different htable), so no locking issues. Will include this in the next update.
| # 3. Handle un-register (Expires: 0) | ||
| $var(expires) = 0; | ||
| if (is_present_hf("Expires")) { | ||
| $var(expires) = $hdr(Expires); | ||
| } | ||
|
|
||
| if ($var(expires) == 0) { |
There was a problem hiding this comment.
Un-register detection only checks the Expires header. REGISTER unregistration often uses Contact header parameters (e.g., "Contact: ...;expires=0") without an Expires header, in which case the slot will not be released. Consider also parsing Contact expires (and potentially multiple Contacts) to reliably detect unregistration.
| # 3. Handle un-register (Expires: 0) | |
| $var(expires) = 0; | |
| if (is_present_hf("Expires")) { | |
| $var(expires) = $hdr(Expires); | |
| } | |
| if ($var(expires) == 0) { | |
| # 3. Handle un-register (Expires: 0 or Contact expires=0) | |
| $var(expires) = 0; | |
| $var(is_unreg) = 0; | |
| if (is_present_hf("Expires")) { | |
| $var(expires) = $hdr(Expires); | |
| if ($var(expires) == 0) { | |
| $var(is_unreg) = 1; | |
| } | |
| } | |
| # If there is no explicit Expires: 0, also check Contact header parameters | |
| if ($var(is_unreg) == 0 && is_present_hf("Contact")) { | |
| # Treat as un-register if any Contact has an expires=0 parameter | |
| if ($hdr(Contact) =~ "(^|[; ,])expires=0([^0-9]|$)") { | |
| $var(is_unreg) = 1; | |
| } | |
| } | |
| if ($var(is_unreg) == 1) { |
There was a problem hiding this comment.
The observation is technically valid in the general SIP case, but in this specific context the risk is minimal:
This route handles outbound trunk registrations (Asterisk → SIP provider), not UA client registrations. Trunk REGISTER unsubscriptions use the Expires: 0 header — the Contact: ...;expires=0 pattern is typical of UA registrations, which are not handled here.
The suggested regex is fragile — parsing Contact header parameters with regex can break on whitespace variations, quoted strings, or multiple Contact entries.
There's already a safety net: slotassign entries have a 10800-second TTL (3 hours). Even if an unregister is somehow missed, the slot is automatically freed by event_route[htable:expired:slotassign].
Given these factors, we don't think adding Contact header parsing complexity is warranted here.
| # 5. Find first available slot | ||
| $var(found) = 0; | ||
| $var(i) = 1; | ||
| while ($var(i) <= TRUNK_SLOTS && $var(found) == 0) { | ||
| $var(slot_name) = "slot" + $var(i); | ||
| $var(pool_val) = $sht(slotpool=>$var(slot_name)); | ||
| if ($var(pool_val) == 0) { | ||
| # Slot is free — claim it | ||
| $sht(slotpool=>$var(slot_name)) = 1; | ||
| $sht(slotassign=>$var(slot_key)) = $var(slot_name); | ||
| $shtex(slotassign=>$var(slot_key)) = 10800; | ||
| $fsn = $var(slot_name); | ||
| $var(found) = 1; | ||
| xlog("L_WARN", "[SLOT_REGISTER] - $ci $rm-$cs - ASSIGNED: $var(slot_key) -> $fsn (TTL 3h)\n"); | ||
| } |
There was a problem hiding this comment.
Slot allocation scans slotpool and then writes slotpool/slotassign in multiple steps without any locking/atomicity. With multiple Kamailio children, concurrent REGISTERs can observe the same free slot and both claim it, leading to duplicate port assignments. Consider protecting the allocate/claim section with an htable lock (or moving allocation to an atomic DB operation with uniqueness on slot value).
There was a problem hiding this comment.
Technically correct — the read-then-write on slotpool is not atomic across Kamailio workers. However, the practical risk is negligible:
Trunk registrations are infrequent events — each trunk registers every few minutes. The probability of two new trunks sending their first REGISTER in the same microsecond window is extremely low.
The race window is minimal — microseconds between the slotpool read and write.
It's self-correcting — on the next REGISTER refresh (step 4), the trunk finds its existing assignment in slotassign and renews it. Even if a collision occurred, one trunk would get reassigned a different slot on the next cycle.
The slot count is small — typically single digits, so the scan is near-instantaneous.
A lock()/unlock() (cfgutils module) around the allocation section could eliminate the theoretical race, but it would add complexity for a scenario that is virtually unreachable in production. We'll keep this in mind but don't consider it a priority.
| SLOT_TLS_PORT=$((SLOT_PORT + 1000)) | ||
|
|
||
| if [ "${BEHIND_NAT}" == "true" ]; then | ||
| echo "listen=udp:${PRIVATE_IP}:${SLOT_PORT} advertise ${PUBLIC_IP}:${SLOT_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg | ||
| echo "listen=tcp:${PRIVATE_IP}:${SLOT_PORT} advertise ${PUBLIC_IP}:${SLOT_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg | ||
| echo "listen=tls:${PRIVATE_IP}:${SLOT_TLS_PORT} advertise ${PUBLIC_IP}:${SLOT_TLS_PORT} name \"${SLOT_NAME}_tls\"" >> /tmp/kamailio-local-additional.cfg | ||
| else | ||
| echo "listen=udp:${PUBLIC_IP}:${SLOT_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg | ||
| echo "listen=tcp:${PUBLIC_IP}:${SLOT_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg | ||
| echo "listen=tls:${PUBLIC_IP}:${SLOT_TLS_PORT} name \"${SLOT_NAME}_tls\"" >> /tmp/kamailio-local-additional.cfg |
There was a problem hiding this comment.
bootstrap.sh generates TLS slot listeners on SLOT_TLS_PORT (slot UDP/TCP port + 1000) and names them "slotN_tls", but the Kamailio logic assigns $fsn="slotN" and rewrites Contact using the UDP/TCP slot port even when transport is TLS. This combination will mis-advertise TLS contacts and can select the wrong outbound socket for TLS traffic. Consider aligning TLS port/name selection with the $fsn and Contact rewrite logic (or omit TLS slot listeners if unsupported).
| SLOT_TLS_PORT=$((SLOT_PORT + 1000)) | |
| if [ "${BEHIND_NAT}" == "true" ]; then | |
| echo "listen=udp:${PRIVATE_IP}:${SLOT_PORT} advertise ${PUBLIC_IP}:${SLOT_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg | |
| echo "listen=tcp:${PRIVATE_IP}:${SLOT_PORT} advertise ${PUBLIC_IP}:${SLOT_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg | |
| echo "listen=tls:${PRIVATE_IP}:${SLOT_TLS_PORT} advertise ${PUBLIC_IP}:${SLOT_TLS_PORT} name \"${SLOT_NAME}_tls\"" >> /tmp/kamailio-local-additional.cfg | |
| else | |
| echo "listen=udp:${PUBLIC_IP}:${SLOT_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg | |
| echo "listen=tcp:${PUBLIC_IP}:${SLOT_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg | |
| echo "listen=tls:${PUBLIC_IP}:${SLOT_TLS_PORT} name \"${SLOT_NAME}_tls\"" >> /tmp/kamailio-local-additional.cfg | |
| # Use the same port for TLS as for UDP/TCP to align with Kamailio $fsn and Contact logic | |
| SLOT_TLS_PORT=${SLOT_PORT} | |
| if [ "${BEHIND_NAT}" == "true" ]; then | |
| echo "listen=udp:${PRIVATE_IP}:${SLOT_PORT} advertise ${PUBLIC_IP}:${SLOT_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg | |
| echo "listen=tcp:${PRIVATE_IP}:${SLOT_PORT} advertise ${PUBLIC_IP}:${SLOT_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg | |
| echo "listen=tls:${PRIVATE_IP}:${SLOT_TLS_PORT} advertise ${PUBLIC_IP}:${SLOT_TLS_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg | |
| else | |
| echo "listen=udp:${PUBLIC_IP}:${SLOT_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg | |
| echo "listen=tcp:${PUBLIC_IP}:${SLOT_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg | |
| echo "listen=tls:${PUBLIC_IP}:${SLOT_TLS_PORT} name \"${SLOT_NAME}\"" >> /tmp/kamailio-local-additional.cfg |
There was a problem hiding this comment.
The observation partially identifies a real issue, but the suggested fix (using the same port for UDP and TLS) is incorrect — you cannot bind UDP and TLS on the same port in Kamailio.
Here's how $fsn actually works internally: when $fsn = "slot1" is set, Kamailio resolves it to the first matching listener (UDP, since it's checked first). At send time, if the outbound transport differs (e.g., TLS), Kamailio tries to find a listener on the same IP:port with the correct protocol. Since our TLS listeners are on a different port (SLOT_PORT + 1000), find_si() won't find them and falls back to default socket selection — effectively losing the slot binding for TLS.
That said, in practice this is a non-issue for our use case: trunk registrations to SIP providers (Vianova, etc.) use UDP or TCP, not TLS. The TLS slot listeners are provisioned for future-proofing but are not actively used by any current trunk configuration.
If TLS trunk support becomes a requirement in the future, the fix would be to detect the outbound transport in SLOT_REGISTER/SLOT_INVITE and set $fsn = "slotN_tls" accordingly, plus adjust SLOT_REWRITE_CONTACT to use the TLS port. We'll address that when needed.
| # Calculate port from slot name: "slotN" -> TRUNK_PORT_START + N - 1 | ||
| $var(slot_num) = $(fsn{s.substr,4,0}); | ||
| $var(slot_port) = TRUNK_PORT_START + $var(slot_num) - 1; | ||
|
|
||
| # Determine transport from original Contact | ||
| $var(ctProto) = $(ct{nameaddr.uri}{uri.transport}); | ||
| if ($var(ctProto) == $null || $var(ctProto) == "" || $var(ctProto) == 0) { | ||
| $var(ctProto) = "UDP"; | ||
| } | ||
|
|
||
| # Preserve Contact URI parameters if present | ||
| $var(contactParams) = ""; | ||
| if ($(ct{nameaddr.uri}{uri.params}) != $null && $(ct{nameaddr.uri}{uri.params}) != "" && $(ct{nameaddr.uri}{uri.params}) != 0) { | ||
| $var(contactParams) = ";" + $(ct{nameaddr.uri}{uri.params}); | ||
| } | ||
|
|
||
| remove_hf("Contact"); | ||
| $var(default_contact) = DEFAULT_CONTACT; | ||
| # Strip any existing port from DEFAULT_CONTACT before adding slot port | ||
| $var(contact_host) = $(var(default_contact){s.select,0,:}); | ||
| append_hf("Contact: <sip:$fU@$var(contact_host):$var(slot_port);transport=$(var(ctProto){s.tolower})$var(contactParams)>\r\n", "Call-ID"); |
There was a problem hiding this comment.
SLOT_REWRITE_CONTACT always uses the calculated UDP/TCP slot port, even when the original Contact transport is TLS. However bootstrap.sh creates TLS listeners on a different port (slot_port+1000) and with a different socket name (slotN_tls), so TLS registrations/calls will advertise an incorrect port and $fsn will not select the TLS slot. Consider mapping TLS contacts to the TLS slot port and setting/using the corresponding named socket for TLS traffic.
There was a problem hiding this comment.
This is the same concern raised on the bootstrap.sh TLS listeners — see the response there. In short: trunk registrations in our deployment use UDP/TCP, not TLS. The TLS slot listeners are provisioned for future use. If TLS trunk support becomes a requirement, we'll adjust both $fsn selection and SLOT_REWRITE_CONTACT port calculation to handle the TLS case. Not a priority for now.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Pass TRUNK_* into the container so bootstrap.sh can render TRUNK_SLOTS and TRUNK_PORT_START into kamailio.cfg.
Compute firewall service ports from TRUNK_SLOTS and TRUNK_PORT_START so create-module and configure-module keep the slot range aligned. Move the shared firewall helpers into imageroot/pypkg so the same NAT rich-rule logic is reused during destroy-module cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Seed TRUNK_SLOTS and TRUNK_PORT_START during module updates so upgraded instances keep the new slot-based trunk settings available. Reuse the shared firewall helper constant so the default trunk port start stays aligned with the firewall integration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep the firewall-focused branch testable by preserving the configure-module regression that checks slot ports survive repeated reconfiguration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Match the firewall port calculation to bootstrap.sh so each slot opens the UDP/TCP listener range and the corresponding TLS range at slot_port + 1000. Extend the firewall regression test to assert the TLS slot ports are kept across reconfiguration as well. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add the add-slot-domain action so operators can register a special domain in slot_domains and immediately reload the slotdomains htable in Kamailio. The implementation follows the PR logic directly: create or update the persisted domain entry, then refresh the runtime htable view. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- SLOT_INVITE keyed the slot lookup on $fU (CallerID) but the slot is assigned from $tU on REGISTER. With providers like Vianova, where the auth username differs from the number placed in From, the keys didn't match and the INVITE fell back to the default 5060 socket, causing a 403 from the provider. Use the Contact URI user — Asterisk puts the trunk auth username there. - SLOT_REGISTER released the slot immediately on un-REGISTER (Expires: 0). The 401/407 auth retry then found no slot, $fsn couldn't be set, and the retry went out from 5060. Keep the assignment: auth retries reuse the same slot, a re-REGISTER refreshes it, and an abandoned trunk is eventually freed by TTL expiry.
Summary
voip.vianova.it), it automatically assigns a unique port slot to each(username, domain)tuple88.88.88.1:5071), and all subsequent SIP traffic (INVITE, BYE, etc.) uses the same slotChanges
modules/kamailio/config/kamailio.cfg— New routes:SLOT_REGISTER,SLOT_INVITE,SLOT_REWRITE_CONTACT;SET_SOCKETguard for$fsn; htable definitions (slotdomains,slotassign,slotpool); expired event route for automatic slot releasemodules/kamailio/bootstrap.sh— Dynamic generation of named listen sockets (slots) based onTRUNK_SLOTSandTRUNK_PORT_STARTenv varsmodules/kamailio/config/template.kamailio-local.cfg— AddedTRUNK_SLOTSandTRUNK_PORT_STARTdefinesmodules/postgres/migrations/05_slot_tables.sql— New tablesslot_domainsandslot_assignmentsfor htable persistenceimageroot/actions/create-module/10env— Default env vars (TRUNK_SLOTS=0,TRUNK_PORT_START=5071)How it works
slot_domainstable and reloads withkamcmd htable.reload slotdomains$fU::$rd(From-User + Request-Domain)$fsnforces the outbound socket;SET_SOCKETis guarded to not override itTested on GNS3
040989898→ slot1/5071,040888888→ slot2/5072) registering simultaneouslyRelated
Test plan
20firewallupdate)