Skip to content

FusionPBX Integration (Reselling the Tata Trunk into a Customer FusionPBX)

How Astradial's resold Tata trunk is wired into a customer's FusionPBX (FreeSWITCH) PBX, end to end — plus every trap we hit doing it the first time. This is the companion to SIP Trunk Reseller (the NUC side) and the customer setup guide.

First live FusionPBX customer: Skylink, fpbx.skylinkonline.net (103.60.137.12 public / 103.60.137.36 for the FusionPBX host), reseller tunnel IP 10.30.0.3, DIDs +918065080710 / +918065080711.

FusionPBX ≠ a plain Asterisk trunk

FusionPBX is a web GUI over FreeSWITCH. Almost every failure below came from a FreeSWITCH/FusionPBX-specific behaviour (multi-domain realms, the mod_lua XML handler, dialplan_mode='single', IP-allowlist firewalls), not from the trunk itself. Read the Troubleshooting section before touching a customer FusionPBX.

Architecture

PSTN ── Tata SBC ── NNI ── NUC (10.54.225.90)
                             │  from-tata: literal DID → from-reseller-skylink-inbound
                             │  fork Dial → BOTH customer PBXes
                             ├── wg-reseller ──> .12 OSS PBX   (10.30.0.1)
                             └── wg-reseller ──> .36 FusionPBX (10.30.0.3)
  • One trunk, IP-authed over WireGuard. The NUC dials out to each customer's public WG listener; SIP is IP-authenticated by the tunnel /32. No password.
  • Two knobs unchanged from the reseller model: channel limit (2, shared GROUP_COUNT) and a closed DID allow-list (both directions).
  • The NUC forks inbound to both the OSS box (.12) and FusionPBX (.36); first to answer wins.

Part 1 — WireGuard on the FusionPBX host (.36)

FusionPBX is the WG listener (it has a public IP); the NUC initiates.

# /etc/wireguard/astradial-trunk.conf  (chmod 600)
[Interface]
Address = 10.30.0.3/32
ListenPort = 51821
PrivateKey = <generated on .36>

[Peer]                       # NUC — it dials in; no Endpoint line here
PublicKey = <NUC wg-reseller pubkey>
AllowedIPs = 10.30.0.2/32

