diff --git a/docs/anveo-direct-setup-guide.md b/docs/anveo-direct-setup-guide.md index 967a6f9..ad28055 100644 --- a/docs/anveo-direct-setup-guide.md +++ b/docs/anveo-direct-setup-guide.md @@ -2,9 +2,14 @@ This is the exact sequence that got a real Anveo Direct DID working end to end (both outbound and inbound) with `asterisk-digital-ocean.sh` + -`pstn-trunk.sh`, confirmed live on a real droplet. Follow it in order for -each additional number — steps 1–2 are one-time account setup; steps -3–7 repeat per DID. +`pstn-trunk.sh`, confirmed live on a real droplet. + +**Steps 1, 3 and 4 are one-time account setup** — the outbound Service +Trunk (step 3) and the inbound SIP Trunk (step 4) each cover every DID on +the account, no matter how many you add. **Only steps 2, 4.5, 6 and 7 +repeat per number**: order the DID, point it at the existing inbound trunk +(skippable entirely if you set the account default in step 4.6), assign it +in the dashboard, and test. ## 0. Prerequisites @@ -91,7 +96,7 @@ catch-all entry (prefix `1`); a few specific area codes have their own slightly different override rates, and Virgin Islands is billed separately/higher (~$0.02/min) despite looking like a normal US number. -## 4. Create the SIP Trunk (inbound forwarding) — once per DID +## 4. Create the SIP Trunk (inbound forwarding) — ONE covers every DID Anveo has **two unrelated "trunk" concepts** — don't confuse them: - The **Outbound Service Trunk** from step 3 handles calls *out*. @@ -99,11 +104,20 @@ Anveo has **two unrelated "trunk" concepts** — don't confuse them: routing objects live) handles calls *in* — this is what actually populates a DID's "Destination SIP Trunk" dropdown. -For **each DID**, create one of these: +**You only need ONE of these for the whole account**, no matter how many +DIDs you have. The `$[E164]$` placeholder in step 2 is what makes it +reusable — Anveo substitutes each DID's own number at call time, so the +same trunk object forwards `15551111111@IP:5060` for one DID and +`15552222222@IP:5060` for another. The Asterisk side matches whatever +arrives (`_X.` in `[from-pstn-trunk]`) and looks it up in +`pstn-personal-dids.conf` to decide who rings. Creating one per DID is +possible but pointless unless different DIDs need to forward to genuinely +different destinations. -1. **Trunk Name**: any label (e.g. `asterisk-do-inbound`) — can reuse the - same one for multiple DIDs if you want them all forwarding the same - way, or make one per DID for clarity. +Create it once (steps 1–4), then per DID only step 5 applies — or set the +account default in step 6 and skip even that: + +1. **Trunk Name**: any label (e.g. `asterisk-do-inbound`). 2. **Primary**: type **SIP URI**, value: ``` $[E164]$@:5060 @@ -115,12 +129,13 @@ For **each DID**, create one of these: in the actual INVITE Anveo sends. 3. **Failover**: leave blank. 4. Save. -5. Go to the DID's own **Call Options** tab → set **Destination SIP - Trunk** to this new SIP Trunk object (not the Outbound Service Trunk) +5. **Per DID**: go to that DID's **Call Options** tab → set **Destination + SIP Trunk** to this SIP Trunk object (not the Outbound Service Trunk) → Save. -6. **Optional, recommended once you're adding more DIDs**: Account +6. **Recommended if you have more than one or two DIDs**: Account Options → Service Defaults → set **Default Destination Trunk** to this - SIP Trunk, so future DIDs auto-route here without repeating step 5. + SIP Trunk. New DIDs then auto-route here and step 5 becomes unnecessary + entirely. ## 5. Configure the droplet @@ -216,13 +231,56 @@ installer, not configuration mistakes — listed here for context on what under `setup.sh`'s `set -u` on `source`. Now guarded and falls back to safe defaults, erroring clearly only if genuinely required fields are missing. +- **The inbound `[from-pstn-trunk]` context never loaded at all** — it used + to share one `#include`'d file with the outbound dialplan, where the file + continued `[intercom]` first and only declared `[from-pstn-trunk]` + partway through. Asterisk silently failed to load the *entire* file + (outbound patterns included) with no error anywhere, so every inbound + call got "extension not found in context 'from-pstn-trunk'" and an + immediate SIP rejection. Inbound now lives in its own file that starts + with its context header, matching the structure of the messaging + dialplan that always loaded correctly. +- **Every inbound call's Caller-ID arrived as the DID itself**, not the + real caller — the trunk endpoint pinned a static `callerid=` and + didn't set `trust_id_inbound`, so Asterisk ignored the identity Anveo + actually sends and fell back to that default. Every restricted-tier + `allowed_numbers` check was therefore unwinnable regardless of config. + Fixed by dropping the static default (redundant — the outbound dialplan + sets `CALLERID(num)` on the channel itself) and adding + `trust_id_inbound=yes`. +- **A leading `+` broke whitelist matching** — once the fix above started + surfacing real caller identity, Anveo delivers it `+E.164` style + (`+15551234567`). The normalizer only added a leading `1` to 10-digit + values and never stripped the `+`, so a 12-character value could never + match an 11-digit, digits-only `allowed_numbers` entry no matter how + correctly the number was whitelisted. The `+` is now stripped in the + dialplan, the group-ring script, and the dashboard's admin-input side + (so pasting a number straight from a call log works too). +- **Bare `Hangup()` on inbound denials** instead of `Busy(15)` — three + inbound denial paths didn't produce a proper "486 Busy Here", making a + correct permission denial sound like a generic call failure and + disguising config mismatches as dialplan bugs. + +## Dashboard changes may need "Commit Changes" + +Confirmed live: edits made in the Security Dashboard's PSTN Trunk tab +(permission tiers, groups, personal-DID owners, limits) are written to disk +immediately, but `AST_CONFIG()` in the dialplan has been observed still +returning the *old* value until the Asterisk container is fully restarted — +a `dialplan reload` / `module reload` is not enough. This contradicts the +"reads fresh on every call, no restart needed" design premise these files +otherwise rely on; root cause not established. + +The PSTN Trunk tab has a **Commit Changes (Restart Asterisk)** button for +this, which appears after any save on that tab and warns if you navigate +away with changes uncommitted. Existing calls are unaffected by the +restart. If a change doesn't seem to be taking effect on a test call, press +it before assuming the config itself is wrong. ## Still open -- The 2-phone-number account cap (see step 1) blocks getting all 4 - planned numbers until either Anveo lifts it or a tax ID is provided — - tabled for now. -- The interactive CLI walkthrough (`pstn-trunk.sh` actually prompting - through account setup step by step, not just this static doc) hasn't - been built yet — this guide is the reference for building that once - there's appetite for it. +- Nothing blocking. The two items previously listed here are both resolved: + the 2-phone-number account cap no longer applies (additional DIDs have + since been obtained), and the interactive CLI walkthrough was built — + see `_pstn_anveo_walkthrough` in `services/pstn-trunk.sh`, reachable from + the provider quick-pick when you choose Anveo Direct. diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 1e6c735..2d36529 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -583,30 +583,30 @@ __ALERT_KILLED_IN_LINE__ ; live config files and applies the identical tier/allowed_numbers logic ; in a plain shell loop. ; -; Every OTHER denial path in this file (outbound tier/number/intl/killswitch, -; the shared ring-group's pstn_in_denied, this owner's own denial just -; above) plays a proper Busy(15) tone before Hangup() — pstn_in_denied and -; the personal-DID denial paths were missing that Busy() and fell straight -; to a bare Hangup() on an unanswered channel instead, which chan_pjsip -; does NOT map to "486 Busy Here" the way Busy() does. NOT yet confirmed -; live against a real call, but this is a real, reproducible inconsistency -; (grep this file for "Hangup()" without a preceding Busy() and these three -; exits were the only inbound ones missing it) and is a plausible cause of -; a caller hearing one ring and then an ambiguous fast-busy/call-failed -; tone on what looks like a correctly-authorized group member: if the -; group/tier/allowed_numbers config has ANY mismatch against the test -; caller ID, the call was always going to be denied here regardless of -; this fix — the missing Busy() just made a config problem sound like a -; dialplan problem. Fixed below (Busy(15) added to all three inbound -; denial exits). If a group-assigned personal number still goes straight -; to busy after this, the NoOp() logged right after the SHELL() call in -; pstn_personal_group_ring prints the resolved group name, caller ID, and -; PSTN_RING_LIST — check `asterisk -rx "core show channels verbose"` / the -; full log for that line first; an empty list there means the config (not -; the dialplan) is the next thing to check — confirm the calling number is -; in allowed_numbers for at least one CURRENT group member whose tier is +; Every denial path in this file (outbound tier/number/intl/killswitch, the +; shared ring-group's pstn_in_denied, the personal-DID owner and group +; denials) plays a proper Busy(15) tone before Hangup(). pstn_in_denied and +; the two personal-DID denial paths used to fall straight to a bare +; Hangup() on an unanswered channel instead, which chan_pjsip does NOT map +; to "486 Busy Here" the way Busy() does — making a config mismatch sound +; like a dialplan failure. Confirmed live (2026-07-24): a group-owned +; personal DID is now fully working end to end, ringing every authorized +; member simultaneously, after this plus three other fixes found in the +; same session (see _pstn_write_inbound_dialplan_include's file-split +; comment, and _pstn_write_pjsip_include's trust_id_inbound/callerid +; comment). +; +; If a group-assigned personal number goes straight to busy, the NoOp() +; logged right after the SHELL() call in pstn_personal_group_ring prints +; the resolved group name, caller ID, and PSTN_RING_LIST — check the +; Asterisk console/full log for that line first. An empty list there means +; the config, not the dialplan: confirm the calling number is in +; allowed_numbers for at least one CURRENT group member whose tier is ; "restricted" or "full" in pstn-permissions.conf, and that the group's -; members= line in pstn-groups.conf actually lists that extension. +; members= line in pstn-groups.conf actually lists that extension. Note +; that a dashboard edit may need the "Commit Changes" button (a container +; restart — see restart_asterisk_container() in services/security-dashboard.sh) +; before AST_CONFIG() actually returns the new value. exten => pstn_personal_inbound,1,GotoIf($["${PSTN_PERSONAL_OWNER:0:1}" = "@"]?pstn_personal_group_ring,1) same => n,Set(PSTN_OWNER_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_PERSONAL_OWNER},tier)}) same => n,GotoIf($["${PSTN_OWNER_TIER}" = "full"]?pstn_personal_ring,1) @@ -672,6 +672,10 @@ EOF # Safe REGEX direction: $allowed is admin-entered (pstn-permissions.conf), # $caller is the incoming Caller-ID — pattern is always the admin data, # string is always the caller-controlled data, never the reverse. +# Confirmed live (2026-07-24): SHELL() invoking this script from the +# dialplan works end to end against a real inbound call — the returned +# &-joined list rings every authorized member simultaneously, first to +# answer wins, and unauthorized members are correctly skipped. _pstn_write_personal_group_ring_script() { local FILE="$1" cat > "$FILE" << 'SCRIPT' diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 186219d..0438726 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -458,15 +458,20 @@ _secdash_write_sudoers() { # `docker exec -i tee ` instead of a direct # host-side file write (see _secdash_grant_asterisk_access's comment on # why), plus the two Asterisk CLI calls needed after a change and the - # live registration-status check. All six are exact commands, no + # live registration-status check. All seven are exact commands, no # wildcards, scoped to the one container actually installed on this box. + # The last line (docker restart) backs the PSTN Trunk tab's "Commit + # Changes" button — see restart_asterisk_container()'s comment for why + # that exists (AST_CONFIG() live-reads not always picking up dashboard + # edits without a full container restart). if [ -n "$_ea_container" ]; then _ea_lines="$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec -i $_ea_container tee /etc/asterisk/pjsip.conf $_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec -i $_ea_container tee /etc/easy-asterisk/categories.conf $_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec -i $_ea_container tee /etc/easy-asterisk/rooms.conf $_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container asterisk -rx module\ reload\ res_pjsip.so $_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container asterisk -rx pjsip\ show\ endpoints -$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container /usr/local/bin/easy-asterisk --rebuild-dialplan" +$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container /usr/local/bin/easy-asterisk --rebuild-dialplan +$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker restart $_ea_container" fi cat > /etc/sudoers.d/security-dashboard << SUDOERS $_svc_user ALL=(root) NOPASSWD: /usr/bin/cscli decisions delete --id [0-9]* @@ -1644,6 +1649,27 @@ def ea_rebuild_dialplan(): run_sudo(["docker", "exec", ASTERISK_EA_CONTAINER, "/usr/local/bin/easy-asterisk", "--rebuild-dialplan"]) +def restart_asterisk_container(): + """Restarts the Easy Asterisk container - the "Commit Changes" button on + the PSTN Trunk tab. Confirmed live: dashboard writes to + pstn-permissions.conf/pstn-groups.conf/pstn-personal-dids.conf land on + disk immediately (readable via a plain `cat` right after saving), but + AST_CONFIG() in the dialplan sometimes kept returning a stale value + until the container was fully restarted - not just a `dialplan reload` + or `module reload`, an actual container restart. Root cause not fully + understood (contradicts AST_CONFIG's whole "reads fresh every call, no + restart needed" design premise, which this codebase otherwise relies on + throughout), but the restart reliably clears it, so this button exists + instead of requiring every admin to rediscover "just restart it" the + hard way. Uses the same ASTERISK_EA_CONTAINER/run_sudo mechanism as the + Easy Asterisk Admin tab's own docker exec calls - no new sudoers scope + needed beyond the one line added for this.""" + if not ASTERISK_EA_CONTAINER: + return False, "No Asterisk container detected on this box" + ok, _out, err = run_sudo(["docker", "restart", ASTERISK_EA_CONTAINER], timeout=30) + return ok, ("" if ok else (err or "Restart failed")) + + def ea_get_status(): """Registered/unregistered per extension — same 'pjsip show endpoints' parsing as the vendored get_registered_endpoints().""" @@ -2311,9 +2337,17 @@ INDEX_HTML = """