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
558 lines
36 KiB
Markdown
558 lines
36 KiB
Markdown
# 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.
|
||
|
||
**For the confirmed-working, step-by-step Anveo Direct account + droplet
|
||
setup (real values, real bugs hit and fixed), see
|
||
`docs/anveo-direct-setup-guide.md` instead of re-deriving it from this
|
||
file** — this doc is the design/decision log; that one is the clean
|
||
how-to.
|
||
|
||
**Implemented** — see `services/pstn-trunk.sh` (run `sudo ./setup.sh
|
||
pstn-trunk` after `asterisk` is installed — droplet or home/LAN, 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's `AST_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.sh` integration** — its Extensions tab
|
||
shows both concurrency caps and every extension (parsed from
|
||
`pjsip.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.env` so "update in place"
|
||
reapplies them without re-prompting — the concurrency cap *numbers*
|
||
themselves are not structural, they live in `pstn-limits.conf` instead
|
||
(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 is ever compromised.
|
||
|
||
**Update — read VoIP.ms's actual ToS (not just the wiki) on this.** The
|
||
wiki says plainly "only accounts with a balance over $0 are able to send
|
||
and receive calls" — new call attempts should be blocked in real time at
|
||
$0, and that's still the core assumption this design leans on. But the
|
||
ToS separately says the account "may run on a negative balance," that any
|
||
negative balance is "immediately due and payable," that VoIP.ms may
|
||
suspend an account below a $5 minimum balance (30-day notice first), and
|
||
may permanently close it after 30 *consecutive* days negative. Read
|
||
together, not a contradiction — two different things:
|
||
- **Can new calls start** — real-time balance check, blocked at $0. Core
|
||
assumption holds.
|
||
- **Can the balance ever read negative** — yes, most plausibly from
|
||
recurring fees (DID monthly, E911) landing when the balance is already
|
||
near zero, or edge-case settlement of an in-progress call ticking
|
||
slightly negative before teardown. Neither is a runaway toll-fraud
|
||
scenario; both mean liability isn't cleanly capped at the funded amount
|
||
to the exact penny, and the account needs topping up within the 30-day
|
||
windows or it gets suspended/closed (an account-status consequence, not
|
||
"30 free days of unblocked calling while negative").
|
||
- Still not verified against an actual live account — this is a read of
|
||
their published wiki + ToS text, not a test. Watch the real balance for
|
||
the first month or two after go-live, and don't panic at a small
|
||
negative reading — check whether it's a recurring fee or an actual call
|
||
spike before assuming the block failed.
|
||
- **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* — but
|
||
**NANP-only alone was not actually sufficient**, and this design carried
|
||
that gap for a while before it was caught. NANP includes Caribbean/Atlantic
|
||
nations and several US territories (Bahamas, Puerto Rico, Dominican
|
||
Republic, and 24 others) that dial exactly like a normal 10-digit US number
|
||
but bill at international/premium rates — a well-known toll-fraud/
|
||
"one-ring scam" vector specifically because the format looks domestic. The
|
||
NANP pattern match alone would have let a compromised extension reach every
|
||
one of these. **Fixed**: `services/pstn-trunk.sh` now blocks those 27 area
|
||
codes explicitly, checked before permission tier, for every extension
|
||
regardless of tier — see the dialplan file's own comment and
|
||
"Non-US NANP area codes are blocked" in its generated README for the full
|
||
list. With that fix in place, cost-per-minute risk actually is closed (a
|
||
compromised box can only ever reach genuine US-rate destinations). 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, droplet mode)
|
||
- 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**:
|
||
`_1NXXNXXXXXX` (11-digit NANP with leading 1) and `_NXXNXXXXXX` (10-digit,
|
||
auto-prefixed with 1), both routed to the trunk. No catch-all `_X.`
|
||
pattern. **Also implemented**: an explicit block on the 27 NANP area
|
||
codes that aren't actually US (Caribbean/Atlantic nations + US
|
||
territories — see the toll-fraud nuance above for why this matters),
|
||
checked via `REGEX()` against the extracted area code before permission
|
||
tier, ahead of every other check.
|
||
- **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 Extensions
|
||
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,
|
||
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_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)
|
||
Looked at as alternatives to VoIP.ms specifically because VoIP.ms's own ToS
|
||
confirms it *selectively* requires a government ID scan at signup (VPN/proxy
|
||
use, certain number types, internal risk scoring — not universal, but real).
|
||
None of these were verified to *never* require ID either; KYC requirements
|
||
are trending industry-wide (FCC STIR/SHAKEN, Robocall Mitigation Database),
|
||
so treat "no ID requirement found" as exactly that, not confirmation.
|
||
|
||
- **DIDLogic** — **ruled out**: has a $20/month minimum, confirmed directly
|
||
by checking their portal (not something search results surfaced). Would
|
||
otherwise have needed zero code changes — same IP-auth model and
|
||
single-IP-per-regional-POP hostname pattern this service already handles.
|
||
- **Anveo Direct** (anveodirect.com — a *different product* from plain
|
||
Anveo/anveo.com, its hosted-PBX sibling with monthly subscription tiers;
|
||
don't confuse the two, their pricing models aren't comparable) — leading
|
||
candidate. ~$0.001/min outbound, no minimum commitment, $25 minimum to
|
||
fund the account (close to VoIP.ms's $15). Confirmed via their current
|
||
official FAQ (fetched directly, not via search — search results had gone
|
||
stale on this exact point):
|
||
- IP authentication, no SIP registration — matches this service's
|
||
architecture with zero changes needed to the auth model.
|
||
- **No dial-prefix requirement** — a stale/outdated forum claim (found via
|
||
search) suggested one was needed; the *current* official FAQ's own
|
||
sample config shows plain number dialing, nothing prepended. Don't
|
||
trust that claim if it resurfaces.
|
||
- **Sends inbound signaling from multiple fixed IPs**, not one:
|
||
`169.48.232.158`, `204.216.109.55`, `176.9.39.206`, `72.9.149.25` (per
|
||
their FAQ as of this writing — confirmed different from what web search
|
||
surfaced independently, which was a different, apparently stale set of
|
||
5 IPs). This is what prompted the multi-IP `identify` support below.
|
||
**Re-confirmed directly with Anveo Direct support** (ticket reply from
|
||
MFonk, 7/22/2026): these 4 IPs are still current/accurate.
|
||
- STIR/SHAKEN signing is handled by Anveo as intermediate provider for
|
||
personal use (originating your own calls, not reselling to customers) —
|
||
no FCC registration/certificate needed on our side.
|
||
- ~~Still open: does Anveo actually block new outbound calls in real time
|
||
at $0 balance~~ **Resolved** — asked their support directly (same
|
||
ticket, 7/22/2026): "all calls (incoming and outgoing) will be blocked"
|
||
at $0 balance, in real time, not just via the recurring-fee
|
||
grace-period mechanism. This also answers the inbound question that
|
||
hadn't been asked of VoIP.ms either (see item 6 below): **inbound is
|
||
blocked at $0 the same as outbound**, not billed-through unconditionally.
|
||
This closes the crux assumption behind the toll-fraud backstop layering
|
||
for Anveo Direct specifically — VoIP.ms's own equivalent behavior is
|
||
still unverified against a live account (see item 6).
|
||
- **SIP.US** — prepaid, but a flat ~$24.95/mo per-channel (unlimited minutes
|
||
on that channel) model, not pay-per-minute — **not actually
|
||
similarly-priced** at this project's volume (100 min/month costs ~$1-3/mo
|
||
pay-per-minute vs. $24.95/mo flat). Notable anyway for automated real-time
|
||
fraud detection (kills in-progress unauthorized calls, auto-disables
|
||
international calling on suspicious activity) — a provider-side version of
|
||
what this repo hand-built in the dialplan, if that trade-off (higher
|
||
price, less to maintain yourself) ever appeals.
|
||
- 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.
|
||
|
||
## Multi-provider support beyond VoIP.ms — implemented
|
||
`services/pstn-trunk.sh` now generalizes two things that were originally
|
||
built VoIP.ms-shaped, driven directly by researching Anveo Direct as an
|
||
alternative:
|
||
- **Multiple inbound `identify` match IPs**, not just one. The install
|
||
prompt still auto-resolves the server hostname to an IP (VoIP.ms's
|
||
one-POP-one-hostname model), but now also asks for any *additional* known
|
||
source IPs a provider might publish (Anveo Direct's case) — PJSIP allows
|
||
repeating `match=` within a single `identify` object to build one match
|
||
set against the trunk endpoint, so this needed no new config objects, just
|
||
a loop emitting one `match=` line per IP.
|
||
- No dial-prefix support was added — turned out not to be needed for Anveo
|
||
Direct once verified against their current official FAQ rather than a
|
||
stale search result. If a future provider genuinely needs one, that's the
|
||
next generalization point (a prefix prepended to `${EXTEN}` before
|
||
`Dial()`).
|
||
|
||
**Bug caught while testing this**: `.pstn-trunk.env` (the structural
|
||
settings file "update in place" reapplies) was being written with unquoted
|
||
values. Harmless as long as every value happened to be a single word, but a
|
||
multi-IP list (always space-separated once there's more than one) or a
|
||
multi-word provider name (e.g. "Anveo Direct") broke `source`-ing it
|
||
entirely — bash treats the second word as a command to run ("Direct:
|
||
command not found"). This was a latent bug that predates the multi-IP work
|
||
(a multi-extension ring group has the same shape), only surfaced by
|
||
actually exercising the update-mode round trip instead of just inspecting
|
||
generator output. Fixed by quoting every value in that heredoc.
|
||
|
||
## 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 — `_1NXXNXXXXXX` / `_NXXNXXXXXX`. ~~NANP
|
||
≠ US gap~~ Done — 27 Caribbean/territory area codes explicitly blocked
|
||
(see toll-fraud nuance above); this was a real, live gap in the design
|
||
for a while, not a hypothetical.
|
||
**Real bug, confirmed live (2026-07-23):** both patterns shipped one
|
||
digit short (`_1NXXNXXXXX` / `_NXXNXXXXX` — 10/9 characters instead of
|
||
the correct 11/10), so Asterisk's exact-length pattern matching never
|
||
matched a real NANP number at all. Every outbound test call failed with
|
||
"extension not found in context 'intercom'" — looked exactly like a
|
||
config-loading problem (and several genuinely were, along the way: a
|
||
stale settings file, the #include never reaching the live config) but
|
||
the actual root cause underneath all of that was this one missing `X`.
|
||
Found by dumping `dialplan show intercom` and counting characters by
|
||
hand against a real dialed number, not by inspection alone.
|
||
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` supports the `asterisk` install in either mode,
|
||
droplet or home/LAN (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 — 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 via
|
||
`pstn-limits.conf`/web UI. ~~Spend/volume alert~~ Done — ntfy, monthly
|
||
threshold + hourly burst check, run **every minute** (systemd timer, not
|
||
hourly cron — see item 8), plus immediate alerts on denied/rejected
|
||
calls.
|
||
6. Verify against a live VoIP.ms account (still not done — only their wiki
|
||
+ ToS text has been read, see "Decision so far" above for what that
|
||
turned up): confirm new outbound calls actually get blocked at $0
|
||
balance as documented; watch whether/when the balance goes slightly
|
||
negative in normal operation (expected from recurring fees, not
|
||
necessarily a sign of a problem) and top up within the 30-day windows
|
||
the ToS describes so the account/DID doesn't get suspended or closed.
|
||
Also confirm 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).
|
||
7. Anveo Direct specifically has a provider-side "Call Security" setting
|
||
(account portal, not exposed through SIP config) — a per-minute rate
|
||
cap ("block calls with rate more than $X/min, 0 = block all") and a
|
||
maximum call duration. Worth setting on any Anveo Direct account as a
|
||
provider-side backstop independent of this repo's own dialplan code —
|
||
not automatable from here since it's their web UI, not a config file.
|
||
8. ~~Spend-cap kill-switch~~ Done — a genuinely hard stop, not just an
|
||
alert: once `pstn-trunk-usage-alert.sh` estimates month-to-date spend
|
||
has reached an admin-set cap (prompted at install/update, `0` = disabled),
|
||
it writes `tripped=1` to `pstn-trunk-killswitch.conf`, read live by the
|
||
dialplan on *every* PSTN call attempt (both directions — internal
|
||
Asterisk-to-Asterisk calling is untouched) and blocked immediately with a
|
||
loud (`Priority: urgent`) ntfy alert. A separate loud warning fires once
|
||
spend reaches 80% of the cap, before it trips. **Does not auto-reset** —
|
||
requires manually clearing it via the CLI installer (update mode), by
|
||
design, so a compromised/careless web session can't quietly re-enable
|
||
spend after a trip. Honesty caveat carried over from the original spend
|
||
estimate: this is estimate-based (call count/duration × an entered
|
||
rate), not real billing data, and only as fresh as the last check — now
|
||
every minute (see item 9) rather than hourly, shrinking but not
|
||
eliminating the reaction-time gap between an overage happening and
|
||
calling actually getting blocked.
|
||
9. ~~Hourly cron → per-minute systemd timer~~ Done —
|
||
`pstn-trunk-usage.timer`/`.service` (falls back to a cron.d entry if
|
||
systemd isn't available), running the same usage-alert script every
|
||
minute instead of hourly. Directly motivated by item 8: the periodic
|
||
check is now also the kill-switch's enforcement point, so the interval
|
||
between checks is the exposure window, and a tighter interval shrinks it.
|
||
10. ~~International calling (beyond NANP/US)~~ Done — CLI-only (never the
|
||
Security Dashboard web UI, on purpose: this widens which countries can
|
||
be dialed/billed to at all, a more security-sensitive control than
|
||
who's already allowed to use an already-fixed scope), continent →
|
||
country menu (`_pstn_manage_international` in `services/pstn-trunk.sh`),
|
||
always asked every run with no way to skip the *question* itself
|
||
(though answering "no" leaves the existing allow-list untouched), with
|
||
the resulting allow-list printed exactly once right after — not
|
||
repeated during the spend-cap prompts, a correction from an earlier
|
||
draft of this design. Optional auto-expiry with two ntfy notices (day
|
||
of, and at the moment of expiry) and active re-blocking (the periodic
|
||
script clears the allow-list once past `expires`, it doesn't just
|
||
notify). Dialing uses the US `011` prefix convention (`_011X.` dialplan
|
||
pattern); allowed country codes are the REGEX() *pattern* side (admin-
|
||
controlled), dialed digits are always the *string* being tested — same
|
||
safe direction as every other permission check in this file. Only
|
||
`full`-tier extensions can use it regardless of which countries are
|
||
allowed.
|
||
11. Internal SIP `MESSAGE` (native Asterisk texting, no carrier SMS/cost) —
|
||
**done**. The permission layer: a `messaging=yes` flag per extension in
|
||
`pstn-permissions.conf`, independent of the PSTN calling tiers (an
|
||
extension can be internal-tier for calling and still messaging-enabled,
|
||
or vice versa), prompted at install time AND a checkbox right in the
|
||
Security Dashboard's PSTN Trunk permissions table (alongside
|
||
tier/approved-numbers) — no need to re-run the CLI installer just to
|
||
change who can message. Confirmed it correctly survives tier changes and
|
||
personal-DID assignment/removal on the same extension (this is what
|
||
surfaced the tier=internal section-wipe bug fixed above).
|
||
The dialplan gap flagged here previously is now closed: a real install's
|
||
`pjsip.conf`/`extensions.conf` were pulled (2026-07-23) and confirmed
|
||
every endpoint sets `context=intercom` with `message_context` blank
|
||
(falls back to `context`), and `[intercom]` gets one exact-match
|
||
`exten => <ext>,1,...` per device, freshly regenerated by Easy
|
||
Asterisk's own `rebuild_dialplan()` on every dialplan rebuild — exactly
|
||
the collision this doc worried about. Solved by NOT sharing
|
||
`[intercom]`: `services/asterisk.sh` now explicitly sets
|
||
`message_context=sip-messaging` on every endpoint (patched into both of
|
||
Easy Asterisk's device-creation code paths — the CLI menu's bash
|
||
heredoc and the web admin's Python `add_device()` — so new devices pick
|
||
it up automatically, plus a one-time migration for devices that already
|
||
existed) and routes messages to a dedicated `[sip-messaging]` context in
|
||
`messaging-dialplan.conf`, gated on the sender's `messaging` flag via
|
||
`AST_CONFIG()`. Zero overlap with `[intercom]`'s own call routing.
|
||
One piece still flagged rather than papered over: the `MESSAGE(from)`
|
||
sender-extraction (`CUT()`-based, written to tolerate a display-name
|
||
prefix) hasn't been confirmed against real MESSAGE traffic yet — fails
|
||
closed (denies) if it ever parses wrong, but worth a live test.
|
||
12. Anveo Direct's real-time \$0-balance blocking, confirmed — support reply
|
||
(MFonk, 7/22/2026): "all calls (incoming and outgoing) will be blocked"
|
||
at \$0, in real time, not just via a recurring-fee grace period. This
|
||
directly answers the question this doc used to flag as a live
|
||
verification gap (see the Anveo Direct provider notes above) and
|
||
validates the outermost, actually-hard layer of the defense-in-depth
|
||
stack — everything else in this file (dialplan restrictions, the
|
||
kill-switch below) is a second layer on top of that, not a replacement
|
||
for it.
|
||
13. ~~Kill-switch: blended rate estimate~~ Done — per-country international
|
||
rates. The kill-switch/spend-alert estimate used one flat `RATE` for
|
||
every outbound minute, domestic or international, which badly
|
||
under/over-estimates international cost (rates for other countries are
|
||
rarely close to the ~$0.01/min US rate). Now the CLI country menu also
|
||
asks for a per-minute rate when a country is added (admin-entered, not
|
||
fetched live — no confirmed rates API exists for either supported
|
||
provider), stored alongside the code/name in `pstn-intl-allowed.conf`.
|
||
`pstn-trunk-usage-alert.sh` buckets each month's outbound call log into
|
||
domestic vs. per-country-international (longest-calling-code-prefix
|
||
match against the dialed digits after "011") and prices each bucket at
|
||
its own rate before summing. An international call to a code that's no
|
||
longer on the allow-list is excluded from the estimate entirely (a
|
||
known, minor undercount) since there's no rate left to charge it at.
|
||
14. ~~Kill-switch: in-progress calls not stopped~~ Done — active hangup.
|
||
Previously, a call already connected when the spend cap was crossed
|
||
just kept running (and costing) until it ended naturally — the
|
||
dialplan only gates the *start* of a call, and the periodic check
|
||
can't retroactively un-place one. Now, every run of
|
||
`pstn-trunk-usage-alert.sh` while the kill-switch is tripped (not just
|
||
the run that trips it, closing a race where a call starts just before
|
||
the flag goes live) also does `docker exec ... asterisk -rx "core show
|
||
channels concise"`, filters for the trunk endpoint's active channels,
|
||
and force-hangs each one up via `channel request hangup` — hanging up
|
||
the trunk-side leg of a bridged call tears down both legs. This is
|
||
deliberately placed OUTSIDE the "does the call log exist" guard, since
|
||
the sweep must still run even if the log is missing/rotated (caught by
|
||
testing: an earlier version nested it inside that guard and the sweep
|
||
silently never fired without a log file present). Bounds worst-case
|
||
overage on an in-progress call to roughly one check interval, not
|
||
open-ended. Still not a real-billing guarantee — see the "How
|
||
bulletproof is this?" section `services/pstn-trunk.sh` writes into
|
||
`README-pstn-trunk.md` for the full honest breakdown of what's actually
|
||
hard (the provider's own $0-balance block) vs. estimate-based (this
|
||
kill-switch).
|
||
15. ~~Multiple DIDs, one per extension ("personal numbers")~~ Done —
|
||
additive to the existing shared trunk DID/ring-group, not a
|
||
replacement. Anveo Direct's DID pricing (~$0.15/mo + $0.25 setup on the
|
||
Per Minute plan) makes "everyone gets their own number" genuinely cheap
|
||
at personal-use volumes, and multiple DIDs sharing one trunk/account is
|
||
exactly what that plan is built for (10 dedicated incoming channels
|
||
bundled in). New `pstn-personal-dids.conf` (DID -> owner extension,
|
||
read live by the dialplan for inbound routing) plus a `personal_did=`
|
||
field per extension in `pstn-permissions.conf` (the outbound Caller-ID
|
||
override) — both kept in sync automatically by a single write path
|
||
(CLI prompt at install/update, or the Security Dashboard's "PSTN
|
||
Trunk" tab), never requiring the admin to hand-edit both files
|
||
consistently. Inbound: 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 a personal DID isn't the shared line.
|
||
Outbound: `pstn_check_busy` (the one shared exit point for both
|
||
domestic and international dialing) looks up the calling extension's
|
||
`personal_did` and uses it as `CALLERID(num)` instead of the shared
|
||
trunk DID when one is assigned. A personal DID assigned to an
|
||
internal-tier extension is accepted but silently never rings anyone
|
||
until that extension is also granted full/restricted tier — both the
|
||
CLI and the dashboard warn about this at assignment time rather than
|
||
blocking it, matching this repo's general permissive-with-warnings
|
||
style. Caught and fixed a real bug while building this: the
|
||
dashboard's `write_permission()` did `cp.remove_section(ext)` when
|
||
tier was set to "internal", which silently discarded any
|
||
`messaging=yes` or `personal_did=` already on that extension — fixed
|
||
to remove only the tier/allowed_numbers keys, dropping the section
|
||
only once nothing else is left in it.
|
||
16. Known-provider quick-pick — Done. A "1) Anveo Direct / 2) VoIP.ms /
|
||
3) manual" choice at the top of the provider prompts pre-fills known
|
||
values (Anveo Direct: `sbc.anveo.com`, the 4 published signaling IPs;
|
||
VoIP.ms: just the provider name) — every value stays editable at each
|
||
prompt, so this changes defaults only, never behavior. Extensions/
|
||
users remain fully independent of this service either way — they're
|
||
created through the base Asterisk install's own device management,
|
||
and `pstn-trunk.sh` only grants PSTN permissions/personal numbers to
|
||
extensions that already exist.
|
||
17. **Open, unresolved**: Anveo Direct's own "Outbound Trunks" configuration
|
||
page documents outbound dialing as `[PREFIX]PHONENUMBER@sbc.anveo.com`
|
||
— a per-trunk custom prefix. This directly contradicts the earlier
|
||
"no dial prefix needed" finding sourced from their FAQ (see the Anveo
|
||
Direct provider notes above). Not resolved either way: whether the
|
||
Prefix field can be left blank when creating an outbound Call
|
||
Termination trunk in their portal. This dialplan dials the bare
|
||
number with no prefix, matching the FAQ-sourced finding — if the
|
||
Prefix field turns out to require a non-empty value, outbound calls
|
||
through that trunk won't match and will fail, and this installer
|
||
would need a `PREFIX` setting threaded into `${EXTEN}` before
|
||
`Dial()`. Flagged with a loud warning in the CLI's Anveo Direct
|
||
quick-pick rather than silently trusting the older finding. Also
|
||
unresolved: a Trial Anveo Direct account shows its own "$2 / 30 days"
|
||
spending limit and "$2 minimum account balance" in the portal —
|
||
unclear whether either changes once the account is verified/funded
|
||
beyond Trial status; this sits below and independent of anything this
|
||
repo's own kill-switch enforces.
|