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 2fd381f..bd4890d 100755 --- a/services/asterisk-digital-ocean.sh +++ b/services/asterisk-digital-ocean.sh @@ -293,6 +293,334 @@ $_ea_dir/logs/full { LOGROTATE } +# ── 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-do 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_do_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-digital-ocean.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_do_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_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 + 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_do_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_do_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_do_write_presence_alert_script "$EA_DIR/asterisk-presence-alert.sh" "easy-asterisk-do" "$PRESENCE_NTFY_URL" "$STATE_FILE" + _asterisk_do_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. @@ -380,6 +708,14 @@ install_asterisk-digital-ocean() { echo "[DRY-RUN] Would reverse-proxy the web admin on the SAME FQDN used for SIP if Caddy is already installed (needed for cert sync)" echo "[DRY-RUN] Would offer local OR remote Authelia to protect the web admin, if either is already available" echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if $EA_DIR already exists" + 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 @@ -403,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 @@ -411,6 +752,8 @@ install_asterisk-digital-ocean() { log_warning "docker compose up failed — check: docker compose -f $EA_DIR/docker-compose.yml logs" fi + _asterisk_do_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-)" @@ -472,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 @@ -839,6 +1186,9 @@ CADDY_BLOCK log_info "It auto-detects this asterisk-digital-ocean install and wires up SIP protection on its own." fi + # ── Extension presence (online/offline) ntfy alerts ──────────────────────── + _asterisk_do_run_presence_step "$EA_DIR" + # ── README ──────────────────────────────────────────────────────────────── write_readme "$EA_DIR" << MD # Easy Asterisk PBX + coturn — DigitalOcean droplet edition @@ -921,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/crowdsec.sh b/services/crowdsec.sh index ddaec1c..eeed3b3 100644 --- a/services/crowdsec.sh +++ b/services/crowdsec.sh @@ -110,6 +110,41 @@ install_crowdsec() { return 0 fi + # ── Existing install? Offer update-in-place instead of a full reconfigure ─ + # Everything below (ASN exemptions, geo-allowlist, ntfy, remote LAPI) is + # additive/idempotent at the file level, but there was no gate at all + # here before — every rerun re-asked all four optional questions + # unconditionally, which reads as "reconfigure" rather than "update" even + # though nothing already-configured was actually being destroyed. + local _REMOTE_LAPI_PENDING="" + local ASK_OPTIONAL=true + if command -v cscli &> /dev/null; then + echo " CrowdSec is already installed." + local CS_MODE="" + prompt_reinstall_mode CS_MODE 2>/dev/null || { + # prompt_reinstall_mode isn't defined in the standalone stub — fall + # back to a plain yes/no when run outside the full repo. + local _r="" + prompt_yn " Reconfigure the optional settings below (ASN exemptions, geo-allowlist, ntfy, remote LAPI)? (y/n):" "n" _r + [ "$_r" = "y" ] || [ "$_r" = "Y" ] && CS_MODE="fresh" || CS_MODE="update" + } + case "$CS_MODE" in + update) + ASK_OPTIONAL=false + log_info "Refreshing agent/collections/acquisitions only — ASN exemptions," + log_info "geo-allowlist, ntfy alerts, and remote-LAPI settings are left exactly as" + log_info "they are. Choose 'fresh' instead to revisit any of those." + ;; + cancel) + log_info "Leaving the existing CrowdSec install as-is." + return 0 + ;; + fresh) + log_info "Proceeding with a full reconfigure — every optional prompt below runs again." + ;; + esac + fi + # ── 1. Install the CrowdSec agent ──────────────────────────────────────── if command -v cscli &> /dev/null; then echo " ✓ CrowdSec is already installed" @@ -198,7 +233,9 @@ labels: # the hub originals so there's no double-processing of the same events. echo "" local ASN_EXEMPT="" - prompt_yn "Exempt specific carrier ASNs from Asterisk brute-force bans only? (y/n):" "n" ASN_EXEMPT + if [ "$ASK_OPTIONAL" = true ]; then + prompt_yn "Exempt specific carrier ASNs from Asterisk brute-force bans only? (y/n):" "n" ASN_EXEMPT + fi if [ "$ASN_EXEMPT" = "y" ] || [ "$ASN_EXEMPT" = "Y" ]; then sudo cscli collections install crowdsecurity/geoip-enrich 2>/dev/null || true echo " ASNs observed live: T-Mobile 21928, Starlink 14593. A carrier can operate" @@ -295,7 +332,9 @@ ASTENUM echo " https://app.crowdsec.net/" echo "" local GEO_ALLOWLIST="" - prompt_yn "Restrict Caddy-fronted web traffic to North America + Europe only (block every other country)? Does NOT affect SSH. (y/n):" "n" GEO_ALLOWLIST + if [ "$ASK_OPTIONAL" = true ]; then + prompt_yn "Restrict Caddy-fronted web traffic to North America + Europe only (block every other country)? Does NOT affect SSH. (y/n):" "n" GEO_ALLOWLIST + fi if [ "$GEO_ALLOWLIST" = "y" ] || [ "$GEO_ALLOWLIST" = "Y" ]; then echo " Installing geoip-enrich (tags every event with a country code; no MaxMind" echo " account needed — CrowdSec bundles its own redistributable GeoLite2 data)..." @@ -353,7 +392,9 @@ labels: # ── 7. Optional: push ban alerts to ntfy ───────────────────────────────── echo "" local CS_NTFY="" - prompt_yn "Send CrowdSec ban alerts to an ntfy topic? (y/n):" "n" CS_NTFY + if [ "$ASK_OPTIONAL" = true ]; then + prompt_yn "Send CrowdSec ban alerts to an ntfy topic? (y/n):" "n" CS_NTFY + fi if [ "$CS_NTFY" = "y" ] || [ "$CS_NTFY" = "Y" ]; then # Prefer a locally-installed ntfy's own base-url as the default, if one # exists and actually looks configured (not still the placeholder @@ -420,8 +461,10 @@ headers: # of every box running its own. Useful if you already have CrowdSec on # a homelab and don't want a second LAPI+SQLite DB on this droplet. echo "" - local USE_REMOTE_LAPI="" _REMOTE_LAPI_PENDING="" - prompt_yn "Point this agent at a remote/central LAPI instead of running its own (e.g. one already on a homelab)? (y/n):" "n" USE_REMOTE_LAPI + local USE_REMOTE_LAPI="" + if [ "$ASK_OPTIONAL" = true ]; then + prompt_yn "Point this agent at a remote/central LAPI instead of running its own (e.g. one already on a homelab)? (y/n):" "n" USE_REMOTE_LAPI + fi if [ "$USE_REMOTE_LAPI" = "y" ] || [ "$USE_REMOTE_LAPI" = "Y" ]; then echo "" echo " This registers this machine and disables its local API server." @@ -506,6 +549,14 @@ install. The real configuration lives under `/etc/crowdsec`. before they ever touch your services. - Optionally enriches events with **geo/ASN** data for geo-blocking. +## Rerunning this script + +Rerunning offers **update** (refreshes the agent/collections/acquisitions +only — ASN exemptions, geo-allowlist, ntfy, and remote-LAPI settings are left +exactly as they are) or **fresh** (revisit every optional prompt again, same +as a first install). Nothing here is destructive either way — "fresh" only +overwrites a setting if you actually answer its prompt differently. + ## Key commands ``` diff --git a/services/ntfy.sh b/services/ntfy.sh index 4560c4a..f173bb8 100644 --- a/services/ntfy.sh +++ b/services/ntfy.sh @@ -260,12 +260,26 @@ base-url: "${NTFY_BASE_URL:-https://ntfy.example.com}" # UPDATE to your actual cache-file: /var/cache/ntfy/cache.db cache-duration: 12h auth-file: /var/cache/ntfy/auth.db -auth-default-access: deny-all +# read-write, not deny-all: with an auth-file present, ntfy auth-checks every +# request, and deny-all blocks anonymous publish/subscribe on EVERY topic +# unless you separately create a user and grant a per-topic ACL (nothing in +# this installer, crowdsec.sh, or pstn-trunk.sh does that) — every ntfy alert +# they curl to a topic silently 403s, and a plain phone subscription gets +# nothing, with no visible error either side. read-write matches public +# ntfy.sh's own model instead: anyone who knows/guesses the topic name can +# read/publish it, so treat topic names as a shared secret (long/random for +# anything sensitive) rather than relying on auth you'd have to wire up +# per-script. Confirmed live: this was the actual cause of a self-hosted +# instance never delivering alerts despite a correct topic subscription. +auth-default-access: read-write behind-proxy: true NTFY_CFG [[ -n "$NTFY_BASE_URL" ]] \ && log_info "base-url set to $NTFY_BASE_URL — update if domain changes" \ || log_warning "base-url set to placeholder — edit config/server.yml after install" + log_info "auth-default-access: read-write — topics are open to anyone who knows the" + log_info "topic name (same model as public ntfy.sh). Use long/random topic names for" + log_info "anything sensitive, or lock down individual topics later with 'ntfy access'." fi chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR" @@ -288,6 +302,19 @@ phone or browser. - Send a notification: \`curl -d "Hello!" localhost:8090/mytopic\` - Subscribe on phone: ntfy app -> Add subscription -> localhost:8090/mytopic +## Access model +\`auth-default-access: read-write\` — anyone who knows a topic name can read +and publish to it, same as public ntfy.sh. There's no per-script login wired +up (crowdsec.sh, pstn-trunk.sh, etc. all just \`curl -d ... \$topic_url\` with +no auth), so this is what makes those alerts actually arrive. Treat topic +names as a shared secret — use long/random ones for anything sensitive — or +lock a specific topic down individually: +\`\`\` +docker exec ntfy ntfy user add myuser +docker exec ntfy ntfy access myuser mytopic read-write +docker exec ntfy ntfy access everyone mytopic deny +\`\`\` + ## Data - Config: ./config (mounted to /etc/ntfy) - Cache: ./cache (mounted to /var/cache/ntfy) diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 87e568c..b2cf083 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -141,7 +141,7 @@ install_security-dashboard() { prompt_yn "Reconfigure this dashboard's Caddy protection (Authelia domain, or add/rotate an independent Basic Auth layer)? (y/n):" "n" _reconf if [[ "$_reconf" =~ ^[Yy]$ ]]; then _secdash_remove_caddy_block "$DASHBOARD_PORT" - _secdash_configure_caddy "$DASHBOARD_PORT" + _secdash_configure_caddy "$DASHBOARD_PORT" "$ASTERISK_ADMIN_URL" fi return 0 ;; @@ -184,7 +184,7 @@ install_security-dashboard() { # _secdash_configure_caddy so "update" mode can also offer to reconfigure # it later (e.g. to add Basic Auth to an already-deployed dashboard) # without duplicating this logic — see that function for the rest. - _secdash_configure_caddy "$DASHBOARD_PORT" + _secdash_configure_caddy "$DASHBOARD_PORT" "$ASTERISK_ADMIN_URL" write_readme "$APP_DIR" << README_MD # Security Dashboard @@ -236,7 +236,20 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly. 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). +- **Asterisk Admin** — an embedded, lazy-loaded iframe of the real Asterisk + web admin (only fetched the first time you open the tab), plus an + "open in a new tab" fallback link that's always there regardless. Only + shows up once an Asterisk install is detected. If a local Caddy install is + found for both this dashboard and the Asterisk admin's own domain, install + automatically patches the admin's Caddy site block from + `X-Frame-Options` to a `Content-Security-Policy: frame-ancestors` entry + naming only this dashboard's domain, so the browser actually allows the + frame — every other site is still refused framing exactly as before. This + is best-effort (it depends on matching the exact header line + `services/asterisk-digital-ocean.sh` itself writes, and hasn't been + confirmed against Authelia's own portal-framing behavior on a live + install) — if the tab shows a blank frame, use the fallback link and check + this service's own log output from install time for a manual one-line fix. ## Manage \`\`\` @@ -378,7 +391,7 @@ SUDOERS # retroactively) using the exact same code path as a fresh install, instead # of hand-patching a live Caddyfile block in place. _secdash_configure_caddy() { - local DASHBOARD_PORT="$1" + local DASHBOARD_PORT="$1" ADMIN_URL="${2:-}" echo "" if ! command -v docker &>/dev/null || ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$"; then @@ -509,6 +522,66 @@ CADDYBLOCK ufw_allow_from_caddy_net "${DASHBOARD_PORT}" fi fi + + _secdash_allow_asterisk_admin_iframe "$ADMIN_URL" "$SD_DOMAIN" +} + +# Best-effort: lets the dashboard's "Asterisk Admin" tab iframe-embed the +# real Asterisk web admin, by swapping that domain's own Caddy site block +# from X-Frame-Options to a CSP frame-ancestors entry naming ONLY this +# dashboard's domain — every other site is still refused framing exactly as +# before, this just relaxes it for the one origin that's supposed to embed +# it. Best-effort because it depends on finding the exact +# X-Frame-Options line services/asterisk-digital-ocean.sh itself generates, +# inside a live Caddyfile it doesn't own — if that block was hand-edited +# since, or doesn't exist yet (Asterisk installed after this dashboard, or +# no local Caddy at all), this silently does nothing and the tab's "open in +# a new tab" fallback link still works either way. +_secdash_allow_asterisk_admin_iframe() { + local ADMIN_URL="$1" SD_DOMAIN="$2" + [ -n "$ADMIN_URL" ] || return 0 + [ -n "$SD_DOMAIN" ] || return 0 + command -v docker &>/dev/null || return 0 + docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$" || return 0 + + local ADMIN_DOMAIN="${ADMIN_URL#https://}" + ADMIN_DOMAIN="${ADMIN_DOMAIN#http://}" + local CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile" + [ -f "$CADDY_FILE" ] || return 0 + grep -q "^${ADMIN_DOMAIN} {" "$CADDY_FILE" || return 0 + + if grep -qF "frame-ancestors 'self' https://${SD_DOMAIN};" "$CADDY_FILE"; then + return 0 # already patched for this exact dashboard domain + fi + + local CSP_LINE=" Content-Security-Policy \"frame-ancestors 'self' https://${SD_DOMAIN};\"" + local TMP_FILE + TMP_FILE="$(mktemp)" + awk -v domain="${ADMIN_DOMAIN} {" -v csp="$CSP_LINE" ' + BEGIN { in_block = 0; patched = 0 } + index($0, domain) == 1 { in_block = 1 } + in_block && !patched && /X-Frame-Options/ { print csp; patched = 1; next } + { print } + in_block && /^}/ { in_block = 0 } + ' "$CADDY_FILE" > "$TMP_FILE" + + if grep -qF "frame-ancestors 'self' https://${SD_DOMAIN};" "$TMP_FILE"; then + cp "$CADDY_FILE" "$CADDY_FILE.backup.$(date +%Y%m%d-%H%M%S)" + mv "$TMP_FILE" "$CADDY_FILE" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || docker restart caddy &>/dev/null; then + log_success "Asterisk web admin (${ADMIN_DOMAIN}) now allows embedding from https://${SD_DOMAIN} — the dashboard's Asterisk Admin tab should load it." + else + log_warning "Caddyfile patched, but reload/restart failed — check: docker logs caddy" + fi + else + rm -f "$TMP_FILE" + log_warning "Couldn't find an X-Frame-Options line in ${ADMIN_DOMAIN}'s Caddy block to patch —" + log_warning "the dashboard's Asterisk Admin tab will show a blank frame. Add this line yourself" + log_warning "inside that domain's 'header { }' block in $CADDY_FILE, replacing X-Frame-Options:" + log_warning " Content-Security-Policy \"frame-ancestors 'self' https://${SD_DOMAIN};\"" + log_warning "then: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi } # Removes the dashboard's existing Caddyfile site block (found via its @@ -1433,8 +1506,8 @@ INDEX_HTML = """ + -
@@ -1480,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
@@ -1522,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
@@ -1542,17 +1615,35 @@ INDEX_HTML = """
+