From 3f033da635e58fc036e9afcc0cb8874439fcfd0c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 17:10:13 +0000 Subject: [PATCH 1/3] Fix trunk/messaging #include lines never reaching an existing install's live config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed live via a real failed test call ("extension not found in context 'intercom'"): Easy Asterisk's entrypoint only regenerates pjsip.conf/ extensions.conf if they don't already exist. Patching the vendor generator functions (as both pstn-trunk.sh and the earlier SIP MESSAGE work did) only takes effect on a future full regeneration — a box that already has devices configured, the normal case, never triggers one on a plain restart, so the #include lines never actually reached the live files despite the generator patch succeeding. Both installers now also patch the live pjsip.conf/extensions.conf directly (same anchors, idempotent) and force a reload, so the trunk/messaging dialplan actually takes effect immediately regardless of whether Easy Asterisk ever regenerates its config on its own. --- services/asterisk-digital-ocean.sh | 26 ++++++++++++++ services/pstn-trunk.sh | 57 +++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/services/asterisk-digital-ocean.sh b/services/asterisk-digital-ocean.sh index bd4890d..b529a6e 100755 --- a/services/asterisk-digital-ocean.sh +++ b/services/asterisk-digital-ocean.sh @@ -469,6 +469,30 @@ _asterisk_do_patch_messaging_vendor_files() { log_success "Vendor generator functions patched for internal SIP messaging." } +# Confirmed live (2026-07-23, via a real pstn-trunk.sh failure that hit this +# same mechanism): the vendor-generator patch above only takes effect on a +# FUTURE regeneration, and Easy Asterisk's own entrypoint only regenerates +# extensions.conf if it doesn't already exist (docker/entrypoint.sh guards +# it behind `[[ ! -f ... ]]`) — a box that already has devices configured, +# which is the normal case here, never regenerates it on a plain restart. +# Patches the LIVE file directly instead, so it takes effect immediately +# regardless of whether Easy Asterisk ever regenerates it on its own. +_asterisk_do_ensure_live_messaging_include() { + local EA_DIR="$1" + local EXT_LIVE="$EA_DIR/config/asterisk/extensions.conf" + [[ -f "$EXT_LIVE" ]] || return 0 + if ! grep -q 'messaging-dialplan.conf' "$EXT_LIVE"; then + if grep -q '^\[intercom\]$' "$EXT_LIVE"; then + sed -i '/^\[intercom\]$/a #include messaging-dialplan.conf' "$EXT_LIVE" + log_success "Patched the messaging #include directly into the live extensions.conf." + else + log_warning "Couldn't find '[intercom]' in the live extensions.conf — add" + log_warning "'#include messaging-dialplan.conf' manually, then: docker exec easy-asterisk-do asterisk -rx \"dialplan reload\"" + fi + fi + docker exec easy-asterisk-do asterisk -rx "dialplan reload" &>/dev/null || true +} + # 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. @@ -741,6 +765,7 @@ install_asterisk-digital-ocean() { _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_ensure_live_messaging_include "$EA_DIR" _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" @@ -817,6 +842,7 @@ install_asterisk-digital-ocean() { _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_ensure_live_messaging_include "$EA_DIR" ensure_docker_dir_ownership "$EA_DIR/config/asterisk" chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index a3298d1..f1c64bd 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -109,6 +109,51 @@ _pstn_patch_vendor_files() { log_success "Vendor generator functions patched to include the PSTN trunk config." } +# ── Shared: force the #include lines into the LIVE config files ──────────── +# Confirmed live (2026-07-23): _pstn_patch_vendor_files above patches the +# *generator functions* so a FUTURE full regeneration includes the trunk +# config — but Easy Asterisk's entrypoint only calls generate_pjsip_conf()/ +# rebuild_dialplan() when pjsip.conf/extensions.conf DON'T ALREADY EXIST +# (docker/entrypoint.sh guards both behind `[[ ! -f ... ]]`). Any box that +# already has devices configured — which is the common case, since this +# service is added on top of an existing Asterisk install — never +# regenerates either file on a plain `docker compose restart`, so the +# #include lines patched into the generator never actually reach the live +# files. Confirmed by a real failure: an outbound call got "extension not +# found in context 'intercom'" because pstn-trunk-dialplan.conf was never +# actually #include'd, despite the generator patch having succeeded. +# This directly patches the LIVE files too (idempotent, same anchors), so +# it takes effect immediately regardless of whether Easy Asterisk ever +# regenerates them on its own. +_pstn_ensure_live_includes() { + local ASTERISK_DIR="$1" CONTAINER_NAME="$2" + local PJSIP_LIVE="$ASTERISK_DIR/pjsip.conf" + local EXT_LIVE="$ASTERISK_DIR/extensions.conf" + + if [[ -f "$PJSIP_LIVE" ]] && ! grep -q 'pstn-trunk-pjsip.conf' "$PJSIP_LIVE"; then + if grep -q '^user_agent=EasyAsterisk$' "$PJSIP_LIVE"; then + sed -i '/^user_agent=EasyAsterisk$/a #include pstn-trunk-pjsip.conf' "$PJSIP_LIVE" + log_success "Patched the trunk's #include directly into the live pjsip.conf." + else + log_warning "Couldn't find 'user_agent=EasyAsterisk' in the live pjsip.conf — add" + log_warning "'#include pstn-trunk-pjsip.conf' manually after [global], then reload." + fi + fi + + if [[ -f "$EXT_LIVE" ]] && ! grep -q 'pstn-trunk-dialplan.conf' "$EXT_LIVE"; then + if grep -q '^\[intercom\]$' "$EXT_LIVE"; then + sed -i '/^\[intercom\]$/a #include pstn-trunk-dialplan.conf' "$EXT_LIVE" + log_success "Patched the trunk's #include directly into the live extensions.conf." + else + log_warning "Couldn't find '[intercom]' in the live extensions.conf — add" + log_warning "'#include pstn-trunk-dialplan.conf' manually, then reload." + fi + fi + + docker exec "$CONTAINER_NAME" asterisk -rx "module reload res_pjsip.so" &>/dev/null || true + docker exec "$CONTAINER_NAME" asterisk -rx "dialplan reload" &>/dev/null || true +} + # ── Shared: pjsip trunk config (aor/identify/endpoint, IP-authenticated) ─── # SERVER_IPS is space-separated — one IP is the common case (one POP, one # hostname resolution, e.g. VoIP.ms), but some providers (e.g. Anveo Direct) @@ -1197,7 +1242,10 @@ install_pstn-trunk() { echo "[DRY-RUN] pstn-permissions.conf, pstn-limits.conf, or the kill-switch trip state)" echo "[DRY-RUN] instead of a fresh install if already configured; the international-calling" echo "[DRY-RUN] review/change question is still asked every run either way" - echo "[DRY-RUN] Would restart the asterisk container to apply" + echo "[DRY-RUN] Would restart the asterisk container to apply, AND directly patch the live" + echo "[DRY-RUN] pjsip.conf/extensions.conf with the #include lines regardless — Easy Asterisk" + echo "[DRY-RUN] only regenerates those files if they don't already exist, so an existing" + echo "[DRY-RUN] install (the common case) would otherwise never actually load the trunk config" return 0 fi @@ -1251,6 +1299,7 @@ install_pstn-trunk() { ( 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" + _pstn_ensure_live_includes "$ASTERISK_DIR" "$CONTAINER_NAME" log_info "pstn-permissions.conf and pstn-limits.conf were NOT touched — edit them" log_info "directly, via the Security Dashboard, or choose FRESH reinstall to reset them." # Always asked, every run, update mode included — see @@ -1963,6 +2012,12 @@ MD else log_info "Apply later with: docker compose -f $EA_DIR/docker-compose.yml restart asterisk" fi + # Patches the live config files directly regardless of the restart choice + # above — see _pstn_ensure_live_includes's own comment for why this is + # necessary even after a restart, on a box that already had devices + # configured. Its final reload commands just no-op harmlessly if + # Asterisk isn't up yet (e.g. restart declined above). + _pstn_ensure_live_includes "$ASTERISK_DIR" "$CONTAINER_NAME" echo "" log_success "PSTN trunk configured." From a33b8e7b23abdd50e960793fade3d76eb39ff18a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 17:16:42 +0000 Subject: [PATCH 2/3] Simplify pstn-trunk.sh: point to the dashboard instead of CLI permission prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed the full/restricted-extension, approved-numbers pool/whiptail, messaging-extensions, and personal-DID-assignment prompts from the installer. All four are already live-editable, no-restart-needed settings the Security Dashboard's PSTN Trunk tab manages end to end — the CLI wall of prompts (that then needed a full reinstall to change) just duplicated that with more friction. Every extension now starts at internal tier (no PSTN, no messaging) until granted via the dashboard. Kept: provider/DID setup, concurrency caps, and the inbound ring-group (basic trunk wiring, not a permission). Also updated the generated README and CLI summary to match, and refreshed the stale "known gap" messaging section that predated the dialplan enforcement built earlier this session. --- services/pstn-trunk.sh | 203 ++++++++--------------------------------- 1 file changed, 40 insertions(+), 163 deletions(-) diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index f1c64bd..59cf342 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -1224,14 +1224,13 @@ install_pstn-trunk() { echo "[DRY-RUN] Would require an existing asterisk-digital-ocean OR asterisk (LAN) install" 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] 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] max concurrent outbound/inbound calls (default 10/10), 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" + echo "[DRY-RUN] Would NOT prompt for who can call/be called, messaging, or personal numbers —" + echo "[DRY-RUN] all managed live via the Security Dashboard's PSTN Trunk tab instead; every" + echo "[DRY-RUN] extension defaults to 'internal' (no PSTN, no messaging) until granted there" echo "[DRY-RUN] Would resolve the server hostname to an IP, plus prompt for any additional" echo "[DRY-RUN] known source IPs (some providers publish a fixed list), for inbound call matching" echo "[DRY-RUN] Would patch vendor generator functions to #include the trunk config" @@ -1427,78 +1426,25 @@ install_pstn-trunk() { return 1 fi - # ── Permission tiers ─────────────────────────────────────────────────── + # ── Permission tiers, messaging, personal numbers — all managed via the + # Security Dashboard, not prompted here ──────────────────────────────── + # This used to prompt for full/restricted extensions, approved numbers, + # messaging extensions, and personal-DID assignments right here at + # install time. All four are live-editable, no-restart-needed settings + # in pstn-permissions.conf / pstn-personal-dids.conf that the Security + # Dashboard's PSTN Trunk tab already manages end to end — duplicating + # that as a wall of CLI prompts (that you'd then have to redo via a full + # reinstall to change) added friction the dashboard already solves + # better. Every extension defaults to "internal" (no PSTN, no + # messaging, no personal number) until granted otherwise there. echo "" - echo " Three PSTN permission tiers. Below, you'll enter EXTENSION NUMBERS at each" - echo " prompt (e.g. 999, 213) — never the tier name itself:" - echo " internal — call/receive other Asterisk extensions + internal ring" - echo " groups only. No PSTN at all. The default for any extension" - echo " not entered at either prompt below — nothing to type for it." - echo " restricted — internal, PLUS call/receive ONLY pre-approved US numbers." - echo " full — internal, PLUS call/receive ANY US number." - echo " Live-editable after install (pstn-permissions.conf) — via the Security" - echo " Dashboard web UI if installed, or by hand — no restart/reinstall needed." - local FULL_EXTS="" - prompt_text "Extension NUMBERS to grant FULL PSTN access (space-separated, e.g. '999 213', blank = none):" "" FULL_EXTS - - local RESTRICTED_EXTS="" - prompt_text "Extension NUMBERS to grant RESTRICTED PSTN access (space-separated, e.g. '301', blank = none):" "" RESTRICTED_EXTS - - local RESTRICTED_ARGS=() - if [[ -n "$RESTRICTED_EXTS" ]]; then - # Shared pool, entered once — faster than retyping the same numbers - # per extension when several extensions overlap. Picking per - # extension then uses a whiptail checklist (multi-select, toggle - # with space) against this pool if whiptail is available and this - # isn't an unattended run; otherwise falls back to typing numbers - # directly (or "all" for the whole pool) per extension, same as - # before this existed. - echo "" - echo " Optional: enter a shared pool of approved numbers ONCE below, then pick" - echo " which ones apply to each restricted extension next — instead of retyping" - echo " the same numbers for every extension that shares them." - local MASTER_NUMS_RAW="" MASTER_NUMS=() - prompt_text " Approved-numbers pool (comma/space-separated, 11-digit US numbers, e.g. '15551234567 15559876543', blank = enter per-extension instead):" "" MASTER_NUMS_RAW - if [[ -n "$MASTER_NUMS_RAW" ]]; then - local _pool_n - while IFS= read -r _pool_n; do - [[ -n "$_pool_n" ]] && MASTER_NUMS+=("$_pool_n") - done < <(echo "$MASTER_NUMS_RAW" | tr ', ' '\n\n' | grep -E '^[0-9]{11}$' | sort -u) - if [[ ${#MASTER_NUMS[@]} -eq 0 ]]; then - log_warning "No valid 11-digit numbers found in that pool — falling back to per-extension entry." - else - log_success "Pool: ${#MASTER_NUMS[@]} number(s) — ${MASTER_NUMS[*]}" - fi - fi - - local _ext _raw_nums _clean_nums - for _ext in $RESTRICTED_EXTS; do - _clean_nums="" - if [[ ${#MASTER_NUMS[@]} -gt 0 ]] && command -v whiptail >/dev/null 2>&1 && [[ "$UNATTENDED" != true ]]; then - local _wt_args=() _wt_n _selected - for _wt_n in "${MASTER_NUMS[@]}"; do - _wt_args+=("$_wt_n" "" "off") - done - _selected="$(whiptail --title "Extension $_ext" --checklist \ - "Approved numbers for extension $_ext (space to toggle, Enter to confirm):" \ - 20 70 10 "${_wt_args[@]}" 3>&1 1>&2 2>&3)" - [[ -n "$_selected" ]] && _clean_nums="$(echo "$_selected" | tr -d '"' | tr ' ' '\n' | paste -sd'|' -)" - else - prompt_text " Approved numbers for extension $_ext (comma/space-separated, 11-digit US numbers, e.g. 15551234567, or 'all' for the whole pool above):" "" _raw_nums - if [[ "$_raw_nums" == "all" && ${#MASTER_NUMS[@]} -gt 0 ]]; then - _clean_nums="$(printf '%s\n' "${MASTER_NUMS[@]}" | paste -sd'|' -)" - else - _clean_nums="$(echo "$_raw_nums" | tr ', ' '\n\n' | grep -E '^[0-9]{11}$' | paste -sd'|' - 2>/dev/null)" - fi - fi - if [[ -z "$_clean_nums" ]]; then - log_warning "No valid 11-digit numbers entered for $_ext — it will be restricted with an EMPTY" - log_warning "approved list, meaning no PSTN number can currently reach/be reached by it until" - log_warning "you add some (via the Security Dashboard or by editing pstn-permissions.conf)." - fi - RESTRICTED_ARGS+=("$_ext" "$_clean_nums") - done - fi + log_info "Who can call/be called, internal SIP messaging, and personal numbers are" + log_info "all managed from the Security Dashboard's PSTN Trunk tab (not here) — install" + log_info "it if you haven't: sudo ./setup.sh security-dashboard. Every extension starts" + log_info "at 'internal' (no PSTN, no messaging) until you grant it there; changes apply" + log_info "live, no restart or reinstall needed." + local FULL_EXTS="" RESTRICTED_EXTS="" RESTRICTED_ARGS=() + local MESSAGING_EXTS="" PERSONAL_DID_PAIRS=() PERSONAL_DID_ASSIGNMENTS="" echo "" echo " Concurrent-call caps (both directions) are also live — changeable later via" @@ -1516,70 +1462,13 @@ install_pstn-trunk() { MAX_INBOUND=10 fi - local _suggested_ring - _suggested_ring="$(echo "$FULL_EXTS $RESTRICTED_EXTS" | xargs)" local RING_EXTS="" - prompt_text "Extensions to ring for inbound PSTN calls (space-separated — one, or several for a ring group; only full/restricted-tier members will actually ring):" "$_suggested_ring" RING_EXTS + prompt_text "Extensions to ring for inbound PSTN calls (space-separated — one, or several for a ring group; only full/restricted-tier members will actually ring, once granted via the dashboard):" "" RING_EXTS if [[ -z "$RING_EXTS" ]]; then log_error "At least one extension is required for inbound routing — aborting." return 1 fi - # ── Internal SIP messaging — a separate axis from PSTN calling ───────── - # Asterisk's native SIP MESSAGE (extension-to-extension texting) has no - # cost/carrier involvement at all, unlike PSTN calling, so it gets its - # own independent flag in pstn-permissions.conf rather than being folded - # into the internal/restricted/full tiers above — an extension can be - # "internal" for calling (no PSTN) and still messaging-enabled, or vice - # versa. Off by default, same "opt in" posture as PSTN access. - echo "" - echo " Asterisk also supports native SIP texting between extensions (no carrier" - echo " SMS, no PSTN, no cost) — a separate permission from PSTN calling above." - local MESSAGING_EXTS="" - prompt_text "Extensions allowed to use internal SIP messaging (space-separated, blank = none):" "" MESSAGING_EXTS - - # ── 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 @@ -1748,11 +1637,9 @@ background, cost estimate, and toll-fraud reasoning. | Server/POP | ${TRUNK_SERVER} (inbound match IPs: ${TRUNK_SERVER_IPS}) | | DID | ${TRUNK_DID} | | Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all, minus 27 non-US/premium NANP area codes (see below) | -| Full-PSTN extensions | ${FULL_EXTS:-none} | -| Restricted-PSTN extensions | ${RESTRICTED_EXTS:-none} | +| Permission tiers, messaging, personal numbers | Managed live via the Security Dashboard's PSTN Trunk tab — not set at install, so not shown here (this file isn't regenerated when you change them there). Everyone starts at \`internal\` (no PSTN, no messaging) until granted. | | Concurrency caps | ${MAX_OUTBOUND} outbound / ${MAX_INBOUND} inbound simultaneous calls (live — see \`pstn-limits.conf\` below) | | Inbound ring-group | ${RING_EXTS} | -| Internal SIP messaging extensions | ${MESSAGING_EXTS:-none} (separate from PSTN calling permission — see below) | | ntfy alerts | ${NTFY_URL:-disabled} | | Estimated rate | \$${RATE_PER_MIN}/min | | Monthly spend alert threshold | \$${MONTH_THRESHOLD} | @@ -1916,31 +1803,19 @@ permission tiers. 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}. Live-editable any time via the Security -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. +tiers above — off by default, same "opt in" posture. Live-editable any +time via the Security Dashboard's "PSTN Trunk" tab, in its own +always-available "Internal SIP messaging" card — no dependency on this +trunk (or any PSTN trunk at all) being installed. -**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. +Actually enforced, not just a flag — \`services/asterisk-digital-ocean.sh\` +(and \`services/asterisk.sh\` for the LAN edition) routes messages through a +dedicated \`[sip-messaging]\` dialplan context (separate from \`[intercom]\`'s +own per-device call routing, so there's no collision risk) and checks this +same flag via \`AST_CONFIG()\` before delivering. One caveat still flagged +rather than papered over: the \`MESSAGE(from)\` sender-extraction hasn't +been confirmed against real MESSAGE traffic on a live install — it fails +closed (denies) if it ever parses wrong, but worth a live test. ## Personal numbers @@ -2026,10 +1901,12 @@ MD echo " DID: $TRUNK_DID" echo " Outbound: US/NANP only, max $MAX_OUTBOUND concurrent calls" echo " Inbound: max $MAX_INBOUND concurrent calls" - echo " Full-PSTN extensions: ${FULL_EXTS:-none}" - echo " Restricted extensions: ${RESTRICTED_EXTS:-none}" echo " Inbound ring-group: $RING_EXTS" echo " ntfy alerts: ${NTFY_URL:-disabled}" echo " Docs: $DOC_FILE" echo "" + log_info "Everyone's at 'internal' tier (no PSTN, no messaging) until you grant access" + log_info "via the Security Dashboard's PSTN Trunk tab — sudo ./setup.sh security-dashboard" + log_info "if it isn't installed yet." + echo "" } From 02bf2fe3a27f1c1e92c2fc0cb131102b1e5066b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 17:19:31 +0000 Subject: [PATCH 3/3] Mirror SIP messaging + presence alerts to asterisk.sh (LAN edition) Ports the internal SIP MESSAGE dialplan enforcement, extension presence (online/offline) ntfy alerts, and the live-config-include patching from asterisk-digital-ocean.sh, for feature parity between the two Asterisk flavors. The pstn-trunk.sh live-include fix already applied to both, since that's shared code parametrized by which flavor is installed. --- services/asterisk.sh | 394 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) diff --git a/services/asterisk.sh b/services/asterisk.sh index 43cc7cd..060a1c4 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -257,6 +257,358 @@ _asterisk_refresh_vendor_files() { ./scripts/vpn-diagnostics.sh ./scripts/dns-whitelist.sh } +# ── Shared: extension presence (online/offline) ntfy alerts ──────────────── +# Polls PJSIP registration state and alerts only on a CHANGE from the last +# check (never on every poll) — same periodic-check shape as pstn-trunk.sh's +# usage-alert script, but purely informational, so a looser 2-minute +# interval is fine here (nothing enforces/blocks anything off the back of +# this one). UNVERIFIED: the `pjsip show contacts` column layout below is +# parsed defensively (grep for the Avail/Unavail keyword rather than a fixed +# column position) specifically because it hasn't been confirmed against a +# live install's actual output yet — run +# `docker exec easy-asterisk asterisk -rx "pjsip show contacts"` yourself +# after enabling this to confirm extensions/status actually show up as +# expected, same as any other not-yet-live-tested piece in this project. +_asterisk_write_presence_alert_script() { + local FILE="$1" CONTAINER_NAME="$2" NTFY_URL="$3" STATE_FILE="$4" + cat > "$FILE" << 'SCRIPT' +#!/bin/bash +# Auto-generated by services/asterisk.sh — rerun the installer's +# presence-alert step to change settings instead of editing this directly. +CONTAINER_NAME="__PRESENCE_CONTAINER__" +NTFY_URL="__PRESENCE_NTFY_URL__" +STATE_FILE="__PRESENCE_STATE_FILE__" + +[[ -z "$NTFY_URL" ]] && exit 0 + +send_ntfy() { + curl -m 5 -s -d "$1" "$NTFY_URL" >/dev/null 2>&1 +} + +CURRENT="$(docker exec "$CONTAINER_NAME" asterisk -rx "pjsip show contacts" 2>/dev/null | grep '^ Contact:' | while read -r _ aor rest; do + ext="${aor%%/*}" + status="Unknown" + case "$rest" in + *Unavail*) status="Unavail" ;; + *Avail*) status="Avail" ;; + esac + echo "${ext}:${status}" +done)" + +[[ -z "$CURRENT" ]] && exit 0 + +touch "$STATE_FILE" +declare -A OLD_STATE +while IFS=: read -r ext status; do + [[ -n "$ext" ]] && OLD_STATE["$ext"]="$status" +done < "$STATE_FILE" + +: > "${STATE_FILE}.new" +while IFS=: read -r ext status; do + [[ -z "$ext" ]] && continue + echo "${ext}:${status}" >> "${STATE_FILE}.new" + old="${OLD_STATE[$ext]:-}" + if [[ -n "$old" && "$old" != "$status" && "$status" != "Unknown" ]]; then + if [[ "$status" == "Avail" ]]; then + send_ntfy "Extension $ext is back online." + elif [[ "$old" == "Avail" ]]; then + send_ntfy "Extension $ext went offline." + fi + fi +done <<< "$CURRENT" +mv "${STATE_FILE}.new" "$STATE_FILE" +SCRIPT + sed -i "s#__PRESENCE_CONTAINER__#${CONTAINER_NAME}#g; s#__PRESENCE_NTFY_URL__#${NTFY_URL}#g; s#__PRESENCE_STATE_FILE__#${STATE_FILE}#g" "$FILE" + chmod 755 "$FILE" +} + +_asterisk_install_presence_timer() { + local EA_DIR="$1" + mkdir -p "$EA_DIR/logs" + + if command -v systemctl >/dev/null 2>&1 && [[ -d /run/systemd/system ]]; then + cat > /etc/systemd/system/asterisk-presence-alert.service << SVCEOF +[Unit] +Description=Asterisk extension presence (online/offline) check + +[Service] +Type=oneshot +ExecStart=/bin/bash $EA_DIR/asterisk-presence-alert.sh +StandardOutput=append:$EA_DIR/logs/asterisk-presence-alert.log +StandardError=append:$EA_DIR/logs/asterisk-presence-alert.log +SVCEOF + + cat > /etc/systemd/system/asterisk-presence-alert.timer << SVCEOF +[Unit] +Description=Run the Asterisk presence check every 2 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=2min +AccuracySec=10s + +[Install] +WantedBy=timers.target +SVCEOF + + systemctl daemon-reload + systemctl enable --now asterisk-presence-alert.timer + log_success "Presence check installed (systemd timer, every 2 minutes)." + elif command -v cron >/dev/null 2>&1 || [[ -d /etc/cron.d ]]; then + cat > /etc/cron.d/asterisk-presence-alert << CRON +*/2 * * * * root /bin/bash $EA_DIR/asterisk-presence-alert.sh >> $EA_DIR/logs/asterisk-presence-alert.log 2>&1 +CRON + log_success "Presence check installed (cron.d fallback — systemd not detected)." + else + log_warning "Neither systemd nor cron available — run $EA_DIR/asterisk-presence-alert.sh manually/periodically." + 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_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." +} + +# Confirmed live (2026-07-23, via a real pstn-trunk.sh failure that hit this +# same mechanism): the vendor-generator patch above only takes effect on a +# FUTURE regeneration, and Easy Asterisk's own entrypoint only regenerates +# extensions.conf if it doesn't already exist (docker/entrypoint.sh guards +# it behind `[[ ! -f ... ]]`) — a box that already has devices configured, +# which is the normal case here, never regenerates it on a plain restart. +# Patches the LIVE file directly instead, so it takes effect immediately +# regardless of whether Easy Asterisk ever regenerates it on its own. +_asterisk_ensure_live_messaging_include() { + local EA_DIR="$1" + local EXT_LIVE="$EA_DIR/config/asterisk/extensions.conf" + [[ -f "$EXT_LIVE" ]] || return 0 + if ! grep -q 'messaging-dialplan.conf' "$EXT_LIVE"; then + if grep -q '^\[intercom\]$' "$EXT_LIVE"; then + sed -i '/^\[intercom\]$/a #include messaging-dialplan.conf' "$EXT_LIVE" + log_success "Patched the messaging #include directly into the live extensions.conf." + else + log_warning "Couldn't find '[intercom]' in the live extensions.conf — add" + log_warning "'#include messaging-dialplan.conf' manually, then: docker exec easy-asterisk asterisk -rx \"dialplan reload\"" + fi + fi + docker exec easy-asterisk asterisk -rx "dialplan reload" &>/dev/null || true +} + +# 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_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_write_messaging_dialplan() { + local FILE="$1" + cat > "$FILE" << 'EOF' +; Internal SIP MESSAGE routing/enforcement — services/asterisk.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_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_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 + rm -f /etc/cron.d/asterisk-presence-alert + systemctl daemon-reload 2>/dev/null || true +} + +# Interactive step — called from both the fresh-install flow and "update in +# place" (always asked either way, same reasoning as pstn-trunk.sh's +# international-calling step: this is a live-editable extra, not a +# structural setting, so it doesn't belong exclusively to one path). +_asterisk_run_presence_step() { + local EA_DIR="$1" + local SETTINGS_FILE="$EA_DIR/.presence-alert.env" + local STATE_FILE="$EA_DIR/.presence-alert.state" + + echo "" + local _CUR_ENABLED="n" _CUR_NTFY="" + if [[ -f "$SETTINGS_FILE" ]]; then + # shellcheck disable=SC1090 + source "$SETTINGS_FILE" + _CUR_ENABLED="${PRESENCE_ENABLED:-n}" + _CUR_NTFY="${PRESENCE_NTFY_URL:-}" + fi + + if [[ "$_CUR_ENABLED" == "y" ]]; then + echo " Extension online/offline ntfy alerts are ON (topic: $_CUR_NTFY)." + local _CHANGE="" + prompt_yn " Change or disable this? (y/n):" "n" _CHANGE + [[ "$_CHANGE" =~ ^[Yy]$ ]] || return 0 + local _DISABLE="" + prompt_yn " Disable presence alerts entirely? (y/n):" "n" _DISABLE + if [[ "$_DISABLE" =~ ^[Yy]$ ]]; then + _asterisk_remove_presence_timer + rm -f "$EA_DIR/asterisk-presence-alert.sh" "$STATE_FILE" + cat > "$SETTINGS_FILE" << ENV +PRESENCE_ENABLED="n" +PRESENCE_NTFY_URL="" +ENV + log_success "Presence alerts disabled." + return 0 + fi + else + local _WANT="" + prompt_yn "Send an ntfy alert when an extension's SIP registration goes offline / comes back online? (y/n):" "n" _WANT + [[ "$_WANT" =~ ^[Yy]$ ]] || return 0 + fi + + local _ntfy_default="${_CUR_NTFY:-https://ntfy.sh/asterisk-presence}" + if [[ -z "$_CUR_NTFY" ]] && [[ -f "$DOCKER_DIR/ntfy/config/server.yml" ]]; then + local _local_base_url + _local_base_url="$(grep -oP '(?<=base-url: ")[^"]+' "$DOCKER_DIR/ntfy/config/server.yml" 2>/dev/null || true)" + if [[ -n "$_local_base_url" ]] && [[ "$_local_base_url" != "https://ntfy.example.com" ]]; then + _ntfy_default="${_local_base_url}/asterisk-presence" + log_info "Detected a configured local ntfy instance at $_local_base_url — using it as the default." + fi + fi + local PRESENCE_NTFY_URL="" + prompt_text " ntfy topic URL:" "$_ntfy_default" PRESENCE_NTFY_URL + if [[ -z "$PRESENCE_NTFY_URL" ]]; then + log_warning "No topic entered — presence alerts not enabled." + return 0 + fi + + _asterisk_write_presence_alert_script "$EA_DIR/asterisk-presence-alert.sh" "easy-asterisk" "$PRESENCE_NTFY_URL" "$STATE_FILE" + _asterisk_install_presence_timer "$EA_DIR" + + cat > "$SETTINGS_FILE" << ENV +PRESENCE_ENABLED="y" +PRESENCE_NTFY_URL="${PRESENCE_NTFY_URL}" +ENV + chown "$ACTUAL_USER:$ACTUAL_USER" "$SETTINGS_FILE" 2>/dev/null || true + log_success "Presence alerts enabled (checked every 2 minutes) — topic: $PRESENCE_NTFY_URL" + log_info "Fires only on a state CHANGE, never every check — the first check after enabling" + log_info "never alerts by itself, since there's no prior state to compare against yet." +} + # ── Shared: docker-compose.yml ───────────────────────────────────────────── # Same reasoning as above — one copy of the template used by both fresh # installs and updates. Must be called with $PWD already at $EA_DIR. @@ -341,6 +693,13 @@ install_asterisk() { echo "[DRY-RUN] Would scan for a free web admin port starting at 8081 (avoids e.g. CrowdSec's 8080)" echo "[DRY-RUN] Would open UFW ports: 5060, 5061, , 8088, 8089, 3478, 10000-20000, 49152-49252" echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if $EA_DIR already exists" + 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 — migrates" + echo "[DRY-RUN] any already-existing devices too" + 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)" return 0 fi @@ -363,6 +722,12 @@ install_asterisk() { _asterisk_refresh_vendor_files _asterisk_write_compose + _asterisk_patch_messaging_vendor_files "$EA_DIR" + _asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" + _asterisk_ensure_live_messaging_include "$EA_DIR" + _asterisk_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 @@ -371,6 +736,8 @@ install_asterisk() { log_warning "docker compose up failed — check: docker compose -f $EA_DIR/docker-compose.yml logs" fi + _asterisk_run_presence_step "$EA_DIR" + local _EXISTING_DOMAIN _EXISTING_PORT _EXISTING_DOMAIN="$(grep -E '^DOMAIN_NAME=' .env | cut -d= -f2-)" _EXISTING_PORT="$(grep -E '^WEB_ADMIN_PORT=' .env | cut -d= -f2-)" @@ -402,6 +769,10 @@ install_asterisk() { cd "$EA_DIR" || return 1 _asterisk_refresh_vendor_files + _asterisk_patch_messaging_vendor_files "$EA_DIR" + _asterisk_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" # ── Networking mode ─────────────────────────────────────────────────────── echo "" @@ -542,6 +913,9 @@ ENV log_success "UFW rules added." fi + # ── Extension presence (online/offline) ntfy alerts ──────────────────────── + _asterisk_run_presence_step "$EA_DIR" + # ── README ──────────────────────────────────────────────────────────────── write_readme "$EA_DIR" << 'MD' # Easy Asterisk PBX + coturn @@ -624,6 +998,26 @@ value.) | spool/ | /var/spool/asterisk | | lib/ | /var/lib/asterisk | +## 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. + +## 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. + ## Ports | Port | Protocol | Purpose |