# FISHFUSION DEPLOYMENT GUIDE ## FusionPBX @ Fishtank — Telnyx Type C Registered Trunk, E911, and Working Call Forwarding **Revision:** 2026-08-17 — rewritten from the dual-provider draft. Flowroute removed. Every dialplan value below is transcribed from the live, verified-working system rather than from theory. **Scope:** Telnyx account setup, Debian/FusionPBX install, trunk configuration, inbound and outbound routing, **call forwarding on both external and internal paths**, endpoint provisioning, Magikarp firewall changes, and acceptance testing. --- ## 0. ENVIRONMENT REFERENCE CARD | Item | Value | |---|---| | PBX hostname (system) | `fishfusion` | | PBX FQDN / SIP domain | `voip.maxdevnet.cc` → **172.20.8.5, internal only** | | Public DDNS name | `pbx.maxdevnet.cc` → current WAN IP | | PBX IP | `172.20.8.5/24` — static, **no IPv6** | | PBX gateway / DNS | `172.20.8.1` (Magikarp VLAN 77 SVI) | | HPBX network | VLAN **77**, `172.20.8.0/24` | | VLAN 77 DHCP pool | `172.20.8.40–240` | | Magikarp WAN | `enp16s0f0np0` (`$WAN_IF`), DHCP, currently `38.212.0.31` | | Magikarp LAN trunk | `enp16s0f1np1` (`$LAN_IF`) | | Magikarp VLAN 77 | `enp16s0f1np1.77` (`$VLAN77_IF`) | | Reverse proxy | Pleco — `192.168.1.125 (LAN)`, `172.20.8.213 (VLAN 77)` | | Adtran TA908e | `172.20.8.15` — FXS ports for 1001/1002/1003 | | FusionPBX domain UUID | `027f07e4-43ce-4df0-ba4d-47d4a316654b` | | Telnyx gateway | name `telnyx`, user `maxdevtrunk01`, UUID `c54db2c4-ee9d-43d6-b65e-1e002145dbb3` | | Trunk type | **Type C — credential/registration** (correct for DHCP WAN) | | Carrier-facing profile | FreeSWITCH **external**, UDP **5080** | | RTP range | UDP **16384–32768** | ### DID / extension map | DID | Extension | Devices | |---|---|---| | +1 235 208 7012 | 1001 | Adtran FXS 0/1 + VVX 311 | | +1 235 208 7013 | 1002 | Adtran FXS 0/2 | | +1 235 208 7014 | 1003 | Adtran FXS 0/3 + VVX 250 | | +1 235 208 7015 | 1004 | VVX 250 | ### Dialplan order map — context `voip.maxdevnet.cc` | Order | Entry | Purpose | |---|---|---| | 22 | `call_direction-outbound` ×2 | Direction tagging, one per outbound pattern | | 80 | `default_caller_id` | Stock defaults | | **99** | **`telnyx.forward`** | Forwarded calls arriving from the carrier | | 100 | `telnyx.911` | Emergency | | 100 | `telnyx.11d` | Normal outbound + internal-origin forwards | > **Orders must be distinct between routes that can both match.** `telnyx.forward` at 99 > and `telnyx.11d` at 100 is load-bearing. Equal orders evaluate arbitrarily with no error. --- ## 1. SILENT FAILURE MODES — READ BEFORE ANYTHING ELSE FusionPBX discards configuration in several places **without logging anything**. Each of these cost hours during the build. Check them before assuming a change didn't take. ### 1.1 `dialplan_detail_inline` — the big one FreeSWITCH processes a dialplan entry in two phases: - **Routing phase** — conditions evaluated, `inline="true"` actions executed - **Execution phase** — everything else, *after* the bridge target is already built Any `set` that must influence outgoing SIP headers — `effective_caller_id_number`, `sip_from_user`, `sip_from_display`, `origination_caller_id_number` — **must be inline**. Non-inline writes the variable to the channel, but the From header was already constructed and never sees it. **Symptom:** row correct in the database, appears in the generated dialplan, executes in the log, and has zero effect on the wire. ### 1.2 The diagnostic that ends most of these ``` Dialplan: ... Action set(sip_from_user=${sip_to_user}) INLINE ← UNRESOLVED template EXECUTE ... set(sip_from_user=12352087012) ← RESOLVED value ``` - **Action line shows a literal where a variable should be** → running config is stale; GUI-save the entry (§1.6) - **Action line shows the variable, EXECUTE shows the wrong value** → variable is wrong; pick another ```bash grep -E 'Action set\(sip_from_user|EXECUTE.*sip_from_user' /tmp/log ``` Reach for this before reading SIP traces. It replaces an entire class of guesswork. ### 1.3 Hostname-scoping columns `v_vars.hostname` and `v_sip_profiles.sip_profile_hostname` scope rows to a node. The installer populates them with FQDNs, which never match the system hostname `fishfusion`, so those rows are **silently excluded** from generated config. Worst case: `` — valid XML, zero profiles, FreeSWITCH starts with no error. ```sql SELECT table_name, column_name FROM information_schema.columns WHERE column_name LIKE '%hostname%' AND table_schema='public' ORDER BY table_name; UPDATE v_sip_profiles SET sip_profile_hostname = NULL; UPDATE v_vars SET hostname = NULL; ``` ### 1.4 The `enabled` flag vs the value Many settings have a value **and** a separate enabled boolean. A row can read `value = 'true'` while `default_setting_enabled = false` and be dropped entirely. This is how the provisioning app appeared missing for hours. **Always check both columns.** ### 1.5 The cache `/var/cache/fusionpbx/` is file-backed and **not** cleared by `reloadxml`, `sofia profile restart`, or `systemctl restart freeswitch`. ```bash rm -f /var/cache/fusionpbx/* systemctl restart freeswitch ``` Flush **then** restart. Reverse order re-caches the old values. ### 1.6 GUI save is the reliable write path Direct SQL to `v_dialplan_details` repeatedly failed to reach the running dialplan even after cache flush plus full restart. Opening the entry in **Dialplan → Dialplan Manager** and clicking **Save** — with no changes — forced regeneration every time. **When a SQL edit refuses to take effect, GUI-save the entry before investigating anything else.** ### 1.7 Static XML shadowing the database Stock dialplan entries also exist as files in `/var/www/fusionpbx/app/dialplans/resources/switch/conf/dialplan/`. Where a file exists, it may take precedence over `v_dialplan_details`. ```bash grep -rn 'the-string-youre-changing' /var/www/fusionpbx/app/dialplans/resources/switch/conf/dialplan/ ``` Entries **you** create (destinations, outbound routes) are genuinely DB-driven. ### 1.8 Destination selection is an exact string match Inbound lookup selects by matching `v_dialplans.dialplan_number` against the arriving `destination_number` as a plain string. The condition regex is irrelevant until the entry has already been selected. This bit twice: `+12352087012` vs `12352087012`, then `2352087012` vs `12352087012`. --- # PART 2 — TELNYX ACCOUNT SETUP ## 2.1 Account creation & verification 1. Sign up at `telnyx.com` with real name/address — licensed carrier, KYC-checked. 2. Complete **Level 2 verification** immediately (Account Settings → Verifications): government ID + address. Gates number purchasing, porting, concurrency, SMS. Can take minutes to days — front-load it. 3. Fund $10–20. Set a **low balance email alert** while in billing. ## 2.2 Create the credential SIP Connection (this is what makes it Type C) Mission Control → **Voice → SIP Trunking → SIP Connections → Create SIP Connection**. 1. **Connection name:** `fishfusion-trunk` 2. **Authentication:** **Credentials.** Telnyx generates a SIP username and lets you set a password. > **Password character set matters.** Generate 24+ chars but **avoid `@ : ; " '`**. > sofia-sip builds its credential string as `scheme:realm:user:password` and certain > characters corrupt that parse — producing a registration that shows `Password yes` > in status and never sends an Authorization header. If you hit repeated 401s with > credentials you're certain are right, regenerate as plain alphanumeric. 3. **Inbound tab:** - **Destination number format:** note what you pick — this determines `dialplan_number` in §5.1 and is the single most common inbound failure. Current working system receives **11 digits, no plus**: `12352087012`. - Transport: UDP - Codecs: `G711U (PCMU)` first, then `G722`, then `G711A`. Disable the exotics. 4. **Outbound tab → Outbound Voice Profile** (`fishfusion-ovp`): - **Allowed destinations:** United States (+ Canada if wanted). Everything else OFF. This is the toll-fraud seatbelt. - Traffic type: Conversational - **Concurrent channel limit:** 2–5 - **Daily spend limit:** $5–10 5. Registration goes to `sip.telnyx.com`. > **`sip.telnyx.com` resolves to multiple nodes.** FreeSWITCH may re-resolve between > the 401 challenge and the authenticated retry, sending the digest to a node that > never issued that nonce → `403 Authentication Failed`. Symptom is intermittent > registration with correct credentials. If it recurs, set **Register Transport** to > `tcp`, which pins the transaction to one node (and add 5060/TCP to the firewall). ## 2.3 Buy the DIDs Mission Control → **Numbers → Search & Buy Numbers**. 1. Search by area code or rate center → buy. 2. **Each number → Connection = `fishfusion-trunk`.** Without this, inbound never reaches your registration. 3. Record in +E.164. ## 2.4 E911 1. Numbers → select DID → **Emergency (E911)** → enable. 2. Enter the **exact physical address**, including unit. Validated against MSAG; tweak formatting (St vs Street) until it passes. 3. Billing: small monthly per E911-enabled DID, plus a per-call fee for real 911 use and a large fee for false/abusive calls. 4. **Test with 933, never 911.** 933 reads back the registered address and callback number without dispatching anyone. > **Treat E911 as blocking, not a to-do.** The FXS ports feed ordinary analog telephones. > Anyone who picks one up has no way to discover 911 doesn't work except by needing it. ## 2.5 Collect the IP ranges Telnyx publishes **SIP signaling** and **RTP media** ranges separately in their docs. Pull both live at deploy time — do not trust any list baked into a document. Observed in production traffic (illustrative, **not** a substitute for the published list): - Signaling: `64.16.250.10`, `192.76.120.10` - Media: `50.114.144.23`, `50.114.146.14`, `50.114.148.59`, `50.114.150.23`, `64.16.229.4/5/67` Note media and signaling come from **different** ranges. A single capture never shows the full set. --- # PART 3 — DEBIAN + FUSIONPBX INSTALL ## 3.1 Base install Standard Debian 13 netinst. Static IP 172.20.8.5/24, gateway 172.20.8.1, DNS 172.20.8.1. Hostname `fishfusion`. No desktop, SSH server + standard utilities only. Chrony: `server ntp.socket.net iburst` (and/or `216.106.44.17`). ## 3.2 FusionPBX ```bash cd /usr/src git clone https://github.com/fusionpbx/fusionpbx-install.sh.git cd fusionpbx-install.sh/debian ./install.sh ``` Record the generated admin credentials. ### 3.2.1 nginx will not start — IPv6 The installer writes IPv6 listeners. With IPv6 disabled at the kernel (`ipv6.disable=1`), `AF_INET6` doesn't exist and nginx cannot create the socket: ``` nginx: [emerg] socket() [::]:80 failed (97: Address family not supported by protocol) ``` ```bash sed -i -E 's/^([[:space:]]*)(listen[[:space:]]+\[::\].*)$/\1# \2/' \ /etc/nginx/sites-available/fusionpbx /etc/nginx/sites-enabled/default nginx -t && systemctl restart nginx ``` > **Better long-term:** replace `ipv6.disable=1` on the kernel cmdline with > `net.ipv6.conf.all.disable_ipv6=1` / `net.ipv6.conf.default.disable_ipv6=1` sysctls. > `AF_INET6` stays available so software can open the socket, while no v6 traffic reaches > the wire. Avoids this class of problem across FreeSWITCH and Debian daemons generally. ### 3.2.2 Immediate remediation ```bash sudo -u postgres pg_dump fusionpbx > /root/fusionpbx-baseline-$(date +%Y%m%d-%H%M).sql ``` Then run the §1.3 hostname sweep **before configuring anything else**. ## 3.3 Domain rename — do this before creating anything The installer names the domain after whatever address you reached it by (typically the IP). The domain name becomes the **SIP realm every endpoint registers against**, the dialplan context name, and the GUI tenant key. Renaming later means re-provisioning everything. **Advanced → Domains** → rename to `voip.maxdevnet.cc`. After renaming, browse to `https://voip.maxdevnet.cc` or log in as `admin@voip.maxdevnet.cc` — FusionPBX resolves the tenant from the HTTP Host header. ## 3.4 SIP profiles — bind vs advertise | Parameter | Meaning | Value | |---|---|---| | `sip-ip` / `rtp-ip` | **Bind** — must be an address the host owns | `$${local_ip_v4}` | | `ext-sip-ip` / `ext-rtp-ip` | **Advertised** in Contact and SDP | `host:pbx.maxdevnet.cc` | Putting the public address in `sip-ip` produces `Error Creating SIP UA ... The IP the profile is attempting to bind to is not local` and the profile dies. **internal** (VLAN 77 endpoints, same subnet): `sip-ip`/`rtp-ip` = `$${local_ip_v4}`, `ext-*` **disabled**. **external** (carrier-facing): `sip-ip`/`rtp-ip` = `$${local_ip_v4}`, `ext-sip-ip`/`ext-rtp-ip` = `host:pbx.maxdevnet.cc`. Set literal values on the profile rather than referencing `$${external_sip_ip}` — that variable lives only in `vars.xml`, which is generated from `v_vars` and subject to the hostname filter. > **`host:` resolves once, at profile start.** On a DHCP WAN the advertised address goes > stale on lease change. Mitigate with a poller that restarts the external profile when > the DDNS record changes — with an in-call guard, since a profile restart drops every > channel on it. Verify: ```bash fs_cli -x "sofia status" ss -lunp | egrep '5060|5080' ``` Both profiles **bound to 172.20.8.5**; external's Data column shows the public IP as the *advertised* address. ## 3.5 TLS certificate — DNS-01 only WAN 80/443 DNAT to **Pleco**, so HTTP-01 validation lands there and 404s. Confirmed: ``` "38.212.0.31: Invalid response from https://voip.maxdevnet.cc/.well-known/acme-challenge/...: 404" ``` ```bash apt install certbot python3-certbot-dns-cloudflare printf 'dns_cloudflare_api_token = \n' > /etc/letsencrypt/cloudflare.ini chmod 600 /etc/letsencrypt/cloudflare.ini certbot certonly --dry-run --dns-cloudflare \ --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \ --dns-cloudflare-propagation-seconds 60 -d voip.maxdevnet.cc ``` Dry run first — production allows 5 failed validations per hour. Then drop `--dry-run` and add `--agree-tos --no-eff-email -m `. nginx: ```nginx ssl_certificate /etc/letsencrypt/live/voip.maxdevnet.cc/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/voip.maxdevnet.cc/privkey.pem; ``` > **`fullchain.pem`, not `cert.pem`** — Polycom phones validate the intermediate chain > and will refuse `cert.pem`. > **Do NOT run the installer's `letsencrypt.sh`.** It uses dehydrated with HTTP-01, > rewrites the nginx vhost to `/etc/dehydrated/certs/...`, fails, and leaves nginx down. > Recovery: point the vhost back at `/etc/ssl/certs/nginx.crt` and > `/etc/ssl/private/nginx.key` (the installer's snakeoil pair) to get the GUI back. **HSTS caveat:** the vhost sends `Strict-Transport-Security`. Once cached, a self-signed cert gives `ERR_CERT_AUTHORITY_INVALID` with **no click-through**. Clear via `chrome://net-internals/#hsts`, or browse by IP. --- # PART 4 — TELNYX GATEWAY **Accounts → Gateways → Add.** Values below are transcribed from the working system. | Field | Value | |---|---| | Gateway | `telnyx` | | Username | `maxdevtrunk01` | | Password | *(from §2.2)* | | From User | *(blank)* | | From Domain | `sip.telnyx.com` | | Proxy | `sip.telnyx.com` | | Realm | *(blank — defaults to proxy)* | | Expire Seconds | `800` | | Register | **True** | | Retry Seconds | `30` | | Register Transport | *(blank = UDP)* | | **Caller ID In From** | **True** | | **Ping** | `100` | | Context | `public` | | Profile | **`external`** | | Enabled | True | ### Why these specific settings **Profile must be `external`.** On `internal`, the Contact carries 172.20.8.5 and the carrier cannot route back. **Caller ID In From = True** puts `effective_caller_id_number` into the From URI user part. **Telnyx validates the From URI user part, not the display name.** Without this the From carries the gateway username (`maxdevtrunk01`) and calls are rejected `403 Unverified origination number D51`. **Ping** is not optional. Registration expiry is 800s; conntrack UDP timeout on Magikarp is 120s assured. Without OPTIONS keepalives the NAT pinhole closes between refreshes and inbound INVITEs land on nothing. It also gives dead-peer detection. **Realm must match the challenge exactly** if set. A truncated realm produces a permanent `TRYING` state with no useful error — digest can't be computed and the registrar never sees a valid response. ### Gateway naming in bridge strings FusionPBX may register a gateway under its **UUID** rather than its friendly name. Verify which before writing bridge strings: ```bash fs_cli -x "sofia status" ``` Read the gateway line. On this system the friendly name `telnyx` works, so bridge strings use `sofia/gateway/telnyx/$1`. If `sofia status gateway telnyx` returns `Invalid Gateway!`, use the UUID (`c54db2c4-ee9d-43d6-b65e-1e002145dbb3`) instead. **A name sofia doesn't know fails every call** with `GATEWAY_DOWN` / `NO_ROUTE_DESTINATION`. ### Verify ```bash fs_cli -x "sofia status gateway telnyx" ``` State `REGED`, Status `UP`, a `PingTime` value. Healthy registration exchange: `REGISTER` → `401` + nonce → `REGISTER` with `Authorization` on the **same Call-ID, incremented CSeq** → `200 OK`. If the second REGISTER never appears, sofia could not build a credential — check realm and password. --- # PART 5 — DIALPLAN ## 5.1 Inbound destinations **Dialplan → Destinations → Add**, one per DID: | Field | Value | |---|---| | Type | Inbound | | Destination | *(exactly what Telnyx sends — see below)* | | Context | **`public`** | | Action | Transfer → `` XML `voip.maxdevnet.cc` | | Enabled | true | Context is `public` because the gateway's Context is `public`. Inbound lands there, matches, and **transfers into** the domain context. ### Determine the digit format empirically ```bash fs_cli -x "console loglevel debug" ``` Place an inbound call and read: ``` [xml_handler] single key:dialplan:public:12352087012 ``` That string is what `dialplan_number` must equal. **Current confirmed format: 11 digits, no plus.** Working destinations: | `dialplan_number` | Transfer target | |---|---| | `12352087012` | `1001 XML voip.maxdevnet.cc` | | `12352087013` | `1002 XML voip.maxdevnet.cc` | | `12352087014` | `1003 XML voip.maxdevnet.cc` | | `12352087015` | `1004 XML voip.maxdevnet.cc` | Each also sets `effective_caller_id_name=2352087012#${caller_id_name}` so the handset shows which DID was dialed. Keep the *condition regex* permissive — `^\+?1?(2352087012)$` — so format drift doesn't break matching once selected. ### Provider ACL Inbound INVITEs get `407 Proxy Authentication Required` unless the source is in the `providers` ACL — the carrier will never answer that challenge, so the call retries and fails. **Advanced → Access Control → `providers`** → add Telnyx signaling ranges as `allow` CIDR nodes. ```bash rm -f /var/cache/fusionpbx/configuration.acl.conf fs_cli -x "reloadacl" fs_cli -x "acl providers list" ``` An empty providers list logs `Created ip list providers default (deny)` at startup with no entries — and every inbound call gets 407'd. --- ## 5.2 `telnyx.11d` — normal outbound (order 100) **Entry:** name `telnyx.11d`, context `voip.maxdevnet.cc`, order `100`, continue `false`, enabled true, description `NANP 10/11-digit → Telnyx`. **Conditions, group 0:** | Order | Type | Data | |---|---|---| | 10 | `${user_exists}` | `false` | | 20 | `${toll_allow}` | `domestic` | | 30 | `destination_number` | `^\+?1?([2-9][0-9]{2}[2-9][0-9]{6})$` | > **`${user_exists} false` is the exit door for PSTN-bound calls.** Setting it to `true` > removes the only way out of the domain context for external destinations and produces > an infinite transfer loop terminating in `483 Too Many Hops`. Do not change it. **Actions, group 0:** | Order | Type | Data | Inline | Enabled | |---|---|---|---|---| | 40 | set | `sip_h_accountcode=${accountcode}` | | False | | 50 | export | `call_direction=outbound` | true | True | | 60 | unset | `call_timeout` | | True | | 70 | set | `hangup_after_bridge=true` | | True | | 80 | set | `effective_caller_id_name=${outbound_caller_id_name}` | | True | | 90 | set | `effective_caller_id_number=${outbound_caller_id_number}` | | True | | 100 | set | `inherit_codec=true` | | True | | 110 | set | `ignore_display_updates=true` | | True | | 120 | set | `callee_id_number=$1` | | True | | 130 | set | `continue_on_fail=1,2,3,6,18,21,27,28,31,34,38,41,42,44,58,88,111,403,501,602,607,809` | | True | | 140 | set | `limit_max=5` | | True | | 150 | limit | `hash ${domain_name} outbound ${limit_max} !USER_BUSY` | | True | | 160 | set | `pin_number=` | | False | | 170 | lua | `pin_number.lua` | | False | | 180 | bridge | `sofia/gateway/telnyx/$1` | | True | The regex accepts `2352087013`, `12352087013`, and `+12352087013`; `$1` is the bare 10 digits, which Telnyx accepts on outbound. **This route also handles internal-origin forwards** — see §5.4. ### Companion direction-tagging entries The generator creates a `call_direction-outbound` entry at order 22 per destination pattern. These are **not duplicates** — each carries a different `destination_number` regex. Their regex must match the corresponding route's regex, or calls in the non-matching format skip direction tagging (affecting CDR direction and `user_record`). --- ## 5.3 `telnyx.forward` — external-origin forwards (order 99) This is the route that took the longest to get right. Every value below is transcribed from the working system. **Entry:** name `telnyx.forward`, context `voip.maxdevnet.cc`, **order `099`**, continue `false`, enabled true, description `Forwarded calls -> Telnyx`. **Conditions, group 0:** | Order | Type | Data | |---|---|---| | 10 | `${user_exists}` | `false` | | **15** | **`${forward_all_destination}`** | **`^\d+$`** | | 20 | `${toll_allow}` | `domestic` | | **25** | **`${sip_to_user}`** | **`^\d{10,11}$`** | | 30 | `destination_number` | `^\+?1?([2-9][0-9]{2}[2-9][0-9]{6})$` | **Actions, group 0:** | Order | Type | Data | Inline | Enabled | |---|---|---|---|---| | 40 | set | `sip_h_accountcode=${accountcode}` | | False | | 50 | export | `call_direction=outbound` | true | True | | 60 | unset | `call_timeout` | | True | | 70 | set | `hangup_after_bridge=true` | | True | | **80** | set | `effective_caller_id_name=${caller_id_number}` | **true** | True | | **90** | set | `effective_caller_id_number=${sip_to_user}` | | True | | **92** | set | `sip_from_user=${sip_to_user}` | **true** | True | | **94** | set | `sip_from_display=${outbound_caller_id_name}` | **true** | True | | 100 | set | `inherit_codec=true` | | True | | 110 | set | `ignore_display_updates=true` | | True | | 120 | set | `callee_id_number=$1` | | True | | 130 | set | `continue_on_fail=1,2,3,...` | | True | | 140 | set | `limit_max=` | | False | | 150 | limit | `hash ${domain_name} outbound ${limit_max} !USER_BUSY` | | False | | 160 | set | `pin_number=` | | False | | 170 | lua | `pin_number.lua` | | False | | 180 | bridge | `sofia/gateway/telnyx/$1` | | True | ### Why each piece exists **Condition 15 (`${forward_all_destination}`) is the forward marker.** Populated only on calls that traversed `call-forward-all`, and it survives the transfer. A normal outbound call from an extension has it empty, fails this condition, and falls through to `telnyx.11d` with its toll gate intact. > **Without condition 15, this route hijacks all outbound traffic** and every extension > gets a fast busy. First thing to check if outbound breaks after touching this route. **Condition 25 (`${sip_to_user}` 10–11 digits) splits external from internal origin.** On an external inbound call the To header is the DID. On an internal ext-to-ext call it's an extension number (4 digits), which fails this condition and falls through to `telnyx.11d`. Without it, internal forwards send an extension number as caller ID and get `403 Caller Origination Number is Invalid D35`. **`${sip_to_user}` is the dialed DID.** Sofia sets it directly from the inbound INVITE's To header, so no dialplan entry can clear it. Verified producing three different DIDs across three test calls: | Dialed DID | From presented | |---|---| | 12352087012 | 12352087012 | | 12352087013 | 12352087013 | | 12352087014 | 12352087014 | **Order 92 (`sip_from_user`) inline is what Telnyx actually validates.** This is the field that determines acceptance. ### Variables that do *not* work here — and why Documented so nobody retries them: | Variable | Fails because | |---|---| | `${outbound_caller_id_number}` | Holds the **caller's** number on a forwarded leg — stock `call-forward-all` sets it from `${caller_id_number}`. Produces D51. | | `${caller_destination}` | On the external path this is the **DID**, so `${user_data ${caller_destination}@...}` returns empty — the extension is `1001`. | | `${dialed_extension}` | Empty on this path; exported by `local_extension`, which never runs because `call-forward-all` breaks out first. | --- ## 5.4 Internal-origin forwards (ext → forwarded ext) When 1004 dials 1001 and 1001 has a forward armed, the call takes a different path: 1. `call_direction` is set to `local`, then `call-forward-all` **group 0** (`^local$`) matches 2. Group 0 exports `call_direction=outbound` and loads caller ID from the extension record 3. Transfer to the forward destination 4. `telnyx.forward` condition 25 fails (`sip_to_user` is `1001`, not 10–11 digits) 5. Falls through to `telnyx.11d`, which uses `${outbound_caller_id_number}` For step 2 to produce a usable number, **`call-forward-all` group 0 orders 15 and 20 must be enabled** and reference `${destination_number}` (which is `1001` at that point): ```sql SELECT dialplan_detail_group, dialplan_detail_order, dialplan_detail_data, dialplan_detail_inline, dialplan_detail_enabled FROM v_dialplan_details dt JOIN v_dialplans d USING (dialplan_uuid) WHERE d.dialplan_name = 'call-forward-all' ORDER BY dialplan_detail_group, dialplan_detail_order; ``` Group 0 target state: | Order | Data | Inline | Enabled | |---|---|---|---| | 15 | `outbound_caller_id_name=${user_data ${destination_number}@${domain_name} var outbound_caller_id_name}` | true | **true** | | 20 | `outbound_caller_id_number=${user_data ${destination_number}@${domain_name} var outbound_caller_id_number}` | true | **true** | | 25 | `accountcode=${user_data ${destination_number}@${domain_name} var accountcode}` | true | true | | 30 | `${forward_all_destination} XML ${domain_name}` | | true | > This is a **database edit to a stock entry**. Add it to the upgrade-hazard list (§9). --- ## 5.5 `telnyx.911` — emergency (order 100) **Entry:** name `telnyx.911`, context `voip.maxdevnet.cc`, order `100`, continue `false`, enabled true. **Conditions:** | Order | Type | Data | |---|---|---| | 10 | `${user_exists}` | `false` | | 20 | `destination_number` | `(^911$\|^933$)` | > **No `toll_allow` condition.** An extension restricted from all other dialing must still > reach emergency services. **Actions:** | Order | Type | Data | Inline | Enabled | |---|---|---|---|---| | 30 | set | `sip_h_accountcode=${accountcode}` | | False | | 40 | export | `call_direction=outbound` | true | True | | 50 | unset | `call_timeout` | | True | | 60 | set | `hangup_after_bridge=true` | | True | | 70 | set | `effective_caller_id_name=${emergency_caller_id_name}` | | True | | 80 | set | `effective_caller_id_number=${emergency_caller_id_number}` | | True | | 90 | set | `call_date=${strftime(%d-%b-%Y %r)}` | | False | | 100 | lua | `app.lua emergency 1` | | **← enable this** | | 110–140 | *(codec / display / callee / continue_on_fail)* | | True | | 190 | bridge | `sofia/gateway/telnyx/$1` | | True | Bridge uses bare `$1` — emergency numbers pass through unmodified, no `+1`. **Enable order 100 (`lua app.lua emergency 1`)** — fires an email when 911 is dialed. Recipients under Advanced → Default Settings, category `emergency`. Note it fires on 933 test calls too. **Per-extension fields are required:** ```sql UPDATE v_extensions SET emergency_caller_id_number = '+1235208701X', emergency_caller_id_name = '' WHERE extension = '' AND domain_uuid = (SELECT domain_uuid FROM v_domains WHERE domain_name='voip.maxdevnet.cc'); ``` Empty means `effective_caller_id_number` resolves to nothing and the PSAP has no ANI to dispatch on. --- ## 5.6 Extensions **Accounts → Extensions.** Fields that matter: | Field | Guidance | |---|---| | Password | Generate; regenerate as plain alphanumeric if a device gives repeated 401s | | **Outbound Caller ID Number** | **A DID you own.** Empty = outbound rejected. | | **Emergency Caller ID Number** | Per-extension, geographically correct. Required for E911. | | **Toll Allow** | `domestic`. **Empty = cannot dial out at all.** This is the toll-fraud boundary, not a bug. | | **CIDR** | `172.20.8.0/24`. Restricts where the extension may register from — a leaked SIP password becomes unusable from outside. | | **Limit Max** | `2`. Caps concurrent calls. | | Call Timeout | `30` | | Call Group | e.g. `Engineering` — enables group pickup | Current state: | Ext | Toll Allow | Outbound CID | |---|---|---| | 1001 | `domestic` | `+12352087012` | | 1002 | `domestic` | `+12352087013` | | 1003 | `domestic` | `+12352087014` | | 1004 | `domestic` | `+12352087015` | > **A 480 with no INVITE leaving the box, on one extension but not others, is almost > always an empty `toll_allow`.** ### Multiple devices per extension Registering two devices to the same extension gives simultaneous ring — first to answer wins. Both appear in `sofia status profile internal reg`; correct, not a fault. This is *not* Shared Line Appearance. **SLA is not achievable on FXS ports at all** — an analog port has a hook switch, a ringer, and a battery feed, with no channel to carry appearance state. --- # PART 6 — MAGIKARP FIREWALL ## 6.1 Defines ```nft define WAN_IF = enp16s0f0np0 define LAN_IF = enp16s0f1np1 define VLAN77_IF = enp16s0f1np1.77 define FISHFUSION = 172.20.8.5 define TELNYX_SIP = { 192.76.120.0/24, 64.16.248.0/23, 64.16.226.0-64.16.230.255, # ... remaining published signaling ranges } define TELNYX_MEDIA = { 50.114.144.0/21, 64.16.224.0/19, # ... remaining published media ranges } ``` ## 6.2 Prerouting (dstnat) ```nft iifname $WAN_IF ip saddr $TELNYX_SIP \ meta l4proto { tcp, udp } th dport { 5060, 5080 } \ counter dnat ip to $FISHFUSION iifname $WAN_IF ip saddr $TELNYX_MEDIA udp dport 16384-32768 \ counter dnat ip to $FISHFUSION ``` > **`tcp dport` alone silently drops everything.** Telnyx signals over UDP. A TCP-only > DNAT never matches, so the packet keeps its original destination, > `fib daddr type local` sends it to the **input** chain instead of forward, and it's > dropped by policy. This produced hours of "inbound calls don't arrive" with no counter > movement anywhere useful. > **Host entries vs prefixes.** A single-host entry (`192.76.120.14`) will not match a > neighbouring node (`192.76.120.10`). Use the published prefixes. ## 6.3 Forward chain ```nft iifname $WAN_IF oifname $VLAN77_IF ip saddr $TELNYX_SIP counter accept iifname $WAN_IF oifname $VLAN77_IF ip saddr $TELNYX_MEDIA counter accept iifname $WAN_IF oifname $VLAN77_IF ct status dnat counter accept ``` The `ct status dnat` catch-all mirrors the existing `$LAN_IF` rule and covers DNAT'd flows whose source wasn't anticipated. ## 6.4 Scanner suppression (recommended) Public 5060 attracts continuous scanning — observed probes from `51.178.198.251`, `165.232.117.238`, and others with `sip:chinamobile.com` style Request-URIs. Drop non-carrier SIP before conntrack allocates state: ```nft table inet raw { chain prerouting { type filter hook prerouting priority raw; policy accept; iifname $WAN_IF udp dport { 5060, 5080 } \ ip saddr != $SIP_TRUSTED counter drop } } ``` `$SIP_TRUSTED` must be a **single merged define** — nftables rejects nested sets inside an anonymous set (`!= { $A, $B }`). ## 6.5 Apply and verify ```bash nft -c -f /etc/nftables.conf # syntax check, no apply nft -f /etc/nftables.conf nft list ruleset -a | grep -iE 'telnyx|5080' ``` Place a call, re-check counters. **DNAT counter incrementing but forward counter flat** → filter chain. **Neither moving** → the match isn't hitting. ## 6.6 Flowtable and conntrack caveats Once an RTP flow offloads to the flowtable it bypasses the forward chain entirely. Counters undercount, and rules added later don't apply to flows already in the fast path. **When debugging audio, exempt VLAN 77 from the flowtable first.** A UDP conntrack entry created while a rule was broken keeps bypassing DNAT until it ages out: ```bash conntrack -D -p udp --dport 5080 conntrack -D -p udp --dport 5060 ``` --- # PART 7 — ENDPOINTS ## 7.1 Prerequisites **DNS on Magikarp:** ``` host-record=voip.maxdevnet.cc,172.20.8.5 ``` `host-record=`, not `address=` — the latter matches the entire subtree beneath a name. Do **not** create a public A record for `voip.maxdevnet.cc`; DNS-01 doesn't need it and a public registrar hostname attracts scanners. **DHCP option 66:** ``` dhcp-option=vlan77,66,"https://voip.maxdevnet.cc/app/provision/" ``` > **Watch the scheme.** A missing `//` causes phones to silently skip provisioning with > no log entry anywhere. This exact typo cost an evening. **Switch port:** the EX3300 has access ports in trunk mode (known audit finding). For a factory-fresh phone, set the port to **untagged VLAN 77** for initial provisioning, then return it to tagged once the phone has its config. Confirmed working workflow. ## 7.2 Enable provisioning **Advanced → Default Settings**, category `provision`: | Subcategory | Value | Enabled | |---|---|---| | `enabled` | `true` | **true** | | `admin_name` | `admin` | true | | `admin_password` | *(strong, unique — see below)* | true | | `cidr` | `172.20.8.0/24` | true | | `auto_insert_enabled` | | **false** | | `ntp_server_primary` | `ntp.socket.net` | true | | `ntp_server_secondary` | `216.106.44.17` | true | > **`admin_password` is written into every phone config as > `device.auth.localAdminPassword` and `device.auth.localUserPassword`, in cleartext.** > It must be unique to this purpose and must not match any switch, server, or network > credential. > **`auto_insert_enabled` must stay disabled** — enabled, any MAC that requests > provisioning gets a device record created. > **`cidr` is the highest-value control here.** The provisioning response contains SIP > extension passwords in cleartext. ## 7.3 What actually reaches the phone Counter-intuitive and cost significant time: | Source | Reaches the phone? | |---|---| | **Default Settings** (`provision` category) | **Yes** — via named `{$variable}` template slots | | **Template hardcodes** | **Yes** | | **Device Profile → Settings tab** | **No** — inert on stock templates | | **Device Profile → Keys** | Yes — line keys, BLF | | Device record → Device Lines | Yes — registration parameters | The stock Polycom templates substitute a **fixed list of named variables** with no passthrough loop over profile settings. > **This is a trap.** The `VVX-standard` profile's Settings table showing > `device.sntp.gmtOffset = -21600` looks authoritative and does nothing. Put a note in the > profile's Description field. Always verify against the rendered config: ```bash curl -s -u admin:'' \ "https://voip.maxdevnet.cc/app/provision/?mac=" | grep -i '' ``` Run from within the `cidr` range. ## 7.4 Timezone `polycom_gmt_offset` did **not** render despite a correct, enabled, text-typed row. Root cause never determined. Working fix — hardcode in the templates: ```bash cd /var/www/fusionpbx/resources/templates/provision/polycom for m in vvx311 vvx250 vvx450; do cp "$m/{\$mac}.cfg" "$m/{\$mac}.cfg.bak"; done sed -i 's/gmtOffset="{$polycom_gmt_offset}"/gmtOffset="-21600"/g' \ "vvx311/{\$mac}.cfg" "vvx250/{\$mac}.cfg" "vvx450/{\$mac}.cfg" ``` `-21600` is CST; the template's `daylightSavings.enable="1"` block adds the DST hour. Template location is `/var/www/fusionpbx/resources/templates/provision/`, **not** `/var/www/fusionpbx/app/provision/resources/templates/`. ## 7.5 Device records **Accounts → Devices → Add:** MAC lowercase unpunctuated (`64167f222921`), template `polycom/vvx311`, profile `VVX-standard`, enabled. Then **Accounts → Extensions → *ext* → Devices tab** → assign the MAC. That populates line 1 from the extension record including the password. > **Zero rows in `v_device_lines` means the phone provisions successfully and registers > nothing** — the config contains `` with an empty body. Phone shows the > model name instead of an extension. Common and confusing. The MAC column is `device_address`, not `device_mac_address`. ## 7.6 Adtran TA908e ``` voice trunk T01 type sip sip-server primary voip.maxdevnet.cc registrar primary voip.maxdevnet.cc domain "voip.maxdevnet.cc" register 1001 auth-name "1001" password register 1002 auth-name "1002" password register 1003 auth-name "1003" password register-expires 300 grammar request-uri host domain grammar from host domain grammar to host domain codec-list FUSION both dtmf-relay rtp-events ! voice user 1001 connect fxs 0/1 sip-identity 1001 T01 ! voice feature-mode network ip rtp symmetric-filter ``` > **`grammar request-uri host domain` and `grammar from host domain` are required.** > Without them the Adtran sends `sip:1001@172.20.8.5`, FreeSWITCH finds no domain named > by IP, and returns 403 — which looks exactly like a credential failure. > **No `register` keyword on `sip-identity`** — the trunk-level `register` lines handle > it; duplicating produces two registrations per AOR and a flapping contact. `show interface fxs 0/1` during an inbound call should show `Two-wire Status is: Ringing` with ring voltage (50 Vrms @ 20 Hz) — that confirms the port is driving the loop, not merely holding call state. --- # PART 8 — ACCEPTANCE TESTING Run in order; each gates the next. ## 8.1 Platform ```bash fs_cli -x "sofia status" ss -lunp | egrep '5060|5080' ``` - [ ] `internal` RUNNING, bound 172.20.8.5:5060 - [ ] `external` RUNNING, bound 172.20.8.5:5080, advertising WAN IP - [ ] Telnyx gateway `REGED`, Ping active ## 8.2 Registrations ```bash fs_cli -x "sofia status profile internal reg" ``` - [ ] Adtran extensions, contact 172.20.8.15 - [ ] VVX handsets on 172.20.8.x - [ ] All `Ping-Status: Reachable` ## 8.3 Inbound For each DID, call from an external phone. - [ ] Both devices on the extension ring - [ ] Caller ID displays with the DID prefix - [ ] Answer → two-way audio - [ ] Hold / resume → audio resumes ## 8.4 Outbound From **each** extension: - [ ] 10-digit dial completes - [ ] 11-digit dial completes - [ ] Correct per-extension DID at the far end - [ ] Two-way audio ## 8.5 Forwarding — both paths **External origin:** - [ ] Arm forward on 1001, verify in DB - [ ] Call `+12352087012` from an external phone - [ ] Cell rings; caller ID shows **12352087012** - [ ] Repeat with 1002/7013 → shows **12352087013** **Internal origin:** - [ ] From 1004, dial 1001 (forwarded) - [ ] Cell rings; caller ID shows **1001's DID**, not `1004` and not an extension number **Regression:** - [ ] Normal outbound from an extension still works (proves condition 15) ```sql SELECT caller_id_number, destination_number, hangup_cause, duration FROM v_xml_cdr ORDER BY start_stamp DESC LIMIT 10; ``` ## 8.6 Emergency - [ ] E911 provisioned per number on Telnyx - [ ] `emergency_caller_id_number` populated per extension - [ ] **933** readback returns the correct address - [ ] Emergency notification email received ## 8.7 Test tooling (no handset needed) ```bash # straight out the gateway with echo — proves signalling + both RTP directions fs_cli -x "originate {origination_caller_id_number=+12352087013}sofia/gateway/telnyx/15551234567 &echo" # through the dialplan fs_cli -x "originate {origination_caller_id_number=1002,toll_allow=domestic}loopback/15551234567/voip.maxdevnet.cc &echo" # force a Polycom to originate (auto-answer) fs_cli -x "originate {sip_auto_answer=true}user/1002@voip.maxdevnet.cc 15551234567 XML voip.maxdevnet.cc" ``` Mid-call RTP verification: ```bash fs_cli -x "show channels" fs_cli -x "uuid_dump " | grep -iE 'rtp_audio_in_packet_count|rtp_audio_out_packet_count' ``` Both climbing = two-way media. One at zero = one-way audio → check the Telnyx media ranges. ## 8.8 Snapshot ```bash sudo -u postgres pg_dump fusionpbx > /root/fusionpbx-verified-$(date +%Y%m%d-%H%M).sql ``` --- # PART 9 — BACKUP, RESTORE, UPGRADE HAZARDS ## 9.1 Backup ```bash sudo -u postgres pg_dump fusionpbx > /root/fusionpbx-$(date +%Y%m%d-%H%M).sql ``` **Take one before every configuration session.** The most useful backups during this build were the ones taken immediately before a change went wrong. Also back up (not in the database): ``` /etc/freeswitch/vars.xml /etc/nginx/sites-available/fusionpbx /etc/letsencrypt/ /var/www/fusionpbx/resources/templates/provision/polycom/vvx{311,250,450}/ ``` Cheap pre-change insurance for routing work: ```bash sudo -u postgres pg_dump fusionpbx -t v_dialplans -t v_dialplan_details \ > /root/dialplan-$(date +%Y%m%d-%H%M).sql ``` ## 9.2 Full restore ```bash sudo -u postgres pg_dump fusionpbx > /root/fusionpbx-broken-$(date +%Y%m%d-%H%M).sql systemctl stop freeswitch nginx php8.3-fpm sudo -u postgres dropdb fusionpbx sudo -u postgres createdb fusionpbx cp /root/.sql /tmp/restore.sql && chmod 644 /tmp/restore.sql sudo -u postgres psql fusionpbx < /tmp/restore.sql rm -f /var/cache/fusionpbx/* systemctl start php8.3-fpm nginx freeswitch ``` - `postgres` cannot read `/root` — copy to `/tmp` and `chmod 644` - `pg_restore` will **not** read a plain-SQL dump; use `psql` ## 9.3 Table-scoped restore (dialplan only) Far less disruptive when only routing has broken. No service stop needed. ```bash sed -n '/^COPY public\.v_dialplans /,/^\\\.$/p' /tmp/restore.sql > /tmp/dp.sql sed -n '/^COPY public\.v_dialplan_details /,/^\\\.$/p' /tmp/restore.sql >> /tmp/dp.sql sudo -u postgres psql fusionpbx -c "TRUNCATE v_dialplan_details, v_dialplans;" sudo -u postgres psql fusionpbx < /tmp/dp.sql rm -f /var/cache/fusionpbx/* systemctl restart freeswitch ``` > **Does not restore `v_extensions`.** Toll-allow and caller-ID changes survive and must > be checked separately. ## 9.4 Upgrade hazards These carry local edits and **will be overwritten by a FusionPBX upgrade**: | Item | Modification | |---|---| | `/etc/nginx/sites-available/fusionpbx` | IPv6 listeners commented; LE cert paths | | `polycom/vvx311/{$mac}.cfg` | `gmtOffset` hardcoded `-21600` | | `polycom/vvx250/{$mac}.cfg` | same | | `polycom/vvx450/{$mac}.cfg` | same | | `call-forward-all` group 0 (DB) | orders 15/20 enabled — see §5.4 | **Post-upgrade checklist:** 1. `nginx -t` — check cert paths and v6 listeners 2. Re-apply template gmtOffset edits 3. Sweep hostname columns (§1.3) — the upgrade may repopulate them 4. Re-check `call-forward-all` group 0 5. `rm -f /var/cache/fusionpbx/*` and restart 6. Run Part 8 **Upgrade-safe alternative for templates:** copy to `-max` variants and point device records at them. Custom directories aren't touched by upgrades. --- # APPENDIX A — TROUBLESHOOTING DECISION TREE | Symptom | First thing to check | |---|---| | Nothing works after a config change | §1.5 cache — flush **then** restart | | Setting correct in DB, no effect | §1.3 hostname → §1.4 enabled flag → §1.7 file shadow → §1.6 GUI-save | | Inbound gets 404 | §1.8 `dialplan_number` exact match — read the `single key:dialplan:public:` log line | | Inbound gets 407 | `providers` ACL empty or missing the source | | Inbound INVITE never reaches Fishfusion | Firewall — packet on WAN with no `Out` on VLAN = DNAT didn't match. Check `meta l4proto` and the saddr set | | Outbound gets 480, no INVITE leaves | Empty `toll_allow` on the extension | | Outbound 403 D51 (Unverified origination) | From URI user part isn't a number you own — check `sip_from_user` **and** its inline flag | | Outbound 403 D35 (Caller Origination Invalid) | An extension number reached the From — `telnyx.forward` condition 25 missing | | Forward loops → 483 Too Many Hops | No route caught the forwarded leg. Check `telnyx.forward` order 99, condition 15, and that `telnyx.11d` still has `${user_exists} false` | | Forward presents wrong number | Order collision, inline missing, or stale config — use the Action-vs-EXECUTE comparison (§1.2) | | Phone provisions but doesn't register | No `v_device_lines` rows | | Phone won't provision at all | `provision.enabled` flag → `cidr` scope → DHCP option 66 syntax → cert validity | | Registration stuck in TRYING | Realm mismatch — no 401/403 is ever seen because no valid digest is produced | ## Standard capture ```bash fs_cli -x "console loglevel debug" timeout 60 tail -f /var/log/freeswitch/freeswitch.log > /tmp/t.log # ... place the call, LET THE TIMEOUT EXPIRE (Ctrl-C truncates) ... grep -E 'Processing|parsing \[voip|Regex \(|Action set\(|EXECUTE.*set\(|bridge\(|Hangup' /tmp/t.log ``` ```bash # what variables actually exist on a live channel — ends guessing fs_cli -x "show channels" fs_cli -x "uuid_dump " | grep -iE 'sip_to_user|sip_req_user|caller_destination|toll_allow' ``` > `xml_locate dialplan context name ` returns nothing useful on this install — the > handler serves per-destination lookups. Don't draw conclusions from an empty result. ## Method The recurring pattern in this build was chasing inference from packet captures when the dialplan log had the literal answer. The order that works: 1. **`uuid_dump` / debug log** — what does FreeSWITCH think is true? 2. **`Action` vs `EXECUTE`** — is the config current, or is the variable wrong? 3. **SIP trace** — only once you know the box is running what you think it is. Reversing that order costs hours. --- # APPENDIX B — SCHEMA COLUMN NAMES FusionPBX naming is inconsistent. **Always run `\d ` before writing an INSERT.** | Table | Notable columns | |---|---| | `v_devices` | `device_address` (the MAC), `device_template`, `device_profile_uuid` | | `v_device_lines` | `device_line_uuid`, `line_number`, `server_address`, `user_id`, `auth_id`, `password`, `enabled` | | `v_device_profile_settings` | `profile_setting_name`, `profile_setting_value`, `profile_setting_enabled` (only the PK uses the long prefix) | | `v_dialplans` | `dialplan_name`, `dialplan_number`, `dialplan_context`, `dialplan_order`, `dialplan_continue`, `dialplan_enabled`, `app_uuid` | | `v_dialplan_details` | `dialplan_detail_tag`, `dialplan_detail_type`, `dialplan_detail_data`, `dialplan_detail_inline`, `dialplan_detail_group`, `dialplan_detail_order`, `dialplan_detail_enabled` | | `v_sip_profiles` | `sip_profile_hostname` | | `v_default_settings` | `default_setting_name` is the **type** field (text/numeric/boolean/dir/array) | | `v_extensions` | `toll_allow`, `outbound_caller_id_number`, `forward_all_enabled`, `forward_all_destination`, `enabled` | | `v_gateways` | `gateway`, `username`, `password`, `proxy`, `realm`, `from_domain`, `register`, `profile`, `enabled` | --- # APPENDIX C — CREDENTIAL INVENTORY | Credential | Where | Exposure | |---|---|---| | FusionPBX GUI admin | `v_users` | — | | Telnyx gateway password | `v_gateways.password` | **Cleartext in `xml_locate configuration ... sofia.conf` output** | | Extension SIP passwords | `v_extensions.password` | **Cleartext in every provisioning response** | | Provisioning HTTP auth | `provision.admin_name` / `admin_password` | Also written to phones as local admin/user password | | Adtran enable/root | device config | Cleartext in `show run` | | Cloudflare API token | `/etc/letsencrypt/cloudflare.ini` | `chmod 600` | **Rules:** 1. **The provisioning password must be unique to provisioning.** Reusing a switch or server credential means anyone who reaches the provisioning endpoint owns that device too. 2. **Scope `provision.cidr` tightly** — it's the only thing between a network foothold and a working SIP credential that can originate toll calls. 3. **Set per-extension CIDR.** 4. **Keep `Limit Max` low** on extensions and outbound routes. 5. **International outbound stays disabled** until specifically needed. --- # APPENDIX D — OUT OF SCOPE / FUTURE WORK - Telnyx **media** ranges in nftables — RTP currently works by riding the signaling conntrack entry, which is fragile. Highest-priority remaining item. - Ring groups, IVR, time conditions - Shared Line Appearance on the VVXs (`mod_sla`, separate SIP profile) - Multi-site: E911 address is **per-DID**. When phones exist at Jeff/Linda/Melody sites over the mesh, each site needs its own DID + address, and each site's extensions need emergency CID pinned to *their site's* DID. - Custom provisioning template with a profile-settings passthrough loop, if per-group parameter variation becomes necessary - SIP over TLS (5061) + SRTP --- *End of document.*