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.
15 KiB
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.
- Two independent concurrent-call caps, one per direction (default 10 outbound / 10 inbound — bumped up from an initial default of 3 once roles existed to gate who can even reach the trunk; "ability creep is real," so the two caps stay the hard backstop regardless). Global per direction, 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 — deliberately, even though VoIP.ms itself offers free SIP-to-SIP calling, to avoid routing purely-internal calls through an extra external hop for no benefit. - Permissions AND concurrency caps are both live, not baked into the
dialplan. Stored in
pstn-permissions.conf/pstn-limits.conf, read by the dialplan via Asterisk'sAST_CONFIG()on every call — editing either file takes effect on the next call, no restart, no reinstall.services/pstn-trunk.sh's "update in place" mode deliberately never touches either (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.shintegration — a "PSTN Trunk" tab shows both concurrency caps and every extension (parsed frompjsip.conf) with its live tier and approved numbers, all editable with no restart. This is what makes the tier model and caps actually manageable day-to-day instead of needing a reinstall for every change.- ntfy alerts on denied/rejected calls (immediate — permission denied, number not approved, or either 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, ntfy,
rate/thresholds) persist to
.pstn-trunk.envso "update in place" reapplies them without re-prompting — the concurrency cap numbers themselves are not structural, they live inpstn-limits.confinstead (see above).
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:
- 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.
- 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 $2–5/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 caps in services/pstn-trunk.sh are
global per direction (default 10 outbound / 10 inbound, each tracked via
its own GROUP()/GROUP_COUNT() in the dialplan, shared across all
extensions) — not per-extension. Inbound didn't have a cap at all until
this was pointed out as a gap (outbound's cap doesn't protect against an
inbound call-flood, which also costs money per-minute on VoIP.ms) — both
directions are covered symmetrically now. The spend/volume alert is also
implemented: 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/identifysections in the pjsip config. Implemented with IP authentication (noauthsection, no SIP password stored anywhere) — seeservices/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/fullper extension, read live frompstn-permissions.confviaAST_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 aREGEX()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 inservices/pstn-trunk.shput 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 frompjsip.conf, the same marker format Easy Asterisk's ownrebuild_dialplan()uses) with a tier dropdown and approved-numbers field, saving straight topstn-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.shprompts for the server hostname, DID, allowed extensions, ring extensions, both concurrency caps, ntfy topic, and spend-alert settings at install time (the cap numbers are then live/web-editable afterward — see above). - Defense-in-depth alongside the trunk:
- Implemented: independent outbound/inbound concurrent-call caps in
the dialplan (
GROUP()/GROUP_COUNT(), default 10/10) so an unauthorized or compromised extension can't open dozens of simultaneous legs in either direction. Global per direction, 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_enumscenarios (seeservices/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.
- Implemented: independent outbound/inbound concurrent-call caps in
the dialplan (
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
Decide: newDone — separate service file, generalized to any IP-auth SIP provider (VoIP.ms is just the default).services/pstn-trunk.sh...IP auth vs. registrationDone — IP authentication, no password stored.Exact NANP dial pattern(s)Done —_1NXXNXXXXX/_NXXNXXXXX.InboundDone — rings a configurable list of extensions (ring-group supported), each checked live per-call against its own tier.Permission modelDone — superseded the original flat allow-list with a 3-tier model (internal/restricted/full) managed live viapstn-permissions.conf+ the Security Dashboard web UI, no reinstall needed to change.Generic Asterisk targetDone —services/pstn-trunk.shnow supports eitherasterisk-digital-oceanor the home/LANasteriskinstall (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).Concurrent-call capDone — both directions now (inbound was a real gap, since it also costs money per-minute and outbound's cap doesn't cover it), default 10/10, global not per-extension, live-editable viapstn-limits.conf/web UI.Spend/volume alertDone — ntfy, hourly threshold + burst check, plus immediate alerts on denied/rejected calls.- 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.shresolved via DNS at install time (VoIP.ms's docs mention some redundancy/failover between servers — if inbound calls ever stop matching theidentifysection, this is the first thing to check).