wg-quick up astradial-trunk + systemctl enable wg-quick@astradial-trunk. Send us the public key + the public IP:51821. On the NUC we add the peer (tighten the existing customer peer to /32 first — WireGuard can't have two peers overlapping an address) and wg syncconf (no bounce).

Verify: wg show astradial-trunk (fresh handshake) + ping 10.30.0.2.

Part 2 — NUC (reseller gateway) side

Per-customer PJSIP endpoint (IP-auth, no digest), added to /etc/asterisk/pjsip_reseller.conf via #include:

[reseller_skylink_fpbx]
type = endpoint
context = from-reseller-skylink-outbound   ; shares CID allow-list + channel group
transport = transport-reseller2
disallow = all
allow = ulaw,alaw
identify_by = ip
aors = reseller_skylink_fpbx
; ... force_rport/rewrite_contact/rtp_symmetric = yes ...

[reseller_skylink_fpbx_identify]
type = identify
endpoint = reseller_skylink_fpbx
match = 10.30.0.3

[reseller_skylink_fpbx]
type = aor
max_contacts = 1
qualify_frequency = 0
contact = sip:10.30.0.3:5060

Reload with module reload res_pjsip.so, not pjsip reload

pjsip reload does not pick up a new #include. Use asterisk -rx "module reload res_pjsip.so".

Inbound fork (/etc/asterisk/ext_reseller.conf, from-reseller-skylink-inbound):

exten => _+X.,1,NoOp(reseller=skylink in did=${EXTEN})   ; NOTE: _+X. not _X.
 ...
 same => n,Dial(PJSIP/${EXTEN}@reseller_skylink&PJSIP/${EXTEN:1}@reseller_skylink_fpbx,60)

Two deliberate details, both learned the hard way (see Troubleshooting #1, #5):

  • _+X. — Tata delivers the DID as +918065080710 (leading +). _X. requires a leading digit and silently never matches → "number incorrect".
  • ${EXTEN:1} on the FusionPBX leg strips the + so .36 receives plain 918065080710 — required for the FusionPBX v_destinations match. The OSS .12 leg keeps ${EXTEN} (with +).

Part 3 — FusionPBX side

3.0 The multi-domain trap (do this first)

FusionPBX is multi-tenant. Skylink has two domains:

Domain Role
skylink.local Production — all real extensions live here (context skylink.local, realm skylink.local)
fpbx.skylinkonline.net Web/secondary domain

Configure everything for the trunk on skylink.local (switch domains via the top-right selector). Gateways are global; Destinations (inbound) and Outbound Routes are per-domain and must be created on skylink.local pointing at real extensions there. Our first attempt built the DIDs on the wrong domain (routing to a throwaway 1230@fpbx…) — that alone guarantees a broken call.

3.1 Dedicated SIP profile bound to the tunnel IP

The public external profile binds only the public IP. SIP sourced from it is dropped by the NUC's WireGuard (cryptokey routing only accepts 10.30.0.3). Create a dedicated sofia profile (GUI → Advanced → SIP Profiles, copy external):

sip-ip = 10.30.0.3   rtp-ip = 10.30.0.3
ext-sip-ip = 10.30.0.3   ext-rtp-ip = 10.30.0.3    (direct tunnel, no NAT)
sip-port = 5060   context = public   auth-calls = false   (remove apply-inbound-acl)

Start it; confirm sofia status shows it RUNNING on 10.30.0.3:5060.

3.2 Gateway

Accounts → Gateways → Add astradial:

Field Value
Proxy 10.30.0.2:5070 · Register False
Username / From User 918065080710 (an allocated DID)
Profile the tunnel profile (3.1) · Context public

From User must be an allocated DID. Default FreeSWITCH puts FreeSWITCH in the From URI user → NUC anti-spoof 403s it (Troubleshooting #3).

3.3 Inbound Destinations

Dialplan → Destinations on skylink.local, one per DID:

  • Destination number: plain digits 918065080710 (NOT a regex, NOT +…)
  • Context public · Action: transfer to a real ext (e.g. 6000)

Plain digits only — this is the single biggest gotcha

This box runs dialplan_mode='single', where the handler matches the incoming number against the v_destinations table by exact equality. A regex (\+?918065080710) or a + there never equals the incoming plain digits → 404, silently. See Troubleshooting #5.

3.4 Outbound Routes

Dialplan → Outbound Routes on skylink.local → gateway astradial. Model on the existing per-extension route:

<condition field="destination_number" expression="^(?:\+?91|0091|0)?([6-9]\d{9})$">
  <action application="set" data="effective_caller_id_number=918065080710"/>
  <action application="bridge" data="sofia/gateway/<astradial-uuid>/$1"/>
</condition>

Send the 10-digit $1 to the gateway; the NUC's from-reseller-skylink-outbound normalises to the 0+10-digit shape Tata requires. CID is forced to an allocated DID by the gateway's From User (§3.2) regardless of the extension.

3.5 Extensions & softphone registration

Extensions live on skylink.local, so the SIP realm is skylink.local (not the server hostname). A remote softphone must:

  • Resolve skylink.local — add 103.60.137.36 skylink.local to the client's /etc/hosts (it's a private .local name), or use an outbound proxy.
  • Register to the internal profile port — on this box that's 5080 (external/internal are swapped here; the 30 phones register on 5080).
  • Be from an allowlisted IP — the firewall is INPUT DROP + per-IP SIP allow. Add the client's public IP (Troubleshooting #4).

Troubleshooting (each an independent topic)

1. Inbound plays "number incorrect / cannot be connected" (carrier voice)

Cause: the NUC's from-reseller-skylink-inbound matched _X., which requires a leading digit — but Tata sends +918065080710. No match → the NUC errors the call back to Tata, which plays the announcement. It looks like a carrier problem; it isn't. Fix: _+X. (or strip the + before the Goto). Diagnose: broad-capture ALL SIP on the NUC (tcpdump -i any udp port 5060, no source filter — Tata's INVITE may come from an SBC IP other than the OPTIONS IP) and confirm the INVITE arrives, then check dialplan show +91…@context.

2. Outbound INVITE never reaches the NUC (gateway times out, RECOVERY_ON_TIMER_EXPIRE)

Cause: FreeSWITCH sent from the public profile IP (103.60.137.36); the NUC's WireGuard drops any inner source that isn't 10.30.0.3. Fix: put the gateway on a profile bound to 10.30.0.3 (§3.1). Diagnose: on the NUC tcpdump -i wg-reseller udp port 5070 shows nothing; the FS log shows sofia/external/<num> (wrong profile).

3. Outbound gets 403 Forbidden from the NUC

Cause: From URI user is FreeSWITCH (or a non-allocated number). The NUC's anti-spoof checks the From user and requires an allocated DID. Fix: gateway From User = an allocated DID (§3.2). For per-extension CID: outbound route sets effective_caller_id_number + gateway caller-id-in-from=true. Diagnose: capture the INVITE with -A; the From shows the DID only in the display name but FreeSWITCH in the URI.

4. Softphone won't register (408 Request Timeout, or endless "Connecting")

Work through, in order: - Wrong port — register to the internal profile (here 5080), not the trunk profile's 5060. - Wrong realm — the extension's realm is skylink.local; use that as the SIP domain, with /etc/hosts mapping it to the server (see §3.5). - Firewall — the client's public IP isn't in the INPUT SIP allowlist so iptables drops the REGISTER before FreeSWITCH (no FS log, just 408). Add it: iptables -I INPUT -s <ip>/32 -p udp --dport 5060:5091 -j ACCEPT (+ the RTP range). Check the actual client IP — NAT means the softphone's Contact shows a private LAN IP; the source IP on the wire is what matters. - The macOS "Telephone" app only sends STUN, never a proper REGISTER, to a non-standard port — use Zoiper/Linphone instead. - fail2ban — repeated bad attempts can ban the IP; check fail2ban-client banned.

5. Inbound 404 Not Found from FusionPBX even though the Destination exists ⭐

The one that ate an evening. FreeSWITCH answers 100 Trying then 404. Everything looked identical to a working DID (Voxseed), yet ours 404'd through cache clears, reloadxml, xml_flush_cache, and a full FreeSWITCH restart.

Root cause: this box serves config via a mod_lua XML handler (/usr/share/freeswitch/scripts/app/xml_handler/…/dialplan/dialplan.lua), and it runs in dialplan_mode='single'. In single mode the public handler finds the inbound dialplan by matching the incoming number against the v_destinations table:

WHERE (COALESCE(destination_prefix,'')||COALESCE(destination_area_code,'')||
       COALESCE(destination_number,'')) = :destination_number

Our v_destinations.destination_number still held the regex \+?918065080710 (FusionPBX stores whatever you typed in the Destination field). \+?918065080710 never equals the incoming 918065080710 → never found → 404.

Fix: v_destinations.destination_number must be plain digits equal to what the NUC sends (we strip the + on the NUC, so 918065080710):

UPDATE v_destinations SET destination_number='918065080710'
WHERE destination_number='\+?918065080710';

Or, cleaner: create the Destination in the GUI with a plain number in the first place. Lessons: (a) in single mode v_destinations is the match key — v_dialplans.dialplan_xml/v_dialplan_details edits don't affect finding the route; (b) read the handler script instead of guessing; (c) dialplan_xml (not v_dialplan_details) is what the handler actually serves once found, so fix the transfer target there too.

6. What FusionPBX actually serves to FreeSWITCH (mental model)

  • Config is DB-backed, served dynamically by the mod_lua xml_handler (NOT mod_xml_curl here — module_exists mod_xml_curl is false; the static /etc/freeswitch/** files are .noload/empty). Editing files on disk does nothing.
  • Response cache is file-based at /var/cache/fusionpbx/ (cache.method=file). rm /var/cache/fusionpbx/* is non-recursive; use find /var/cache/fusionpbx -type f -delete + fs_cli -x reloadxml.
  • mod_lua is not unloadable — clearing an in-memory Lua cache needs a FreeSWITCH restart (⚠️ drops all calls/registrations; maintenance window).
  • Finding a route: v_destinations (single mode). Serving it once found: v_dialplans.dialplan_xml. The GUI source of truth: v_dialplan_details (regenerated to dialplan_xml on GUI save). Prefer the GUI over raw SQL — raw edits leave these three out of sync.

7. OSS .12 leg returns 401 Unauthorized to the fork

Cause: the AstraDial OSS PBX challenges the NUC's INVITE for digest auth, but the reseller trunk is IP-auth (no password). Fix (only needed if you want the OSS box to ring alongside FusionPBX): on .12, add a PJSIP identify for the NUC's tunnel IP 10.30.0.2 so it trusts the peer instead of challenging.

Verify end-to-end

  • Inbound: call the DID from a mobile → the mapped extension rings, two-way audio. Simulate without PSTN from the NUC: asterisk -rx "channel originate PJSIP/918065080710@reseller_skylink_fpbx application Echo" and tcpdump -i wg-reseller host 10.30.0.3 and port 5060 — expect 100 → 183 → 200, not 404.
  • Outbound: from the extension, dial a PSTN number → connects, callee sees the allocated DID.
  • Isolation/cap unchanged — see SIP Trunk Reseller.

See also