Add inbound concurrency cap, bump defaults to 10/10, make caps live-editable

Adds an inbound concurrent-call cap mirroring the existing outbound one -
outbound alone didn't protect against an inbound call-flood, which also
costs money per-minute on VoIP.ms. Both defaults bumped from 3 to 10.

Moves the cap numbers themselves out of static dialplan text and into a new
pstn-limits.conf, read live via AST_CONFIG() the same way permission tiers
already are - changing either cap takes effect on the next call, no
Asterisk restart, no reinstall. "update in place" never touches this file,
matching the existing protection for pstn-permissions.conf/.env/firewall/
Caddy config.

Adds a concurrency-caps card to security-dashboard.sh's "PSTN Trunk" tab,
above the existing permissions table, so both caps are visible and editable
from the same web page. Tested against a real running instance of the
Python app: default fallback when the file doesn't exist yet, save/persist,
invalid-input rejection, and a bash-to-Python round trip on the generated
file format.

Inbound dialplan ordering mirrors outbound's existing pattern: permission
check (is any ring-group member authorized for this caller) before the
concurrency check, consistent with outbound's tier-check-then-busy-check
order.
This commit is contained in:
Claude
2026-07-22 00:49:41 +00:00
parent 3bd952e55d
commit 876fd6553b
3 changed files with 267 additions and 97 deletions
+45 -30
View File
@@ -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: hardcoded to it — any provider supporting IP authentication works. Covers:
- IP-authenticated trunk, US/NANP-only outbound dialplan, no catch-all. - 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 - **Three-tier per-extension permission model**: `internal` (default — no
PSTN at all, but can always call/receive other extensions and internal PSTN at all, but can always call/receive other extensions and internal
ring groups), `restricted` (also only pre-approved US numbers, both ring groups), `restricted` (also only pre-approved US numbers, both
directions), `full` (also any US number). Internal extension-to-extension directions), `full` (also any US number). Internal extension-to-extension
dialing is *never* gated by any tier. dialing is *never* gated by any tier — deliberately, even though VoIP.ms
- **Permissions are live, not baked into the dialplan.** Stored in itself offers free SIP-to-SIP calling, to avoid routing purely-internal
`pstn-permissions.conf`, read by the dialplan via Asterisk's calls through an extra external hop for no benefit.
`AST_CONFIG()` on every call — editing that file takes effect on the next - **Permissions AND concurrency caps are both live, not baked into the
call, no restart, no reinstall. `services/pstn-trunk.sh`'s "update in dialplan.** Stored in `pstn-permissions.conf` / `pstn-limits.conf`, read
place" mode deliberately never touches it (same protection this repo's by the dialplan via Asterisk's `AST_CONFIG()` on every call — editing
update-mode convention already gives `.env`/firewall/Caddy config 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 elsewhere) — only a "fresh" reinstall (with confirmation) or the web UI
below change it. below change it.
- A configurable **inbound ring-group** (one extension or several), each - A configurable **inbound ring-group** (one extension or several), each
member's live tier/approved-numbers checked per inbound call via an member's live tier/approved-numbers checked per inbound call via an
unrolled per-member dialplan block (no AGI needed). unrolled per-member dialplan block (no AGI needed).
- **`services/security-dashboard.sh` integration** — a "PSTN Trunk" tab - **`services/security-dashboard.sh` integration** — a "PSTN Trunk" tab
lists every extension (parsed from `pjsip.conf`) with its live tier and shows both concurrency caps and every extension (parsed from
approved numbers, editable with no restart. This is what makes the tier `pjsip.conf`) with its live tier and approved numbers, all editable with
model actually manageable day-to-day instead of needing a reinstall for no restart. This is what makes the tier model and caps actually
every roster change. manageable day-to-day instead of needing a reinstall for every change.
- **ntfy alerts** on denied/rejected calls (immediate — permission denied, - **ntfy alerts** on denied/rejected calls (immediate — permission denied,
number not approved, or concurrency cap hit) and spend/volume thresholds number not approved, or either concurrency cap hit) and spend/volume
(hourly check: once/month on a spend threshold, every hour on a call-burst thresholds (hourly check: once/month on a spend threshold, every hour on
threshold). a call-burst threshold).
- Structural settings (server, DID, ring-group membership, cap, ntfy, - Structural settings (server, DID, ring-group *membership*, ntfy,
rate/thresholds) persist to `.pstn-trunk.env` so "update in place" 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 `services/pstn-trunk.sh`'s own header comment explains how the trunk/dialplan
config survives Easy Asterisk's regeneration, and why permissions are a 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 the concurrent-call cap and spend/volume alert below as required before
funding a live trunk, not optional hardening. funding a live trunk, not optional hardening.
**Implemented:** the concurrent-call cap in `services/pstn-trunk.sh` is a **Implemented:** the concurrent-call caps in `services/pstn-trunk.sh` are
*global* cap (configurable, default max 3 outbound legs total via the trunk, *global* per direction (default 10 outbound / 10 inbound, each tracked via
via `GROUP()`/`GROUP_COUNT()` in the dialplan, shared across all extensions) its own `GROUP()`/`GROUP_COUNT()` in the dialplan, shared across all
— not per-extension. That was the explicit ask when this got built. The extensions) — not per-extension. Inbound didn't have a cap at all until
spend/volume alert is also implemented now: an hourly cron script reads a 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 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 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 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 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` ($15 minimum for VoIP.ms), turn off auto-recharge. `services/pstn-trunk.sh`
prompts for the server hostname, DID, allowed extensions, ring extensions, 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: - Defense-in-depth alongside the trunk:
- **Implemented:** a global concurrent-call cap in the dialplan - **Implemented:** independent outbound/inbound concurrent-call caps in
(`GROUP()`/`GROUP_COUNT()`, configurable, default max 3 outbound legs via the dialplan (`GROUP()`/`GROUP_COUNT()`, default 10/10) so an
the trunk at once) so an unauthorized or compromised extension can't unauthorized or compromised extension can't open dozens of simultaneous
open dozens of simultaneous outbound legs. Global, not per-extension — legs in either direction. Global per direction, not per-extension —
see the note above. see the note above.
- **Implemented:** an outbound call-count/spend alert via ntfy — a - **Implemented:** an outbound call-count/spend alert via ntfy — a
self-contained call log (not Asterisk's CDR) plus an hourly cron script. 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 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, vs. unlimited DID plan on VoIP.ms's side based on real expected volume,
and decide on E911 (see cost estimate). and decide on E911 (see cost estimate).
5. ~~Concurrent-call cap~~ Done — configurable, default 3, global not 5. ~~Concurrent-call cap~~ Done — both directions now (inbound was a real
per-extension. ~~Spend/volume alert~~ Done — ntfy, hourly threshold + gap, since it also costs money per-minute and outbound's cap doesn't
burst check, plus immediate alerts on denied/rejected calls. 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 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 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 calls matches what `services/pstn-trunk.sh` resolved via DNS at install
+126 -60
View File
@@ -1,11 +1,15 @@
#!/bin/bash #!/bin/bash
# services/pstn-trunk.sh — SIP PSTN trunk add-on for asterisk-digital-ocean # services/pstn-trunk.sh — SIP PSTN trunk add-on for asterisk-digital-ocean
# (or the home/LAN asterisk install): US-only outbound (NANP dialplan # (or the home/LAN asterisk install): US-only outbound (NANP dialplan
# restriction), a configurable concurrent-call cap, a 3-tier permission model # restriction), independent outbound/inbound concurrent-call caps, a 3-tier
# per extension (internal-only / restricted to pre-approved numbers / full US # permission model per extension (internal-only / restricted to pre-approved
# calling), a configurable inbound ring-group, IP-authenticated trunk (no SIP # numbers / full US calling), a configurable inbound ring-group,
# password stored), ntfy alerts on denied/rejected calls, and a periodic # IP-authenticated trunk (no SIP password stored), ntfy alerts on
# spend/volume check. # 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/ # 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 # 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 # 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 # 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 # alone. Permission tiers AND concurrency caps are managed live (no restart
# pstn-permissions.conf — editable by hand, or from services/security-dashboard.sh's # needed) via pstn-permissions.conf / pstn-limits.conf — editable by hand, or
# "PSTN Trunk" tab if that's installed. # from services/security-dashboard.sh's "PSTN Trunk" tab if that's installed.
# #
# Part of the modular post-install system (sourced by setup.sh). # 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 # CDR CSV's comma-quoting entirely (our own pipe-delimited format has no
# embedded-delimiter risk since every field here is digits/hostnames). # embedded-delimiter risk since every field here is digits/hostnames).
_pstn_write_dialplan_include() { _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' cat > "$FILE" << 'EOF'
; PSTN outbound/inbound — US-only (NANP), concurrent-call cap, tiered ; PSTN outbound/inbound — US-only (NANP). Concurrent-call caps (both
; permissions (internal / restricted / full) read LIVE from ; directions) AND tiered permissions (internal / restricted / full) are read
; pstn-permissions.conf via AST_CONFIG() — edit permissions there, or via the ; LIVE from pstn-limits.conf / pstn-permissions.conf via AST_CONFIG() — edit
; Security Dashboard web UI, with no restart needed. Regenerated by ; either there, or via the Security Dashboard web UI, with no restart
; services/pstn-trunk.sh — edit there, not here directly, or a ; needed. Regenerated by services/pstn-trunk.sh — edit there, not here
; reinstall/update will overwrite this file (pstn-permissions.conf itself is ; directly, or a reinstall/update will overwrite this file (pstn-limits.conf
; NOT touched by "update", only by a "fresh" reinstall or the web UI). ; 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 ; 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 ; 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,Busy(15)
same => n,Hangup() 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(GROUP()=pstn-out)
same => n,Set(CALLERID(num)=__PSTN_DID__) same => n,Set(CALLERID(num)=__PSTN_DID__)
same => n,Set(PSTN_START=${EPOCH}) 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,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() 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__ __ALERT_BUSY_LINE__
same => n,Busy(15) same => n,Busy(15)
same => n,Hangup() same => n,Hangup()
EOF 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 if [[ -n "$NTFY_URL" ]]; then
local _esc_url="${NTFY_URL//&/\\&}" 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_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_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 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_NUMBER_LINE__/d; /__ALERT_BUSY_LINE__/d" "$FILE"
fi 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' cat >> "$FILE" << 'EOF'
[from-pstn-trunk] [from-pstn-trunk]
@@ -268,6 +278,10 @@ EOF
cat >> "$FILE" << 'EOF' cat >> "$FILE" << 'EOF'
same => n,GotoIf($["${PSTN_RING_LIST}" = ""]?pstn_in_denied,1) 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,Set(PSTN_START=${EPOCH})
same => n,Dial(${PSTN_RING_LIST},20) same => n,Dial(${PSTN_RING_LIST},20)
same => n,Set(PSTN_DUR=$[${EPOCH} - ${PSTN_START}]) 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) exten => pstn_in_denied,1,NoOp(Inbound PSTN call from ${CALLERID(num)} - no ring target authorized for this caller)
__ALERT_DENY_INBOUND_LINE__ __ALERT_DENY_INBOUND_LINE__
same => n,Hangup() 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 EOF
if [[ -n "$NTFY_URL" ]]; then if [[ -n "$NTFY_URL" ]]; then
local _esc_url2="${NTFY_URL//&/\\&}" 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_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 else
sed -i "/__ALERT_DENY_INBOUND_LINE__/d" "$FILE" sed -i "/__ALERT_DENY_INBOUND_LINE__/d; /__ALERT_BUSY_IN_LINE__/d" "$FILE"
fi 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 — # ── Shared: initial permission tiers (fresh install / explicit reset only —
# "update in place" never calls this, matching how .env/firewall/Caddy config # "update in place" never calls this, matching how .env/firewall/Caddy config
# are protected elsewhere in this repo; see file-level comment above) ────── # are protected elsewhere in this repo; see file-level comment above) ──────
@@ -382,15 +422,15 @@ EOF
# for why that file is managed separately. # for why that file is managed separately.
_pstn_apply_settings() { _pstn_apply_settings() {
local EA_DIR="$1" ASTERISK_DIR="$2" local EA_DIR="$1" ASTERISK_DIR="$2"
local SERVER="$3" SERVER_IP="$4" DID="$5" MAX_CONCURRENT="$6" local SERVER="$3" SERVER_IP="$4" DID="$5"
local RING_EXTS="$7" NTFY_URL="$8" RATE="$9" MONTH_THRESHOLD="${10}" BURST_THRESHOLD="${11}" local RING_EXTS="$6" NTFY_URL="$7" RATE="$8" MONTH_THRESHOLD="$9" BURST_THRESHOLD="${10}"
local PROVIDER_NAME="${12}" local PROVIDER_NAME="${11}"
_pstn_patch_vendor_files "$EA_DIR" || return 1 _pstn_patch_vendor_files "$EA_DIR" || return 1
mkdir -p "$ASTERISK_DIR" 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_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" _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" ensure_docker_dir_ownership "$ASTERISK_DIR"
chmod 644 "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$ASTERISK_DIR/pstn-trunk-dialplan.conf" 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=${SERVER}
TRUNK_SERVER_IP=${SERVER_IP} TRUNK_SERVER_IP=${SERVER_IP}
TRUNK_DID=${DID} TRUNK_DID=${DID}
MAX_CONCURRENT=${MAX_CONCURRENT}
RING_EXTS=${RING_EXTS} RING_EXTS=${RING_EXTS}
NTFY_URL=${NTFY_URL} NTFY_URL=${NTFY_URL}
RATE_PER_MIN=${RATE} RATE_PER_MIN=${RATE}
@@ -435,6 +474,7 @@ install_pstn-trunk() {
local PJSIP_INCLUDE="$ASTERISK_DIR/pstn-trunk-pjsip.conf" local PJSIP_INCLUDE="$ASTERISK_DIR/pstn-trunk-pjsip.conf"
local DIALPLAN_INCLUDE="$ASTERISK_DIR/pstn-trunk-dialplan.conf" local DIALPLAN_INCLUDE="$ASTERISK_DIR/pstn-trunk-dialplan.conf"
local PERMISSIONS_FILE="$ASTERISK_DIR/pstn-permissions.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 SETTINGS_FILE="$EA_DIR/.pstn-trunk.env"
local CONTAINER_NAME="easy-asterisk" local CONTAINER_NAME="easy-asterisk"
[[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do" [[ "$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 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] 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] 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] 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 for inbound call matching"
echo "[DRY-RUN] Would patch vendor generator functions to #include the trunk config" 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] Would write pjsip/dialplan includes, pstn-permissions.conf + pstn-limits.conf"
echo "[DRY-RUN] and an hourly usage-alert script + cron.d entry" 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] 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" echo "[DRY-RUN] Would restart the asterisk container to apply"
return 0 return 0
fi fi
@@ -496,14 +537,14 @@ install_pstn-trunk() {
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "$SETTINGS_FILE" source "$SETTINGS_FILE"
_pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ _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" \ "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \
"$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1
( cd "$EA_DIR" && docker compose restart asterisk ) \ ( cd "$EA_DIR" && docker compose restart asterisk ) \
&& log_success "Updated — settings unchanged (server $TRUNK_SERVER, DID $TRUNK_DID, ring exts: $RING_EXTS)." \ && 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_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 "pstn-permissions.conf and pstn-limits.conf were NOT touched — edit them"
log_info "Dashboard, or choose FRESH reinstall to reset it." log_info "directly, via the Security Dashboard, or choose FRESH reinstall to reset them."
return 0 return 0
else else
log_warning "No $SETTINGS_FILE found (pre-dates this settings-file version) — falling back to a fresh install (every prompt below)." 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 return 0
;; ;;
fresh) fresh)
if [[ -f "$PERMISSIONS_FILE" ]]; then if [[ -f "$PERMISSIONS_FILE" || -f "$LIMITS_FILE" ]]; then
log_warning "pstn-permissions.conf already exists and may have been edited since" log_warning "pstn-permissions.conf and/or pstn-limits.conf already exist and may have"
log_warning "(directly, or via the Security Dashboard). A fresh reinstall OVERWRITES it" log_warning "been edited since (directly, or via the Security Dashboard). A fresh"
log_warning "with whatever you enter below." log_warning "reinstall OVERWRITES both with whatever you enter below."
local _confirm_reset="" 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 if [[ ! "$_confirm_reset" =~ ^[Yy]$ ]]; then
log_info "Cancelled — nothing changed." log_info "Cancelled — nothing changed."
return 0 return 0
@@ -594,11 +635,20 @@ install_pstn-trunk() {
done done
fi fi
local MAX_CONCURRENT="" echo ""
prompt_text "Max simultaneous outbound PSTN calls allowed:" "3" MAX_CONCURRENT echo " Concurrent-call caps (both directions) are also live — changeable later via"
if [[ ! "$MAX_CONCURRENT" =~ ^[0-9]+$ ]]; then echo " the Security Dashboard or by hand, no restart needed."
log_warning "Not a number — defaulting to 3." local MAX_OUTBOUND=""
MAX_CONCURRENT=3 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 fi
local _suggested_ring 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 prompt_text " Alert if more than this many outbound calls happen in one hour:" "10" BURST_THRESHOLD
_pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \ _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" \ "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \
"$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1 "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1
_pstn_write_permissions_file "$PERMISSIONS_FILE" "$FULL_EXTS" "${RESTRICTED_ARGS[@]}" _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" ensure_docker_dir_ownership "$ASTERISK_DIR"
# No new firewall rules: the base install already opens SIP (5060/5061) # 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 | | Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all |
| Full-PSTN extensions | ${FULL_EXTS:-none} | | Full-PSTN extensions | ${FULL_EXTS:-none} |
| Restricted-PSTN extensions | ${RESTRICTED_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} | | Inbound ring-group | ${RING_EXTS} |
| ntfy alerts | ${NTFY_URL:-disabled} | | ntfy alerts | ${NTFY_URL:-disabled} |
| Estimated rate | \$${RATE_PER_MIN}/min | | 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 CLAUDE.md's update-mode convention gives \`.env\`/firewall/Caddy config
elsewhere in this repo. 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 ## How this survives Easy Asterisk's own regeneration
Easy Asterisk rewrites \`pjsip.conf\` and \`extensions.conf\` from its own 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\`/ - \`config/asterisk/pstn-trunk-pjsip.conf\` — the trunk's \`aor\`/\`identify\`/
\`endpoint\` sections (IP-authenticated, no password stored). \`endpoint\` sections (IP-authenticated, no password stored).
- \`config/asterisk/pstn-trunk-dialplan.conf\` — NANP-only outbound routing, - \`config/asterisk/pstn-trunk-dialplan.conf\` — NANP-only outbound routing,
the concurrency cap, ntfy alert hooks, and the \`[from-pstn-trunk]\` inbound ntfy alert hooks, and the \`[from-pstn-trunk]\` inbound context. Reads
context. Reads permission tiers live from \`pstn-permissions.conf\` (above) permission tiers from \`pstn-permissions.conf\` and concurrency caps from
rather than baking them in, specifically so they can change without \`pstn-limits.conf\` (both above) live, rather than baking either in,
touching this file. specifically so they can change without touching this file.
The \`#include\` lines themselves are patched into Easy Asterisk's *generator The \`#include\` lines themselves are patched into Easy Asterisk's *generator
functions* (\`docker/entrypoint.sh\`, \`easy-asterisk.sh\`, and its versioned 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 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 \`sudo ./setup.sh pstn-trunk\` afterward (update mode reapplies the patch and
rewrites structural settings from \`.pstn-trunk.env\`, no re-prompting, 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 ## 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. independent of whether it's crossed the monthly dollar threshold yet.
Separately, denied calls (no permission / number not pre-approved) and Separately, denied calls (no permission / number not pre-approved) and
rejected calls (concurrency cap hit) alert **immediately**, not on the rejected calls (either concurrency cap hit) alert **immediately**, not on
hourly schedule. the hourly schedule.
These are cost *estimates* (call count/duration × your entered rate), not These are cost *estimates* (call count/duration × your entered rate), not
real billing data — treat them as a safety net, not a substitute for 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 ## Managing this from a web UI
If \`services/security-dashboard.sh\` is installed, its "PSTN Trunk" tab If \`services/security-dashboard.sh\` is installed, its "PSTN Trunk" tab
lists every known extension (parsed from \`pjsip.conf\`) with its current shows both the per-extension permission tiers and the outbound/inbound
tier and approved-numbers list, editable live — no restart, no reinstall. concurrency caps, all editable live — no restart, no reinstall. Install/
Install/update it any time with \`sudo ./setup.sh security-dashboard\`; it update it any time with \`sudo ./setup.sh security-dashboard\`; it
auto-detects this install. auto-detects this install.
## Manual edits ## Manual edits
@@ -770,9 +834,10 @@ auto-detects this install.
Don't hand-edit \`pstn-trunk-pjsip.conf\` / \`pstn-trunk-dialplan.conf\` / 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 \`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 later — it overwrites all three unconditionally from \`.pstn-trunk.env\` on
both fresh and update. \`pstn-permissions.conf\` is different — see both fresh and update. \`pstn-permissions.conf\` and \`pstn-limits.conf\` are
"Permission tiers" above, it's safe to hand-edit any time. For one-off different — see "Permission tiers" / "Concurrent-call caps" above, both are
testing, restart the container instead of running the installer: safe to hand-edit any time. For one-off testing, restart the container
instead of running the installer:
\`\`\`bash \`\`\`bash
docker compose -f $EA_DIR/docker-compose.yml restart asterisk docker compose -f $EA_DIR/docker-compose.yml restart asterisk
@@ -813,7 +878,8 @@ MD
log_success "PSTN trunk configured." log_success "PSTN trunk configured."
echo " Provider: $PROVIDER_NAME ($TRUNK_SERVER / $TRUNK_SERVER_IP)" echo " Provider: $PROVIDER_NAME ($TRUNK_SERVER / $TRUNK_SERVER_IP)"
echo " DID: $TRUNK_DID" 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 " Full-PSTN extensions: ${FULL_EXTS:-none}"
echo " Restricted extensions: ${RESTRICTED_EXTS:-none}" echo " Restricted extensions: ${RESTRICTED_EXTS:-none}"
echo " Inbound ring-group: $RING_EXTS" echo " Inbound ring-group: $RING_EXTS"
+96 -7
View File
@@ -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 - **Unwhitelist + Ban** does that *and* immediately bans (24h) every IP
CrowdSec has ever recorded for that ASN, for accidental-whitelist cases CrowdSec has ever recorded for that ASN, for accidental-whitelist cases
where you don't want to wait for it to misbehave again. where you don't want to wait for it to misbehave again.
- **PSTN Trunk** (only if \`services/pstn-trunk.sh\` is installed) — every - **PSTN Trunk** (only if \`services/pstn-trunk.sh\` is installed) — the
known extension (parsed from \`pjsip.conf\`) with its current permission outbound/inbound concurrent-call caps, and every known extension (parsed
tier (internal / restricted / full) and, for restricted, its approved from \`pjsip.conf\`) with its current permission tier (internal /
numbers, editable live — no Asterisk restart, no reinstall. Writes restricted / full) and, for restricted, its approved numbers — all
directly to \`pstn-permissions.conf\`, which the dialplan reads fresh on editable live, no Asterisk restart, no reinstall. Writes directly to
every call. \`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). - Link to the Asterisk web admin itself (doesn't embed it, just links out).
## Manage ## Manage
@@ -904,6 +905,60 @@ def write_permission(ext, tier, numbers_raw):
return True, "Saved" 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 = """<!doctype html> INDEX_HTML = """<!doctype html>
<html><head><meta charset="utf-8"> <html><head><meta charset="utf-8">
<title>Security Dashboard</title> <title>Security Dashboard</title>
@@ -965,6 +1020,16 @@ INDEX_HTML = """<!doctype html>
</div> </div>
</div> </div>
<div id="tab-pstn" style="display:none"> <div id="tab-pstn" style="display:none">
<div class="card">
<h3 style="margin-top:0">Concurrent-call caps</h3>
<p class="muted">A call over either cap gets a busy signal (and an ntfy alert, if enabled) — existing calls are never affected. Changes apply live, on the next call.</p>
<div class="row">
<label class="muted" style="white-space:nowrap">Max outbound<br><input type="text" id="limit-out" style="width:5rem"></label>
<label class="muted" style="white-space:nowrap">Max inbound<br><input type="text" id="limit-in" style="width:5rem"></label>
<button class="action" id="limits-save" style="align-self:flex-end">Save</button>
</div>
<div id="limits-msg" class="muted" style="margin-top:0.5rem"></div>
</div>
<div class="card"> <div class="card">
<h3 style="margin-top:0">PSTN permission tiers</h3> <h3 style="margin-top:0">PSTN permission tiers</h3>
<p class="muted"> <p class="muted">
@@ -987,7 +1052,7 @@ document.querySelectorAll(".tab-btn").forEach(btn => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active"); btn.classList.add("active");
TABS.forEach(t => { document.getElementById("tab-" + t).style.display = btn.dataset.tab === t ? "" : "none"; }); TABS.forEach(t => { document.getElementById("tab-" + t).style.display = btn.dataset.tab === t ? "" : "none"; });
if (btn.dataset.tab === "pstn") loadPstnPermissions(); if (btn.dataset.tab === "pstn") { loadPstnLimits(); loadPstnPermissions(); }
}); });
}); });
@@ -1088,6 +1153,25 @@ async function banAsn(asn) {
loadDecisions(); loadDecisions();
} }
async function loadPstnLimits() {
const res = await fetch("/api/pstn-limits");
const data = await res.json();
document.getElementById("limit-out").value = data.max_outbound;
document.getElementById("limit-in").value = data.max_inbound;
}
document.getElementById("limits-save").addEventListener("click", async () => {
const maxOut = document.getElementById("limit-out").value;
const maxIn = document.getElementById("limit-in").value;
const res = await fetch("/api/pstn-limits", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({max_outbound: maxOut, max_inbound: maxIn}),
});
const data = await res.json();
document.getElementById("limits-msg").textContent = data.message || (data.ok ? "Saved" : "Failed");
loadPstnLimits();
});
async function loadPstnPermissions() { async function loadPstnPermissions() {
const res = await fetch("/api/pstn-permissions"); const res = await fetch("/api/pstn-permissions");
const data = await res.json(); const data = await res.json();
@@ -1187,6 +1271,8 @@ class Handler(BaseHTTPRequestHandler):
extensions.append({"ext": e["ext"], "name": e["name"], extensions.append({"ext": e["ext"], "name": e["name"],
"tier": p["tier"], "allowed_numbers": p["allowed_numbers"]}) "tier": p["tier"], "allowed_numbers": p["allowed_numbers"]})
self._json({"extensions": extensions}) self._json({"extensions": extensions})
elif self.path == "/api/pstn-limits":
self._json(get_limits())
else: else:
self._json({"error": "not found"}, 404) self._json({"error": "not found"}, 404)
@@ -1211,6 +1297,9 @@ class Handler(BaseHTTPRequestHandler):
payload.get("ext", ""), payload.get("tier", ""), payload.get("allowed_numbers", "") payload.get("ext", ""), payload.get("tier", ""), payload.get("allowed_numbers", "")
) )
self._json({"ok": ok, "message": message}) self._json({"ok": ok, "message": message})
elif self.path == "/api/pstn-limits":
ok, message = write_limits(payload.get("max_outbound", ""), payload.get("max_inbound", ""))
self._json({"ok": ok, "message": message})
else: else:
self._json({"error": "not found"}, 404) self._json({"error": "not found"}, 404)