Multi-Carrier Trunks (Design Spec — Pre-Implementation)¶
Status: design proposal, not implemented
This document captures the proposed model for supporting multiple carrier trunks (Tata + Jio + Vodafone + future). No code in this repo implements it yet. Until it's implemented, "adding a new carrier" is a manual project — not a data-only operation.
Adding a new DID range to the existing Tata trunk IS data-only today after the 2026-05-16 generator fix. See Add a New DID Range for that workflow.
Why this exists¶
Today the platform is built around a single carrier (Tata) with hardcoded identifiers throughout the Asterisk configs and the deployment service:
tata-endpoint,tata-identify,tata-aor(PJSIP, both NUC and cloud)from-tata(NUC dialplan context name)tata_gateway(cloud-side endpoint name)tata-inbound,tata-did-route(cloud dialplan contexts)did_numbers.providertext column hardcoded to'Tata'in 59 of 60 rowsdid_numbers.trunk_idexists but is set on only 1 of 60 rows; not used by the dialplan generator
The existing sip_trunks table is per-org (10 rows, one "Tata SIP Trunk" per organisation, all pointing at host=10.10.10.2). It models the org→PBX peering relationship, not the carrier→platform relationship. It is not the right table to represent "Tata" or "Jio" as a system-level trunk.
When the user wants to onboard a new carrier (Jio over public internet, a second Tata NNI through another NUC, a Vodafone DID block), today they have to:
- Hand-write PJSIP endpoint config for that carrier (NUC or cloud).
- Hand-write dialplan contexts that mirror the
from-tata/tata-inboundpattern. - Tag DIDs in the pool with the right trunk somehow (no plumbing exists).
- Update the generator to know about the new trunk.
This spec proposes a model where adding a carrier is one DB row plus adding DIDs. No code change.
Two carrier topologies we need to support¶
Indian carriers in 2026 offer SIP interconnect in one of two shapes:
1. NNI via NUC (the Tata model)¶
Carrier SBC ── physical NNI (VLAN-tagged) ── NUC eth ── NUC Asterisk
│
WireGuard ───┘
│
Cloud Asterisk
- IP-identified, no auth.
- NUC is the only thing the carrier can reach (10.54.225.90).
- Cloud reaches the carrier indirectly via WireGuard → NUC.
- Hardware-bound. One physical link per trunk.
2. SIP direct to cloud VPS (likely Jio / Vodafone model)¶
- IP-identified or registration-based.
- No NUC in the path. The cloud public IP is what the carrier knows.
- Multiple carriers can share the same cloud Asterisk.
The model must support BOTH. Adding one of each type should be the same abstract workflow.
Proposed data model¶
New table carrier_trunks (system-level, not org-scoped):
CREATE TABLE carrier_trunks (
id CHAR(36) PRIMARY KEY, -- UUID
name VARCHAR(64) UNIQUE NOT NULL, -- "tata_main", "jio_b2c", "vodafone_idea"
provider VARCHAR(32) NOT NULL, -- "Tata", "Jio", "Vodafone Idea"
description VARCHAR(255),
connection_type ENUM('nuc','direct') NOT NULL,
nuc_id CHAR(36), -- FK nucs.id when connection_type='nuc'
endpoint_host VARCHAR(64) NOT NULL, -- e.g. "10.79.215.102" or "sbc.jio.in"
endpoint_port INT NOT NULL DEFAULT 5060,
transport ENUM('udp','tcp','tls') NOT NULL DEFAULT 'udp',
identify_match_ips JSON NOT NULL, -- ["10.79.215.102"] or ["210.18.x.y", "210.18.x.z"]
auth_type ENUM('none','userpass') NOT NULL DEFAULT 'none',
auth_username VARCHAR(128),
auth_secret VARBINARY(255), -- encrypted at app layer
codecs JSON NOT NULL DEFAULT '["alaw","ulaw"]',
qualify_frequency INT NOT NULL DEFAULT 0, -- 0 = no OPTIONS (Tata behaviour)
outbound_dial_format VARCHAR(32) NOT NULL DEFAULT 'asis', -- 'asis' / 'leading_zero' / 'plus_e164'
status ENUM('active','inactive','maintenance') NOT NULL DEFAULT 'active',
configuration JSON NOT NULL DEFAULT '{}', -- escape hatch for carrier oddities
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
New table nucs (machine inventory):
CREATE TABLE nucs (
id CHAR(36) PRIMARY KEY,
name VARCHAR(64) UNIQUE NOT NULL, -- "nuc-bangalore-1"
hostname VARCHAR(128) NOT NULL, -- "nuc.astradial.com"
wireguard_ip VARCHAR(45) NOT NULL, -- "10.10.10.2"
nni_interface VARCHAR(32), -- "enp86s0"
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
notes TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
Migration to did_numbers:
- Make
trunk_idNOT NULL, FK tocarrier_trunks.id(wassip_trunks.id). - Backfill: insert one
carrier_trunksrow for "tata_main" with the existing Tata config, thenUPDATE did_numbers SET trunk_id = '<tata_main_uuid>'for all current rows.
Keep sip_trunks as-is. It serves a different purpose (per-org peering, e.g. V7's UCM6301 connection). Don't conflate the two concepts.
What the generator does after the refactor¶
configDeploymentService.deployGatewayRouting() becomes:
const trunks = await CarrierTrunk.findAll({ where: { status: 'active' } });
const dids = await DidNumber.findAll({ where: { ... } });
// 1. Emit PJSIP endpoints — one per active trunk, on the appropriate machine.
const cloudPjsip = trunks
.filter(t => t.connection_type === 'direct')
.map(t => renderPjsipEndpoint(t))
.join('\n');
const nucPjsipByNuc = groupBy(trunks.filter(t => t.connection_type === 'nuc'), 'nuc_id');
// 2. Emit dialplan inbound contexts — one [<name>-inbound] per trunk,
// plus a single global [did-route] keyed by DID.
const inboundContexts = trunks.map(t => renderInboundContext(t)).join('\n');
const didRouteContext = renderDidRoute(dids);
// 3. Write to cloud /etc/asterisk/, scp to each NUC.
Inbound context naming changes from tata-inbound → <trunk_name>-inbound, keyed off the trunk row. The PJSIP endpoint's context= points at it.
For NUCs, the generator writes per-NUC config tarballs to a known location (e.g. /var/lib/astradial/nuc-configs/<nuc_id>/) and a sidecar process (systemd timer on each NUC, or a push step from cloud) deploys them.
Adding a carrier — target workflow after refactor¶
Case A: New carrier direct to cloud (Jio over internet)¶
- Admin UI → Trunks → New Trunk. Fill: name
jio_main, providerJio, connection_typedirect, endpoint_hostsbc.jio.in, identify_match_ips["210.18.x.y"], auth as needed, codecs. - Save. Generator runs. Cloud Asterisk gets a new
[jio_main]PJSIP endpoint and a[jio_main-inbound]dialplan context. - Add DIDs to the pool with the trunk dropdown set to
jio_main. Assign to orgs as usual. - Test: dial a Jio DID. Cloud receives the INVITE, identifies the trunk, passes through to
did-route, routes to the org. Done.
Case B: New carrier behind a new NUC (second Tata NNI in Chennai)¶
- Provision a NUC, install WireGuard + Asterisk, register it in the
nucstable. - Admin UI → Trunks → New Trunk. connection_type
nuc, nuc_id pointed at the new NUC, endpoint_host = carrier SBC IP. - Save. Generator writes the NUC's PJSIP + dialplan config. Sync to NUC over SSH (manual
make sync-nucfor first-time, or sidecar deploys). - Add DIDs, assign to orgs. Test.
Case C: New range on existing trunk¶
This is already the case today and IS data-only — see the Add a New DID Range guide.
Implementation phasing (proposed)¶
Each is a separate PR:
| Phase | Scope | Risk |
|---|---|---|
| P1 (done) | Generator emits pass-through tata-inbound instead of hardcoded range. Tests. | Low — surgical regex change, validated on prod |
| P2 | Schema: carrier_trunks + nucs tables. Migration to backfill Tata trunk + populate did_numbers.trunk_id. NO behaviour change yet — the generator still hardcodes "tata" names but reads the carrier_trunks row | Medium — DB migration on prod, but no Asterisk-config change |
| P3 | Generator reads from carrier_trunks: emits per-trunk PJSIP endpoint config (cloud-side trunks first, simpler). Rename tata-inbound → <trunk>-inbound with a back-compat alias context that routes tata-inbound → <tata-trunk>-inbound. Keep prod working through cutover | Medium-high — touches every prod org's call path; alias must work flawlessly |
| P4 | NUC config generator: configDeploymentService.deployNucConfig(nuc_id) writes a tarball. Manual push step at first (cloud writes, operator runs make push-nuc) | Medium — new failure mode (NUC config drift between sync runs) |
| P5 | Admin UI: Trunks page (list, create, edit, deactivate). NUCs page (list, view) | Low — UI only, gated to admin role |
| P6 | Automate NUC push (sidecar on NUC pulls + applies, or cloud SSH+rsync) | Medium — getting it idempotent is the hard part |
| P7 | Remove "tata"-specific identifiers entirely. Cleanup compat aliases | Low — by this point nothing depends on the names |
The user signs off on each phase's design before its PR opens.
Decisions still to make¶
- NUC config sync mechanism. Three options:
- a) Cloud SSHes to each NUC and rsyncs (cloud has the keys). Tight coupling but reuses existing access.
- b) NUC polls cloud for a config tarball (
curl + tar). Loose coupling, NUC initiates the connection (NAT-friendly). - c) Push the configs into the monorepo per-NUC and let CI/CD deploy via the standard pipeline. Auditable but slow.
Lean: (b) — NUC has WireGuard + DNS to cloud; a single systemd timer pulls every minute. Tarball includes a sha so reload is skipped when unchanged. Auditable via the cloud-side build log.
-
Trunk identification when source IP overlaps. If two carriers ever send from the same SBC IP (Reliance, Tata via the same upstream), PJSIP
identify_by=ipcan't distinguish them. Need a fallback toidentify_by=usernameor per-trunkmatch_request_user. Tata today doesn't have this problem, but the design must accept it. -
What happens to the per-org
sip_trunksrows. They keep working; they describe org-to-platform peering. But they should be renamed in the UI to "Customer SIP Peers" or similar to remove confusion with carrier trunks. Cosmetic; defer. -
Outbound CID validation. Today NUC validates CID against a hardcoded range. Proposal (already agreed in 2026-05-16 session): remove NUC validation entirely. Each org's outbound context sets CALLERID from the assigned DID; if a misconfiguration sends an unauthorized CID, the carrier rejects with 403 — surface the bug rather than silently mask it with a fallback CID.
Open questions for review¶
- Does
carrier_trunksbelong in the same DB as the app data, or is it more of an "infrastructure inventory" thing that wants its own store? (I think same DB — it has FK relationships withdid_numbersandnucs.) - For NUC-routed trunks, do we want one Asterisk per trunk or one Asterisk hosting multiple trunks? (Lean: one Asterisk hosting many; same as cloud.)
- For direct-to-cloud trunks: do all share the same
transport-udpon port 5060, or do we want carrier-isolation via separate transports/ports? (Lean: same transport unless a carrier requires else.) - Permissions model: should "create trunk" be admin-only? (Yes — it's platform infrastructure, not org-tenant config.)
Sign-off¶
Once you've reviewed the proposal, file the open questions above as concrete decisions on this page (commit them), then we open PR-P2.
Background — why this was needed¶
The 2026-05-16 V7 outage: a new Tata DID range was added to the pool, V7 was assigned a number from the new range and made it default-outbound. Tata delivered the inbound INVITE fine, but the platform 404'd because the dialplan generator hardcoded the old range as a pattern. Fixing it required: hand-editing the NUC's extensions.conf, hand-editing the cloud's ext_tata_gateway.conf (which is auto-generated and would clobber on next deploy), plus a recovery script to re-apply if regen ran.
The surgical fix (PR-P1) made the pattern range-agnostic for Tata. The multi-trunk refactor (P2 onward) makes the same pattern carrier-agnostic. The user wants to add Jio or Vodafone next without revisiting the dialplan generator.