Confirmed live: install_frigate()'s fresh-install path overwrote a
working, hand-crafted docker-compose.yml (Frigate + mosquitto +
frigate-notify) with zero backup, because that file's shape didn't match
what frigate.sh's own "existing install" detection knew how to recognize.
Every service's own detection is a judgment call about what counts as
"already installed" and can miss a real setup built outside this repo's
conventions.
lib/common.sh gains backup_if_exists(FILE) — copies FILE to
FILE.bak.<timestamp> if it exists, no-ops otherwise (including DRY_RUN).
Applied before every service's own `cat > docker-compose.yml`/`cat > .env`
write across all 60 services that do one (115 call sites), plus a matching
standalone-mode stub added to every service's own bootstrap block, same
convention already used for port_in_use/find_free_port. This doesn't
replace a service's own update/fresh-reinstall detection — it's the safety
net underneath it, so a wrong detection costs a .bak file to restore from
instead of the original silently disappearing.
Also fixes the actual gap that surfaced this: services/frigate.sh's
Authelia offer only checked for Authelia installed locally on Frigate's
own box, which is never true for a dedicated NVR box with no local Caddy
either (the common shape — Caddy lives elsewhere, snippet-generation mode
already handles that). Now offers Authelia protection unconditionally and,
when Authelia isn't local, asks whether it lives on the same machine as
Caddy (still "import authelia", since that's local to wherever Caddy ends
up) or on a genuinely separate third machine (the explicit
header-pinned forward_auth form, per CLAUDE.md's "forward_auth to a remote
Authelia" note, needed because a bare authelia:9091 shortcut only works
one hop).
New installs now name the container "asterisk", matching every other
service's container_name == service name convention, instead of
reusing the vendored easy-asterisk CLI tool's own name (which stays
/usr/local/bin/easy-asterisk inside the container, unrelated and
unchanged).
An existing "easy-asterisk" install is never silently renamed: every
place that resolves the container name (_asterisk_resolve_layout in
asterisk.sh, plus the duplicated copies in security-dashboard.sh,
sms-inbound.sh, pstn-trunk.sh, and tools/pstn-test-check.sh's docker ps
detection) now reads it from the box's own docker-compose.yml instead
of assuming it, falling back to "asterisk" only when there's no
existing install to read. Migrating a live box to the new name is a
one-time manual action (edit docker-compose.yml's container_name for
Asterisk and its coturn sidecar, docker compose down + up -d); every
sibling service then picks it up automatically on its next run.
The DigitalOcean-droplet layout (asterisk-digital-ocean directory,
easy-asterisk-do container) is untouched by this - that naming stays
exactly as documented for pre-merge droplet installs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc
CONTAINER_NAME was correctly re-detected from the current layout
(ASTERISK_KIND) at the top of install_pstn-trunk(), then immediately
clobbered by sourcing the saved .pstn-trunk.env settings file — and
_pstn_apply_settings writes CONTAINER_NAME straight back into that
same file every run, so a stale value self-perpetuates forever once
it's wrong.
Confirmed live: a box migrated from a DigitalOcean droplet (where the
settings file correctly saved CONTAINER_NAME=easy-asterisk-do) to a
plain/home install kept that droplet-era value indefinitely, since
only "update" runs happened after the migration. Every update
reloaded/restarted a container that no longer existed instead of the
one actually running Asterisk, so dialplan changes (including
pstn-trunk-inbound-dialplan.conf) never reached the live process even
though the config files themselves were written correctly.
Re-asserts the freshly-detected value after the source instead of
trusting whatever was saved, so it can't drift from the box's actual
current layout and self-heals the persisted file on the next update.
pstn_personal_ring dialed the owner and hung up regardless of
DIALSTATUS, so no-answer/busy calls to a personal DID just dropped
silently instead of offering voicemail. Now falls to
VoiceMail(<owner>@default,u) on anything but ANSWER, gated by the
owner's existing voicemail=yes/no flag in pstn-permissions.conf.
The shared ring-group and group-owned personal DID inbound paths have
the same gap but no single owning extension to pick a mailbox for —
left as-is pending a decision on what that should do.
Surfaces pstn-trunk.sh's existing pstn-trunk-calls.log (already recording
every PSTN call's numbers, just never shown on the dashboard) plus two new
metadata-only logs: sip-messages.log for internal SIP MESSAGE
deliveries/denials (asterisk.sh) and pstn-sms.log for SMS-over-SIP arrivals
(pstn-trunk.sh). No message bodies are ever logged. The dashboard reads all
three via a new /api/pstn-calls and /api/comms-texts pair, sharing a common
bounded tail helper with the Security Log parser.
services/sms-inbound.sh was rebuilt to deliver texts into Asterisk over AMI,
landing in the softphone's own thread, and ntfy was dropped from that path
entirely. Two pieces of prose written against the earlier design survived and
now contradict the working implementation:
- docs/anveo-direct-setup-guide.md still ended its "native Messages app"
section with "Codes arrive as ntfy push notifications instead". The point
of that section — no SIP client can write into Android Messages or iOS
Messages — is unchanged, but the place texts actually land is Sipnetic's
message thread.
- services/pstn-trunk.sh's generated README claimed inbound SMS "doesn't
touch Asterisk at all" and set up ntfy notifications. It is now the exact
opposite: sms-inbound reads this service's pstn-personal-dids.conf and
pstn-groups.conf to resolve DID ownership, the same files the inbound-voice
ring logic uses, which also makes install order matter — noted there now.
Documentation only; no behaviour change, and services/sms-inbound.sh is not
touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
missing SMS-URI print on update
Two real bugs from the previous commit, both confirmed live on an
actual box where inbound calling had been working:
1. Interleaving the new #include between the two existing trunk
includes broke 'from-pstn-trunk' loading entirely (confirmed via
the box's own dialplan-reload verification check going from
passing to failing on the exact run that added the new include).
Root cause of the underlying quirk still isn't understood — same
as the original single-file-structure bug this file's own comments
already document — so the fix is defensive rather than diagnosed:
append the new include at the very end of the file instead of
between two already-working ones, minimizing disturbance to
whatever ordering/adjacency that loader is sensitive to.
2. When the same verification check fails, _pstn_ensure_live_includes
now automatically falls back to a full container restart and
re-checks, instead of just warning and leaving inbound calls
broken until the operator finds this on their own — confirmed live
this session that a restart (not just dialplan reload) reliably
fixes a context that's genuinely new to the running process.
3. The SMS-over-SIP URI printout was appended only to
install_pstn-trunk's fresh-install tail, which "update in place"
never reaches (it returns early). Factored into
_pstn_print_sms_over_sip_info and called from both paths, so it
actually shows up on the far more common update-in-place run.
Wires message_context=pstn-sms-inbound onto the trunk endpoint (same
pattern services/asterisk.sh already uses for internal SIP MESSAGE
texting — a dedicated context so an inbound MESSAGE doesn't fall back
to context=from-pstn-trunk, which already owns "_X." for INVITE-based
calls) and writes a new [pstn-sms-inbound] dialplan context.
That context deliberately only logs every plausible source for the
sender number, destination DID, and body (X-ANVEO-SMS-FROM/TO
headers, MESSAGE(from)/(to)/(body)) rather than attempting to parse
and route immediately — Anveo's own documentation for this feature
has already proven unreliable twice this session (a dead SMSbySMS
endpoint, and three mutually-contradicting docs pages for the general
API), so this waits for a real test message to confirm which fields
actually show up before building extraction/routing logic against
them.
The install summary now reads DOMAIN_NAME straight from Easy
Asterisk's own .env (the same FQDN already serving SIP for the voice
trunk) and prints the exact `smshandler@<domain>:5060` URI to paste
into the DID's SMS-over-SIP settings, plus how to watch for the first
test message in the Asterisk console.
PJSIP's From-header username comes exclusively from the endpoint's
static from_user — it never falls back to the channel's CALLERID(num).
With from_user unset, Asterisk fell back to its own hardcoded default
("asterisk"), which the provider doesn't recognize as an account DID
and rejected outright: every outbound call went straight to busy with
the callee never ringing.
Restore the static from_user (required for the provider to accept the
call at all) and instead relay the per-call CALLERID(num) — personal_did
or the shared DID — via send_pai/send_rpid, which populate the
P-Asserted-Identity/Remote-Party-ID headers from the live channel
Caller-ID independent of from_user. This is the standard mechanism for
presenting a per-call Caller-ID over a trunk with one fixed From user.
The pjsip endpoint pinned a static from_user to the shared trunk DID,
which silently wins over CALLERID(num) for the SIP From header on
outbound calls. The dialplan's per-extension personal_did lookup
still set CALLERID(num) correctly, but every call still went out
showing the same DID regardless. Leaving from_user unset lets PJSIP
fall back to the channel's caller ID number, mirroring the inbound
side's existing no-static-callerid fix.
The behaviour was right but the vocabulary wasn't: the previous commit
replaced full/restricted/internal with a new none/open/out/in/both naming,
when the ask was to extend the existing tier dropdown rather than supplant it.
The dropdown now reads: full, restricted (both ways), restricted incoming,
restricted outgoing, internal. The stored values match — internal,
restricted, full, restricted-in, restricted-out — so the original three keep
their own names in the config file and migrating a legacy install is now
near-identity for them (tier=full becomes restrict=full, tier=restricted
becomes restrict=restricted, which is what that tier already meant).
restricted incoming = dials anywhere, only whitelisted numbers get through.
restricted outgoing = anyone can call in, may only dial the whitelist. Named
in parallel rather than as "full incoming", so the two sit next to each other
in the list without needing the parenthetical to tell them apart.
No behavioural change: the derived tier_out/allowed_out/tier_in/allowed_in
the dialplan reads are unchanged, so the dialplan itself is untouched.
Verified: legacy migration mapping the original tiers to their same-named
modes; both new modes round-tripping through the dashboard and compiling to
the right derived keys (restricted-in sets allowed_in only, restricted-out
sets allowed_out only); whitelist disabled on full and internal; and the
group-ring helper still ringing restricted-incoming only for a whitelisted
caller while restricted-outgoing rings for everyone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
The previous commit gave each extension two independent lists — numbers it
may dial and caller IDs that may reach it. That was more than asked for: the
whitelist is one set of numbers per extension, and what varies is which
direction(s) it constrains.
pstn-permissions.conf now has an authored pair, 'restrict' and
'allowed_numbers', where restrict is one of:
none no PSTN at all open unrestricted both ways
out may only dial the list in may only be called by the list
both the list applies both ways
tier_out/allowed_out/tier_in/allowed_in are now derived from that pair rather
than authored directly, and remain what the dialplan reads — so the dialplan
is unchanged from the previous commit and stays tested. 'tier' still mirrors
tier_out for rollback. Keeping the compiled keys means the file has one place
a human edits and one place Asterisk reads, which is the same authored/
compiled split a named-number-list feature would need later.
The migration handles both prior shapes: a genuinely legacy single-tier file
(full becomes open, restricted becomes both — reproducing what the old
dialplan did), and the short-lived two-list shape from the previous commit
(inferred back to a mode, preferring the more restrictive reading). Still
idempotent, still backs up first.
The dashboard drops from two dropdowns and two fields to one of each, with
the whitelist greyed out for the modes that don't use one, and the PSTN
column sorting by how much reach a mode grants rather than alphabetically.
Verified: migration from both shapes; the dashboard round-trip writing
restrict=in with the list compiled to allowed_in only; and the group-ring
helper across all four modes — Restrict inbound rings only for a whitelisted
caller, Restrict outbound rings for everyone, Restrict both rings only for
its own list, No PSTN never rings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
One tier and one allowed_numbers list governed both directions, with the same
list matched against dialled numbers going out and caller IDs coming in.
Those are different sets, so "dial anyone but only accept calls from a short
list" — and its reverse — were not expressible at all.
pstn-permissions.conf now carries tier_out/allowed_out and tier_in/allowed_in.
The dialplan reads them in the four places that gate a call: the outbound
NANP pattern, the international leg, each ring-group member block, and
personal-DID inbound routing — plus the group-ring shell helper. Internal
extension-to-extension calling and ring groups remain ungated by either, as
before.
Existing installs are migrated in place by _pstn_migrate_permissions_split,
which copies the old single tier into both directions (reproducing the box's
current behaviour exactly), backs the file up first, and is idempotent. It
runs alongside the dialplan write rather than after a reload, since the new
dialplan reading un-migrated keys would fail closed and deny every call.
tier/allowed_numbers keep being written as a mirror of the outbound values so
that rolling back to a pre-split pstn-trunk.sh degrades to the old semantics
instead of breaking.
The dashboard's Extensions table gains Outbound/Can-dial and Inbound/Can-be-
called-by columns, each number field enabled only by its own tier, tier
columns sorting by permissiveness rather than alphabetically, and a save that
posts the whole permission record so an unchanged direction isn't reset.
Verified: the migration on a hand-written config (idempotent on rerun,
messaging and personal_did preserved); generated dialplan and group-ring
helper reading the split keys; and the group-ring helper resolving a real
three-extension case — out=full/in=restricted rings only for a whitelisted
caller, out=restricted/in=full rings for everyone, in=internal never rings —
plus the browser round-trip writing tier_out=full with tier_in=restricted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
New service for one narrow job — getting SMS verification codes sent to a
VoIP number onto a phone with no SIM. Deliberately not a texting app: no
outbound path (Anveo Direct has none; that needs an Anveo Retail account, and
a free texting app covers sending), and messages arrive as push notifications
rather than being routed into Asterisk as SIP MESSAGE, since a code you read
and type is better served by a notification than a softphone chat thread.
Two modes, both driven entirely from the provider's "forward SMS to URL" box:
- direct — the provider calls ntfy itself; nothing installed here. ntfy
accepts GET publishing at /{topic}/(publish|send|trigger) with message and
title as query params, and auth via ?auth= holding base64url (unpadded) of
the literal "Bearer <token>" — confirmed against ntfy's server.go and
server_auth.go rather than its docs.
- relay — a stdlib systemd service, Caddy-fronted on its own domain with no
Authelia (the provider can't log in; a random 32-char token in the path is
the secret). Buys two things direct mode can't have: an unescaped "&" in a
message body survives intact, because the relay takes everything after the
last message= verbatim instead of parse_qs — which is why the generated URL
always puts the message placeholder last — and no ntfy credentials sit in a
third party's web portal.
Verification codes are bearer credentials, so: a 24-char random topic name
(the repo's ntfy defaults to auth-default-access: read-write, making the topic
name the read credential), constant-time token compare, a 60/min rate limit,
and the relay logs sender/recipient/length but never the message body.
The Anveo guide gains a section covering the two things that actually decide
whether codes arrive: short-code support (Anveo has it, unusually — VoIP.ms
does not except for Google) and Anveo's carrier-sourced *mobile* DIDs, which
are classified as mobile in the lookups that reject VoIP numbers at signup.
Also documents MMS and group texts being out of reach, and why the native
Messages app never sees any of this.
Verified against a stub ntfy: plain OTP, encoded "&", unencoded "&", "+" as
space, wrong token (404), missing message (400) and the rate limit (57x204
then 429) all behave; both installer modes were run end to end in a sandbox
and their generated URLs, settings files and READMEs checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
Three tabs listed the same extensions three different ways: Asterisk Admin
as devices with category/status/transport, Extensions as a row of messaging
checkboxes, PSTN Trunk as permission tiers with a duplicate Messaging column
that wrote the same flag. Changing one extension meant knowing which of the
three owned the setting you wanted.
There is now one Extensions tab with one extensions table, merged from
pjsip.conf (via /api/pstn-permissions, which always works) and /api/ea-devices
where the Easy Asterisk container is reachable, keyed by extension so a row
known to only one source still shows. Capabilities add columns rather than
nav buttons: Category/Status/Transport are .ea-only, Tier/Approved-numbers
are .pstn-only, and both classes start on <body> so nothing flashes before
/api/ea-status and /api/pstn-status answer. Categories, Rooms, Groups,
Concurrent-call caps and Personal numbers are cards under the same tab, gated
the same way.
Per-row Save picks its write path: tier + approved numbers + messaging via
/api/pstn-permissions with a trunk installed, messaging alone via
/api/pstn-messaging without one — which is what that endpoint has always
been for. No backend changes; the standalone messaging-chips card and the
duplicate Messaging column are both gone.
Verified in Chromium against a fixture Asterisk config: full layout renders
nine columns and six cards, the bare layout collapses to Ext/Name/Messaging
with two cards, and both save paths write pstn-permissions.conf correctly
(messaging-only leaves tier and allowed_numbers untouched).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
services/asterisk-digital-ocean.sh was a near-verbatim copy of
services/asterisk.sh — same vendor refresh, compose template, messaging
dialplan, presence alerts, UFW rules and dashboard/trunk chaining, with the
helper functions renamed _asterisk_do_*. Two copies meant every fix had to
land twice, and several never did.
There is now one `asterisk` service. It reads the DigitalOcean metadata
service and asks either way (so a droplet with metadata blocked, or another
provider's public VM, can still opt in), then gates the genuinely
droplet-specific behaviour on that one answer: swapfile for low-RAM plans,
public-FQDN-only setup with no LAN/VLAN prompts, a Caddy site block pinned
to that FQDN, the remote-Authelia option, and the doctl Cloud Firewall.
Two things that were droplet-only for no real reason now apply everywhere:
the entrypoint patch that writes security-level events to logs/full, and
the logrotate config for that file. Without them the Security Dashboard's
Security Log tab and CrowdSec's Asterisk acquisition were silently empty on
every home/LAN install; crowdsec.sh now detects either install directory.
Existing droplets are left alone: an install at ~/docker/asterisk-digital-ocean
keeps its directory and easy-asterisk-do container names, since its Caddyfile
block, UFW rules, Cloud Firewall, CrowdSec acquisition and PSTN trunk all
name those exactly. New installs use ~/docker/asterisk / easy-asterisk.
`sudo ./setup.sh asterisk-digital-ocean` still works via a new SERVICE_ALIAS
map in setup.sh, without a second menu entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
A group-owned personal DID is now working end to end against a real
inbound call: it rings every authorized group member simultaneously, first
to answer wins, and members whose own tier/allowed_numbers don't authorize
the caller are correctly skipped.
Updates the code comments that were written before that confirmation
existed (the Busy(15) block's "NOT yet confirmed live", and the group-ring
script's unverified-SHELL() note), and documents all four fixes plus the
"Commit Changes" caveat in the Anveo setup guide's bugs-fixed section so
the next person hitting one-ring-then-busy has the trail.
Also clears the guide's "Still open" section: the 2-number account cap no
longer applies, and the interactive walkthrough it described as unbuilt
was built (_pstn_anveo_walkthrough).
Live trace confirmed: once trust_id_inbound=yes started surfacing real
caller identity (previous commit), Anveo delivers it "+E.164" style (e.g.
"+15551234567") instead of the bare digits the old callerid-fallback path
produced. PSTN_CALLERID_NORM's existing "add a leading 1 if length is 10"
check never fires for a 12-character "+"-prefixed value and never strips
the "+" either, so it can never match an 11-digit, digits-only
allowed_numbers entry no matter how correctly the number is whitelisted -
a correctly-configured restricted-tier extension or group member would
still always get busy.
Fixed in three places: the inbound dialplan's PSTN_CALLERID_NORM
computation (new PSTN_CID_RAW step strips a leading "+" first), the
group-ring shell script's own defensive re-normalization, and the
dashboard's admin-input-side normalizers (_normalize_nanp_number,
_normalize_personal_did_input) so pasting a number straight out of a
phone's call log (which naturally includes the "+") works too instead of
being silently dropped.
Live trace confirmed every inbound call's CALLERID(num) came back identical
to the DID that was dialed, regardless of who actually called - meaning
every restricted-tier allowed_numbers check was structurally impossible to
satisfy, since the "caller ID" Asterisk saw was never the real one.
The [pstn-trunk] endpoint had a static `callerid=<DID>` default (redundant
for outbound - the dialplan already sets CALLERID(num) on the calling
channel before Dial(), which is what actually gets presented) with no
trust_id_inbound setting. Removed the static default and added
trust_id_inbound=yes so real caller identity the provider sends
(P-Asserted-Identity/Remote-Party-ID, in addition to the From header) is
honored for inbound calls instead of falling back to the DID.
Root cause found via live debugging: pstn-trunk-dialplan.conf mixed two
contexts in one file - outbound patterns continuing [intercom], then a
[from-pstn-trunk] header appearing partway through the same file. On the
reporting box this caused the ENTIRE file to silently fail to load: `dialplan
show intercom` showed zero of the outbound NANP patterns, and `dialplan show
from-pstn-trunk` reported the context didn't exist, with no warning anywhere
(config log, full log, or the reload command's own output).
messaging-dialplan.conf, #include'd the same way right after [intercom],
loaded fine - the working structural difference is that its first real line
IS its own context header, never trying to continue [intercom] first.
Split pstn-trunk-dialplan.conf into that same working shape:
- pstn-trunk-dialplan.conf keeps only the outbound content (continues
[intercom], unchanged from before).
- pstn-trunk-inbound-dialplan.conf is new, and starts with [from-pstn-trunk]
as its first non-comment line - nothing before it.
Both _pstn_patch_vendor_files (generator patch) and _pstn_ensure_live_includes
(direct live-file patch) now add a second #include line for the new file.
_pstn_ensure_live_includes also now verifies after reload that
'from-pstn-trunk' actually has a matching extension, and warns loudly if not,
instead of unconditionally reporting success regardless of whether the
reload actually worked.
pstn_in_denied, the personal-DID owner denial, and pstn_personal_denied_group
fell straight to a bare Hangup() on an unanswered channel instead of
Busy(15) like every other denial path in this file (outbound tier/number/
intl/killswitch). A bare Hangup() doesn't map to "486 Busy Here" the way
Busy() does, so a denied inbound call (wrong tier, number not on the
approved list, group membership mismatch) could sound like a generic
call-failed/fast-busy rather than a clean busy signal - easy to mistake
for a broken permission check when the real issue is a config mismatch.
Also added a NoOp() logging the resolved group name, caller ID, and
PSTN_RING_LIST right after the group-ring SHELL() call, so the next test
of a group-assigned personal number shows directly (via the Asterisk
console/full log) whether the ring list came back empty - and if so, which
of group membership, tier, or allowed_numbers to check next - instead of
requiring another guess-and-recheck cycle.
Full audit of every phone-number comparison/storage point across
pstn-trunk.sh, security-dashboard.sh, asterisk.sh, asterisk-digital-ocean.sh,
and the vendored easy-asterisk base script, prompted by the inbound
Caller-ID normalization fix — the same digit-count mismatch was also
possible on the admin-input side, just silent instead of loud:
- security-dashboard.sh's write_permission(): a 10-digit whitelist entry
was silently DROPPED (NUMBER_RE required exactly 11 digits), with no
warning unless every entry in the field was invalid — a mixed
10-digit + 11-digit list saved "successfully" while quietly losing the
10-digit one. Now normalizes any bare 10-digit token to 11-digit instead
of discarding it (_normalize_nanp_number).
- write_personal_did(): required exactly 10 digits, rejecting an
11-digit entry outright with a clear (but avoidable) error. Now accepts
either and normalizes to the canonical 10-digit storage form
(_normalize_personal_did_input).
- pstn-trunk.sh's TRUNK_DID install prompt: same fix, strips a leading
"1" instead of aborting the install over it.
Everything else checked out clean: outbound dialed-number matching
already normalizes via the existing _NXXNXXXXXX pattern (adds "1" before
any tier check), the area-code/country-code international gates aren't
phone numbers so digit-count doesn't apply, and asterisk.sh/
asterisk-digital-ocean.sh/the vendored base script have no phone-number
comparison logic at all — this class of bug only lives in the PSTN
permission/whitelist layer this project added on top.
allowed_numbers is always stored 11-digit (dashboard's NUMBER_RE requires
exactly 11 digits), but nothing normalized the inbound CALLERID(num) to
match. Confirmed live: Anveo delivered a bare 10-digit caller ID
(5557654321) for a call to a group-owned personal DID whose members'
allowed_numbers were correctly stored as 15557654321 — every restricted-
tier REGEX comparison failed on a plain digit-count mismatch, not because
the number was actually unauthorized. Outbound already guards against the
mirror-image case (_NXXNXXXXXX prepends "1" before any tier check); this
adds the equivalent for inbound via a new PSTN_CALLERID_NORM dialplan
variable, used everywhere CALLERID(num) previously fed a restricted-tier
comparison: the shared ring-group's per-member block, the single-owner
personal DID check, and the group-owned personal DID's ring script
invocation (which also gets a defensive normalize of its own, since it's
useful to invoke by hand for testing).
Verified by regenerating the actual group-ring script and running it
against the exact config dump and caller ID from the live failing call —
now correctly resolves all three group members instead of an empty list.
install_pstn-trunk's "update" branch unconditionally restarted the
asterisk container, then immediately called _pstn_ensure_live_includes,
which already patches the live pjsip.conf/extensions.conf #include lines
and reloads res_pjsip/dialplan — the restart accomplished nothing the
reload doesn't, while dropping any calls already in progress. Confirmed
live as a real double-restart when this runs chained from
asterisk-digital-ocean.sh, which had just rebuilt/restarted the same
container moments earlier.
Fresh installs keep their existing (still restart-first) apply step
unchanged — only "update in place" loses the redundant restart.
_ini_get() in _pstn_write_personal_group_ring_script split on '=' and
compared $1 == key without trimming whitespace, but configparser.write()
(used to write pstn-groups.conf and pstn-permissions.conf) pads '=' with
spaces by default. Every lookup — including the group's own members= line —
silently returned empty, so group-owned personal DID ring-groups never
actually rang anyone regardless of member tier/whitelist config. Trim
whitespace around both the extracted key and value before comparing.
Also switch the script's final echo to printf to remove any ambiguity in
the dialplan's SHELL()-empty-string check.
The dashboard's Personal Numbers card now accepts a group (stored as
"@GroupName", unambiguous against a same-named numeric extension) as well
as a plain extension. A group-owned DID rings every CURRENT member whose
own tier/approved-numbers authorize the caller, computed fresh on every
call by a generated pstn-personal-group-ring.sh (invoked via the
dialplan's SHELL() function) rather than unrolled at install time, since
group membership can change any time via the dashboard with no reinstall
- unlike the shared ring-group, which is fixed at install/update time.
Applies the identical per-member permission check the shared ring-group
already bakes into the dialplan, just computed in a plain shell loop
against the same two config files - group ownership doesn't bypass the
tier/approved-numbers model. Tested standalone against mock config data
(full/restricted/internal mix, matching/non-matching caller, empty and
nonexistent groups) - all four cases behaved correctly. The dialplan's own
SHELL() invocation is still unverified against a real call.
Group ownership never touches pstn-permissions.conf's personal_did
(outbound Caller-ID override) field, since there's no single extension to
hang that on for a group.
Replaces the static wall of warnings with a real 5-step guided walkthrough
(account/funding, DID ordering, both trunk objects, confirmed rate),
pausing for confirmation between each step and showing this box's actual
public IP inline for direct copy-paste into Anveo's Authorized IP
Addresses and SIP Trunk forwarding fields. Built directly from
docs/anveo-direct-setup-guide.md now that the whole flow is confirmed
working end-to-end, so a fresh run shouldn't need the trial-and-error
session that guide was written from.
Confirmed live: PSTN_PERSONAL_OWNER came back empty despite a real
assignment existing, because the Security Dashboard stores personal DIDs
as 10 digits (PERSONAL_DID_RE = ^\d{10}$) while Anveo's inbound INVITE
delivers the called number as 11-digit E.164 (with leading 1) in ${EXTEN}
- confirmed via PSTN_DID_CALLED=15557776655 in the live test. AST_CONFIG()
looking up an 11-digit key never found the 10-digit section.
Normalizes to 10 digits before the lookup (strips a leading digit only
when the string is actually 11 characters, so this doesn't misfire against
a provider that already sends 10). Also fixed the dashboard's own DID
input placeholder, which contradicted its own 10-digit validation error.
Confirmed live: a ring-group list with a repeated extension ("201 301 313
312 313") generated duplicate ring<ext>/skip<ext> priority labels in the
same [from-pstn-trunk] extension. Asterisk doesn't error on this - it
silently resolves Goto() to the wrong occurrence and loops between the two
blocks forever, so the call never reaches the actual Dial() and nothing
ever rings, with no error to point at the cause. Dedupes (first-occurrence
order preserved) regardless of how the duplicate got in there.
Confirmed live: Dial() actually placed the call to "PJSIP/pstn_check_busy@
pstn-trunk" instead of the dialed number. Root cause is systemic, not one
line — Goto(label,1) to a *different named extension* (as opposed to a
same-extension priority label, which _pstn_ring_member_block already knew
to avoid) resets ${EXTEN} to that label's own name. Every helper extension
downstream of a Goto (pstn_check_allow_out, pstn_check_busy,
pstn_intl_check_country, pstn_intl_country_denied, pstn_personal_inbound)
was reading ${EXTEN} expecting the originally-dialed number and getting
its own extension name instead.
This wasn't just cosmetic: pstn_check_allow_out's REGEX approved-number
check compared against the label name too, meaning restricted-tier
extensions could never successfully place a call regardless of whether
the number was actually approved.
Fixed by capturing the real dialed number into PSTN_DIALED (outbound) /
PSTN_DID_CALLED (inbound) at each entry point, before any Goto, and using
that instead of ${EXTEN} in every downstream extension.
Confirmed live: "Unknown or unavailable item requested: 'peername'" left
PSTN_CALLER empty, which made AST_CONFIG() error ("requires a category")
and PSTN_TIER come back empty too — every call fell through to "no PSTN
permission" and got busy-signaled regardless of the extension's actual
tier. Replaced with CUT(CHANNEL,/,2) then CUT(...,-,1), extracting the
endpoint name from the channel name itself (e.g. "PJSIP/201-00000006" ->
"201") — a plain string operation with no dependency on which channel
function items this particular Asterisk build supports.
_1NXXNXXXXX / _NXXNXXXXX were one digit short (10/9 characters instead of
the correct 11/10 for a full NANP number: 1 + area code(3) + exchange(3) +
subscriber(4)). Asterisk's exact-length pattern matching never matched a
real dialed number as a result — confirmed live via "extension not found
in context 'intercom'" on every outbound test call, traced by dumping
`dialplan show intercom` and counting characters against the actual
pattern. This was the real root cause underneath everything else fixed
along the way (stale settings file, the #include not reaching the live
config) — those were real bugs too, but this one was still there
underneath all of them.
setup.sh runs with set -uo pipefail, and source-ing a settings file with
any stray unexpanded/unset variable reference (confirmed live on an older
deployed copy) was fatal to the entire script, not just that one prompt.
Wraps the source in set +u/-u and falls back to sane defaults for anything
missing, but still hard-errors with a clear message if TRUNK_SERVER/
TRUNK_DID themselves are missing, rather than silently writing a broken
trunk config.
Removed the full/restricted-extension, approved-numbers pool/whiptail,
messaging-extensions, and personal-DID-assignment prompts from the
installer. All four are already live-editable, no-restart-needed settings
the Security Dashboard's PSTN Trunk tab manages end to end — the CLI wall
of prompts (that then needed a full reinstall to change) just duplicated
that with more friction. Every extension now starts at internal tier (no
PSTN, no messaging) until granted via the dashboard. Kept: provider/DID
setup, concurrency caps, and the inbound ring-group (basic trunk wiring,
not a permission). Also updated the generated README and CLI summary to
match, and refreshed the stale "known gap" messaging section that predated
the dialplan enforcement built earlier this session.
Confirmed live via a real failed test call ("extension not found in context
'intercom'"): Easy Asterisk's entrypoint only regenerates pjsip.conf/
extensions.conf if they don't already exist. Patching the vendor generator
functions (as both pstn-trunk.sh and the earlier SIP MESSAGE work did) only
takes effect on a future full regeneration — a box that already has devices
configured, the normal case, never triggers one on a plain restart, so the
#include lines never actually reached the live files despite the generator
patch succeeding.
Both installers now also patch the live pjsip.conf/extensions.conf directly
(same anchors, idempotent) and force a reload, so the trunk/messaging
dialplan actually takes effect immediately regardless of whether Easy
Asterisk ever regenerates its config on its own.
0.00388/min for standard US-to-US domestic, confirmed against Anveo's own
Prime rate card CSV (the route set the outbound trunk's LCR config actually
pulls from) rather than the earlier ~0.001 ballpark guess. Defaults to it
automatically when the Anveo Direct quick-pick was chosen.
Restricted extensions previously required retyping the full 11-digit
number list from scratch at each extension's own prompt. Now offers
entering a pool once, then a whiptail checklist per extension to pick a
subset (falls back to typing numbers directly, or 'all' for the whole
pool, when whiptail isn't available or the run is unattended).
User feedback: reading the tier list (internal/restricted/full) right before
"Full-PSTN extensions:" read as ambiguous about whether to type the tier
word or extension numbers. Reworded both prompts to say "extension NUMBERS"
explicitly and added inline examples (e.g. '999 213').
The dialing-prefix ambiguity is resolved: Anveo's own trunk edit screen has
no required-field marker on Dialing Prefix, confirming it's optional. Also
documents the separate Outbound Service Trunk vs. inbound SIP Trunk
distinction discovered while walking through a real account setup.
Messaging has no dependency on a PSTN trunk existing (no cost, no
carrier, no DID), but the whole PSTN Trunk tab - including the messaging
checkbox added last commit - was hidden behind pstn_installed(), which
only becomes true once services/pstn-trunk.sh's dialplan is actually
wired in. That meant enabling messaging required going through full SIP
trunk/provider setup first for no real reason.
Added a standalone "Internal SIP messaging" card that's always visible in
the tab regardless of trunk status, backed by a new write_messaging()
that only touches the messaging key (leaving tier/allowed_numbers/
personal_did untouched) and creates pstn-permissions.conf from scratch if
it doesn't exist yet. Confirmed the systemd unit's ReadWritePaths and the
group/chmod grants already covered this - both are set up whenever a base
Asterisk install is detected, independent of pstn-trunk - so no
permission-layer changes were needed, only the dashboard's own artificial
UI gate.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
get_all_permissions()/write_permission() now handle messaging alongside
tier/allowed_numbers as one save action, and the Security Dashboard's PSTN
Trunk table gets a Messaging checkbox column - no more needing to re-run
pstn-trunk.sh's CLI just to change who can use internal SIP texting,
matching how tier/allowed_numbers/personal_did already worked.
Tested that messaging correctly survives tier changes and personal-DID
assignment/removal on the same extension (independent axes, as intended).
Still explicitly not done, and said so in both READMEs rather than
implying otherwise now that there's a nice UI for it: the actual SIP
MESSAGE dialplan wiring that would make Asterisk enforce this flag. That
gap hasn't changed - only the permission storage/UI layer around it has.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
Prompts now start with a 1/2/3 choice (Anveo Direct / VoIP.ms / manual)
that pre-fills already-known values - Anveo Direct's sbc.anveo.com
hostname and its 4 published signaling IPs, so they don't need retyping
from memory - plus provider-specific account-setup reminders. Every
value stays editable at each subsequent prompt; this only changes
defaults, not behavior, so existing manual-entry users see no change.
Flagged rather than silently trusted: Anveo Direct's own Outbound Trunks
page documents dialing as [PREFIX]PHONENUMBER@sbc.anveo.com, which
contradicts an earlier no-prefix-needed finding from their FAQ. The
dialplan still dials the bare number (matching the older finding) - the
quick-pick prints a loud warning to verify the Prefix field can be left
blank before relying on this, rather than guessing and hardcoding prefix
handling without live confirmation either way.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
Multiple DIDs can now share one trunk/account, each assigned to a specific
extension - additive to the existing shared trunk DID/ring-group, which
keeps working unchanged for everyone regardless of what's assigned here.
- New pstn-personal-dids.conf (DID -> owner extension), read live by the
dialplan for inbound routing: a call to a personal DID routes straight
to its owner, checked against the owner's own tier/approved-numbers, no
ring-group fallback since it's that extension's own line.
- New personal_did= field per extension in pstn-permissions.conf: the
outbound Caller-ID override, used by pstn_check_busy (the shared exit
point for both domestic and international dialing) instead of the
shared trunk DID when the calling extension has one assigned.
- Both files kept in sync automatically by one write path - CLI prompt at
install/update, or a new "Personal numbers" card in the Security
Dashboard's PSTN Trunk tab - rather than requiring hand-editing both
consistently. Reassigning a DID or giving an extension a new one cleanly
drops the stale side of the old mapping.
- Assigning a DID to an internal-tier extension is accepted but warned
about (won't ring anyone until also granted full/restricted tier),
matching this repo's permissive-with-warnings style rather than
blocking the action outright.
Also fixed a real bug found while building this: security-dashboard.sh's
write_permission() did cp.remove_section(ext) whenever tier was set to
"internal", silently discarding any messaging=yes or personal_did=
already set on that extension. Now removes only the tier/allowed_numbers
keys, dropping the section only once nothing else remains in it.
Separately, de-anchored pstn-trunk.sh's install prompts from VoIP.ms as
the implicit default (provider name default, server-hostname example
wording, rate-prompt wording) now that Anveo Direct is an equally
confirmed, tested provider option.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
- CLI country menu now asks for a per-minute rate when a country is added,
stored alongside its code/name in pstn-intl-allowed.conf. The usage-alert
script buckets each month's outbound calls into domestic vs. per-country
international (longest-code-prefix match on the dialed digits) and prices
each bucket at its own rate instead of one blended flat rate, which badly
under/over-estimated international cost.
- While the kill-switch is tripped, the periodic script now also force-hangs
up any PSTN call already in progress (docker exec + Asterisk's "channel
request hangup" against the trunk's active channels), not just new calls
- closing the gap where an in-progress call just kept running until it
ended naturally. Runs every check while tripped, not only the run that
trips it, to close a start-of-call race.
- Fixed a bug caught by testing: the hangup sweep was nested inside the
"does the call log exist" guard, so it silently never fired if the log
was missing/rotated while tripped. Moved it outside that guard.
- Documented an honest "how bulletproof is this?" breakdown in the
generated README: the provider's own real-time $0-balance block is the
actual hard ceiling (confirmed for Anveo Direct), the kill-switch here is
an estimate-based second layer, not a replacement for funding the prepaid
account at your actual risk tolerance.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
- Replace the hourly cron.d spend/volume check with a per-minute systemd
timer (cron.d fallback), since it's now also the kill-switch's
enforcement point and the interval is the exposure window.
- Add a hard monthly spend-cap kill-switch: trips pstn-trunk-killswitch.conf
(read live by the dialplan, both call directions, internal calling
unaffected) once estimated spend reaches an admin-set cap, plus a loud
ntfy warning at 80% of the cap before it trips. Does not auto-reset;
clearing it is a CLI-only action (update mode) so a compromised/careless
web session can't quietly re-enable spend after a trip.
- Add a CLI-only (never web-dashboard) international-calling allow-list:
continent/country menu, always asked every run with the resulting
allow-list printed exactly once right after, optional auto-expiry with
day-of and at-expiry ntfy notices, and active re-blocking once expired.
Dialplan gates the US "011" prefix pattern on it for full-tier extensions.
- Add an independent messaging_enabled flag per extension in
pstn-permissions.conf for Asterisk's native SIP MESSAGE texting, since
the risk profile (no cost) differs from PSTN calling permissions. The
dialplan wiring to enforce it is flagged as a known gap pending live
verification against Easy Asterisk's message routing, rather than
shipping a guessed pattern.
- security-dashboard.sh: detect whether pstn-trunk is actually installed
(not just base Asterisk) and show a clear "not installed" state instead
of misleading default caps/extensions; add clickable column-header
sorting to the CrowdSec active-bans table.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
Anveo Direct's "Call Security" rate-cap feature (found while evaluating
their account portal) prompted a closer look at our own NANP dialplan
pattern, which turned out to have a real gap: NANP covers Caribbean/
Atlantic nations and several US territories, not just the US - these dial
exactly like a normal 10-digit US number but bill at international/
premium rates, a well-known toll-fraud/"one-ring scam" vector precisely
because the format looks domestic. The existing _1NXXNXXXXX/_NXXNXXXXX
patterns matched all of them equally, meaning any extension (including
"full" tier) could reach premium Caribbean/territory numbers despite the
whole point of the design being "US only, no premium-rate destinations."
Verified the area code list against multiple sources rather than relying
on training-data recall alone, then added an explicit block on all 27
(Bahamas, Barbados, Anguilla, Antigua & Barbuda, British Virgin Islands,
US Virgin Islands, Cayman Islands, Bermuda, Grenada, Turks & Caicos,
Jamaica, Montserrat, Northern Mariana Islands, Guam, American Samoa, Sint
Maarten, Saint Lucia, Dominica, Saint Vincent, Puerto Rico, Dominican
Republic, Trinidad & Tobago, Saint Kitts & Nevis) - checked via REGEX()
against the extracted area code, before permission-tier checks, so it
applies regardless of tier. Tested the extraction/matching logic against
both blocked and legitimate US area codes.
Updated docs/pstn-calling-voipms-plan.md's toll-fraud reasoning, which had
previously (incorrectly) claimed NANP-only restriction alone fully closed
the cost-per-minute risk - noted as a real, live gap rather than glossing
over it. Also noted Anveo Direct's provider-side Call Security setting
(rate cap + max call duration) as a complementary backstop worth
configuring on their account portal directly, independent of this repo's
code.
VoIP.ms's one-POP-one-hostname model meant a single resolved IP was enough
for the trunk's identify match=, but Anveo Direct (researched as an
alternative after finding VoIP.ms's ToS selectively requires government ID
at signup) sends inbound signaling from a fixed set of published IPs
instead. Generalizes the pjsip include to accept multiple match= lines in
one identify object (PJSIP allows repeating it to build one match set - no
separate config objects needed per IP), with a new install prompt for any
additional known source IPs beyond the auto-resolved one.
No dial-prefix support was added despite earlier research suggesting Anveo
Direct needed one - verified against their current official FAQ (fetched
directly rather than trusting a stale search-result claim) that plain
number dialing works with no prefix.
Fixed a real bug caught while testing the update-mode round trip:
.pstn-trunk.env was written with unquoted values, which breaks `source`
entirely once any value contains a space - a multi-IP list or a
multi-word provider name (e.g. "Anveo Direct") would make bash try to run
the second word as a command. Predates this change (a multi-extension
ring group has the same shape); only surfaced by actually re-sourcing the
file and reapplying, not by inspecting generator output. Fixed by quoting
every value.
Updated docs/pstn-calling-voipms-plan.md with the Anveo Direct/DIDLogic/
SIP.US research findings and what's now implemented vs. still open
(real-time balance enforcement at Anveo specifically is unconfirmed).
Adds an inbound concurrent-call cap mirroring the existing outbound one -
outbound alone didn't protect against an inbound call-flood, which also
costs money per-minute on VoIP.ms. Both defaults bumped from 3 to 10.
Moves the cap numbers themselves out of static dialplan text and into a new
pstn-limits.conf, read live via AST_CONFIG() the same way permission tiers
already are - changing either cap takes effect on the next call, no
Asterisk restart, no reinstall. "update in place" never touches this file,
matching the existing protection for pstn-permissions.conf/.env/firewall/
Caddy config.
Adds a concurrency-caps card to security-dashboard.sh's "PSTN Trunk" tab,
above the existing permissions table, so both caps are visible and editable
from the same web page. Tested against a real running instance of the
Python app: default fallback when the file doesn't exist yet, save/persist,
invalid-input rejection, and a bash-to-Python round trip on the generated
file format.
Inbound dialplan ordering mirrors outbound's existing pattern: permission
check (is any ring-group member authorized for this caller) before the
concurrency check, consistent with outbound's tier-check-then-busy-check
order.
Reworks the outbound permission model from a flat allow-list into three
per-extension tiers (internal / restricted / full), addressing the ask for
extensions that can only reach pre-approved numbers plus extensions with
full US calling, while internal extension-to-extension dialing and ring
groups stay ungated for everyone regardless of tier.
Permissions now live in pstn-permissions.conf, read by the dialplan via
Asterisk's AST_CONFIG() on every call instead of being baked into static
dialplan text - editing that file takes effect on the next call, no
Asterisk restart and no re-running the installer. "update in place" mode
never touches this file (same protection this repo's update-mode
convention already gives .env/firewall/Caddy config); only a "fresh"
reinstall (with confirmation) or the web UI change it.
Adds a "PSTN Trunk" tab to services/security-dashboard.sh: lists every
extension (parsed from pjsip.conf) with its live tier and approved numbers,
editable with no restart - this is what makes the tier model actually
manageable day to day. Extracted the dashboard's systemd-unit writing into
its own function so "update" mode refreshes it too (previously only fresh
installs did), and generalized both the dashboard and the trunk service to
detect either asterisk-digital-ocean or the home/LAN asterisk install.
Inbound ring-group membership now checks each member's tier live per call
via an unrolled per-member dialplan block (full always rings, restricted
only if the caller's number is approved, internal never rings) rather than
a single static Dial() string.
Caught and fixed two real bugs during testing against a sandboxed vendor
copy and a live instance of the (stdlib-only) Python dashboard app:
- Asterisk Goto/GotoIf argument parsing: ring<ext>/skip<ext> are named
priorities within the same extension (declared via "same => n(label),..."),
not separate exten => entries, so jumping to them needs the single-argument
Goto(label) form - the two-argument Goto(label,1) form used initially
addresses a different, nonexistent extension named "label" instead.
- A security-relevant REGEX() direction issue: the inbound Caller-ID check
initially interpolated attacker-influenced call data into the PATTERN side
of a REGEX() match rather than the tested-string side, which would let a
crafted Caller-ID forge a match against an unrelated approved-numbers
entry. Fixed by keeping the admin-controlled approved-list as the pattern
and the live call data as the string being tested, consistently on both
the outbound and inbound checks.
Verified end-to-end: dialplan/pjsip generation and vendor-file patching
(idempotent, syntax-checked) as before, plus the new permission-file
round-trip between bash and Python, and the dashboard's new API endpoints
exercised against a real running Python server (extension parsing, tier
changes, number normalization, invalid-input rejection, atomic file writes).
Generalizes services/pstn-trunk.sh (renamed from voipms-trunk.sh in the
prior commit) away from VoIP.ms specifics - any IP-authenticated SIP
provider works, VoIP.ms is just the suggested default. Adds:
- Role-based outbound permission: a configurable allow-list of extensions
that may dial PSTN numbers (regex-gated on CHANNEL(peername)), separate
from internal extension-to-extension dialing which stays open to everyone
regardless. Blank list preserves the original "everyone can dial out"
behavior.
- Inbound ring-group: rings a configurable list of extensions instead of a
single hardcoded one.
- ntfy alerts: immediate on denied (unauthorized extension) or rejected
(concurrency cap hit) calls, plus an hourly cron-driven check that alerts
once per month when estimated spend crosses a threshold and every hour
call volume looks like a burst. Uses a self-contained pipe-delimited call
log rather than Asterisk's CDR, to avoid depending on CDR module
availability and CSV comma-quoting.
- Settings persisted to .pstn-trunk.env so "update in place" reapplies
everything from that file instead of fragile re-parsing out of generated
Asterisk config (which had a real bug: update mode was extracting the
wrong Dial(PJSIP/...) line).
Tested end-to-end against a sandboxed copy of the real vendor files:
permission-gate regex, ring-group dial-string construction, ntfy line
injection/removal, and the usage-alert script's threshold/burst/monthly-
dedup logic all verified with synthetic data. Caught and fixed a sed `&`
escaping bug in the ring-group substitution before it shipped (RING_DIAL
contains literal `&` join characters, which sed's replacement syntax
otherwise treats as "insert the match").