Add spend-cap kill-switch, international-calling toggle, per-minute checks

- Replace the hourly cron.d spend/volume check with a per-minute systemd
  timer (cron.d fallback), since it's now also the kill-switch's
  enforcement point and the interval is the exposure window.
- Add a hard monthly spend-cap kill-switch: trips pstn-trunk-killswitch.conf
  (read live by the dialplan, both call directions, internal calling
  unaffected) once estimated spend reaches an admin-set cap, plus a loud
  ntfy warning at 80% of the cap before it trips. Does not auto-reset;
  clearing it is a CLI-only action (update mode) so a compromised/careless
  web session can't quietly re-enable spend after a trip.
- Add a CLI-only (never web-dashboard) international-calling allow-list:
  continent/country menu, always asked every run with the resulting
  allow-list printed exactly once right after, optional auto-expiry with
  day-of and at-expiry ntfy notices, and active re-blocking once expired.
  Dialplan gates the US "011" prefix pattern on it for full-tier extensions.
- Add an independent messaging_enabled flag per extension in
  pstn-permissions.conf for Asterisk's native SIP MESSAGE texting, since
  the risk profile (no cost) differs from PSTN calling permissions. The
  dialplan wiring to enforce it is flagged as a known gap pending live
  verification against Easy Asterisk's message routing, rather than
  shipping a guessed pattern.
- security-dashboard.sh: detect whether pstn-trunk is actually installed
  (not just base Asterisk) and show a clear "not installed" state instead
  of misleading default caps/extensions; add clickable column-header
  sorting to the CrowdSec active-bans table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf
