Rework PSTN trunk: role-based permissions, ring-groups, ntfy spend alerts

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").
This commit is contained in:
Claude
2026-07-21 23:38:08 +00:00
parent 6b0e378123
commit 1e2a3743ab
4 changed files with 471 additions and 205 deletions
+68 -41
View File
@@ -3,13 +3,20 @@
Research and decisions from a design discussion, saved here so the work can
be picked up in a fresh chat without re-deriving the background.
**Implemented** — see `services/voipms-trunk.sh` (run `sudo ./setup.sh
voipms-trunk` after `asterisk-digital-ocean` is installed). IP-authenticated
trunk, US/NANP-only outbound dialplan, 3-concurrent-call cap, inbound to one
extension. That file's own header comment explains how it survives Easy
Asterisk's config regeneration (an architectural wrinkle discovered while
implementing this — worth reading before touching either file). The
outbound spend/volume alert mentioned below is still not implemented.
**Implemented** — see `services/pstn-trunk.sh` (run `sudo ./setup.sh
pstn-trunk` after `asterisk-digital-ocean` is installed). Generic SIP trunk
add-on that defaults to VoIP.ms but isn't hardcoded to it — any provider
supporting IP authentication works. Covers: IP-authenticated trunk,
US/NANP-only outbound dialplan, a configurable concurrent-call cap (default
3), **role-based outbound permission** (some extensions internal-only, some
PSTN-enabled — internal intercom dialing is never gated either way), a
configurable **inbound ring-group** (one extension or several), **ntfy
alerts** on denied/rejected calls (immediate) and spend/volume thresholds
(hourly check), and settings persisted to `.pstn-trunk.env` so "update in
place" reapplies everything without re-prompting. That file's own header
comment explains how it survives Easy Asterisk's config regeneration (an
architectural wrinkle discovered while implementing this — worth reading
before touching either file).
## Decision so far
- **Provider: VoIP.ms.** Chosen for its prepaid-balance model: turn off
@@ -76,35 +83,55 @@ ceiling; only a concurrent-call cap bounds the *speed* of a breach. Treat
the concurrent-call cap and spend/volume alert below as required before
funding a live trunk, not optional hardening.
**Implemented:** the concurrent-call cap in `services/voipms-trunk.sh` is a
*global* cap (max 3 outbound legs total via the trunk, via
`GROUP()`/`GROUP_COUNT()` in the dialplan, shared across all extensions)
not per-extension. That was the explicit ask when this got built; a
per-extension cap layered on top is still a possible future refinement, not
done. The spend/volume alert is still not implemented.
**Implemented:** the concurrent-call cap in `services/pstn-trunk.sh` is a
*global* cap (configurable, default max 3 outbound legs total via the trunk,
via `GROUP()`/`GROUP_COUNT()` in the dialplan, shared across all extensions)
not per-extension. That was the explicit ask when this got built. The
spend/volume alert is also implemented now: an hourly cron script reads a
call log the dialplan appends to directly (not Asterisk's CDR — see the
service file's own comments for why) and alerts via ntfy once per month when
estimated spend crosses a threshold, and every hour that call volume in the
last hour looks like a burst. Denied/rejected calls alert immediately,
separately from that hourly check.
## What it takes technically (asterisk-digital-ocean)
- A PJSIP trunk to VoIP.ms: `endpoint` / `aor` / `identify` sections in the
pjsip config. **Implemented with IP authentication** (no `auth` section,
no SIP password stored anywhere) — see `services/voipms-trunk.sh`.
- A PJSIP trunk: `endpoint` / `aor` / `identify` sections in the pjsip
config. **Implemented with IP authentication** (no `auth` section, no SIP
password stored anywhere) — see `services/pstn-trunk.sh`. Provider name,
server hostname, and DID are all prompted at install time (VoIP.ms is only
the suggested default), so any provider supporting IP auth works.
- An outbound dialplan route matching US numbers only — **implemented**:
`_1NXXNXXXXX` (11-digit NANP with leading 1) and `_NXXNXXXXX` (10-digit,
auto-prefixed with 1), both routed to the VoIP.ms trunk. No catch-all
`_X.` pattern.
- VoIP.ms-specific setup that isn't scriptable (user does this manually):
create the account, order a DID (inbound is wanted — see above), decide
pay-per-minute vs. unlimited DID plan and whether to add E911, pick a
VoIP.ms POP/server (affects the trunk hostname), fund the prepaid balance
($15 minimum), turn off auto-recharge. `services/voipms-trunk.sh` prompts
for the POP hostname, DID, and a ring extension for inbound at install time.
auto-prefixed with 1), both routed to the trunk. No catch-all `_X.`
pattern.
- **Role-based outbound permission — implemented.** A space-separated list
of extensions allowed to dial PSTN, prompted at install (blank = every
extension, the original default before roles existed). Baked into the
dialplan as a `REGEX()` check against `${CHANNEL(peername)}` — no changes
needed to Easy Asterisk's own per-device pjsip.conf sections, since the
gate lives entirely in code this repo already owns. Internal
extension-to-extension dialing is never gated by this, regardless of PSTN
permission — only the two NANP patterns above are.
- **Inbound ring-group — implemented.** A space-separated list of
extensions to ring for inbound calls (one, or several for a ring group via
`Dial(PJSIP/a&PJSIP/b,20)`), prompted at install.
- Provider-specific setup that isn't scriptable (user does this manually):
create the account, order a DID, decide pay-per-minute vs. unlimited DID
plan and whether to add E911, pick a server/POP, fund the prepaid balance
($15 minimum for VoIP.ms), turn off auto-recharge. `services/pstn-trunk.sh`
prompts for the server hostname, DID, allowed extensions, ring extensions,
concurrency cap, ntfy topic, and spend-alert settings at install time.
- Defense-in-depth alongside the trunk:
- **Implemented:** a global concurrent-call cap in the dialplan
(`GROUP()`/`GROUP_COUNT()`, max 3 outbound legs via the trunk at once)
so a compromised extension can't open dozens of simultaneous outbound
legs. Global, not per-extension — see the note above.
- **Not implemented:** a simple outbound call-count/spend alert — could
live in the existing `security-dashboard` service (see
`services/security-dashboard.sh`) or as a separate CDR-based check.
(`GROUP()`/`GROUP_COUNT()`, configurable, default max 3 outbound legs via
the trunk at once) so an unauthorized or compromised extension can't
open dozens of simultaneous outbound legs. Global, not per-extension —
see the note above.
- **Implemented:** an outbound call-count/spend alert via ntfy — a
self-contained call log (not Asterisk's CDR) plus an hourly cron script.
Denied/rejected calls also alert immediately. See
`services/pstn-trunk.sh`'s "Spend/volume alerts" README section for the
exact mechanics and why CDR wasn't used.
- Worth being explicit that CrowdSec's existing `asterisk_bf` /
`asterisk_user_enum` scenarios (see `services/crowdsec.sh`) cover
registration brute-force, which is a *different* threat model from a
@@ -122,21 +149,21 @@ done. The spend/volume alert is still not implemented.
across the space, VoIP.ms included.
## Open items for whoever picks this up next
1. ~~Decide: new `services/voipms-trunk.sh`...~~ Done — separate service file.
1. ~~Decide: new `services/pstn-trunk.sh`...~~ Done — separate service file,
generalized to any IP-auth SIP provider (VoIP.ms is just the default).
2. ~~IP auth vs. registration~~ Done — IP authentication, no password stored.
3. ~~Exact NANP dial pattern(s)~~ Done — `_1NXXNXXXXX` / `_NXXNXXXXX`.
4. ~~Inbound~~ Done — rings one extension, prompted at install time. Still
unresolved: pick pay-per-minute vs. unlimited DID plan on VoIP.ms's side
based on real expected volume, and decide on E911 (see cost estimate).
5. ~~Concurrent-call cap~~ Done — global 3-call cap. Still not implemented:
the spend/volume alert (security-dashboard integration or CDR-based
check) — treat as still-required before fully trusting this against a
sustained breach, not optional (see toll-fraud nuance above: the NANP
restriction + call cap bound cost/min and burn speed, but nothing here
yet notices a breach in progress or alerts on unusual volume).
4. ~~Inbound~~ Done — rings a configurable list of extensions (ring-group
supported), prompted at install time. ~~Role-based outbound permission~~
Done — space-separated allow-list, blank = everyone. Still unresolved:
pick pay-per-minute vs. unlimited DID plan on VoIP.ms's side based on
real expected volume, and decide on E911 (see cost estimate).
5. ~~Concurrent-call cap~~ Done — configurable, default 3, global not
per-extension. ~~Spend/volume alert~~ Done — ntfy, hourly threshold +
burst check, plus immediate alerts on denied/rejected calls.
6. Verify against a live VoIP.ms account: auto-recharge-off behavior at
sign-up, and that the chosen POP server's actual source IP for inbound
calls matches what `services/voipms-trunk.sh` resolved via DNS at install
calls matches what `services/pstn-trunk.sh` resolved via DNS at install
time (VoIP.ms's docs mention some redundancy/failover between servers —
if inbound calls ever stop matching the `identify` section, this is the
first thing to check).