From 719f9117be627c270de10dc678806e1e74aa1c77 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 22:41:22 +0000 Subject: [PATCH 1/6] Record inbound decision and cost estimate in VoIP.ms planning doc Inbound (DID) is now decided as wanted, not outbound-only. Adds a cost estimate table for 100 min/month each direction, and clarifies that NANP-only restriction bounds cost-per-minute but not burn speed, so the concurrent-call cap and spend alert are required before funding a live trunk, not optional hardening. --- docs/pstn-calling-voipms-plan.md | 53 +++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index 47cc74c..5a6176c 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -18,6 +18,31 @@ addition on top of `asterisk-digital-ocean`. 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 @@ -32,6 +57,19 @@ anyone notices. Two independent layers matter more than either alone: 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 per-extension concurrent-call cap and spend/volume alert below as +required before funding a live trunk, not optional hardening. + ## What it takes technically (asterisk-digital-ocean) - A PJSIP trunk to VoIP.ms: `endpoint` / `aor` / `auth` / `identify` sections in the pjsip config, using either IP authentication or SIP @@ -45,10 +83,10 @@ anyone notices. Two independent layers matter more than either alone: catch-all `_X.` pattern — an explicit NANP pattern is itself a hard block on non-US destinations at the dialplan level. - VoIP.ms-specific setup that isn't scriptable (user does this manually): - create the account, decide whether a DID is needed (this research was - outbound-only — inbound PSTN wasn't discussed/decided), pick a VoIP.ms - POP/server (affects the trunk hostname), fund the prepaid balance, turn - off auto-recharge. + 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. - Defense-in-depth to design alongside the trunk (not yet designed): - Per-extension concurrent-call cap in the dialplan (`GROUP()` / `GROUP_COUNT()`) so one compromised extension can't open dozens of @@ -81,7 +119,8 @@ anyone notices. Two independent layers matter more than either alone: 2. IP auth vs. registration — confirm which VoIP.ms recommends for a single fixed-IP droplet. 3. Exact NANP dial pattern(s) and any prefix-stripping VoIP.ms requires. -4. Whether inbound (a DID) is wanted at all, or outbound-only for now — not - discussed yet. +4. Inbound is decided (wanted) — still need to pick pay-per-minute vs. + unlimited DID plan based on real expected volume, and decide on E911. 5. Design the concurrent-call cap and any spend/volume alerting mentioned - above. + above — treat as required before funding a live trunk, not optional + (see toll-fraud nuance above: NANP-only bounds cost/min, not burn speed). From 2ac2982e3820e34e96a355a55296f3b4dbc0cdba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 23:13:59 +0000 Subject: [PATCH 2/6] Add voipms-trunk service: US-only outbound PSTN, max 3 concurrent calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a VoIP.ms SIP trunk on top of asterisk-digital-ocean: IP-authenticated trunk (no password stored), NANP-only outbound dialplan, a global 3-call concurrent cap via GROUP()/GROUP_COUNT(), and inbound routing to one extension. Config lives in its own include files rather than being appended directly to pjsip.conf/extensions.conf, since Easy Asterisk fully regenerates both from its own internal state — the includes are patched into the vendor's generator functions so they survive that regeneration. Wires the new service into setup.sh's is_installed() and README's services table, and updates docs/pstn-calling-voipms-plan.md to reflect what's now implemented vs. still open (spend/volume alerting, live-account verification). --- README.md | 3 +- docs/pstn-calling-voipms-plan.md | 90 ++++--- services/voipms-trunk.sh | 389 +++++++++++++++++++++++++++++++ setup.sh | 1 + 4 files changed, 445 insertions(+), 38 deletions(-) create mode 100644 services/voipms-trunk.sh diff --git a/README.md b/README.md index 7daf1da..a280f6c 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. | Group | Services | |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | -| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `asterisk-digital-ocean`, `security-dashboard`, `sunshine` | +| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `asterisk-digital-ocean`, `voipms-trunk`, `security-dashboard`, `sunshine` | | `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | @@ -92,6 +92,7 @@ homelab homeassistant asterisk asterisk-digital-ocean + voipms-trunk security-dashboard sunshine diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index 5a6176c..2916480 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -1,9 +1,15 @@ # 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. **Nothing -has been implemented yet** — this is prep for a future `services/*.sh` -addition on top of `asterisk-digital-ocean`. +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. ## Decision so far - **Provider: VoIP.ms.** Chosen for its prepaid-balance model: turn off @@ -67,33 +73,38 @@ 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 per-extension concurrent-call cap and spend/volume alert below as -required before funding a live trunk, not optional hardening. +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. ## What it takes technically (asterisk-digital-ocean) -- A PJSIP trunk to VoIP.ms: `endpoint` / `aor` / `auth` / `identify` - sections in the pjsip config, using either IP authentication or SIP - registration — VoIP.ms supports both. IP auth is simpler for a droplet - (it has a static IP already) and avoids storing a SIP password in the - config at all — worth confirming with VoIP.ms which they actually - recommend before choosing. -- An outbound dialplan route matching US numbers only, e.g. `_1NXXNXXXXX` - (11-digit NANP with leading 1) or `_NXXNXXXXX`, depending on how numbers - get dialed from the existing extensions, routed to the VoIP.ms trunk. No - catch-all `_X.` pattern — an explicit NANP pattern is itself a hard block - on non-US destinations at the dialplan level. +- 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`. +- 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. -- Defense-in-depth to design alongside the trunk (not yet designed): - - Per-extension concurrent-call cap in the dialplan (`GROUP()` / - `GROUP_COUNT()`) so one compromised extension can't open dozens of - simultaneous outbound legs at once. - - 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. Not designed yet. + ($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. +- 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. - 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 @@ -111,16 +122,21 @@ required before funding a live trunk, not optional hardening. across the space, VoIP.ms included. ## Open items for whoever picks this up next -1. Decide: new `services/voipms-trunk.sh`, or an optional trunk section - added directly to `services/asterisk-digital-ocean.sh`? Leaning toward a - separate service file so trunk config isn't forced on installs that - don't want PSTN calling, matching this repo's one-feature-per-file - convention (see CLAUDE.md). -2. IP auth vs. registration — confirm which VoIP.ms recommends for a single - fixed-IP droplet. -3. Exact NANP dial pattern(s) and any prefix-stripping VoIP.ms requires. -4. Inbound is decided (wanted) — still need to pick pay-per-minute vs. - unlimited DID plan based on real expected volume, and decide on E911. -5. Design the concurrent-call cap and any spend/volume alerting mentioned - above — treat as required before funding a live trunk, not optional - (see toll-fraud nuance above: NANP-only bounds cost/min, not burn speed). +1. ~~Decide: new `services/voipms-trunk.sh`...~~ Done — separate service file. +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). +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 + 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). diff --git a/services/voipms-trunk.sh b/services/voipms-trunk.sh new file mode 100644 index 0000000..929b3d6 --- /dev/null +++ b/services/voipms-trunk.sh @@ -0,0 +1,389 @@ +#!/bin/bash +# services/voipms-trunk.sh — VoIP.ms SIP trunk add-on for asterisk-digital-ocean: +# US-only outbound (NANP dialplan restriction), capped at 3 concurrent +# outbound calls, IP-authenticated trunk (no SIP password stored), plus +# inbound routing to one extension. +# +# Requires an existing services/asterisk-digital-ocean.sh install — this adds +# a PSTN trunk on top of it and does not stand alone. See +# docs/pstn-calling-voipms-plan.md for the design/cost background this is +# built from. +# +# Part of the modular post-install system (sourced by setup.sh). + +register_service voipms-trunk homelab "VoIP.ms SIP trunk for asterisk-digital-ocean — US-only outbound, max 3 concurrent calls" + +# ── Surviving Easy Asterisk's regeneration ────────────────────────────────── +# Easy Asterisk (the vendor project asterisk-digital-ocean.sh builds on) fully +# OVERWRITES both pjsip.conf and extensions.conf from its own internal state: +# - extensions.conf: rebuilt by rebuild_dialplan() on every container start, +# and whenever a device/room is added or removed via the web admin. +# - pjsip.conf: rewritten by generate_pjsip_conf() whenever VLAN/domain/TLS +# settings are changed via the CLI menu (docker exec ... easy-asterisk). +# It restores only its own "; === Device:"-marked sections from backup — +# a hand-appended trunk section would be silently wiped the next time +# that runs. +# So the trunk/dialplan content below lives in its own files and is +# #include'd from the generated files instead of appended directly. To make +# the #include itself survive regeneration too, _voipms_patch_vendor_files +# (below) patches it into the vendor's *generator functions* — the same +# technique this repo already uses for the logger.conf security-logging fix +# in _asterisk_do_refresh_vendor_files (see services/asterisk-digital-ocean.sh). +# +# Caveat: if the base asterisk-digital-ocean install is later refreshed +# ("update in place", which re-copies fresh vendor files) independently of +# this service, the patch is wiped along with it and needs reapplying — run +# this service again (fresh or update mode both reapply it) after any +# asterisk-digital-ocean update. + +# ── Shared: patch vendor generator functions to #include our config ──────── +# Anchors on "user_agent=EasyAsterisk" (pjsip.conf's [global] section) and +# "[intercom]" (extensions.conf) — each confirmed to appear exactly once per +# file in the vendor source, so this is safe regardless of what else changes +# around it upstream. Idempotent: skips files that already have the include. +_voipms_patch_vendor_files() { + local EA_DIR="$1" + local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh" + local EASY1="$EA_DIR/easy-asterisk.sh" + local EASY2="$EA_DIR/easy-asterisk-v0.10.0.sh" + local f + + for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do + [[ -f "$f" ]] || { log_error "$f not found — is asterisk-digital-ocean fully installed?"; return 1; } + done + + for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do + if ! grep -q 'voipms-trunk-pjsip.conf' "$f"; then + if grep -q '^user_agent=EasyAsterisk$' "$f"; then + sed -i '/^user_agent=EasyAsterisk$/a #include voipms-trunk-pjsip.conf' "$f" + else + log_warning "$(basename "$f"): 'user_agent=EasyAsterisk' anchor not found — vendor template changed upstream." + log_warning " Add '#include voipms-trunk-pjsip.conf' manually after [global] in this file's pjsip.conf heredoc." + fi + fi + done + + for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do + if ! grep -q 'voipms-trunk-dialplan.conf' "$f"; then + if grep -q '^\[intercom\]$' "$f"; then + sed -i '/^\[intercom\]$/a #include voipms-trunk-dialplan.conf' "$f" + else + log_warning "$(basename "$f"): '[intercom]' anchor not found — vendor template changed upstream." + log_warning " Add '#include voipms-trunk-dialplan.conf' manually after [intercom] in this file's extensions.conf heredoc." + fi + fi + done + + log_success "Vendor generator functions patched to include the VoIP.ms trunk config." +} + +# ── Shared: pjsip trunk config (aor/identify/endpoint, IP-authenticated) ─── +_voipms_write_pjsip_include() { + local FILE="$1" SERVER="$2" SERVER_IP="$3" DID="$4" + cat > "$FILE" << 'EOF' +; VoIP.ms trunk — IP authentication, no password stored (see +; docs/pstn-calling-voipms-plan.md). Regenerated by services/voipms-trunk.sh — +; edit there, not here directly, or a reinstall/update will overwrite this. +; +; match= below is the resolved IP of the server hostname at install time. +; VoIP.ms's servers are fixed per-POP, but if calls stop matching after a +; provider-side change, re-run this service to re-resolve and rewrite it. + +[voipms-trunk] +type=aor +contact=sip:__VOIPMS_SERVER__ +qualify_frequency=60 + +[voipms-trunk] +type=identify +endpoint=voipms-trunk +match=__VOIPMS_SERVER_IP__ + +[voipms-trunk] +type=endpoint +context=from-voipms +disallow=all +allow=ulaw,alaw +aors=voipms-trunk +from_user=__VOIPMS_DID__ +from_domain=__VOIPMS_SERVER__ +callerid=__VOIPMS_DID__ +direct_media=no +EOF + sed -i "s/__VOIPMS_SERVER_IP__/${SERVER_IP}/g; s/__VOIPMS_SERVER__/${SERVER}/g; s/__VOIPMS_DID__/${DID}/g" "$FILE" +} + +# ── Shared: outbound/inbound dialplan (NANP-only, 3-concurrent cap) ──────── +# Continues in the [intercom] context established just above this include +# (rebuild_dialplan() writes "[intercom]" then this #include right after it), +# so existing extensions can dial out through it directly. [from-voipms] +# below is a separate context, for calls arriving from the trunk. +_voipms_write_dialplan_include() { + local FILE="$1" DID="$2" RING_EXT="$3" + cat > "$FILE" << 'EOF' +; VoIP.ms outbound/inbound — US-only (NANP), max 3 concurrent outbound calls. +; Regenerated by services/voipms-trunk.sh — edit there, not here directly. +; +; No catch-all pattern here on purpose: only these two NANP patterns route +; to the trunk, so a compromised extension can't reach anything else even if +; the trunk itself would technically allow more. See +; docs/pstn-calling-voipms-plan.md for the full toll-fraud reasoning. + +exten => _1NXXNXXXXX,1,NoOp(VoIP.ms outbound call to ${EXTEN}) + same => n,GotoIf($[${GROUP_COUNT(voipms-out)} >= 3]?voipms_busy,1) + same => n,Set(GROUP()=voipms-out) + same => n,Set(CALLERID(num)=__VOIPMS_DID__) + same => n,Dial(PJSIP/${EXTEN}@voipms-trunk,60) + same => n,Hangup() + +exten => _NXXNXXXXX,1,NoOp(Assuming NANP, adding leading 1) + same => n,Goto(1${EXTEN},1) + +exten => voipms_busy,1,NoOp(VoIP.ms: 3 concurrent outbound calls already active — rejecting) + same => n,Busy(15) + same => n,Hangup() + +[from-voipms] +exten => _X.,1,NoOp(Inbound PSTN call from ${CALLERID(num)}) + same => n,Dial(PJSIP/__VOIPMS_RING_EXT__,20) + same => n,Hangup() +EOF + sed -i "s/__VOIPMS_DID__/${DID}/g; s/__VOIPMS_RING_EXT__/${RING_EXT}/g" "$FILE" +} + +install_voipms-trunk() { + require_docker || return 1 + + local EA_DIR="$DOCKER_DIR/asterisk-digital-ocean" + local ASTERISK_DIR="$EA_DIR/config/asterisk" + local PJSIP_INCLUDE="$ASTERISK_DIR/voipms-trunk-pjsip.conf" + local DIALPLAN_INCLUDE="$ASTERISK_DIR/voipms-trunk-dialplan.conf" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would require an existing asterisk-digital-ocean install at $EA_DIR" + echo "[DRY-RUN] Would prompt for VoIP.ms server/POP hostname, DID, and ring extension" + echo "[DRY-RUN] Would resolve the server hostname to an IP for inbound call matching" + echo "[DRY-RUN] Would patch vendor generator functions to #include the trunk config" + echo "[DRY-RUN] Would write $PJSIP_INCLUDE and $DIALPLAN_INCLUDE" + echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if already configured" + echo "[DRY-RUN] Would restart the asterisk container to apply" + return 0 + fi + + if [[ ! -f "$EA_DIR/docker-compose.yml" ]]; then + log_error "asterisk-digital-ocean isn't installed at $EA_DIR — install it first:" + log_error " sudo ./setup.sh asterisk-digital-ocean" + log_error "This service adds a VoIP.ms PSTN trunk on top of it; it doesn't stand alone." + return 1 + fi + + log_info "Configuring VoIP.ms SIP trunk for asterisk-digital-ocean..." + log_info "US-only outbound (NANP dialplan), max 3 concurrent outbound calls, IP-authenticated trunk." + echo "" + log_warning "Before continuing, on VoIP.ms's side you should already have: created an" + log_warning "account, funded the \$15 minimum prepaid balance, turned OFF auto-recharge" + log_warning "(Client Area -> Balance Management), ordered a DID with IP authentication" + log_warning "pointed at this droplet's public IP, and picked a server/POP. Also restrict" + log_warning "outbound routing to US/NANP on VoIP.ms's own side if it offers that — this" + log_warning "dialplan is the second, independent layer, not a substitute for the first." + log_warning "See docs/pstn-calling-voipms-plan.md for the full background." + echo "" + + # ── Existing install? Offer update-in-place instead of a full reinstall ── + if [[ -f "$PJSIP_INCLUDE" && -f "$DIALPLAN_INCLUDE" ]]; then + log_info "Existing VoIP.ms trunk config found." + local REINSTALL_MODE="" + prompt_reinstall_mode REINSTALL_MODE + case "$REINSTALL_MODE" in + update) + local _SERVER _SERVER_IP _DID _RING_EXT + _SERVER="$(grep -m1 '^from_domain=' "$PJSIP_INCLUDE" | cut -d= -f2-)" + _SERVER_IP="$(grep -m1 '^match=' "$PJSIP_INCLUDE" | cut -d= -f2-)" + _DID="$(grep -m1 '^from_user=' "$PJSIP_INCLUDE" | cut -d= -f2-)" + # Match only the inbound line (Dial(PJSIP/,20)) — the + # outbound line matches Dial(PJSIP/${EXTEN}@voipms-trunk,60), + # which starts with "$" right after PJSIP/, not a digit. + _RING_EXT="$(grep -m1 -E 'Dial\(PJSIP/[0-9]+,20\)' "$DIALPLAN_INCLUDE" | sed -E 's/.*Dial\(PJSIP\/([0-9]+),20\).*/\1/')" + if [[ -z "$_SERVER" || -z "$_DID" || -z "$_RING_EXT" ]]; then + log_warning "Couldn't parse existing settings back out — falling back to a fresh install (every prompt below)." + else + _voipms_patch_vendor_files "$EA_DIR" || return 1 + _voipms_write_pjsip_include "$PJSIP_INCLUDE" "$_SERVER" "$_SERVER_IP" "$_DID" + _voipms_write_dialplan_include "$DIALPLAN_INCLUDE" "$_DID" "$_RING_EXT" + ensure_docker_dir_ownership "$ASTERISK_DIR" + ( cd "$EA_DIR" && docker compose restart asterisk ) \ + && log_success "Updated — settings unchanged (server $_SERVER, DID $_DID, ring ext $_RING_EXT)." \ + || log_warning "Restart failed — check: docker compose -f $EA_DIR/docker-compose.yml logs asterisk" + return 0 + fi + ;; + cancel) + log_info "Leaving the existing VoIP.ms trunk config as-is." + return 0 + ;; + fresh) + log_info "Proceeding with a full fresh reinstall — every prompt below runs from scratch." + ;; + esac + fi + + # ── Prompts — VoIP.ms account details aren't scriptable, user sets these + # up manually on VoIP.ms's own site first (see warning above) ─────────── + local VOIPMS_SERVER="" + prompt_text "VoIP.ms server/POP hostname (e.g. atlanta2.voip.ms — pick the one closest to this droplet from VoIP.ms's server list):" "" VOIPMS_SERVER + if [[ -z "$VOIPMS_SERVER" ]]; then + log_error "A VoIP.ms server hostname is required — aborting." + return 1 + fi + + local VOIPMS_SERVER_IP="" + VOIPMS_SERVER_IP="$(getent ahostsv4 "$VOIPMS_SERVER" 2>/dev/null | awk '{print $1}' | head -1)" + if [[ -z "$VOIPMS_SERVER_IP" ]]; then + log_warning "Couldn't resolve $VOIPMS_SERVER — the identify section needs an IP to match inbound calls against." + prompt_text "Enter its IP manually (check VoIP.ms's server list page):" "" VOIPMS_SERVER_IP + if [[ -z "$VOIPMS_SERVER_IP" ]]; then + log_error "No IP available — aborting." + return 1 + fi + else + log_success "Resolved $VOIPMS_SERVER -> $VOIPMS_SERVER_IP" + fi + + local VOIPMS_DID="" + prompt_text "VoIP.ms DID (the 10-digit US phone number assigned to this trunk, digits only):" "" VOIPMS_DID + if [[ ! "$VOIPMS_DID" =~ ^[0-9]{10}$ ]]; then + log_error "That doesn't look like a 10-digit US number — aborting." + return 1 + fi + + local RING_EXT="" + prompt_text "Local extension to ring for inbound calls (must already exist in Easy Asterisk):" "" RING_EXT + if [[ -z "$RING_EXT" ]]; then + log_error "An extension number is required for inbound routing — aborting." + return 1 + fi + + # ── Patch vendor generator functions, then write the include files ─────── + _voipms_patch_vendor_files "$EA_DIR" || return 1 + + mkdir -p "$ASTERISK_DIR" + _voipms_write_pjsip_include "$PJSIP_INCLUDE" "$VOIPMS_SERVER" "$VOIPMS_SERVER_IP" "$VOIPMS_DID" + _voipms_write_dialplan_include "$DIALPLAN_INCLUDE" "$VOIPMS_DID" "$RING_EXT" + ensure_docker_dir_ownership "$ASTERISK_DIR" + chmod 644 "$PJSIP_INCLUDE" "$DIALPLAN_INCLUDE" + + # No new firewall rules: asterisk-digital-ocean.sh already opens SIP + # (5060/5061) and RTP (10000-20000) to the internet, and VoIP.ms's source + # IPs vary by POP/redundancy, so there's no single IP to scope this to + # even if narrowing it were otherwise worthwhile. + + # ── Docs (separate file — asterisk-digital-ocean already owns README.md + # in this same directory via write_readme, so don't overwrite it) ─────── + local DOC_FILE="$EA_DIR/README-voipms-trunk.md" + cat > "$DOC_FILE" << MD +# VoIP.ms SIP trunk (add-on to asterisk-digital-ocean) + +US-only outbound PSTN calling over a VoIP.ms trunk, capped at 3 concurrent +outbound calls, plus inbound routing to one extension. See +\`docs/pstn-calling-voipms-plan.md\` in the repo for the full design +background, cost estimate, and toll-fraud reasoning. + +## Current settings + +| Setting | Value | +|---|---| +| VoIP.ms server/POP | ${VOIPMS_SERVER} (${VOIPMS_SERVER_IP}) | +| DID | ${VOIPMS_DID} | +| Inbound rings extension | ${RING_EXT} | +| Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all | +| Concurrency cap | 3 simultaneous outbound calls (GROUP()/GROUP_COUNT() in the dialplan); a 4th gets a busy signal | + +## How this survives Easy Asterisk's own regeneration + +Easy Asterisk rewrites \`pjsip.conf\` and \`extensions.conf\` from its own +internal state (device list, network settings) rather than treating them as +hand-edited files. Trunk/dialplan config here lives in two files of its own, +\`#include\`'d from the generated files: + +- \`config/asterisk/voipms-trunk-pjsip.conf\` — the trunk's \`aor\`/\`identify\`/ + \`endpoint\` sections (IP-authenticated, no password stored). +- \`config/asterisk/voipms-trunk-dialplan.conf\` — the NANP-only outbound + routing + concurrency cap, and the \`[from-voipms]\` inbound context. + +The \`#include\` lines themselves are patched into Easy Asterisk's *generator +functions* (\`docker/entrypoint.sh\`, \`easy-asterisk.sh\`, +\`easy-asterisk-v0.10.0.sh\`) so they get re-emitted every time those functions +regenerate the config, instead of being wiped. + +**Caveat:** if the base \`asterisk-digital-ocean\` service is ever updated +independently (\`sudo ./setup.sh asterisk-digital-ocean\`, choosing "update in +place" — that path re-copies fresh vendor files), this patch is wiped along +with it. Re-run \`sudo ./setup.sh voipms-trunk\` afterward (update mode +reapplies the patch and rewrites both include files with your existing +settings, no re-prompting). + +## Changing settings + +Re-run \`sudo ./setup.sh voipms-trunk\` and choose "reinstall in place" — +current settings are read back out of the include files above and reapplied. +Choose "full install" instead to re-prompt for everything (new server, DID, +or ring extension). + +## Manual edits + +Don't hand-edit \`voipms-trunk-pjsip.conf\` / \`voipms-trunk-dialplan.conf\` +directly if you plan to re-run this installer later — it overwrites both +files unconditionally. If you do need one-off manual changes (e.g. testing a +second POP as a failover \`match=\` line), edit them directly and just don't +re-run the installer until you're ready to lose those changes, or restart +the container instead of running the installer to test them: + +\`\`\`bash +docker compose -f $EA_DIR/docker-compose.yml restart asterisk +\`\`\` + +## What the 3-call cap does and doesn't cover + +It bounds *how many* outbound calls can be active at once — the first line +of defense against a compromised extension opening dozens of parallel legs. +It does not bound total spend by itself; that's still the prepaid-balance + +auto-recharge-off layer on VoIP.ms's side. Both matter — see the toll-fraud +section of \`docs/pstn-calling-voipms-plan.md\`. + +## Verifying it's working + +\`\`\`bash +docker exec -it easy-asterisk-do asterisk -rx "pjsip show endpoint voipms-trunk" +docker exec -it easy-asterisk-do asterisk -rx "dialplan show intercom" +docker exec -it easy-asterisk-do asterisk -rx "dialplan show from-voipms" +\`\`\` + +A registered device should be able to dial a 10-digit or 11-digit US number +and reach the VoIP.ms trunk; a call to \`${VOIPMS_DID}\` from outside should +ring extension \`${RING_EXT}\`. +MD + chown "$ACTUAL_USER:$ACTUAL_USER" "$DOC_FILE" 2>/dev/null || true + + # ── Apply ────────────────────────────────────────────────────────────── + echo "" + local RESTART_NOW="" + prompt_yn "Restart the asterisk container now to apply the trunk config? (y/n):" "y" RESTART_NOW + if [[ "$RESTART_NOW" =~ ^[Yy]$ ]]; then + if ( cd "$EA_DIR" && docker compose restart asterisk ); then + log_success "Asterisk restarted — trunk config applied." + else + log_warning "Restart failed — check: docker compose -f $EA_DIR/docker-compose.yml logs asterisk" + fi + else + log_info "Apply later with: docker compose -f $EA_DIR/docker-compose.yml restart asterisk" + fi + + echo "" + log_success "VoIP.ms trunk configured." + echo " Server: $VOIPMS_SERVER ($VOIPMS_SERVER_IP)" + echo " DID: $VOIPMS_DID" + echo " Outbound: US/NANP only, max 3 concurrent calls (dialplan-enforced)" + echo " Inbound rings: extension $RING_EXT" + echo " Docs: $DOC_FILE" + echo "" +} diff --git a/setup.sh b/setup.sh index 3a7b304..509aecd 100755 --- a/setup.sh +++ b/setup.sh @@ -89,6 +89,7 @@ is_installed() { sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;; sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;; sky-cam-frigate) [ -d "$ACTUAL_HOME/sky-cam/.git" ] && [ -f "$ACTUAL_HOME/sky-cam/frigate-retime.sh" ] ;; + voipms-trunk) [ -f "$DOCKER_DIR/asterisk-digital-ocean/config/asterisk/voipms-trunk-pjsip.conf" ] ;; ssh-config) false ;; # repeatable management tool, never shows [installed] *) [ -e "$DOCKER_DIR/$1" ] ;; esac From 6b0e37812349168dcc7849a89758961ebf005c62 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 23:37:27 +0000 Subject: [PATCH 3/6] Rework PSTN trunk: role-based permissions, ring-groups, ntfy spend alerts Renames services/voipms-trunk.sh to services/pstn-trunk.sh and generalizes it 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"). --- services/{voipms-trunk.sh => pstn-trunk.sh} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename services/{voipms-trunk.sh => pstn-trunk.sh} (100%) diff --git a/services/voipms-trunk.sh b/services/pstn-trunk.sh similarity index 100% rename from services/voipms-trunk.sh rename to services/pstn-trunk.sh From 1e2a3743ab4a0953f3953e0db23eb2861bf1e706 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 23:38:08 +0000 Subject: [PATCH 4/6] 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"). --- README.md | 4 +- docs/pstn-calling-voipms-plan.md | 109 +++--- services/pstn-trunk.sh | 561 ++++++++++++++++++++++--------- setup.sh | 2 +- 4 files changed, 471 insertions(+), 205 deletions(-) diff --git a/README.md b/README.md index a280f6c..6e9e59c 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. | Group | Services | |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | -| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `asterisk-digital-ocean`, `voipms-trunk`, `security-dashboard`, `sunshine` | +| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `asterisk-digital-ocean`, `pstn-trunk`, `security-dashboard`, `sunshine` | | `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | @@ -92,7 +92,7 @@ homelab homeassistant asterisk asterisk-digital-ocean - voipms-trunk + pstn-trunk security-dashboard sunshine diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index 2916480..23ca7b2 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -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). diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 929b3d6..2186826 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -1,17 +1,21 @@ #!/bin/bash -# services/voipms-trunk.sh — VoIP.ms SIP trunk add-on for asterisk-digital-ocean: -# US-only outbound (NANP dialplan restriction), capped at 3 concurrent -# outbound calls, IP-authenticated trunk (no SIP password stored), plus -# inbound routing to one extension. +# services/pstn-trunk.sh — SIP PSTN trunk add-on for asterisk-digital-ocean: +# US-only outbound (NANP dialplan restriction), a configurable concurrent-call +# cap, role-based outbound permission (some extensions internal-only, some +# PSTN-enabled), a configurable inbound ring-group, IP-authenticated trunk +# (no SIP password stored), ntfy alerts on denied/rejected calls, and a +# periodic spend/volume check. +# +# Defaults to VoIP.ms (see docs/pstn-calling-voipms-plan.md for the design/ +# cost background this is built from) but isn't hardcoded to it — any SIP +# trunk provider that supports IP authentication works the same way. # # Requires an existing services/asterisk-digital-ocean.sh install — this adds -# a PSTN trunk on top of it and does not stand alone. See -# docs/pstn-calling-voipms-plan.md for the design/cost background this is -# built from. +# a PSTN trunk on top of it and does not stand alone. # # Part of the modular post-install system (sourced by setup.sh). -register_service voipms-trunk homelab "VoIP.ms SIP trunk for asterisk-digital-ocean — US-only outbound, max 3 concurrent calls" +register_service pstn-trunk homelab "SIP PSTN trunk for asterisk-digital-ocean — US-only, role-based permissions, spend/volume alerts (defaults to VoIP.ms)" # ── Surviving Easy Asterisk's regeneration ────────────────────────────────── # Easy Asterisk (the vendor project asterisk-digital-ocean.sh builds on) fully @@ -25,7 +29,7 @@ register_service voipms-trunk homelab "VoIP.ms SIP trunk for asterisk-digital-oc # that runs. # So the trunk/dialplan content below lives in its own files and is # #include'd from the generated files instead of appended directly. To make -# the #include itself survive regeneration too, _voipms_patch_vendor_files +# the #include itself survive regeneration too, _pstn_patch_vendor_files # (below) patches it into the vendor's *generator functions* — the same # technique this repo already uses for the logger.conf security-logging fix # in _asterisk_do_refresh_vendor_files (see services/asterisk-digital-ocean.sh). @@ -41,7 +45,7 @@ register_service voipms-trunk homelab "VoIP.ms SIP trunk for asterisk-digital-oc # "[intercom]" (extensions.conf) — each confirmed to appear exactly once per # file in the vendor source, so this is safe regardless of what else changes # around it upstream. Idempotent: skips files that already have the include. -_voipms_patch_vendor_files() { +_pstn_patch_vendor_files() { local EA_DIR="$1" local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh" local EASY1="$EA_DIR/easy-asterisk.sh" @@ -53,119 +57,273 @@ _voipms_patch_vendor_files() { done for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do - if ! grep -q 'voipms-trunk-pjsip.conf' "$f"; then + if ! grep -q 'pstn-trunk-pjsip.conf' "$f"; then if grep -q '^user_agent=EasyAsterisk$' "$f"; then - sed -i '/^user_agent=EasyAsterisk$/a #include voipms-trunk-pjsip.conf' "$f" + sed -i '/^user_agent=EasyAsterisk$/a #include pstn-trunk-pjsip.conf' "$f" else log_warning "$(basename "$f"): 'user_agent=EasyAsterisk' anchor not found — vendor template changed upstream." - log_warning " Add '#include voipms-trunk-pjsip.conf' manually after [global] in this file's pjsip.conf heredoc." + log_warning " Add '#include pstn-trunk-pjsip.conf' manually after [global] in this file's pjsip.conf heredoc." fi fi done for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do - if ! grep -q 'voipms-trunk-dialplan.conf' "$f"; then + if ! grep -q 'pstn-trunk-dialplan.conf' "$f"; then if grep -q '^\[intercom\]$' "$f"; then - sed -i '/^\[intercom\]$/a #include voipms-trunk-dialplan.conf' "$f" + sed -i '/^\[intercom\]$/a #include pstn-trunk-dialplan.conf' "$f" else log_warning "$(basename "$f"): '[intercom]' anchor not found — vendor template changed upstream." - log_warning " Add '#include voipms-trunk-dialplan.conf' manually after [intercom] in this file's extensions.conf heredoc." + log_warning " Add '#include pstn-trunk-dialplan.conf' manually after [intercom] in this file's extensions.conf heredoc." fi fi done - log_success "Vendor generator functions patched to include the VoIP.ms trunk config." + log_success "Vendor generator functions patched to include the PSTN trunk config." } # ── Shared: pjsip trunk config (aor/identify/endpoint, IP-authenticated) ─── -_voipms_write_pjsip_include() { +_pstn_write_pjsip_include() { local FILE="$1" SERVER="$2" SERVER_IP="$3" DID="$4" cat > "$FILE" << 'EOF' -; VoIP.ms trunk — IP authentication, no password stored (see -; docs/pstn-calling-voipms-plan.md). Regenerated by services/voipms-trunk.sh — +; SIP PSTN trunk — IP authentication, no password stored (see +; docs/pstn-calling-voipms-plan.md). Regenerated by services/pstn-trunk.sh — ; edit there, not here directly, or a reinstall/update will overwrite this. ; ; match= below is the resolved IP of the server hostname at install time. -; VoIP.ms's servers are fixed per-POP, but if calls stop matching after a -; provider-side change, re-run this service to re-resolve and rewrite it. +; Providers sometimes send inbound INVITEs from a different IP than the one +; their hostname resolves to (load balancing / multiple servers per POP) — +; if inbound calls stop matching after a provider-side change, re-run this +; service to re-resolve and rewrite it, or add extra "type=identify" / +; "match=" lines here by hand for additional known source IPs. -[voipms-trunk] +[pstn-trunk] type=aor -contact=sip:__VOIPMS_SERVER__ +contact=sip:__PSTN_SERVER__ qualify_frequency=60 -[voipms-trunk] +[pstn-trunk] type=identify -endpoint=voipms-trunk -match=__VOIPMS_SERVER_IP__ +endpoint=pstn-trunk +match=__PSTN_SERVER_IP__ -[voipms-trunk] +[pstn-trunk] type=endpoint -context=from-voipms +context=from-pstn-trunk disallow=all allow=ulaw,alaw -aors=voipms-trunk -from_user=__VOIPMS_DID__ -from_domain=__VOIPMS_SERVER__ -callerid=__VOIPMS_DID__ +aors=pstn-trunk +from_user=__PSTN_DID__ +from_domain=__PSTN_SERVER__ +callerid=__PSTN_DID__ direct_media=no EOF - sed -i "s/__VOIPMS_SERVER_IP__/${SERVER_IP}/g; s/__VOIPMS_SERVER__/${SERVER}/g; s/__VOIPMS_DID__/${DID}/g" "$FILE" + sed -i "s/__PSTN_SERVER_IP__/${SERVER_IP}/g; s/__PSTN_SERVER__/${SERVER}/g; s/__PSTN_DID__/${DID}/g" "$FILE" } -# ── Shared: outbound/inbound dialplan (NANP-only, 3-concurrent cap) ──────── +# ── Shared: outbound/inbound dialplan ─────────────────────────────────────── # Continues in the [intercom] context established just above this include # (rebuild_dialplan() writes "[intercom]" then this #include right after it), -# so existing extensions can dial out through it directly. [from-voipms] +# so existing extensions can dial out through it directly. [from-pstn-trunk] # below is a separate context, for calls arriving from the trunk. -_voipms_write_dialplan_include() { - local FILE="$1" DID="$2" RING_EXT="$3" +# +# Role model: internal intercom dialing (extension-to-extension) is NEVER +# gated here — everyone keeps that, regardless of PSTN permission. Only the +# two NANP patterns (the trunk route) are gated by ALLOWED_REGEX. An empty +# allow-list at install time becomes ".*" (match anything), preserving +# "every extension can dial out" as the explicit opt-in default. +# +# Calls are logged to pstn-trunk-calls.log (epoch|direction|who|what|seconds) +# for the usage-alert script — not Asterisk's own CDR, to avoid depending on +# whether cdr_csv is enabled/configured on a given image, and to sidestep +# CDR CSV's comma-quoting entirely (our own pipe-delimited format has no +# embedded-delimiter risk since every field here is digits/hostnames). +_pstn_write_dialplan_include() { + local FILE="$1" DID="$2" ALLOWED_REGEX="$3" MAX_CONCURRENT="$4" RING_DIAL="$5" NTFY_URL="$6" cat > "$FILE" << 'EOF' -; VoIP.ms outbound/inbound — US-only (NANP), max 3 concurrent outbound calls. -; Regenerated by services/voipms-trunk.sh — edit there, not here directly. +; PSTN outbound/inbound — US-only (NANP), concurrent-call cap, role-based +; outbound permission. Regenerated by services/pstn-trunk.sh — edit there, +; not here directly, or a reinstall/update will overwrite this. ; ; No catch-all pattern here on purpose: only these two NANP patterns route -; to the trunk, so a compromised extension can't reach anything else even if -; the trunk itself would technically allow more. See -; docs/pstn-calling-voipms-plan.md for the full toll-fraud reasoning. +; to the trunk, so an unauthorized or compromised extension can't reach +; anything else even if the trunk itself would technically allow more. See +; docs/pstn-calling-voipms-plan.md for the toll-fraud reasoning. -exten => _1NXXNXXXXX,1,NoOp(VoIP.ms outbound call to ${EXTEN}) - same => n,GotoIf($[${GROUP_COUNT(voipms-out)} >= 3]?voipms_busy,1) - same => n,Set(GROUP()=voipms-out) - same => n,Set(CALLERID(num)=__VOIPMS_DID__) - same => n,Dial(PJSIP/${EXTEN}@voipms-trunk,60) - same => n,Hangup() - -exten => _NXXNXXXXX,1,NoOp(Assuming NANP, adding leading 1) - same => n,Goto(1${EXTEN},1) - -exten => voipms_busy,1,NoOp(VoIP.ms: 3 concurrent outbound calls already active — rejecting) +exten => _1NXXNXXXXX,1,NoOp(PSTN outbound call attempt from ${CHANNEL(peername)} to ${EXTEN}) + same => n,Set(PSTN_CALLER=${CHANNEL(peername)}) + same => n,GotoIf($[${REGEX("^(__PSTN_ALLOWED_REGEX__)$" ${PSTN_CALLER})} = 1]?pstn_check_busy,1) + same => n,NoOp(Denied - ${PSTN_CALLER} is not authorized for PSTN outbound) +__ALERT_DENY_LINE__ same => n,Busy(15) same => n,Hangup() -[from-voipms] +exten => _NXXNXXXXX,1,NoOp(Assuming NANP - adding leading 1) + same => n,Goto(1${EXTEN},1) + +exten => pstn_check_busy,1,GotoIf($[${GROUP_COUNT(pstn-out)} >= __PSTN_MAX_CONCURRENT__]?pstn_busy,1) + same => n,Set(GROUP()=pstn-out) + same => n,Set(CALLERID(num)=__PSTN_DID__) + same => n,Set(PSTN_START=${EPOCH}) + same => n,Dial(PJSIP/${EXTEN}@pstn-trunk,60) + same => n,Set(PSTN_DUR=$[${EPOCH} - ${PSTN_START}]) + same => n,System(printf '%s|out|%s|%s|%s\n' "${PSTN_START}" "${PSTN_CALLER}" "${EXTEN}" "${PSTN_DUR}" >> /var/log/asterisk/pstn-trunk-calls.log) + same => n,Hangup() + +exten => pstn_busy,1,NoOp(PSTN trunk - concurrent-call cap reached, rejecting) +__ALERT_BUSY_LINE__ + same => n,Busy(15) + same => n,Hangup() + +[from-pstn-trunk] exten => _X.,1,NoOp(Inbound PSTN call from ${CALLERID(num)}) - same => n,Dial(PJSIP/__VOIPMS_RING_EXT__,20) + same => n,Set(PSTN_START=${EPOCH}) + same => n,Dial(__PSTN_RING_DIAL__,20) + same => n,Set(PSTN_DUR=$[${EPOCH} - ${PSTN_START}]) + same => n,System(printf '%s|in|%s|ring-group|%s\n' "${PSTN_START}" "${CALLERID(num)}" "${PSTN_DUR}" >> /var/log/asterisk/pstn-trunk-calls.log) same => n,Hangup() EOF - sed -i "s/__VOIPMS_DID__/${DID}/g; s/__VOIPMS_RING_EXT__/${RING_EXT}/g" "$FILE" + sed -i "s/__PSTN_ALLOWED_REGEX__/${ALLOWED_REGEX}/g; s/__PSTN_MAX_CONCURRENT__/${MAX_CONCURRENT}/g; s/__PSTN_DID__/${DID}/g" "$FILE" + # RING_DIAL is "PJSIP/a&PJSIP/b&..." — the literal "&" must be escaped in + # a sed replacement (bare "&" means "the matched text", same gotcha as + # NTFY_URL below), or every "&" gets replaced with the placeholder itself. + local _esc_ring_dial="${RING_DIAL//&/\\&}" + sed -i "s#__PSTN_RING_DIAL__#${_esc_ring_dial}#g" "$FILE" + + if [[ -n "$NTFY_URL" ]]; then + local _esc_url="${NTFY_URL//&/\\&}" + sed -i "s#__ALERT_DENY_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call denied - extension not authorized.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" + sed -i "s#__ALERT_BUSY_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: concurrent-call cap reached - a call was rejected.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" + else + sed -i "/__ALERT_DENY_LINE__/d; /__ALERT_BUSY_LINE__/d" "$FILE" + fi } -install_voipms-trunk() { +# ── Shared: periodic spend/volume checker (run hourly via cron) ──────────── +_pstn_write_usage_alert_script() { + local FILE="$1" EA_DIR="$2" RATE="$3" MONTH_THRESHOLD="$4" BURST_THRESHOLD="$5" NTFY_URL="$6" + cat > "$FILE" << 'EOF' +#!/bin/bash +# Auto-generated by services/pstn-trunk.sh — do not edit directly, re-run +# the installer instead. Run hourly via /etc/cron.d/pstn-trunk-usage. +# Reads the call log pstn-trunk-dialplan.conf appends to and alerts via +# ntfy when month-to-date estimated spend crosses a threshold (alerted once +# per month) or when call volume in the last hour looks like a burst. + +LOG_FILE="__EA_DIR__/logs/pstn-trunk-calls.log" +STATE_FILE="__EA_DIR__/.pstn-trunk-alert-state" +RATE="__PSTN_RATE__" +MONTH_THRESHOLD="__PSTN_MONTH_THRESHOLD__" +BURST_THRESHOLD="__PSTN_BURST_THRESHOLD__" +NTFY_URL="__PSTN_NTFY_URL__" + +[[ -f "$LOG_FILE" ]] || exit 0 + +now_epoch=$(date +%s) +current_month=$(date +%Y-%m) +one_hour_ago=$((now_epoch - 3600)) +month_start_epoch=$(date -d "$(date +%Y-%m-01)" +%s) + +month_seconds=$(awk -F'|' -v start="$month_start_epoch" '$2=="out" && $1+0>=start {sum+=$5} END{print sum+0}' "$LOG_FILE") +month_minutes=$(awk -v s="$month_seconds" 'BEGIN{printf "%.1f", s/60}') +month_cost=$(awk -v m="$month_minutes" -v r="$RATE" 'BEGIN{printf "%.2f", m*r}') +hour_calls=$(awk -F'|' -v start="$one_hour_ago" '$2=="out" && $1+0>=start {c++} END{print c+0}' "$LOG_FILE") + +send_ntfy() { + [[ -n "$NTFY_URL" ]] && curl -m 5 -s -d "$1" "$NTFY_URL" >/dev/null 2>&1 +} + +last_alert_month="" +[[ -f "$STATE_FILE" ]] && last_alert_month=$(cat "$STATE_FILE") + +if awk -v c="$month_cost" -v t="$MONTH_THRESHOLD" 'BEGIN{exit !(c>=t)}'; then + if [[ "$last_alert_month" != "$current_month" ]]; then + send_ntfy "PSTN trunk: estimated spend this month (\$${month_cost}) has crossed the \$${MONTH_THRESHOLD} threshold. ${month_minutes} minutes so far." + echo "$current_month" > "$STATE_FILE" + fi +fi + +if [[ "$hour_calls" -ge "$BURST_THRESHOLD" ]]; then + send_ntfy "PSTN trunk: $hour_calls outbound calls placed in the last hour - check for unusual activity." +fi +EOF + sed -i "s#__EA_DIR__#${EA_DIR}#g; s/__PSTN_RATE__/${RATE}/g; s/__PSTN_MONTH_THRESHOLD__/${MONTH_THRESHOLD}/g; s/__PSTN_BURST_THRESHOLD__/${BURST_THRESHOLD}/g" "$FILE" + sed -i "s#__PSTN_NTFY_URL__#${NTFY_URL}#g" "$FILE" + chmod 755 "$FILE" +} + +# ── Shared: apply everything from a settings set (used by fresh + update) ── +_pstn_apply_settings() { + local EA_DIR="$1" ASTERISK_DIR="$2" + local SERVER="$3" SERVER_IP="$4" DID="$5" ALLOWED_EXTS="$6" MAX_CONCURRENT="$7" + local RING_EXTS="$8" NTFY_URL="$9" RATE="${10}" MONTH_THRESHOLD="${11}" BURST_THRESHOLD="${12}" + local PROVIDER_NAME="${13}" + + local ALLOWED_REGEX + if [[ -z "$ALLOWED_EXTS" ]]; then + ALLOWED_REGEX=".*" + else + ALLOWED_REGEX="$(echo "$ALLOWED_EXTS" | tr -s ' ' '|')" + fi + + local RING_DIAL="" _ext + for _ext in $RING_EXTS; do + [[ -n "$RING_DIAL" ]] && RING_DIAL="${RING_DIAL}&" + RING_DIAL="${RING_DIAL}PJSIP/${_ext}" + done + + _pstn_patch_vendor_files "$EA_DIR" || return 1 + + mkdir -p "$ASTERISK_DIR" + _pstn_write_pjsip_include "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$SERVER" "$SERVER_IP" "$DID" + _pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$ALLOWED_REGEX" "$MAX_CONCURRENT" "$RING_DIAL" "$NTFY_URL" + _pstn_write_usage_alert_script "$EA_DIR/pstn-trunk-usage-alert.sh" "$EA_DIR" "$RATE" "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$NTFY_URL" + ensure_docker_dir_ownership "$ASTERISK_DIR" + chmod 644 "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$ASTERISK_DIR/pstn-trunk-dialplan.conf" + + cat > "$EA_DIR/.pstn-trunk.env" << ENV +PROVIDER_NAME=${PROVIDER_NAME} +TRUNK_SERVER=${SERVER} +TRUNK_SERVER_IP=${SERVER_IP} +TRUNK_DID=${DID} +PSTN_ALLOWED_EXTS=${ALLOWED_EXTS} +MAX_CONCURRENT=${MAX_CONCURRENT} +RING_EXTS=${RING_EXTS} +NTFY_URL=${NTFY_URL} +RATE_PER_MIN=${RATE} +MONTH_THRESHOLD=${MONTH_THRESHOLD} +BURST_THRESHOLD=${BURST_THRESHOLD} +ENV + chown "$ACTUAL_USER:$ACTUAL_USER" "$EA_DIR/.pstn-trunk.env" 2>/dev/null || true + + if command -v cron >/dev/null 2>&1 || [[ -d /etc/cron.d ]]; then + cat > /etc/cron.d/pstn-trunk-usage << CRON +0 * * * * root /bin/bash $EA_DIR/pstn-trunk-usage-alert.sh >> $EA_DIR/logs/pstn-trunk-usage-alert.log 2>&1 +CRON + log_success "Hourly spend/volume check installed (cron.d)." + else + log_warning "cron not available — run $EA_DIR/pstn-trunk-usage-alert.sh manually/periodically for spend/volume alerts." + fi +} + +install_pstn-trunk() { require_docker || return 1 local EA_DIR="$DOCKER_DIR/asterisk-digital-ocean" local ASTERISK_DIR="$EA_DIR/config/asterisk" - local PJSIP_INCLUDE="$ASTERISK_DIR/voipms-trunk-pjsip.conf" - local DIALPLAN_INCLUDE="$ASTERISK_DIR/voipms-trunk-dialplan.conf" + local PJSIP_INCLUDE="$ASTERISK_DIR/pstn-trunk-pjsip.conf" + local DIALPLAN_INCLUDE="$ASTERISK_DIR/pstn-trunk-dialplan.conf" + local SETTINGS_FILE="$EA_DIR/.pstn-trunk.env" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would require an existing asterisk-digital-ocean install at $EA_DIR" - echo "[DRY-RUN] Would prompt for VoIP.ms server/POP hostname, DID, and ring extension" + echo "[DRY-RUN] Would prompt for: SIP provider name (default VoIP.ms), server/POP hostname," + echo "[DRY-RUN] DID, extensions allowed to dial out (blank=all), max concurrent calls (default 3)," + echo "[DRY-RUN] extensions to ring inbound (space-separated, ring-group supported)," + echo "[DRY-RUN] ntfy alert topic (optional), per-minute rate + monthly/hourly alert thresholds" echo "[DRY-RUN] Would resolve the server hostname to an IP for inbound call matching" echo "[DRY-RUN] Would patch vendor generator functions to #include the trunk config" - echo "[DRY-RUN] Would write $PJSIP_INCLUDE and $DIALPLAN_INCLUDE" - echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if already configured" + echo "[DRY-RUN] Would write $PJSIP_INCLUDE, $DIALPLAN_INCLUDE, and an hourly usage-alert script + cron.d entry" + echo "[DRY-RUN] Would offer 'update in place' (reads settings back from $SETTINGS_FILE) instead of a fresh install if already configured" echo "[DRY-RUN] Would restart the asterisk container to apply" return 0 fi @@ -173,52 +331,47 @@ install_voipms-trunk() { if [[ ! -f "$EA_DIR/docker-compose.yml" ]]; then log_error "asterisk-digital-ocean isn't installed at $EA_DIR — install it first:" log_error " sudo ./setup.sh asterisk-digital-ocean" - log_error "This service adds a VoIP.ms PSTN trunk on top of it; it doesn't stand alone." + log_error "This service adds a PSTN trunk on top of it; it doesn't stand alone." return 1 fi - log_info "Configuring VoIP.ms SIP trunk for asterisk-digital-ocean..." - log_info "US-only outbound (NANP dialplan), max 3 concurrent outbound calls, IP-authenticated trunk." + log_info "Configuring a SIP PSTN trunk for asterisk-digital-ocean (defaults to VoIP.ms)." + log_info "US-only outbound (NANP dialplan), a concurrent-call cap, role-based outbound permission," + log_info "an inbound ring-group, and ntfy alerts on denied/rejected calls plus spend/volume checks." echo "" - log_warning "Before continuing, on VoIP.ms's side you should already have: created an" - log_warning "account, funded the \$15 minimum prepaid balance, turned OFF auto-recharge" - log_warning "(Client Area -> Balance Management), ordered a DID with IP authentication" - log_warning "pointed at this droplet's public IP, and picked a server/POP. Also restrict" - log_warning "outbound routing to US/NANP on VoIP.ms's own side if it offers that — this" - log_warning "dialplan is the second, independent layer, not a substitute for the first." + log_warning "Before continuing, on your provider's side you should already have: created an" + log_warning "account, funded and set up prepaid billing with auto-recharge OFF (VoIP.ms: Client" + log_warning "Area -> Balance Management), ordered a DID with IP authentication pointed at this" + log_warning "droplet's public IP, and picked a server/POP. Also restrict outbound routing to" + log_warning "US/NANP on the provider's own side if it offers that — this dialplan is the second," + log_warning "independent layer, not a substitute for the first." log_warning "See docs/pstn-calling-voipms-plan.md for the full background." echo "" # ── Existing install? Offer update-in-place instead of a full reinstall ── if [[ -f "$PJSIP_INCLUDE" && -f "$DIALPLAN_INCLUDE" ]]; then - log_info "Existing VoIP.ms trunk config found." + log_info "Existing PSTN trunk config found." local REINSTALL_MODE="" prompt_reinstall_mode REINSTALL_MODE case "$REINSTALL_MODE" in update) - local _SERVER _SERVER_IP _DID _RING_EXT - _SERVER="$(grep -m1 '^from_domain=' "$PJSIP_INCLUDE" | cut -d= -f2-)" - _SERVER_IP="$(grep -m1 '^match=' "$PJSIP_INCLUDE" | cut -d= -f2-)" - _DID="$(grep -m1 '^from_user=' "$PJSIP_INCLUDE" | cut -d= -f2-)" - # Match only the inbound line (Dial(PJSIP/,20)) — the - # outbound line matches Dial(PJSIP/${EXTEN}@voipms-trunk,60), - # which starts with "$" right after PJSIP/, not a digit. - _RING_EXT="$(grep -m1 -E 'Dial\(PJSIP/[0-9]+,20\)' "$DIALPLAN_INCLUDE" | sed -E 's/.*Dial\(PJSIP\/([0-9]+),20\).*/\1/')" - if [[ -z "$_SERVER" || -z "$_DID" || -z "$_RING_EXT" ]]; then - log_warning "Couldn't parse existing settings back out — falling back to a fresh install (every prompt below)." - else - _voipms_patch_vendor_files "$EA_DIR" || return 1 - _voipms_write_pjsip_include "$PJSIP_INCLUDE" "$_SERVER" "$_SERVER_IP" "$_DID" - _voipms_write_dialplan_include "$DIALPLAN_INCLUDE" "$_DID" "$_RING_EXT" - ensure_docker_dir_ownership "$ASTERISK_DIR" + if [[ -f "$SETTINGS_FILE" ]]; then + # shellcheck disable=SC1090 + source "$SETTINGS_FILE" + _pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ + "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" "$PSTN_ALLOWED_EXTS" \ + "$MAX_CONCURRENT" "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ + "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 ( cd "$EA_DIR" && docker compose restart asterisk ) \ - && log_success "Updated — settings unchanged (server $_SERVER, DID $_DID, ring ext $_RING_EXT)." \ + && log_success "Updated — settings unchanged (server $TRUNK_SERVER, DID $TRUNK_DID, ring exts: $RING_EXTS)." \ || log_warning "Restart failed — check: docker compose -f $EA_DIR/docker-compose.yml logs asterisk" return 0 + else + log_warning "No $SETTINGS_FILE found (pre-dates this settings-file version) — falling back to a fresh install (every prompt below)." fi ;; cancel) - log_info "Leaving the existing VoIP.ms trunk config as-is." + log_info "Leaving the existing PSTN trunk config as-is." return 0 ;; fresh) @@ -227,64 +380,117 @@ install_voipms-trunk() { esac fi - # ── Prompts — VoIP.ms account details aren't scriptable, user sets these - # up manually on VoIP.ms's own site first (see warning above) ─────────── - local VOIPMS_SERVER="" - prompt_text "VoIP.ms server/POP hostname (e.g. atlanta2.voip.ms — pick the one closest to this droplet from VoIP.ms's server list):" "" VOIPMS_SERVER - if [[ -z "$VOIPMS_SERVER" ]]; then - log_error "A VoIP.ms server hostname is required — aborting." + # ── Prompts — provider account details aren't scriptable, set up manually + # on the provider's own site first (see warning above) ─────────────────── + local PROVIDER_NAME="" + prompt_text "SIP trunk provider name (for your reference/docs only):" "VoIP.ms" PROVIDER_NAME + + local TRUNK_SERVER="" + prompt_text "Server/POP hostname (e.g. atlanta2.voip.ms for VoIP.ms — pick the one closest to this droplet from your provider's server list):" "" TRUNK_SERVER + if [[ -z "$TRUNK_SERVER" ]]; then + log_error "A server hostname is required — aborting." return 1 fi - local VOIPMS_SERVER_IP="" - VOIPMS_SERVER_IP="$(getent ahostsv4 "$VOIPMS_SERVER" 2>/dev/null | awk '{print $1}' | head -1)" - if [[ -z "$VOIPMS_SERVER_IP" ]]; then - log_warning "Couldn't resolve $VOIPMS_SERVER — the identify section needs an IP to match inbound calls against." - prompt_text "Enter its IP manually (check VoIP.ms's server list page):" "" VOIPMS_SERVER_IP - if [[ -z "$VOIPMS_SERVER_IP" ]]; then + local TRUNK_SERVER_IP="" + TRUNK_SERVER_IP="$(getent ahostsv4 "$TRUNK_SERVER" 2>/dev/null | awk '{print $1}' | head -1)" + if [[ -z "$TRUNK_SERVER_IP" ]]; then + log_warning "Couldn't resolve $TRUNK_SERVER — the identify section needs an IP to match inbound calls against." + prompt_text "Enter its IP manually (check your provider's server list page):" "" TRUNK_SERVER_IP + if [[ -z "$TRUNK_SERVER_IP" ]]; then log_error "No IP available — aborting." return 1 fi else - log_success "Resolved $VOIPMS_SERVER -> $VOIPMS_SERVER_IP" + log_success "Resolved $TRUNK_SERVER -> $TRUNK_SERVER_IP" fi - local VOIPMS_DID="" - prompt_text "VoIP.ms DID (the 10-digit US phone number assigned to this trunk, digits only):" "" VOIPMS_DID - if [[ ! "$VOIPMS_DID" =~ ^[0-9]{10}$ ]]; then + local TRUNK_DID="" + prompt_text "DID (the 10-digit US phone number assigned to this trunk, digits only):" "" TRUNK_DID + if [[ ! "$TRUNK_DID" =~ ^[0-9]{10}$ ]]; then log_error "That doesn't look like a 10-digit US number — aborting." return 1 fi - local RING_EXT="" - prompt_text "Local extension to ring for inbound calls (must already exist in Easy Asterisk):" "" RING_EXT - if [[ -z "$RING_EXT" ]]; then - log_error "An extension number is required for inbound routing — aborting." + echo "" + echo " Role model: EVERY extension can always call/receive calls from other" + echo " Asterisk extensions (internal intercom dialing is never restricted" + echo " here). The setting below only controls PSTN (real phone number)" + echo " access — extensions left out behave exactly as they do today." + local PSTN_ALLOWED_EXTS="" + prompt_text "Extensions allowed to dial PSTN numbers (space-separated, e.g. '1001 1002'; blank = every extension):" "" PSTN_ALLOWED_EXTS + if [[ -z "$PSTN_ALLOWED_EXTS" ]]; then + log_info "No restriction entered — every extension will be able to dial PSTN numbers." + else + log_info "Only these extensions may dial PSTN numbers: $PSTN_ALLOWED_EXTS" + fi + + local MAX_CONCURRENT="" + prompt_text "Max simultaneous outbound PSTN calls allowed:" "3" MAX_CONCURRENT + if [[ ! "$MAX_CONCURRENT" =~ ^[0-9]+$ ]]; then + log_warning "Not a number — defaulting to 3." + MAX_CONCURRENT=3 + fi + + local RING_EXTS="" + prompt_text "Extensions to ring for inbound PSTN calls (space-separated — one extension, or several for a ring group):" "" RING_EXTS + if [[ -z "$RING_EXTS" ]]; then + log_error "At least one extension is required for inbound routing — aborting." return 1 fi - # ── Patch vendor generator functions, then write the include files ─────── - _voipms_patch_vendor_files "$EA_DIR" || return 1 + echo "" + local WANT_NTFY="" + prompt_yn "Send an ntfy alert when a call is denied (unauthorized extension) or rejected (concurrency cap hit)? (y/n):" "y" WANT_NTFY + local NTFY_URL="" + if [[ "$WANT_NTFY" =~ ^[Yy]$ ]]; then + # Prefer a locally-installed ntfy's own base-url as the default, same + # detection pattern services/crowdsec.sh uses for its own ntfy alerts. + local _ntfy_default="https://ntfy.sh/pstn-trunk-alerts" + if [ -f "$DOCKER_DIR/ntfy/config/server.yml" ]; then + local _local_base_url + _local_base_url="$(grep -oP '(?<=base-url: ")[^"]+' "$DOCKER_DIR/ntfy/config/server.yml" 2>/dev/null || true)" + if [ -n "$_local_base_url" ] && [ "$_local_base_url" != "https://ntfy.example.com" ]; then + _ntfy_default="${_local_base_url}/pstn-trunk-alerts" + log_info "Detected a configured local ntfy instance at $_local_base_url — using it as the default." + fi + fi + if [ "$_ntfy_default" = "https://ntfy.sh/pstn-trunk-alerts" ]; then + log_info "No configured local ntfy instance detected — defaulting to the public ntfy.sh." + log_info "If you have one hosted elsewhere, enter its topic URL instead." + fi + prompt_text " ntfy topic URL:" "$_ntfy_default" NTFY_URL + fi - mkdir -p "$ASTERISK_DIR" - _voipms_write_pjsip_include "$PJSIP_INCLUDE" "$VOIPMS_SERVER" "$VOIPMS_SERVER_IP" "$VOIPMS_DID" - _voipms_write_dialplan_include "$DIALPLAN_INCLUDE" "$VOIPMS_DID" "$RING_EXT" - ensure_docker_dir_ownership "$ASTERISK_DIR" - chmod 644 "$PJSIP_INCLUDE" "$DIALPLAN_INCLUDE" + echo "" + log_info "Spend/volume alert settings (used only to estimate cost and flag unusual usage —" + log_info "not billing-accurate, just a safety net)." + local RATE_PER_MIN="" + prompt_text " Outbound per-minute rate in USD (VoIP.ms US rate is 0.01):" "0.01" RATE_PER_MIN + local MONTH_THRESHOLD="" + prompt_text " Alert once when estimated spend this month reaches (USD):" "10" MONTH_THRESHOLD + local BURST_THRESHOLD="" + prompt_text " Alert if more than this many outbound calls happen in one hour:" "10" BURST_THRESHOLD + + _pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ + "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" "$PSTN_ALLOWED_EXTS" \ + "$MAX_CONCURRENT" "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ + "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 # No new firewall rules: asterisk-digital-ocean.sh already opens SIP - # (5060/5061) and RTP (10000-20000) to the internet, and VoIP.ms's source + # (5060/5061) and RTP (10000-20000) to the internet, and providers' source # IPs vary by POP/redundancy, so there's no single IP to scope this to # even if narrowing it were otherwise worthwhile. # ── Docs (separate file — asterisk-digital-ocean already owns README.md # in this same directory via write_readme, so don't overwrite it) ─────── - local DOC_FILE="$EA_DIR/README-voipms-trunk.md" + local DOC_FILE="$EA_DIR/README-pstn-trunk.md" cat > "$DOC_FILE" << MD -# VoIP.ms SIP trunk (add-on to asterisk-digital-ocean) +# SIP PSTN trunk (add-on to asterisk-digital-ocean) -US-only outbound PSTN calling over a VoIP.ms trunk, capped at 3 concurrent -outbound calls, plus inbound routing to one extension. See +US-only outbound PSTN calling over a SIP trunk (defaults to VoIP.ms, works +with any IP-authenticated provider), role-based outbound permission, a +configurable concurrent-call cap, and an inbound ring-group. See \`docs/pstn-calling-voipms-plan.md\` in the repo for the full design background, cost estimate, and toll-fraud reasoning. @@ -292,11 +498,26 @@ background, cost estimate, and toll-fraud reasoning. | Setting | Value | |---|---| -| VoIP.ms server/POP | ${VOIPMS_SERVER} (${VOIPMS_SERVER_IP}) | -| DID | ${VOIPMS_DID} | -| Inbound rings extension | ${RING_EXT} | +| Provider | ${PROVIDER_NAME} | +| Server/POP | ${TRUNK_SERVER} (${TRUNK_SERVER_IP}) | +| DID | ${TRUNK_DID} | | Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all | -| Concurrency cap | 3 simultaneous outbound calls (GROUP()/GROUP_COUNT() in the dialplan); a 4th gets a busy signal | +| PSTN-allowed extensions | ${PSTN_ALLOWED_EXTS:-all extensions} | +| Concurrency cap | ${MAX_CONCURRENT} simultaneous outbound calls | +| Inbound rings | ${RING_EXTS} | +| ntfy alerts | ${NTFY_URL:-disabled} | +| Estimated rate | \$${RATE_PER_MIN}/min | +| Monthly spend alert threshold | \$${MONTH_THRESHOLD} | +| Hourly burst alert threshold | ${BURST_THRESHOLD} calls/hour | + +## Role model + +Every extension can always call and receive calls from other Asterisk +extensions — that's unchanged and never gated. The PSTN-allowed list above +only controls the two additional things a "PSTN-enabled" extension gets on +top of that: dialing real phone numbers out, and being included in the +inbound ring-group. Leaving the allow-list blank means every extension gets +PSTN access too (the original default before roles existed). ## How this survives Easy Asterisk's own regeneration @@ -305,10 +526,11 @@ internal state (device list, network settings) rather than treating them as hand-edited files. Trunk/dialplan config here lives in two files of its own, \`#include\`'d from the generated files: -- \`config/asterisk/voipms-trunk-pjsip.conf\` — the trunk's \`aor\`/\`identify\`/ +- \`config/asterisk/pstn-trunk-pjsip.conf\` — the trunk's \`aor\`/\`identify\`/ \`endpoint\` sections (IP-authenticated, no password stored). -- \`config/asterisk/voipms-trunk-dialplan.conf\` — the NANP-only outbound - routing + concurrency cap, and the \`[from-voipms]\` inbound context. +- \`config/asterisk/pstn-trunk-dialplan.conf\` — NANP-only outbound routing, + the outbound permission gate, the concurrency cap, ntfy alert hooks, and + the \`[from-pstn-trunk]\` inbound context. The \`#include\` lines themselves are patched into Easy Asterisk's *generator functions* (\`docker/entrypoint.sh\`, \`easy-asterisk.sh\`, @@ -318,49 +540,64 @@ regenerate the config, instead of being wiped. **Caveat:** if the base \`asterisk-digital-ocean\` service is ever updated independently (\`sudo ./setup.sh asterisk-digital-ocean\`, choosing "update in place" — that path re-copies fresh vendor files), this patch is wiped along -with it. Re-run \`sudo ./setup.sh voipms-trunk\` afterward (update mode -reapplies the patch and rewrites both include files with your existing -settings, no re-prompting). +with it. Re-run \`sudo ./setup.sh pstn-trunk\` afterward (update mode +reapplies the patch and rewrites everything from \`.pstn-trunk.env\`, no +re-prompting). + +## Spend/volume alerts + +\`pstn-trunk-usage-alert.sh\` runs hourly (\`/etc/cron.d/pstn-trunk-usage\`) and +reads \`logs/pstn-trunk-calls.log\` (appended to directly by the dialplan, not +Asterisk's own CDR — a deliberate choice to avoid depending on whether this +image's CDR modules are enabled/configured, and to sidestep CDR CSV's +comma-quoting). It sends an ntfy alert: + +- **Once per calendar month** the first time estimated spend crosses + \$${MONTH_THRESHOLD} (state tracked in \`.pstn-trunk-alert-state\` so it + doesn't repeat every hour). +- **Every hour** that outbound call volume exceeds ${BURST_THRESHOLD} + calls/hour — this is the faster tripwire for a burst/abuse scenario, + independent of whether it's crossed the monthly dollar threshold yet. + +Separately, denied calls (unauthorized extension) and rejected calls +(concurrency cap hit) alert **immediately**, not on the hourly schedule — +see the dialplan file's \`__ALERT_DENY_LINE__\`/\`__ALERT_BUSY_LINE__\` sites. + +These are cost *estimates* (call count/duration × your entered rate), not +real billing data — treat them as a safety net, not a substitute for +checking your provider's own balance/usage dashboard. ## Changing settings -Re-run \`sudo ./setup.sh voipms-trunk\` and choose "reinstall in place" — -current settings are read back out of the include files above and reapplied. -Choose "full install" instead to re-prompt for everything (new server, DID, -or ring extension). +Re-run \`sudo ./setup.sh pstn-trunk\` and choose "reinstall in place" — +current settings are read from \`.pstn-trunk.env\` and reapplied exactly, +including regenerating the usage-alert script and cron entry. Choose "full +install" instead to re-prompt for everything. ## Manual edits -Don't hand-edit \`voipms-trunk-pjsip.conf\` / \`voipms-trunk-dialplan.conf\` -directly if you plan to re-run this installer later — it overwrites both -files unconditionally. If you do need one-off manual changes (e.g. testing a -second POP as a failover \`match=\` line), edit them directly and just don't -re-run the installer until you're ready to lose those changes, or restart -the container instead of running the installer to test them: +Don't hand-edit \`pstn-trunk-pjsip.conf\` / \`pstn-trunk-dialplan.conf\` / +\`pstn-trunk-usage-alert.sh\` directly if you plan to re-run this installer +later — it overwrites all three unconditionally from \`.pstn-trunk.env\`. For +one-off testing, restart the container instead of running the installer: \`\`\`bash docker compose -f $EA_DIR/docker-compose.yml restart asterisk \`\`\` -## What the 3-call cap does and doesn't cover - -It bounds *how many* outbound calls can be active at once — the first line -of defense against a compromised extension opening dozens of parallel legs. -It does not bound total spend by itself; that's still the prepaid-balance + -auto-recharge-off layer on VoIP.ms's side. Both matter — see the toll-fraud -section of \`docs/pstn-calling-voipms-plan.md\`. - ## Verifying it's working \`\`\`bash -docker exec -it easy-asterisk-do asterisk -rx "pjsip show endpoint voipms-trunk" +docker exec -it easy-asterisk-do asterisk -rx "pjsip show endpoint pstn-trunk" docker exec -it easy-asterisk-do asterisk -rx "dialplan show intercom" -docker exec -it easy-asterisk-do asterisk -rx "dialplan show from-voipms" +docker exec -it easy-asterisk-do asterisk -rx "dialplan show from-pstn-trunk" +tail -f $EA_DIR/logs/pstn-trunk-calls.log \`\`\` -A registered device should be able to dial a 10-digit or 11-digit US number -and reach the VoIP.ms trunk; a call to \`${VOIPMS_DID}\` from outside should -ring extension \`${RING_EXT}\`. +A PSTN-allowed device should be able to dial a 10-digit or 11-digit US +number and reach the trunk; a non-allowed device should get a busy signal +(and an ntfy alert, if enabled). A call to \`${TRUNK_DID}\` from outside +should ring: ${RING_EXTS}. MD chown "$ACTUAL_USER:$ACTUAL_USER" "$DOC_FILE" 2>/dev/null || true @@ -379,11 +616,13 @@ MD fi echo "" - log_success "VoIP.ms trunk configured." - echo " Server: $VOIPMS_SERVER ($VOIPMS_SERVER_IP)" - echo " DID: $VOIPMS_DID" - echo " Outbound: US/NANP only, max 3 concurrent calls (dialplan-enforced)" - echo " Inbound rings: extension $RING_EXT" - echo " Docs: $DOC_FILE" + log_success "PSTN trunk configured." + echo " Provider: $PROVIDER_NAME ($TRUNK_SERVER / $TRUNK_SERVER_IP)" + echo " DID: $TRUNK_DID" + echo " Outbound: US/NANP only, max $MAX_CONCURRENT concurrent calls" + echo " PSTN-allowed: ${PSTN_ALLOWED_EXTS:-all extensions}" + echo " Inbound rings: $RING_EXTS" + echo " ntfy alerts: ${NTFY_URL:-disabled}" + echo " Docs: $DOC_FILE" echo "" } diff --git a/setup.sh b/setup.sh index 509aecd..cf4005b 100755 --- a/setup.sh +++ b/setup.sh @@ -89,7 +89,7 @@ is_installed() { sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;; sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;; sky-cam-frigate) [ -d "$ACTUAL_HOME/sky-cam/.git" ] && [ -f "$ACTUAL_HOME/sky-cam/frigate-retime.sh" ] ;; - voipms-trunk) [ -f "$DOCKER_DIR/asterisk-digital-ocean/config/asterisk/voipms-trunk-pjsip.conf" ] ;; + pstn-trunk) [ -f "$DOCKER_DIR/asterisk-digital-ocean/config/asterisk/pstn-trunk-pjsip.conf" ] ;; ssh-config) false ;; # repeatable management tool, never shows [installed] *) [ -e "$DOCKER_DIR/$1" ] ;; esac From 3bd952e55de0748b397791e07bf83c49dabb5f04 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 23:59:50 +0000 Subject: [PATCH 5/6] 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/skip 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). --- docs/pstn-calling-voipms-plan.md | 109 +++++-- services/pstn-trunk.sh | 491 +++++++++++++++++++++---------- services/security-dashboard.sh | 354 +++++++++++++++++++--- setup.sh | 2 +- 4 files changed, 736 insertions(+), 220 deletions(-) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index 23ca7b2..b5e442f 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -4,19 +4,47 @@ 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` 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). +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 @@ -104,17 +132,40 @@ separately from that hourly check. `_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. -- **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. +- **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. + `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 @@ -154,10 +205,16 @@ separately from that hourly check. 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), 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). + 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. diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 2186826..40cb35c 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -1,25 +1,30 @@ #!/bin/bash -# services/pstn-trunk.sh — SIP PSTN trunk add-on for asterisk-digital-ocean: -# US-only outbound (NANP dialplan restriction), a configurable concurrent-call -# cap, role-based outbound permission (some extensions internal-only, some -# PSTN-enabled), a configurable inbound ring-group, IP-authenticated trunk -# (no SIP password stored), ntfy alerts on denied/rejected calls, and a -# periodic spend/volume check. +# services/pstn-trunk.sh — SIP PSTN trunk add-on for asterisk-digital-ocean +# (or the home/LAN asterisk install): US-only outbound (NANP dialplan +# restriction), a configurable concurrent-call cap, a 3-tier permission model +# per extension (internal-only / restricted to pre-approved numbers / full US +# calling), a configurable inbound ring-group, IP-authenticated trunk (no SIP +# password stored), ntfy alerts on denied/rejected calls, and a periodic +# spend/volume check. # # Defaults to VoIP.ms (see docs/pstn-calling-voipms-plan.md for the design/ # cost background this is built from) but isn't hardcoded to it — any SIP # trunk provider that supports IP authentication works the same way. # -# Requires an existing services/asterisk-digital-ocean.sh install — this adds -# a PSTN trunk on top of it and does not stand alone. +# Requires an existing services/asterisk-digital-ocean.sh OR services/asterisk.sh +# install — this adds a PSTN trunk on top of one of them and does not stand +# alone. Permission tiers are managed live (no restart needed) via +# pstn-permissions.conf — editable by hand, or from services/security-dashboard.sh's +# "PSTN Trunk" tab if that's installed. # # Part of the modular post-install system (sourced by setup.sh). -register_service pstn-trunk homelab "SIP PSTN trunk for asterisk-digital-ocean — US-only, role-based permissions, spend/volume alerts (defaults to VoIP.ms)" +register_service pstn-trunk homelab "SIP PSTN trunk for asterisk-digital-ocean/asterisk — US-only, per-extension permission tiers, spend/volume alerts (defaults to VoIP.ms)" # ── Surviving Easy Asterisk's regeneration ────────────────────────────────── -# Easy Asterisk (the vendor project asterisk-digital-ocean.sh builds on) fully -# OVERWRITES both pjsip.conf and extensions.conf from its own internal state: +# Easy Asterisk (the vendor project asterisk-digital-ocean.sh/asterisk.sh +# build on) fully OVERWRITES both pjsip.conf and extensions.conf from its own +# internal state: # - extensions.conf: rebuilt by rebuild_dialplan() on every container start, # and whenever a device/room is added or removed via the web admin. # - pjsip.conf: rewritten by generate_pjsip_conf() whenever VLAN/domain/TLS @@ -34,11 +39,28 @@ register_service pstn-trunk homelab "SIP PSTN trunk for asterisk-digital-ocean # technique this repo already uses for the logger.conf security-logging fix # in _asterisk_do_refresh_vendor_files (see services/asterisk-digital-ocean.sh). # -# Caveat: if the base asterisk-digital-ocean install is later refreshed -# ("update in place", which re-copies fresh vendor files) independently of -# this service, the patch is wiped along with it and needs reapplying — run -# this service again (fresh or update mode both reapply it) after any -# asterisk-digital-ocean update. +# Caveat: if the base asterisk-digital-ocean/asterisk install is later +# refreshed ("update in place", which re-copies fresh vendor files) +# independently of this service, the patch is wiped along with it and needs +# reapplying — run this service again (fresh or update mode both reapply it) +# after any base install update. +# +# ── Why permissions are a separate live file, not baked into the dialplan ── +# pstn-permissions.conf holds each extension's tier (internal/restricted/full) +# and, for restricted, its pipe-separated approved-number list. The dialplan +# reads it via Asterisk's AST_CONFIG() function, which re-reads the file from +# disk on every call — so editing this file (by hand, or via the Security +# Dashboard web UI) takes effect on the very next call, no Asterisk restart +# and no re-running this installer needed. "update in place" (below) never +# touches this file once it exists, for the same reason CLAUDE.md's +# update-mode convention protects .env/firewall/Caddy config — only "fresh" +# reinstall or the web UI change it. This is also why numbers are stored +# pipe-separated, not comma-separated: they're used directly as a regex +# alternation pattern in the dialplan, and the caller-supplied number being +# checked against them must never itself be interpolated into the PATTERN +# side of a REGEX() call (that would let a crafted Caller-ID/dialed-string +# forge a match) — this file's contents are always the pattern, the +# live call data is always the string being tested, never the reverse. # ── Shared: patch vendor generator functions to #include our config ──────── # Anchors on "user_agent=EasyAsterisk" (pjsip.conf's [global] section) and @@ -49,11 +71,13 @@ _pstn_patch_vendor_files() { local EA_DIR="$1" local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh" local EASY1="$EA_DIR/easy-asterisk.sh" - local EASY2="$EA_DIR/easy-asterisk-v0.10.0.sh" + local EASY2 + EASY2="$(find "$EA_DIR" -maxdepth 1 -name 'easy-asterisk-v*.sh' | head -1)" + [[ -z "$EASY2" ]] && EASY2="$EA_DIR/easy-asterisk-v0.10.0.sh" local f for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do - [[ -f "$f" ]] || { log_error "$f not found — is asterisk-digital-ocean fully installed?"; return 1; } + [[ -f "$f" ]] || { log_error "$f not found — is the base Asterisk install fully set up?"; return 1; } done for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do @@ -120,6 +144,36 @@ EOF sed -i "s/__PSTN_SERVER_IP__/${SERVER_IP}/g; s/__PSTN_SERVER__/${SERVER}/g; s/__PSTN_DID__/${DID}/g" "$FILE" } +# ── Shared: one inbound ring-group member's live permission check ───────── +# Emits a block that only adds this extension to PSTN_RING_LIST if it's +# "full" tier, or "restricted" tier AND the inbound Caller-ID is on its +# approved list. Uses a single-quoted heredoc (fully literal — no bash +# expansion) captured into a variable, then a pure bash string replace for +# the extension number placeholder — safer than sed here since it needs no +# escaping at all (the extension is plain digits, but this avoids relying on +# that fact staying true). +_pstn_ring_member_block() { + local EXT="$1" + local block + # ring__EXT__/skip__EXT__ are named priorities WITHIN this same extension + # (declared below via "same => n(label),..."), not separate exten => + # entries — Goto/GotoIf must use the single-argument label form here + # (bare "?label" / "Goto(label)"), not "label,1" (which addresses a + # different, nonexistent extension named "label" instead). + block=$(cat << 'MEMBER' + same => n,Set(PSTN_M_TIER=${AST_CONFIG(pstn-permissions.conf,__EXT__,tier)}) + same => n,GotoIf($["${PSTN_M_TIER}" = "full"]?ring__EXT__) + same => n,Set(PSTN_M_ALLOWED=${AST_CONFIG(pstn-permissions.conf,__EXT__,allowed_numbers)}) + same => n,GotoIf($["${PSTN_M_TIER}" = "restricted" & ${REGEX("^(${PSTN_M_ALLOWED})$" ${CALLERID(num)})}=1]?ring__EXT__) + same => n,Goto(skip__EXT__) + same => n(ring__EXT__),Set(PSTN_RING_LIST=${PSTN_RING_LIST}${PSTN_RING_SEP}PJSIP/__EXT__) + same => n,Set(PSTN_RING_SEP=&) + same => n(skip__EXT__),NoOp() +MEMBER +) + echo "${block//__EXT__/$EXT}" +} + # ── Shared: outbound/inbound dialplan ─────────────────────────────────────── # Continues in the [intercom] context established just above this include # (rebuild_dialplan() writes "[intercom]" then this #include right after it), @@ -127,10 +181,10 @@ EOF # below is a separate context, for calls arriving from the trunk. # # Role model: internal intercom dialing (extension-to-extension) is NEVER -# gated here — everyone keeps that, regardless of PSTN permission. Only the -# two NANP patterns (the trunk route) are gated by ALLOWED_REGEX. An empty -# allow-list at install time becomes ".*" (match anything), preserving -# "every extension can dial out" as the explicit opt-in default. +# gated here — everyone keeps that, regardless of PSTN tier. Only the two +# NANP patterns (the trunk route) and the inbound ring-group are gated, both +# via a LIVE read of pstn-permissions.conf (see the file-level comment above +# for why that's a separate file rather than baked in here). # # Calls are logged to pstn-trunk-calls.log (epoch|direction|who|what|seconds) # for the usage-alert script — not Asterisk's own CDR, to avoid depending on @@ -138,11 +192,15 @@ EOF # CDR CSV's comma-quoting entirely (our own pipe-delimited format has no # embedded-delimiter risk since every field here is digits/hostnames). _pstn_write_dialplan_include() { - local FILE="$1" DID="$2" ALLOWED_REGEX="$3" MAX_CONCURRENT="$4" RING_DIAL="$5" NTFY_URL="$6" + local FILE="$1" DID="$2" MAX_CONCURRENT="$3" RING_EXTS="$4" NTFY_URL="$5" cat > "$FILE" << 'EOF' -; PSTN outbound/inbound — US-only (NANP), concurrent-call cap, role-based -; outbound permission. Regenerated by services/pstn-trunk.sh — edit there, -; not here directly, or a reinstall/update will overwrite this. +; PSTN outbound/inbound — US-only (NANP), concurrent-call cap, tiered +; permissions (internal / restricted / full) read LIVE from +; pstn-permissions.conf via AST_CONFIG() — edit permissions there, or via the +; Security Dashboard web UI, with no restart needed. Regenerated by +; services/pstn-trunk.sh — edit there, not here directly, or a +; reinstall/update will overwrite this file (pstn-permissions.conf itself is +; NOT touched by "update", only by a "fresh" reinstall or the web UI). ; ; No catch-all pattern here on purpose: only these two NANP patterns route ; to the trunk, so an unauthorized or compromised extension can't reach @@ -151,15 +209,24 @@ _pstn_write_dialplan_include() { exten => _1NXXNXXXXX,1,NoOp(PSTN outbound call attempt from ${CHANNEL(peername)} to ${EXTEN}) same => n,Set(PSTN_CALLER=${CHANNEL(peername)}) - same => n,GotoIf($[${REGEX("^(__PSTN_ALLOWED_REGEX__)$" ${PSTN_CALLER})} = 1]?pstn_check_busy,1) - same => n,NoOp(Denied - ${PSTN_CALLER} is not authorized for PSTN outbound) -__ALERT_DENY_LINE__ + same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier)}) + same => n,GotoIf($["${PSTN_TIER}" = "full"]?pstn_check_busy,1) + same => n,GotoIf($["${PSTN_TIER}" = "restricted"]?pstn_check_allow_out,1) + same => n,NoOp(Denied - ${PSTN_CALLER} has no PSTN permission, tier: ${PSTN_TIER}) +__ALERT_DENY_TIER_LINE__ same => n,Busy(15) same => n,Hangup() exten => _NXXNXXXXX,1,NoOp(Assuming NANP - adding leading 1) same => n,Goto(1${EXTEN},1) +exten => pstn_check_allow_out,1,Set(PSTN_ALLOWED=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},allowed_numbers)}) + same => n,GotoIf($[${REGEX("^(${PSTN_ALLOWED})$" ${EXTEN})} = 1]?pstn_check_busy,1) + same => n,NoOp(Denied - ${EXTEN} not on ${PSTN_CALLER}'s approved number list) +__ALERT_DENY_NUMBER_LINE__ + same => n,Busy(15) + same => n,Hangup() + exten => pstn_check_busy,1,GotoIf($[${GROUP_COUNT(pstn-out)} >= __PSTN_MAX_CONCURRENT__]?pstn_busy,1) same => n,Set(GROUP()=pstn-out) same => n,Set(CALLERID(num)=__PSTN_DID__) @@ -173,29 +240,88 @@ exten => pstn_busy,1,NoOp(PSTN trunk - concurrent-call cap reached, rejecting) __ALERT_BUSY_LINE__ same => n,Busy(15) same => n,Hangup() - -[from-pstn-trunk] -exten => _X.,1,NoOp(Inbound PSTN call from ${CALLERID(num)}) - same => n,Set(PSTN_START=${EPOCH}) - same => n,Dial(__PSTN_RING_DIAL__,20) - same => n,Set(PSTN_DUR=$[${EPOCH} - ${PSTN_START}]) - same => n,System(printf '%s|in|%s|ring-group|%s\n' "${PSTN_START}" "${CALLERID(num)}" "${PSTN_DUR}" >> /var/log/asterisk/pstn-trunk-calls.log) - same => n,Hangup() EOF - sed -i "s/__PSTN_ALLOWED_REGEX__/${ALLOWED_REGEX}/g; s/__PSTN_MAX_CONCURRENT__/${MAX_CONCURRENT}/g; s/__PSTN_DID__/${DID}/g" "$FILE" - # RING_DIAL is "PJSIP/a&PJSIP/b&..." — the literal "&" must be escaped in - # a sed replacement (bare "&" means "the matched text", same gotcha as - # NTFY_URL below), or every "&" gets replaced with the placeholder itself. - local _esc_ring_dial="${RING_DIAL//&/\\&}" - sed -i "s#__PSTN_RING_DIAL__#${_esc_ring_dial}#g" "$FILE" + sed -i "s/__PSTN_MAX_CONCURRENT__/${MAX_CONCURRENT}/g; s/__PSTN_DID__/${DID}/g" "$FILE" if [[ -n "$NTFY_URL" ]]; then local _esc_url="${NTFY_URL//&/\\&}" - sed -i "s#__ALERT_DENY_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call denied - extension not authorized.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" + sed -i "s#__ALERT_DENY_TIER_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call denied - no PSTN permission.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" + sed -i "s#__ALERT_DENY_NUMBER_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call denied - number not pre-approved.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" sed -i "s#__ALERT_BUSY_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: concurrent-call cap reached - a call was rejected.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" else - sed -i "/__ALERT_DENY_LINE__/d; /__ALERT_BUSY_LINE__/d" "$FILE" + sed -i "/__ALERT_DENY_TIER_LINE__/d; /__ALERT_DENY_NUMBER_LINE__/d; /__ALERT_BUSY_LINE__/d" "$FILE" fi + + # ── Inbound: [from-pstn-trunk], one unrolled block per ring-group member + cat >> "$FILE" << 'EOF' + +[from-pstn-trunk] +exten => _X.,1,NoOp(Inbound PSTN call from ${CALLERID(num)}) + same => n,Set(PSTN_RING_LIST=) + same => n,Set(PSTN_RING_SEP=) +EOF + + local _ext + for _ext in $RING_EXTS; do + _pstn_ring_member_block "$_ext" >> "$FILE" + done + + cat >> "$FILE" << 'EOF' + same => n,GotoIf($["${PSTN_RING_LIST}" = ""]?pstn_in_denied,1) + same => n,Set(PSTN_START=${EPOCH}) + same => n,Dial(${PSTN_RING_LIST},20) + same => n,Set(PSTN_DUR=$[${EPOCH} - ${PSTN_START}]) + same => n,System(printf '%s|in|%s|ring-group|%s\n' "${PSTN_START}" "${CALLERID(num)}" "${PSTN_DUR}" >> /var/log/asterisk/pstn-trunk-calls.log) + same => n,Hangup() + +exten => pstn_in_denied,1,NoOp(Inbound PSTN call from ${CALLERID(num)} - no ring target authorized for this caller) +__ALERT_DENY_INBOUND_LINE__ + same => n,Hangup() +EOF + + if [[ -n "$NTFY_URL" ]]; then + local _esc_url2="${NTFY_URL//&/\\&}" + sed -i "s#__ALERT_DENY_INBOUND_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: inbound call rejected - caller not approved for any ring target.' '${_esc_url2}' >/dev/null 2>\\&1 \\&)#" "$FILE" + else + sed -i "/__ALERT_DENY_INBOUND_LINE__/d" "$FILE" + fi +} + +# ── Shared: initial permission tiers (fresh install / explicit reset only — +# "update in place" never calls this, matching how .env/firewall/Caddy config +# are protected elsewhere in this repo; see file-level comment above) ────── +# Args: FILE, space-separated FULL_EXTS, then "ext" "pipe|separated|numbers" +# pairs for each restricted extension. +_pstn_write_permissions_file() { + local FILE="$1" FULL_EXTS="$2" + shift 2 + { + echo "; PSTN permission tiers — internal / restricted / full." + echo "; Read LIVE by the dialplan on every call (AST_CONFIG()) — no Asterisk" + echo "; restart needed when this changes. Edit here directly, via the Security" + echo "; Dashboard web UI's \"PSTN Trunk\" tab (if installed), or by re-running" + echo "; 'sudo ./setup.sh pstn-trunk' and choosing a FRESH reinstall (\"update in" + echo "; place\" leaves this file alone on purpose)." + echo "; Any extension not listed here is internal-only (no PSTN) by default —" + echo "; it can still call/receive other Asterisk extensions and join internal" + echo "; ring groups, just not the PSTN trunk." + echo "" + local _ext + for _ext in $FULL_EXTS; do + echo "[$_ext]" + echo "tier=full" + echo "" + done + while [[ $# -gt 0 ]]; do + _ext="$1"; local _nums="$2" + shift 2 + echo "[$_ext]" + echo "tier=restricted" + echo "allowed_numbers=${_nums}" + echo "" + done + } > "$FILE" + chmod 664 "$FILE" } # ── Shared: periodic spend/volume checker (run hourly via cron) ──────────── @@ -251,31 +377,20 @@ EOF chmod 755 "$FILE" } -# ── Shared: apply everything from a settings set (used by fresh + update) ── +# ── Shared: structural settings only (used by fresh install AND update) ──── +# Does NOT touch pstn-permissions.conf — see the file-level comment above +# for why that file is managed separately. _pstn_apply_settings() { local EA_DIR="$1" ASTERISK_DIR="$2" - local SERVER="$3" SERVER_IP="$4" DID="$5" ALLOWED_EXTS="$6" MAX_CONCURRENT="$7" - local RING_EXTS="$8" NTFY_URL="$9" RATE="${10}" MONTH_THRESHOLD="${11}" BURST_THRESHOLD="${12}" - local PROVIDER_NAME="${13}" - - local ALLOWED_REGEX - if [[ -z "$ALLOWED_EXTS" ]]; then - ALLOWED_REGEX=".*" - else - ALLOWED_REGEX="$(echo "$ALLOWED_EXTS" | tr -s ' ' '|')" - fi - - local RING_DIAL="" _ext - for _ext in $RING_EXTS; do - [[ -n "$RING_DIAL" ]] && RING_DIAL="${RING_DIAL}&" - RING_DIAL="${RING_DIAL}PJSIP/${_ext}" - done + local SERVER="$3" SERVER_IP="$4" DID="$5" MAX_CONCURRENT="$6" + local RING_EXTS="$7" NTFY_URL="$8" RATE="$9" MONTH_THRESHOLD="${10}" BURST_THRESHOLD="${11}" + local PROVIDER_NAME="${12}" _pstn_patch_vendor_files "$EA_DIR" || return 1 mkdir -p "$ASTERISK_DIR" _pstn_write_pjsip_include "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$SERVER" "$SERVER_IP" "$DID" - _pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$ALLOWED_REGEX" "$MAX_CONCURRENT" "$RING_DIAL" "$NTFY_URL" + _pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$MAX_CONCURRENT" "$RING_EXTS" "$NTFY_URL" _pstn_write_usage_alert_script "$EA_DIR/pstn-trunk-usage-alert.sh" "$EA_DIR" "$RATE" "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$NTFY_URL" ensure_docker_dir_ownership "$ASTERISK_DIR" chmod 644 "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$ASTERISK_DIR/pstn-trunk-dialplan.conf" @@ -285,7 +400,6 @@ PROVIDER_NAME=${PROVIDER_NAME} TRUNK_SERVER=${SERVER} TRUNK_SERVER_IP=${SERVER_IP} TRUNK_DID=${DID} -PSTN_ALLOWED_EXTS=${ALLOWED_EXTS} MAX_CONCURRENT=${MAX_CONCURRENT} RING_EXTS=${RING_EXTS} NTFY_URL=${NTFY_URL} @@ -308,41 +422,64 @@ CRON install_pstn-trunk() { require_docker || return 1 - local EA_DIR="$DOCKER_DIR/asterisk-digital-ocean" + local EA_DIR="" ASTERISK_KIND="" + if [[ -f "$DOCKER_DIR/asterisk-digital-ocean/docker-compose.yml" ]]; then + EA_DIR="$DOCKER_DIR/asterisk-digital-ocean" + ASTERISK_KIND="asterisk-digital-ocean" + elif [[ -f "$DOCKER_DIR/asterisk/docker-compose.yml" ]]; then + EA_DIR="$DOCKER_DIR/asterisk" + ASTERISK_KIND="asterisk" + fi + local ASTERISK_DIR="$EA_DIR/config/asterisk" local PJSIP_INCLUDE="$ASTERISK_DIR/pstn-trunk-pjsip.conf" local DIALPLAN_INCLUDE="$ASTERISK_DIR/pstn-trunk-dialplan.conf" + local PERMISSIONS_FILE="$ASTERISK_DIR/pstn-permissions.conf" local SETTINGS_FILE="$EA_DIR/.pstn-trunk.env" + local CONTAINER_NAME="easy-asterisk" + [[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would require an existing asterisk-digital-ocean install at $EA_DIR" - echo "[DRY-RUN] Would prompt for: SIP provider name (default VoIP.ms), server/POP hostname," - echo "[DRY-RUN] DID, extensions allowed to dial out (blank=all), max concurrent calls (default 3)," - echo "[DRY-RUN] extensions to ring inbound (space-separated, ring-group supported)," + echo "[DRY-RUN] Would require an existing asterisk-digital-ocean OR asterisk (LAN) install" + echo "[DRY-RUN] Would prompt for: SIP provider name (default VoIP.ms), server/POP hostname, DID," + echo "[DRY-RUN] full-PSTN extensions, restricted-PSTN extensions + their approved numbers," + echo "[DRY-RUN] max concurrent calls (default 3), inbound ring-group extensions," echo "[DRY-RUN] ntfy alert topic (optional), per-minute rate + monthly/hourly alert thresholds" echo "[DRY-RUN] Would resolve the server hostname to an IP for inbound call matching" echo "[DRY-RUN] Would patch vendor generator functions to #include the trunk config" - echo "[DRY-RUN] Would write $PJSIP_INCLUDE, $DIALPLAN_INCLUDE, and an hourly usage-alert script + cron.d entry" - echo "[DRY-RUN] Would offer 'update in place' (reads settings back from $SETTINGS_FILE) instead of a fresh install if already configured" + echo "[DRY-RUN] Would write pjsip/dialplan includes, pstn-permissions.conf (fresh install only)," + echo "[DRY-RUN] and an hourly usage-alert script + cron.d entry" + echo "[DRY-RUN] Would offer 'update in place' (structural settings only — never touches" + echo "[DRY-RUN] pstn-permissions.conf) instead of a fresh install if already configured" echo "[DRY-RUN] Would restart the asterisk container to apply" return 0 fi - if [[ ! -f "$EA_DIR/docker-compose.yml" ]]; then - log_error "asterisk-digital-ocean isn't installed at $EA_DIR — install it first:" - log_error " sudo ./setup.sh asterisk-digital-ocean" - log_error "This service adds a PSTN trunk on top of it; it doesn't stand alone." + if [[ -z "$EA_DIR" ]]; then + log_error "Neither asterisk-digital-ocean nor asterisk (LAN) is installed — install one first:" + log_error " sudo ./setup.sh asterisk-digital-ocean (recommended — public droplet, static IP)" + log_error " sudo ./setup.sh asterisk (home/LAN — see the static-IP caveat below)" + log_error "This service adds a PSTN trunk on top of one of them; it doesn't stand alone." return 1 fi - log_info "Configuring a SIP PSTN trunk for asterisk-digital-ocean (defaults to VoIP.ms)." - log_info "US-only outbound (NANP dialplan), a concurrent-call cap, role-based outbound permission," - log_info "an inbound ring-group, and ntfy alerts on denied/rejected calls plus spend/volume checks." + if [[ "$ASTERISK_KIND" == "asterisk" ]]; then + echo "" + log_warning "Using the home/LAN asterisk install. IP authentication needs a STABLE public IP —" + log_warning "if this box is behind a dynamic home IP, your provider's IP allow-list goes stale" + log_warning "whenever your ISP rotates it, breaking calls until you update it there yourself." + log_warning "A static IP from your ISP avoids that; asterisk-digital-ocean sidesteps it entirely." + fi + + log_info "Configuring a SIP PSTN trunk for $ASTERISK_KIND (defaults to VoIP.ms)." + log_info "US-only outbound (NANP dialplan), a concurrent-call cap, per-extension permission" + log_info "tiers, an inbound ring-group, and ntfy alerts on denied/rejected calls plus" + log_info "spend/volume checks." echo "" log_warning "Before continuing, on your provider's side you should already have: created an" log_warning "account, funded and set up prepaid billing with auto-recharge OFF (VoIP.ms: Client" log_warning "Area -> Balance Management), ordered a DID with IP authentication pointed at this" - log_warning "droplet's public IP, and picked a server/POP. Also restrict outbound routing to" + log_warning "box's public IP, and picked a server/POP. Also restrict outbound routing to" log_warning "US/NANP on the provider's own side if it offers that — this dialplan is the second," log_warning "independent layer, not a substitute for the first." log_warning "See docs/pstn-calling-voipms-plan.md for the full background." @@ -359,12 +496,14 @@ install_pstn-trunk() { # shellcheck disable=SC1090 source "$SETTINGS_FILE" _pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ - "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" "$PSTN_ALLOWED_EXTS" \ - "$MAX_CONCURRENT" "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ + "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" "$MAX_CONCURRENT" \ + "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 ( cd "$EA_DIR" && docker compose restart asterisk ) \ && log_success "Updated — settings unchanged (server $TRUNK_SERVER, DID $TRUNK_DID, ring exts: $RING_EXTS)." \ || log_warning "Restart failed — check: docker compose -f $EA_DIR/docker-compose.yml logs asterisk" + log_info "pstn-permissions.conf was NOT touched — edit it directly, via the Security" + log_info "Dashboard, or choose FRESH reinstall to reset it." return 0 else log_warning "No $SETTINGS_FILE found (pre-dates this settings-file version) — falling back to a fresh install (every prompt below)." @@ -375,6 +514,17 @@ install_pstn-trunk() { return 0 ;; fresh) + if [[ -f "$PERMISSIONS_FILE" ]]; then + log_warning "pstn-permissions.conf already exists and may have been edited since" + log_warning "(directly, or via the Security Dashboard). A fresh reinstall OVERWRITES it" + log_warning "with whatever you enter below." + local _confirm_reset="" + prompt_yn "Continue and reset permission tiers? (y/n):" "n" _confirm_reset + if [[ ! "$_confirm_reset" =~ ^[Yy]$ ]]; then + log_info "Cancelled — nothing changed." + return 0 + fi + fi log_info "Proceeding with a full fresh reinstall — every prompt below runs from scratch." ;; esac @@ -386,7 +536,7 @@ install_pstn-trunk() { prompt_text "SIP trunk provider name (for your reference/docs only):" "VoIP.ms" PROVIDER_NAME local TRUNK_SERVER="" - prompt_text "Server/POP hostname (e.g. atlanta2.voip.ms for VoIP.ms — pick the one closest to this droplet from your provider's server list):" "" TRUNK_SERVER + prompt_text "Server/POP hostname (e.g. atlanta2.voip.ms for VoIP.ms — pick the one closest to this box from your provider's server list):" "" TRUNK_SERVER if [[ -z "$TRUNK_SERVER" ]]; then log_error "A server hostname is required — aborting." return 1 @@ -412,17 +562,36 @@ install_pstn-trunk() { return 1 fi + # ── Permission tiers ─────────────────────────────────────────────────── echo "" - echo " Role model: EVERY extension can always call/receive calls from other" - echo " Asterisk extensions (internal intercom dialing is never restricted" - echo " here). The setting below only controls PSTN (real phone number)" - echo " access — extensions left out behave exactly as they do today." - local PSTN_ALLOWED_EXTS="" - prompt_text "Extensions allowed to dial PSTN numbers (space-separated, e.g. '1001 1002'; blank = every extension):" "" PSTN_ALLOWED_EXTS - if [[ -z "$PSTN_ALLOWED_EXTS" ]]; then - log_info "No restriction entered — every extension will be able to dial PSTN numbers." - else - log_info "Only these extensions may dial PSTN numbers: $PSTN_ALLOWED_EXTS" + echo " Three tiers, per extension:" + echo " internal — call/receive other Asterisk extensions + internal ring" + echo " groups only. No PSTN at all. Default for anything not" + echo " listed below." + echo " restricted — internal, PLUS call/receive ONLY pre-approved US numbers." + echo " full — internal, PLUS call/receive ANY US number." + echo " These are managed LIVE after install (pstn-permissions.conf) — via the" + echo " Security Dashboard web UI if installed, or by hand — with no restart or" + echo " reinstall needed to change them later." + local FULL_EXTS="" + prompt_text "Full-PSTN extensions (space-separated, blank = none):" "" FULL_EXTS + + local RESTRICTED_EXTS="" + prompt_text "Restricted-PSTN extensions (space-separated, blank = none):" "" RESTRICTED_EXTS + + local RESTRICTED_ARGS=() + if [[ -n "$RESTRICTED_EXTS" ]]; then + local _ext _raw_nums _clean_nums + for _ext in $RESTRICTED_EXTS; do + prompt_text " Approved numbers for extension $_ext (comma/space-separated, 11-digit US numbers, e.g. 15551234567):" "" _raw_nums + _clean_nums="$(echo "$_raw_nums" | tr ', ' '\n\n' | grep -E '^[0-9]{11}$' | paste -sd'|' - 2>/dev/null)" + if [[ -z "$_clean_nums" ]]; then + log_warning "No valid 11-digit numbers entered for $_ext — it will be restricted with an EMPTY" + log_warning "approved list, meaning no PSTN number can currently reach/be reached by it until" + log_warning "you add some (via the Security Dashboard or by editing pstn-permissions.conf)." + fi + RESTRICTED_ARGS+=("$_ext" "$_clean_nums") + done fi local MAX_CONCURRENT="" @@ -432,8 +601,10 @@ install_pstn-trunk() { MAX_CONCURRENT=3 fi + local _suggested_ring + _suggested_ring="$(echo "$FULL_EXTS $RESTRICTED_EXTS" | xargs)" local RING_EXTS="" - prompt_text "Extensions to ring for inbound PSTN calls (space-separated — one extension, or several for a ring group):" "" RING_EXTS + prompt_text "Extensions to ring for inbound PSTN calls (space-separated — one, or several for a ring group; only full/restricted-tier members will actually ring):" "$_suggested_ring" RING_EXTS if [[ -z "$RING_EXTS" ]]; then log_error "At least one extension is required for inbound routing — aborting." return 1 @@ -441,7 +612,7 @@ install_pstn-trunk() { echo "" local WANT_NTFY="" - prompt_yn "Send an ntfy alert when a call is denied (unauthorized extension) or rejected (concurrency cap hit)? (y/n):" "y" WANT_NTFY + prompt_yn "Send an ntfy alert when a call is denied (permission tier/approved-number check failed) or rejected (concurrency cap hit)? (y/n):" "y" WANT_NTFY local NTFY_URL="" if [[ "$WANT_NTFY" =~ ^[Yy]$ ]]; then # Prefer a locally-installed ntfy's own base-url as the default, same @@ -473,23 +644,26 @@ install_pstn-trunk() { prompt_text " Alert if more than this many outbound calls happen in one hour:" "10" BURST_THRESHOLD _pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ - "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" "$PSTN_ALLOWED_EXTS" \ - "$MAX_CONCURRENT" "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ + "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" "$MAX_CONCURRENT" \ + "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 - # No new firewall rules: asterisk-digital-ocean.sh already opens SIP - # (5060/5061) and RTP (10000-20000) to the internet, and providers' source - # IPs vary by POP/redundancy, so there's no single IP to scope this to - # even if narrowing it were otherwise worthwhile. + _pstn_write_permissions_file "$PERMISSIONS_FILE" "$FULL_EXTS" "${RESTRICTED_ARGS[@]}" + ensure_docker_dir_ownership "$ASTERISK_DIR" - # ── Docs (separate file — asterisk-digital-ocean already owns README.md - # in this same directory via write_readme, so don't overwrite it) ─────── + # No new firewall rules: the base install already opens SIP (5060/5061) + # and RTP (10000-20000) to the internet, and providers' source IPs vary + # by POP/redundancy, so there's no single IP to scope this to even if + # narrowing it were otherwise worthwhile. + + # ── Docs (separate file — the base install already owns README.md in + # this same directory via write_readme, so don't overwrite it) ───────── local DOC_FILE="$EA_DIR/README-pstn-trunk.md" cat > "$DOC_FILE" << MD -# SIP PSTN trunk (add-on to asterisk-digital-ocean) +# SIP PSTN trunk (add-on to $ASTERISK_KIND) US-only outbound PSTN calling over a SIP trunk (defaults to VoIP.ms, works -with any IP-authenticated provider), role-based outbound permission, a +with any IP-authenticated provider), per-extension permission tiers, a configurable concurrent-call cap, and an inbound ring-group. See \`docs/pstn-calling-voipms-plan.md\` in the repo for the full design background, cost estimate, and toll-fraud reasoning. @@ -502,47 +676,63 @@ background, cost estimate, and toll-fraud reasoning. | Server/POP | ${TRUNK_SERVER} (${TRUNK_SERVER_IP}) | | DID | ${TRUNK_DID} | | Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all | -| PSTN-allowed extensions | ${PSTN_ALLOWED_EXTS:-all extensions} | +| Full-PSTN extensions | ${FULL_EXTS:-none} | +| Restricted-PSTN extensions | ${RESTRICTED_EXTS:-none} | | Concurrency cap | ${MAX_CONCURRENT} simultaneous outbound calls | -| Inbound rings | ${RING_EXTS} | +| Inbound ring-group | ${RING_EXTS} | | ntfy alerts | ${NTFY_URL:-disabled} | | Estimated rate | \$${RATE_PER_MIN}/min | | Monthly spend alert threshold | \$${MONTH_THRESHOLD} | | Hourly burst alert threshold | ${BURST_THRESHOLD} calls/hour | -## Role model +## Permission tiers Every extension can always call and receive calls from other Asterisk -extensions — that's unchanged and never gated. The PSTN-allowed list above -only controls the two additional things a "PSTN-enabled" extension gets on -top of that: dialing real phone numbers out, and being included in the -inbound ring-group. Leaving the allow-list blank means every extension gets -PSTN access too (the original default before roles existed). +extensions, and join internal ring groups — that's unchanged and never +gated by anything below. Three tiers control PSTN (real phone number) +access specifically: + +- **internal** (default — anything not listed as full/restricted): no PSTN + at all, in or out. +- **restricted**: can only call and be called by numbers on its own + pre-approved list. +- **full**: can call/receive any US number. + +Stored in \`config/asterisk/pstn-permissions.conf\`, read **live** by the +dialplan via Asterisk's \`AST_CONFIG()\` on every call — editing this file +(by hand, or via the Security Dashboard's "PSTN Trunk" tab, if that service +is installed) takes effect on the next call, no restart needed. Re-running +this installer in "update" mode never touches this file — only a "fresh" +reinstall (with confirmation) or the web UI change it, the same protection +CLAUDE.md's update-mode convention gives \`.env\`/firewall/Caddy config +elsewhere in this repo. ## How this survives Easy Asterisk's own regeneration Easy Asterisk rewrites \`pjsip.conf\` and \`extensions.conf\` from its own internal state (device list, network settings) rather than treating them as -hand-edited files. Trunk/dialplan config here lives in two files of its own, +hand-edited files. Trunk/dialplan config here lives in files of its own, \`#include\`'d from the generated files: - \`config/asterisk/pstn-trunk-pjsip.conf\` — the trunk's \`aor\`/\`identify\`/ \`endpoint\` sections (IP-authenticated, no password stored). - \`config/asterisk/pstn-trunk-dialplan.conf\` — NANP-only outbound routing, - the outbound permission gate, the concurrency cap, ntfy alert hooks, and - the \`[from-pstn-trunk]\` inbound context. + the concurrency cap, ntfy alert hooks, and the \`[from-pstn-trunk]\` inbound + context. Reads permission tiers live from \`pstn-permissions.conf\` (above) + rather than baking them in, specifically so they can change without + touching this file. The \`#include\` lines themselves are patched into Easy Asterisk's *generator -functions* (\`docker/entrypoint.sh\`, \`easy-asterisk.sh\`, -\`easy-asterisk-v0.10.0.sh\`) so they get re-emitted every time those functions -regenerate the config, instead of being wiped. +functions* (\`docker/entrypoint.sh\`, \`easy-asterisk.sh\`, and its versioned +copy) so they get re-emitted every time those functions regenerate the +config, instead of being wiped. -**Caveat:** if the base \`asterisk-digital-ocean\` service is ever updated -independently (\`sudo ./setup.sh asterisk-digital-ocean\`, choosing "update in -place" — that path re-copies fresh vendor files), this patch is wiped along -with it. Re-run \`sudo ./setup.sh pstn-trunk\` afterward (update mode -reapplies the patch and rewrites everything from \`.pstn-trunk.env\`, no -re-prompting). +**Caveat:** if the base $ASTERISK_KIND service is ever updated independently +(\`sudo ./setup.sh $ASTERISK_KIND\`, choosing "update in place" — that path +re-copies fresh vendor files), this patch is wiped along with it. Re-run +\`sudo ./setup.sh pstn-trunk\` afterward (update mode reapplies the patch and +rewrites structural settings from \`.pstn-trunk.env\`, no re-prompting, and +without touching \`pstn-permissions.conf\`). ## Spend/volume alerts @@ -559,27 +749,30 @@ comma-quoting). It sends an ntfy alert: calls/hour — this is the faster tripwire for a burst/abuse scenario, independent of whether it's crossed the monthly dollar threshold yet. -Separately, denied calls (unauthorized extension) and rejected calls -(concurrency cap hit) alert **immediately**, not on the hourly schedule — -see the dialplan file's \`__ALERT_DENY_LINE__\`/\`__ALERT_BUSY_LINE__\` sites. +Separately, denied calls (no permission / number not pre-approved) and +rejected calls (concurrency cap hit) alert **immediately**, not on the +hourly schedule. These are cost *estimates* (call count/duration × your entered rate), not real billing data — treat them as a safety net, not a substitute for checking your provider's own balance/usage dashboard. -## Changing settings +## Managing this from a web UI -Re-run \`sudo ./setup.sh pstn-trunk\` and choose "reinstall in place" — -current settings are read from \`.pstn-trunk.env\` and reapplied exactly, -including regenerating the usage-alert script and cron entry. Choose "full -install" instead to re-prompt for everything. +If \`services/security-dashboard.sh\` is installed, its "PSTN Trunk" tab +lists every known extension (parsed from \`pjsip.conf\`) with its current +tier and approved-numbers list, editable live — no restart, no reinstall. +Install/update it any time with \`sudo ./setup.sh security-dashboard\`; it +auto-detects this install. ## Manual edits Don't hand-edit \`pstn-trunk-pjsip.conf\` / \`pstn-trunk-dialplan.conf\` / \`pstn-trunk-usage-alert.sh\` directly if you plan to re-run this installer -later — it overwrites all three unconditionally from \`.pstn-trunk.env\`. For -one-off testing, restart the container instead of running the installer: +later — it overwrites all three unconditionally from \`.pstn-trunk.env\` on +both fresh and update. \`pstn-permissions.conf\` is different — see +"Permission tiers" above, it's safe to hand-edit any time. For one-off +testing, restart the container instead of running the installer: \`\`\`bash docker compose -f $EA_DIR/docker-compose.yml restart asterisk @@ -588,16 +781,17 @@ docker compose -f $EA_DIR/docker-compose.yml restart asterisk ## Verifying it's working \`\`\`bash -docker exec -it easy-asterisk-do asterisk -rx "pjsip show endpoint pstn-trunk" -docker exec -it easy-asterisk-do asterisk -rx "dialplan show intercom" -docker exec -it easy-asterisk-do asterisk -rx "dialplan show from-pstn-trunk" +docker exec -it $CONTAINER_NAME asterisk -rx "pjsip show endpoint pstn-trunk" +docker exec -it $CONTAINER_NAME asterisk -rx "dialplan show intercom" +docker exec -it $CONTAINER_NAME asterisk -rx "dialplan show from-pstn-trunk" tail -f $EA_DIR/logs/pstn-trunk-calls.log \`\`\` -A PSTN-allowed device should be able to dial a 10-digit or 11-digit US -number and reach the trunk; a non-allowed device should get a busy signal -(and an ntfy alert, if enabled). A call to \`${TRUNK_DID}\` from outside -should ring: ${RING_EXTS}. +A full-tier device should be able to dial a 10-digit or 11-digit US number +and reach the trunk; a restricted-tier device should only reach numbers on +its approved list; an internal-tier device should get a busy signal (and an +ntfy alert, if enabled). A call to \`${TRUNK_DID}\` from an approved/any US +number (depending on tier) should ring: ${RING_EXTS}. MD chown "$ACTUAL_USER:$ACTUAL_USER" "$DOC_FILE" 2>/dev/null || true @@ -617,12 +811,13 @@ MD echo "" log_success "PSTN trunk configured." - echo " Provider: $PROVIDER_NAME ($TRUNK_SERVER / $TRUNK_SERVER_IP)" - echo " DID: $TRUNK_DID" - echo " Outbound: US/NANP only, max $MAX_CONCURRENT concurrent calls" - echo " PSTN-allowed: ${PSTN_ALLOWED_EXTS:-all extensions}" - echo " Inbound rings: $RING_EXTS" - echo " ntfy alerts: ${NTFY_URL:-disabled}" - echo " Docs: $DOC_FILE" + echo " Provider: $PROVIDER_NAME ($TRUNK_SERVER / $TRUNK_SERVER_IP)" + echo " DID: $TRUNK_DID" + echo " Outbound: US/NANP only, max $MAX_CONCURRENT concurrent calls" + echo " Full-PSTN extensions: ${FULL_EXTS:-none}" + echo " Restricted extensions: ${RESTRICTED_EXTS:-none}" + echo " Inbound ring-group: $RING_EXTS" + echo " ntfy alerts: ${NTFY_URL:-disabled}" + echo " Docs: $DOC_FILE" echo "" } diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index a928b02..ef85d82 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -69,27 +69,40 @@ register_service security-dashboard homelab "Security dashboard: Asterisk failed install_security-dashboard() { local APP_DIR="/opt/security-dashboard" local DASHBOARD_PORT=8092 - local ASTERISK_LOG_DIR="$DOCKER_DIR/asterisk-digital-ocean/logs" local SVC_USER="secdash" + + # Either Asterisk flavor works — prefer asterisk-digital-ocean if both + # happen to be installed, matching services/pstn-trunk.sh's own + # preference order for consistency. + local ASTERISK_EA_DIR="" + if [ -d "$DOCKER_DIR/asterisk-digital-ocean" ]; then + ASTERISK_EA_DIR="$DOCKER_DIR/asterisk-digital-ocean" + elif [ -d "$DOCKER_DIR/asterisk" ]; then + ASTERISK_EA_DIR="$DOCKER_DIR/asterisk" + fi + local ASTERISK_LOG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/logs}" + local ASTERISK_CONFIG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/config/asterisk}" + local ASTERISK_ADMIN_URL="" - if [ -f "$DOCKER_DIR/asterisk-digital-ocean/.env" ]; then + if [ -n "$ASTERISK_EA_DIR" ] && [ -f "$ASTERISK_EA_DIR/.env" ]; then local _ea_domain - _ea_domain="$(grep -E '^DOMAIN_NAME=' "$DOCKER_DIR/asterisk-digital-ocean/.env" | cut -d= -f2-)" + _ea_domain="$(grep -E '^DOMAIN_NAME=' "$ASTERISK_EA_DIR/.env" | cut -d= -f2-)" [ -n "$_ea_domain" ] && ASTERISK_ADMIN_URL="https://${_ea_domain}" fi echo "" echo "┌─────────────────────────────────────────────────────────────────┐" echo "│ SECURITY DASHBOARD │" - echo "│ Asterisk failed-connection log + CrowdSec decisions, one page. │" - echo "│ Runs natively on the host (not Docker) so it can call cscli and │" - echo "│ read Asterisk's security log directly. Authelia-protected. │" + echo "│ Asterisk failed-connection log + CrowdSec decisions + PSTN │" + echo "│ trunk permissions, one page. Runs natively on the host (not │" + echo "│ Docker) so it can call cscli and read Asterisk's files │" + echo "│ directly. Authelia-protected. │" echo "└─────────────────────────────────────────────────────────────────┘" echo "" - if [ ! -d "$ASTERISK_LOG_DIR" ]; then - log_warning "No asterisk-digital-ocean install detected at $ASTERISK_LOG_DIR." - log_warning "The Security Log tab will just be empty — CrowdSec's tab still works fine." + if [ -z "$ASTERISK_EA_DIR" ]; then + log_warning "No asterisk-digital-ocean or asterisk install detected." + log_warning "The Security Log and PSTN Trunk tabs will just be empty — CrowdSec's tab still works fine." fi if [ "$DRY_RUN" = true ]; then @@ -97,6 +110,7 @@ install_security-dashboard() { echo "[DRY-RUN] Would write $APP_DIR/app.py" echo "[DRY-RUN] Would write /etc/sudoers.d/security-dashboard (scoped cscli/systemctl only)" echo "[DRY-RUN] Would write a systemd unit and start it on 0.0.0.0:$DASHBOARD_PORT (firewalled via UFW, not interface binding)" + echo "[DRY-RUN] Would grant read/write access to the detected Asterisk config dir (for the PSTN Trunk tab)" echo "[DRY-RUN] Would configure Caddy + Authelia for a domain you'll be prompted for" return 0 fi @@ -112,9 +126,11 @@ install_security-dashboard() { } case "$MODE" in update) - log_info "Refreshing app code + sudoers rule (no config/domain changes)..." + log_info "Refreshing app code + sudoers rule + systemd unit (no Caddy/domain changes)..." + _secdash_grant_asterisk_access "$SVC_USER" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" _secdash_write_app "$APP_DIR" _secdash_write_sudoers "$SVC_USER" + _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_ADMIN_URL" systemctl restart security-dashboard 2>/dev/null \ && log_success "security-dashboard restarted" \ || log_warning "Restart failed — check: systemctl status security-dashboard" @@ -142,45 +158,14 @@ install_security-dashboard() { log_success "Created system user $SVC_USER" fi - # Read access to the Asterisk security log without running as root or the - # actual user — add secdash to the group that owns the log files instead. - if [ -d "$ASTERISK_LOG_DIR" ]; then - local _log_group - _log_group="$(stat -c '%G' "$ASTERISK_LOG_DIR" 2>/dev/null || echo "$ACTUAL_USER")" - usermod -aG "$_log_group" "$SVC_USER" 2>/dev/null || true - chmod 750 "$ASTERISK_LOG_DIR" 2>/dev/null || true - fi + _secdash_grant_asterisk_access "$SVC_USER" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" mkdir -p "$APP_DIR" _secdash_write_app "$APP_DIR" chown -R "$SVC_USER:$SVC_USER" "$APP_DIR" _secdash_write_sudoers "$SVC_USER" - - # ── systemd unit ────────────────────────────────────────────────────────── - cat > /etc/systemd/system/security-dashboard.service << SDSVC -[Unit] -Description=Security dashboard (Asterisk security log + CrowdSec decisions) -After=network.target - -[Service] -Type=simple -User=$SVC_USER -Group=$SVC_USER -Environment=DASHBOARD_PORT=$DASHBOARD_PORT -Environment=ASTERISK_LOG=$ASTERISK_LOG_DIR/full -Environment=ASTERISK_ADMIN_URL=$ASTERISK_ADMIN_URL -ExecStart=/usr/bin/python3 $APP_DIR/app.py -Restart=on-failure -RestartSec=3 -NoNewPrivileges=false -ProtectSystem=strict -ReadOnlyPaths=$ASTERISK_LOG_DIR -ReadWritePaths=/etc/crowdsec/scenarios - -[Install] -WantedBy=multi-user.target -SDSVC + _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_ADMIN_URL" systemctl daemon-reload systemctl enable security-dashboard >/dev/null 2>&1 @@ -221,6 +206,12 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly. - **Unwhitelist + Ban** does that *and* immediately bans (24h) every IP CrowdSec has ever recorded for that ASN, for accidental-whitelist cases where you don't want to wait for it to misbehave again. +- **PSTN Trunk** (only if \`services/pstn-trunk.sh\` is installed) — every + known extension (parsed from \`pjsip.conf\`) with its current permission + tier (internal / restricted / full) and, for restricted, its approved + numbers, editable live — no Asterisk restart, no reinstall. Writes + directly to \`pstn-permissions.conf\`, which the dialplan reads fresh on + every call. - Link to the Asterisk web admin itself (doesn't embed it, just links out). ## Manage @@ -257,6 +248,77 @@ README_MD echo "" } +# Grants secdash read/write access to wherever Asterisk's config lives +# without running the dashboard as root or the actual user — added to the +# group that already owns those directories (ensure_docker_dir_ownership +# elsewhere in this repo sets both owner AND group to ACTUAL_USER, so the log +# dir and config dir normally share one group already; handled separately +# anyway in case that ever changes). Separate function, called from both +# "update" and fresh-install, so a PSTN trunk installed *after* this +# dashboard (or an asterisk-digital-ocean/asterisk swap) reaches an existing +# install on its next update instead of silently only applying to new ones. +_secdash_grant_asterisk_access() { + local _svc_user="$1" _log_dir="$2" _config_dir="$3" + local _dir + for _dir in "$_log_dir" "$_config_dir"; do + [ -n "$_dir" ] && [ -d "$_dir" ] || continue + local _group + _group="$(stat -c '%G' "$_dir" 2>/dev/null || echo "$ACTUAL_USER")" + usermod -aG "$_group" "$_svc_user" 2>/dev/null || true + chmod 750 "$_dir" 2>/dev/null || true + done + # pstn-permissions.conf specifically needs group WRITE (750 above is + # read+execute for the group, not write) — the file itself is written + # group-writable (664) by services/pstn-trunk.sh, but the containing + # directory also needs the group execute+write bit for a new file save + # (configparser writes a fresh temp file then renames it into place) to + # succeed. 770 only on the config dir, not the log dir (no reason for + # secdash to ever create files in the log dir). + if [ -n "$_config_dir" ] && [ -d "$_config_dir" ]; then + chmod 770 "$_config_dir" 2>/dev/null || true + fi +} + +# Systemd unit — separate function so "update" mode can refresh it too +# (Environment= vars and ReadWritePaths depend on which Asterisk flavor is +# detected, which can change between installs — e.g. a PSTN trunk or a +# different Asterisk flavor installed after this dashboard's first setup). +# ProtectSystem=strict makes the whole filesystem read-only for this unit +# except the paths explicitly listed below, regardless of Unix permissions — +# both layers (this AND the group access above) need to agree, or writes +# fail even when Unix permissions alone would have allowed them. +_secdash_write_systemd_unit() { + local _app_dir="$1" _svc_user="$2" _port="$3" _log_dir="$4" _config_dir="$5" _admin_url="$6" + local _read_only_paths="" _read_write_paths="/etc/crowdsec/scenarios" + [ -n "$_log_dir" ] && _read_only_paths="$_log_dir" + [ -n "$_config_dir" ] && _read_write_paths="$_read_write_paths $_config_dir" + + cat > /etc/systemd/system/security-dashboard.service << SDSVC +[Unit] +Description=Security dashboard (Asterisk security log + CrowdSec decisions + PSTN trunk permissions) +After=network.target + +[Service] +Type=simple +User=$_svc_user +Group=$_svc_user +Environment=DASHBOARD_PORT=$_port +Environment=ASTERISK_LOG=${_log_dir:+$_log_dir/full} +Environment=ASTERISK_CONFIG_DIR=$_config_dir +Environment=ASTERISK_ADMIN_URL=$_admin_url +ExecStart=/usr/bin/python3 $_app_dir/app.py +Restart=on-failure +RestartSec=3 +NoNewPrivileges=false +ProtectSystem=strict +ReadOnlyPaths=$_read_only_paths +ReadWritePaths=$_read_write_paths + +[Install] +WantedBy=multi-user.target +SDSVC +} + # Scoped sudo — only the exact commands the app needs, nothing else. Numeric- # only glob on the decision ID; Python subprocess calls always pass args as a # list (no shell=True anywhere), so there's no shell-metachar injection @@ -473,6 +535,7 @@ _secdash_write_app() { Stdlib only, deliberately — this runs on a small droplet alongside Asterisk, Caddy, and CrowdSec, and shouldn't add a framework's worth of RAM overhead. """ +import configparser import json import os import re @@ -482,6 +545,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer PORT = int(os.environ.get("DASHBOARD_PORT", "8092")) ASTERISK_LOG = os.environ.get("ASTERISK_LOG", "") ASTERISK_ADMIN_URL = os.environ.get("ASTERISK_ADMIN_URL", "") +ASTERISK_CONFIG_DIR = os.environ.get("ASTERISK_CONFIG_DIR", "") ASN_SCENARIO_FILES = [ "/etc/crowdsec/scenarios/local-asterisk_bf.yaml", "/etc/crowdsec/scenarios/local-asterisk_user_enum.yaml", @@ -493,6 +557,11 @@ ASN_FILTER_RE = re.compile(r"ASNNumber in \[([^\]]*)\]\)") ID_RE = re.compile(r"^\d+$") ASN_RE = re.compile(r"^\d+$") IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$") +DEVICE_MARKER_RE = re.compile(r"^; === Device: (.+?)(?:\s*\[AA:(?:yes|no)\])?\s*\((.+?)\)\s*===\s*$") +EXT_HEADER_RE = re.compile(r"^\[(\d+)\]") +EXTEN_RE = re.compile(r"^\d+$") +TIER_RE = re.compile(r"^(internal|restricted|full)$") +NUMBER_RE = re.compile(r"^\d{11}$") def parse_security_log(limit=200): @@ -711,6 +780,130 @@ def ban_asn(asn): } +def list_extensions(): + """Extension numbers + display names, parsed from pjsip.conf the same + way Easy Asterisk's own rebuild_dialplan() finds them: a + "; === Device: NAME (category) ===" comment immediately followed (once + other lines are skipped) by that device's "[extnum]" section header. + Read-only, best-effort — an unparseable/missing file just means an empty + list, not an error, same convention as parse_security_log.""" + if not ASTERISK_CONFIG_DIR: + return [] + path = os.path.join(ASTERISK_CONFIG_DIR, "pjsip.conf") + if not os.path.isfile(path): + return [] + try: + with open(path, "r", errors="replace") as f: + lines = f.readlines() + except OSError: + return [] + extensions = [] + pending_name = None + for line in lines: + line = line.rstrip("\n") + m = DEVICE_MARKER_RE.match(line) + if m: + pending_name = m.group(1).strip() + continue + m = EXT_HEADER_RE.match(line) + if m and pending_name is not None: + extensions.append({"ext": m.group(1), "name": pending_name}) + pending_name = None + return extensions + + +def _permissions_path(): + return os.path.join(ASTERISK_CONFIG_DIR, "pstn-permissions.conf") if ASTERISK_CONFIG_DIR else None + + +def _read_permissions_cp(): + cp = configparser.ConfigParser(delimiters=("=",)) + path = _permissions_path() + if path and os.path.isfile(path): + try: + cp.read(path) + except configparser.Error: + pass + return cp + + +def get_all_permissions(): + """{ext: {"tier": ..., "allowed_numbers": "num|num|..."}} for every + extension with a non-internal tier on record. Extensions with no section + are implicitly "internal" — the dialplan's AST_CONFIG() lookup treats a + missing section as empty/denied the same way, so there's nothing to + return for them here; the UI fills in "internal" as the default for any + known extension (from list_extensions()) not present in this dict.""" + cp = _read_permissions_cp() + result = {} + for section in cp.sections(): + if not EXTEN_RE.match(section): + continue + result[section] = { + "tier": cp.get(section, "tier", fallback="internal"), + "allowed_numbers": cp.get(section, "allowed_numbers", fallback=""), + } + return result + + +def write_permission(ext, tier, numbers_raw): + """Saves one extension's tier + (for restricted) approved-number list. + Numbers are normalized to a pipe-separated list of 11-digit US numbers — + pipe, not comma, because the dialplan uses this value directly as a + REGEX() alternation pattern (see services/pstn-trunk.sh's file-level + comment on why the untrusted call data is always the string being + tested, never interpolated into the pattern side).""" + if not ASTERISK_CONFIG_DIR: + return False, "No Asterisk install detected on this box" + ext = str(ext).strip() + if not EXTEN_RE.match(ext): + return False, "Invalid extension" + if not TIER_RE.match(tier): + return False, "Invalid tier" + + tokens = re.split(r"[,\s|]+", (numbers_raw or "").strip()) + clean_numbers = [t for t in tokens if NUMBER_RE.match(t)] + numbers = "|".join(clean_numbers) + + cp = _read_permissions_cp() + if tier == "internal": + if cp.has_section(ext): + cp.remove_section(ext) + else: + if not cp.has_section(ext): + cp.add_section(ext) + cp.set(ext, "tier", tier) + if tier == "restricted": + cp.set(ext, "allowed_numbers", numbers) + elif cp.has_option(ext, "allowed_numbers"): + cp.remove_option(ext, "allowed_numbers") + + path = _permissions_path() + tmp_path = path + ".tmp" + try: + with open(tmp_path, "w") as f: + f.write( + "; PSTN permission tiers - internal / restricted / full.\n" + "; Read LIVE by the dialplan on every call (AST_CONFIG()) - no\n" + "; Asterisk restart needed. Managed here (Security Dashboard); also\n" + "; safe to edit by hand. 'sudo ./setup.sh pstn-trunk' update mode\n" + "; never touches this file, only a fresh reinstall does.\n" + "; Any extension not listed here is internal-only (no PSTN) by default.\n\n" + ) + cp.write(f) + os.replace(tmp_path, path) + except OSError as e: + try: + os.remove(tmp_path) + except OSError: + pass + return False, "Failed writing %s: %s" % (path, e) + + if tier == "restricted" and not clean_numbers: + return True, "Saved as restricted with an EMPTY approved list — no PSTN number can reach/be reached by it yet." + return True, "Saved" + + INDEX_HTML = """ Security Dashboard @@ -744,6 +937,7 @@ INDEX_HTML = """ @@ -770,16 +964,30 @@ INDEX_HTML = """
+