Files
ubuntu-post-install/docs/pstn-calling-voipms-plan.md
T
Claude 3bd952e55d PSTN trunk: 3-tier live permissions + Security Dashboard web UI + dual target
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).
2026-07-21 23:59:50 +00:00

227 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PSTN Calling via VoIP.ms — Planning Notes
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/pstn-trunk.sh` (run `sudo ./setup.sh
pstn-trunk` after `asterisk-digital-ocean` **or** `asterisk` (home/LAN) is
installed — both are supported, see the file for the static-IP caveat on the
LAN variant). 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, no catch-all.
- A configurable concurrent-call cap (default 3, global not per-extension).
- **Three-tier per-extension permission model**: `internal` (default — no
PSTN at all, but can always call/receive other extensions and internal
ring groups), `restricted` (also only pre-approved US numbers, both
directions), `full` (also any US number). Internal extension-to-extension
dialing is *never* gated by any tier.
- **Permissions are live, not baked into the dialplan.** Stored in
`pstn-permissions.conf`, read by the dialplan via Asterisk's
`AST_CONFIG()` on every call — editing that file takes effect on the next
call, no restart, no reinstall. `services/pstn-trunk.sh`'s "update in
place" mode deliberately never touches it (same protection this repo's
update-mode convention already gives `.env`/firewall/Caddy config
elsewhere) — only a "fresh" reinstall (with confirmation) or the web UI
below change it.
- A configurable **inbound ring-group** (one extension or several), each
member's live tier/approved-numbers checked per inbound call via an
unrolled per-member dialplan block (no AGI needed).
- **`services/security-dashboard.sh` integration** — a "PSTN Trunk" tab
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 instead of needing a reinstall for
every roster change.
- **ntfy alerts** on denied/rejected calls (immediate — permission denied,
number not approved, or concurrency cap hit) and spend/volume thresholds
(hourly check: once/month on a spend threshold, every hour on a call-burst
threshold).
- Structural settings (server, DID, ring-group membership, cap, ntfy,
rate/thresholds) persist to `.pstn-trunk.env` so "update in place"
reapplies them without re-prompting.
`services/pstn-trunk.sh`'s own header comment explains how the trunk/dialplan
config survives Easy Asterisk's regeneration, and why permissions are a
separate live file rather than baked in — both architectural wrinkles
discovered while implementing this, worth reading before touching either
file.
## Decision so far
- **Provider: VoIP.ms.** Chosen for its prepaid-balance model: turn off
auto-recharge in the account's Finances settings and outbound calls simply
fail once the balance hits $0 — that's the toll-fraud backstop if the
droplet's Asterisk (`asterisk-digital-ocean`) is ever compromised. This
behavior wasn't verified against a live account — confirm the
auto-recharge toggle still works this way at sign-up time, since billing
UX can change.
- **Scope: US calling only, for now.** No international, no premium-rate
destinations. Enforce this twice — once via whatever dial-plan/prefix
VoIP.ms requires for US routing, and again independently in Asterisk's own
dialplan (see below), so a compromised extension can't reach anything
outside the US even if the trunk itself would technically allow more later.
- **Inbound: wanted.** A DID is in scope, not outbound-only. Decide pay-per-minute
vs. unlimited DID plan based on expected inbound volume (see cost estimate
below), and decide E911 deliberately rather than skipping it by default —
VoIP.ms doesn't require it, but without it 911 dialed from the line either
fails or doesn't carry accurate address/location info.
## Cost estimate (100 min/month each direction, US-only)
Verified against VoIP.ms's public wiki/rate pages, not a live account — confirm
at sign-up since rates can change.
| Item | Rate | Monthly | Annual |
|---|---|---|---|
| DID (phone number), pay-per-minute plan | $0.85/mo flat | — | $10.20 |
| Inbound usage | $0.009/min | $0.90 | $10.80 |
| Outbound usage | $0.01/min | $1.00 | $12.00 |
| **Total** | | **~$2.75** | **~$33** |
- Skipping the DID (outbound-only) drops this to ~$12/year.
- Adding E911 adds a $1.50 one-time fee plus **$1.50/month** regulatory fee
(~$18/year) — pushes the total above to ~$51/year.
- **Funding minimum:** VoIP.ms requires a **$15 minimum deposit** to activate
calling — a one-time balance top-up, not a recurring charge. At ~$2.75/month
usage that balance lasts ~5 months before a refill is needed (longer at
lower volume). Leave auto-recharge **off** per the toll-fraud design above.
## Why this matters (toll fraud)
A compromised Asterisk box can dial premium-rate or international numbers
that cost real money fast (some destinations run several $/min) before
anyone notices. Two independent layers matter more than either alone:
1. **Trunk-side cap** — prepaid balance, auto-recharge off. Limits total
possible loss to whatever the balance is topped up to, but a live
compromise could still burn through that balance in minutes if nothing
else restricts what can be dialed.
2. **Dialplan-side restriction** — Asterisk should refuse to route calls
outside the US/NANP pattern at all, regardless of what the trunk allows.
This is the first line of defense and should exist independent of the
trunk's own capabilities.
**Important nuance: these two layers bound different things, and neither
alone bounds both.** NANP-only restriction bounds *cost-per-minute* (a
compromised box can only ever reach $0.01/min US numbers, never $25/min
international/premium destinations) — that risk is fully closed. It does
**not** bound *how fast* the prepaid balance gets burned: nothing stops a
compromised box from opening many concurrent US-destination calls in
parallel and draining the whole balance (e.g. $15 balance ÷ $0.01/min =
1,500 minutes total, which 20 concurrent legs could burn through in under
an hour). The prepaid-balance-off-auto-recharge layer bounds the *dollar*
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/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: `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 trunk. No catch-all `_X.`
pattern.
- **Three-tier permission model — implemented**, superseding an earlier flat
allow-list design. `internal` / `restricted` / `full` per extension, read
live from `pstn-permissions.conf` via `AST_CONFIG()` rather than baked
into the dialplan text — no changes needed to Easy Asterisk's own
per-device pjsip.conf sections, since the gate lives entirely in files
this repo already owns. Internal extension-to-extension dialing is never
gated by any tier — only the two NANP patterns (outbound) and the
ring-group (inbound) are. Numbers are stored pipe-separated specifically
because they're used as a `REGEX()` alternation pattern in the dialplan —
see the security note below on why the untrusted call-time value must
never be interpolated into the *pattern* side of that check.
- **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. Each member's tier is
checked live per inbound call (an unrolled dialplan block per member —
full always rings, restricted only rings if the caller's number is on
that member's approved list, internal never rings).
- **Security note on the REGEX() checks**: an inbound Caller-ID (or an
outbound dialed number) is attacker-influenced data and must never be
interpolated into the *pattern* argument of `REGEX()` — only ever the
string being tested. Doing it backwards would let a crafted Caller-ID
(e.g. containing regex metacharacters) forge a match against an unrelated
approved-numbers entry. Both checks in `services/pstn-trunk.sh` put the
admin-controlled approved-list in the pattern position and the live call
data in the tested-string position — worth keeping that direction if this
is ever refactored.
- **Web UI — implemented.** `services/security-dashboard.sh`'s "PSTN Trunk"
tab lists every extension (parsed from `pjsip.conf`, the same marker
format Easy Asterisk's own `rebuild_dialplan()` uses) with a tier dropdown
and approved-numbers field, saving straight to `pstn-permissions.conf`.
Tested end-to-end with a real running instance of the (stdlib-only)
Python app: extension parsing, tier changes, number normalization/
validation, and persistence all verified with actual HTTP requests against
a live server in a sandboxed test — not just read through.
- 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()`, 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
legitimately-registered extension being used for toll fraud — nobody
should assume CrowdSec alone already covers this.
## Provider landscape (for reference — not chosen)
- **SIP.US** — also prepaid, flat per-channel rate, built-in fraud
detection. Considered, not chosen.
- Several providers (Nextiva, IDT Express) advertise AI/ML-based fraud
monitoring as a second layer on top of normal billing — an extra net, not
a substitute for a hard prepaid ceiling.
- Most providers don't market an explicit "spending cap" feature — the
prepaid-balance + auto-recharge-off pattern is the de facto mechanism
across the space, VoIP.ms included.
## Open items for whoever picks this up next
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 a configurable list of extensions (ring-group
supported), each checked live per-call against its own tier. ~~Permission
model~~ Done — superseded the original flat allow-list with a 3-tier
model (internal/restricted/full) managed live via
`pstn-permissions.conf` + the Security Dashboard web UI, no reinstall
needed to change. ~~Generic Asterisk target~~ Done —
`services/pstn-trunk.sh` now supports either `asterisk-digital-ocean` or
the home/LAN `asterisk` install (the latter with a static-IP caveat for
the provider's IP authentication). 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/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).