From fcb01a4d9343bc958912dbf2b5bf06996172a97a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 05:03:13 +0000 Subject: [PATCH 1/4] Add personal/per-extension DID assignment ("their own numbers") Multiple DIDs can now share one trunk/account, each assigned to a specific extension - additive to the existing shared trunk DID/ring-group, which keeps working unchanged for everyone regardless of what's assigned here. - New pstn-personal-dids.conf (DID -> owner extension), read live by the dialplan for inbound routing: a call to a personal DID routes straight to its owner, checked against the owner's own tier/approved-numbers, no ring-group fallback since it's that extension's own line. - New personal_did= field per extension in pstn-permissions.conf: the outbound Caller-ID override, used by pstn_check_busy (the shared exit point for both domestic and international dialing) instead of the shared trunk DID when the calling extension has one assigned. - Both files kept in sync automatically by one write path - CLI prompt at install/update, or a new "Personal numbers" card in the Security Dashboard's PSTN Trunk tab - rather than requiring hand-editing both consistently. Reassigning a DID or giving an extension a new one cleanly drops the stale side of the old mapping. - Assigning a DID to an internal-tier extension is accepted but warned about (won't ring anyone until also granted full/restricted tier), matching this repo's permissive-with-warnings style rather than blocking the action outright. Also fixed a real bug found while building this: security-dashboard.sh's write_permission() did cp.remove_section(ext) whenever tier was set to "internal", silently discarding any messaging=yes or personal_did= already set on that extension. Now removes only the tier/allowed_numbers keys, dropping the section only once nothing else remains in it. Separately, de-anchored pstn-trunk.sh's install prompts from VoIP.ms as the implicit default (provider name default, server-hostname example wording, rate-prompt wording) now that Anveo Direct is an equally confirmed, tested provider option. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf --- docs/pstn-calling-voipms-plan.md | 29 ++++ services/pstn-trunk.sh | 205 ++++++++++++++++++++---- services/security-dashboard.sh | 260 ++++++++++++++++++++++++++++--- 3 files changed, 442 insertions(+), 52 deletions(-) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index 99c52af..e82ad8b 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -475,3 +475,32 @@ generator output. Fixed by quoting every value in that heredoc. `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). +15. ~~Multiple DIDs, one per extension ("personal numbers")~~ Done — + additive to the existing shared trunk DID/ring-group, not a + replacement. Anveo Direct's DID pricing (~$0.15/mo + $0.25 setup on the + Per Minute plan) makes "everyone gets their own number" genuinely cheap + at personal-use volumes, and multiple DIDs sharing one trunk/account is + exactly what that plan is built for (10 dedicated incoming channels + bundled in). New `pstn-personal-dids.conf` (DID -> owner extension, + read live by the dialplan for inbound routing) plus a `personal_did=` + field per extension in `pstn-permissions.conf` (the outbound Caller-ID + override) — both kept in sync automatically by a single write path + (CLI prompt at install/update, or the Security Dashboard's "PSTN + Trunk" tab), never requiring the admin to hand-edit both files + consistently. Inbound: a call to a personal DID routes straight to its + owner, checked against the *owner's own* tier/approved-numbers — no + ring-group fallback, since a personal DID isn't the shared line. + Outbound: `pstn_check_busy` (the one shared exit point for both + domestic and international dialing) looks up the calling extension's + `personal_did` and uses it as `CALLERID(num)` instead of the shared + trunk DID when one is assigned. A personal DID assigned to an + internal-tier extension is accepted but silently never rings anyone + until that extension is also granted full/restricted tier — both the + CLI and the dashboard warn about this at assignment time rather than + blocking it, matching this repo's general permissive-with-warnings + style. Caught and fixed a real bug while building this: the + dashboard's `write_permission()` did `cp.remove_section(ext)` when + tier was set to "internal", which silently discarded any + `messaging=yes` or `personal_did=` already on that extension — fixed + to remove only the tier/allowed_numbers keys, dropping the section + only once nothing else is left in it. diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 2b29cab..6188bbb 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -11,9 +11,9 @@ # never gated by any of the above, regardless of tier — the trunk is purely # an additional path out to/in from the real phone network. # -# Defaults to VoIP.ms (see docs/pstn-calling-voipms-plan.md for the design/ -# cost background this is built from) but isn't hardcoded to it — any SIP -# trunk provider that supports IP authentication works the same way. +# Provider-agnostic — any SIP trunk provider that supports IP authentication +# works the same way. VoIP.ms and Anveo Direct are both confirmed working; +# see docs/pstn-calling-voipms-plan.md for the design/cost background. # # Requires an existing services/asterisk-digital-ocean.sh OR services/asterisk.sh # install — this adds a PSTN trunk on top of one of them and does not stand @@ -23,7 +23,7 @@ # # Part of the modular post-install system (sourced by setup.sh). -register_service pstn-trunk homelab "SIP PSTN trunk for asterisk-digital-ocean/asterisk — US-only, per-extension permission tiers, spend/volume alerts (defaults to VoIP.ms)" +register_service pstn-trunk homelab "SIP PSTN trunk for asterisk-digital-ocean/asterisk — US-only, per-extension permission tiers, spend/volume alerts (any IP-authenticated provider — VoIP.ms and Anveo Direct both confirmed)" # ── Surviving Easy Asterisk's regeneration ────────────────────────────────── # Easy Asterisk (the vendor project asterisk-digital-ocean.sh/asterisk.sh @@ -306,7 +306,8 @@ exten => pstn_check_busy,1,Set(PSTN_MAX_OUT=${AST_CONFIG(pstn-limits.conf,limits same => n,Set(PSTN_MAX_OUT=${IF($["${PSTN_MAX_OUT}" = ""]?10:${PSTN_MAX_OUT})}) same => n,GotoIf($[${GROUP_COUNT(pstn-out)} >= ${PSTN_MAX_OUT}]?pstn_busy,1) same => n,Set(GROUP()=pstn-out) - same => n,Set(CALLERID(num)=__PSTN_DID__) + same => n,Set(PSTN_PERSONAL_CID=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},personal_did)}) + same => n,Set(CALLERID(num)=${IF($["${PSTN_PERSONAL_CID}" = ""]?__PSTN_DID__:${PSTN_PERSONAL_CID})}) same => n,Set(PSTN_START=${EPOCH}) same => n,Dial(PJSIP/${EXTEN}@pstn-trunk,60) same => n,Set(PSTN_DUR=$[${EPOCH} - ${PSTN_START}]) @@ -340,9 +341,11 @@ EOF cat >> "$FILE" << 'EOF' [from-pstn-trunk] -exten => _X.,1,NoOp(Inbound PSTN call from ${CALLERID(num)}) +exten => _X.,1,NoOp(Inbound PSTN call from ${CALLERID(num)} to ${EXTEN}) 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_PERSONAL_OWNER=${AST_CONFIG(pstn-personal-dids.conf,${EXTEN},owner)}) + same => n,GotoIf($["${PSTN_PERSONAL_OWNER}" != ""]?pstn_personal_inbound,1) same => n,Set(PSTN_RING_LIST=) same => n,Set(PSTN_RING_SEP=) EOF @@ -376,6 +379,29 @@ __ALERT_BUSY_IN_LINE__ exten => pstn_in_killed,1,NoOp(PSTN trunk - spend-cap kill-switch is tripped, rejecting inbound call) __ALERT_KILLED_IN_LINE__ same => n,Hangup() + +; Personal DID inbound routing — rings ONLY the assigned owner, gated by +; that owner's own tier/approved-numbers (same check every ring-group +; member gets, just for a single specific target instead of a list, and +; with no fallback to the shared ring-group if the owner can't take this +; call — it's their own number, not the shared line). +exten => pstn_personal_inbound,1,Set(PSTN_OWNER_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_PERSONAL_OWNER},tier)}) + same => n,GotoIf($["${PSTN_OWNER_TIER}" = "full"]?pstn_personal_ring,1) + same => n,Set(PSTN_OWNER_ALLOWED=${AST_CONFIG(pstn-permissions.conf,${PSTN_PERSONAL_OWNER},allowed_numbers)}) + same => n,GotoIf($["${PSTN_OWNER_TIER}" = "restricted" & ${REGEX("^(${PSTN_OWNER_ALLOWED})$" ${CALLERID(num)})}=1]?pstn_personal_ring,1) + same => n,NoOp(Denied - personal DID ${EXTEN}'s owner ${PSTN_PERSONAL_OWNER} not authorized for this caller) +__ALERT_DENY_PERSONAL_LINE__ + same => n,Hangup() + +exten => pstn_personal_ring,1,Set(PSTN_MAX_IN=${AST_CONFIG(pstn-limits.conf,limits,max_inbound)}) + same => n,Set(PSTN_MAX_IN=${IF($["${PSTN_MAX_IN}" = ""]?10:${PSTN_MAX_IN})}) + same => n,GotoIf($[${GROUP_COUNT(pstn-in)} >= ${PSTN_MAX_IN}]?pstn_in_busy,1) + same => n,Set(GROUP()=pstn-in) + same => n,Set(PSTN_START=${EPOCH}) + same => n,Dial(PJSIP/${PSTN_PERSONAL_OWNER},20) + same => n,Set(PSTN_DUR=$[${EPOCH} - ${PSTN_START}]) + same => n,System(printf '%s|in|%s|%s|%s\n' "${PSTN_START}" "${CALLERID(num)}" "${EXTEN}" "${PSTN_DUR}" >> /var/log/asterisk/pstn-trunk-calls.log) + same => n,Hangup() EOF if [[ -n "$NTFY_URL" ]]; then @@ -383,8 +409,9 @@ EOF 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" + sed -i "s#__ALERT_DENY_PERSONAL_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: inbound call to a personal DID rejected - owner not authorized for this caller.' '${_esc_url2}' >/dev/null 2>\\&1 \\&)#" "$FILE" else - sed -i "/__ALERT_DENY_INBOUND_LINE__/d; /__ALERT_BUSY_IN_LINE__/d; /__ALERT_KILLED_IN_LINE__/d" "$FILE" + sed -i "/__ALERT_DENY_INBOUND_LINE__/d; /__ALERT_BUSY_IN_LINE__/d; /__ALERT_KILLED_IN_LINE__/d; /__ALERT_DENY_PERSONAL_LINE__/d" "$FILE" fi } @@ -411,18 +438,30 @@ _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, space-separated MESSAGING_EXTS, then -# "ext" "pipe|separated|numbers" pairs for each restricted extension. +# Args: FILE, space-separated FULL_EXTS, space-separated MESSAGING_EXTS, +# space-separated "ext=did" PERSONAL_DID_ASSIGNMENTS, then "ext" +# "pipe|separated|numbers" pairs for each restricted extension. _pstn_write_permissions_file() { - local FILE="$1" FULL_EXTS="$2" MESSAGING_EXTS="$3" - shift 3 + local FILE="$1" FULL_EXTS="$2" MESSAGING_EXTS="$3" PERSONAL_DID_ASSIGNMENTS="$4" + shift 4 + local -A _personal_did_map=() + local _pd_token + for _pd_token in $PERSONAL_DID_ASSIGNMENTS; do + _personal_did_map["${_pd_token%%=*}"]="${_pd_token#*=}" + done local _written_exts="" { - 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 "; PSTN permission tiers — internal / restricted / full — PLUS two independent" + echo "; axes per extension:" + echo "; - 'messaging' for Asterisk's native internal SIP MESSAGE texting (no carrier" + echo "; SMS, no PSTN, no cost — a separate axis from PSTN calling, since the risk" + echo "; profile is different: an extension can be internal-tier for calling and" + echo "; still messaging-enabled, or vice versa)." + echo "; - 'personal_did' assigns this extension its own DID (see" + echo "; pstn-personal-dids.conf, which the dialplan reads for inbound routing —" + echo "; this key here is only the OUTBOUND Caller-ID override). A personal DID" + echo "; only actually rings anyone if its owner is also full or restricted tier —" + echo "; internal tier means no PSTN either way, personal DID or not." 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" @@ -438,6 +477,7 @@ _pstn_write_permissions_file() { echo "[$_ext]" echo "tier=full" [[ " $MESSAGING_EXTS " == *" $_ext "* ]] && echo "messaging=yes" + [[ -n "${_personal_did_map[$_ext]:-}" ]] && echo "personal_did=${_personal_did_map[$_ext]}" echo "" _written_exts="$_written_exts $_ext" done @@ -448,6 +488,7 @@ _pstn_write_permissions_file() { echo "tier=restricted" echo "allowed_numbers=${_nums}" [[ " $MESSAGING_EXTS " == *" $_ext "* ]] && echo "messaging=yes" + [[ -n "${_personal_did_map[$_ext]:-}" ]] && echo "personal_did=${_personal_did_map[$_ext]}" echo "" _written_exts="$_written_exts $_ext" done @@ -455,6 +496,15 @@ _pstn_write_permissions_file() { if [[ " $_written_exts " != *" $_ext "* ]]; then echo "[$_ext]" echo "messaging=yes" + [[ -n "${_personal_did_map[$_ext]:-}" ]] && echo "personal_did=${_personal_did_map[$_ext]}" + echo "" + _written_exts="$_written_exts $_ext" + fi + done + for _ext in "${!_personal_did_map[@]}"; do + if [[ " $_written_exts " != *" $_ext "* ]]; then + echo "[$_ext]" + echo "personal_did=${_personal_did_map[$_ext]}" echo "" fi done @@ -462,6 +512,36 @@ _pstn_write_permissions_file() { chmod 664 "$FILE" } +# ── Shared: personal DID -> owner-extension mapping (fresh install / explicit +# reset only — same "update never touches it" protection as +# pstn-permissions.conf). Args: FILE, then "did" "owner" pairs. +_pstn_write_personal_dids_file() { + local FILE="$1" + shift + { + echo "; Personal DID -> owner-extension mapping. Read LIVE by the dialplan" + echo "; (AST_CONFIG()) on every inbound call — no restart needed. An inbound call" + echo "; to a DID listed here routes directly to its owner, checked against the" + echo "; owner's OWN tier/approved-numbers in pstn-permissions.conf — no ring-group" + echo "; fallback, since this is that extension's own number, not the shared line." + echo "; The matching outbound Caller-ID override lives in pstn-permissions.conf" + echo "; ('personal_did=' per extension) — kept in sync automatically whenever a" + echo "; DID is assigned/removed via the CLI installer or the Security Dashboard's" + echo "; PSTN Trunk tab, rather than hand-editing both files separately." + echo "; The shared trunk DID keeps working as the main/ring-group line regardless" + echo "; of anything assigned here." + echo "" + while [[ $# -gt 0 ]]; do + local _did="$1" _owner="$2" + shift 2 + echo "[$_did]" + echo "owner=$_owner" + echo "" + done + } > "$FILE" + chmod 664 "$FILE" +} + # ── Shared: kill-switch state (fresh install / explicit reset only — same # "update never touches it" protection as pstn-permissions.conf/ # pstn-limits.conf) ───────────────────────────────────────────────────────── @@ -1090,13 +1170,14 @@ install_pstn-trunk() { 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 PERSONAL_DIDS_FILE="$ASTERISK_DIR/pstn-personal-dids.conf" local SETTINGS_FILE="$EA_DIR/.pstn-trunk.env" local CONTAINER_NAME="easy-asterisk" [[ "$ASTERISK_KIND" == "asterisk-digital-ocean" ]] && CONTAINER_NAME="easy-asterisk-do" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would require an existing asterisk-digital-ocean OR asterisk (LAN) install" - echo "[DRY-RUN] Would prompt for: SIP provider name (default VoIP.ms), server/POP hostname, DID," + echo "[DRY-RUN] Would prompt for: SIP provider name (any IP-authenticated provider), 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," @@ -1133,15 +1214,17 @@ install_pstn-trunk() { log_warning "A static IP from your ISP avoids that; asterisk-digital-ocean sidesteps it entirely." fi - log_info "Configuring a SIP PSTN trunk for $ASTERISK_KIND (defaults to VoIP.ms)." + log_info "Configuring a SIP PSTN trunk for $ASTERISK_KIND (any IP-authenticated provider —" + log_info "VoIP.ms and Anveo Direct are both confirmed working; see docs/pstn-calling-voipms-plan.md)." log_info "US-only outbound (NANP dialplan), a concurrent-call cap, per-extension permission" log_info "tiers, an inbound ring-group, and ntfy alerts on denied/rejected calls plus" log_info "spend/volume checks." echo "" log_warning "Before continuing, on your provider's side you should already have: created an" log_warning "account, funded and set up prepaid billing with auto-recharge OFF (VoIP.ms: Client" - log_warning "Area -> Balance Management), ordered a DID with IP authentication pointed at this" - log_warning "box's public IP, and picked a server/POP. Also restrict outbound routing to" + log_warning "Area -> Balance Management; Anveo Direct: fund the account balance directly)," + log_warning "ordered a DID with IP authentication pointed at this box's public IP, and picked" + log_warning "a server/POP. Also restrict outbound routing to" log_warning "US/NANP on the provider's own side if it offers that — this dialplan is the second," log_warning "independent layer, not a substitute for the first." log_warning "See docs/pstn-calling-voipms-plan.md for the full background." @@ -1200,7 +1283,7 @@ install_pstn-trunk() { # ── Prompts — provider account details aren't scriptable, set up manually # on the provider's own site first (see warning above) ─────────────────── local PROVIDER_NAME="" - prompt_text "SIP trunk provider name (for your reference/docs only):" "VoIP.ms" PROVIDER_NAME + prompt_text "SIP trunk provider name (for your reference/docs only — e.g. VoIP.ms, Anveo Direct):" "" PROVIDER_NAME local TRUNK_SERVER="" prompt_text "Server/POP hostname (e.g. atlanta2.voip.ms for VoIP.ms — pick the one closest to this box from your provider's server list):" "" TRUNK_SERVER @@ -1317,6 +1400,48 @@ install_pstn-trunk() { local MESSAGING_EXTS="" prompt_text "Extensions allowed to use internal SIP messaging (space-separated, blank = none):" "" MESSAGING_EXTS + # ── Personal numbers — optional, additive to the shared trunk DID ────── + # Multiple DIDs can share this one trunk/account. Assigning one to a + # specific extension makes inbound calls to it ring ONLY that extension + # (still gated by that extension's own tier/approved-numbers — a + # personal DID doesn't bypass PSTN permission, it just narrows routing + # from "the shared ring group" to "this one owner"), and makes that + # extension's outbound calls show its own DID as Caller-ID instead of + # the shared one. The shared DID/ring-group above is unaffected either + # way — this is purely additive. + echo "" + echo " Personal numbers (optional): assign specific DIDs to specific extensions." + echo " Inbound calls to that DID ring only its owner; outbound calls from that" + echo " extension show its own DID as Caller-ID. Requires the owner to also be" + echo " full or restricted tier to actually receive anything on it." + local WANT_PERSONAL_DIDS="" + prompt_yn "Assign any personal DIDs now? (y/n):" "n" WANT_PERSONAL_DIDS + local PERSONAL_DID_PAIRS=() PERSONAL_DID_ASSIGNMENTS="" + if [[ "$WANT_PERSONAL_DIDS" =~ ^[Yy]$ ]]; then + local _pd_more="y" + while [[ "$_pd_more" =~ ^[Yy]$ ]]; do + local _pd_did="" _pd_owner="" + prompt_text " DID (10-digit US number, digits only):" "" _pd_did + if [[ "$_pd_did" =~ ^[0-9]{10}$ ]]; then + prompt_text " Owner extension for $_pd_did:" "" _pd_owner + if [[ "$_pd_owner" =~ ^[0-9]+$ ]]; then + PERSONAL_DID_PAIRS+=("$_pd_did" "$_pd_owner") + PERSONAL_DID_ASSIGNMENTS="${PERSONAL_DID_ASSIGNMENTS} ${_pd_owner}=${_pd_did}" + if [[ " $FULL_EXTS $RESTRICTED_EXTS " != *" $_pd_owner "* ]]; then + log_warning "Extension $_pd_owner isn't full/restricted tier yet — it won't actually" + log_warning "receive calls on $_pd_did until you also grant it one of those tiers." + fi + log_success "Will assign $_pd_did to extension $_pd_owner." + else + log_warning "Not a valid extension — skipped." + fi + else + log_warning "Not a valid 10-digit DID — skipped." + fi + prompt_yn " Assign another? (y/n):" "n" _pd_more + done + fi + 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 @@ -1348,7 +1473,7 @@ install_pstn-trunk() { log_info "Spend/volume alert settings (used only to estimate cost and flag unusual usage —" log_info "not billing-accurate, just a safety net)." local RATE_PER_MIN="" - prompt_text " Outbound per-minute rate in USD (VoIP.ms US rate is 0.01):" "0.01" RATE_PER_MIN + prompt_text " Outbound per-minute rate in USD (check your provider's published rate — e.g. VoIP.ms US is ~0.01, Anveo Direct US is ~0.001):" "0.01" RATE_PER_MIN local MONTH_THRESHOLD="" prompt_text " Alert once when estimated spend this month reaches (USD):" "10" MONTH_THRESHOLD local BURST_THRESHOLD="" @@ -1383,9 +1508,10 @@ install_pstn-trunk() { "$RING_EXTS" "$NTFY_URL" "$RATE_PER_MIN" \ "$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_permissions_file "$PERMISSIONS_FILE" "$FULL_EXTS" "$MESSAGING_EXTS" "$PERSONAL_DID_ASSIGNMENTS" "${RESTRICTED_ARGS[@]}" _pstn_write_limits_file "$LIMITS_FILE" "$MAX_OUTBOUND" "$MAX_INBOUND" _pstn_write_killswitch_file "$KILLSWITCH_FILE" + _pstn_write_personal_dids_file "$PERSONAL_DIDS_FILE" "${PERSONAL_DID_PAIRS[@]}" ensure_docker_dir_ownership "$ASTERISK_DIR" # No new firewall rules: the base install already opens SIP (5060/5061) @@ -1461,9 +1587,10 @@ a strong safety net, not an absolute guarantee against any overage. See cat > "$DOC_FILE" << MD # SIP PSTN trunk (add-on to $ASTERISK_KIND) -US-only outbound PSTN calling over a SIP trunk (defaults to VoIP.ms, works -with any IP-authenticated provider), per-extension permission tiers, a -configurable concurrent-call cap, and an inbound ring-group. See +US-only outbound PSTN calling over a SIP trunk (any IP-authenticated +provider — VoIP.ms and Anveo Direct both confirmed working), per-extension +permission tiers, a configurable concurrent-call cap, and an inbound +ring-group. See \`docs/pstn-calling-voipms-plan.md\` in the repo for the full design background, cost estimate, and toll-fraud reasoning. @@ -1656,13 +1783,31 @@ 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. +## Personal numbers + +Multiple DIDs can share this one trunk/account — assign one to a specific +extension and inbound calls to it route straight to that owner, still +gated by the owner's own tier/approved-numbers (no ring-group fallback, +since it's that extension's own line, not the shared one), while that +extension's outbound calls show its own DID as Caller-ID instead of the +shared trunk DID above. Entirely additive: the shared DID/ring-group keeps +working for everyone regardless of what's assigned here. + +$([ "${#PERSONAL_DID_PAIRS[@]}" -gt 0 ] && { local _i; for ((_i=0; _i<${#PERSONAL_DID_PAIRS[@]}; _i+=2)); do echo "- \`${PERSONAL_DID_PAIRS[$_i]}\` -> extension ${PERSONAL_DID_PAIRS[$_i+1]}"; done; } || echo "None assigned yet.") + +Stored in \`config/asterisk/pstn-personal-dids.conf\` (DID -> owner, read live +by the dialplan for inbound routing) and a \`personal_did=\` field per +extension in \`pstn-permissions.conf\` (the outbound Caller-ID override) — +both kept in sync automatically by the CLI installer and the Security +Dashboard's "PSTN Trunk" tab, live, no restart needed. + ## Managing this from a web UI If \`services/security-dashboard.sh\` is installed, its "PSTN Trunk" tab -shows both the per-extension permission tiers and the outbound/inbound -concurrency caps, all editable live — no restart, no reinstall. Install/ -update it any time with \`sudo ./setup.sh security-dashboard\`; it -auto-detects this install. +shows the per-extension permission tiers, the outbound/inbound concurrency +caps, and personal-number assignments, all editable live — no restart, no +reinstall. Install/update it any time with \`sudo ./setup.sh +security-dashboard\`; it auto-detects this install. ## Manual edits diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index d25bef8..6bb851a 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -925,6 +925,51 @@ def list_extensions(): return extensions +def _write_ini_cp(path, header, cp): + """Shared temp-write-then-rename for every live-editable PSTN conf file — + one copy of the atomic-write/error-handling logic instead of repeating + it per file. Returns (ok, error_message_or_None).""" + if not path: + return False, "No Asterisk install detected on this box" + tmp_path = path + ".tmp" + try: + with open(tmp_path, "w") as f: + f.write(header) + cp.write(f) + os.replace(tmp_path, path) + except OSError as e: + try: + os.remove(tmp_path) + except OSError: + pass + return False, "Failed writing %s: %s" % (path, e) + return True, None + + +PERMISSIONS_HEADER = ( + "; PSTN permission tiers - internal / restricted / full - PLUS two\n" + "; independent per-extension axes: messaging (internal SIP MESSAGE\n" + "; texting) and personal_did (outbound Caller-ID override; inbound\n" + "; routing for personal DIDs lives in pstn-personal-dids.conf).\n" + "; Read LIVE by the dialplan on every call (AST_CONFIG()) - no\n" + "; Asterisk restart needed. Managed here (Security Dashboard); also\n" + "; safe to edit by hand. 'sudo ./setup.sh pstn-trunk' update mode\n" + "; never touches this file, only a fresh reinstall does.\n" + "; Any extension not listed here is internal-only (no PSTN) by default.\n\n" +) + +PERSONAL_DIDS_HEADER = ( + "; Personal DID -> owner-extension mapping. Read LIVE by the dialplan\n" + "; (AST_CONFIG()) on every inbound call - no restart needed. Managed here\n" + "; (Security Dashboard); also safe to edit by hand. Kept in sync with\n" + "; pstn-permissions.conf's personal_did= field automatically by\n" + "; write_personal_did()/remove_personal_did() below - editing this file\n" + "; by hand also requires updating that field yourself to match.\n" + "; 'sudo ./setup.sh pstn-trunk' update mode never touches this file, only\n" + "; a fresh reinstall does.\n\n" +) + + def _permissions_path(): return os.path.join(ASTERISK_CONFIG_DIR, "pstn-permissions.conf") if ASTERISK_CONFIG_DIR else None @@ -980,8 +1025,20 @@ def write_permission(ext, tier, numbers_raw): cp = _read_permissions_cp() if tier == "internal": + # Only drop the tier/allowed_numbers keys, NOT the whole section — + # an extension can independently have messaging=yes and/or a + # personal_did assigned, and those must survive a tier change back + # to internal. Confirmed live as a real bug: cp.remove_section(ext) + # here used to silently discard both whenever tier was set to + # internal. Only remove the section itself once nothing else is + # left in it. if cp.has_section(ext): - cp.remove_section(ext) + if cp.has_option(ext, "tier"): + cp.remove_option(ext, "tier") + if cp.has_option(ext, "allowed_numbers"): + cp.remove_option(ext, "allowed_numbers") + if not cp.options(ext): + cp.remove_section(ext) else: if not cp.has_section(ext): cp.add_section(ext) @@ -991,26 +1048,9 @@ def write_permission(ext, tier, numbers_raw): elif cp.has_option(ext, "allowed_numbers"): cp.remove_option(ext, "allowed_numbers") - path = _permissions_path() - tmp_path = path + ".tmp" - try: - with open(tmp_path, "w") as f: - f.write( - "; PSTN permission tiers - internal / restricted / full.\n" - "; Read LIVE by the dialplan on every call (AST_CONFIG()) - no\n" - "; Asterisk restart needed. Managed here (Security Dashboard); also\n" - "; safe to edit by hand. 'sudo ./setup.sh pstn-trunk' update mode\n" - "; never touches this file, only a fresh reinstall does.\n" - "; Any extension not listed here is internal-only (no PSTN) by default.\n\n" - ) - cp.write(f) - os.replace(tmp_path, path) - except OSError as e: - try: - os.remove(tmp_path) - except OSError: - pass - return False, "Failed writing %s: %s" % (path, e) + ok, err = _write_ini_cp(_permissions_path(), PERMISSIONS_HEADER, cp) + if not ok: + return False, err if tier == "restricted" and not clean_numbers: return True, "Saved as restricted with an EMPTY approved list — no PSTN number can reach/be reached by it yet." @@ -1083,6 +1123,118 @@ def write_limits(max_outbound, max_inbound): return True, "Saved" +PERSONAL_DID_RE = re.compile(r"^\d{10}$") + + +def _personal_dids_path(): + return os.path.join(ASTERISK_CONFIG_DIR, "pstn-personal-dids.conf") if ASTERISK_CONFIG_DIR else None + + +def _read_personal_dids_cp(): + cp = configparser.ConfigParser(delimiters=("=",)) + path = _personal_dids_path() + if path and os.path.isfile(path): + try: + cp.read(path) + except configparser.Error: + pass + return cp + + +def list_personal_dids(): + """[{"did": ..., "owner": ...}] for every currently-assigned personal + DID, sorted by DID.""" + cp = _read_personal_dids_cp() + result = [] + for section in cp.sections(): + if not PERSONAL_DID_RE.match(section): + continue + result.append({"did": section, "owner": cp.get(section, "owner", fallback="")}) + result.sort(key=lambda d: d["did"]) + return result + + +def write_personal_did(did, owner): + """Assigns did -> owner, keeping pstn-personal-dids.conf (inbound + routing, read by the dialplan) and pstn-permissions.conf's + personal_did= (outbound Caller-ID override) in sync. One owner has at + most one personal_did (AST_CONFIG() returns a single value per key), so + reassigning a DID to a new owner drops the previous owner's claim on + it, and giving an extension a new personal DID drops whichever one it + had before — this always leaves a clean 1:1 mapping in both files, + rather than requiring the caller to clean up the old assignment + itself.""" + if not ASTERISK_CONFIG_DIR: + return False, "No Asterisk install detected on this box" + did = str(did).strip() + owner = str(owner).strip() + if not PERSONAL_DID_RE.match(did): + return False, "DID must be a 10-digit US number" + if not EXTEN_RE.match(owner): + return False, "Invalid owner extension" + + dids_cp = _read_personal_dids_cp() + perms_cp = _read_permissions_cp() + + for section in perms_cp.sections(): + if section != owner and perms_cp.get(section, "personal_did", fallback="") == did: + perms_cp.remove_option(section, "personal_did") + if not perms_cp.options(section): + perms_cp.remove_section(section) + + for section in list(dids_cp.sections()): + if section != did and dids_cp.get(section, "owner", fallback="") == owner: + dids_cp.remove_section(section) + + if not dids_cp.has_section(did): + dids_cp.add_section(did) + dids_cp.set(did, "owner", owner) + + if not perms_cp.has_section(owner): + perms_cp.add_section(owner) + perms_cp.set(owner, "personal_did", did) + + ok, err = _write_ini_cp(_personal_dids_path(), PERSONAL_DIDS_HEADER, dids_cp) + if not ok: + return False, err + ok, err = _write_ini_cp(_permissions_path(), PERMISSIONS_HEADER, perms_cp) + if not ok: + return False, err + + owner_tier = perms_cp.get(owner, "tier", fallback="internal") + if owner_tier not in ("full", "restricted"): + return True, "Assigned %s to extension %s - note: %s is internal-tier, so it won't actually receive calls on this DID until you also grant it full or restricted tier." % (did, owner, owner) + return True, "Assigned %s to extension %s" % (did, owner) + + +def remove_personal_did(did): + if not ASTERISK_CONFIG_DIR: + return False, "No Asterisk install detected on this box" + did = str(did).strip() + if not PERSONAL_DID_RE.match(did): + return False, "Invalid DID" + + dids_cp = _read_personal_dids_cp() + perms_cp = _read_permissions_cp() + + if dids_cp.has_section(did): + dids_cp.remove_section(did) + + for section in perms_cp.sections(): + if perms_cp.get(section, "personal_did", fallback="") == did: + perms_cp.remove_option(section, "personal_did") + if not perms_cp.options(section): + perms_cp.remove_section(section) + + ok, err = _write_ini_cp(_personal_dids_path(), PERSONAL_DIDS_HEADER, dids_cp) + if not ok: + return False, err + ok, err = _write_ini_cp(_permissions_path(), PERMISSIONS_HEADER, perms_cp) + if not ok: + return False, err + return True, "Removed %s" % did + + INDEX_HTML = """ Security Dashboard @@ -1181,6 +1333,19 @@ INDEX_HTML = """
ExtNameTierApproved numbers (restricted only)
+
+

Personal numbers

+

+ Multiple DIDs can share this one trunk. Assigning a DID to an extension routes inbound calls to that DID straight to its owner (still gated by the owner's own tier/approved-numbers above — no ring-group fallback), and makes that extension's outbound calls show this DID as Caller-ID instead of the shared trunk DID. The shared DID/ring-group keeps working regardless. +

+
+ + + +
+
DIDOwner
+
+
@@ -1346,7 +1511,7 @@ async function loadPstnStatus() { 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(); } + if (data.installed) { loadPstnLimits(); loadPstnPermissions(); loadPersonalDids(); } } async function loadPstnLimits() { @@ -1372,6 +1537,11 @@ async function loadPstnPermissions() { const res = await fetch("/api/pstn-permissions"); const data = await res.json(); const exts = data.extensions || []; + + const ownerSel = document.getElementById("pd-owner"); + ownerSel.innerHTML = exts.map(e => ``).join("") + || ''; + const tbody = document.querySelector("#pstn-table tbody"); if (!exts.length) { tbody.innerHTML = 'No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).'; @@ -1411,6 +1581,42 @@ async function savePstnPermission(ext) { loadPstnPermissions(); } +async function loadPersonalDids() { + const res = await fetch("/api/pstn-personal-dids"); + const data = await res.json(); + const dids = data.dids || []; + const tbody = document.querySelector("#pd-table tbody"); + tbody.innerHTML = dids.map(d => ` + ${esc(d.did)} + ${esc(d.owner)}${d.owner_name ? " — " + esc(d.owner_name) : ""} + + `).join("") || "No personal numbers assigned — every extension shares the main trunk DID."; +} + +document.getElementById("pd-save").addEventListener("click", async () => { + const did = document.getElementById("pd-did").value.trim(); + const owner = document.getElementById("pd-owner").value; + const res = await fetch("/api/pstn-personal-dids", { + method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify({did: did, owner: owner}), + }); + const data = await res.json(); + document.getElementById("pd-msg").textContent = data.message || (data.ok ? "Saved" : "Failed"); + if (data.ok) document.getElementById("pd-did").value = ""; + loadPersonalDids(); +}); + +async function removePersonalDid(did) { + if (!confirm("Remove personal number " + did + "? Its owner falls back to the shared trunk DID for outbound Caller-ID, and this DID stops routing anywhere until reassigned.")) return; + const res = await fetch("/api/pstn-personal-dids/delete", { + method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify({did: did}), + }); + const data = await res.json(); + document.getElementById("pd-msg").textContent = data.message || (data.ok ? "Removed" : "Failed"); + loadPersonalDids(); +} + const adminUrl = "__ASTERISK_ADMIN_URL__"; if (adminUrl) { const link = document.getElementById("admin-link"); @@ -1469,6 +1675,10 @@ class Handler(BaseHTTPRequestHandler): self._json({"extensions": extensions}) elif self.path == "/api/pstn-limits": self._json(get_limits()) + elif self.path == "/api/pstn-personal-dids": + names = {e["ext"]: e["name"] for e in list_extensions()} + dids = [dict(d, owner_name=names.get(d["owner"], "")) for d in list_personal_dids()] + self._json({"dids": dids}) elif self.path == "/api/pstn-status": self._json({"installed": pstn_installed()}) else: @@ -1498,6 +1708,12 @@ class Handler(BaseHTTPRequestHandler): elif self.path == "/api/pstn-limits": ok, message = write_limits(payload.get("max_outbound", ""), payload.get("max_inbound", "")) self._json({"ok": ok, "message": message}) + elif self.path == "/api/pstn-personal-dids": + ok, message = write_personal_did(payload.get("did", ""), payload.get("owner", "")) + self._json({"ok": ok, "message": message}) + elif self.path == "/api/pstn-personal-dids/delete": + ok, message = remove_personal_did(payload.get("did", "")) + self._json({"ok": ok, "message": message}) else: self._json({"error": "not found"}, 404) From 3705fb5fcced22c191c431e44c76be306687f929 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 05:08:30 +0000 Subject: [PATCH 2/4] Add Anveo Direct/VoIP.ms known-provider quick-pick to pstn-trunk.sh Prompts now start with a 1/2/3 choice (Anveo Direct / VoIP.ms / manual) that pre-fills already-known values - Anveo Direct's sbc.anveo.com hostname and its 4 published signaling IPs, so they don't need retyping from memory - plus provider-specific account-setup reminders. Every value stays editable at each subsequent prompt; this only changes defaults, not behavior, so existing manual-entry users see no change. Flagged rather than silently trusted: Anveo Direct's own Outbound Trunks page documents dialing as [PREFIX]PHONENUMBER@sbc.anveo.com, which contradicts an earlier no-prefix-needed finding from their FAQ. The dialplan still dials the bare number (matching the older finding) - the quick-pick prints a loud warning to verify the Prefix field can be left blank before relying on this, rather than guessing and hardcoding prefix handling without live confirmation either way. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf --- docs/pstn-calling-voipms-plan.md | 27 ++++++++++++++++ services/pstn-trunk.sh | 55 +++++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index e82ad8b..c6b54d4 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -504,3 +504,30 @@ generator output. Fixed by quoting every value in that heredoc. `messaging=yes` or `personal_did=` already on that extension — fixed to remove only the tier/allowed_numbers keys, dropping the section only once nothing else is left in it. +16. Known-provider quick-pick — Done. A "1) Anveo Direct / 2) VoIP.ms / + 3) manual" choice at the top of the provider prompts pre-fills known + values (Anveo Direct: `sbc.anveo.com`, the 4 published signaling IPs; + VoIP.ms: just the provider name) — every value stays editable at each + prompt, so this changes defaults only, never behavior. Extensions/ + users remain fully independent of this service either way — they're + created through the base Asterisk install's own device management, + and `pstn-trunk.sh` only grants PSTN permissions/personal numbers to + extensions that already exist. +17. **Open, unresolved**: Anveo Direct's own "Outbound Trunks" configuration + page documents outbound dialing as `[PREFIX]PHONENUMBER@sbc.anveo.com` + — a per-trunk custom prefix. This directly contradicts the earlier + "no dial prefix needed" finding sourced from their FAQ (see the Anveo + Direct provider notes above). Not resolved either way: whether the + Prefix field can be left blank when creating an outbound Call + Termination trunk in their portal. This dialplan dials the bare + number with no prefix, matching the FAQ-sourced finding — if the + Prefix field turns out to require a non-empty value, outbound calls + through that trunk won't match and will fail, and this installer + would need a `PREFIX` setting threaded into `${EXTEN}` before + `Dial()`. Flagged with a loud warning in the CLI's Anveo Direct + quick-pick rather than silently trusting the older finding. Also + unresolved: a Trial Anveo Direct account shows its own "$2 / 30 days" + spending limit and "$2 minimum account balance" in the portal — + unclear whether either changes once the account is verified/funded + beyond Trial status; this sits below and independent of anything this + repo's own kill-switch enforces. diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 6188bbb..a2f1978 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -1177,10 +1177,13 @@ install_pstn-trunk() { if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would require an existing asterisk-digital-ocean OR asterisk (LAN) install" - echo "[DRY-RUN] Would prompt for: SIP provider name (any IP-authenticated provider), server/POP hostname, DID," + echo "[DRY-RUN] Would prompt for: known-provider quick-pick (Anveo Direct/VoIP.ms pre-fill known" + echo "[DRY-RUN] server/signaling-IP values; still editable) or manual entry, SIP provider name, 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] optional personal-number assignments (DID -> owner extension, additive to the" + echo "[DRY-RUN] shared trunk DID), max concurrent outbound/inbound calls (default 10/10)," + echo "[DRY-RUN] inbound ring-group extensions," 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" @@ -1282,11 +1285,53 @@ install_pstn-trunk() { # ── Prompts — provider account details aren't scriptable, set up manually # on the provider's own site first (see warning above) ─────────────────── + # Known-provider quick-path: only pre-fills the defaults below (still + # fully editable at each prompt) — doesn't change any dialplan/auth + # behavior, which stays provider-generic either way. + echo " Known/tested providers — this only pre-fills the defaults below (server" + echo " hostname, signaling IPs, account-setup reminders); every value is still" + echo " editable at each prompt." + echo " 1) Anveo Direct" + echo " 2) VoIP.ms" + echo " 3) Something else / manual entry" + local _provider_choice="" + prompt_text "Choice (1/2/3):" "3" _provider_choice + + local _default_provider_name="" _default_server="" _default_extra_ips="" + case "$_provider_choice" in + 1) + _default_provider_name="Anveo Direct" + _default_server="sbc.anveo.com" + _default_extra_ips="169.48.232.158 204.216.109.55 176.9.39.206 72.9.149.25" + echo "" + log_info "Anveo Direct known values pre-filled below (hostname, 4 published signaling IPs)." + log_warning "Before continuing: create/verify your account at" + log_warning " https://www.anveo.com/account.asp?account_type=direct" + log_warning "fund it, order a DID, and configure at least one outbound Call" + log_warning "Termination trunk in Anveo's own portal (Outbound Trunks page). A Trial" + log_warning "account shows its own \$2/30-day spend limit and \$2 minimum balance —" + log_warning "separate from anything this installer enforces; confirm with Anveo" + log_warning "support whether those change once verified/funded. Their CallerID policy" + log_warning "requires a verified/Anveo-owned number as Caller-ID — any DID you order" + log_warning "through them satisfies that automatically (see this service's personal-" + log_warning "number feature for assigning a specific one per extension)." + log_warning "UNVERIFIED: Anveo's own outbound-trunk page documents dialing as" + log_warning "[PREFIX]PHONENUMBER@sbc.anveo.com (a per-trunk prefix) — this contradicts" + log_warning "an earlier no-prefix-needed finding from their FAQ. This dialplan dials" + log_warning "the bare number, no prefix. Check whether your configured trunk's Prefix" + log_warning "field can be left blank before relying on this — if it can't, outbound" + log_warning "calls through that trunk won't match and will fail." + ;; + 2) + _default_provider_name="VoIP.ms" + ;; + esac + local PROVIDER_NAME="" - prompt_text "SIP trunk provider name (for your reference/docs only — e.g. VoIP.ms, Anveo Direct):" "" PROVIDER_NAME + prompt_text "SIP trunk provider name (for your reference/docs only — e.g. VoIP.ms, Anveo Direct):" "$_default_provider_name" PROVIDER_NAME local TRUNK_SERVER="" - prompt_text "Server/POP hostname (e.g. atlanta2.voip.ms for VoIP.ms — pick the one closest to this box from your provider's server list):" "" TRUNK_SERVER + prompt_text "Server/POP hostname (e.g. atlanta2.voip.ms for VoIP.ms, sbc.anveo.com for Anveo Direct — pick the one closest to this box from your provider's server list):" "$_default_server" TRUNK_SERVER if [[ -z "$TRUNK_SERVER" ]]; then log_error "A server hostname is required — aborting." return 1 @@ -1311,7 +1356,7 @@ install_pstn-trunk() { # dial out to) — the resolved IP above always gets included, this just # adds any others the provider documents. local EXTRA_IPS="" - prompt_text "Any additional known source IPs for inbound calls, space-separated (check your provider's docs — e.g. a firewall/signaling IP list; blank if the resolved IP above is the only one):" "" EXTRA_IPS + prompt_text "Any additional known source IPs for inbound calls, space-separated (check your provider's docs — e.g. a firewall/signaling IP list; blank if the resolved IP above is the only one):" "$_default_extra_ips" EXTRA_IPS local _octet='(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)' local _ip_re="^${_octet}\\.${_octet}\\.${_octet}\\.${_octet}\$" local TRUNK_SERVER_IPS="$TRUNK_SERVER_IP" _ip From 8291eba55e2bef2784fb9f4fe191646e7252b49f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 05:27:58 +0000 Subject: [PATCH 3/4] Make the messaging permission flag live-editable via the dashboard get_all_permissions()/write_permission() now handle messaging alongside tier/allowed_numbers as one save action, and the Security Dashboard's PSTN Trunk table gets a Messaging checkbox column - no more needing to re-run pstn-trunk.sh's CLI just to change who can use internal SIP texting, matching how tier/allowed_numbers/personal_did already worked. Tested that messaging correctly survives tier changes and personal-DID assignment/removal on the same extension (independent axes, as intended). Still explicitly not done, and said so in both READMEs rather than implying otherwise now that there's a nice UI for it: the actual SIP MESSAGE dialplan wiring that would make Asterisk enforce this flag. That gap hasn't changed - only the permission storage/UI layer around it has. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf --- docs/pstn-calling-voipms-plan.md | 11 ++++- services/pstn-trunk.sh | 32 +++++++++---- services/security-dashboard.sh | 78 +++++++++++++++++++++----------- 3 files changed, 83 insertions(+), 38 deletions(-) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index c6b54d4..ec6b488 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -416,8 +416,15 @@ generator output. Fixed by quoting every value in that heredoc. `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. + prompted at install time AND now a checkbox right in the Security + Dashboard's PSTN Trunk permissions table (alongside tier/approved- + numbers) — no need to re-run the CLI installer just to change who can + message. Confirmed it correctly survives tier changes and personal-DID + assignment/removal on the same extension (this is what surfaced the + tier=internal section-wipe bug fixed above). **Not done**: the actual + dialplan wiring that would make Asterisk *enforce* this flag on + inbound `MESSAGE` requests — this flag currently does nothing at the + Asterisk level yet, it's groundwork. 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 diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index a2f1978..10dec8c 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -1816,17 +1816,29 @@ 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}. +${MESSAGING_EXTS:-none}. Live-editable any time via the Security +Dashboard's "PSTN Trunk" tab (a checkbox per extension, right in the same +table as the calling tiers) — no need to re-run this installer just to +change who can message. -**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. +**Won't show up in Easy Asterisk's own web admin, by design** — same as +the PSTN calling tiers, this is a permission this repo layers on top, +not an Easy Asterisk feature, so it's only manageable here or via the +Security Dashboard. + +**Known gap:** the flag above is real and live-editable, but the actual +SIP \`MESSAGE\` routing dialplan wiring — does Asterisk actually deliver/ +gate a message using this flag — 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, so it hasn't been guessed at. If you want this working end to +end, the fastest path is checking a few things on a live box (e.g. +whether an endpoint has \`message_context\` set, and what happens when you +send a test SIP MESSAGE to one) so the dialplan gate can be built against +real behavior instead of assumption — ask if you want to walk through +that. ## Personal numbers diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 6bb851a..c58328b 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -214,13 +214,17 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly. 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. 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. + restricted / full), for restricted its approved numbers, and its + internal-SIP-messaging flag (independent of the calling tier — see + \`services/pstn-trunk.sh\`'s "Known gap" note on messaging for what this + flag does and doesn't do yet) — all editable live, no Asterisk restart, + no reinstall. Also manages personal-number assignments (DID -> owner + extension), additive to the shared trunk DID. Writes directly to + \`pstn-limits.conf\` / \`pstn-permissions.conf\` / \`pstn-personal-dids.conf\`, + which the dialplan reads 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 @@ -986,12 +990,13 @@ def _read_permissions_cp(): def get_all_permissions(): - """{ext: {"tier": ..., "allowed_numbers": "num|num|..."}} for every - extension with a non-internal tier on record. Extensions with no section - are implicitly "internal" — the dialplan's AST_CONFIG() lookup treats a - missing section as empty/denied the same way, so there's nothing to - return for them here; the UI fills in "internal" as the default for any - known extension (from list_extensions()) not present in this dict.""" + """{ext: {"tier": ..., "allowed_numbers": "num|num|...", "messaging": + bool}} for every extension with a non-default record. Extensions with + no section are implicitly "internal"/messaging-disabled — the + dialplan's AST_CONFIG() lookup treats a missing section/key as empty/ + denied the same way, so there's nothing to return for them here; the + UI fills in the defaults for any known extension (from + list_extensions()) not present in this dict.""" cp = _read_permissions_cp() result = {} for section in cp.sections(): @@ -1000,12 +1005,17 @@ def get_all_permissions(): result[section] = { "tier": cp.get(section, "tier", fallback="internal"), "allowed_numbers": cp.get(section, "allowed_numbers", fallback=""), + "messaging": cp.getboolean(section, "messaging", fallback=False), } return result -def write_permission(ext, tier, numbers_raw): - """Saves one extension's tier + (for restricted) approved-number list. +def write_permission(ext, tier, numbers_raw, messaging_enabled=False): + """Saves one extension's tier + (for restricted) approved-number list + + messaging flag in one action — messaging is an independent axis from + the calling tier (see pstn-trunk.sh's file-level comment: an extension + can be internal-tier for calling and still messaging-enabled, or vice + versa), so it's set/cleared regardless of which tier branch runs below. Numbers are normalized to a pipe-separated list of 11-digit US numbers — pipe, not comma, because the dialplan uses this value directly as a REGEX() alternation pattern (see services/pstn-trunk.sh's file-level @@ -1030,15 +1040,12 @@ def write_permission(ext, tier, numbers_raw): # personal_did assigned, and those must survive a tier change back # to internal. Confirmed live as a real bug: cp.remove_section(ext) # here used to silently discard both whenever tier was set to - # internal. Only remove the section itself once nothing else is - # left in it. + # internal. if cp.has_section(ext): if cp.has_option(ext, "tier"): cp.remove_option(ext, "tier") if cp.has_option(ext, "allowed_numbers"): cp.remove_option(ext, "allowed_numbers") - if not cp.options(ext): - cp.remove_section(ext) else: if not cp.has_section(ext): cp.add_section(ext) @@ -1048,6 +1055,19 @@ def write_permission(ext, tier, numbers_raw): elif cp.has_option(ext, "allowed_numbers"): cp.remove_option(ext, "allowed_numbers") + if messaging_enabled: + if not cp.has_section(ext): + cp.add_section(ext) + cp.set(ext, "messaging", "yes") + elif cp.has_section(ext) and cp.has_option(ext, "messaging"): + cp.remove_option(ext, "messaging") + + # Drop the section entirely once nothing (tier, numbers, messaging, + # personal_did) is left in it — only reached this way when tier is + # internal, messaging is off, and no personal_did was ever assigned. + if cp.has_section(ext) and not cp.options(ext): + cp.remove_section(ext) + ok, err = _write_ini_cp(_permissions_path(), PERMISSIONS_HEADER, cp) if not ok: return False, err @@ -1330,7 +1350,10 @@ INDEX_HTML = """ full — internal, plus any US number. Changes apply live, on the next call — no Asterisk restart needed.

-
ExtNameTierApproved numbers (restricted only)
+

+ Messaging — Asterisk's native internal SIP texting (no carrier SMS, no PSTN, no cost), independent of the calling tier. Note: this flag is live-editable here, but whether Asterisk actually delivers/gates messages using it depends on dialplan wiring not yet verified against a live install — see this service's README. +

+
ExtNameTierApproved numbers (restricted only)Messaging
@@ -1544,7 +1567,7 @@ async function loadPstnPermissions() { const tbody = document.querySelector("#pstn-table tbody"); if (!exts.length) { - tbody.innerHTML = 'No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).'; + tbody.innerHTML = 'No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).'; return; } tbody.innerHTML = exts.map(e => ` @@ -1558,6 +1581,7 @@ async function loadPstnPermissions() { + `).join(""); @@ -1572,9 +1596,10 @@ async function savePstnPermission(ext) { const row = document.querySelector(`#pstn-table tr[data-ext="${ext}"]`); const tier = row.querySelector(".pstn-tier").value; const numbers = row.querySelector(".pstn-numbers").value; + const messaging = row.querySelector(".pstn-messaging").checked; const res = await fetch("/api/pstn-permissions", { method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ext: ext, tier: tier, allowed_numbers: numbers}), + body: JSON.stringify({ext: ext, tier: tier, allowed_numbers: numbers, messaging: messaging}), }); const data = await res.json(); document.getElementById("pstn-msg").textContent = (data.message || (data.ok ? "Saved" : "Failed")) + " (extension " + ext + ")"; @@ -1669,9 +1694,9 @@ class Handler(BaseHTTPRequestHandler): perms = get_all_permissions() extensions = [] for e in list_extensions(): - p = perms.get(e["ext"], {"tier": "internal", "allowed_numbers": ""}) - extensions.append({"ext": e["ext"], "name": e["name"], - "tier": p["tier"], "allowed_numbers": p["allowed_numbers"]}) + p = perms.get(e["ext"], {"tier": "internal", "allowed_numbers": "", "messaging": False}) + extensions.append({"ext": e["ext"], "name": e["name"], "tier": p["tier"], + "allowed_numbers": p["allowed_numbers"], "messaging": p["messaging"]}) self._json({"extensions": extensions}) elif self.path == "/api/pstn-limits": self._json(get_limits()) @@ -1702,7 +1727,8 @@ class Handler(BaseHTTPRequestHandler): self._json(ban_asn(payload.get("asn", ""))) elif self.path == "/api/pstn-permissions": ok, message = write_permission( - payload.get("ext", ""), payload.get("tier", ""), payload.get("allowed_numbers", "") + payload.get("ext", ""), payload.get("tier", ""), payload.get("allowed_numbers", ""), + bool(payload.get("messaging", False)) ) self._json({"ok": ok, "message": message}) elif self.path == "/api/pstn-limits": From 7851e2befd1611d54231da143826e6dd7aabd554 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 05:38:36 +0000 Subject: [PATCH 4/4] Decouple internal SIP messaging management from PSTN trunk installation Messaging has no dependency on a PSTN trunk existing (no cost, no carrier, no DID), but the whole PSTN Trunk tab - including the messaging checkbox added last commit - was hidden behind pstn_installed(), which only becomes true once services/pstn-trunk.sh's dialplan is actually wired in. That meant enabling messaging required going through full SIP trunk/provider setup first for no real reason. Added a standalone "Internal SIP messaging" card that's always visible in the tab regardless of trunk status, backed by a new write_messaging() that only touches the messaging key (leaving tier/allowed_numbers/ personal_did untouched) and creates pstn-permissions.conf from scratch if it doesn't exist yet. Confirmed the systemd unit's ReadWritePaths and the group/chmod grants already covered this - both are set up whenever a base Asterisk install is detected, independent of pstn-trunk - so no permission-layer changes were needed, only the dashboard's own artificial UI gate. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf --- services/pstn-trunk.sh | 7 ++- services/security-dashboard.sh | 106 ++++++++++++++++++++++++++++----- 2 files changed, 96 insertions(+), 17 deletions(-) diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 10dec8c..cb1c23b 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -1817,9 +1817,10 @@ 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}. Live-editable any time via the Security -Dashboard's "PSTN Trunk" tab (a checkbox per extension, right in the same -table as the calling tiers) — no need to re-run this installer just to -change who can message. +Dashboard's "PSTN Trunk" tab, in its own always-available "Internal SIP +messaging" card — no need to re-run this installer, and no dependency on +this trunk (or any PSTN trunk at all) being installed, unlike the +calling-permissions table below it in that same tab. **Won't show up in Easy Asterisk's own web admin, by design** — same as the PSTN calling tiers, this is a permission this repo layers on top, diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index c58328b..6daa0db 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -208,18 +208,22 @@ 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** — 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), for restricted its approved numbers, and its - internal-SIP-messaging flag (independent of the calling tier — see - \`services/pstn-trunk.sh\`'s "Known gap" note on messaging for what this - flag does and doesn't do yet) — all editable live, no Asterisk restart, - no reinstall. Also manages personal-number assignments (DID -> owner - extension), additive to the shared trunk DID. Writes directly to +- **PSTN Trunk** — an "Internal SIP messaging" card at the top is always + available, whether or not a PSTN trunk has ever been installed: a + checkbox per known extension (parsed from \`pjsip.conf\`) for + Asterisk's native SIP texting, independent of PSTN calling entirely (no + cost, no carrier, no DID, no dependency on \`services/pstn-trunk.sh\` + having been run — see its "Known gap" note on messaging for what this + flag does and doesn't do yet at the Asterisk level). Below that, the + rest of the tab 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 calling-permissions editor + if not, so it never shows real-looking-but-unenforced defaults. When + installed: the outbound/inbound concurrent-call caps, and every known + extension's permission tier (internal / restricted / full) and, for + restricted, its approved numbers — all editable live, no Asterisk + restart, no reinstall. Also manages personal-number assignments (DID -> + owner extension), additive to the shared trunk DID. Writes directly to \`pstn-limits.conf\` / \`pstn-permissions.conf\` / \`pstn-personal-dids.conf\`, which the dialplan reads fresh on every call. The spend-cap kill-switch and international-calling allow-list are deliberately **not** managed @@ -1077,6 +1081,37 @@ def write_permission(ext, tier, numbers_raw, messaging_enabled=False): return True, "Saved" +def write_messaging(ext, enabled): + """Sets/clears just the messaging flag for one extension, leaving any + tier/allowed_numbers/personal_did untouched. This is the write path for + the standalone "Internal SIP messaging" card, which works whether or + not a PSTN trunk has ever been installed — messaging has no dependency + on one (no cost, no carrier, no DID), unlike the calling-permissions + table this dashboard otherwise gates behind pstn_installed(). Creates + pstn-permissions.conf from scratch if it doesn't exist yet.""" + if not ASTERISK_CONFIG_DIR: + return False, "No Asterisk install detected on this box" + ext = str(ext).strip() + if not EXTEN_RE.match(ext): + return False, "Invalid extension" + + cp = _read_permissions_cp() + if enabled: + if not cp.has_section(ext): + cp.add_section(ext) + cp.set(ext, "messaging", "yes") + elif cp.has_section(ext) and cp.has_option(ext, "messaging"): + cp.remove_option(ext, "messaging") + + if cp.has_section(ext) and not cp.options(ext): + cp.remove_section(ext) + + ok, err = _write_ini_cp(_permissions_path(), PERMISSIONS_HEADER, cp) + if not ok: + return False, err + return True, "Saved" + + LIMIT_RE = re.compile(r"^\d+$") @@ -1327,9 +1362,17 @@ INDEX_HTML = """