diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index b5e442f..27a2b54 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -10,35 +10,44 @@ LAN variant). Generic SIP trunk add-on that defaults to VoIP.ms but isn't hardcoded to it — any provider supporting IP authentication works. Covers: - IP-authenticated trunk, US/NANP-only outbound dialplan, no catch-all. -- A configurable concurrent-call cap (default 3, global not per-extension). +- **Two independent concurrent-call caps**, one per direction (default 10 + outbound / 10 inbound — bumped up from an initial default of 3 once roles + existed to gate who can even reach the trunk; "ability creep is real," so + the two caps stay the hard backstop regardless). Global per direction, not + per-extension. - **Three-tier per-extension permission model**: `internal` (default — no PSTN at all, but can always call/receive other extensions and internal ring groups), `restricted` (also only pre-approved US numbers, both directions), `full` (also any US number). Internal extension-to-extension - dialing is *never* gated by any tier. -- **Permissions are live, not baked into the dialplan.** Stored in - `pstn-permissions.conf`, read by the dialplan via Asterisk's - `AST_CONFIG()` on every call — editing that file takes effect on the next - call, no restart, no reinstall. `services/pstn-trunk.sh`'s "update in - place" mode deliberately never touches it (same protection this repo's - update-mode convention already gives `.env`/firewall/Caddy config + dialing is *never* gated by any tier — deliberately, even though VoIP.ms + itself offers free SIP-to-SIP calling, to avoid routing purely-internal + calls through an extra external hop for no benefit. +- **Permissions AND concurrency caps are both live, not baked into the + dialplan.** Stored in `pstn-permissions.conf` / `pstn-limits.conf`, read + by the dialplan via Asterisk's `AST_CONFIG()` on every call — editing + either file takes effect on the next call, no restart, no reinstall. + `services/pstn-trunk.sh`'s "update in place" mode deliberately never + touches either (same protection this repo's update-mode convention + already gives `.env`/firewall/Caddy config elsewhere) — only a "fresh" reinstall (with confirmation) or the web UI below change it. - A configurable **inbound ring-group** (one extension or several), each member's live tier/approved-numbers checked per inbound call via an unrolled per-member dialplan block (no AGI needed). - **`services/security-dashboard.sh` integration** — a "PSTN Trunk" tab - lists every extension (parsed from `pjsip.conf`) with its live tier and - approved numbers, editable with no restart. This is what makes the tier - model actually manageable day-to-day instead of needing a reinstall for - every roster change. + shows both concurrency caps and every extension (parsed from + `pjsip.conf`) with its live tier and approved numbers, all editable with + no restart. This is what makes the tier model and caps actually + manageable day-to-day instead of needing a reinstall for every change. - **ntfy alerts** on denied/rejected calls (immediate — permission denied, - number not approved, or concurrency cap hit) and spend/volume thresholds - (hourly check: once/month on a spend threshold, every hour on a call-burst - threshold). -- Structural settings (server, DID, ring-group membership, cap, ntfy, + number not approved, or either concurrency cap hit) and spend/volume + thresholds (hourly check: once/month on a spend threshold, every hour on + a call-burst threshold). +- Structural settings (server, DID, ring-group *membership*, ntfy, rate/thresholds) persist to `.pstn-trunk.env` so "update in place" - reapplies them without re-prompting. + reapplies them without re-prompting — the concurrency cap *numbers* + themselves are not structural, they live in `pstn-limits.conf` instead + (see above). `services/pstn-trunk.sh`'s own header comment explains how the trunk/dialplan config survives Easy Asterisk's regeneration, and why permissions are a @@ -111,11 +120,14 @@ ceiling; only a concurrent-call cap bounds the *speed* of a breach. Treat the concurrent-call cap and spend/volume alert below as required before funding a live trunk, not optional hardening. -**Implemented:** the concurrent-call cap in `services/pstn-trunk.sh` is a -*global* cap (configurable, default max 3 outbound legs total via the trunk, -via `GROUP()`/`GROUP_COUNT()` in the dialplan, shared across all extensions) -— not per-extension. That was the explicit ask when this got built. The -spend/volume alert is also implemented now: an hourly cron script reads a +**Implemented:** the concurrent-call caps in `services/pstn-trunk.sh` are +*global* per direction (default 10 outbound / 10 inbound, each tracked via +its own `GROUP()`/`GROUP_COUNT()` in the dialplan, shared across all +extensions) — not per-extension. Inbound didn't have a cap at all until +this was pointed out as a gap (outbound's cap doesn't protect against an +inbound call-flood, which also costs money per-minute on VoIP.ms) — both +directions are covered symmetrically now. The spend/volume alert is also +implemented: an hourly cron script reads a call log the dialplan appends to directly (not Asterisk's CDR — see the service file's own comments for why) and alerts via ntfy once per month when estimated spend crosses a threshold, and every hour that call volume in the @@ -171,12 +183,13 @@ separately from that hourly check. plan and whether to add E911, pick a server/POP, fund the prepaid balance ($15 minimum for VoIP.ms), turn off auto-recharge. `services/pstn-trunk.sh` prompts for the server hostname, DID, allowed extensions, ring extensions, - concurrency cap, ntfy topic, and spend-alert settings at install time. + both concurrency caps, ntfy topic, and spend-alert settings at install + time (the cap *numbers* are then live/web-editable afterward — see above). - Defense-in-depth alongside the trunk: - - **Implemented:** a global concurrent-call cap in the dialplan - (`GROUP()`/`GROUP_COUNT()`, configurable, default max 3 outbound legs via - the trunk at once) so an unauthorized or compromised extension can't - open dozens of simultaneous outbound legs. Global, not per-extension — + - **Implemented:** independent outbound/inbound concurrent-call caps in + the dialplan (`GROUP()`/`GROUP_COUNT()`, default 10/10) so an + unauthorized or compromised extension can't open dozens of simultaneous + legs in either direction. Global per direction, not per-extension — see the note above. - **Implemented:** an outbound call-count/spend alert via ntfy — a self-contained call log (not Asterisk's CDR) plus an hourly cron script. @@ -215,9 +228,11 @@ separately from that hourly check. the provider's IP authentication). Still unresolved: pick pay-per-minute vs. unlimited DID plan on VoIP.ms's side based on real expected volume, and decide on E911 (see cost estimate). -5. ~~Concurrent-call cap~~ Done — configurable, default 3, global not - per-extension. ~~Spend/volume alert~~ Done — ntfy, hourly threshold + - burst check, plus immediate alerts on denied/rejected calls. +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. 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 diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 40cb35c..9ded13f 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -1,11 +1,15 @@ #!/bin/bash # services/pstn-trunk.sh — SIP PSTN trunk add-on for asterisk-digital-ocean # (or the home/LAN asterisk install): US-only outbound (NANP dialplan -# restriction), a configurable concurrent-call cap, a 3-tier permission model -# per extension (internal-only / restricted to pre-approved numbers / full US -# calling), a configurable inbound ring-group, IP-authenticated trunk (no SIP -# password stored), ntfy alerts on denied/rejected calls, and a periodic -# spend/volume check. +# restriction), independent outbound/inbound concurrent-call caps, a 3-tier +# permission model per extension (internal-only / restricted to pre-approved +# numbers / full US calling), a configurable inbound ring-group, +# IP-authenticated trunk (no SIP password stored), ntfy alerts on +# denied/rejected calls, and a periodic spend/volume check. +# +# Internal extension-to-extension calling (and internal ring groups) is +# never gated by any of the above, regardless of tier — the trunk is purely +# an additional path out to/in from the real phone network. # # Defaults to VoIP.ms (see docs/pstn-calling-voipms-plan.md for the design/ # cost background this is built from) but isn't hardcoded to it — any SIP @@ -13,9 +17,9 @@ # # Requires an existing services/asterisk-digital-ocean.sh OR services/asterisk.sh # install — this adds a PSTN trunk on top of one of them and does not stand -# alone. Permission tiers are managed live (no restart needed) via -# pstn-permissions.conf — editable by hand, or from services/security-dashboard.sh's -# "PSTN Trunk" tab if that's installed. +# alone. Permission tiers AND concurrency caps are managed live (no restart +# needed) via pstn-permissions.conf / pstn-limits.conf — editable by hand, or +# from services/security-dashboard.sh's "PSTN Trunk" tab if that's installed. # # Part of the modular post-install system (sourced by setup.sh). @@ -192,15 +196,16 @@ MEMBER # CDR CSV's comma-quoting entirely (our own pipe-delimited format has no # embedded-delimiter risk since every field here is digits/hostnames). _pstn_write_dialplan_include() { - local FILE="$1" DID="$2" MAX_CONCURRENT="$3" RING_EXTS="$4" NTFY_URL="$5" + local FILE="$1" DID="$2" RING_EXTS="$3" NTFY_URL="$4" cat > "$FILE" << 'EOF' -; PSTN outbound/inbound — US-only (NANP), concurrent-call cap, tiered -; permissions (internal / restricted / full) read LIVE from -; pstn-permissions.conf via AST_CONFIG() — edit permissions there, or via the -; Security Dashboard web UI, with no restart needed. Regenerated by -; services/pstn-trunk.sh — edit there, not here directly, or a -; reinstall/update will overwrite this file (pstn-permissions.conf itself is -; NOT touched by "update", only by a "fresh" reinstall or the web UI). +; PSTN outbound/inbound — US-only (NANP). Concurrent-call caps (both +; directions) AND tiered permissions (internal / restricted / full) are read +; LIVE from pstn-limits.conf / pstn-permissions.conf via AST_CONFIG() — edit +; either there, or via the Security Dashboard web UI, with no restart +; needed. Regenerated by services/pstn-trunk.sh — edit there, not here +; directly, or a reinstall/update will overwrite this file (pstn-limits.conf +; and pstn-permissions.conf are NOT touched by "update", only by a "fresh" +; reinstall or the web UI). ; ; No catch-all pattern here on purpose: only these two NANP patterns route ; to the trunk, so an unauthorized or compromised extension can't reach @@ -227,7 +232,9 @@ __ALERT_DENY_NUMBER_LINE__ same => n,Busy(15) same => n,Hangup() -exten => pstn_check_busy,1,GotoIf($[${GROUP_COUNT(pstn-out)} >= __PSTN_MAX_CONCURRENT__]?pstn_busy,1) +exten => pstn_check_busy,1,Set(PSTN_MAX_OUT=${AST_CONFIG(pstn-limits.conf,limits,max_outbound)}) + same => n,Set(PSTN_MAX_OUT=${IF($["${PSTN_MAX_OUT}" = ""]?10:${PSTN_MAX_OUT})}) + same => n,GotoIf($[${GROUP_COUNT(pstn-out)} >= ${PSTN_MAX_OUT}]?pstn_busy,1) same => n,Set(GROUP()=pstn-out) same => n,Set(CALLERID(num)=__PSTN_DID__) same => n,Set(PSTN_START=${EPOCH}) @@ -236,23 +243,26 @@ exten => pstn_check_busy,1,GotoIf($[${GROUP_COUNT(pstn-out)} >= __PSTN_MAX_CONCU same => n,System(printf '%s|out|%s|%s|%s\n' "${PSTN_START}" "${PSTN_CALLER}" "${EXTEN}" "${PSTN_DUR}" >> /var/log/asterisk/pstn-trunk-calls.log) same => n,Hangup() -exten => pstn_busy,1,NoOp(PSTN trunk - concurrent-call cap reached, rejecting) +exten => pstn_busy,1,NoOp(PSTN trunk - outbound concurrent-call cap reached, rejecting) __ALERT_BUSY_LINE__ same => n,Busy(15) same => n,Hangup() EOF - sed -i "s/__PSTN_MAX_CONCURRENT__/${MAX_CONCURRENT}/g; s/__PSTN_DID__/${DID}/g" "$FILE" + sed -i "s/__PSTN_DID__/${DID}/g" "$FILE" 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_NUMBER_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call denied - number not pre-approved.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" - sed -i "s#__ALERT_BUSY_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: concurrent-call cap reached - a call was rejected.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" + 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" fi - # ── Inbound: [from-pstn-trunk], one unrolled block per ring-group member + # ── Inbound: [from-pstn-trunk], one unrolled block per ring-group member. + # Permission check (is anyone in the ring group authorized for this + # caller) happens before the concurrency check, mirroring outbound's + # own ordering (permission gate, then busy gate). cat >> "$FILE" << 'EOF' [from-pstn-trunk] @@ -268,6 +278,10 @@ EOF cat >> "$FILE" << 'EOF' same => n,GotoIf($["${PSTN_RING_LIST}" = ""]?pstn_in_denied,1) + same => n,Set(PSTN_MAX_IN=${AST_CONFIG(pstn-limits.conf,limits,max_inbound)}) + same => n,Set(PSTN_MAX_IN=${IF($["${PSTN_MAX_IN}" = ""]?10:${PSTN_MAX_IN})}) + same => n,GotoIf($[${GROUP_COUNT(pstn-in)} >= ${PSTN_MAX_IN}]?pstn_in_busy,1) + same => n,Set(GROUP()=pstn-in) same => n,Set(PSTN_START=${EPOCH}) same => n,Dial(${PSTN_RING_LIST},20) same => n,Set(PSTN_DUR=$[${EPOCH} - ${PSTN_START}]) @@ -277,16 +291,42 @@ EOF exten => pstn_in_denied,1,NoOp(Inbound PSTN call from ${CALLERID(num)} - no ring target authorized for this caller) __ALERT_DENY_INBOUND_LINE__ same => n,Hangup() + +exten => pstn_in_busy,1,NoOp(PSTN trunk - inbound concurrent-call cap reached, rejecting) +__ALERT_BUSY_IN_LINE__ + same => n,Busy(15) + 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" else - sed -i "/__ALERT_DENY_INBOUND_LINE__/d" "$FILE" + sed -i "/__ALERT_DENY_INBOUND_LINE__/d; /__ALERT_BUSY_IN_LINE__/d" "$FILE" fi } +# ── Shared: initial concurrency limits (fresh install / explicit reset only +# — same "update never touches it" protection as pstn-permissions.conf, see +# the file-level comment above) ───────────────────────────────────────────── +_pstn_write_limits_file() { + local FILE="$1" MAX_OUT="$2" MAX_IN="$3" + { + echo "; PSTN concurrent-call caps, both directions." + 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 "" + echo "[limits]" + echo "max_outbound=${MAX_OUT}" + echo "max_inbound=${MAX_IN}" + } > "$FILE" + chmod 664 "$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) ────── @@ -382,15 +422,15 @@ 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" MAX_CONCURRENT="$6" - local RING_EXTS="$7" NTFY_URL="$8" RATE="$9" MONTH_THRESHOLD="${10}" BURST_THRESHOLD="${11}" - local PROVIDER_NAME="${12}" + local SERVER="$3" SERVER_IP="$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_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$MAX_CONCURRENT" "$RING_EXTS" "$NTFY_URL" + _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" @@ -400,7 +440,6 @@ PROVIDER_NAME=${PROVIDER_NAME} TRUNK_SERVER=${SERVER} TRUNK_SERVER_IP=${SERVER_IP} TRUNK_DID=${DID} -MAX_CONCURRENT=${MAX_CONCURRENT} RING_EXTS=${RING_EXTS} NTFY_URL=${NTFY_URL} RATE_PER_MIN=${RATE} @@ -435,6 +474,7 @@ install_pstn-trunk() { local PJSIP_INCLUDE="$ASTERISK_DIR/pstn-trunk-pjsip.conf" local DIALPLAN_INCLUDE="$ASTERISK_DIR/pstn-trunk-dialplan.conf" local PERMISSIONS_FILE="$ASTERISK_DIR/pstn-permissions.conf" + local LIMITS_FILE="$ASTERISK_DIR/pstn-limits.conf" local SETTINGS_FILE="$EA_DIR/.pstn-trunk.env" local CONTAINER_NAME="easy-asterisk" [[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do" @@ -443,14 +483,15 @@ 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] max concurrent calls (default 3), inbound ring-group extensions," + 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 patch vendor generator functions to #include the trunk config" - echo "[DRY-RUN] Would write pjsip/dialplan includes, pstn-permissions.conf (fresh install only)," - echo "[DRY-RUN] and an hourly usage-alert script + cron.d entry" + echo "[DRY-RUN] Would 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 offer 'update in place' (structural settings only — never touches" - echo "[DRY-RUN] pstn-permissions.conf) instead of a fresh install if already configured" + echo "[DRY-RUN] pstn-permissions.conf or pstn-limits.conf) instead of a fresh install if" + echo "[DRY-RUN] already configured" echo "[DRY-RUN] Would restart the asterisk container to apply" return 0 fi @@ -496,14 +537,14 @@ install_pstn-trunk() { # shellcheck disable=SC1090 source "$SETTINGS_FILE" _pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ - "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" "$MAX_CONCURRENT" \ + "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" \ "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 ( cd "$EA_DIR" && docker compose restart asterisk ) \ && log_success "Updated — settings unchanged (server $TRUNK_SERVER, DID $TRUNK_DID, ring exts: $RING_EXTS)." \ || log_warning "Restart failed — check: docker compose -f $EA_DIR/docker-compose.yml logs asterisk" - log_info "pstn-permissions.conf was NOT touched — edit it directly, via the Security" - log_info "Dashboard, or choose FRESH reinstall to reset it." + 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." return 0 else log_warning "No $SETTINGS_FILE found (pre-dates this settings-file version) — falling back to a fresh install (every prompt below)." @@ -514,12 +555,12 @@ install_pstn-trunk() { return 0 ;; fresh) - if [[ -f "$PERMISSIONS_FILE" ]]; then - log_warning "pstn-permissions.conf already exists and may have been edited since" - log_warning "(directly, or via the Security Dashboard). A fresh reinstall OVERWRITES it" - log_warning "with whatever you enter below." + 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." local _confirm_reset="" - prompt_yn "Continue and reset permission tiers? (y/n):" "n" _confirm_reset + prompt_yn "Continue and reset permission tiers + concurrency caps? (y/n):" "n" _confirm_reset if [[ ! "$_confirm_reset" =~ ^[Yy]$ ]]; then log_info "Cancelled — nothing changed." return 0 @@ -594,11 +635,20 @@ install_pstn-trunk() { done fi - local MAX_CONCURRENT="" - prompt_text "Max simultaneous outbound PSTN calls allowed:" "3" MAX_CONCURRENT - if [[ ! "$MAX_CONCURRENT" =~ ^[0-9]+$ ]]; then - log_warning "Not a number — defaulting to 3." - MAX_CONCURRENT=3 + echo "" + echo " Concurrent-call caps (both directions) are also live — changeable later via" + echo " the Security Dashboard or by hand, no restart needed." + local MAX_OUTBOUND="" + prompt_text "Max simultaneous outbound PSTN calls allowed:" "10" MAX_OUTBOUND + if [[ ! "$MAX_OUTBOUND" =~ ^[0-9]+$ ]]; then + log_warning "Not a number — defaulting to 10." + MAX_OUTBOUND=10 + fi + local MAX_INBOUND="" + prompt_text "Max simultaneous inbound PSTN calls allowed:" "10" MAX_INBOUND + if [[ ! "$MAX_INBOUND" =~ ^[0-9]+$ ]]; then + log_warning "Not a number — defaulting to 10." + MAX_INBOUND=10 fi local _suggested_ring @@ -644,11 +694,12 @@ 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" "$MAX_CONCURRENT" \ + "$TRUNK_SERVER" "$TRUNK_SERVER_IP" "$TRUNK_DID" \ "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 _pstn_write_permissions_file "$PERMISSIONS_FILE" "$FULL_EXTS" "${RESTRICTED_ARGS[@]}" + _pstn_write_limits_file "$LIMITS_FILE" "$MAX_OUTBOUND" "$MAX_INBOUND" ensure_docker_dir_ownership "$ASTERISK_DIR" # No new firewall rules: the base install already opens SIP (5060/5061) @@ -678,7 +729,7 @@ background, cost estimate, and toll-fraud reasoning. | Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all | | Full-PSTN extensions | ${FULL_EXTS:-none} | | Restricted-PSTN extensions | ${RESTRICTED_EXTS:-none} | -| Concurrency cap | ${MAX_CONCURRENT} simultaneous outbound calls | +| Concurrency caps | ${MAX_OUTBOUND} outbound / ${MAX_INBOUND} inbound simultaneous calls (live — see \`pstn-limits.conf\` below) | | Inbound ring-group | ${RING_EXTS} | | ntfy alerts | ${NTFY_URL:-disabled} | | Estimated rate | \$${RATE_PER_MIN}/min | @@ -707,6 +758,19 @@ reinstall (with confirmation) or the web UI change it, the same protection CLAUDE.md's update-mode convention gives \`.env\`/firewall/Caddy config elsewhere in this repo. +## Concurrent-call caps + +Two independent caps, one per direction — outbound (\`${MAX_OUTBOUND}\`) and +inbound (\`${MAX_INBOUND}\`), tracked separately (\`GROUP()\`/\`GROUP_COUNT()\` +on \`pstn-out\`/\`pstn-in\`). A cap being hit rejects the *next* call over the +limit with a busy signal (and an ntfy alert, if enabled) — existing calls +are never affected. + +Stored in \`config/asterisk/pstn-limits.conf\`, read **live** the same way as +permission tiers — editable by hand, via the Security Dashboard, with no +restart needed, and likewise untouched by "update in place" (only "fresh" +reinstall or the web UI change it). + ## How this survives Easy Asterisk's own regeneration Easy Asterisk rewrites \`pjsip.conf\` and \`extensions.conf\` from its own @@ -717,10 +781,10 @@ hand-edited files. Trunk/dialplan config here lives in files of its own, - \`config/asterisk/pstn-trunk-pjsip.conf\` — the trunk's \`aor\`/\`identify\`/ \`endpoint\` sections (IP-authenticated, no password stored). - \`config/asterisk/pstn-trunk-dialplan.conf\` — NANP-only outbound routing, - the concurrency cap, ntfy alert hooks, and the \`[from-pstn-trunk]\` inbound - context. Reads permission tiers live from \`pstn-permissions.conf\` (above) - rather than baking them in, specifically so they can change without - touching this file. + ntfy alert hooks, and the \`[from-pstn-trunk]\` inbound context. Reads + permission tiers from \`pstn-permissions.conf\` and concurrency caps from + \`pstn-limits.conf\` (both above) live, rather than baking either in, + specifically so they can change without touching this file. The \`#include\` lines themselves are patched into Easy Asterisk's *generator functions* (\`docker/entrypoint.sh\`, \`easy-asterisk.sh\`, and its versioned @@ -732,7 +796,7 @@ config, instead of being wiped. re-copies fresh vendor files), this patch is wiped along with it. Re-run \`sudo ./setup.sh pstn-trunk\` afterward (update mode reapplies the patch and rewrites structural settings from \`.pstn-trunk.env\`, no re-prompting, and -without touching \`pstn-permissions.conf\`). +without touching \`pstn-permissions.conf\` or \`pstn-limits.conf\`). ## Spend/volume alerts @@ -750,8 +814,8 @@ comma-quoting). It sends an ntfy alert: independent of whether it's crossed the monthly dollar threshold yet. Separately, denied calls (no permission / number not pre-approved) and -rejected calls (concurrency cap hit) alert **immediately**, not on the -hourly schedule. +rejected calls (either concurrency cap hit) alert **immediately**, not on +the hourly schedule. These are cost *estimates* (call count/duration × your entered rate), not real billing data — treat them as a safety net, not a substitute for @@ -760,9 +824,9 @@ checking your provider's own balance/usage dashboard. ## Managing this from a web UI If \`services/security-dashboard.sh\` is installed, its "PSTN Trunk" tab -lists every known extension (parsed from \`pjsip.conf\`) with its current -tier and approved-numbers list, editable live — no restart, no reinstall. -Install/update it any time with \`sudo ./setup.sh security-dashboard\`; it +shows both the per-extension permission tiers and the outbound/inbound +concurrency caps, all editable live — no restart, no reinstall. Install/ +update it any time with \`sudo ./setup.sh security-dashboard\`; it auto-detects this install. ## Manual edits @@ -770,9 +834,10 @@ auto-detects this install. Don't hand-edit \`pstn-trunk-pjsip.conf\` / \`pstn-trunk-dialplan.conf\` / \`pstn-trunk-usage-alert.sh\` directly if you plan to re-run this installer later — it overwrites all three unconditionally from \`.pstn-trunk.env\` on -both fresh and update. \`pstn-permissions.conf\` is different — see -"Permission tiers" above, it's safe to hand-edit any time. For one-off -testing, restart the container instead of running the installer: +both fresh and update. \`pstn-permissions.conf\` and \`pstn-limits.conf\` are +different — see "Permission tiers" / "Concurrent-call caps" above, both are +safe to hand-edit any time. For one-off testing, restart the container +instead of running the installer: \`\`\`bash docker compose -f $EA_DIR/docker-compose.yml restart asterisk @@ -813,7 +878,8 @@ MD log_success "PSTN trunk configured." echo " Provider: $PROVIDER_NAME ($TRUNK_SERVER / $TRUNK_SERVER_IP)" echo " DID: $TRUNK_DID" - echo " Outbound: US/NANP only, max $MAX_CONCURRENT concurrent calls" + echo " Outbound: US/NANP only, max $MAX_OUTBOUND concurrent calls" + echo " Inbound: max $MAX_INBOUND concurrent calls" echo " Full-PSTN extensions: ${FULL_EXTS:-none}" echo " Restricted extensions: ${RESTRICTED_EXTS:-none}" echo " Inbound ring-group: $RING_EXTS" diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index ef85d82..c3d05a6 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -206,12 +206,13 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly. - **Unwhitelist + Ban** does that *and* immediately bans (24h) every IP CrowdSec has ever recorded for that ASN, for accidental-whitelist cases where you don't want to wait for it to misbehave again. -- **PSTN Trunk** (only if \`services/pstn-trunk.sh\` is installed) — every - known extension (parsed from \`pjsip.conf\`) with its current permission - tier (internal / restricted / full) and, for restricted, its approved - numbers, editable live — no Asterisk restart, no reinstall. Writes - directly to \`pstn-permissions.conf\`, which the dialplan reads fresh on - every call. +- **PSTN Trunk** (only if \`services/pstn-trunk.sh\` is 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. - Link to the Asterisk web admin itself (doesn't embed it, just links out). ## Manage @@ -904,6 +905,60 @@ def write_permission(ext, tier, numbers_raw): return True, "Saved" +LIMIT_RE = re.compile(r"^\d+$") + + +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 + file is missing or a key is absent, so a display here is never wrong + even before pstn-limits.conf exists.""" + if not ASTERISK_CONFIG_DIR: + return {"max_outbound": 10, "max_inbound": 10} + path = os.path.join(ASTERISK_CONFIG_DIR, "pstn-limits.conf") + cp = configparser.ConfigParser(delimiters=("=",)) + if os.path.isfile(path): + try: + cp.read(path) + except configparser.Error: + pass + return { + "max_outbound": cp.getint("limits", "max_outbound", fallback=10), + "max_inbound": cp.getint("limits", "max_inbound", fallback=10), + } + + +def write_limits(max_outbound, max_inbound): + if not ASTERISK_CONFIG_DIR: + return False, "No Asterisk install detected on this box" + max_outbound, max_inbound = str(max_outbound).strip(), str(max_inbound).strip() + if not LIMIT_RE.match(max_outbound) or not LIMIT_RE.match(max_inbound): + return False, "Both caps must be whole numbers" + + path = os.path.join(ASTERISK_CONFIG_DIR, "pstn-limits.conf") + tmp_path = path + ".tmp" + try: + with open(tmp_path, "w") as f: + f.write( + "; PSTN concurrent-call caps, both directions.\n" + "; Read LIVE by the dialplan on every call (AST_CONFIG()) - no Asterisk\n" + "; restart needed. Managed here (Security Dashboard); also safe to edit\n" + "; by hand. 'sudo ./setup.sh pstn-trunk' update mode never touches this\n" + "; file, only a fresh reinstall does.\n\n" + "[limits]\n" + "max_outbound=%s\n" + "max_inbound=%s\n" % (max_outbound, max_inbound) + ) + os.replace(tmp_path, path) + except OSError as e: + try: + os.remove(tmp_path) + except OSError: + pass + return False, "Failed writing %s: %s" % (path, e) + return True, "Saved" + + INDEX_HTML = """