From 3661c8fbca9858ed19b796cf8af1d27f663b7e93 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 15:03:03 +0000 Subject: [PATCH] Wire up internal SIP MESSAGE enforcement using a dedicated dialplan context Confirmed against a live install's pjsip.conf/extensions.conf that every endpoint falls back to context=intercom for messaging (message_context blank), and that [intercom] owns one exact-match per-device dial pattern regenerated on every dialplan rebuild. Rather than risk racing that, every endpoint now gets message_context=sip-messaging (patched into both of Easy Asterisk's device-creation code paths, plus a one-time migration for existing devices), routing messages to their own [sip-messaging] context gated on the existing pstn-permissions.conf messaging flag. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf --- docs/pstn-calling-voipms-plan.md | 54 +++++---- services/asterisk-digital-ocean.sh | 184 +++++++++++++++++++++++++++++ services/security-dashboard.sh | 4 +- 3 files changed, 214 insertions(+), 28 deletions(-) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index ec6b488..dddd7d9 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -412,32 +412,34 @@ generator output. Fixed by quoting every value in that heredoc. `full`-tier extensions can use it regardless of which countries are allowed. 11. Internal SIP `MESSAGE` (native Asterisk texting, no carrier SMS/cost) — - **partially done**. The permission layer is real and live-editable: a - `messaging=yes` flag per extension in `pstn-permissions.conf`, - independent of the PSTN calling tiers (an extension can be - internal-tier for calling and still messaging-enabled, or vice versa), - prompted at install time 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 - would take precedence over — or conflict with — Easy Asterisk's own - generated per-device dial patterns in that same context isn't something - that can be safely determined without a live install to test against. - Shipping a guessed pattern risked either silently not working or, worse, - interfering with call-routing precedence for the same extensions. - Treat this the same way as the VoIP.ms live-account verification in - item 6 above: a real gap, flagged rather than papered over, not a - hypothetical. Next step for whoever picks this up: verify message - routing behavior against a live Easy Asterisk container, then wire the - dialplan gate using the existing flag. + **done**. The permission layer: a `messaging=yes` flag per extension in + `pstn-permissions.conf`, independent of the PSTN calling tiers (an + extension can be internal-tier for calling and still messaging-enabled, + or vice versa), prompted at install time AND 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). + The dialplan gap flagged here previously is now closed: a real install's + `pjsip.conf`/`extensions.conf` were pulled (2026-07-23) and confirmed + every endpoint sets `context=intercom` with `message_context` blank + (falls back to `context`), and `[intercom]` gets one exact-match + `exten => ,1,...` per device, freshly regenerated by Easy + Asterisk's own `rebuild_dialplan()` on every dialplan rebuild — exactly + the collision this doc worried about. Solved by NOT sharing + `[intercom]`: `services/asterisk-digital-ocean.sh` now explicitly sets + `message_context=sip-messaging` on every endpoint (patched into both of + Easy Asterisk's device-creation code paths — the CLI menu's bash + heredoc and the web admin's Python `add_device()` — so new devices pick + it up automatically, plus a one-time migration for devices that already + existed) and routes messages to a dedicated `[sip-messaging]` context in + `messaging-dialplan.conf`, gated on the sender's `messaging` flag via + `AST_CONFIG()`. Zero overlap with `[intercom]`'s own call routing. + One piece still flagged rather than papered over: the `MESSAGE(from)` + sender-extraction (`CUT()`-based, written to tolerate a display-name + prefix) hasn't been confirmed against real MESSAGE traffic yet — fails + closed (denies) if it ever parses wrong, but worth a live test. 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 diff --git a/services/asterisk-digital-ocean.sh b/services/asterisk-digital-ocean.sh index 80d0cd7..bd4890d 100755 --- a/services/asterisk-digital-ocean.sh +++ b/services/asterisk-digital-ocean.sh @@ -400,6 +400,150 @@ CRON fi } +# ── Shared: internal SIP MESSAGE routing/enforcement ──────────────────────── +# Confirmed live against a real install's pjsip.conf/extensions.conf +# (2026-07-23): every endpoint sets context=intercom and leaves +# message_context blank, so PJSIP messaging falls back to context=intercom — +# and [intercom] already owns an exact-match `exten => ,1,...` per +# device, freshly regenerated by the vendor's own rebuild_dialplan() on +# every dialplan rebuild. A competing priority-1 declaration for the same +# extension number in a #include'd file would race that (Asterisk doesn't +# merge two independent priority-1 declarations for the same context+exten — +# one silently wins) and risks breaking normal internal calling entirely. +# So this uses its own dedicated [sip-messaging] context instead, reached by +# explicitly setting message_context=sip-messaging on every endpoint, so +# there is never any overlap with [intercom]'s own per-device call routing. +# +# The vendor's device-creation code has exactly two independent code paths +# that write a fresh endpoint block (confirmed via grep — both contain the +# literal line "context=intercom" exactly once): the CLI menu's bash heredoc, +# and the web admin's Python add_device(). Patching the vendor's own +# generator source (same technique as _pstn_patch_vendor_files) makes every +# device added FROM NOW ON pick this up automatically, in either path. +# Devices that already existed before this was installed need one one-time +# migration pass over the live pjsip.conf (below) since they were written +# before the patch existed. +_asterisk_do_patch_messaging_vendor_files() { + local EA_DIR="$1" + local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh" + local EASY1="$EA_DIR/easy-asterisk.sh" + local EASY2 + EASY2="$(find "$EA_DIR" -maxdepth 1 -name 'easy-asterisk-v*.sh' | head -1)" + [[ -z "$EASY2" ]] && EASY2="$EA_DIR/easy-asterisk-v0.10.0.sh" + local f + + for f in "$EASY1" "$EASY2"; do + [[ -f "$f" ]] || { log_error "$f not found — is the base Asterisk install fully set up?"; return 1; } + done + + # Device-creation templates: both occurrences of "context=intercom" in + # these two files (identical vendor source, copied twice) are the CLI + # and web-admin device-creation code paths — a single anchor on the bare + # line patches both in one pass. + for f in "$EASY1" "$EASY2"; do + if ! grep -q '^message_context=sip-messaging$' "$f"; then + if grep -q '^context=intercom$' "$f"; then + sed -i '/^context=intercom$/a message_context=sip-messaging' "$f" + else + log_warning "$(basename "$f"): 'context=intercom' anchor not found — vendor template changed upstream." + log_warning " Add 'message_context=sip-messaging' manually after every 'context=intercom' line in this file's device-creation code." + fi + fi + done + + # extensions.conf: same [intercom] anchor _pstn_patch_vendor_files uses, + # a SEPARATE #include so this coexists whether or not pstn-trunk is + # installed — messaging is independent of the PSTN trunk entirely. + for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do + [[ -f "$f" ]] || continue + if ! grep -q 'messaging-dialplan.conf' "$f"; then + if grep -q '^\[intercom\]$' "$f"; then + sed -i '/^\[intercom\]$/a #include messaging-dialplan.conf' "$f" + else + log_warning "$(basename "$f"): '[intercom]' anchor not found — vendor template changed upstream." + log_warning " Add '#include messaging-dialplan.conf' manually after [intercom] in this file's extensions.conf heredoc." + fi + fi + done + + log_success "Vendor generator functions patched for internal SIP messaging." +} + +# One-time migration for devices that already existed before the patch above +# — new devices pick up message_context=sip-messaging automatically from now +# on, but anything already in pjsip.conf was written before that existed. +# Idempotent: buffers the file and only inserts where the very next line +# isn't already the exact value, so reruns (every "update") never duplicate it. +_asterisk_do_migrate_existing_devices_message_context() { + local PJSIP_FILE="$1" + [[ -f "$PJSIP_FILE" ]] || return 0 + grep -q '^context=intercom$' "$PJSIP_FILE" || return 0 + + local TMP_FILE + TMP_FILE="$(mktemp)" + awk ' + { lines[NR] = $0 } + END { + for (i = 1; i <= NR; i++) { + print lines[i] + if (lines[i] == "context=intercom" && lines[i+1] != "message_context=sip-messaging") { + print "message_context=sip-messaging" + } + } + } + ' "$PJSIP_FILE" > "$TMP_FILE" + + if ! diff -q "$PJSIP_FILE" "$TMP_FILE" >/dev/null 2>&1; then + cp "$PJSIP_FILE" "$PJSIP_FILE.backup.$(date +%Y%m%d-%H%M%S)" + mv "$TMP_FILE" "$PJSIP_FILE" + chown asterisk:asterisk "$PJSIP_FILE" 2>/dev/null || true + log_success "Existing devices migrated to message_context=sip-messaging (backup saved alongside pjsip.conf)." + else + rm -f "$TMP_FILE" + fi +} + +# The actual enforcement — gated on the SENDER's own "messaging" flag in +# pstn-permissions.conf (the exact file/flag the Security Dashboard's +# "Internal SIP messaging" checkbox writes, independent of whether the PSTN +# trunk is installed), read live via AST_CONFIG() on every message, same +# mechanism pstn-trunk.sh's own dialplan already relies on for permission +# tiers — no restart needed to take effect. Off by default: an extension +# with no entry, or messaging=no, is denied. UNVERIFIED: MESSAGE(from)'s +# exact format hasn't been confirmed on a live install — the CUT()-based +# extraction below is written to tolerate a display name (e.g. this +# project's "name0" <999> callerid format) but if it ever fails to parse, +# FROM_EXT ends up empty/wrong and the AST_CONFIG() lookup simply finds no +# match, which denies by default (same fail-closed behavior as an +# unlisted extension) rather than silently allowing anything through. +_asterisk_do_write_messaging_dialplan() { + local FILE="$1" + cat > "$FILE" << 'EOF' +; Internal SIP MESSAGE routing/enforcement — services/asterisk-digital-ocean.sh. +; Regenerated on every install/update; edit there, not here directly. +; +; Reached via each endpoint's message_context=sip-messaging (patched into +; Easy Asterisk's own device-creation code — see +; _asterisk_do_patch_messaging_vendor_files) instead of falling back to +; [intercom], which already owns an exact-match "exten => ,1,..." per +; device for CALLS, regenerated fresh on every dialplan rebuild — a +; competing priority-1 declaration for the same extension number here would +; race that and risk breaking normal internal calling. This context ONLY +; ever receives MESSAGE requests, never calls. +[sip-messaging] +exten => _X.,1,NoOp(SIP MESSAGE to ${EXTEN}) + same => n,Set(FROM_URI=${MESSAGE(from)}) + same => n,Set(FROM_PART=${CUT(FROM_URI,@,1)}) + same => n,Set(FROM_EXT=${CUT(FROM_PART,:,2)}) + same => n,Set(SENDER_OK=${AST_CONFIG(pstn-permissions.conf,${FROM_EXT},messaging)}) + same => n,GotoIf($["${SENDER_OK}" = "yes"]?deliver:deny) + same => n(deliver),MessageSend(pjsip:${EXTEN},${FROM_URI}) + same => n,Hangup() + same => n(deny),NoOp(Denied — extension ${FROM_EXT} is not messaging-enabled) + same => n,Hangup() +EOF +} + _asterisk_do_remove_presence_timer() { systemctl disable --now asterisk-presence-alert.timer 2>/dev/null || true rm -f /etc/systemd/system/asterisk-presence-alert.timer /etc/systemd/system/asterisk-presence-alert.service @@ -567,6 +711,11 @@ install_asterisk-digital-ocean() { echo "[DRY-RUN] Would offer optional ntfy alerts on extension registration going offline/online" echo "[DRY-RUN] (checked every 2 minutes via systemd timer, cron.d fallback; always asked," echo "[DRY-RUN] update mode included)" + echo "[DRY-RUN] Would patch vendor device-creation code + extensions.conf generator to route" + echo "[DRY-RUN] internal SIP MESSAGE through a dedicated [sip-messaging] dialplan context," + echo "[DRY-RUN] gated live on each sender's 'messaging' flag in pstn-permissions.conf (the" + echo "[DRY-RUN] same file/flag the Security Dashboard's checkbox writes) — independent of" + echo "[DRY-RUN] whether the PSTN trunk is installed; migrates any already-existing devices too" return 0 fi @@ -590,6 +739,11 @@ install_asterisk-digital-ocean() { _asterisk_do_refresh_vendor_files _asterisk_do_write_compose _asterisk_do_write_logrotate "$EA_DIR" + _asterisk_do_patch_messaging_vendor_files "$EA_DIR" + _asterisk_do_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" + _asterisk_do_migrate_existing_devices_message_context "$EA_DIR/config/asterisk/pjsip.conf" + ensure_docker_dir_ownership "$EA_DIR/config/asterisk" + chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" log_info "Rebuilding and restarting containers..." if docker compose up -d --build --force-recreate; then @@ -661,6 +815,10 @@ install_asterisk-digital-ocean() { _asterisk_do_refresh_vendor_files _asterisk_do_write_logrotate "$EA_DIR" + _asterisk_do_patch_messaging_vendor_files "$EA_DIR" + _asterisk_do_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" + ensure_docker_dir_ownership "$EA_DIR/config/asterisk" + chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" # ── DigitalOcean droplet detection ──────────────────────────────────────── # A droplet's own public IP/ID are readable, unauthenticated, from the @@ -1113,6 +1271,32 @@ plan for the admin panel. | 10000–20000 | UDP | RTP media streams | | 49152–49252 | UDP | TURN relay media ports | +## Internal SIP messaging (no PSTN trunk needed) + +Every extension can send/receive Asterisk's native SIP MESSAGE (no carrier +SMS, no PSTN, no cost) once its "messaging" flag is set to yes in +\`pstn-permissions.conf\` — via the Security Dashboard's "Internal SIP +messaging" card, or by hand. This works independent of \`pstn-trunk.sh\` +entirely. Under the hood: every device endpoint gets +\`message_context=sip-messaging\`, routing messages to a dedicated +\`config/asterisk/messaging-dialplan.conf\` context instead of \`[intercom]\` +(which already owns per-device call routing) — this install/update patches +both the device-creation code (so new extensions pick it up automatically) +and any devices that already existed. Confirmed against a live install's +\`pjsip.conf\`/\`extensions.conf\` on 2026-07-23 (message_context falls back to +context=intercom, one exact-match dialplan entry per device) — the MESSAGE +sender-extraction logic itself is still unconfirmed against real traffic; +if messages silently don't arrive, check +\`docker exec easy-asterisk-do asterisk -rx "core set verbose 3"\` while +sending one. + +## Extension presence (online/offline) alerts + +Optional ntfy alert when an extension's SIP registration changes state — +offered on both fresh install and "update in place". Checked every 2 +minutes (systemd timer, cron.d fallback); fires only on a change, never on +every check. + ## Other services (installed separately, not by this script) This installer only sets up Asterisk + coturn. Everything else — Caddy, diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 7ab401d..b2cf083 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -1553,7 +1553,7 @@ INDEX_HTML = """

Internal SIP messaging

- Asterisk's native SIP texting between extensions — no carrier SMS, no PSTN, no cost, and no dependency on a PSTN trunk being installed at all. Independent of the calling permissions below. 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. + Asterisk's native SIP texting between extensions — no carrier SMS, no PSTN, no cost, and no dependency on a PSTN trunk being installed at all. Independent of the calling permissions below. Enforced live by a dedicated dialplan context (see services/asterisk-digital-ocean.sh's README) — install/rerun that service to pick up the dialplan wiring if this box predates it.

ExtNameEnabled
@@ -1595,7 +1595,7 @@ INDEX_HTML = """ Changes apply live, on the next call — no Asterisk restart needed.

- 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. + Messaging — Asterisk's native internal SIP texting (no carrier SMS, no PSTN, no cost), independent of the calling tier. Enforced live by a dedicated dialplan context — see services/asterisk-digital-ocean.sh's README for how, and its caveat on the sender-extraction logic still needing real-traffic confirmation.

ExtNameTierApproved numbers (restricted only)Messaging