diff --git a/services/asterisk.sh b/services/asterisk.sh index 826f307..bc71888 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -579,6 +579,21 @@ _asterisk_patch_messaging_vendor_files() { # 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. + # + # Anchor AFTER [intercom]$, not before — confirmed live (see + # _pstn_write_inbound_dialplan_include's comment in pstn-trunk.sh, + # 2026-07-24): a #include'd file whose first real line is its own + # [context] header, inserted right after [intercom]$ via this same + # mechanism, loads fine and does NOT swallow the per-device "exten =>" + # lines the runtime loop appends after it — messaging-dialplan.conf has + # done exactly this since it was written. (The failure mode that + # comment documents is different: mixing "continues the ambient + # context" content with a later context header IN THE SAME FILE broke + # everything in that file, which is why pstn-trunk-dialplan.conf and + # pstn-trunk-inbound-dialplan.conf are two separate files. That doesn't + # apply here — this file is nothing but [sip-messaging] from its first + # line.) Asterisk's exact internal handling isn't fully understood, but + # this position is the one this repo has actually verified working. for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do [[ -f "$f" ]] || continue if ! grep -q 'messaging-dialplan.conf' "$f"; then @@ -612,7 +627,7 @@ _asterisk_ensure_live_messaging_include() { 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 ${CONTAINER_NAME} asterisk -rx \"dialplan reload\"" + log_warning "'#include messaging-dialplan.conf' manually after [intercom], then: docker exec ${CONTAINER_NAME} asterisk -rx \"dialplan reload\"" fi fi docker exec "$CONTAINER_NAME" asterisk -rx "dialplan reload" &>/dev/null || true @@ -701,6 +716,138 @@ exten => _X.,1,NoOp(SIP MESSAGE to ${EXTEN}) EOF } +# Voicemail access codes — reached via [intercom]'s "include => voicemail- +# access" fallback (patched into Easy Asterisk's own device-creation code — +# see _asterisk_patch_voicemail_vendor_files): if a dialed pattern isn't one +# of [intercom]'s own per-device "exten =>" declarations, Asterisk checks +# this context next. *97/*98 are reserved codes here — Easy Asterisk only +# ever assigns numeric-only device extensions, so they can't collide. +# +# Gated live on the TARGET's own "voicemail" flag in pstn-permissions.conf +# (same AST_CONFIG() mechanism messaging-dialplan.conf uses for its +# "messaging" flag, and the exact flag the Security Dashboard's Extensions +# tab writes) — no restart needed to take effect. Off by default: an +# extension with no entry, or voicemail=no, is denied. The mailbox itself +# (password/greeting) lives in voicemail.conf, generated/kept in sync by the +# dashboard's write_voicemail() whenever the flag is toggled — always +# regenerated here is safe since this file has no per-extension state of its +# own, unlike voicemail.conf. +_asterisk_write_voicemail_dialplan() { + local FILE="$1" + cat > "$FILE" << 'EOF' +; Voicemail access codes — services/asterisk.sh. +; Regenerated on every install/update; edit there, not here directly. +[voicemail-access] +; Dial *97 to check your OWN mailbox (matched by caller ID). +exten => *97,1,NoOp(Voicemail check from ${CALLERID(num)}) + same => n,VoiceMailMain(${CALLERID(num)}@default) + same => n,Hangup() + +; Dial *98 to leave a message directly in that extension's +; mailbox without ringing it first. +exten => _*98X.,1,NoOp(Direct voicemail drop for ${EXTEN:4}) + same => n,Set(TARGET=${EXTEN:4}) + same => n,Set(VM_OK=${AST_CONFIG(pstn-permissions.conf,${TARGET},voicemail)}) + same => n,GotoIf($["${VM_OK}" = "yes"]?leave:deny) + same => n(leave),VoiceMail(${TARGET}@default,u) + same => n,Hangup() + same => n(deny),Playback(privacy-incorrect) + same => n,Hangup() +EOF +} + +# Mailbox skeleton only — [general] settings plus an empty [default] +# section. Deliberately NOT regenerated on every install/update once it +# exists: past this point, the Security Dashboard's write_voicemail() owns +# the [default] section's actual mailbox lines (added/removed as extensions +# toggle their voicemail flag, with a PIN generated once and persisted in +# pstn-permissions.conf's voicemail_pin= so it survives every future +# regeneration). Regenerating wholesale here on every "update" would fight +# that — same non-destructive-update rule as every other install-time-vs- +# dashboard-owned file split in this repo (.env, firewall rules, etc.). +_asterisk_write_voicemail_conf() { + local FILE="$1" + [[ -f "$FILE" ]] && return 0 + cat > "$FILE" << 'EOF' +; Voicemail mailboxes — services/asterisk.sh (initial skeleton). +; Mailbox lines below [default] are added/removed by the Security +; Dashboard's Extensions tab (write_voicemail() in app.py) when voicemail is +; toggled for an extension, and this file is never regenerated wholesale +; after this first creation — hand edits below [default] survive. +[general] +format=wav +attach=no +maxmsg=100 +maxsecs=180 +minsecs=3 +review=yes +operator=no + +[default] +EOF +} + +# Same anchor-position reasoning as _asterisk_patch_messaging_vendor_files: +# Same anchor as messaging's own patch (see the comment above +# _asterisk_patch_messaging_vendor_files for the live-confirmed reasoning): +# #include voicemail-dialplan.conf goes right AFTER [intercom]$, same +# position that's actually been verified not to disturb the per-device +# "exten =>" lines that follow it. Unlike messaging, though, voicemail +# access codes (*97/*98) need to actually be DIALABLE from [intercom] +# — messaging is reached via message_context and never through call +# dialplan at all — so this also adds a plain "include => voicemail-access" +# line INSIDE [intercom]'s own body (a dialplan include, not a config +# #include — doesn't open a new context, just tells Asterisk to check +# voicemail-access next if nothing in [intercom] itself matches the dialed +# digits). Both anchor on the same [intercom]$ line; order between them +# doesn't matter to Asterisk (neither is a context header), only that both +# land inside [intercom]'s body. +_asterisk_patch_voicemail_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 "$ENTRYPOINT" "$EASY1" "$EASY2"; do + [[ -f "$f" ]] || continue + if ! grep -q '^\[intercom\]$' "$f"; then + log_warning "$(basename "$f"): '[intercom]' anchor not found — vendor template changed upstream." + log_warning " Add '#include voicemail-dialplan.conf' and 'include => voicemail-access' manually after [intercom] in this file's extensions.conf heredoc." + continue + fi + grep -q '^include => voicemail-access$' "$f" || sed -i '/^\[intercom\]$/a include => voicemail-access' "$f" + grep -q 'voicemail-dialplan.conf' "$f" || sed -i '/^\[intercom\]$/a #include voicemail-dialplan.conf' "$f" + done + + log_success "Vendor generator functions patched for voicemail access codes." +} + +# Live-file counterpart to the vendor-template patch above, same reasoning +# as _asterisk_ensure_live_messaging_include (Easy Asterisk's entrypoint +# only regenerates extensions.conf if it's missing, so a box with existing +# devices never picks up the vendor patch on a plain restart). +_asterisk_ensure_live_voicemail_include() { + local EA_DIR="$1" CONTAINER_NAME="$2" + local EXT_LIVE="$EA_DIR/config/asterisk/extensions.conf" + [[ -f "$EXT_LIVE" ]] || return 0 + if ! grep -q '^\[intercom\]$' "$EXT_LIVE"; then + log_warning "Couldn't find '[intercom]' in the live extensions.conf — add" + log_warning "'#include voicemail-dialplan.conf' and 'include => voicemail-access' manually after [intercom], then: docker exec ${CONTAINER_NAME} asterisk -rx \"dialplan reload\"" + return 0 + fi + if ! grep -q '^include => voicemail-access$' "$EXT_LIVE"; then + sed -i '/^\[intercom\]$/a include => voicemail-access' "$EXT_LIVE" + fi + if ! grep -q 'voicemail-dialplan.conf' "$EXT_LIVE"; then + sed -i '/^\[intercom\]$/a #include voicemail-dialplan.conf' "$EXT_LIVE" + fi + log_success "Patched voicemail access codes directly into the live extensions.conf." + docker exec "$CONTAINER_NAME" asterisk -rx "dialplan reload" &>/dev/null || true +} + _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 @@ -1494,8 +1641,12 @@ install_asterisk() { _asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" _asterisk_ensure_live_messaging_include "$EA_DIR" "$CONTAINER" _asterisk_migrate_existing_devices_message_context "$EA_DIR/config/asterisk/pjsip.conf" + _asterisk_patch_voicemail_vendor_files "$EA_DIR" + _asterisk_write_voicemail_dialplan "$EA_DIR/config/asterisk/voicemail-dialplan.conf" + _asterisk_write_voicemail_conf "$EA_DIR/config/asterisk/voicemail.conf" + _asterisk_ensure_live_voicemail_include "$EA_DIR" "$CONTAINER" ensure_docker_dir_ownership "$EA_DIR/config/asterisk" - chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" + chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" "$EA_DIR/config/asterisk/voicemail-dialplan.conf" log_info "Rebuilding and restarting containers..." if docker compose up -d --build --force-recreate; then @@ -1552,8 +1703,12 @@ install_asterisk() { _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" "$CONTAINER" + _asterisk_patch_voicemail_vendor_files "$EA_DIR" + _asterisk_write_voicemail_dialplan "$EA_DIR/config/asterisk/voicemail-dialplan.conf" + _asterisk_write_voicemail_conf "$EA_DIR/config/asterisk/voicemail.conf" + _asterisk_ensure_live_voicemail_include "$EA_DIR" "$CONTAINER" ensure_docker_dir_ownership "$EA_DIR/config/asterisk" - chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" + chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" "$EA_DIR/config/asterisk/voicemail-dialplan.conf" # ── Domain / networking mode ────────────────────────────────────────────── # A public cloud box is always reachable from anywhere, so there's no diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 9cb099e..c88d45e 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -93,6 +93,14 @@ install_security-dashboard() { # set up before the two Asterisk services merged, matching whichever # container_name services/asterisk.sh actually used there. local ASTERISK_EA_CONFIG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/config/easy-asterisk}" + # Voicemail messages land here (Asterisk's own default spool layout: + # spool/voicemail///INBOX/msgNNNN.{wav,txt}) — the + # ./spool:/var/spool/asterisk bind mount in services/asterisk.sh's + # compose file has always existed, voicemail is just the first feature + # that reads it from the host side. Read-only grant (see + # _secdash_grant_asterisk_access) — the Voicemail tab only ever lists + # and streams messages, never deletes or writes them. + local ASTERISK_SPOOL_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/spool}" local ASTERISK_EA_CONTAINER="" if [[ "$ASTERISK_EA_DIR" == *asterisk-digital-ocean ]]; then ASTERISK_EA_CONTAINER="easy-asterisk-do" @@ -123,6 +131,7 @@ install_security-dashboard() { echo "[DRY-RUN] Would write /etc/sudoers.d/security-dashboard (scoped cscli/systemctl/set-asn-exempt.sh only)" echo "[DRY-RUN] Would write a systemd unit and start it on 0.0.0.0:$DASHBOARD_PORT (firewalled via UFW, not interface binding)" echo "[DRY-RUN] Would grant read/write access to the detected Asterisk config dir (for the Extensions tab)" + echo "[DRY-RUN] Would grant read-only access to the Asterisk voicemail spool dir (for the Voicemail tab)" echo "[DRY-RUN] Would configure Caddy + Authelia for a domain you'll be prompted for" return 0 fi @@ -139,12 +148,12 @@ install_security-dashboard() { case "$MODE" in update) log_info "Refreshing app code + sudoers rule + systemd unit (no Caddy/domain changes)..." - _secdash_grant_asterisk_access "$SVC_USER" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_EA_CONFIG_DIR" + _secdash_grant_asterisk_access "$SVC_USER" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_EA_CONFIG_DIR" "$ASTERISK_SPOOL_DIR" _secdash_write_app "$APP_DIR" _secdash_write_asn_helper "$APP_DIR" _secdash_copy_kiosk_installer "$APP_DIR" _secdash_write_sudoers "$SVC_USER" "$ASTERISK_EA_CONTAINER" - _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_EA_CONFIG_DIR" "$ASTERISK_EA_CONTAINER" + _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_EA_CONFIG_DIR" "$ASTERISK_EA_CONTAINER" "$ASTERISK_SPOOL_DIR" # systemd caches unit files; a plain restart re-runs the OLD # one. Without this, any Environment= or ReadOnlyPaths line # added since the last FRESH install is written to disk and @@ -179,7 +188,7 @@ install_security-dashboard() { log_success "Created system user $SVC_USER" fi - _secdash_grant_asterisk_access "$SVC_USER" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_EA_CONFIG_DIR" + _secdash_grant_asterisk_access "$SVC_USER" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_EA_CONFIG_DIR" "$ASTERISK_SPOOL_DIR" mkdir -p "$APP_DIR" _secdash_write_app "$APP_DIR" @@ -188,7 +197,7 @@ install_security-dashboard() { _secdash_copy_kiosk_installer "$APP_DIR" _secdash_write_sudoers "$SVC_USER" "$ASTERISK_EA_CONTAINER" - _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_EA_CONFIG_DIR" "$ASTERISK_EA_CONTAINER" + _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_EA_CONFIG_DIR" "$ASTERISK_EA_CONTAINER" "$ASTERISK_SPOOL_DIR" systemctl daemon-reload systemctl enable security-dashboard >/dev/null 2>&1 @@ -217,10 +226,10 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly. ## Tabs -Four tabs: **Security Log**, **Calls & Texts**, **Extensions**, **CrowdSec**. -The first three are always there (they only need Asterisk itself, detected -once at install time); CrowdSec checks its own live install state on every -page load and hides its nav button if \`cscli\` isn't found. +Five tabs: **Security Log**, **Calls & Texts**, **Extensions**, **Voicemail**, +**CrowdSec**. The first four are always there (they only need Asterisk +itself, detected once at install time); CrowdSec checks its own live install +state on every page load and hides its nav button if \`cscli\` isn't found. Extensions used to be three separate tabs — *Asterisk Admin*, *Extensions* and *PSTN Trunk* — which between them listed the same extensions three times: @@ -253,8 +262,13 @@ which tab a given extension's settings live on. always works) and, when the Easy Asterisk container is reachable, its own device list. Columns: Ext, Name, then Category/Status/Transport if that container is present, then **PSTN** + **Whitelist** if a trunk dialplan is - installed, then Messaging (always: internal SIP texting has no PSTN - dependency at all — no cost, no carrier, no DID). + installed, then Messaging and Voicemail (always: neither has a PSTN + dependency at all — no cost, no carrier, no DID). Enabling Voicemail + generates a 4-digit PIN, shown in that column, for checking messages by + phone (\`*97\`); \`*98\` leaves a message directly without ringing. +- **Voicemail** — every message across every mailbox's spool, newest first, + with an inline player per row (click-to-play, no download). Read-only — + delete a message by dialing in with the PIN and using the phone menu. Each extension has one whitelist and a mode saying which direction(s) it applies to: **No PSTN**, **Unrestricted**, **Restrict outbound** (may only @@ -436,7 +450,7 @@ _secdash_grant_ancestor_traversal() { # approach with a warning if the `acl` package isn't installed for some # reason (should always be present — installed below). _secdash_grant_asterisk_access() { - local _svc_user="$1" _log_dir="$2" _config_dir="$3" _ea_config_dir="${4:-}" + local _svc_user="$1" _log_dir="$2" _config_dir="$3" _ea_config_dir="${4:-}" _spool_dir="${5:-}" command -v setfacl >/dev/null 2>&1 || run_cmd apt-get install -y acl >/dev/null 2>&1 local _have_acl=false @@ -444,7 +458,7 @@ _secdash_grant_asterisk_access() { [ "$_have_acl" = true ] || log_warning "Package 'acl' unavailable — falling back to group-based access, which can silently break again whenever the Asterisk container re-chowns its own config directory. Install 'acl' and re-run to fix that properly." local _dir - for _dir in "$_log_dir" "$_config_dir" "$_ea_config_dir"; do + for _dir in "$_log_dir" "$_config_dir" "$_ea_config_dir" "$_spool_dir"; do [ -n "$_dir" ] && [ -d "$_dir" ] || continue _secdash_grant_ancestor_traversal "$_svc_user" "$_dir" if [ "$_have_acl" = true ]; then @@ -503,10 +517,11 @@ _secdash_grant_asterisk_access() { # both layers (this AND the group access above) need to agree, or writes # fail even when Unix permissions alone would have allowed them. _secdash_write_systemd_unit() { - local _app_dir="$1" _svc_user="$2" _port="$3" _log_dir="$4" _config_dir="$5" _ea_config_dir="${6:-}" _ea_container="${7:-}" + local _app_dir="$1" _svc_user="$2" _port="$3" _log_dir="$4" _config_dir="$5" _ea_config_dir="${6:-}" _ea_container="${7:-}" _spool_dir="${8:-}" local _read_only_paths="" _read_write_paths="/etc/crowdsec/scenarios" [ -n "$_log_dir" ] && _read_only_paths="$_log_dir" [ -n "$_ea_config_dir" ] && _read_only_paths="$_read_only_paths $_ea_config_dir" + [ -n "$_spool_dir" ] && _read_only_paths="$_read_only_paths $_spool_dir" # ProtectSystem=strict hides everything not listed, so the .env grant above # is only half the story — the unit has to be told it may read the file too. [ -n "$_config_dir" ] && _read_only_paths="$_read_only_paths ${_config_dir%/config/asterisk}/.env" @@ -528,6 +543,7 @@ Environment=ASTERISK_CONFIG_DIR=$_config_dir Environment=ASTERISK_EA_CONFIG_DIR=$_ea_config_dir Environment=ASTERISK_EA_CONTAINER=$_ea_container Environment=ASTERISK_EA_ENV=${_config_dir%/config/asterisk}/.env +Environment=ASTERISK_SPOOL_DIR=$_spool_dir ExecStart=/usr/bin/python3 $_app_dir/app.py Restart=on-failure RestartSec=3 @@ -571,6 +587,7 @@ $_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container chown asteri $_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container chown asterisk\:asterisk /etc/easy-asterisk/rooms.conf $_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container asterisk -rx module\ reload\ res_pjsip.so $_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container asterisk -rx pjsip\ show\ endpoints +$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container asterisk -rx module\ reload\ app_voicemail.so $_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec $_ea_container /usr/local/bin/easy-asterisk --rebuild-dialplan $_svc_user ALL=(root) NOPASSWD: /usr/bin/docker restart $_ea_container" fi @@ -883,6 +900,7 @@ Caddy, and CrowdSec, and shouldn't add a framework's worth of RAM overhead. import configparser import json import os +import random import re import subprocess import time @@ -900,6 +918,11 @@ PSTN_CALLS_LOG = os.path.join(ASTERISK_LOG_DIR, "pstn-trunk-calls.log") if ASTER SIP_MESSAGES_LOG = os.path.join(ASTERISK_LOG_DIR, "sip-messages.log") if ASTERISK_LOG_DIR else "" SMS_LOG = os.path.join(ASTERISK_LOG_DIR, "pstn-sms.log") if ASTERISK_LOG_DIR else "" ASTERISK_CONFIG_DIR = os.environ.get("ASTERISK_CONFIG_DIR", "") +# Voicemail spool root — see the comment above ASTERISK_SPOOL_DIR's +# declaration in install_security-dashboard() for the on-disk layout. +# Read-only (ReadOnlyPaths in the systemd unit + a read-only ACL grant), the +# Voicemail tab only ever lists/streams, never writes or deletes. +ASTERISK_SPOOL_DIR = os.environ.get("ASTERISK_SPOOL_DIR", "") ASN_SCENARIO_FILES = [ "/etc/crowdsec/scenarios/local-asterisk_bf.yaml", "/etc/crowdsec/scenarios/local-asterisk_user_enum.yaml", @@ -1340,10 +1363,13 @@ PERMISSIONS_HEADER = ( "; restrict + allowed_numbers are the authored pair; tier_out/allowed_out\n" "; and tier_in/allowed_in are DERIVED from them and are what the dialplan\n" "; reads; 'tier' is a rollback mirror for a pre-split pstn-trunk.sh.\n" - "; PLUS two\n" + "; PLUS three\n" "; independent per-extension axes: messaging (internal SIP MESSAGE\n" - "; texting) and personal_did (outbound Caller-ID override; inbound\n" - "; routing for personal DIDs lives in pstn-personal-dids.conf).\n" + "; texting), personal_did (outbound Caller-ID override; inbound\n" + "; routing for personal DIDs lives in pstn-personal-dids.conf), and\n" + "; voicemail (mailbox enabled; voicemail_pin is generated once and kept\n" + "; even if voicemail is later disabled, so re-enabling doesn't change it\n" + "; on the user - see write_voicemail()/regenerate_voicemail_conf()).\n" "; Read LIVE by the dialplan on every call (AST_CONFIG()) - no\n" "; Asterisk restart needed. Managed here (Security Dashboard); also\n" "; safe to edit by hand. 'sudo ./setup.sh pstn-trunk' update mode\n" @@ -1439,6 +1465,8 @@ def get_all_permissions(): "restrict": restrict, "allowed_numbers": numbers, "messaging": cp.getboolean(section, "messaging", fallback=False), + "voicemail": cp.getboolean(section, "voicemail", fallback=False), + "voicemail_pin": cp.get(section, "voicemail_pin", fallback=""), } return result @@ -1465,9 +1493,36 @@ def _set_or_clear(cp, ext, key, value): cp.remove_option(ext, key) -def write_permission(ext, restrict, numbers_raw, messaging_enabled=False): +def _apply_voicemail_flag(cp, ext, enabled): + """Shared by write_permission() (the combined-save endpoint, used when a + PSTN trunk is installed) and write_voicemail() (the standalone endpoint, + used when it isn't) — same PIN-preservation behavior either way, see + write_voicemail()'s docstring.""" + if enabled: + if not cp.has_section(ext): + cp.add_section(ext) + cp.set(ext, "voicemail", "yes") + if not cp.get(ext, "voicemail_pin", fallback=""): + cp.set(ext, "voicemail_pin", _generate_voicemail_pin()) + elif cp.has_section(ext) and cp.has_option(ext, "voicemail"): + cp.remove_option(ext, "voicemail") + + +def _sync_voicemail_conf_if_present(): + """Regenerates voicemail.conf + reloads app_voicemail, but only if + voicemail has actually been set up on this box (asterisk.sh writes the + skeleton at install/update time) — silently a no-op otherwise, so boxes + that never touched voicemail don't get a spurious error surfaced from + every combined permissions save.""" + path = _voicemail_conf_path() + if path and os.path.isfile(path): + regenerate_voicemail_conf() + ea_reload_voicemail() + + +def write_permission(ext, restrict, numbers_raw, messaging_enabled=False, voicemail_enabled=False): """Saves one extension's PSTN restriction mode, its single whitelist, and - its messaging flag in one action. + its messaging/voicemail flags in one action. One list, not two: the whitelist is "the numbers this extension deals with", and the mode says whether that constrains dialling out, being @@ -1480,9 +1535,10 @@ def write_permission(ext, restrict, numbers_raw, messaging_enabled=False): tier_in/allowed_in what the dialplan reads tier, allowed_numbers rollback mirror for a pre-split installer - Messaging is an independent axis (see pstn-trunk.sh's file-level comment: - an extension can have no PSTN at all and still be messaging-enabled, or - vice versa), so it's set/cleared regardless of the mode. + Messaging and voicemail are both independent axes (see pstn-trunk.sh's + file-level comment: an extension can have no PSTN at all and still be + messaging/voicemail-enabled, or vice versa), so both are set/cleared + regardless of the mode. Numbers normalize to a pipe-separated list of 11-digit US numbers (a bare 10-digit entry gains a leading "1" rather than being dropped — see @@ -1531,9 +1587,11 @@ def write_permission(ext, restrict, numbers_raw, messaging_enabled=False): elif cp.has_section(ext) and cp.has_option(ext, "messaging"): cp.remove_option(ext, "messaging") + _apply_voicemail_flag(cp, ext, voicemail_enabled) + # Drop the section entirely once nothing is left in it — only reachable - # when the mode is internal, messaging is off, and no personal_did was - # ever assigned. + # when the mode is internal, messaging and voicemail are both off, and + # no personal_did/voicemail_pin was ever assigned. if cp.has_section(ext) and not cp.options(ext): cp.remove_section(ext) @@ -1541,6 +1599,8 @@ def write_permission(ext, restrict, numbers_raw, messaging_enabled=False): if not ok: return False, err + _sync_voicemail_conf_if_present() + if restrict not in ("internal", "full") and not clean: return True, ("Saved, but the whitelist is EMPTY — with this mode that means no PSTN " "number is permitted in the restricted direction yet.") @@ -1578,6 +1638,188 @@ def write_messaging(ext, enabled): return True, "Saved" +def _voicemail_conf_path(): + return os.path.join(ASTERISK_CONFIG_DIR, "voicemail.conf") if ASTERISK_CONFIG_DIR else None + + +def _generate_voicemail_pin(): + return "%04d" % random.randint(0, 9999) + + +def regenerate_voicemail_conf(): + """Rewrites voicemail.conf's [default] mailbox list from every extension + currently voicemail=yes in pstn-permissions.conf. [general] (and + anything else above [default]) is preserved verbatim — this only ever + replaces [default] onward, mirroring _asterisk_write_voicemail_conf's + skeleton in services/asterisk.sh, which always writes [default] as the + file's last section. + + Mailbox lines can't be a live AST_CONFIG() lookup the way the messaging/ + voicemail permission flags are — app_voicemail reads mailbox + definitions from its own module config, not the dialplan, so this file + has to actually list them. That's also why a module reload is needed + after this (see write_voicemail()), unlike a plain permission-flag + toggle.""" + path = _voicemail_conf_path() + if not path or not os.path.isfile(path): + return False, "No voicemail.conf found — is voicemail set up? (re-run: sudo ./setup.sh asterisk)" + + with open(path, "r") as f: + content = f.read() + + idx = content.find("\n[default]") + if idx == -1: + return False, "voicemail.conf has no [default] section — was it hand-edited? Re-run: sudo ./setup.sh asterisk" + preamble = content[:idx] + + cp = _read_permissions_cp() + mailbox_lines = [] + for section in cp.sections(): + if not EXTEN_RE.match(section): + continue + if not cp.getboolean(section, "voicemail", fallback=False): + continue + pin = cp.get(section, "voicemail_pin", fallback="") + if pin: + mailbox_lines.append("%s => %s,Extension %s" % (section, pin, section)) + + new_content = preamble.rstrip("\n") + "\n\n[default]\n" + "\n".join(mailbox_lines) + new_content += "\n" if mailbox_lines else "" + + tmp = path + ".tmp" + try: + with open(tmp, "w") as f: + f.write(new_content) + os.replace(tmp, path) + except OSError as e: + return False, str(e) + return True, "" + + +def ea_reload_voicemail(): + if not ASTERISK_EA_CONTAINER: + return + run_sudo(["docker", "exec", ASTERISK_EA_CONTAINER, "asterisk", "-rx", "module reload app_voicemail.so"]) + + +def write_voicemail(ext, enabled): + """Sets/clears the voicemail flag for one extension, then regenerates + voicemail.conf and reloads app_voicemail so the change takes effect + without a full Asterisk restart (unlike the messaging/PSTN permission + flags, which the dialplan reads live via AST_CONFIG() with no reload of + any kind needed — voicemail.conf is Asterisk's own module config, not + something AST_CONFIG() can substitute for). + + A PIN is generated the first time voicemail is enabled and then left in + pstn-permissions.conf even after disabling — toggling it off and back on + later reuses the same PIN instead of silently changing it on the user. + Independent of pstn_installed() the same way messaging is: voicemail has + no PSTN/trunk dependency.""" + if not ASTERISK_CONFIG_DIR: + return False, "No Asterisk install detected on this box" + ext = str(ext).strip() + if not EXTEN_RE.match(ext): + return False, "Invalid extension" + + cp = _read_permissions_cp() + _apply_voicemail_flag(cp, ext, enabled) + + if cp.has_section(ext) and not cp.options(ext): + cp.remove_section(ext) + + ok, err = _write_ini_cp(_permissions_path(), PERMISSIONS_HEADER, cp) + if not ok: + return False, err + + ok, err = regenerate_voicemail_conf() + if not ok: + return True, "Saved, but voicemail.conf couldn't be regenerated: %s" % err + + ea_reload_voicemail() + return True, "Saved" + + +# "default" here is the voicemail.conf CONTEXT name (the [default] section +# _asterisk_write_voicemail_conf writes in services/asterisk.sh, matching +# the "@default" suffix voicemail-dialplan.conf's VoiceMailMain()/VoiceMail() +# calls use) — not a placeholder, Asterisk's own spool layout is +# spool/voicemail////msgNNNN.{wav,txt}. +VOICEMAIL_CONTEXT = "default" +VOICEMAIL_MSG_RE = re.compile(r"^msg\d+$") + + +def list_voicemail_messages(): + """Every message across every mailbox's INBOX folder, newest first. + Reads each msgNNNN.txt sidecar app_voicemail writes alongside the .wav + (INI-style, a [message] section with callerid/origtime/duration) — this + dashboard never writes these files, only reads them.""" + if not ASTERISK_SPOOL_DIR: + return [] + base = os.path.join(ASTERISK_SPOOL_DIR, "voicemail", VOICEMAIL_CONTEXT) + if not os.path.isdir(base): + return [] + messages = [] + try: + mailboxes = os.listdir(base) + except OSError: + return [] + for mailbox in mailboxes: + if not EXTEN_RE.match(mailbox): + continue + inbox = os.path.join(base, mailbox, "INBOX") + if not os.path.isdir(inbox): + continue + try: + files = os.listdir(inbox) + except OSError: + continue + for fname in files: + if not fname.endswith(".wav"): + continue + msg_id = fname[:-4] + if not VOICEMAIL_MSG_RE.match(msg_id): + continue + callerid, origtime, duration = "", "", "" + txt_path = os.path.join(inbox, msg_id + ".txt") + if os.path.isfile(txt_path): + cp = configparser.ConfigParser(delimiters=("=",)) + try: + cp.read(txt_path) + if cp.has_section("message"): + callerid = cp.get("message", "callerid", fallback="") + origtime = cp.get("message", "origtime", fallback="") + duration = cp.get("message", "duration", fallback="") + except configparser.Error: + pass + messages.append({ + "ext": mailbox, "msg": msg_id, + "callerid": callerid, "origtime": origtime, "duration": duration, + }) + messages.sort(key=lambda m: int(m["origtime"]) if m["origtime"].isdigit() else 0, reverse=True) + return messages + + +def voicemail_audio_path(ext, msg): + """Resolves a mailbox+message ID to an on-disk .wav path, or None if + anything about the request doesn't check out. Two independent checks, + not one: EXTEN_RE/VOICEMAIL_MSG_RE already forbid any path-traversal + character (only digits, and "msg"+digits, are accepted at all — no "/", + "..", or similar can ever reach os.path.join), and the resolved + realpath is then confirmed to still land inside the mailbox's own INBOX + before this is ever handed to open().""" + ext = str(ext or "").strip() + msg = str(msg or "").strip() + if not ASTERISK_SPOOL_DIR or not EXTEN_RE.match(ext) or not VOICEMAIL_MSG_RE.match(msg): + return None + inbox = os.path.join(ASTERISK_SPOOL_DIR, "voicemail", VOICEMAIL_CONTEXT, ext, "INBOX") + path = os.path.join(inbox, msg + ".wav") + real_inbox = os.path.realpath(inbox) + real_path = os.path.realpath(path) + if not real_path.startswith(real_inbox + os.sep) or not os.path.isfile(real_path): + return None + return real_path + + GROUP_NAME_RE = re.compile(r"^[A-Za-z0-9_ -]{1,40}$") GROUPS_HEADER = ( @@ -3271,6 +3513,7 @@ INDEX_HTML = """ + @@ -3417,6 +3660,7 @@ INDEX_HTML = """

Internal extension-to-extension calling and ring groups are never gated by any of this. Changes are usually live on the next call; if one doesn't seem to take effect, use "Commit changes" above.

Messaging — Asterisk's native SIP texting between extensions: no carrier SMS, no PSTN, no cost, and no dependency on a PSTN trunk at all (which is why this column is here even with no trunk installed). Independent of the calling tier. Enforced live by a dedicated dialplan context — see services/asterisk.sh's README, including its caveat that the sender-extraction logic still needs real-traffic confirmation. If this box predates that wiring, rerun sudo ./setup.sh asterisk.

+

Voicemail — enables a mailbox for the extension. Dial *97 to check your own messages, or *98<ext> to leave one directly without ringing it. A 4-digit PIN is generated the first time you enable it (shown in this column) and kept even if you later disable and re-enable voicemail. Also independent of the calling tier and PSTN trunk. Play messages back on the Voicemail tab. If this box predates voicemail support, rerun sudo ./setup.sh asterisk.

@@ -3429,6 +3673,7 @@ INDEX_HTML = """ PSTN Whitelist Messaging + Voicemail
@@ -3506,6 +3751,23 @@ INDEX_HTML = """ + +
""" @@ -5384,11 +5731,14 @@ class Handler(BaseHTTPRequestHandler): perms = get_all_permissions() extensions = [] for e in list_extensions(): - p = perms.get(e["ext"], {"restrict": "internal", "allowed_numbers": "", "messaging": False}) + p = perms.get(e["ext"], {"restrict": "internal", "allowed_numbers": "", + "messaging": False, "voicemail": False, "voicemail_pin": ""}) extensions.append({"ext": e["ext"], "name": e["name"], "restrict": p["restrict"], "allowed_numbers": p["allowed_numbers"], - "messaging": p["messaging"]}) + "messaging": p["messaging"], + "voicemail": p["voicemail"], + "voicemail_pin": p["voicemail_pin"]}) self._json({"extensions": extensions}) elif self.path == "/api/pstn-limits": self._json(get_limits()) @@ -5453,6 +5803,22 @@ class Handler(BaseHTTPRequestHandler): self._json({"devices": ea_list_devices(), "status": ea_get_status()}) elif self.path == "/api/ea-rooms": self._json({"rooms": ea_list_rooms()}) + elif self.path == "/api/voicemail": + self._json({"messages": list_voicemail_messages()}) + elif self.path.startswith("/voicemail/audio?"): + qs = urllib.parse.parse_qs(self.path.split("?", 1)[1]) + path = voicemail_audio_path((qs.get("ext") or [""])[0], (qs.get("msg") or [""])[0]) + if not path: + self._json({"error": "not found"}, 404) + else: + with open(path, "rb") as f: + raw = f.read() + self.send_response(200) + self.send_header("Content-Type", "audio/wav") + self.send_header("Content-Length", str(len(raw))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(raw) else: self._json({"error": "not found"}, 404) @@ -5477,6 +5843,7 @@ class Handler(BaseHTTPRequestHandler): payload.get("ext", ""), payload.get("restrict", ""), payload.get("allowed_numbers", ""), bool(payload.get("messaging", False)), + bool(payload.get("voicemail", False)), ) self._json({"ok": ok, "message": message}) elif self.path == "/api/pstn-limits": @@ -5491,6 +5858,9 @@ class Handler(BaseHTTPRequestHandler): elif self.path == "/api/pstn-messaging": ok, message = write_messaging(payload.get("ext", ""), bool(payload.get("enabled", False))) self._json({"ok": ok, "message": message}) + elif self.path == "/api/pstn-voicemail": + ok, message = write_voicemail(payload.get("ext", ""), bool(payload.get("enabled", False))) + self._json({"ok": ok, "message": message}) elif self.path == "/api/asterisk-restart": ok, message = restart_asterisk_container() self._json({"ok": ok, "message": message})