From 2d9dd80271ad813547929edfc2fd6ef94cefdb8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 15:52:08 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf --- docs/pstn-calling-voipms-plan.md | 44 +++++ services/pstn-trunk.sh | 298 +++++++++++++++++++++++++------ 2 files changed, 285 insertions(+), 57 deletions(-) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index c5a9535..99c52af 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -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). diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 04f8416..2b29cab 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -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