From 8e2552ae7391b1e9d7032b129bde547d21f98954 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 02:27:26 +0000 Subject: [PATCH 1/5] Reconcile VoIP.ms ToS negative-balance language with the toll-fraud design The user found ToS language stating the account "may run on a negative balance" and describing a 30-day-notice-then-suspend / 30-consecutive- days-then-close process - worth reading against the wiki's "balance must be over $0 to call" claim the whole toll-fraud design leans on. Reconciled: these describe two different things, not a contradiction. New call attempts should still be blocked in real time at $0 (the wiki's claim, and the core assumption this design needs). The ToS's negative- balance language most plausibly covers recurring fees (DID/E911) landing when balance is already near zero, and in-progress-call settlement edge cases - not a window where fraud keeps dialing while negative. Still not verified against a live account either way. --- docs/pstn-calling-voipms-plan.md | 46 +++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index 27a2b54..28e1943 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -59,10 +59,32 @@ file. - **Provider: VoIP.ms.** Chosen for its prepaid-balance model: turn off auto-recharge in the account's Finances settings and outbound calls simply fail once the balance hits $0 — that's the toll-fraud backstop if the - droplet's Asterisk (`asterisk-digital-ocean`) is ever compromised. This - behavior wasn't verified against a live account — confirm the - auto-recharge toggle still works this way at sign-up time, since billing - UX can change. + droplet's Asterisk (`asterisk-digital-ocean`) is ever compromised. + + **Update — read VoIP.ms's actual ToS (not just the wiki) on this.** The + wiki says plainly "only accounts with a balance over $0 are able to send + and receive calls" — new call attempts should be blocked in real time at + $0, and that's still the core assumption this design leans on. But the + ToS separately says the account "may run on a negative balance," that any + negative balance is "immediately due and payable," that VoIP.ms may + suspend an account below a $5 minimum balance (30-day notice first), and + may permanently close it after 30 *consecutive* days negative. Read + together, not a contradiction — two different things: + - **Can new calls start** — real-time balance check, blocked at $0. Core + assumption holds. + - **Can the balance ever read negative** — yes, most plausibly from + recurring fees (DID monthly, E911) landing when the balance is already + near zero, or edge-case settlement of an in-progress call ticking + slightly negative before teardown. Neither is a runaway toll-fraud + scenario; both mean liability isn't cleanly capped at the funded amount + to the exact penny, and the account needs topping up within the 30-day + windows or it gets suspended/closed (an account-status consequence, not + "30 free days of unblocked calling while negative"). + - Still not verified against an actual live account — this is a read of + their published wiki + ToS text, not a test. Watch the real balance for + the first month or two after go-live, and don't panic at a small + negative reading — check whether it's a recurring fee or an actual call + spike before assuming the block failed. - **Scope: US calling only, for now.** No international, no premium-rate destinations. Enforce this twice — once via whatever dial-plan/prefix VoIP.ms requires for US routing, and again independently in Asterisk's own @@ -233,9 +255,15 @@ separately from that hourly check. cover it), default 10/10, global not per-extension, live-editable via `pstn-limits.conf`/web UI. ~~Spend/volume alert~~ Done — ntfy, hourly threshold + burst check, plus immediate alerts on denied/rejected calls. -6. Verify against a live VoIP.ms account: auto-recharge-off behavior at - sign-up, and that the chosen POP server's actual source IP for inbound - calls matches what `services/pstn-trunk.sh` resolved via DNS at install - time (VoIP.ms's docs mention some redundancy/failover between servers — - if inbound calls ever stop matching the `identify` section, this is the +6. Verify against a live VoIP.ms account (still not done — only their wiki + + ToS text has been read, see "Decision so far" above for what that + turned up): confirm new outbound calls actually get blocked at $0 + balance as documented; watch whether/when the balance goes slightly + negative in normal operation (expected from recurring fees, not + necessarily a sign of a problem) and top up within the 30-day windows + the ToS describes so the account/DID doesn't get suspended or closed. + Also confirm the chosen POP server's actual source IP for inbound calls + matches what `services/pstn-trunk.sh` resolved via DNS at install time + (VoIP.ms's docs mention some redundancy/failover between servers — if + inbound calls ever stop matching the `identify` section, this is the first thing to check). From c8cc2271a7ea1b73ed227f419dcac5bb187e8381 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 03:58:41 +0000 Subject: [PATCH 2/5] Support providers with multiple inbound signaling IPs (e.g. Anveo Direct) VoIP.ms's one-POP-one-hostname model meant a single resolved IP was enough for the trunk's identify match=, but Anveo Direct (researched as an alternative after finding VoIP.ms's ToS selectively requires government ID at signup) sends inbound signaling from a fixed set of published IPs instead. Generalizes the pjsip include to accept multiple match= lines in one identify object (PJSIP allows repeating it to build one match set - no separate config objects needed per IP), with a new install prompt for any additional known source IPs beyond the auto-resolved one. No dial-prefix support was added despite earlier research suggesting Anveo Direct needed one - verified against their current official FAQ (fetched directly rather than trusting a stale search-result claim) that plain number dialing works with no prefix. Fixed a real bug caught while testing the update-mode round trip: .pstn-trunk.env was written with unquoted values, which breaks `source` entirely once any value contains a space - a multi-IP list or a multi-word provider name (e.g. "Anveo Direct") would make bash try to run the second word as a command. Predates this change (a multi-extension ring group has the same shape); only surfaced by actually re-sourcing the file and reapplying, not by inspecting generator output. Fixed by quoting every value. Updated docs/pstn-calling-voipms-plan.md with the Anveo Direct/DIDLogic/ SIP.US research findings and what's now implemented vs. still open (real-time balance enforcement at Anveo specifically is unconfirmed). --- docs/pstn-calling-voipms-plan.md | 77 ++++++++++++++++++++++++++-- services/pstn-trunk.sh | 88 ++++++++++++++++++++++---------- 2 files changed, 136 insertions(+), 29 deletions(-) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index 28e1943..c391020 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -224,9 +224,52 @@ separately from that hourly check. legitimately-registered extension being used for toll fraud — nobody should assume CrowdSec alone already covers this. -## Provider landscape (for reference — not chosen) -- **SIP.US** — also prepaid, flat per-channel rate, built-in fraud - detection. Considered, not chosen. +## Provider landscape (for reference) +Looked at as alternatives to VoIP.ms specifically because VoIP.ms's own ToS +confirms it *selectively* requires a government ID scan at signup (VPN/proxy +use, certain number types, internal risk scoring — not universal, but real). +None of these were verified to *never* require ID either; KYC requirements +are trending industry-wide (FCC STIR/SHAKEN, Robocall Mitigation Database), +so treat "no ID requirement found" as exactly that, not confirmation. + +- **DIDLogic** — **ruled out**: has a $20/month minimum, confirmed directly + by checking their portal (not something search results surfaced). Would + otherwise have needed zero code changes — same IP-auth model and + single-IP-per-regional-POP hostname pattern this service already handles. +- **Anveo Direct** (anveodirect.com — a *different product* from plain + Anveo/anveo.com, its hosted-PBX sibling with monthly subscription tiers; + don't confuse the two, their pricing models aren't comparable) — leading + candidate. ~$0.001/min outbound, no minimum commitment, $25 minimum to + fund the account (close to VoIP.ms's $15). Confirmed via their current + official FAQ (fetched directly, not via search — search results had gone + stale on this exact point): + - IP authentication, no SIP registration — matches this service's + architecture with zero changes needed to the auth model. + - **No dial-prefix requirement** — a stale/outdated forum claim (found via + search) suggested one was needed; the *current* official FAQ's own + sample config shows plain number dialing, nothing prepended. Don't + trust that claim if it resurfaces. + - **Sends inbound signaling from multiple fixed IPs**, not one: + `169.48.232.158`, `204.216.109.55`, `176.9.39.206`, `72.9.149.25` (per + their FAQ as of this writing — confirmed different from what web search + surfaced independently, which was a different, apparently stale set of + 5 IPs). This is what prompted the multi-IP `identify` support below. + - STIR/SHAKEN signing is handled by Anveo as intermediate provider for + personal use (originating your own calls, not reselling to customers) — + no FCC registration/certificate needed on our side. + - Still open: does Anveo actually block *new* outbound calls in real time + at $0 balance (matching VoIP.ms's documented behavior), or only enforce + via the recurring-fee grace-period mechanism their FAQ describes? Not + confirmed either way — ask their support before funding a real account, + since it's the crux of the toll-fraud backstop assumption. +- **SIP.US** — prepaid, but a flat ~$24.95/mo per-channel (unlimited minutes + on that channel) model, not pay-per-minute — **not actually + similarly-priced** at this project's volume (100 min/month costs ~$1-3/mo + pay-per-minute vs. $24.95/mo flat). Notable anyway for automated real-time + fraud detection (kills in-progress unauthorized calls, auto-disables + international calling on suspicious activity) — a provider-side version of + what this repo hand-built in the dialplan, if that trade-off (higher + price, less to maintain yourself) ever appeals. - Several providers (Nextiva, IDT Express) advertise AI/ML-based fraud monitoring as a second layer on top of normal billing — an extra net, not a substitute for a hard prepaid ceiling. @@ -234,6 +277,34 @@ separately from that hourly check. prepaid-balance + auto-recharge-off pattern is the de facto mechanism across the space, VoIP.ms included. +## Multi-provider support beyond VoIP.ms — implemented +`services/pstn-trunk.sh` now generalizes two things that were originally +built VoIP.ms-shaped, driven directly by researching Anveo Direct as an +alternative: +- **Multiple inbound `identify` match IPs**, not just one. The install + prompt still auto-resolves the server hostname to an IP (VoIP.ms's + one-POP-one-hostname model), but now also asks for any *additional* known + source IPs a provider might publish (Anveo Direct's case) — PJSIP allows + repeating `match=` within a single `identify` object to build one match + set against the trunk endpoint, so this needed no new config objects, just + a loop emitting one `match=` line per IP. +- No dial-prefix support was added — turned out not to be needed for Anveo + Direct once verified against their current official FAQ rather than a + stale search result. If a future provider genuinely needs one, that's the + next generalization point (a prefix prepended to `${EXTEN}` before + `Dial()`). + +**Bug caught while testing this**: `.pstn-trunk.env` (the structural +settings file "update in place" reapplies) was being written with unquoted +values. Harmless as long as every value happened to be a single word, but a +multi-IP list (always space-separated once there's more than one) or a +multi-word provider name (e.g. "Anveo Direct") broke `source`-ing it +entirely — bash treats the second word as a command to run ("Direct: +command not found"). This was a latent bug that predates the multi-IP work +(a multi-extension ring group has the same shape), only surfaced by +actually exercising the update-mode round trip instead of just inspecting +generator output. Fixed by quoting every value in that heredoc. + ## Open items for whoever picks this up next 1. ~~Decide: new `services/pstn-trunk.sh`...~~ Done — separate service file, generalized to any IP-auth SIP provider (VoIP.ms is just the default). diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 9ded13f..e3ab521 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -110,19 +110,24 @@ _pstn_patch_vendor_files() { } # ── Shared: pjsip trunk config (aor/identify/endpoint, IP-authenticated) ─── +# SERVER_IPS is space-separated — one IP is the common case (one POP, one +# hostname resolution, e.g. VoIP.ms), but some providers (e.g. Anveo Direct) +# send inbound signaling from a fixed set of published IPs regardless of +# which hostname you dial out to. PJSIP's identify object allows repeating +# match= to build one match set against a single endpoint — no separate +# identify/endpoint objects needed per IP, unlike the older chan_sip +# peer-per-source-IP pattern some providers' sample configs still show. _pstn_write_pjsip_include() { - local FILE="$1" SERVER="$2" SERVER_IP="$3" DID="$4" + local FILE="$1" SERVER="$2" SERVER_IPS="$3" DID="$4" cat > "$FILE" << 'EOF' ; 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. -; 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. +; match= lines below are the known/resolved source IP(s) for inbound calls. +; If inbound calls stop matching after a provider-side change, re-run this +; service to re-resolve/re-enter them, or add extra match= lines here by +; hand for additional known source IPs. [pstn-trunk] type=aor @@ -132,7 +137,12 @@ qualify_frequency=60 [pstn-trunk] type=identify endpoint=pstn-trunk -match=__PSTN_SERVER_IP__ +EOF + local _ip + for _ip in $SERVER_IPS; do + echo "match=${_ip}" >> "$FILE" + done + cat >> "$FILE" << 'EOF' [pstn-trunk] type=endpoint @@ -145,7 +155,7 @@ from_domain=__PSTN_SERVER__ callerid=__PSTN_DID__ direct_media=no EOF - sed -i "s/__PSTN_SERVER_IP__/${SERVER_IP}/g; s/__PSTN_SERVER__/${SERVER}/g; s/__PSTN_DID__/${DID}/g" "$FILE" + sed -i "s/__PSTN_SERVER__/${SERVER}/g; s/__PSTN_DID__/${DID}/g" "$FILE" } # ── Shared: one inbound ring-group member's live permission check ───────── @@ -422,29 +432,35 @@ EOF # 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" + local SERVER="$3" SERVER_IPS="$4" DID="$5" local RING_EXTS="$6" NTFY_URL="$7" RATE="$8" MONTH_THRESHOLD="$9" BURST_THRESHOLD="${10}" local PROVIDER_NAME="${11}" _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_pjsip_include "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$SERVER" "$SERVER_IPS" "$DID" _pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$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" + # Values are double-quoted: this file is `source`d back in on "update" + # (and RING_EXTS/TRUNK_SERVER_IPS are space-separated whenever there's + # more than one entry, and PROVIDER_NAME can be multi-word, e.g. "Anveo + # Direct") — unquoted, bash's `source` would treat the second word of + # any such value as a command to run ("Direct: command not found"), + # confirmed live while testing the multi-IP change. cat > "$EA_DIR/.pstn-trunk.env" << ENV -PROVIDER_NAME=${PROVIDER_NAME} -TRUNK_SERVER=${SERVER} -TRUNK_SERVER_IP=${SERVER_IP} -TRUNK_DID=${DID} -RING_EXTS=${RING_EXTS} -NTFY_URL=${NTFY_URL} -RATE_PER_MIN=${RATE} -MONTH_THRESHOLD=${MONTH_THRESHOLD} -BURST_THRESHOLD=${BURST_THRESHOLD} +PROVIDER_NAME="${PROVIDER_NAME}" +TRUNK_SERVER="${SERVER}" +TRUNK_SERVER_IPS="${SERVER_IPS}" +TRUNK_DID="${DID}" +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 @@ -485,7 +501,8 @@ install_pstn-trunk() { echo "[DRY-RUN] full-PSTN extensions, restricted-PSTN extensions + their approved numbers," echo "[DRY-RUN] max concurrent outbound/inbound calls (default 10/10), 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 resolve the server hostname to an IP, plus prompt for any additional" + echo "[DRY-RUN] known source IPs (some providers publish a fixed list), for inbound call matching" echo "[DRY-RUN] Would patch vendor generator functions to #include the trunk config" echo "[DRY-RUN] Would write pjsip/dialplan includes, pstn-permissions.conf + pstn-limits.conf" echo "[DRY-RUN] (fresh install only), and an hourly usage-alert script + cron.d entry" @@ -537,7 +554,7 @@ install_pstn-trunk() { # shellcheck disable=SC1090 source "$SETTINGS_FILE" _pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ - "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" \ + "$TRUNK_SERVER" "$TRUNK_SERVER_IPS" "$TRUNK_DID" \ "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 ( cd "$EA_DIR" && docker compose restart asterisk ) \ @@ -586,7 +603,7 @@ install_pstn-trunk() { 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." + log_warning "Couldn't resolve $TRUNK_SERVER — the identify section needs at least one 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." @@ -596,6 +613,24 @@ install_pstn-trunk() { log_success "Resolved $TRUNK_SERVER -> $TRUNK_SERVER_IP" fi + # Some providers send inbound signaling from a fixed set of published IPs + # that don't necessarily match what the server hostname resolves to (e.g. + # Anveo Direct publishes 4 signaling IPs regardless of which hostname you + # dial out to) — the resolved IP above always gets included, this just + # adds any others the provider documents. + local EXTRA_IPS="" + prompt_text "Any additional known source IPs for inbound calls, space-separated (check your provider's docs — e.g. a firewall/signaling IP list; blank if the resolved IP above is the only one):" "" EXTRA_IPS + local _octet='(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)' + local _ip_re="^${_octet}\\.${_octet}\\.${_octet}\\.${_octet}\$" + local TRUNK_SERVER_IPS="$TRUNK_SERVER_IP" _ip + for _ip in $EXTRA_IPS; do + if [[ "$_ip" =~ $_ip_re ]]; then + TRUNK_SERVER_IPS="${TRUNK_SERVER_IPS} ${_ip}" + else + log_warning "Skipping '$_ip' — doesn't look like an IPv4 address." + fi + done + 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 @@ -694,7 +729,7 @@ 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" \ + "$TRUNK_SERVER" "$TRUNK_SERVER_IPS" "$TRUNK_DID" \ "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 @@ -724,7 +759,7 @@ background, cost estimate, and toll-fraud reasoning. | Setting | Value | |---|---| | Provider | ${PROVIDER_NAME} | -| Server/POP | ${TRUNK_SERVER} (${TRUNK_SERVER_IP}) | +| Server/POP | ${TRUNK_SERVER} (inbound match IPs: ${TRUNK_SERVER_IPS}) | | DID | ${TRUNK_DID} | | Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all | | Full-PSTN extensions | ${FULL_EXTS:-none} | @@ -876,7 +911,8 @@ MD echo "" log_success "PSTN trunk configured." - echo " Provider: $PROVIDER_NAME ($TRUNK_SERVER / $TRUNK_SERVER_IP)" + echo " Provider: $PROVIDER_NAME ($TRUNK_SERVER)" + echo " Inbound match IPs: $TRUNK_SERVER_IPS" echo " DID: $TRUNK_DID" echo " Outbound: US/NANP only, max $MAX_OUTBOUND concurrent calls" echo " Inbound: max $MAX_INBOUND concurrent calls" From 26f3f08304f791449eefcd893207973bbaf4404d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 13:22:46 +0000 Subject: [PATCH 3/5] Block non-US NANP area codes - NANP != US, a real gap the design carried Anveo Direct's "Call Security" rate-cap feature (found while evaluating their account portal) prompted a closer look at our own NANP dialplan pattern, which turned out to have a real gap: NANP covers Caribbean/ Atlantic nations and several US territories, not just the US - these dial exactly like a normal 10-digit US number but bill at international/ premium rates, a well-known toll-fraud/"one-ring scam" vector precisely because the format looks domestic. The existing _1NXXNXXXXX/_NXXNXXXXX patterns matched all of them equally, meaning any extension (including "full" tier) could reach premium Caribbean/territory numbers despite the whole point of the design being "US only, no premium-rate destinations." Verified the area code list against multiple sources rather than relying on training-data recall alone, then added an explicit block on all 27 (Bahamas, Barbados, Anguilla, Antigua & Barbuda, British Virgin Islands, US Virgin Islands, Cayman Islands, Bermuda, Grenada, Turks & Caicos, Jamaica, Montserrat, Northern Mariana Islands, Guam, American Samoa, Sint Maarten, Saint Lucia, Dominica, Saint Vincent, Puerto Rico, Dominican Republic, Trinidad & Tobago, Saint Kitts & Nevis) - checked via REGEX() against the extracted area code, before permission-tier checks, so it applies regardless of tier. Tested the extraction/matching logic against both blocked and legitimate US area codes. Updated docs/pstn-calling-voipms-plan.md's toll-fraud reasoning, which had previously (incorrectly) claimed NANP-only restriction alone fully closed the cost-per-minute risk - noted as a real, live gap rather than glossing over it. Also noted Anveo Direct's provider-side Call Security setting (rate cap + max call duration) as a complementary backstop worth configuring on their account portal directly, independent of this repo's code. --- docs/pstn-calling-voipms-plan.md | 34 +++++++++++++++++++---- services/pstn-trunk.sh | 46 ++++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index c391020..c93c304 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -130,9 +130,20 @@ anyone notices. Two independent layers matter more than either alone: 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 +alone bounds both.** NANP-only restriction bounds *cost-per-minute* — but +**NANP-only alone was not actually sufficient**, and this design carried +that gap for a while before it was caught. NANP includes Caribbean/Atlantic +nations and several US territories (Bahamas, Puerto Rico, Dominican +Republic, and 24 others) that dial exactly like a normal 10-digit US number +but bill at international/premium rates — a well-known toll-fraud/ +"one-ring scam" vector specifically because the format looks domestic. The +NANP pattern match alone would have let a compromised extension reach every +one of these. **Fixed**: `services/pstn-trunk.sh` now blocks those 27 area +codes explicitly, checked before permission tier, for every extension +regardless of tier — see the dialplan file's own comment and +"Non-US NANP area codes are blocked" in its generated README for the full +list. With that fix in place, cost-per-minute risk actually is closed (a +compromised box can only ever reach genuine US-rate destinations). It does **not** bound *how fast* the prepaid balance gets burned: nothing stops a compromised box from opening many concurrent US-destination calls in parallel and draining the whole balance (e.g. $15 balance ÷ $0.01/min = @@ -165,7 +176,11 @@ separately from that hourly check. - An outbound dialplan route matching US numbers only — **implemented**: `_1NXXNXXXXX` (11-digit NANP with leading 1) and `_NXXNXXXXX` (10-digit, auto-prefixed with 1), both routed to the trunk. No catch-all `_X.` - pattern. + pattern. **Also implemented**: an explicit block on the 27 NANP area + codes that aren't actually US (Caribbean/Atlantic nations + US + territories — see the toll-fraud nuance above for why this matters), + checked via `REGEX()` against the extracted area code before permission + tier, ahead of every other check. - **Three-tier permission model — implemented**, superseding an earlier flat allow-list design. `internal` / `restricted` / `full` per extension, read live from `pstn-permissions.conf` via `AST_CONFIG()` rather than baked @@ -309,7 +324,10 @@ generator output. Fixed by quoting every value in that heredoc. 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`. +3. ~~Exact NANP dial pattern(s)~~ Done — `_1NXXNXXXXX` / `_NXXNXXXXX`. ~~NANP + ≠ US gap~~ Done — 27 Caribbean/territory area codes explicitly blocked + (see toll-fraud nuance above); this was a real, live gap in the design + for a while, not a hypothetical. 4. ~~Inbound~~ Done — rings a configurable list of extensions (ring-group supported), each checked live per-call against its own tier. ~~Permission model~~ Done — superseded the original flat allow-list with a 3-tier @@ -338,3 +356,9 @@ generator output. Fixed by quoting every value in that heredoc. (VoIP.ms's docs mention some redundancy/failover between servers — if inbound calls ever stop matching the `identify` section, this is the first thing to check). +7. Anveo Direct specifically has a provider-side "Call Security" setting + (account portal, not exposed through SIP config) — a per-minute rate + cap ("block calls with rate more than $X/min, 0 = block all") and a + maximum call duration. Worth setting on any Anveo Direct account as a + provider-side backstop independent of this repo's own dialplan code — + not automatable from here since it's their web UI, not a config file. diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index e3ab521..ea63d9d 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -221,8 +221,19 @@ _pstn_write_dialplan_include() { ; 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. +; +; NANP is NOT the same thing as "US" — it also covers Caribbean/Atlantic +; nations and several US territories, all of which dial exactly like a +; normal 10-digit US number but get billed by most providers at +; international/premium rates (a well-known toll-fraud/"one-ring scam" +; vector precisely because the number format looks domestic). Blocked by +; area code below, checked before tier permission — this is a hard "never +; reachable" rule, not something even a "full" tier extension can override, +; since "full" means "any US number," not "any NANP-shaped number." exten => _1NXXNXXXXX,1,NoOp(PSTN outbound call attempt from ${CHANNEL(peername)} to ${EXTEN}) + same => n,Set(PSTN_AREA_CODE=${EXTEN:1:3}) + same => n,GotoIf($[${REGEX("^(242|246|264|268|284|340|345|441|473|649|658|664|670|671|684|721|758|767|784|787|809|829|849|868|869|876|939)$" ${PSTN_AREA_CODE})} = 1]?pstn_intl_blocked,1) same => n,Set(PSTN_CALLER=${CHANNEL(peername)}) same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier)}) same => n,GotoIf($["${PSTN_TIER}" = "full"]?pstn_check_busy,1) @@ -235,6 +246,11 @@ __ALERT_DENY_TIER_LINE__ exten => _NXXNXXXXX,1,NoOp(Assuming NANP - adding leading 1) same => n,Goto(1${EXTEN},1) +exten => pstn_intl_blocked,1,NoOp(PSTN outbound call to ${EXTEN} blocked - non-US/premium NANP area code ${PSTN_AREA_CODE}) +__ALERT_DENY_INTL_LINE__ + same => n,Busy(15) + same => n,Hangup() + 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) @@ -263,10 +279,11 @@ EOF if [[ -n "$NTFY_URL" ]]; then local _esc_url="${NTFY_URL//&/\\&}" 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_INTL_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call blocked - non-US/premium NANP area code.' '${_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: outbound concurrent-call cap reached - a call was rejected.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" else - sed -i "/__ALERT_DENY_TIER_LINE__/d; /__ALERT_DENY_NUMBER_LINE__/d; /__ALERT_BUSY_LINE__/d" "$FILE" + sed -i "/__ALERT_DENY_TIER_LINE__/d; /__ALERT_DENY_INTL_LINE__/d; /__ALERT_DENY_NUMBER_LINE__/d; /__ALERT_BUSY_LINE__/d" "$FILE" fi # ── Inbound: [from-pstn-trunk], one unrolled block per ring-group member. @@ -761,7 +778,7 @@ background, cost estimate, and toll-fraud reasoning. | Provider | ${PROVIDER_NAME} | | Server/POP | ${TRUNK_SERVER} (inbound match IPs: ${TRUNK_SERVER_IPS}) | | DID | ${TRUNK_DID} | -| Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all | +| Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all, minus 27 non-US/premium NANP area codes (see below) | | Full-PSTN extensions | ${FULL_EXTS:-none} | | Restricted-PSTN extensions | ${RESTRICTED_EXTS:-none} | | Concurrency caps | ${MAX_OUTBOUND} outbound / ${MAX_INBOUND} inbound simultaneous calls (live — see \`pstn-limits.conf\` below) | @@ -771,6 +788,31 @@ background, cost estimate, and toll-fraud reasoning. | Monthly spend alert threshold | \$${MONTH_THRESHOLD} | | Hourly burst alert threshold | ${BURST_THRESHOLD} calls/hour | +## Non-US NANP area codes are blocked, not just "anything outside NANP" + +NANP (the North American Numbering Plan) isn't the same thing as "US" — it +also covers several Caribbean/Atlantic nations and US territories, all of +which dial exactly like a normal 10-digit US number but get billed by most +providers at international/premium rates. This is a well-known toll-fraud/ +"one-ring scam" vector specifically because the number *looks* domestic. +27 area codes are blocked explicitly, checked before permission tier — this +applies to **every** extension regardless of tier, since "full" means "any +US number," not "any NANP-shaped number": + +Bahamas (242), Barbados (246), Anguilla (264), Antigua & Barbuda (268), +British Virgin Islands (284), US Virgin Islands (340), Cayman Islands +(345), Bermuda (441), Grenada (473), Turks & Caicos (649), Jamaica +(658/876), Montserrat (664), Northern Mariana Islands (670), Guam (671), +American Samoa (684), Sint Maarten (721), Saint Lucia (758), Dominica +(767), Saint Vincent (784), Puerto Rico (787/939), Dominican Republic +(809/829/849), Trinidad & Tobago (868), Saint Kitts & Nevis (869). + +If you have a legitimate reason to call one of these (e.g. family in Puerto +Rico), remove that entry from the `REGEX()` pattern in +`pstn-trunk-dialplan.conf`'s `_1NXXNXXXXX` extension — it'll be +regenerated exactly the same way on the next reinstall/update, so note the +change somewhere you'll remember it, or keep a local diff. + ## Permission tiers Every extension can always call and receive calls from other Asterisk From 9b950808cc67c3110ac658bec5146c484a53e576 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 14:51:19 +0000 Subject: [PATCH 4/5] Add spend-cap kill-switch, international-calling toggle, per-minute checks - Replace the hourly cron.d spend/volume check with a per-minute systemd timer (cron.d fallback), since it's now also the kill-switch's enforcement point and the interval is the exposure window. - Add a hard monthly spend-cap kill-switch: trips pstn-trunk-killswitch.conf (read live by the dialplan, both call directions, internal calling unaffected) once estimated spend reaches an admin-set cap, plus a loud ntfy warning at 80% of the cap before it trips. Does not auto-reset; clearing it is a CLI-only action (update mode) so a compromised/careless web session can't quietly re-enable spend after a trip. - Add a CLI-only (never web-dashboard) international-calling allow-list: continent/country menu, always asked every run with the resulting allow-list printed exactly once right after, optional auto-expiry with day-of and at-expiry ntfy notices, and active re-blocking once expired. Dialplan gates the US "011" prefix pattern on it for full-tier extensions. - Add an independent messaging_enabled flag per extension in pstn-permissions.conf for Asterisk's native SIP MESSAGE texting, since the risk profile (no cost) differs from PSTN calling permissions. The dialplan wiring to enforce it is flagged as a known gap pending live verification against Easy Asterisk's message routing, rather than shipping a guessed pattern. - security-dashboard.sh: detect whether pstn-trunk is actually installed (not just base Asterisk) and show a clear "not installed" state instead of misleading default caps/extensions; add clickable column-header sorting to the CrowdSec active-bans table. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf --- docs/pstn-calling-voipms-plan.md | 66 ++- services/pstn-trunk.sh | 710 ++++++++++++++++++++++++++++--- services/security-dashboard.sh | 108 ++++- 3 files changed, 806 insertions(+), 78 deletions(-) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index c93c304..ab642da 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -342,8 +342,10 @@ generator output. Fixed by quoting every value in that heredoc. 5. ~~Concurrent-call cap~~ Done — both directions now (inbound was a real gap, since it also costs money per-minute and outbound's cap doesn't cover it), default 10/10, global not per-extension, live-editable via - `pstn-limits.conf`/web UI. ~~Spend/volume alert~~ Done — ntfy, hourly - threshold + burst check, plus immediate alerts on denied/rejected calls. + `pstn-limits.conf`/web UI. ~~Spend/volume alert~~ Done — ntfy, monthly + threshold + hourly burst check, run **every minute** (systemd timer, not + hourly cron — see item 8), plus immediate alerts on denied/rejected + calls. 6. Verify against a live VoIP.ms account (still not done — only their wiki + ToS text has been read, see "Decision so far" above for what that turned up): confirm new outbound calls actually get blocked at $0 @@ -362,3 +364,63 @@ generator output. Fixed by quoting every value in that heredoc. maximum call duration. Worth setting on any Anveo Direct account as a provider-side backstop independent of this repo's own dialplan code — not automatable from here since it's their web UI, not a config file. +8. ~~Spend-cap kill-switch~~ Done — a genuinely hard stop, not just an + alert: once `pstn-trunk-usage-alert.sh` estimates month-to-date spend + has reached an admin-set cap (prompted at install/update, `0` = disabled), + it writes `tripped=1` to `pstn-trunk-killswitch.conf`, read live by the + dialplan on *every* PSTN call attempt (both directions — internal + Asterisk-to-Asterisk calling is untouched) and blocked immediately with a + loud (`Priority: urgent`) ntfy alert. A separate loud warning fires once + spend reaches 80% of the cap, before it trips. **Does not auto-reset** — + requires manually clearing it via the CLI installer (update mode), by + design, so a compromised/careless web session can't quietly re-enable + spend after a trip. Honesty caveat carried over from the original spend + estimate: this is estimate-based (call count/duration × an entered + rate), not real billing data, and only as fresh as the last check — now + every minute (see item 9) rather than hourly, shrinking but not + eliminating the reaction-time gap between an overage happening and + calling actually getting blocked. +9. ~~Hourly cron → per-minute systemd timer~~ Done — + `pstn-trunk-usage.timer`/`.service` (falls back to a cron.d entry if + systemd isn't available), running the same usage-alert script every + minute instead of hourly. Directly motivated by item 8: the periodic + check is now also the kill-switch's enforcement point, so the interval + between checks is the exposure window, and a tighter interval shrinks it. +10. ~~International calling (beyond NANP/US)~~ Done — CLI-only (never the + Security Dashboard web UI, on purpose: this widens which countries can + be dialed/billed to at all, a more security-sensitive control than + who's already allowed to use an already-fixed scope), continent → + country menu (`_pstn_manage_international` in `services/pstn-trunk.sh`), + always asked every run with no way to skip the *question* itself + (though answering "no" leaves the existing allow-list untouched), with + the resulting allow-list printed exactly once right after — not + repeated during the spend-cap prompts, a correction from an earlier + draft of this design. Optional auto-expiry with two ntfy notices (day + of, and at the moment of expiry) and active re-blocking (the periodic + script clears the allow-list once past `expires`, it doesn't just + notify). Dialing uses the US `011` prefix convention (`_011X.` dialplan + pattern); allowed country codes are the REGEX() *pattern* side (admin- + controlled), dialed digits are always the *string* being tested — same + safe direction as every other permission check in this file. Only + `full`-tier extensions can use it regardless of which countries are + allowed. +11. Internal SIP `MESSAGE` (native Asterisk texting, no carrier SMS/cost) — + **partially done**. The permission layer is real and live-editable: a + `messaging=yes` flag per extension in `pstn-permissions.conf`, + independent of the PSTN calling tiers (an extension can be + internal-tier for calling and still messaging-enabled, or vice versa), + prompted at install time. **Not done**: the actual dialplan wiring that + would make Asterisk *enforce* this flag on inbound `MESSAGE` requests. + Reasoned through but deliberately not shipped: Easy Asterisk dispatches + messages through the same `[intercom]` context calls use (no + `message_context` override), and whether a hand-written pattern there + would take precedence over — or conflict with — Easy Asterisk's own + generated per-device dial patterns in that same context isn't something + that can be safely determined without a live install to test against. + Shipping a guessed pattern risked either silently not working or, worse, + interfering with call-routing precedence for the same extensions. + Treat this the same way as the VoIP.ms live-account verification in + item 6 above: a real gap, flagged rather than papered over, not a + hypothetical. Next step for whoever picks this up: verify message + routing behavior against a live Easy Asterisk container, then wire the + dialplan gate using the existing flag. diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index ea63d9d..04f8416 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -232,6 +232,8 @@ _pstn_write_dialplan_include() { ; since "full" means "any US number," not "any NANP-shaped number." exten => _1NXXNXXXXX,1,NoOp(PSTN outbound call attempt from ${CHANNEL(peername)} to ${EXTEN}) + same => n,Set(PSTN_KILLED=${AST_CONFIG(pstn-trunk-killswitch.conf,state,tripped)}) + same => n,GotoIf($["${PSTN_KILLED}" = "1"]?pstn_killed,1) same => n,Set(PSTN_AREA_CODE=${EXTEN:1:3}) same => n,GotoIf($[${REGEX("^(242|246|264|268|284|340|345|441|473|649|658|664|670|671|684|721|758|767|784|787|809|829|849|868|869|876|939)$" ${PSTN_AREA_CODE})} = 1]?pstn_intl_blocked,1) same => n,Set(PSTN_CALLER=${CHANNEL(peername)}) @@ -251,6 +253,45 @@ __ALERT_DENY_INTL_LINE__ same => n,Busy(15) same => n,Hangup() +exten => pstn_killed,1,NoOp(PSTN trunk - spend-cap kill-switch is tripped, rejecting outbound call) +__ALERT_KILLED_LINE__ + same => n,Busy(15) + same => n,Hangup() + +; International (non-NANP) dialing — US "011" prefix convention. Gated on +; BOTH full tier (same as domestic) AND the live international allow-list +; (pstn-intl-allowed.conf), managed ONLY via the CLI installer, never the +; Security Dashboard web UI — see _pstn_run_international_step. The +; allow-list holds admin-entered country calling codes (the PATTERN side of +; the REGEX() below); the caller-dialed digits are always the STRING being +; tested, never the reverse, same safe direction as every other permission +; check in this file. Reuses pstn_check_busy for the actual dial once a +; country check passes — same concurrency cap and call-log accounting as +; domestic calls (the estimated per-minute RATE assumed there is a US rate +; and will under/over-estimate true international cost; treat the spend +; figures as even less precise for international minutes than domestic). +exten => _011X.,1,NoOp(PSTN international outbound call attempt from ${CHANNEL(peername)} to ${EXTEN}) + same => n,Set(PSTN_KILLED=${AST_CONFIG(pstn-trunk-killswitch.conf,state,tripped)}) + same => n,GotoIf($["${PSTN_KILLED}" = "1"]?pstn_killed,1) + same => n,Set(PSTN_CALLER=${CHANNEL(peername)}) + same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier)}) + same => n,GotoIf($["${PSTN_TIER}" = "full"]?pstn_intl_check_country,1) + same => n,NoOp(Denied intl - ${PSTN_CALLER} tier ${PSTN_TIER} not eligible for international calling) +__ALERT_DENY_INTL_TIER_LINE__ + same => n,Busy(15) + same => n,Hangup() + +exten => pstn_intl_check_country,1,Set(PSTN_INTL_ALLOWED=${AST_CONFIG(pstn-intl-allowed.conf,countries,allowed_codes)}) + same => n,Set(PSTN_INTL_DIGITS=${EXTEN:3}) + same => n,GotoIf($["${PSTN_INTL_ALLOWED}" = ""]?pstn_intl_country_denied,1) + same => n,GotoIf($[${REGEX("^(${PSTN_INTL_ALLOWED})" ${PSTN_INTL_DIGITS})} = 1]?pstn_check_busy,1) + same => n,Goto(pstn_intl_country_denied,1) + +exten => pstn_intl_country_denied,1,NoOp(Denied intl - ${EXTEN} not on the current international allow-list) +__ALERT_DENY_INTL_COUNTRY_LINE__ + same => n,Busy(15) + same => n,Hangup() + 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) @@ -282,8 +323,11 @@ EOF sed -i "s#__ALERT_DENY_INTL_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call blocked - non-US/premium NANP area code.' '${_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: outbound concurrent-call cap reached - a call was rejected.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" + sed -i "s#__ALERT_KILLED_LINE__# same => n,System(curl -m 5 -s -H 'Priority: urgent' -d 'PSTN trunk: outbound call rejected - spend-cap kill-switch is tripped.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" + sed -i "s#__ALERT_DENY_INTL_TIER_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: international call denied - extension is not full-tier.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" + sed -i "s#__ALERT_DENY_INTL_COUNTRY_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: international call denied - country not on the current allow-list.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" else - sed -i "/__ALERT_DENY_TIER_LINE__/d; /__ALERT_DENY_INTL_LINE__/d; /__ALERT_DENY_NUMBER_LINE__/d; /__ALERT_BUSY_LINE__/d" "$FILE" + sed -i "/__ALERT_DENY_TIER_LINE__/d; /__ALERT_DENY_INTL_LINE__/d; /__ALERT_DENY_NUMBER_LINE__/d; /__ALERT_BUSY_LINE__/d; /__ALERT_KILLED_LINE__/d; /__ALERT_DENY_INTL_TIER_LINE__/d; /__ALERT_DENY_INTL_COUNTRY_LINE__/d" "$FILE" fi # ── Inbound: [from-pstn-trunk], one unrolled block per ring-group member. @@ -294,6 +338,8 @@ EOF [from-pstn-trunk] exten => _X.,1,NoOp(Inbound PSTN call from ${CALLERID(num)}) + same => n,Set(PSTN_KILLED=${AST_CONFIG(pstn-trunk-killswitch.conf,state,tripped)}) + same => n,GotoIf($["${PSTN_KILLED}" = "1"]?pstn_in_killed,1) same => n,Set(PSTN_RING_LIST=) same => n,Set(PSTN_RING_SEP=) EOF @@ -323,14 +369,19 @@ exten => pstn_in_busy,1,NoOp(PSTN trunk - inbound concurrent-call cap reached, r __ALERT_BUSY_IN_LINE__ same => n,Busy(15) same => n,Hangup() + +exten => pstn_in_killed,1,NoOp(PSTN trunk - spend-cap kill-switch is tripped, rejecting inbound call) +__ALERT_KILLED_IN_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" sed -i "s#__ALERT_BUSY_IN_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: inbound concurrent-call cap reached - a call was rejected.' '${_esc_url2}' >/dev/null 2>\\&1 \\&)#" "$FILE" + sed -i "s#__ALERT_KILLED_IN_LINE__# same => n,System(curl -m 5 -s -H 'Priority: urgent' -d 'PSTN trunk: inbound call rejected - spend-cap kill-switch is tripped.' '${_esc_url2}' >/dev/null 2>\\&1 \\&)#" "$FILE" else - sed -i "/__ALERT_DENY_INBOUND_LINE__/d; /__ALERT_BUSY_IN_LINE__/d" "$FILE" + sed -i "/__ALERT_DENY_INBOUND_LINE__/d; /__ALERT_BUSY_IN_LINE__/d; /__ALERT_KILLED_IN_LINE__/d" "$FILE" fi } @@ -357,27 +408,35 @@ _pstn_write_limits_file() { # ── 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. +# Args: FILE, space-separated FULL_EXTS, space-separated MESSAGING_EXTS, then +# "ext" "pipe|separated|numbers" pairs for each restricted extension. _pstn_write_permissions_file() { - local FILE="$1" FULL_EXTS="$2" - shift 2 + local FILE="$1" FULL_EXTS="$2" MESSAGING_EXTS="$3" + shift 3 + local _written_exts="" { - echo "; PSTN permission tiers — internal / restricted / full." + echo "; PSTN permission tiers — internal / restricted / full — PLUS an independent" + echo "; 'messaging' flag for Asterisk's native internal SIP MESSAGE texting (no" + echo "; carrier SMS, no PSTN, no cost — a separate axis from PSTN calling, since" + echo "; the risk profile is different: an extension can be internal-tier for" + echo "; calling and still messaging-enabled, or vice versa)." 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 "; Any extension not listed here is internal-only (no PSTN) AND" + echo "; messaging-disabled by default — it can still call/receive other Asterisk" + echo "; extensions and join internal ring groups, just not the PSTN trunk or" + echo "; internal texting." echo "" local _ext for _ext in $FULL_EXTS; do echo "[$_ext]" echo "tier=full" + [[ " $MESSAGING_EXTS " == *" $_ext "* ]] && echo "messaging=yes" echo "" + _written_exts="$_written_exts $_ext" done while [[ $# -gt 0 ]]; do _ext="$1"; local _nums="$2" @@ -385,65 +444,451 @@ _pstn_write_permissions_file() { echo "[$_ext]" echo "tier=restricted" echo "allowed_numbers=${_nums}" + [[ " $MESSAGING_EXTS " == *" $_ext "* ]] && echo "messaging=yes" echo "" + _written_exts="$_written_exts $_ext" + done + for _ext in $MESSAGING_EXTS; do + if [[ " $_written_exts " != *" $_ext "* ]]; then + echo "[$_ext]" + echo "messaging=yes" + echo "" + fi done } > "$FILE" chmod 664 "$FILE" } -# ── Shared: periodic spend/volume checker (run hourly via cron) ──────────── +# ── Shared: kill-switch state (fresh install / explicit reset only — same +# "update never touches it" protection as pstn-permissions.conf/ +# pstn-limits.conf) ───────────────────────────────────────────────────────── +_pstn_write_killswitch_file() { + local FILE="$1" + { + echo "; PSTN spend-cap kill-switch state. 'tripped=1' blocks ALL PSTN calling" + echo "; (in and out — internal Asterisk-to-Asterisk calling is unaffected), read" + echo "; LIVE by the dialplan (AST_CONFIG()) on every call attempt. Written" + echo "; automatically by pstn-trunk-usage-alert.sh once estimated monthly spend" + echo "; reaches the cap set during install/update — it does NOT reset on its own." + echo "; To clear a trip: re-run 'sudo ./setup.sh pstn-trunk', choose update mode," + echo "; and answer yes when asked, or hand-edit this file back to tripped=0 (same" + echo "; 'safe to edit by hand' convention as pstn-permissions.conf/pstn-limits.conf)." + echo "; Deliberately NOT exposed on the Security Dashboard web UI — clearing a" + echo "; kill-switch is a CLI-only action, consistent with the international-" + echo "; calling toggle below, so a compromised/careless web session can't quietly" + echo "; re-enable spend after a trip." + echo "" + echo "[state]" + echo "tripped=0" + } > "$FILE" + chmod 664 "$FILE" +} + +# ── Shared: international-calling allow-list (CLI-managed only — see +# _pstn_manage_international/_pstn_run_international_step below) ─────────── +_pstn_write_intl_allowed_file() { + local FILE="$1" CODES="$2" NAMES="$3" EXPIRES="$4" + { + echo "; International-calling allow-list (country calling codes, beyond NANP/US)." + echo "; Read LIVE by the dialplan (AST_CONFIG()) on every _011 international call —" + echo "; no Asterisk restart needed. Managed ONLY from the CLI installer" + echo "; ('sudo ./setup.sh pstn-trunk' -> 'Review/change allowed international-" + echo "; calling countries now?'), deliberately NOT exposed on the Security" + echo "; Dashboard web UI — this widens which countries can be dialed/billed to at" + echo "; all, a more security-sensitive control than who's already allowed to use" + echo "; an already-fixed scope. pstn-trunk-usage-alert.sh actively clears this" + echo "; (re-blocking) once 'expires' has passed, and sends ntfy notices both the" + echo "; day of and at the moment of expiry." + echo "" + echo "[countries]" + echo "allowed_codes=${CODES}" + echo "allowed_names=${NAMES}" + echo "expires=${EXPIRES}" + } > "$FILE" + chmod 664 "$FILE" +} + +_pstn_read_intl_current() { + local FILE="$1" + _PSTN_CUR_CODES="" + _PSTN_CUR_NAMES="" + _PSTN_CUR_EXPIRES="" + if [[ -f "$FILE" ]]; then + _PSTN_CUR_CODES="$(grep '^allowed_codes=' "$FILE" | head -1 | cut -d= -f2-)" + _PSTN_CUR_NAMES="$(grep '^allowed_names=' "$FILE" | head -1 | cut -d= -f2-)" + _PSTN_CUR_EXPIRES="$(grep '^expires=' "$FILE" | head -1 | cut -d= -f2-)" + fi +} + +_pstn_print_intl_allowed() { + local FILE="$1" + _pstn_read_intl_current "$FILE" + echo "" + if [[ -n "$_PSTN_CUR_CODES" ]]; then + log_info "International calling currently ALLOWED to: ${_PSTN_CUR_NAMES//|/, }" + if [[ -n "$_PSTN_CUR_EXPIRES" ]]; then + log_info " Expires: $_PSTN_CUR_EXPIRES (auto-revoked and re-blocked after this date)." + else + log_info " No expiry set — stays allowed until you change it here again." + fi + else + log_info "International calling: no countries currently allowed (US/NANP only, per the tiers above)." + fi +} + +# Continent -> country menu. Only reachable from the CLI (never the web +# dashboard) and only ever invoked via _pstn_run_international_step, which +# always asks the y/n gate first and always prints the resulting allow-list +# exactly once afterward, regardless of the answer. +_pstn_manage_international() { + local ASTERISK_DIR="$1" + local FILE="$ASTERISK_DIR/pstn-intl-allowed.conf" + mkdir -p "$ASTERISK_DIR" + + _pstn_read_intl_current "$FILE" + + local -A SELECTED=() + if [[ -n "$_PSTN_CUR_CODES" ]]; then + local -a _cur_codes_arr _cur_names_arr + IFS='|' read -ra _cur_codes_arr <<< "$_PSTN_CUR_CODES" + IFS='|' read -ra _cur_names_arr <<< "$_PSTN_CUR_NAMES" + local _k + for _k in "${!_cur_codes_arr[@]}"; do + SELECTED["${_cur_codes_arr[$_k]}"]="${_cur_names_arr[$_k]}" + done + fi + + local -a NA=("Mexico|52" "Greenland|299") + local -a SA=("Brazil|55" "Argentina|54" "Colombia|57" "Chile|56" "Peru|51" "Ecuador|593" "Venezuela|58") + local -a EU=("United Kingdom|44" "Germany|49" "France|33" "Spain|34" "Italy|39" "Netherlands|31" "Ireland|353" "Portugal|351" "Poland|48" "Switzerland|41") + local -a ASIA=("India|91" "China|86" "Japan|81" "South Korea|82" "Philippines|63" "Israel|972" "United Arab Emirates|971" "Thailand|66" "Vietnam|84") + local -a AF=("Nigeria|234" "South Africa|27" "Egypt|20" "Kenya|254" "Morocco|212") + local -a OC=("Australia|61" "New Zealand|64") + + echo "" + echo " Select countries to allow international (non-NANP) calling to/from —" + echo " nested by continent so the less-common calling codes stay out of the way" + echo " until you need them. Only 'full'-tier extensions can use this regardless" + echo " of which countries are allowed here." + + local DONE="" + while [[ "$DONE" != "y" && "$DONE" != "Y" ]]; do + echo "" + echo " Continents:" + echo " 1) North America (non-NANP) 2) South America 3) Europe" + echo " 4) Asia 5) Africa 6) Oceania" + echo " 0) Enter a country/code manually (not listed above)" + local _cur_list="" _cc + for _cc in "${!SELECTED[@]}"; do _cur_list="${_cur_list}${_cur_list:+, }${SELECTED[$_cc]}"; done + echo " Currently selected: ${_cur_list:-none}" + local _choice="" + prompt_text " Continent number, 0, or 'd' when done:" "d" _choice + local -a _countries=() + local _cname="" + case "$_choice" in + d|D) DONE=y; continue ;; + 0) + local _manual_name="" _manual_code="" + prompt_text " Country name (for your reference):" "" _manual_name + prompt_text " Country calling code (digits only, e.g. 44):" "" _manual_code + if [[ "$_manual_code" =~ ^[0-9]{1,4}$ ]]; then + SELECTED["$_manual_code"]="${_manual_name:-Unnamed} (+$_manual_code)" + log_success "Added ${_manual_name:-Unnamed} (+$_manual_code)." + else + log_warning "Not a valid calling code — skipped." + fi + continue + ;; + 1) _countries=("${NA[@]}"); _cname="North America" ;; + 2) _countries=("${SA[@]}"); _cname="South America" ;; + 3) _countries=("${EU[@]}"); _cname="Europe" ;; + 4) _countries=("${ASIA[@]}"); _cname="Asia" ;; + 5) _countries=("${AF[@]}"); _cname="Africa" ;; + 6) _countries=("${OC[@]}"); _cname="Oceania" ;; + *) log_warning "Invalid choice."; continue ;; + esac + + local _sub_done="" + while [[ "$_sub_done" != "y" && "$_sub_done" != "Y" ]]; do + echo "" + echo " $_cname:" + local _j _entry _name _code _mark + for _j in "${!_countries[@]}"; do + _entry="${_countries[$_j]}" + _name="${_entry%%|*}"; _code="${_entry##*|}" + _mark=" " + [[ -n "${SELECTED[$_code]+x}" ]] && _mark="x" + echo " $((_j+1))) [$_mark] $_name (+$_code)" + done + local _pick="" + prompt_text " Toggle a number, or 'b' for back:" "b" _pick + if [[ "$_pick" == "b" || "$_pick" == "B" ]]; then + _sub_done=y + elif [[ "$_pick" =~ ^[0-9]+$ ]] && (( _pick >= 1 && _pick <= ${#_countries[@]} )); then + _entry="${_countries[$((_pick-1))]}" + _name="${_entry%%|*}"; _code="${_entry##*|}" + if [[ -n "${SELECTED[$_code]+x}" ]]; then + unset 'SELECTED[$_code]' + log_info "Removed $_name." + else + SELECTED["$_code"]="$_name (+$_code)" + log_info "Added $_name." + fi + else + log_warning "Invalid choice." + fi + done + done + + local NEW_CODES="" NEW_NAMES="" _sep="" _code + for _code in "${!SELECTED[@]}"; do + NEW_CODES="${NEW_CODES}${_sep}${_code}" + NEW_NAMES="${NEW_NAMES}${_sep}${SELECTED[$_code]}" + _sep="|" + done + + local NEW_EXPIRES="" + if [[ -n "$NEW_CODES" ]]; then + local WANT_EXPIRY="" + prompt_yn " Auto-expire this international access? (y/n):" "y" WANT_EXPIRY + if [[ "$WANT_EXPIRY" =~ ^[Yy]$ ]]; then + local _days="" + prompt_text " Expire after how many days:" "30" _days + [[ "$_days" =~ ^[0-9]+$ ]] || _days=30 + NEW_EXPIRES="$(date -d "+${_days} days" +%Y-%m-%d)" + log_info " Will auto-expire on $NEW_EXPIRES — you'll get an ntfy notice that day and again when it actually revokes." + fi + fi + + _pstn_write_intl_allowed_file "$FILE" "$NEW_CODES" "$NEW_NAMES" "$NEW_EXPIRES" + chmod 664 "$FILE" +} + +# Always asked, never skippable ("no bypassing the option" per design) — +# both on fresh install AND on "update in place", unlike every other +# structural prompt. Prints the resulting allow-list exactly once, +# immediately after, regardless of the y/n answer — NOT repeated later +# during the spend-cap prompts. +_pstn_run_international_step() { + local ASTERISK_DIR="$1" + local FILE="$ASTERISK_DIR/pstn-intl-allowed.conf" + echo "" + log_info "International calling (beyond NANP/US) is OFF by default and can ONLY be" + log_info "managed from here (this CLI) — never from the Security Dashboard web UI." + log_info "It's a more security-sensitive control (widens which countries can be" + log_info "dialed/billed to at all) than anything already exposed there (which only" + log_info "governs who can use a scope that's already fixed)." + local WANT_INTL_CHANGE="" + prompt_yn "Review/change allowed international-calling countries now? (y/n):" "n" WANT_INTL_CHANGE + if [[ "$WANT_INTL_CHANGE" =~ ^[Yy]$ ]]; then + _pstn_manage_international "$ASTERISK_DIR" + fi + _pstn_print_intl_allowed "$FILE" +} + +# Update-mode-only: pstn-trunk-killswitch.conf is never touched by "update" +# (same protection as pstn-permissions.conf/pstn-limits.conf), so a trip +# persists across settings updates on purpose — this is the CLI-only path to +# clear it back out again once you've confirmed the overage was expected/ +# resolved. +_pstn_check_killswitch_clear() { + local ASTERISK_DIR="$1" + local FILE="$ASTERISK_DIR/pstn-trunk-killswitch.conf" + [[ -f "$FILE" ]] || return 0 + if grep -q '^tripped=1' "$FILE" 2>/dev/null; then + echo "" + log_warning "The spend-cap kill-switch is currently TRIPPED — ALL PSTN calling (in and" + log_warning "out) is blocked. It does not reset automatically." + local CLEAR="" + prompt_yn "Clear it now and resume PSTN calling? (y/n):" "n" CLEAR + if [[ "$CLEAR" =~ ^[Yy]$ ]]; then + sed -i 's/^tripped=.*/tripped=0/' "$FILE" + log_success "Kill-switch cleared — PSTN calling resumes immediately (live, no restart needed)." + else + log_info "Leaving the kill-switch tripped." + fi + fi +} + +# ── Shared: periodic spend/volume/kill-switch/international-expiry checker +# (run every minute via systemd timer, cron.d fallback — see +# _pstn_install_periodic_timer above) ─────────────────────────────────────── _pstn_write_usage_alert_script() { - local FILE="$1" EA_DIR="$2" RATE="$3" MONTH_THRESHOLD="$4" BURST_THRESHOLD="$5" NTFY_URL="$6" + local FILE="$1" EA_DIR="$2" ASTERISK_DIR="$3" RATE="$4" MONTH_THRESHOLD="$5" \ + BURST_THRESHOLD="$6" MAX_MONTHLY_SPEND="$7" NTFY_URL="$8" 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. +# the installer instead. Run every minute via a systemd timer +# (pstn-trunk-usage.timer; cron.d fallback if systemd isn't available — see +# _pstn_install_periodic_timer). Reads the call log pstn-trunk-dialplan.conf +# appends to and: +# - alerts via ntfy when month-to-date estimated spend crosses +# MONTH_THRESHOLD (once per month), or call volume in the last hour +# looks like a burst; +# - trips the spend-cap kill-switch (pstn-trunk-killswitch.conf) once +# estimated spend reaches MAX_MONTHLY_SPEND — read LIVE by the dialplan +# on every call, blocking ALL PSTN calling (in and out) until manually +# cleared (does NOT reset automatically — see CLAUDE.md/README for how); +# - sends a loud (priority=urgent) ntfy warning once spend reaches 80% of +# that cap, distinct from and in addition to the trip alert itself; +# - actively re-blocks (clears) the international-calling allow-list once +# its expiry date has passed, with ntfy notices both the day of and at +# the moment it actually revokes. +# These are estimates (call count/duration x an entered rate), not real +# billing data, and only as fresh as the last run of this script (every +# minute) — a safety net, not a substitute for the provider's own billing. LOG_FILE="__EA_DIR__/logs/pstn-trunk-calls.log" STATE_FILE="__EA_DIR__/.pstn-trunk-alert-state" +WARN_STATE_FILE="__EA_DIR__/.pstn-trunk-warn-state" +INTL_STATE_FILE="__EA_DIR__/.pstn-trunk-intl-state" +KILLSWITCH_FILE="__ASTERISK_DIR__/pstn-trunk-killswitch.conf" +INTL_FILE="__ASTERISK_DIR__/pstn-intl-allowed.conf" RATE="__PSTN_RATE__" MONTH_THRESHOLD="__PSTN_MONTH_THRESHOLD__" BURST_THRESHOLD="__PSTN_BURST_THRESHOLD__" +MAX_MONTHLY_SPEND="__PSTN_MAX_MONTHLY_SPEND__" 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 } +send_ntfy_loud() { + [[ -n "$NTFY_URL" ]] && curl -m 5 -s -H "Priority: urgent" -H "Title: PSTN trunk alert" -d "$1" "$NTFY_URL" >/dev/null 2>&1 +} -last_alert_month="" -[[ -f "$STATE_FILE" ]] && last_alert_month=$(cat "$STATE_FILE") +current_month=$(date +%Y-%m) -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" +if [[ -f "$LOG_FILE" ]]; then + now_epoch=$(date +%s) + 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") + + 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 + + # ── Spend-cap kill-switch: trip, or warn once approaching it ────────── + # MAX_MONTHLY_SPEND=0 means the kill-switch is disabled (not configured). + if awk -v m="$MAX_MONTHLY_SPEND" 'BEGIN{exit !(m+0>0)}'; then + already_tripped="0" + [[ -f "$KILLSWITCH_FILE" ]] && grep -q '^tripped=1' "$KILLSWITCH_FILE" && already_tripped="1" + + if [[ "$already_tripped" != "1" ]] && awk -v c="$month_cost" -v m="$MAX_MONTHLY_SPEND" 'BEGIN{exit !(c>=m)}'; then + cat > "$KILLSWITCH_FILE" << KS +[state] +tripped=1 +KS + send_ntfy_loud "PSTN trunk: SPEND-CAP KILL-SWITCH TRIPPED. Estimated spend this month (\$${month_cost}) reached the \$${MAX_MONTHLY_SPEND} cap. ALL PSTN calling (in and out) is now blocked - internal Asterisk calling is unaffected. This does NOT reset automatically - clear it with 'sudo ./setup.sh pstn-trunk' (update mode)." + elif [[ "$already_tripped" != "1" ]]; then + warn_threshold=$(awk -v m="$MAX_MONTHLY_SPEND" 'BEGIN{printf "%.2f", m*0.8}') + if awk -v c="$month_cost" -v t="$warn_threshold" 'BEGIN{exit !(c>=t)}'; then + last_warn_month="" + [[ -f "$WARN_STATE_FILE" ]] && last_warn_month=$(cat "$WARN_STATE_FILE") + if [[ "$last_warn_month" != "$current_month" ]]; then + send_ntfy_loud "PSTN trunk: approaching the spend cap - estimated spend this month (\$${month_cost}) is at 80%+ of the \$${MAX_MONTHLY_SPEND} kill-switch cap. PSTN calling will be BLOCKED automatically if it reaches \$${MAX_MONTHLY_SPEND}." + echo "$current_month" > "$WARN_STATE_FILE" + fi + fi + fi 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." +# ── International allow-list: expiry notices + active re-block ──────────── +if [[ -f "$INTL_FILE" ]]; then + intl_codes=$(grep '^allowed_codes=' "$INTL_FILE" | head -1 | cut -d= -f2-) + intl_expires=$(grep '^expires=' "$INTL_FILE" | head -1 | cut -d= -f2-) + today=$(date +%Y-%m-%d) + if [[ -n "$intl_codes" && -n "$intl_expires" ]]; then + last_intl_notice="" + [[ -f "$INTL_STATE_FILE" ]] && last_intl_notice=$(cat "$INTL_STATE_FILE") + if [[ "$today" == "$intl_expires" && "$last_intl_notice" != "day-of:$intl_expires" ]]; then + send_ntfy "PSTN trunk: international calling access expires TODAY ($intl_expires)." + echo "day-of:$intl_expires" > "$INTL_STATE_FILE" + fi + if [[ "$today" > "$intl_expires" ]]; then + sed -i 's/^allowed_codes=.*/allowed_codes=/; s/^allowed_names=.*/allowed_names=/; s/^expires=.*/expires=/' "$INTL_FILE" + send_ntfy "PSTN trunk: international calling access EXPIRED ($intl_expires) and has been revoked/re-blocked automatically." + echo "expired:$intl_expires" > "$INTL_STATE_FILE" + fi + fi 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#__EA_DIR__#${EA_DIR}#g; s#__ASTERISK_DIR__#${ASTERISK_DIR}#g; s/__PSTN_RATE__/${RATE}/g; s/__PSTN_MONTH_THRESHOLD__/${MONTH_THRESHOLD}/g; s/__PSTN_BURST_THRESHOLD__/${BURST_THRESHOLD}/g; s/__PSTN_MAX_MONTHLY_SPEND__/${MAX_MONTHLY_SPEND}/g" "$FILE" sed -i "s#__PSTN_NTFY_URL__#${NTFY_URL}#g" "$FILE" chmod 755 "$FILE" } +# ── Shared: per-minute periodic check (systemd timer, cron.d fallback) ───── +# Runs pstn-trunk-usage-alert.sh far more often than the old hourly cron.d +# job — every minute — since it's also the enforcement point for the +# spend-cap kill-switch (see _pstn_write_usage_alert_script below): the +# gap between a check and the next one is the window where an overage +# could still happen before calling actually gets blocked, so a tighter +# interval directly shrinks that exposure. Idempotent: safe to call again +# on "update in place" to migrate an older install off the old cron.d job. +_pstn_install_periodic_timer() { + local EA_DIR="$1" + mkdir -p "$EA_DIR/logs" + + # Older versions of this service installed an hourly cron.d job under + # this same name — remove it so there's only ever one scheduler. + [[ -f /etc/cron.d/pstn-trunk-usage ]] && rm -f /etc/cron.d/pstn-trunk-usage + + if command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then + cat > /etc/systemd/system/pstn-trunk-usage.service << SVCEOF +[Unit] +Description=PSTN trunk spend/volume/kill-switch/international-expiry check + +[Service] +Type=oneshot +ExecStart=/bin/bash $EA_DIR/pstn-trunk-usage-alert.sh +StandardOutput=append:$EA_DIR/logs/pstn-trunk-usage-alert.log +StandardError=append:$EA_DIR/logs/pstn-trunk-usage-alert.log +SVCEOF + + cat > /etc/systemd/system/pstn-trunk-usage.timer << SVCEOF +[Unit] +Description=Run the PSTN trunk usage check every minute + +[Timer] +OnBootSec=1min +OnUnitActiveSec=1min +AccuracySec=5s + +[Install] +WantedBy=timers.target +SVCEOF + + systemctl daemon-reload + systemctl enable --now pstn-trunk-usage.timer + log_success "Per-minute spend/volume/kill-switch check installed (systemd timer)." + elif command -v cron >/dev/null 2>&1 || [[ -d /etc/cron.d ]]; then + cat > /etc/cron.d/pstn-trunk-usage << CRON +* * * * * root /bin/bash $EA_DIR/pstn-trunk-usage-alert.sh >> $EA_DIR/logs/pstn-trunk-usage-alert.log 2>&1 +CRON + log_success "Per-minute spend/volume/kill-switch check installed (cron.d fallback — systemd not detected)." + else + log_warning "Neither systemd nor cron available — run $EA_DIR/pstn-trunk-usage-alert.sh manually/periodically for spend/volume/kill-switch checks." + fi +} + # ── 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. @@ -451,14 +896,15 @@ _pstn_apply_settings() { local EA_DIR="$1" ASTERISK_DIR="$2" local SERVER="$3" SERVER_IPS="$4" DID="$5" local RING_EXTS="$6" NTFY_URL="$7" RATE="$8" MONTH_THRESHOLD="$9" BURST_THRESHOLD="${10}" - local PROVIDER_NAME="${11}" + local PROVIDER_NAME="${11}" MAX_MONTHLY_SPEND="${12:-0}" _pstn_patch_vendor_files "$EA_DIR" || return 1 mkdir -p "$ASTERISK_DIR" _pstn_write_pjsip_include "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$SERVER" "$SERVER_IPS" "$DID" _pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$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" + _pstn_write_usage_alert_script "$EA_DIR/pstn-trunk-usage-alert.sh" "$EA_DIR" "$ASTERISK_DIR" \ + "$RATE" "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$MAX_MONTHLY_SPEND" "$NTFY_URL" ensure_docker_dir_ownership "$ASTERISK_DIR" chmod 644 "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$ASTERISK_DIR/pstn-trunk-dialplan.conf" @@ -478,17 +924,11 @@ NTFY_URL="${NTFY_URL}" RATE_PER_MIN="${RATE}" MONTH_THRESHOLD="${MONTH_THRESHOLD}" BURST_THRESHOLD="${BURST_THRESHOLD}" +MAX_MONTHLY_SPEND="${MAX_MONTHLY_SPEND}" 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 + _pstn_install_periodic_timer "$EA_DIR" } install_pstn-trunk() { @@ -508,6 +948,7 @@ install_pstn-trunk() { local DIALPLAN_INCLUDE="$ASTERISK_DIR/pstn-trunk-dialplan.conf" local PERMISSIONS_FILE="$ASTERISK_DIR/pstn-permissions.conf" local LIMITS_FILE="$ASTERISK_DIR/pstn-limits.conf" + local KILLSWITCH_FILE="$ASTERISK_DIR/pstn-trunk-killswitch.conf" local SETTINGS_FILE="$EA_DIR/.pstn-trunk.env" local CONTAINER_NAME="easy-asterisk" [[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do" @@ -516,16 +957,21 @@ install_pstn-trunk() { 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] internal SIP messaging extensions (separate from PSTN calling permission)," echo "[DRY-RUN] max concurrent outbound/inbound calls (default 10/10), inbound ring-group extensions," - echo "[DRY-RUN] ntfy alert topic (optional), per-minute rate + monthly/hourly alert thresholds" + echo "[DRY-RUN] ntfy alert topic (optional), international-calling allow-list (CLI-only," + echo "[DRY-RUN] always asked, never on the web dashboard), per-minute rate + monthly/hourly" + echo "[DRY-RUN] alert thresholds, and an optional hard monthly spend-cap kill-switch" echo "[DRY-RUN] Would resolve the server hostname to an IP, plus prompt for any additional" echo "[DRY-RUN] known source IPs (some providers publish a fixed list), for inbound call matching" echo "[DRY-RUN] Would patch vendor generator functions to #include the trunk config" - echo "[DRY-RUN] Would write pjsip/dialplan includes, pstn-permissions.conf + pstn-limits.conf" - echo "[DRY-RUN] (fresh install only), and an hourly usage-alert script + cron.d entry" + echo "[DRY-RUN] Would write pjsip/dialplan includes, pstn-permissions.conf, pstn-limits.conf," + echo "[DRY-RUN] and pstn-trunk-killswitch.conf (fresh install only), plus a per-minute" + echo "[DRY-RUN] usage-alert script installed via a systemd timer (cron.d fallback)" echo "[DRY-RUN] Would offer 'update in place' (structural settings only — never touches" - echo "[DRY-RUN] pstn-permissions.conf or pstn-limits.conf) instead of a fresh install if" - echo "[DRY-RUN] already configured" + echo "[DRY-RUN] pstn-permissions.conf, pstn-limits.conf, or the kill-switch trip state)" + echo "[DRY-RUN] instead of a fresh install if already configured; the international-calling" + echo "[DRY-RUN] review/change question is still asked every run either way" echo "[DRY-RUN] Would restart the asterisk container to apply" return 0 fi @@ -570,15 +1016,19 @@ install_pstn-trunk() { if [[ -f "$SETTINGS_FILE" ]]; then # shellcheck disable=SC1090 source "$SETTINGS_FILE" + _pstn_check_killswitch_clear "$ASTERISK_DIR" _pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ "$TRUNK_SERVER" "$TRUNK_SERVER_IPS" "$TRUNK_DID" \ "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ - "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 + "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" "${MAX_MONTHLY_SPEND:-0}" || 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 and pstn-limits.conf were NOT touched — edit them" log_info "directly, via the Security Dashboard, or choose FRESH reinstall to reset them." + # Always asked, every run, update mode included — see + # _pstn_run_international_step's own comment for why. + _pstn_run_international_step "$ASTERISK_DIR" return 0 else log_warning "No $SETTINGS_FILE found (pre-dates this settings-file version) — falling back to a fresh install (every prompt below)." @@ -592,7 +1042,8 @@ install_pstn-trunk() { if [[ -f "$PERMISSIONS_FILE" || -f "$LIMITS_FILE" ]]; then log_warning "pstn-permissions.conf and/or pstn-limits.conf already exist and may have" log_warning "been edited since (directly, or via the Security Dashboard). A fresh" - log_warning "reinstall OVERWRITES both with whatever you enter below." + log_warning "reinstall OVERWRITES both with whatever you enter below, and also resets" + log_warning "the spend-cap kill-switch back to untripped." local _confirm_reset="" prompt_yn "Continue and reset permission tiers + concurrency caps? (y/n):" "n" _confirm_reset if [[ ! "$_confirm_reset" =~ ^[Yy]$ ]]; then @@ -712,6 +1163,19 @@ install_pstn-trunk() { return 1 fi + # ── Internal SIP messaging — a separate axis from PSTN calling ───────── + # Asterisk's native SIP MESSAGE (extension-to-extension texting) has no + # cost/carrier involvement at all, unlike PSTN calling, so it gets its + # own independent flag in pstn-permissions.conf rather than being folded + # into the internal/restricted/full tiers above — an extension can be + # "internal" for calling (no PSTN) and still messaging-enabled, or vice + # versa. Off by default, same "opt in" posture as PSTN access. + echo "" + echo " Asterisk also supports native SIP texting between extensions (no carrier" + echo " SMS, no PSTN, no cost) — a separate permission from PSTN calling above." + local MESSAGING_EXTS="" + prompt_text "Extensions allowed to use internal SIP messaging (space-separated, blank = none):" "" MESSAGING_EXTS + echo "" local 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 @@ -735,6 +1199,10 @@ install_pstn-trunk() { prompt_text " ntfy topic URL:" "$_ntfy_default" NTFY_URL fi + # Always asked, every run, no exceptions — see _pstn_run_international_step's + # own comment for why this can't be skipped like everything else here. + _pstn_run_international_step "$ASTERISK_DIR" + 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)." @@ -745,13 +1213,38 @@ install_pstn-trunk() { local BURST_THRESHOLD="" prompt_text " Alert if more than this many outbound calls happen in one hour:" "10" BURST_THRESHOLD + echo "" + log_warning "Spend-cap kill-switch: a HARD stop, not just an alert. Once estimated spend" + log_warning "this month reaches the cap below, ALL PSTN calling (in and out) is blocked" + log_warning "until you manually clear it by re-running this installer (update mode) — it" + log_warning "does NOT reset automatically next month. Internal Asterisk-to-Asterisk" + log_warning "calling is never affected." + log_warning "Based on estimated cost (call count/duration x the rate above), not your" + log_warning "provider's real billing data, and checked every minute, not instantly — a" + log_warning "strong safety net, not an absolute guarantee against any overage." + local WANT_KILLSWITCH="" + prompt_yn "Enable a hard monthly spend-cap kill-switch? (y/n):" "y" WANT_KILLSWITCH + local MAX_MONTHLY_SPEND="0" + if [[ "$WANT_KILLSWITCH" =~ ^[Yy]$ ]]; then + prompt_text " Monthly spend cap in USD (ALL PSTN calling blocked once reached):" "15" MAX_MONTHLY_SPEND + if [[ ! "$MAX_MONTHLY_SPEND" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + log_warning "Not a number — defaulting to 15." + MAX_MONTHLY_SPEND="15" + fi + log_info "You'll get a loud ntfy alert at 80% of this cap, and another (also loud) when it trips." + else + MAX_MONTHLY_SPEND="0" + log_info "Kill-switch disabled — the spend alerts above still notify you, but calling won't be auto-blocked." + fi + _pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ "$TRUNK_SERVER" "$TRUNK_SERVER_IPS" "$TRUNK_DID" \ "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ - "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 + "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" "$MAX_MONTHLY_SPEND" || return 1 - _pstn_write_permissions_file "$PERMISSIONS_FILE" "$FULL_EXTS" "${RESTRICTED_ARGS[@]}" + _pstn_write_permissions_file "$PERMISSIONS_FILE" "$FULL_EXTS" "$MESSAGING_EXTS" "${RESTRICTED_ARGS[@]}" _pstn_write_limits_file "$LIMITS_FILE" "$MAX_OUTBOUND" "$MAX_INBOUND" + _pstn_write_killswitch_file "$KILLSWITCH_FILE" ensure_docker_dir_ownership "$ASTERISK_DIR" # No new firewall rules: the base install already opens SIP (5060/5061) @@ -761,6 +1254,47 @@ install_pstn-trunk() { # ── Docs (separate file — the base install already owns README.md in # this same directory via write_readme, so don't overwrite it) ───────── + # Kill-switch/international-allowlist blurbs are built into plain + # variables first, not inline $(...) inside the heredoc below — a + # heredoc nested inside a command substitution that's itself inside + # another heredoc doesn't parse in bash (confirmed: "syntax error near + # unexpected token `||'" when tried directly). + local KILLSWITCH_DOC="" + if [[ "$MAX_MONTHLY_SPEND" != "0" ]]; then + KILLSWITCH_DOC="**Enabled — \$${MAX_MONTHLY_SPEND}/month.** Once \`pstn-trunk-usage-alert.sh\` +estimates month-to-date spend has reached this cap, it writes +\`tripped=1\` to \`config/asterisk/pstn-trunk-killswitch.conf\` — read +**live** by the dialplan on every PSTN call attempt (in AND out; internal +Asterisk-to-Asterisk calling is never affected) and blocks it immediately +with a loud (priority=urgent) ntfy alert. You also get a separate loud ntfy +warning once spend reaches 80% of this cap, before it actually trips. + +**This does NOT reset automatically** — once tripped, it stays tripped +until you clear it yourself: re-run \`sudo ./setup.sh pstn-trunk\`, choose +update mode, and answer yes when asked. This is deliberately a CLI-only +action, not exposed on the Security Dashboard web UI, so a compromised or +careless web session can't quietly re-enable spend after a trip. + +Based on estimated cost, not real billing data, and checked once a minute — +a strong safety net, not an absolute guarantee against any overage." + else + KILLSWITCH_DOC="**Disabled.** The spend alerts above still notify you, but PSTN calling won't be auto-blocked. Enable it by re-running \`sudo ./setup.sh pstn-trunk\` (update mode)." + fi + + _pstn_read_intl_current "$ASTERISK_DIR/pstn-intl-allowed.conf" + local INTL_DOC="" + if [[ -n "$_PSTN_CUR_CODES" ]]; then + INTL_DOC="**Currently allowed:** ${_PSTN_CUR_NAMES//|/, } +" + if [[ -n "$_PSTN_CUR_EXPIRES" ]]; then + INTL_DOC="${INTL_DOC}**Expires:** $_PSTN_CUR_EXPIRES — auto-revoked and re-blocked after this date (with an ntfy notice both the day of and at the moment it expires)." + else + INTL_DOC="${INTL_DOC}**No expiry set** — stays allowed until changed again from the CLI." + fi + else + INTL_DOC="**No countries currently allowed** — outbound/inbound PSTN calling is US/NANP-only." + fi + local DOC_FILE="$EA_DIR/README-pstn-trunk.md" cat > "$DOC_FILE" << MD # SIP PSTN trunk (add-on to $ASTERISK_KIND) @@ -783,10 +1317,12 @@ background, cost estimate, and toll-fraud reasoning. | Restricted-PSTN extensions | ${RESTRICTED_EXTS:-none} | | Concurrency caps | ${MAX_OUTBOUND} outbound / ${MAX_INBOUND} inbound simultaneous calls (live — see \`pstn-limits.conf\` below) | | Inbound ring-group | ${RING_EXTS} | +| Internal SIP messaging extensions | ${MESSAGING_EXTS:-none} (separate from PSTN calling permission — see below) | | 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 | +| Spend-cap kill-switch | $([ "$MAX_MONTHLY_SPEND" != "0" ] && echo "\$${MAX_MONTHLY_SPEND}/month — blocks ALL PSTN calling once reached" || echo "disabled") | ## Non-US NANP area codes are blocked, not just "anything outside NANP" @@ -877,26 +1413,64 @@ without touching \`pstn-permissions.conf\` or \`pstn-limits.conf\`). ## 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: +\`pstn-trunk-usage-alert.sh\` runs **every minute** (\`pstn-trunk-usage.timer\`, +a systemd timer — falls back to a cron.d entry if systemd isn't available) +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, + doesn't repeat every minute). +- **Every run** that outbound call volume in the last hour exceeds + ${BURST_THRESHOLD} calls — the faster tripwire for a burst/abuse scenario, independent of whether it's crossed the monthly dollar threshold yet. Separately, denied calls (no permission / number not pre-approved) and -rejected calls (either concurrency cap hit) alert **immediately**, not on -the hourly schedule. +rejected calls (either concurrency cap or the kill-switch) alert +**immediately** from the dialplan itself, not on the periodic 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. +real billing data, and only as fresh as the last check (every minute) — a +safety net, not a substitute for checking your provider's own balance/usage +dashboard. + +## Spend-cap kill-switch + +$KILLSWITCH_DOC + +## International calling (beyond NANP/US) + +$INTL_DOC + +Managed **only** from the CLI (\`sudo ./setup.sh pstn-trunk\` — asked on every +run, fresh install or update, with no way to skip the question, though you +can always answer no to leave things unchanged), deliberately never exposed +on the Security Dashboard web UI: this widens which countries can be +dialed/billed to at all, a more security-sensitive control than who's +already allowed to use an already-fixed scope. Only \`full\`-tier extensions +can place these calls regardless of which countries are allowed. Stored in +\`config/asterisk/pstn-intl-allowed.conf\`, read live the same way as +permission tiers. + +## Internal SIP messaging + +Asterisk's native SIP \`MESSAGE\` support (extension-to-extension texting — +no carrier SMS, no PSTN, no cost) is gated by a \`messaging=yes\` flag per +extension in \`pstn-permissions.conf\`, independent of the PSTN calling +tiers above — off by default, same "opt in" posture. Currently enabled for: +${MESSAGING_EXTS:-none}. + +**Known gap:** this installer writes the permission flag (live-editable, +same mechanism as the calling tiers), but the actual SIP \`MESSAGE\` routing +dialplan wiring depends on how Easy Asterisk's own generated +\`extensions.conf\`/\`pjsip.conf\` route inbound messages, which needs to be +verified against a live install before it's safely automated here — shipping +a guessed pattern risked either silently not working or interfering with +call-routing precedence in the same \`[intercom]\` context. Treat the +permission flag as ready for a dashboard/CLI-managed allow-list once that +routing is confirmed, not as fully wired yet. ## Managing this from a web UI diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index c3d05a6..948ea4a 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -206,13 +206,19 @@ 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) — the +- **PSTN Trunk** — detects whether \`services/pstn-trunk.sh\`'s dialplan is + actually installed (\`pstn-trunk-dialplan.conf\` present) and shows a clear + "not installed" message instead of the caps/tiers editor if not, so it + never shows real-looking-but-unenforced defaults. When installed: the outbound/inbound concurrent-call caps, and every known extension (parsed from \`pjsip.conf\`) with its current permission tier (internal / restricted / full) and, for restricted, its approved numbers — all editable live, no Asterisk restart, no reinstall. Writes directly to \`pstn-limits.conf\` / \`pstn-permissions.conf\`, which the dialplan reads - fresh on every call. + fresh on every call. The spend-cap kill-switch and international-calling + allow-list are deliberately **not** managed here — CLI-only, via + \`sudo ./setup.sh pstn-trunk\` — since both are more security-sensitive + than what this tab already exposes. - Link to the Asterisk web admin itself (doesn't embed it, just links out). ## Manage @@ -908,6 +914,18 @@ def write_permission(ext, tier, numbers_raw): LIMIT_RE = re.compile(r"^\d+$") +def pstn_installed(): + """True only once services/pstn-trunk.sh has actually wired the dialplan + in (pstn-trunk-dialplan.conf existing), not just because base Asterisk is + present — pjsip.conf/extensions.conf exist either way, so extension names + alone can't tell us this. Without this check the tab would show a real + extension list and a default-but-unenforced 10/10 cap even when there is + no PSTN trunk at all.""" + if not ASTERISK_CONFIG_DIR: + return False + return os.path.isfile(os.path.join(ASTERISK_CONFIG_DIR, "pstn-trunk-dialplan.conf")) + + def get_limits(): """Current outbound/inbound concurrent-call caps. Defaults (10/10) match what the dialplan itself falls back to (via AST_CONFIG()+IF()) if this @@ -973,6 +991,9 @@ INDEX_HTML = """ table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #23262f; } th { color: #9aa4b2; font-weight: 600; } + th.sortable { cursor: pointer; user-select: none; } + th.sortable:hover { color: #e6e6e6; } + th.sortable .arrow { opacity: 0.5; font-size: 0.75em; margin-left: 0.25em; } .sev-Error { color: #ff6b6b; } .sev-Warning { color: #f5b342; } .sev-Informational { color: #7fbf7f; } @@ -1006,7 +1027,15 @@ INDEX_HTML = """