SIP Trunk Reseller¶
Astradial resells its Tata PSTN trunk capacity to downstream PBX customers (other Asterisk / FusionPBX / FreePBX installs). Each customer buys a slice of the Tata capacity, sized by two knobs:
| Knob | What it caps | Enforced by |
|---|---|---|
| Channel limit | Max simultaneous calls (in + out combined) | GROUP_COUNT() in the per-customer dialplan |
| DID limit | Exactly which numbers the customer owns | The allocated-DID list — enforced in both directions |
The customer treats us as a generic SIP carrier (like Twilio / Plivo / Exotel). No custom dialplan on their side — they paste a trunk host into their PBX and dial.
This document is the NUC-side plan (where the resale happens) and the customer-side flow. Skylink (pbx.skylinkonline.net, the OSS Astradial PBX on 103.60.137.12) is the first live customer: channel limit 2, DIDs +918065080710 / +918065080711.
Why the NUC, not the cloud¶
The Tata NNI terminates physically at the NUC (10.54.225.90 on the NNI VLAN). All Tata-bound traffic, in and out, must transit the NUC. Putting the resale plane on the NUC keeps the data path short — customer ↔ NUC ↔ Tata — and keeps it isolated by construction from the prod cloud (147.93.168.216), which stays strictly for our own multi-tenant PBX product and is never touched by reseller traffic.
This matters because we run hospital customers on the prod cloud. A compromised or misbehaving reseller customer must never be able to reach prod, staging, or the NUC's control plane. See Isolation below — this is the part to get right, not hand-wave.
Topology¶
The NUC sits behind a home NAT (IPv6-only WAN), so it cannot host a publicly-reachable WireGuard listener. The tunnel is therefore inverted from a textbook "PBX dials into the carrier" picture:
- The customer runs the WireGuard listener (they have a public IP).
- The NUC is the WireGuard initiator — it dials out to each customer's public WG endpoint and holds the tunnel open with
PersistentKeepalive(keeps the home-NAT mapping alive).
WireGuard has no inherent client/server role: one wg-reseller interface on the NUC carries N peers, each with its own Endpoint (the customer's public IP:port) and its own /32 inside 10.30.0.0/24.
graph LR
PSTN["PSTN"]
TATA["Tata SBC<br/>10.79.215.102"]
NUC["NUC<br/>10.54.225.90 (NNI)<br/>10.10.10.2 (wg0 → prod)<br/>10.30.0.2 (wg-reseller)"]
PROD["Prod cloud<br/>147.93.168.216<br/>(hospitals — isolated)"]
SKY["Skylink (customer 1)<br/>103.60.137.12<br/>10.30.0.1 (wg-reseller)"]
CUST2["Customer 2<br/>10.30.0.3 (future)"]
PSTN -->|"SIP/RTP"| TATA
TATA -->|"NNI VLAN"| NUC
NUC -->|"wg0 (separate tunnel)"| PROD
NUC -->|"wg-reseller initiates"| SKY
NUC -.->|"wg-reseller initiates"| CUST2 The NUC runs three completely separate WireGuard interfaces — do not confuse them:
| Interface | Subnet | Role | Peers | Asterisk transport |
|---|---|---|---|---|
wg0 | 10.10.10.0/24 | NUC ↔ prod/staging cloud | Prod (10.10.10.1), Staging (10.10.10.3) | transport-udp on 0.0.0.0:5060 |
wg1 (on cloud, not NUC) | 10.20.0.0/16 | Cloud ↔ its own customer-tunnels | — | — |
wg-reseller | 10.30.0.0/24 | NUC ↔ reseller customers | One /32 per customer | transport-reseller2 on 10.30.0.2:5070 |
The reseller PJSIP transport binds only on 10.30.0.2:5070. It is not reachable from wg0, not from the LAN, not from the public internet. The only way to send SIP to it is to be an authorised peer on wg-reseller.
Authentication — IP-authenticated peer (no SIP password)¶
For a fixed tunnel, registration + digest auth is the wrong model. We use an IP-authenticated peer trunk instead:
[reseller_skylink]
type = endpoint
context = from-reseller-skylink-outbound
transport = transport-reseller2
disallow = all
allow = ulaw,alaw
identify_by = ip ; ← authenticate by source IP, not digest
aors = reseller_skylink
[reseller_skylink_identify]
type = identify
endpoint = reseller_skylink
match = 10.30.0.1 ; ← the customer's wg-reseller address
[reseller_skylink]
type = aor
contact = sip:10.30.0.1:5060 ; static contact → inbound delivery
Why this is strong auth, not weak: the WireGuard tunnel cryptographically guarantees that only the holder of the customer's private key can source packets from 10.30.0.1. So "identify by tunnel IP" is exactly as strong as the WG keypair — there is no password to leak, rotate, or brute-force. This is a standard peer/IP-auth trunk; both FreePBX ("Authentication: None", host = the tunnel IP) and FusionPBX (gateway with no register, ACL by IP) support it natively.
The two knobs¶
Channel limit — GROUP_COUNT()¶
Both inbound and outbound share one group name, so the limit caps total concurrency in any in/out mix:
; outbound context
same => n,Set(GROUP_NAME=reseller_skylink)
same => n,GotoIf($[${GROUP_COUNT(${GROUP_NAME}@reseller)} >= 2]?busy)
same => n,Set(GROUP()=${GROUP_NAME}@reseller)
; ... dial Tata ...
same => n(busy),Busy(5)
same => n,Hangup()
A channel_limit=2 customer can hold at most two simultaneous calls. Excess calls get Busy(5) → SIP 486. This caps the blast radius of a runaway/compromised customer against the shared Tata trunk.
DID limit — bidirectional, from one allocated list¶
The DID limit is not a count; it is a closed list of the exact numbers the customer owns, enforced in both directions from the same list. This is what makes "you own these DIDs and only these" real:
Outbound — anti-spoofing CallerID allow-list. Unlike prod's from-cloud (which permits the whole 8065080700–729 range), the reseller outbound context is a closed exact-match allow-list — the customer can present only its own DIDs as CallerID; everything else is rejected with Hangup(21) = SIP 403:
[from-reseller-skylink-outbound]
exten => _X.,1,Set(CID=${FILTER(0-9,${CALLERID(num)})})
same => n,GotoIf($["${LEN(${CID})}" = "10"]?norm91:cidcheck)
same => n(norm91),Set(CID=91${CID})
same => n(cidcheck),GotoIf($["${CID}" = "918065080710"]?cid_ok)
same => n,GotoIf($["${CID}" = "918065080711"]?cid_ok)
same => n,Goto(reject_cid) ; ← any other number → 403
same => n(cid_ok), ... channel-limit check ...
same => n(reject_cid),Hangup(21)
Inbound — per-DID literal routing in from-tata. Tata delivers every NNI DID to the NUC. A literal exten per allocated DID routes it to that customer; there is no wildcard fallback to "the first reseller", so a customer can only ever receive calls for DIDs it owns:
[from-tata]
; reseller DIDs — literal matches win over the prod wildcard
exten => +918065080710,1,Goto(from-reseller-skylink-inbound,${EXTEN},1)
exten => +918065080711,1,Goto(from-reseller-skylink-inbound,${EXTEN},1)
; prod-cloud DIDs (unchanged) — a different decimal range, cannot collide
exten => _+9180659780XX,1,Dial(PJSIP/${EXTEN}@cloud-endpoint)
Adding a DID to a customer = add it to the allow-list and add a literal from-tata line. provision-reseller.sh does both atomically from the customer's allocated_dids.
Outbound number format — mirror prod or Tata rejects¶
Tata's NNI is strict (403 Forbidden — 6034 otherwise). The reseller outbound context normalises to the exact shape prod's from-cloud uses:
- Dialled number →
0+ 10-digit (e.g.09944421125) - CallerID →
+91XXXXXXXXXX
same => n,Set(CALLERID(num)=+${CID})
same => n,Set(DN=${FILTER(0-9,${EXTEN})}) ; strip to digits
; ... normalise 91-prefixed / 0-prefixed to bare 10-digit ...
same => n,Dial(PJSIP/0${DN}@tata-endpoint,60)
Isolation — the real mechanism¶
This is the security-critical claim. Do not credit it to AllowedIPs.
AllowedIPs only selects which peer receives traffic for a dest IP. On a shared wg-reseller interface, the kernel would happily route customer A → customer B unless the FORWARD chain drops it. The real guarantees are the nftables FORWARD drop + Asterisk being a B2BUA.
Two layers, both verified live on the NUC:
1. nftables FORWARD drop (fail-closed, two priorities). Both a baseline table (priority −200, re-asserted by a 30 s watchdog) and a per-customer table (priority −150) contain:
chain forward {
type filter hook forward priority -200; policy accept;
iifname "wg-reseller" drop # nothing IN from the tunnel may transit
oifname "wg-reseller" drop # nothing may transit OUT to the tunnel
}
A packet from customer A (iif wg-reseller) to customer B (oif wg-reseller) is dropped on both legs. The same drop blocks A from reaching prod (10.10.10.1), staging, the LAN, or anything else — the only thing A can reach is the NUC itself (INPUT chain), and there only udp/5070 (SIP), udp/10000–20000 (RTP), and ICMP echo. ICMP accept is in the input chain only, so A can ping the NUC but not customer B.
2. Asterisk B2BUA. The customer↔Tata media path works despite the oifname drop because Asterisk never IP-forwards a reseller packet. It terminates the customer's call on the NUC and originates a fresh leg to Tata from the NUC itself (OUTPUT, not FORWARD). Customer and Tata are two independent NUC-local sessions, bridged in userspace. There is no IP path between a customer and anything behind the NUC.
Defence-in-depth: each peer should carry AllowedIPs = 10.30.0.x/32 (not /24) so the routing table itself can't be tricked. With a single peer today this is /24; when customer 2 is added, tighten Skylink to 10.30.0.1/32. This is belt-and-braces — the FORWARD drop is the actual guarantee.
Adversarial test (single-customer): flushing the per-customer nft table leaves the baseline table protecting prod; Skylink could reach only 5070/RTP/ICMP on the NUC — not prod (10.10.10.1), staging (10.10.10.3), NUC SSH (22), Tata SIP (5060), or AMI (5038).
File layout¶
NUC: /etc/asterisk/
pjsip.conf existing — only adds #include
extensions.conf existing — from-tata literal DID lines added here
pjsip_reseller.conf transport-reseller2 (bind 10.30.0.2:5070)
ext_reseller.conf from-reseller-<name>-outbound / -inbound
reseller_<name>.conf per-customer endpoint + identify + aor (IP-auth)
NUC: /etc/wireguard/
wg0.conf existing — prod/staging tunnel (do not touch)
wg-reseller.conf reseller tunnel — one [Peer] per customer
NUC: /etc/astradial-reseller/ isolation (fail-closed)
baseline.nft priority -200 FORWARD drop (immutable, watchdog'd)
isolation.nft priority -150 INPUT allow-list + FORWARD drop
NUC: /root/sip-gateway/reseller/
bin/provision-reseller.sh provisions a customer end-to-end
templates/ pjsip_endpoint.tpl, dialplan_context.tpl, wg_peer.tpl
inventory/<name>.json per-customer state (gitignored — no secrets in repo)
{ "name": "skylink", "channels": 2,
"dids": "+918065080710,+918065080711",
"wg": { "peer_ip": "10.30.0.1", "peer_pubkey": "..." } }
Provisioning a customer¶
Run on the NUC. In the inverted topology you pass the customer's tunnel address explicitly (we don't auto-allocate it — the customer's WG listener owns it):
ssh root@nuc.astradial.com
/root/sip-gateway/reseller/bin/provision-reseller.sh \
--name skylink \
--channels 2 \
--dids +918065080710,+918065080711 \
--peer-pubkey "<customer's WG public key>" \
--peer-ip 10.30.0.1 # the customer's wg-reseller address
# --customer-ip defaults to --peer-ip (the IP we IP-auth against)
What it does, idempotently:
- Records / allocates the customer's
/32in10.30.0.0/24. - Writes
reseller_<name>.conf— IP-auth endpoint (identify_by=ip,match=<customer-ip>, static AOR contact). - Writes both dialplan contexts into
ext_reseller.confwith the channel limit and the closed DID allow-list baked in. - Adds the literal
from-tataGotoline per allocated DID. - Adds the
[Peer]towg-reseller.conf+wg syncconf(no interface bounce — existing customers stay up). pjsip reload+dialplan reload(PJSIP transports do not rebind on reload — a new transport needs a full restart, which we avoid; the transport is created once and reused).- Writes
inventory/<name>.jsonfor audit.
DID uniqueness
provision-reseller.sh refuses if a DID already appears in another customer's inventory. One DID, one owner.
Customer-side setup (FreePBX / FusionPBX / Asterisk)¶
The customer needs:
- WireGuard on the host that originates/terminates SIP, configured as the listener (public
Endpoint), with the NUC as a peer. - A WG keypair — they generate it, send us the public key only.
- A PJSIP trunk with no registration and no auth — host =
10.30.0.2, port5070, transport UDP, codecsulaw,alaw. We IP-authenticate them by their tunnel address; there is no password to enter.
| Field | Value |
|---|---|
| Trunk host / server | 10.30.0.2 |
| Port | 5070 |
| Transport | UDP |
| Authentication | None (IP-auth over the tunnel) |
| Register | No |
| Codecs | ulaw,alaw |
| Outbound CallerID | one of their allocated DIDs (others are rejected) |
This is the same shape any commercial SIP trunk offers. No custom dialplan on the customer side.
Risk register¶
| Risk | Mitigation |
|---|---|
| Customer floods Tata with calls | Channel limit caps concurrency; closed-list CID allow-list blocks spoofing. |
| Customer A tries to reach customer B / prod | nftables FORWARD iifname/oifname wg-reseller drop (two priorities, watchdog'd) + B2BUA = no IP path. Not AllowedIPs. |
| Customer's WG drops | AOR unreachable → outbound Busy. No impact on prod or other customers. |
| Customer presents a DID it doesn't own | Outbound closed allow-list → Hangup(21) 403. |
| Customer receives a DID it doesn't own | from-tata literal routing, no wildcard fallback. |
| Reload-storm during provisioning | wg syncconf + chained pjsip reload; existing tunnels/calls survive. |
| Reseller plane interferes with prod trunk | Different transport IP+port (10.30.0.2:5070 vs 0.0.0.0:5060); PJSIP routes by transport. |
Future work¶
- Reseller management UI — provisioning is a CLI today; expose it as a form with DID-pool view + validation.
- Per-reseller CDR / billing — CEL or per-customer
cdr_csvfor usage reports and minute billing. Out of scope until customer 2. - AllowedIPs tighten on scale — switch the single peer from
/24to/32when the second customer lands. - Multi-carrier egress — let
from-reseller-<name>egress via Tata OR another carrier by prefix, once multi-carrier-trunks.md lands.
See also¶
- Customer onboarding doc — the one to send a new customer.
- Customer Tunnels — a different feature (
wg1on the cloud,10.20.0.0/16); do not confuse withwg-reselleron the NUC. - Multi-Carrier Trunks.