Add per-country international rates and active-hangup on kill-switch trip

- CLI country menu now asks for a per-minute rate when a country is added,
  stored alongside its code/name in pstn-intl-allowed.conf. The usage-alert
  script buckets each month's outbound calls into domestic vs. per-country
  international (longest-code-prefix match on the dialed digits) and prices
  each bucket at its own rate instead of one blended flat rate, which badly
  under/over-estimated international cost.
- While the kill-switch is tripped, the periodic script now also force-hangs
  up any PSTN call already in progress (docker exec + Asterisk's "channel
  request hangup" against the trunk's active channels), not just new calls
  - closing the gap where an in-progress call just kept running until it
  ended naturally. Runs every check while tripped, not only the run that
  trips it, to close a start-of-call race.
- Fixed a bug caught by testing: the hangup sweep was nested inside the
  "does the call log exist" guard, so it silently never fired if the log
  was missing/rotated while tripped. Moved it outside that guard.
- Documented an honest "how bulletproof is this?" breakdown in the
  generated README: the provider's own real-time $0-balance block is the
  actual hard ceiling (confirmed for Anveo Direct), the kill-switch here is
  an estimate-based second layer, not a replacement for funding the prepaid
  account at your actual risk tolerance.

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 15:52:08 +00:00
parent f9113b275b
commit 2d9dd80271
2 changed files with 285 additions and 57 deletions
+44
View File
@@ -431,3 +431,47 @@ generator output. Fixed by quoting every value in that heredoc.
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.
12. Anveo Direct's real-time \$0-balance blocking, confirmed — support reply
(MFonk, 7/22/2026): "all calls (incoming and outgoing) will be blocked"
at \$0, in real time, not just via a recurring-fee grace period. This
directly answers the question this doc used to flag as a live
verification gap (see the Anveo Direct provider notes above) and
validates the outermost, actually-hard layer of the defense-in-depth
stack — everything else in this file (dialplan restrictions, the
kill-switch below) is a second layer on top of that, not a replacement
for it.
13. ~~Kill-switch: blended rate estimate~~ Done — per-country international
rates. The kill-switch/spend-alert estimate used one flat `RATE` for
every outbound minute, domestic or international, which badly
under/over-estimates international cost (rates for other countries are
rarely close to the ~$0.01/min US rate). Now the CLI country menu also
asks for a per-minute rate when a country is added (admin-entered, not
fetched live — no confirmed rates API exists for either supported
provider), stored alongside the code/name in `pstn-intl-allowed.conf`.
`pstn-trunk-usage-alert.sh` buckets each month's outbound call log into
domestic vs. per-country-international (longest-calling-code-prefix
match against the dialed digits after "011") and prices each bucket at
its own rate before summing. An international call to a code that's no
longer on the allow-list is excluded from the estimate entirely (a
known, minor undercount) since there's no rate left to charge it at.
14. ~~Kill-switch: in-progress calls not stopped~~ Done — active hangup.
Previously, a call already connected when the spend cap was crossed
just kept running (and costing) until it ended naturally — the
dialplan only gates the *start* of a call, and the periodic check
can't retroactively un-place one. Now, every run of
`pstn-trunk-usage-alert.sh` while the kill-switch is tripped (not just
the run that trips it, closing a race where a call starts just before
the flag goes live) also does `docker exec ... asterisk -rx "core show
channels concise"`, filters for the trunk endpoint's active channels,
and force-hangs each one up via `channel request hangup` — hanging up
the trunk-side leg of a bridged call tears down both legs. This is
deliberately placed OUTSIDE the "does the call log exist" guard, since
the sweep must still run even if the log is missing/rotated (caught by
testing: an earlier version nested it inside that guard and the sweep
silently never fired without a log file present). Bounds worst-case
overage on an in-progress call to roughly one check interval, not
open-ended. Still not a real-billing guarantee — see the "How
bulletproof is this?" section `services/pstn-trunk.sh` writes into
`README-pstn-trunk.md` for the full honest breakdown of what's actually
hard (the provider's own $0-balance block) vs. estimate-based (this
kill-switch).
+241 -57
View File
@@ -267,9 +267,12 @@ __ALERT_KILLED_LINE__
; 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).
; domestic calls. pstn-trunk-usage-alert.sh's cost estimate buckets logged
; calls into domestic vs. per-country international (by the dialed digits
; after "011", longest-code-first match) and applies each country's own
; admin-entered rate (pstn-intl-allowed.conf's allowed_rates), not the flat
; domestic RATE — still an estimate (rates you entered, not fetched live),
; but no longer blended across two very different rate scales.
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)
@@ -487,9 +490,16 @@ _pstn_write_killswitch_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"
local FILE="$1" CODES="$2" NAMES="$3" EXPIRES="$4" RATES="$5"
{
echo "; International-calling allow-list (country calling codes, beyond NANP/US)."
echo "; International-calling allow-list (country calling codes, beyond NANP/US),"
echo "; each with its own admin-entered per-minute rate (allowed_rates, parallel to"
echo "; allowed_codes/allowed_names — NOT fetched live from the provider; no"
echo "; confirmed rates API exists for either supported provider as of this"
echo "; writing, see docs/pstn-calling-voipms-plan.md). pstn-trunk-usage-alert.sh"
echo "; uses these per-country rates instead of the single flat domestic RATE when"
echo "; estimating spend for international minutes, since a blended rate badly"
echo "; under/over-estimates international cost otherwise."
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-"
@@ -503,6 +513,7 @@ _pstn_write_intl_allowed_file() {
echo "[countries]"
echo "allowed_codes=${CODES}"
echo "allowed_names=${NAMES}"
echo "allowed_rates=${RATES}"
echo "expires=${EXPIRES}"
} > "$FILE"
chmod 664 "$FILE"
@@ -512,10 +523,12 @@ _pstn_read_intl_current() {
local FILE="$1"
_PSTN_CUR_CODES=""
_PSTN_CUR_NAMES=""
_PSTN_CUR_RATES=""
_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_RATES="$(grep '^allowed_rates=' "$FILE" | head -1 | cut -d= -f2-)"
_PSTN_CUR_EXPIRES="$(grep '^expires=' "$FILE" | head -1 | cut -d= -f2-)"
fi
}
@@ -525,7 +538,15 @@ _pstn_print_intl_allowed() {
_pstn_read_intl_current "$FILE"
echo ""
if [[ -n "$_PSTN_CUR_CODES" ]]; then
log_info "International calling currently ALLOWED to: ${_PSTN_CUR_NAMES//|/, }"
local -a _p_codes _p_names _p_rates
IFS='|' read -ra _p_codes <<< "$_PSTN_CUR_CODES"
IFS='|' read -ra _p_names <<< "$_PSTN_CUR_NAMES"
IFS='|' read -ra _p_rates <<< "$_PSTN_CUR_RATES"
log_info "International calling currently ALLOWED to:"
local _pi
for _pi in "${!_p_codes[@]}"; do
log_info " ${_p_names[$_pi]:-+${_p_codes[$_pi]}} — \$${_p_rates[$_pi]:-0}/min"
done
if [[ -n "$_PSTN_CUR_EXPIRES" ]]; then
log_info " Expires: $_PSTN_CUR_EXPIRES (auto-revoked and re-blocked after this date)."
else
@@ -547,14 +568,18 @@ _pstn_manage_international() {
_pstn_read_intl_current "$FILE"
# SELECTED[code] = "name|rate" — carries both through add/remove/list so
# the per-country rate survives re-running this menu without re-asking
# for it on every country that's already allowed.
local -A SELECTED=()
if [[ -n "$_PSTN_CUR_CODES" ]]; then
local -a _cur_codes_arr _cur_names_arr
local -a _cur_codes_arr _cur_names_arr _cur_rates_arr
IFS='|' read -ra _cur_codes_arr <<< "$_PSTN_CUR_CODES"
IFS='|' read -ra _cur_names_arr <<< "$_PSTN_CUR_NAMES"
IFS='|' read -ra _cur_rates_arr <<< "$_PSTN_CUR_RATES"
local _k
for _k in "${!_cur_codes_arr[@]}"; do
SELECTED["${_cur_codes_arr[$_k]}"]="${_cur_names_arr[$_k]}"
SELECTED["${_cur_codes_arr[$_k]}"]="${_cur_names_arr[$_k]}|${_cur_rates_arr[$_k]:-0}"
done
fi
@@ -578,8 +603,11 @@ _pstn_manage_international() {
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
local _cur_list="" _cc _cc_name
for _cc in "${!SELECTED[@]}"; do
_cc_name="${SELECTED[$_cc]%%|*}"
_cur_list="${_cur_list}${_cur_list:+, }${_cc_name} (\$${SELECTED[$_cc]##*|}/min)"
done
echo " Currently selected: ${_cur_list:-none}"
local _choice=""
prompt_text " Continent number, 0, or 'd' when done:" "d" _choice
@@ -588,12 +616,14 @@ _pstn_manage_international() {
case "$_choice" in
d|D) DONE=y; continue ;;
0)
local _manual_name="" _manual_code=""
local _manual_name="" _manual_code="" _manual_rate=""
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)."
prompt_text " Per-minute rate in USD for ${_manual_name:-this country} (used in the spend estimate, not fetched live):" "0.05" _manual_rate
[[ "$_manual_rate" =~ ^[0-9]+(\.[0-9]+)?$ ]] || _manual_rate="0.05"
SELECTED["$_manual_code"]="${_manual_name:-Unnamed} (+$_manual_code)|${_manual_rate}"
log_success "Added ${_manual_name:-Unnamed} (+$_manual_code) at \$${_manual_rate}/min."
else
log_warning "Not a valid calling code — skipped."
fi
@@ -612,13 +642,17 @@ _pstn_manage_international() {
while [[ "$_sub_done" != "y" && "$_sub_done" != "Y" ]]; do
echo ""
echo " $_cname:"
local _j _entry _name _code _mark
local _j _entry _name _code _mark _rate_suffix
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)"
_rate_suffix=""
if [[ -n "${SELECTED[$_code]+x}" ]]; then
_mark="x"
_rate_suffix=" — \$${SELECTED[$_code]##*|}/min"
fi
echo " $((_j+1))) [$_mark] $_name (+$_code)${_rate_suffix}"
done
local _pick=""
prompt_text " Toggle a number, or 'b' for back:" "b" _pick
@@ -631,8 +665,11 @@ _pstn_manage_international() {
unset 'SELECTED[$_code]'
log_info "Removed $_name."
else
SELECTED["$_code"]="$_name (+$_code)"
log_info "Added $_name."
local _new_rate=""
prompt_text " Per-minute rate in USD for $_name (used in the spend estimate, not fetched live):" "0.05" _new_rate
[[ "$_new_rate" =~ ^[0-9]+(\.[0-9]+)?$ ]] || _new_rate="0.05"
SELECTED["$_code"]="$_name (+$_code)|${_new_rate}"
log_info "Added $_name at \$${_new_rate}/min."
fi
else
log_warning "Invalid choice."
@@ -640,10 +677,11 @@ _pstn_manage_international() {
done
done
local NEW_CODES="" NEW_NAMES="" _sep="" _code
local NEW_CODES="" NEW_NAMES="" NEW_RATES="" _sep="" _code
for _code in "${!SELECTED[@]}"; do
NEW_CODES="${NEW_CODES}${_sep}${_code}"
NEW_NAMES="${NEW_NAMES}${_sep}${SELECTED[$_code]}"
NEW_NAMES="${NEW_NAMES}${_sep}${SELECTED[$_code]%%|*}"
NEW_RATES="${NEW_RATES}${_sep}${SELECTED[$_code]##*|}"
_sep="|"
done
@@ -660,7 +698,7 @@ _pstn_manage_international() {
fi
fi
_pstn_write_intl_allowed_file "$FILE" "$NEW_CODES" "$NEW_NAMES" "$NEW_EXPIRES"
_pstn_write_intl_allowed_file "$FILE" "$NEW_CODES" "$NEW_NAMES" "$NEW_EXPIRES" "$NEW_RATES"
chmod 664 "$FILE"
}
@@ -715,7 +753,7 @@ _pstn_check_killswitch_clear() {
# _pstn_install_periodic_timer above) ───────────────────────────────────────
_pstn_write_usage_alert_script() {
local FILE="$1" EA_DIR="$2" ASTERISK_DIR="$3" RATE="$4" MONTH_THRESHOLD="$5" \
BURST_THRESHOLD="$6" MAX_MONTHLY_SPEND="$7" NTFY_URL="$8"
BURST_THRESHOLD="$6" MAX_MONTHLY_SPEND="$7" NTFY_URL="$8" CONTAINER_NAME="${9:-easy-asterisk}"
cat > "$FILE" << 'EOF'
#!/bin/bash
# Auto-generated by services/pstn-trunk.sh — do not edit directly, re-run
@@ -726,16 +764,28 @@ _pstn_write_usage_alert_script() {
# - 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;
# - estimates outbound spend as DOMESTIC minutes x RATE, PLUS each
# international destination's own minutes x its own admin-entered rate
# (pstn-intl-allowed.conf's allowed_rates) — a single blended rate badly
# under/over-estimates international cost, which is usually far from
# the domestic rate. Still an estimate (rates you entered, not fetched
# live from the provider — no confirmed rates API exists for either
# supported provider as of this writing).
# - 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;
# - while tripped, actively hangs up any PSTN calls already in progress
# (docker exec + Asterisk's "channel request hangup") instead of only
# blocking new ones — otherwise a call already connected when the cap
# is crossed just keeps running (and costing) until whoever's on it
# hangs up naturally, since the dialplan only gates the START of a call;
# - 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
# These are estimates (call count/duration x entered rates), 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.
@@ -750,6 +800,7 @@ MONTH_THRESHOLD="__PSTN_MONTH_THRESHOLD__"
BURST_THRESHOLD="__PSTN_BURST_THRESHOLD__"
MAX_MONTHLY_SPEND="__PSTN_MAX_MONTHLY_SPEND__"
NTFY_URL="__PSTN_NTFY_URL__"
CONTAINER_NAME="__PSTN_CONTAINER_NAME__"
send_ntfy() {
[[ -n "$NTFY_URL" ]] && curl -m 5 -s -d "$1" "$NTFY_URL" >/dev/null 2>&1
@@ -758,16 +809,95 @@ 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
}
# Hangs up every currently-active channel on the pstn-trunk PJSIP endpoint —
# used only once the kill-switch is tripped, as a backstop against a call
# that was already in progress when the cap was crossed. "core show channels
# concise" is Asterisk's long-documented machine-parsable channel listing
# (bang-delimited, channel name always first field); hanging up the
# trunk-side leg of a bridged call tears down both legs.
_pstn_hangup_active_trunk_calls() {
docker exec "$CONTAINER_NAME" asterisk -rx "core show channels concise" 2>/dev/null \
| awk -F'!' '$1 ~ /^PJSIP\/pstn-trunk-/ {print $1}' \
| while read -r ch; do
[[ -n "$ch" ]] && docker exec "$CONTAINER_NAME" asterisk -rx "channel request hangup ${ch}" >/dev/null 2>&1
done
}
current_month=$(date +%Y-%m)
# ── International allow-list: read once, used both for cost bucketing
# below and for the expiry check further down ─────────────────────────────
intl_codes=""
intl_rates_raw=""
if [[ -f "$INTL_FILE" ]]; then
intl_codes=$(grep '^allowed_codes=' "$INTL_FILE" | head -1 | cut -d= -f2-)
intl_rates_raw=$(grep '^allowed_rates=' "$INTL_FILE" | head -1 | cut -d= -f2-)
fi
declare -A INTL_RATE_MAP=()
INTL_CODES_SORTED=""
if [[ -n "$intl_codes" ]]; then
IFS='|' read -ra _codes_arr <<< "$intl_codes"
IFS='|' read -ra _rates_arr <<< "$intl_rates_raw"
for _i in "${!_codes_arr[@]}"; do
INTL_RATE_MAP["${_codes_arr[$_i]}"]="${_rates_arr[$_i]:-0}"
done
# Longest code first, so e.g. a 4-digit code is tried before a 2-digit
# code that happens to be its prefix.
INTL_CODES_SORTED=$(printf '%s\n' "${_codes_arr[@]}" | awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2- | xargs)
fi
month_cost="0.00"
hour_calls=0
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}')
# ── Bucket this month's outbound seconds: domestic vs. per-country
# international, by longest-prefix match on the dialed digits (after the
# "011" prefix). Domestic entries never start with "011" (NANP dialing
# is always "1" + 10 digits), so the two are unambiguous. An
# international call whose destination code isn't (or is no longer) on
# the allow-list is excluded from the cost estimate entirely — a known
# undercount if the allow-list changed after the call was placed, since
# there's no other rate to charge it at.
domestic_seconds=0
declare -A INTL_SECONDS=()
while IFS='|' read -r ep dir who what secs; do
[[ "$dir" != "out" ]] && continue
(( ep < month_start_epoch )) && continue
if [[ "$what" == 011* ]]; then
digits="${what:3}"
matched=""
for code in $INTL_CODES_SORTED; do
if [[ "$digits" == "$code"* ]]; then
matched="$code"
break
fi
done
[[ -n "$matched" ]] && INTL_SECONDS["$matched"]=$(( ${INTL_SECONDS["$matched"]:-0} + secs ))
else
domestic_seconds=$(( domestic_seconds + secs ))
fi
done < "$LOG_FILE"
domestic_minutes=$(awk -v s="$domestic_seconds" 'BEGIN{printf "%.2f", s/60}')
domestic_cost=$(awk -v m="$domestic_minutes" -v r="$RATE" 'BEGIN{printf "%.2f", m*r}')
intl_cost="0.00"
intl_total_seconds=0
for code in "${!INTL_SECONDS[@]}"; do
secs="${INTL_SECONDS[$code]}"
intl_total_seconds=$(( intl_total_seconds + secs ))
rate="${INTL_RATE_MAP[$code]:-0}"
mins=$(awk -v s="$secs" 'BEGIN{printf "%.2f", s/60}')
c=$(awk -v m="$mins" -v r="$rate" 'BEGIN{printf "%.2f", m*r}')
intl_cost=$(awk -v a="$intl_cost" -v b="$c" 'BEGIN{printf "%.2f", a+b}')
done
month_cost=$(awk -v a="$domestic_cost" -v b="$intl_cost" 'BEGIN{printf "%.2f", a+b}')
month_minutes=$(awk -v d="$domestic_seconds" -v i="$intl_total_seconds" 'BEGIN{printf "%.1f", (d+i)/60}')
hour_calls=$(awk -F'|' -v start="$one_hour_ago" '$2=="out" && $1+0>=start {c++} END{print c+0}' "$LOG_FILE")
last_alert_month=""
@@ -775,7 +905,7 @@ if [[ -f "$LOG_FILE" ]]; then
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."
send_ntfy "PSTN trunk: estimated spend this month (\$${month_cost} - \$${domestic_cost} domestic + \$${intl_cost} international) has crossed the \$${MONTH_THRESHOLD} threshold. ${month_minutes} minutes so far."
echo "$current_month" > "$STATE_FILE"
fi
fi
@@ -783,39 +913,49 @@ if [[ -f "$LOG_FILE" ]]; then
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
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"
# ── Spend-cap kill-switch: trip, or warn once approaching it. Deliberately
# OUTSIDE the "if log file exists" block above — the hangup sweep below
# must still run every minute while tripped even if the call log is
# missing/rotated, otherwise a missing log file would silently stop the
# active-hangup backstop entirely. month_cost/hour_calls default to 0/0
# when there's no log file (set before that block), so this still can't
# falsely trip on no data. ─────────────────────────────────────────────────
# 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
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
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)."
already_tripped="1"
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
# Every run while tripped, not just the run that trips it — closes
# the race where a call started just before the flag went live.
[[ "$already_tripped" == "1" ]] && _pstn_hangup_active_trunk_calls
fi
# ── 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-)
if [[ -n "$intl_codes" ]]; then
intl_expires=$(grep '^expires=' "$INTL_FILE" | head -1 | cut -d= -f2-)
today=$(date +%Y-%m-%d)
if [[ -n "$intl_codes" && -n "$intl_expires" ]]; then
if [[ -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
@@ -823,14 +963,14 @@ if [[ -f "$INTL_FILE" ]]; then
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"
sed -i 's/^allowed_codes=.*/allowed_codes=/; s/^allowed_names=.*/allowed_names=/; s/^allowed_rates=.*/allowed_rates=/; 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#__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#__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; s/__PSTN_CONTAINER_NAME__/${CONTAINER_NAME}/g" "$FILE"
sed -i "s#__PSTN_NTFY_URL__#${NTFY_URL}#g" "$FILE"
chmod 755 "$FILE"
}
@@ -896,7 +1036,7 @@ _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}" MAX_MONTHLY_SPEND="${12:-0}"
local PROVIDER_NAME="${11}" MAX_MONTHLY_SPEND="${12:-0}" CONTAINER_NAME="${13:-easy-asterisk}"
_pstn_patch_vendor_files "$EA_DIR" || return 1
@@ -904,7 +1044,7 @@ _pstn_apply_settings() {
_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" "$ASTERISK_DIR" \
"$RATE" "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$MAX_MONTHLY_SPEND" "$NTFY_URL"
"$RATE" "$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$MAX_MONTHLY_SPEND" "$NTFY_URL" "$CONTAINER_NAME"
ensure_docker_dir_ownership "$ASTERISK_DIR"
chmod 644 "$ASTERISK_DIR/pstn-trunk-pjsip.conf" "$ASTERISK_DIR/pstn-trunk-dialplan.conf"
@@ -925,6 +1065,7 @@ RATE_PER_MIN="${RATE}"
MONTH_THRESHOLD="${MONTH_THRESHOLD}"
BURST_THRESHOLD="${BURST_THRESHOLD}"
MAX_MONTHLY_SPEND="${MAX_MONTHLY_SPEND}"
CONTAINER_NAME="${CONTAINER_NAME}"
ENV
chown "$ACTUAL_USER:$ACTUAL_USER" "$EA_DIR/.pstn-trunk.env" 2>/dev/null || true
@@ -1020,7 +1161,7 @@ install_pstn-trunk() {
_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" "${MAX_MONTHLY_SPEND:-0}" || return 1
"$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" "${MAX_MONTHLY_SPEND:-0}" "$CONTAINER_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"
@@ -1240,7 +1381,7 @@ install_pstn-trunk() {
_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" "$MAX_MONTHLY_SPEND" || return 1
"$MONTH_THRESHOLD" "$BURST_THRESHOLD" "$PROVIDER_NAME" "$MAX_MONTHLY_SPEND" "$CONTAINER_NAME" || return 1
_pstn_write_permissions_file "$PERMISSIONS_FILE" "$FULL_EXTS" "$MESSAGING_EXTS" "${RESTRICTED_ARGS[@]}"
_pstn_write_limits_file "$LIMITS_FILE" "$MAX_OUTBOUND" "$MAX_INBOUND"
@@ -1269,6 +1410,15 @@ 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.
While tripped, every run of \`pstn-trunk-usage-alert.sh\` (every minute) also
force-hangs-up any PSTN call already in progress (via \`docker exec ... asterisk
-rx \"channel request hangup ...\"\` against the trunk's active channels) —
not just new calls. Otherwise a call already connected when the cap is
crossed would just keep running (and costing) until it ends naturally,
since the dialplan only gates the *start* of a call. This bounds worst-case
overage to roughly one check interval's cost across whatever's active,
rather than open-ended.
**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
@@ -1276,7 +1426,8 @@ 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."
a strong safety net, not an absolute guarantee against any overage. See
\"How bulletproof is this?\" below."
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
@@ -1284,12 +1435,23 @@ a strong safety net, not an absolute guarantee against any overage."
_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//|/, }
local -a _doc_codes _doc_names _doc_rates
IFS='|' read -ra _doc_codes <<< "$_PSTN_CUR_CODES"
IFS='|' read -ra _doc_names <<< "$_PSTN_CUR_NAMES"
IFS='|' read -ra _doc_rates <<< "$_PSTN_CUR_RATES"
INTL_DOC="**Currently allowed:**
"
local _di
for _di in "${!_doc_codes[@]}"; do
INTL_DOC="${INTL_DOC}- ${_doc_names[$_di]:-+${_doc_codes[$_di]}} — \$${_doc_rates[$_di]:-0}/min
"
done
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)."
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."
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."
@@ -1440,6 +1602,28 @@ dashboard.
$KILLSWITCH_DOC
### How bulletproof is this?
Not fully — be clear-eyed about what's actually guaranteed vs. estimated:
- **Genuinely hard:** your provider's own prepaid-balance block (confirmed
in writing for Anveo Direct: all calls, in and out, blocked in real time
at \$0 balance — ask the same question of any other provider before
trusting it). That's a real ceiling enforced outside this repo's code
entirely.
- **Not hard:** the kill-switch above only enforces *the dollar figure you
typed in here* — a separate, smaller number than your actual account
balance. It's estimate-based (call count/duration × rates you entered,
not the provider's real billing), checked once a minute, and a call
already in progress is only invisible to the estimator until it either
ends or gets force-hung-up on the next check (see the active-hangup
behavior above — that closes most, not all, of the gap).
- The one setup that's actually bulletproof against losing more than \$X:
fund the prepaid account with exactly \$X (top it up manually, no
auto-recharge) and let the provider's own \$0 block be the real ceiling.
Treat this kill-switch as a fast early-warning/second layer on top of
that, not the guarantee itself.
## International calling (beyond NANP/US)
$INTL_DOC