This commit is contained in:
Claude
2026-07-22 14:51:19 +00:00
parent 26f3f08304
commit 9b950808cc
3 changed files with 806 additions and 78 deletions
+64 -2
View File
@@ -342,8 +342,10 @@ generator output. Fixed by quoting every value in that heredoc.
5. ~~Concurrent-call cap~~ Done — both directions now (inbound was a real
gap, since it also costs money per-minute and outbound's cap doesn't
cover it), default 10/10, global not per-extension, live-editable via
`pstn-limits.conf`/web UI. ~~Spend/volume alert~~ Done — ntfy, hourly
threshold + burst check, plus immediate alerts on denied/rejected calls.
`pstn-limits.conf`/web UI. ~~Spend/volume alert~~ Done — ntfy, monthly
threshold + hourly burst check, run **every minute** (systemd timer, not
hourly cron — see item 8), plus immediate alerts on denied/rejected
calls.
6. Verify against a live VoIP.ms account (still not done — only their wiki
+ ToS text has been read, see "Decision so far" above for what that
turned up): confirm new outbound calls actually get blocked at $0
@@ -362,3 +364,63 @@ generator output. Fixed by quoting every value in that heredoc.
maximum call duration. Worth setting on any Anveo Direct account as a
provider-side backstop independent of this repo's own dialplan code —
not automatable from here since it's their web UI, not a config file.
8. ~~Spend-cap kill-switch~~ Done — a genuinely hard stop, not just an
alert: once `pstn-trunk-usage-alert.sh` estimates month-to-date spend
has reached an admin-set cap (prompted at install/update, `0` = disabled),
it writes `tripped=1` to `pstn-trunk-killswitch.conf`, read live by the
dialplan on *every* PSTN call attempt (both directions — internal
Asterisk-to-Asterisk calling is untouched) and blocked immediately with a
loud (`Priority: urgent`) ntfy alert. A separate loud warning fires once
spend reaches 80% of the cap, before it trips. **Does not auto-reset**
requires manually clearing it via the CLI installer (update mode), by
design, so a compromised/careless web session can't quietly re-enable
spend after a trip. Honesty caveat carried over from the original spend
estimate: this is estimate-based (call count/duration × an entered
rate), not real billing data, and only as fresh as the last check — now
every minute (see item 9) rather than hourly, shrinking but not
eliminating the reaction-time gap between an overage happening and
calling actually getting blocked.
9. ~~Hourly cron → per-minute systemd timer~~ Done —
`pstn-trunk-usage.timer`/`.service` (falls back to a cron.d entry if
systemd isn't available), running the same usage-alert script every
minute instead of hourly. Directly motivated by item 8: the periodic
check is now also the kill-switch's enforcement point, so the interval
between checks is the exposure window, and a tighter interval shrinks it.
10. ~~International calling (beyond NANP/US)~~ Done — CLI-only (never the
Security Dashboard web UI, on purpose: this widens which countries can
be dialed/billed to at all, a more security-sensitive control than
who's already allowed to use an already-fixed scope), continent →
country menu (`_pstn_manage_international` in `services/pstn-trunk.sh`),
always asked every run with no way to skip the *question* itself
(though answering "no" leaves the existing allow-list untouched), with
the resulting allow-list printed exactly once right after — not
repeated during the spend-cap prompts, a correction from an earlier
draft of this design. Optional auto-expiry with two ntfy notices (day
of, and at the moment of expiry) and active re-blocking (the periodic
script clears the allow-list once past `expires`, it doesn't just
notify). Dialing uses the US `011` prefix convention (`_011X.` dialplan
pattern); allowed country codes are the REGEX() *pattern* side (admin-
controlled), dialed digits are always the *string* being tested — same
safe direction as every other permission check in this file. Only
`full`-tier extensions can use it regardless of which countries are
allowed.
11. Internal SIP `MESSAGE` (native Asterisk texting, no carrier SMS/cost) —
**partially done**. The permission layer is real and live-editable: a
`messaging=yes` flag per extension in `pstn-permissions.conf`,
independent of the PSTN calling tiers (an extension can be
internal-tier for calling and still messaging-enabled, or vice versa),
prompted at install time. **Not done**: the actual dialplan wiring that
would make Asterisk *enforce* this flag on inbound `MESSAGE` requests.
Reasoned through but deliberately not shipped: Easy Asterisk dispatches
messages through the same `[intercom]` context calls use (no
`message_context` override), and whether a hand-written pattern there
would take precedence over — or conflict with — Easy Asterisk's own
generated per-device dial patterns in that same context isn't something
that can be safely determined without a live install to test against.
Shipping a guessed pattern risked either silently not working or, worse,
interfering with call-routing precedence for the same extensions.
Treat this the same way as the VoIP.ms live-account verification in
item 6 above: a real gap, flagged rather than papered over, not a
hypothetical. Next step for whoever picks this up: verify message
routing behavior against a live Easy Asterisk container, then wire the
dialplan gate using the existing flag.
+642 -68
View File
@@ -232,6 +232,8 @@ _pstn_write_dialplan_include() {
; since "full" means "any US number," not "any NANP-shaped number."
exten => _1NXXNXXXXX,1,NoOp(PSTN outbound call attempt from ${CHANNEL(peername)} to ${EXTEN})
same => n,Set(PSTN_KILLED=${AST_CONFIG(pstn-trunk-killswitch.conf,state,tripped)})
same => n,GotoIf($["${PSTN_KILLED}" = "1"]?pstn_killed,1)
same => n,Set(PSTN_AREA_CODE=${EXTEN:1:3})
same => n,GotoIf($[${REGEX("^(242|246|264|268|284|340|345|441|473|649|658|664|670|671|684|721|758|767|784|787|809|829|849|868|869|876|939)$" ${PSTN_AREA_CODE})} = 1]?pstn_intl_blocked,1)
same => n,Set(PSTN_CALLER=${CHANNEL(peername)})
@@ -251,6 +253,45 @@ __ALERT_DENY_INTL_LINE__
same => n,Busy(15)
same => n,Hangup()
exten => pstn_killed,1,NoOp(PSTN trunk - spend-cap kill-switch is tripped, rejecting outbound call)
__ALERT_KILLED_LINE__
same => n,Busy(15)
same => n,Hangup()
; International (non-NANP) dialing — US "011" prefix convention. Gated on
; BOTH full tier (same as domestic) AND the live international allow-list
; (pstn-intl-allowed.conf), managed ONLY via the CLI installer, never the
; Security Dashboard web UI — see _pstn_run_international_step. The
; allow-list holds admin-entered country calling codes (the PATTERN side of
; the REGEX() below); the caller-dialed digits are always the STRING being
; tested, never the reverse, same safe direction as every other permission
; check in this file. Reuses pstn_check_busy for the actual dial once a
; country check passes — same concurrency cap and call-log accounting as
; domestic calls (the estimated per-minute RATE assumed there is a US rate
; and will under/over-estimate true international cost; treat the spend
; figures as even less precise for international minutes than domestic).
exten => _011X.,1,NoOp(PSTN international outbound call attempt from ${CHANNEL(peername)} to ${EXTEN})
same => n,Set(PSTN_KILLED=${AST_CONFIG(pstn-trunk-killswitch.conf,state,tripped)})
same => n,GotoIf($["${PSTN_KILLED}" = "1"]?pstn_killed,1)
same => n,Set(PSTN_CALLER=${CHANNEL(peername)})
same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier)})
same => n,GotoIf($["${PSTN_TIER}" = "full"]?pstn_intl_check_country,1)
same => n,NoOp(Denied intl - ${PSTN_CALLER} tier ${PSTN_TIER} not eligible for international calling)
__ALERT_DENY_INTL_TIER_LINE__
same => n,Busy(15)
same => n,Hangup()
exten => pstn_intl_check_country,1,Set(PSTN_INTL_ALLOWED=${AST_CONFIG(pstn-intl-allowed.conf,countries,allowed_codes)})
same => n,Set(PSTN_INTL_DIGITS=${EXTEN:3})
same => n,GotoIf($["${PSTN_INTL_ALLOWED}" = ""]?pstn_intl_country_denied,1)
same => n,GotoIf($[${REGEX("^(${PSTN_INTL_ALLOWED})" ${PSTN_INTL_DIGITS})} = 1]?pstn_check_busy,1)
same => n,Goto(pstn_intl_country_denied,1)
exten => pstn_intl_country_denied,1,NoOp(Denied intl - ${EXTEN} not on the current international allow-list)
__ALERT_DENY_INTL_COUNTRY_LINE__
same => n,Busy(15)
same => n,Hangup()
exten => pstn_check_allow_out,1,Set(PSTN_ALLOWED=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},allowed_numbers)})
same => n,GotoIf($[${REGEX("^(${PSTN_ALLOWED})$" ${EXTEN})} = 1]?pstn_check_busy,1)
same => n,NoOp(Denied - ${EXTEN} not on ${PSTN_CALLER}'s approved number list)
@@ -282,8 +323,11 @@ EOF
sed -i "s#__ALERT_DENY_INTL_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call blocked - non-US/premium NANP area code.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE"
sed -i "s#__ALERT_DENY_NUMBER_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call denied - number not pre-approved.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE"
sed -i "s#__ALERT_BUSY_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound concurrent-call cap reached - a call was rejected.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE"
sed -i "s#__ALERT_KILLED_LINE__# same => n,System(curl -m 5 -s -H 'Priority: urgent' -d 'PSTN trunk: outbound call rejected - spend-cap kill-switch is tripped.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE"
sed -i "s#__ALERT_DENY_INTL_TIER_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: international call denied - extension is not full-tier.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE"
sed -i "s#__ALERT_DENY_INTL_COUNTRY_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: international call denied - country not on the current allow-list.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE"
else
sed -i "/__ALERT_DENY_TIER_LINE__/d; /__ALERT_DENY_INTL_LINE__/d; /__ALERT_DENY_NUMBER_LINE__/d; /__ALERT_BUSY_LINE__/d" "$FILE"
sed -i "/__ALERT_DENY_TIER_LINE__/d; /__ALERT_DENY_INTL_LINE__/d; /__ALERT_DENY_NUMBER_LINE__/d; /__ALERT_BUSY_LINE__/d; /__ALERT_KILLED_LINE__/d; /__ALERT_DENY_INTL_TIER_LINE__/d; /__ALERT_DENY_INTL_COUNTRY_LINE__/d" "$FILE"
fi
# ── Inbound: [from-pstn-trunk], one unrolled block per ring-group member.
@@ -294,6 +338,8 @@ EOF
[from-pstn-trunk]
exten => _X.,1,NoOp(Inbound PSTN call from ${CALLERID(num)})
same => n,Set(PSTN_KILLED=${AST_CONFIG(pstn-trunk-killswitch.conf,state,tripped)})
same => n,GotoIf($["${PSTN_KILLED}" = "1"]?pstn_in_killed,1)
same => n,Set(PSTN_RING_LIST=)
same => n,Set(PSTN_RING_SEP=)
EOF
@@ -323,14 +369,19 @@ exten => pstn_in_busy,1,NoOp(PSTN trunk - inbound concurrent-call cap reached, r
__ALERT_BUSY_IN_LINE__
same => n,Busy(15)
same => n,Hangup()
exten => pstn_in_killed,1,NoOp(PSTN trunk - spend-cap kill-switch is tripped, rejecting inbound call)
__ALERT_KILLED_IN_LINE__
same => n,Hangup()
EOF
if [[ -n "$NTFY_URL" ]]; then
local _esc_url2="${NTFY_URL//&/\\&}"
sed -i "s#__ALERT_DENY_INBOUND_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: inbound call rejected - caller not approved for any ring target.' '${_esc_url2}' >/dev/null 2>\\&1 \\&)#" "$FILE"
sed -i "s#__ALERT_BUSY_IN_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: inbound concurrent-call cap reached - a call was rejected.' '${_esc_url2}' >/dev/null 2>\\&1 \\&)#" "$FILE"
sed -i "s#__ALERT_KILLED_IN_LINE__# same => n,System(curl -m 5 -s -H 'Priority: urgent' -d 'PSTN trunk: inbound call rejected - spend-cap kill-switch is tripped.' '${_esc_url2}' >/dev/null 2>\\&1 \\&)#" "$FILE"
else
sed -i "/__ALERT_DENY_INBOUND_LINE__/d; /__ALERT_BUSY_IN_LINE__/d" "$FILE"
sed -i "/__ALERT_DENY_INBOUND_LINE__/d; /__ALERT_BUSY_IN_LINE__/d; /__ALERT_KILLED_IN_LINE__/d" "$FILE"
fi
}
@@ -357,27 +408,35 @@ _pstn_write_limits_file() {
# ── Shared: initial permission tiers (fresh install / explicit reset only —
# "update in place" never calls this, matching how .env/firewall/Caddy config
# are protected elsewhere in this repo; see file-level comment above) ──────
# Args: FILE, space-separated FULL_EXTS, then "ext" "pipe|separated|numbers"
# pairs for each restricted extension.
# Args: FILE, space-separated FULL_EXTS, space-separated MESSAGING_EXTS, then
# "ext" "pipe|separated|numbers" pairs for each restricted extension.
_pstn_write_permissions_file() {
local FILE="$1" FULL_EXTS="$2"
shift 2
local FILE="$1" FULL_EXTS="$2" MESSAGING_EXTS="$3"
shift 3
local _written_exts=""
{
echo "; PSTN permission tiers — internal / restricted / full."
echo "; PSTN permission tiers — internal / restricted / full — PLUS an independent"
echo "; 'messaging' flag for Asterisk's native internal SIP MESSAGE texting (no"
echo "; carrier SMS, no PSTN, no cost — a separate axis from PSTN calling, since"
echo "; the risk profile is different: an extension can be internal-tier for"
echo "; calling and still messaging-enabled, or vice versa)."
echo "; Read LIVE by the dialplan on every call (AST_CONFIG()) — no Asterisk"
echo "; restart needed when this changes. Edit here directly, via the Security"
echo "; Dashboard web UI's \"PSTN Trunk\" tab (if installed), or by re-running"
echo "; 'sudo ./setup.sh pstn-trunk' and choosing a FRESH reinstall (\"update in"
echo "; place\" leaves this file alone on purpose)."
echo "; Any extension not listed here is internal-only (no PSTN) by default —"
echo "; it can still call/receive other Asterisk extensions and join internal"
echo "; ring groups, just not the PSTN trunk."
echo "; Any extension not listed here is internal-only (no PSTN) AND"
echo "; messaging-disabled by default — it can still call/receive other Asterisk"
echo "; extensions and join internal ring groups, just not the PSTN trunk or"
echo "; internal texting."
echo ""
local _ext
for _ext in $FULL_EXTS; do
echo "[$_ext]"
echo "tier=full"
[[ " $MESSAGING_EXTS " == *" $_ext "* ]] && echo "messaging=yes"
echo ""
_written_exts="$_written_exts $_ext"
done
while [[ $# -gt 0 ]]; do
_ext="$1"; local _nums="$2"
@@ -385,65 +444,451 @@ _pstn_write_permissions_file() {
echo "[$_ext]"
echo "tier=restricted"
echo "allowed_numbers=${_nums}"
[[ " $MESSAGING_EXTS " == *" $_ext "* ]] && echo "messaging=yes"
echo ""
_written_exts="$_written_exts $_ext"
done
for _ext in $MESSAGING_EXTS; do
if [[ " $_written_exts " != *" $_ext "* ]]; then
echo "[$_ext]"
echo "messaging=yes"
echo ""
fi
done
} > "$FILE"
chmod 664 "$FILE"
}
# ── Shared: periodic spend/volume checker (run hourly via cron) ────────────
# ── Shared: kill-switch state (fresh install / explicit reset only — same
# "update never touches it" protection as pstn-permissions.conf/
# pstn-limits.conf) ─────────────────────────────────────────────────────────
_pstn_write_killswitch_file() {
local FILE="$1"
{
echo "; PSTN spend-cap kill-switch state. 'tripped=1' blocks ALL PSTN calling"
echo "; (in and out — internal Asterisk-to-Asterisk calling is unaffected), read"
echo "; LIVE by the dialplan (AST_CONFIG()) on every call attempt. Written"
echo "; automatically by pstn-trunk-usage-alert.sh once estimated monthly spend"
echo "; reaches the cap set during install/update — it does NOT reset on its own."
echo "; To clear a trip: re-run 'sudo ./setup.sh pstn-trunk', choose update mode,"
echo "; and answer yes when asked, or hand-edit this file back to tripped=0 (same"
echo "; 'safe to edit by hand' convention as pstn-permissions.conf/pstn-limits.conf)."
echo "; Deliberately NOT exposed on the Security Dashboard web UI — clearing a"
echo "; kill-switch is a CLI-only action, consistent with the international-"
echo "; calling toggle below, so a compromised/careless web session can't quietly"
echo "; re-enable spend after a trip."
echo ""
echo "[state]"
echo "tripped=0"
} > "$FILE"
chmod 664 "$FILE"
}
# ── Shared: international-calling allow-list (CLI-managed only — see
# _pstn_manage_international/_pstn_run_international_step below) ───────────
_pstn_write_intl_allowed_file() {
local FILE="$1" CODES="$2" NAMES="$3" EXPIRES="$4"
{
echo "; International-calling allow-list (country calling codes, beyond NANP/US)."
echo "; Read LIVE by the dialplan (AST_CONFIG()) on every _011 international call —"
echo "; no Asterisk restart needed. Managed ONLY from the CLI installer"
echo "; ('sudo ./setup.sh pstn-trunk' -> 'Review/change allowed international-"
echo "; calling countries now?'), deliberately NOT exposed on the Security"
echo "; Dashboard web UI — this widens which countries can be dialed/billed to at"
echo "; all, a more security-sensitive control than who's already allowed to use"
echo "; an already-fixed scope. pstn-trunk-usage-alert.sh actively clears this"
echo "; (re-blocking) once 'expires' has passed, and sends ntfy notices both the"
echo "; day of and at the moment of expiry."
echo ""
echo "[countries]"
echo "allowed_codes=${CODES}"
echo "allowed_names=${NAMES}"
echo "expires=${EXPIRES}"
} > "$FILE"
chmod 664 "$FILE"
}
_pstn_read_intl_current() {
local FILE="$1"
_PSTN_CUR_CODES=""
_PSTN_CUR_NAMES=""
_PSTN_CUR_EXPIRES=""
if [[ -f "$FILE" ]]; then
_PSTN_CUR_CODES="$(grep '^allowed_codes=' "$FILE" | head -1 | cut -d= -f2-)"
_PSTN_CUR_NAMES="$(grep '^allowed_names=' "$FILE" | head -1 | cut -d= -f2-)"
_PSTN_CUR_EXPIRES="$(grep '^expires=' "$FILE" | head -1 | cut -d= -f2-)"
fi
}
_pstn_print_intl_allowed() {
local FILE="$1"
_pstn_read_intl_current "$FILE"
echo ""
if [[ -n "$_PSTN_CUR_CODES" ]]; then
log_info "International calling currently ALLOWED to: ${_PSTN_CUR_NAMES//|/, }"
if [[ -n "$_PSTN_CUR_EXPIRES" ]]; then
log_info " Expires: $_PSTN_CUR_EXPIRES (auto-revoked and re-blocked after this date)."
else
log_info " No expiry set — stays allowed until you change it here again."
fi
else
log_info "International calling: no countries currently allowed (US/NANP only, per the tiers above)."
fi
}
# Continent -> country menu. Only reachable from the CLI (never the web
# dashboard) and only ever invoked via _pstn_run_international_step, which
# always asks the y/n gate first and always prints the resulting allow-list
# exactly once afterward, regardless of the answer.
_pstn_manage_international() {
local ASTERISK_DIR="$1"
local FILE="$ASTERISK_DIR/pstn-intl-allowed.conf"
mkdir -p "$ASTERISK_DIR"
_pstn_read_intl_current "$FILE"
local -A SELECTED=()
if [[ -n "$_PSTN_CUR_CODES" ]]; then
local -a _cur_codes_arr _cur_names_arr
IFS='|' read -ra _cur_codes_arr <<< "$_PSTN_CUR_CODES"
IFS='|' read -ra _cur_names_arr <<< "$_PSTN_CUR_NAMES"
local _k
for _k in "${!_cur_codes_arr[@]}"; do
SELECTED["${_cur_codes_arr[$_k]}"]="${_cur_names_arr[$_k]}"
done
fi
local -a NA=("Mexico|52" "Greenland|299")
local -a SA=("Brazil|55" "Argentina|54" "Colombia|57" "Chile|56" "Peru|51" "Ecuador|593" "Venezuela|58")
local -a EU=("United Kingdom|44" "Germany|49" "France|33" "Spain|34" "Italy|39" "Netherlands|31" "Ireland|353" "Portugal|351" "Poland|48" "Switzerland|41")
local -a ASIA=("India|91" "China|86" "Japan|81" "South Korea|82" "Philippines|63" "Israel|972" "United Arab Emirates|971" "Thailand|66" "Vietnam|84")
local -a AF=("Nigeria|234" "South Africa|27" "Egypt|20" "Kenya|254" "Morocco|212")
local -a OC=("Australia|61" "New Zealand|64")
echo ""
echo " Select countries to allow international (non-NANP) calling to/from —"
echo " nested by continent so the less-common calling codes stay out of the way"
echo " until you need them. Only 'full'-tier extensions can use this regardless"
echo " of which countries are allowed here."
local DONE=""
while [[ "$DONE" != "y" && "$DONE" != "Y" ]]; do
echo ""
echo " Continents:"
echo " 1) North America (non-NANP) 2) South America 3) Europe"
echo " 4) Asia 5) Africa 6) Oceania"
echo " 0) Enter a country/code manually (not listed above)"
local _cur_list="" _cc
for _cc in "${!SELECTED[@]}"; do _cur_list="${_cur_list}${_cur_list:+, }${SELECTED[$_cc]}"; done
echo " Currently selected: ${_cur_list:-none}"
local _choice=""
prompt_text " Continent number, 0, or 'd' when done:" "d" _choice
local -a _countries=()
local _cname=""
case "$_choice" in
d|D) DONE=y; continue ;;
0)
local _manual_name="" _manual_code=""
prompt_text " Country name (for your reference):" "" _manual_name
prompt_text " Country calling code (digits only, e.g. 44):" "" _manual_code
if [[ "$_manual_code" =~ ^[0-9]{1,4}$ ]]; then
SELECTED["$_manual_code"]="${_manual_name:-Unnamed} (+$_manual_code)"
log_success "Added ${_manual_name:-Unnamed} (+$_manual_code)."
else
log_warning "Not a valid calling code — skipped."
fi
continue
;;
1) _countries=("${NA[@]}"); _cname="North America" ;;
2) _countries=("${SA[@]}"); _cname="South America" ;;
3) _countries=("${EU[@]}"); _cname="Europe" ;;
4) _countries=("${ASIA[@]}"); _cname="Asia" ;;
5) _countries=("${AF[@]}"); _cname="Africa" ;;
6) _countries=("${OC[@]}"); _cname="Oceania" ;;
*) log_warning "Invalid choice."; continue ;;
esac
local _sub_done=""
while [[ "$_sub_done" != "y" && "$_sub_done" != "Y" ]]; do
echo ""
echo " $_cname:"
local _j _entry _name _code _mark
for _j in "${!_countries[@]}"; do
_entry="${_countries[$_j]}"
_name="${_entry%%|*}"; _code="${_entry##*|}"
_mark=" "
[[ -n "${SELECTED[$_code]+x}" ]] && _mark="x"
echo " $((_j+1))) [$_mark] $_name (+$_code)"
done
local _pick=""
prompt_text " Toggle a number, or 'b' for back:" "b" _pick
if [[ "$_pick" == "b" || "$_pick" == "B" ]]; then
_sub_done=y
elif [[ "$_pick" =~ ^[0-9]+$ ]] && (( _pick >= 1 && _pick <= ${#_countries[@]} )); then
_entry="${_countries[$((_pick-1))]}"
_name="${_entry%%|*}"; _code="${_entry##*|}"
if [[ -n "${SELECTED[$_code]+x}" ]]; then
unset 'SELECTED[$_code]'
log_info "Removed $_name."
else
SELECTED["$_code"]="$_name (+$_code)"
log_info "Added $_name."
fi
else
log_warning "Invalid choice."
fi
done
done
local NEW_CODES="" NEW_NAMES="" _sep="" _code
for _code in "${!SELECTED[@]}"; do
NEW_CODES="${NEW_CODES}${_sep}${_code}"
NEW_NAMES="${NEW_NAMES}${_sep}${SELECTED[$_code]}"
_sep="|"
done
local NEW_EXPIRES=""
if [[ -n "$NEW_CODES" ]]; then
local WANT_EXPIRY=""
prompt_yn " Auto-expire this international access? (y/n):" "y" WANT_EXPIRY
if [[ "$WANT_EXPIRY" =~ ^[Yy]$ ]]; then
local _days=""
prompt_text " Expire after how many days:" "30" _days
[[ "$_days" =~ ^[0-9]+$ ]] || _days=30
NEW_EXPIRES="$(date -d "+${_days} days" +%Y-%m-%d)"
log_info " Will auto-expire on $NEW_EXPIRES — you'll get an ntfy notice that day and again when it actually revokes."
fi
fi
_pstn_write_intl_allowed_file "$FILE" "$NEW_CODES" "$NEW_NAMES" "$NEW_EXPIRES"
chmod 664 "$FILE"
}
# Always asked, never skippable ("no bypassing the option" per design) —
# both on fresh install AND on "update in place", unlike every other
# structural prompt. Prints the resulting allow-list exactly once,
# immediately after, regardless of the y/n answer — NOT repeated later
# during the spend-cap prompts.
_pstn_run_international_step() {
local ASTERISK_DIR="$1"
local FILE="$ASTERISK_DIR/pstn-intl-allowed.conf"
echo ""
log_info "International calling (beyond NANP/US) is OFF by default and can ONLY be"
log_info "managed from here (this CLI) — never from the Security Dashboard web UI."
log_info "It's a more security-sensitive control (widens which countries can be"
log_info "dialed/billed to at all) than anything already exposed there (which only"
log_info "governs who can use a scope that's already fixed)."
local WANT_INTL_CHANGE=""
prompt_yn "Review/change allowed international-calling countries now? (y/n):" "n" WANT_INTL_CHANGE
if [[ "$WANT_INTL_CHANGE" =~ ^[Yy]$ ]]; then
_pstn_manage_international "$ASTERISK_DIR"
fi
_pstn_print_intl_allowed "$FILE"
}
# Update-mode-only: pstn-trunk-killswitch.conf is never touched by "update"
# (same protection as pstn-permissions.conf/pstn-limits.conf), so a trip
# persists across settings updates on purpose — this is the CLI-only path to
# clear it back out again once you've confirmed the overage was expected/
# resolved.
_pstn_check_killswitch_clear() {
local ASTERISK_DIR="$1"
local FILE="$ASTERISK_DIR/pstn-trunk-killswitch.conf"
[[ -f "$FILE" ]] || return 0
if grep -q '^tripped=1' "$FILE" 2>/dev/null; then
echo ""
log_warning "The spend-cap kill-switch is currently TRIPPED — ALL PSTN calling (in and"
log_warning "out) is blocked. It does not reset automatically."
local CLEAR=""
prompt_yn "Clear it now and resume PSTN calling? (y/n):" "n" CLEAR
if [[ "$CLEAR" =~ ^[Yy]$ ]]; then
sed -i 's/^tripped=.*/tripped=0/' "$FILE"
log_success "Kill-switch cleared — PSTN calling resumes immediately (live, no restart needed)."
else
log_info "Leaving the kill-switch tripped."
fi
fi
}
# ── Shared: periodic spend/volume/kill-switch/international-expiry checker
# (run every minute via systemd timer, cron.d fallback — see
# _pstn_install_periodic_timer above) ───────────────────────────────────────
_pstn_write_usage_alert_script() {
local FILE="$1" EA_DIR="$2" RATE="$3" MONTH_THRESHOLD="$4" BURST_THRESHOLD="$5" NTFY_URL="$6"
local FILE="$1" EA_DIR="$2" ASTERISK_DIR="$3" RATE="$4" MONTH_THRESHOLD="$5" \
BURST_THRESHOLD="$6" MAX_MONTHLY_SPEND="$7" NTFY_URL="$8"
cat > "$FILE" << 'EOF'
#!/bin/bash
# Auto-generated by services/pstn-trunk.sh — do not edit directly, re-run
# the installer instead. Run hourly via /etc/cron.d/pstn-trunk-usage.
# Reads the call log pstn-trunk-dialplan.conf appends to and alerts via
# ntfy when month-to-date estimated spend crosses a threshold (alerted once
# per month) or when call volume in the last hour looks like a burst.
# the installer instead. Run every minute via a systemd timer
# (pstn-trunk-usage.timer; cron.d fallback if systemd isn't available — see
# _pstn_install_periodic_timer). Reads the call log pstn-trunk-dialplan.conf
# appends to and:
# - alerts via ntfy when month-to-date estimated spend crosses
# MONTH_THRESHOLD (once per month), or call volume in the last hour
# looks like a burst;
# - trips the spend-cap kill-switch (pstn-trunk-killswitch.conf) once
# estimated spend reaches MAX_MONTHLY_SPEND — read LIVE by the dialplan
# on every call, blocking ALL PSTN calling (in and out) until manually
# cleared (does NOT reset automatically — see CLAUDE.md/README for how);
# - sends a loud (priority=urgent) ntfy warning once spend reaches 80% of
# that cap, distinct from and in addition to the trip alert itself;
# - actively re-blocks (clears) the international-calling allow-list once
# its expiry date has passed, with ntfy notices both the day of and at
# the moment it actually revokes.
# These are estimates (call count/duration x an entered rate), not real
# billing data, and only as fresh as the last run of this script (every
# minute) — a safety net, not a substitute for the provider's own billing.
LOG_FILE="__EA_DIR__/logs/pstn-trunk-calls.log"
STATE_FILE="__EA_DIR__/.pstn-trunk-alert-state"
WARN_STATE_FILE="__EA_DIR__/.pstn-trunk-warn-state"
INTL_STATE_FILE="__EA_DIR__/.pstn-trunk-intl-state"
KILLSWITCH_FILE="__ASTERISK_DIR__/pstn-trunk-killswitch.conf"
INTL_FILE="__ASTERISK_DIR__/pstn-intl-allowed.conf"
RATE="__PSTN_RATE__"
MONTH_THRESHOLD="__PSTN_MONTH_THRESHOLD__"
BURST_THRESHOLD="__PSTN_BURST_THRESHOLD__"
MAX_MONTHLY_SPEND="__PSTN_MAX_MONTHLY_SPEND__"
NTFY_URL="__PSTN_NTFY_URL__"
[[ -f "$LOG_FILE" ]] || exit 0
now_epoch=$(date +%s)
current_month=$(date +%Y-%m)
one_hour_ago=$((now_epoch - 3600))
month_start_epoch=$(date -d "$(date +%Y-%m-01)" +%s)
month_seconds=$(awk -F'|' -v start="$month_start_epoch" '$2=="out" && $1+0>=start {sum+=$5} END{print sum+0}' "$LOG_FILE")
month_minutes=$(awk -v s="$month_seconds" 'BEGIN{printf "%.1f", s/60}')
month_cost=$(awk -v m="$month_minutes" -v r="$RATE" 'BEGIN{printf "%.2f", m*r}')
hour_calls=$(awk -F'|' -v start="$one_hour_ago" '$2=="out" && $1+0>=start {c++} END{print c+0}' "$LOG_FILE")
send_ntfy() {
[[ -n "$NTFY_URL" ]] && curl -m 5 -s -d "$1" "$NTFY_URL" >/dev/null 2>&1
}
send_ntfy_loud() {
[[ -n "$NTFY_URL" ]] && curl -m 5 -s -H "Priority: urgent" -H "Title: PSTN trunk alert" -d "$1" "$NTFY_URL" >/dev/null 2>&1
}
last_alert_month=""
[[ -f "$STATE_FILE" ]] && last_alert_month=$(cat "$STATE_FILE")
current_month=$(date +%Y-%m)
if awk -v c="$month_cost" -v t="$MONTH_THRESHOLD" 'BEGIN{exit !(c>=t)}'; then
if [[ "$last_alert_month" != "$current_month" ]]; then
send_ntfy "PSTN trunk: estimated spend this month (\$${month_cost}) has crossed the \$${MONTH_THRESHOLD} threshold. ${month_minutes} minutes so far."
echo "$current_month" > "$STATE_FILE"
if [[ -f "$LOG_FILE" ]]; then
now_epoch=$(date +%s)
one_hour_ago=$((now_epoch - 3600))
month_start_epoch=$(date -d "$(date +%Y-%m-01)" +%s)
month_seconds=$(awk -F'|' -v start="$month_start_epoch" '$2=="out" && $1+0>=start {sum+=$5} END{print sum+0}' "$LOG_FILE")
month_minutes=$(awk -v s="$month_seconds" 'BEGIN{printf "%.1f", s/60}')
month_cost=$(awk -v m="$month_minutes" -v r="$RATE" 'BEGIN{printf "%.2f", m*r}')
hour_calls=$(awk -F'|' -v start="$one_hour_ago" '$2=="out" && $1+0>=start {c++} END{print c+0}' "$LOG_FILE")
last_alert_month=""
[[ -f "$STATE_FILE" ]] && last_alert_month=$(cat "$STATE_FILE")
if awk -v c="$month_cost" -v t="$MONTH_THRESHOLD" 'BEGIN{exit !(c>=t)}'; then
if [[ "$last_alert_month" != "$current_month" ]]; then
send_ntfy "PSTN trunk: estimated spend this month (\$${month_cost}) has crossed the \$${MONTH_THRESHOLD} threshold. ${month_minutes} minutes so far."
echo "$current_month" > "$STATE_FILE"
fi
fi
if [[ "$hour_calls" -ge "$BURST_THRESHOLD" ]]; then
send_ntfy "PSTN trunk: $hour_calls outbound calls placed in the last hour - check for unusual activity."
fi
# ── Spend-cap kill-switch: trip, or warn once approaching it ──────────
# MAX_MONTHLY_SPEND=0 means the kill-switch is disabled (not configured).
if awk -v m="$MAX_MONTHLY_SPEND" 'BEGIN{exit !(m+0>0)}'; then
already_tripped="0"
[[ -f "$KILLSWITCH_FILE" ]] && grep -q '^tripped=1' "$KILLSWITCH_FILE" && already_tripped="1"
if [[ "$already_tripped" != "1" ]] && awk -v c="$month_cost" -v m="$MAX_MONTHLY_SPEND" 'BEGIN{exit !(c>=m)}'; then
cat > "$KILLSWITCH_FILE" << KS
[state]
tripped=1
KS
send_ntfy_loud "PSTN trunk: SPEND-CAP KILL-SWITCH TRIPPED. Estimated spend this month (\$${month_cost}) reached the \$${MAX_MONTHLY_SPEND} cap. ALL PSTN calling (in and out) is now blocked - internal Asterisk calling is unaffected. This does NOT reset automatically - clear it with 'sudo ./setup.sh pstn-trunk' (update mode)."
elif [[ "$already_tripped" != "1" ]]; then
warn_threshold=$(awk -v m="$MAX_MONTHLY_SPEND" 'BEGIN{printf "%.2f", m*0.8}')
if awk -v c="$month_cost" -v t="$warn_threshold" 'BEGIN{exit !(c>=t)}'; then
last_warn_month=""
[[ -f "$WARN_STATE_FILE" ]] && last_warn_month=$(cat "$WARN_STATE_FILE")
if [[ "$last_warn_month" != "$current_month" ]]; then
send_ntfy_loud "PSTN trunk: approaching the spend cap - estimated spend this month (\$${month_cost}) is at 80%+ of the \$${MAX_MONTHLY_SPEND} kill-switch cap. PSTN calling will be BLOCKED automatically if it reaches \$${MAX_MONTHLY_SPEND}."
echo "$current_month" > "$WARN_STATE_FILE"
fi
fi
fi
fi
fi
if [[ "$hour_calls" -ge "$BURST_THRESHOLD" ]]; then
send_ntfy "PSTN trunk: $hour_calls outbound calls placed in the last hour - check for unusual activity."
# ── International allow-list: expiry notices + active re-block ────────────
if [[ -f "$INTL_FILE" ]]; then
intl_codes=$(grep '^allowed_codes=' "$INTL_FILE" | head -1 | cut -d= -f2-)
intl_expires=$(grep '^expires=' "$INTL_FILE" | head -1 | cut -d= -f2-)
today=$(date +%Y-%m-%d)
if [[ -n "$intl_codes" && -n "$intl_expires" ]]; then
last_intl_notice=""
[[ -f "$INTL_STATE_FILE" ]] && last_intl_notice=$(cat "$INTL_STATE_FILE")
if [[ "$today" == "$intl_expires" && "$last_intl_notice" != "day-of:$intl_expires" ]]; then
send_ntfy "PSTN trunk: international calling access expires TODAY ($intl_expires)."
echo "day-of:$intl_expires" > "$INTL_STATE_FILE"
fi
if [[ "$today" > "$intl_expires" ]]; then
sed -i 's/^allowed_codes=.*/allowed_codes=/; s/^allowed_names=.*/allowed_names=/; s/^expires=.*/expires=/' "$INTL_FILE"
send_ntfy "PSTN trunk: international calling access EXPIRED ($intl_expires) and has been revoked/re-blocked automatically."
echo "expired:$intl_expires" > "$INTL_STATE_FILE"
fi
fi
fi
EOF
sed -i "s#__EA_DIR__#${EA_DIR}#g; s/__PSTN_RATE__/${RATE}/g; s/__PSTN_MONTH_THRESHOLD__/${MONTH_THRESHOLD}/g; s/__PSTN_BURST_THRESHOLD__/${BURST_THRESHOLD}/g" "$FILE"
sed -i "s#__EA_DIR__#${EA_DIR}#g; s#__ASTERISK_DIR__#${ASTERISK_DIR}#g; s/__PSTN_RATE__/${RATE}/g; s/__PSTN_MONTH_THRESHOLD__/${MONTH_THRESHOLD}/g; s/__PSTN_BURST_THRESHOLD__/${BURST_THRESHOLD}/g; s/__PSTN_MAX_MONTHLY_SPEND__/${MAX_MONTHLY_SPEND}/g" "$FILE"
sed -i "s#__PSTN_NTFY_URL__#${NTFY_URL}#g" "$FILE"
chmod 755 "$FILE"
}
# ── Shared: per-minute periodic check (systemd timer, cron.d fallback) ─────
# Runs pstn-trunk-usage-alert.sh far more often than the old hourly cron.d
# job — every minute — since it's also the enforcement point for the
# spend-cap kill-switch (see _pstn_write_usage_alert_script below): the
# gap between a check and the next one is the window where an overage
# could still happen before calling actually gets blocked, so a tighter
# interval directly shrinks that exposure. Idempotent: safe to call again
# on "update in place" to migrate an older install off the old cron.d job.
_pstn_install_periodic_timer() {
local EA_DIR="$1"
mkdir -p "$EA_DIR/logs"
# Older versions of this service installed an hourly cron.d job under
# this same name — remove it so there's only ever one scheduler.
[[ -f /etc/cron.d/pstn-trunk-usage ]] && rm -f /etc/cron.d/pstn-trunk-usage
if command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then
cat > /etc/systemd/system/pstn-trunk-usage.service << SVCEOF
[Unit]
Description=PSTN trunk spend/volume/kill-switch/international-expiry check
[Service]
Type=oneshot
ExecStart=/bin/bash $EA_DIR/pstn-trunk-usage-alert.sh
StandardOutput=append:$EA_DIR/logs/pstn-trunk-usage-alert.log
StandardError=append:$EA_DIR/logs/pstn-trunk-usage-alert.log
SVCEOF
cat > /etc/systemd/system/pstn-trunk-usage.timer << SVCEOF
[Unit]
Description=Run the PSTN trunk usage check every minute
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
AccuracySec=5s
[Install]
WantedBy=timers.target
SVCEOF
systemctl daemon-reload
systemctl enable --now pstn-trunk-usage.timer
log_success "Per-minute spend/volume/kill-switch check installed (systemd timer)."
elif command -v cron >/dev/null 2>&1 || [[ -d /etc/cron.d ]]; then
cat > /etc/cron.d/pstn-trunk-usage << CRON
* * * * * root /bin/bash $EA_DIR/pstn-trunk-usage-alert.sh >> $EA_DIR/logs/pstn-trunk-usage-alert.log 2>&1
CRON
log_success "Per-minute spend/volume/kill-switch check installed (cron.d fallback — systemd not detected)."
else
log_warning "Neither systemd nor cron available — run $EA_DIR/pstn-trunk-usage-alert.sh manually/periodically for spend/volume/kill-switch checks."
fi
}
# ── Shared: structural settings only (used by fresh install AND update) ────
# Does NOT touch pstn-permissions.conf — see the file-level comment above
# for why that file is managed separately.
@@ -451,14 +896,15 @@ _pstn_apply_settings() {
local EA_DIR="$1" ASTERISK_DIR="$2"
local SERVER="$3" SERVER_IPS="$4" DID="$5"
local RING_EXTS="$6" NTFY_URL="$7" RATE="$8" MONTH_THRESHOLD="$9" BURST_THRESHOLD="${10}"
local PROVIDER_NAME="${11}"
local PROVIDER_NAME="${11}" MAX_MONTHLY_SPEND="${12:-0}"
_pstn_patch_vendor_files "$EA_DIR" || return 1
mkdir -p "$ASTERISK_DIR"
_pstn_write_pjsip_include "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$SERVER" "$SERVER_IPS" "$DID"
_pstn_write_dialplan_include "$ASTERISK_DIR/pstn-trunk-dialplan.conf" "$DID" "$RING_EXTS" "$NTFY_URL"
_pstn_write_usage_alert_script "$EA_DIR/pstn-trunk-usage-alert.sh" "$EA_DIR" "$RATE" "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$NTFY_URL"
_pstn_write_usage_alert_script "$EA_DIR/pstn-trunk-usage-alert.sh" "$EA_DIR" "$ASTERISK_DIR" \
"$RATE" "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$MAX_MONTHLY_SPEND" "$NTFY_URL"
ensure_docker_dir_ownership "$ASTERISK_DIR"
chmod 644 "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$ASTERISK_DIR/pstn-trunk-dialplan.conf"
@@ -478,17 +924,11 @@ NTFY_URL="${NTFY_URL}"
RATE_PER_MIN="${RATE}"
MONTH_THRESHOLD="${MONTH_THRESHOLD}"
BURST_THRESHOLD="${BURST_THRESHOLD}"
MAX_MONTHLY_SPEND="${MAX_MONTHLY_SPEND}"
ENV
chown "$ACTUAL_USER:$ACTUAL_USER" "$EA_DIR/.pstn-trunk.env" 2>/dev/null || true
if command -v cron >/dev/null 2>&1 || [[ -d /etc/cron.d ]]; then
cat > /etc/cron.d/pstn-trunk-usage << CRON
0 * * * * root /bin/bash $EA_DIR/pstn-trunk-usage-alert.sh >> $EA_DIR/logs/pstn-trunk-usage-alert.log 2>&1
CRON
log_success "Hourly spend/volume check installed (cron.d)."
else
log_warning "cron not available — run $EA_DIR/pstn-trunk-usage-alert.sh manually/periodically for spend/volume alerts."
fi
_pstn_install_periodic_timer "$EA_DIR"
}
install_pstn-trunk() {
@@ -508,6 +948,7 @@ install_pstn-trunk() {
local DIALPLAN_INCLUDE="$ASTERISK_DIR/pstn-trunk-dialplan.conf"
local PERMISSIONS_FILE="$ASTERISK_DIR/pstn-permissions.conf"
local LIMITS_FILE="$ASTERISK_DIR/pstn-limits.conf"
local KILLSWITCH_FILE="$ASTERISK_DIR/pstn-trunk-killswitch.conf"
local SETTINGS_FILE="$EA_DIR/.pstn-trunk.env"
local CONTAINER_NAME="easy-asterisk"
[[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do"
@@ -516,16 +957,21 @@ install_pstn-trunk() {
echo "[DRY-RUN] Would require an existing asterisk-digital-ocean OR asterisk (LAN) install"
echo "[DRY-RUN] Would prompt for: SIP provider name (default VoIP.ms), server/POP hostname, DID,"
echo "[DRY-RUN] full-PSTN extensions, restricted-PSTN extensions + their approved numbers,"
echo "[DRY-RUN] internal SIP messaging extensions (separate from PSTN calling permission),"
echo "[DRY-RUN] max concurrent outbound/inbound calls (default 10/10), inbound ring-group extensions,"
echo "[DRY-RUN] ntfy alert topic (optional), per-minute rate + monthly/hourly alert thresholds"
echo "[DRY-RUN] ntfy alert topic (optional), international-calling allow-list (CLI-only,"
echo "[DRY-RUN] always asked, never on the web dashboard), per-minute rate + monthly/hourly"
echo "[DRY-RUN] alert thresholds, and an optional hard monthly spend-cap kill-switch"
echo "[DRY-RUN] Would resolve the server hostname to an IP, plus prompt for any additional"
echo "[DRY-RUN] known source IPs (some providers publish a fixed list), for inbound call matching"
echo "[DRY-RUN] Would patch vendor generator functions to #include the trunk config"
echo "[DRY-RUN] Would write pjsip/dialplan includes, pstn-permissions.conf + pstn-limits.conf"
echo "[DRY-RUN] (fresh install only), and an hourly usage-alert script + cron.d entry"
echo "[DRY-RUN] Would write pjsip/dialplan includes, pstn-permissions.conf, pstn-limits.conf,"
echo "[DRY-RUN] and pstn-trunk-killswitch.conf (fresh install only), plus a per-minute"
echo "[DRY-RUN] usage-alert script installed via a systemd timer (cron.d fallback)"
echo "[DRY-RUN] Would offer 'update in place' (structural settings only — never touches"
echo "[DRY-RUN] pstn-permissions.conf or pstn-limits.conf) instead of a fresh install if"
echo "[DRY-RUN] already configured"
echo "[DRY-RUN] pstn-permissions.conf, pstn-limits.conf, or the kill-switch trip state)"
echo "[DRY-RUN] instead of a fresh install if already configured; the international-calling"
echo "[DRY-RUN] review/change question is still asked every run either way"
echo "[DRY-RUN] Would restart the asterisk container to apply"
return 0
fi
@@ -570,15 +1016,19 @@ install_pstn-trunk() {
if [[ -f "$SETTINGS_FILE" ]]; then
# shellcheck disable=SC1090
source "$SETTINGS_FILE"
_pstn_check_killswitch_clear "$ASTERISK_DIR"
_pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \
"$TRUNK_SERVER" "$TRUNK_SERVER_IPS" "$TRUNK_DID" \
"$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \
"$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1
"$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" "${MAX_MONTHLY_SPEND:-0}" || return 1
( cd "$EA_DIR" && docker compose restart asterisk ) \
&& log_success "Updated — settings unchanged (server $TRUNK_SERVER, DID $TRUNK_DID, ring exts: $RING_EXTS)." \
|| log_warning "Restart failed — check: docker compose -f $EA_DIR/docker-compose.yml logs asterisk"
log_info "pstn-permissions.conf and pstn-limits.conf were NOT touched — edit them"
log_info "directly, via the Security Dashboard, or choose FRESH reinstall to reset them."
# Always asked, every run, update mode included — see
# _pstn_run_international_step's own comment for why.
_pstn_run_international_step "$ASTERISK_DIR"
return 0
else
log_warning "No $SETTINGS_FILE found (pre-dates this settings-file version) — falling back to a fresh install (every prompt below)."
@@ -592,7 +1042,8 @@ install_pstn-trunk() {
if [[ -f "$PERMISSIONS_FILE" || -f "$LIMITS_FILE" ]]; then
log_warning "pstn-permissions.conf and/or pstn-limits.conf already exist and may have"
log_warning "been edited since (directly, or via the Security Dashboard). A fresh"
log_warning "reinstall OVERWRITES both with whatever you enter below."
log_warning "reinstall OVERWRITES both with whatever you enter below, and also resets"
log_warning "the spend-cap kill-switch back to untripped."
local _confirm_reset=""
prompt_yn "Continue and reset permission tiers + concurrency caps? (y/n):" "n" _confirm_reset
if [[ ! "$_confirm_reset" =~ ^[Yy]$ ]]; then
@@ -712,6 +1163,19 @@ install_pstn-trunk() {
return 1
fi
# ── Internal SIP messaging — a separate axis from PSTN calling ─────────
# Asterisk's native SIP MESSAGE (extension-to-extension texting) has no
# cost/carrier involvement at all, unlike PSTN calling, so it gets its
# own independent flag in pstn-permissions.conf rather than being folded
# into the internal/restricted/full tiers above — an extension can be
# "internal" for calling (no PSTN) and still messaging-enabled, or vice
# versa. Off by default, same "opt in" posture as PSTN access.
echo ""
echo " Asterisk also supports native SIP texting between extensions (no carrier"
echo " SMS, no PSTN, no cost) — a separate permission from PSTN calling above."
local MESSAGING_EXTS=""
prompt_text "Extensions allowed to use internal SIP messaging (space-separated, blank = none):" "" MESSAGING_EXTS
echo ""
local WANT_NTFY=""
prompt_yn "Send an ntfy alert when a call is denied (permission tier/approved-number check failed) or rejected (concurrency cap hit)? (y/n):" "y" WANT_NTFY
@@ -735,6 +1199,10 @@ install_pstn-trunk() {
prompt_text " ntfy topic URL:" "$_ntfy_default" NTFY_URL
fi
# Always asked, every run, no exceptions — see _pstn_run_international_step's
# own comment for why this can't be skipped like everything else here.
_pstn_run_international_step "$ASTERISK_DIR"
echo ""
log_info "Spend/volume alert settings (used only to estimate cost and flag unusual usage —"
log_info "not billing-accurate, just a safety net)."
@@ -745,13 +1213,38 @@ install_pstn-trunk() {
local BURST_THRESHOLD=""
prompt_text " Alert if more than this many outbound calls happen in one hour:" "10" BURST_THRESHOLD
echo ""
log_warning "Spend-cap kill-switch: a HARD stop, not just an alert. Once estimated spend"
log_warning "this month reaches the cap below, ALL PSTN calling (in and out) is blocked"
log_warning "until you manually clear it by re-running this installer (update mode) — it"
log_warning "does NOT reset automatically next month. Internal Asterisk-to-Asterisk"
log_warning "calling is never affected."
log_warning "Based on estimated cost (call count/duration x the rate above), not your"
log_warning "provider's real billing data, and checked every minute, not instantly — a"
log_warning "strong safety net, not an absolute guarantee against any overage."
local WANT_KILLSWITCH=""
prompt_yn "Enable a hard monthly spend-cap kill-switch? (y/n):" "y" WANT_KILLSWITCH
local MAX_MONTHLY_SPEND="0"
if [[ "$WANT_KILLSWITCH" =~ ^[Yy]$ ]]; then
prompt_text " Monthly spend cap in USD (ALL PSTN calling blocked once reached):" "15" MAX_MONTHLY_SPEND
if [[ ! "$MAX_MONTHLY_SPEND" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
log_warning "Not a number — defaulting to 15."
MAX_MONTHLY_SPEND="15"
fi
log_info "You'll get a loud ntfy alert at 80% of this cap, and another (also loud) when it trips."
else
MAX_MONTHLY_SPEND="0"
log_info "Kill-switch disabled — the spend alerts above still notify you, but calling won't be auto-blocked."
fi
_pstn_apply_settings "$EA_DIR" "$ASTERISK_DIR" \
"$TRUNK_SERVER" "$TRUNK_SERVER_IPS" "$TRUNK_DID" \
"$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \
"$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" || return 1
"$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" "$MAX_MONTHLY_SPEND" || return 1
_pstn_write_permissions_file "$PERMISSIONS_FILE" "$FULL_EXTS" "${RESTRICTED_ARGS[@]}"
_pstn_write_permissions_file "$PERMISSIONS_FILE" "$FULL_EXTS" "$MESSAGING_EXTS" "${RESTRICTED_ARGS[@]}"
_pstn_write_limits_file "$LIMITS_FILE" "$MAX_OUTBOUND" "$MAX_INBOUND"
_pstn_write_killswitch_file "$KILLSWITCH_FILE"
ensure_docker_dir_ownership "$ASTERISK_DIR"
# No new firewall rules: the base install already opens SIP (5060/5061)
@@ -761,6 +1254,47 @@ install_pstn-trunk() {
# ── Docs (separate file — the base install already owns README.md in
# this same directory via write_readme, so don't overwrite it) ─────────
# Kill-switch/international-allowlist blurbs are built into plain
# variables first, not inline $(...) inside the heredoc below — a
# heredoc nested inside a command substitution that's itself inside
# another heredoc doesn't parse in bash (confirmed: "syntax error near
# unexpected token `||'" when tried directly).
local KILLSWITCH_DOC=""
if [[ "$MAX_MONTHLY_SPEND" != "0" ]]; then
KILLSWITCH_DOC="**Enabled — \$${MAX_MONTHLY_SPEND}/month.** Once \`pstn-trunk-usage-alert.sh\`
estimates month-to-date spend has reached this cap, it writes
\`tripped=1\` to \`config/asterisk/pstn-trunk-killswitch.conf\` — read
**live** by the dialplan on every PSTN call attempt (in AND out; internal
Asterisk-to-Asterisk calling is never affected) and blocks it immediately
with a loud (priority=urgent) ntfy alert. You also get a separate loud ntfy
warning once spend reaches 80% of this cap, before it actually trips.
**This does NOT reset automatically** — once tripped, it stays tripped
until you clear it yourself: re-run \`sudo ./setup.sh pstn-trunk\`, choose
update mode, and answer yes when asked. This is deliberately a CLI-only
action, not exposed on the Security Dashboard web UI, so a compromised or
careless web session can't quietly re-enable spend after a trip.
Based on estimated cost, not real billing data, and checked once a minute —
a strong safety net, not an absolute guarantee against any overage."
else
KILLSWITCH_DOC="**Disabled.** The spend alerts above still notify you, but PSTN calling won't be auto-blocked. Enable it by re-running \`sudo ./setup.sh pstn-trunk\` (update mode)."
fi
_pstn_read_intl_current "$ASTERISK_DIR/pstn-intl-allowed.conf"
local INTL_DOC=""
if [[ -n "$_PSTN_CUR_CODES" ]]; then
INTL_DOC="**Currently allowed:** ${_PSTN_CUR_NAMES//|/, }
"
if [[ -n "$_PSTN_CUR_EXPIRES" ]]; then
INTL_DOC="${INTL_DOC}**Expires:** $_PSTN_CUR_EXPIRES — auto-revoked and re-blocked after this date (with an ntfy notice both the day of and at the moment it expires)."
else
INTL_DOC="${INTL_DOC}**No expiry set** — stays allowed until changed again from the CLI."
fi
else
INTL_DOC="**No countries currently allowed** — outbound/inbound PSTN calling is US/NANP-only."
fi
local DOC_FILE="$EA_DIR/README-pstn-trunk.md"
cat > "$DOC_FILE" << MD
# SIP PSTN trunk (add-on to $ASTERISK_KIND)
@@ -783,10 +1317,12 @@ background, cost estimate, and toll-fraud reasoning.
| Restricted-PSTN extensions | ${RESTRICTED_EXTS:-none} |
| Concurrency caps | ${MAX_OUTBOUND} outbound / ${MAX_INBOUND} inbound simultaneous calls (live — see \`pstn-limits.conf\` below) |
| Inbound ring-group | ${RING_EXTS} |
| Internal SIP messaging extensions | ${MESSAGING_EXTS:-none} (separate from PSTN calling permission — see below) |
| ntfy alerts | ${NTFY_URL:-disabled} |
| Estimated rate | \$${RATE_PER_MIN}/min |
| Monthly spend alert threshold | \$${MONTH_THRESHOLD} |
| Hourly burst alert threshold | ${BURST_THRESHOLD} calls/hour |
| Spend-cap kill-switch | $([ "$MAX_MONTHLY_SPEND" != "0" ] && echo "\$${MAX_MONTHLY_SPEND}/month — blocks ALL PSTN calling once reached" || echo "disabled") |
## Non-US NANP area codes are blocked, not just "anything outside NANP"
@@ -877,26 +1413,64 @@ without touching \`pstn-permissions.conf\` or \`pstn-limits.conf\`).
## Spend/volume alerts
\`pstn-trunk-usage-alert.sh\` runs hourly (\`/etc/cron.d/pstn-trunk-usage\`) and
reads \`logs/pstn-trunk-calls.log\` (appended to directly by the dialplan, not
Asterisk's own CDR — a deliberate choice to avoid depending on whether this
image's CDR modules are enabled/configured, and to sidestep CDR CSV's
comma-quoting). It sends an ntfy alert:
\`pstn-trunk-usage-alert.sh\` runs **every minute** (\`pstn-trunk-usage.timer\`,
a systemd timer — falls back to a cron.d entry if systemd isn't available)
and reads \`logs/pstn-trunk-calls.log\` (appended to directly by the
dialplan, not Asterisk's own CDR — a deliberate choice to avoid depending on
whether this image's CDR modules are enabled/configured, and to sidestep CDR
CSV's comma-quoting). It sends an ntfy alert:
- **Once per calendar month** the first time estimated spend crosses
\$${MONTH_THRESHOLD} (state tracked in \`.pstn-trunk-alert-state\` so it
doesn't repeat every hour).
- **Every hour** that outbound call volume exceeds ${BURST_THRESHOLD}
calls/hour — this is the faster tripwire for a burst/abuse scenario,
doesn't repeat every minute).
- **Every run** that outbound call volume in the last hour exceeds
${BURST_THRESHOLD} calls — the faster tripwire for a burst/abuse scenario,
independent of whether it's crossed the monthly dollar threshold yet.
Separately, denied calls (no permission / number not pre-approved) and
rejected calls (either concurrency cap hit) alert **immediately**, not on
the hourly schedule.
rejected calls (either concurrency cap or the kill-switch) alert
**immediately** from the dialplan itself, not on the periodic schedule.
These are cost *estimates* (call count/duration × your entered rate), not
real billing data — treat them as a safety net, not a substitute for
checking your provider's own balance/usage dashboard.
real billing data, and only as fresh as the last check (every minute) — a
safety net, not a substitute for checking your provider's own balance/usage
dashboard.
## Spend-cap kill-switch
$KILLSWITCH_DOC
## International calling (beyond NANP/US)
$INTL_DOC
Managed **only** from the CLI (\`sudo ./setup.sh pstn-trunk\` — asked on every
run, fresh install or update, with no way to skip the question, though you
can always answer no to leave things unchanged), deliberately never exposed
on the Security Dashboard web UI: this widens which countries can be
dialed/billed to at all, a more security-sensitive control than who's
already allowed to use an already-fixed scope. Only \`full\`-tier extensions
can place these calls regardless of which countries are allowed. Stored in
\`config/asterisk/pstn-intl-allowed.conf\`, read live the same way as
permission tiers.
## Internal SIP messaging
Asterisk's native SIP \`MESSAGE\` support (extension-to-extension texting —
no carrier SMS, no PSTN, no cost) is gated by a \`messaging=yes\` flag per
extension in \`pstn-permissions.conf\`, independent of the PSTN calling
tiers above — off by default, same "opt in" posture. Currently enabled for:
${MESSAGING_EXTS:-none}.
**Known gap:** this installer writes the permission flag (live-editable,
same mechanism as the calling tiers), but the actual SIP \`MESSAGE\` routing
dialplan wiring depends on how Easy Asterisk's own generated
\`extensions.conf\`/\`pjsip.conf\` route inbound messages, which needs to be
verified against a live install before it's safely automated here — shipping
a guessed pattern risked either silently not working or interfering with
call-routing precedence in the same \`[intercom]\` context. Treat the
permission flag as ready for a dashboard/CLI-managed allow-list once that
routing is confirmed, not as fully wired yet.
## Managing this from a web UI
+100 -8
View File
@@ -206,13 +206,19 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly.
- **Unwhitelist + Ban** does that *and* immediately bans (24h) every IP
CrowdSec has ever recorded for that ASN, for accidental-whitelist cases
where you don't want to wait for it to misbehave again.
- **PSTN Trunk** (only if \`services/pstn-trunk.sh\` is installed) — the
- **PSTN Trunk** — detects whether \`services/pstn-trunk.sh\`'s dialplan is
actually installed (\`pstn-trunk-dialplan.conf\` present) and shows a clear
"not installed" message instead of the caps/tiers editor if not, so it
never shows real-looking-but-unenforced defaults. When installed: the
outbound/inbound concurrent-call caps, and every known extension (parsed
from \`pjsip.conf\`) with its current permission tier (internal /
restricted / full) and, for restricted, its approved numbers — all
editable live, no Asterisk restart, no reinstall. Writes directly to
\`pstn-limits.conf\` / \`pstn-permissions.conf\`, which the dialplan reads
fresh on every call.
fresh on every call. The spend-cap kill-switch and international-calling
allow-list are deliberately **not** managed here — CLI-only, via
\`sudo ./setup.sh pstn-trunk\` — since both are more security-sensitive
than what this tab already exposes.
- Link to the Asterisk web admin itself (doesn't embed it, just links out).
## Manage
@@ -908,6 +914,18 @@ def write_permission(ext, tier, numbers_raw):
LIMIT_RE = re.compile(r"^\d+$")
def pstn_installed():
"""True only once services/pstn-trunk.sh has actually wired the dialplan
in (pstn-trunk-dialplan.conf existing), not just because base Asterisk is
present — pjsip.conf/extensions.conf exist either way, so extension names
alone can't tell us this. Without this check the tab would show a real
extension list and a default-but-unenforced 10/10 cap even when there is
no PSTN trunk at all."""
if not ASTERISK_CONFIG_DIR:
return False
return os.path.isfile(os.path.join(ASTERISK_CONFIG_DIR, "pstn-trunk-dialplan.conf"))
def get_limits():
"""Current outbound/inbound concurrent-call caps. Defaults (10/10) match
what the dialplan itself falls back to (via AST_CONFIG()+IF()) if this
@@ -973,6 +991,9 @@ INDEX_HTML = """<!doctype html>
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #23262f; }
th { color: #9aa4b2; font-weight: 600; }
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: #e6e6e6; }
th.sortable .arrow { opacity: 0.5; font-size: 0.75em; margin-left: 0.25em; }
.sev-Error { color: #ff6b6b; }
.sev-Warning { color: #f5b342; }
.sev-Informational { color: #7fbf7f; }
@@ -1006,7 +1027,15 @@ INDEX_HTML = """<!doctype html>
<div id="tab-crowdsec" style="display:none">
<div class="card">
<h3 style="margin-top:0">Active bans</h3>
<table id="dec-table"><thead><tr><th>IP/Range</th><th>Scenario</th><th>Network / Carrier</th><th>Country</th><th>Duration</th><th>Origin</th><th></th></tr></thead><tbody></tbody></table>
<table id="dec-table"><thead><tr>
<th class="sortable" data-sort="value">IP/Range</th>
<th class="sortable" data-sort="scenario">Scenario</th>
<th class="sortable" data-sort="carrier">Network / Carrier</th>
<th class="sortable" data-sort="country">Country</th>
<th class="sortable" data-sort="duration">Duration</th>
<th class="sortable" data-sort="origin">Origin</th>
<th></th>
</tr></thead><tbody></tbody></table>
</div>
<div class="card">
<h3 style="margin-top:0">Asterisk brute-force ASN exemptions</h3>
@@ -1020,6 +1049,11 @@ INDEX_HTML = """<!doctype html>
</div>
</div>
<div id="tab-pstn" style="display:none">
<div class="card" id="pstn-not-installed" style="display:none">
<h3 style="margin-top:0">PSTN trunk not installed</h3>
<p class="muted">No PSTN trunk dialplan was found on this box — <code>sudo ./setup.sh pstn-trunk</code> hasn't been run (or its config was removed). Nothing below is enforced yet; install it first, then this tab will manage the real permission tiers and concurrency caps.</p>
</div>
<div id="pstn-installed-cards" 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>
@@ -1041,6 +1075,7 @@ INDEX_HTML = """<!doctype html>
<table id="pstn-table"><thead><tr><th>Ext</th><th>Name</th><th>Tier</th><th>Approved numbers (restricted only)</th><th></th></tr></thead><tbody></tbody></table>
<div id="pstn-msg" class="muted" style="margin-top:0.5rem"></div>
</div>
</div>
</div>
</main>
<script>
@@ -1052,7 +1087,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") { loadPstnLimits(); loadPstnPermissions(); }
if (btn.dataset.tab === "pstn") { loadPstnStatus(); }
});
});
@@ -1070,12 +1105,44 @@ async function loadSecurity() {
}
let lastDecisions = [];
let decSort = { key: null, dir: 1 };
async function loadDecisions() {
const res = await fetch("/api/decisions");
lastDecisions = await res.json();
// Go-style duration strings ("3h59m59.62s", "-1" for permanent) don't sort
// correctly as text, so parse to seconds for the Duration column; permanent
// bans (-1 or unparseable) sort as Infinity, i.e. last in ascending order.
function durationSeconds(s) {
if (!s || s === "-1") return Infinity;
const m = String(s).match(/^(-?\d+h)?(\d+m)?(\d+(?:\.\d+)?s)?$/);
if (!m || !(m[1] || m[2] || m[3])) return Infinity;
const h = parseFloat(m[1]) || 0, mi = parseFloat(m[2]) || 0, se = parseFloat(m[3]) || 0;
return h * 3600 + mi * 60 + se;
}
function decSortValue(d, key) {
switch (key) {
case "carrier": return (d.as_name || d.as_number || "").toLowerCase();
case "duration": return durationSeconds(d.duration);
default: return (d[key] || "").toString().toLowerCase();
}
}
function renderDecisions() {
let rows = lastDecisions.slice();
if (decSort.key) {
rows.sort((a, b) => {
const av = decSortValue(a, decSort.key), bv = decSortValue(b, decSort.key);
if (av < bv) return -1 * decSort.dir;
if (av > bv) return 1 * decSort.dir;
return 0;
});
}
document.querySelectorAll("#dec-table th.sortable .arrow").forEach(a => a.remove());
if (decSort.key) {
const th = document.querySelector(`#dec-table th[data-sort="${decSort.key}"]`);
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${decSort.dir === 1 ? "▲" : "▼"}</span>`);
}
const tbody = document.querySelector("#dec-table tbody");
tbody.innerHTML = lastDecisions.map(d => `<tr>
tbody.innerHTML = rows.map(d => `<tr>
<td>${esc(d.value)}</td>
<td>${esc(d.scenario)}</td>
<td>${d.as_number ? esc(d.as_number) + (d.as_name ? " — " + esc(d.as_name) : "") : ""}</td>
@@ -1089,6 +1156,21 @@ async function loadDecisions() {
</tr>`).join("") || "<tr><td colspan=7 class=muted>No active bans.</td></tr>";
}
document.querySelectorAll("#dec-table th.sortable").forEach(th => {
th.addEventListener("click", () => {
const key = th.dataset.sort;
decSort.dir = (decSort.key === key) ? -decSort.dir : 1;
decSort.key = key;
renderDecisions();
});
});
async function loadDecisions() {
const res = await fetch("/api/decisions");
lastDecisions = await res.json();
renderDecisions();
}
async function unban(id) {
if (!confirm("Unban decision #" + id + "?")) return;
const res = await fetch("/api/decisions/delete", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({id: id})});
@@ -1153,6 +1235,14 @@ async function banAsn(asn) {
loadDecisions();
}
async function loadPstnStatus() {
const res = await fetch("/api/pstn-status");
const data = await res.json();
document.getElementById("pstn-not-installed").style.display = data.installed ? "none" : "";
document.getElementById("pstn-installed-cards").style.display = data.installed ? "" : "none";
if (data.installed) { loadPstnLimits(); loadPstnPermissions(); }
}
async function loadPstnLimits() {
const res = await fetch("/api/pstn-limits");
const data = await res.json();
@@ -1273,6 +1363,8 @@ class Handler(BaseHTTPRequestHandler):
self._json({"extensions": extensions})
elif self.path == "/api/pstn-limits":
self._json(get_limits())
elif self.path == "/api/pstn-status":
self._json({"installed": pstn_installed()})
else:
self._json({"error": "not found"}, 404)