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:
@@ -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
|
||||
|
||||
+126
-60
@@ -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"
|
||||
|
||||
@@ -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 = """<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<title>Security Dashboard</title>
|
||||
@@ -965,6 +1020,16 @@ INDEX_HTML = """<!doctype html>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<h3 style="margin-top:0">PSTN permission tiers</h3>
|
||||
<p class="muted">
|
||||
@@ -987,7 +1052,7 @@ document.querySelectorAll(".tab-btn").forEach(btn => {
|
||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
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();
|
||||
}
|
||||
|
||||
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() {
|
||||
const res = await fetch("/api/pstn-permissions");
|
||||
const data = await res.json();
|
||||
@@ -1187,6 +1271,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
extensions.append({"ext": e["ext"], "name": e["name"],
|
||||
"tier": p["tier"], "allowed_numbers": p["allowed_numbers"]})
|
||||
self._json({"extensions": extensions})
|
||||
elif self.path == "/api/pstn-limits":
|
||||
self._json(get_limits())
|
||||
else:
|
||||
self._json({"error": "not found"}, 404)
|
||||
|
||||
@@ -1211,6 +1297,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
payload.get("ext", ""), payload.get("tier", ""), payload.get("allowed_numbers", "")
|
||||
)
|
||||
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:
|
||||
self._json({"error": "not found"}, 404)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user