Skip to content

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.provider text column hardcoded to 'Tata' in 59 of 60 rows
  • did_numbers.trunk_id exists 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-inbound pattern.
  • 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)

Carrier SBC ── public internet ── Cloud Asterisk (89.116.31.109:5060)
  • 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_id NOT NULL, FK to carrier_trunks.id (was sip_trunks.id).
  • Backfill: insert one carrier_trunks row for "tata_main" with the existing Tata config, then UPDATE 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)

  1. Admin UI → Trunks → New Trunk. Fill: name jio_main, provider Jio, connection_type direct, endpoint_host sbc.jio.in, identify_match_ips ["210.18.x.y"], auth as needed, codecs.
  2. Save. Generator runs. Cloud Asterisk gets a new [jio_main] PJSIP endpoint and a [jio_main-inbound] dialplan context.
  3. Add DIDs to the pool with the trunk dropdown set to jio_main. Assign to orgs as usual.
  4. 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)

  1. Provision a NUC, install WireGuard + Asterisk, register it in the nucs table.
  2. Admin UI → Trunks → New Trunk. connection_type nuc, nuc_id pointed at the new NUC, endpoint_host = carrier SBC IP.
  3. Save. Generator writes the NUC's PJSIP + dialplan config. Sync to NUC over SSH (manual make sync-nuc for first-time, or sidecar deploys).
  4. 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

  1. NUC config sync mechanism. Three options:
  2. a) Cloud SSHes to each NUC and rsyncs (cloud has the keys). Tight coupling but reuses existing access.
  3. b) NUC polls cloud for a config tarball (curl + tar). Loose coupling, NUC initiates the connection (NAT-friendly).
  4. 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.

  1. 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=ip can't distinguish them. Need a fallback to identify_by=username or per-trunk match_request_user. Tata today doesn't have this problem, but the design must accept it.

  2. What happens to the per-org sip_trunks rows. 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.

  3. 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_trunks belong 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 with did_numbers and nucs.)
  • 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-udp on 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.