Add voicemail: dialplan/mailboxes in Asterisk, Extensions toggle + Voicemail tab with click-to-play in the dashboard

Asterisk side (services/asterisk.sh): a [voicemail-access] context reachable
from every extension (*97 checks your own mailbox, *98<ext> drops a message
into another mailbox directly), gated live via AST_CONFIG() on a new
"voicemail" flag in pstn-permissions.conf. voicemail.conf gets a skeleton
[general]+[default] at install/update, then stays dashboard-owned from
there — mailbox lines are never regenerated wholesale by asterisk.sh once
the file exists, matching every other install-time-vs-dashboard-owned file
split in this repo (.env, firewall rules, etc). Vendor files (entrypoint.sh,
easy-asterisk.sh) get patched the same way messaging-dialplan.conf already
does, including the live-extensions.conf patch for boxes with existing
devices.

While tracing the right #include anchor for this, found and then reverted a
theoretical "fix" to messaging's own #include position: pstn-trunk.sh's own
comment (live-confirmed 2026-07-24) directly contradicts the textbook
Asterisk #include semantics I'd assumed, so the safer move was keeping
messaging's anchor exactly as already verified working and using the same
position for voicemail's own #include.

Dashboard side (services/security-dashboard.sh): write_voicemail()/
_apply_voicemail_flag() toggle the flag and a PIN (generated once, kept
across future toggles), regenerate_voicemail_conf() keeps voicemail.conf's
[default] section in sync, and a module reload takes effect without a full
Asterisk restart. Extensions tab gets a Voicemail column next to Messaging,
showing the PIN once generated. New Voicemail tab lists every mailbox's
messages (parsed from Asterisk's own msgNNNN.txt sidecars) with an inline
<audio> player per row — /voicemail/audio validates ext/msg against strict
regexes plus a resolved-path containment check before ever opening a file.
Dashboard gets read-only ACL + systemd ReadOnlyPaths access to the
voicemail spool dir, and a new sudoers-scoped module-reload command.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
This commit is contained in:
Claude
2026-08-11 05:58:42 +00:00
parent 30b5e91249
commit 8950810cea
2 changed files with 564 additions and 39 deletions
+158 -3
View File
@@ -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<extension> 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<ext>) 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
+406 -36
View File
@@ -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/<context>/<mailbox>/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<ext>\` 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/<context>/<mailbox>/<folder>/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 = """<!doctype html>
<button class="tab-btn" data-tab="security">Security Log</button>
<button class="tab-btn" data-tab="comms">Calls &amp; Texts</button>
<button class="tab-btn active" data-tab="extensions">Extensions</button>
<button class="tab-btn" data-tab="voicemail">Voicemail</button>
<button class="tab-btn" id="crowdsec-tab-btn" data-tab="crowdsec" style="display:none">CrowdSec</button>
</nav>
</header>
@@ -3417,6 +3660,7 @@ INDEX_HTML = """<!doctype html>
</ul>
<p class="muted pstn-only">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.</p>
<p class="muted"><b>Messaging</b> — 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 <code>services/asterisk.sh</code>'s README, including its caveat that the sender-extraction logic still needs real-traffic confirmation. If this box predates that wiring, rerun <code>sudo ./setup.sh asterisk</code>.</p>
<p class="muted"><b>Voicemail</b> — enables a mailbox for the extension. Dial <code>*97</code> to check your own messages, or <code>*98&lt;ext&gt;</code> 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 <b>Voicemail</b> tab. If this box predates voicemail support, rerun <code>sudo ./setup.sh asterisk</code>.</p>
</details>
<div class="table-wrap">
@@ -3429,6 +3673,7 @@ INDEX_HTML = """<!doctype html>
<th class="sortable pstn-only" data-sort="restrict">PSTN</th>
<th class="pstn-only">Whitelist</th>
<th class="sortable" data-sort="messaging">Messaging</th>
<th class="sortable" data-sort="voicemail">Voicemail</th>
<th></th>
</tr></thead><tbody></tbody></table>
</div>
@@ -3506,6 +3751,23 @@ INDEX_HTML = """<!doctype html>
</div>
</details>
</div>
<div id="tab-voicemail" style="display:none">
<div class="card">
<div class="card-body">
<p class="muted" style="margin-top:0">Messages left across every mailbox, newest first. Enable voicemail for an extension on the <b>Extensions</b> tab — that also generates the PIN shown there for checking messages by phone (<code>*97</code>). Read-only here — to delete a message, dial in with the PIN (<code>*97</code>) and delete it from the phone menu.</p>
<div class="table-wrap">
<table id="vm-table"><thead><tr>
<th class="sortable" data-sort="origtime">Time</th>
<th class="sortable" data-sort="ext">Mailbox</th>
<th class="sortable" data-sort="callerid">Caller ID</th>
<th class="sortable" data-sort="duration">Duration</th>
<th>Play</th>
</tr></thead><tbody></tbody></table>
</div>
</div>
</div>
</div>
</main>
<div id="toasts"></div>
<script>
@@ -4130,13 +4392,14 @@ var QRCode;
function esc(s) { return (s || "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
const TABS = ["security", "comms", "extensions", "crowdsec"];
const TABS = ["security", "comms", "extensions", "voicemail", "crowdsec"];
document.querySelectorAll(".tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
TABS.forEach(t => { document.getElementById("tab-" + t).style.display = btn.dataset.tab === t ? "" : "none"; });
if (btn.dataset.tab === "extensions") refreshExtensionsTab();
if (btn.dataset.tab === "voicemail") loadVoicemail();
});
});
@@ -4192,6 +4455,65 @@ async function loadSecurity() {
renderSecurity();
}
// ── Voicemail tab ────────────────────────────────────────────────────────
let lastVoicemail = [];
let vmSort = { key: "origtime", dir: -1 };
function fmtVmTime(origtime) {
if (!origtime || !/^\d+$/.test(origtime)) return "";
return new Date(parseInt(origtime, 10) * 1000).toLocaleString();
}
function fmtVmDuration(seconds) {
const s = parseInt(seconds, 10);
if (!Number.isFinite(s) || s < 0) return "";
const m = Math.floor(s / 60), r = s % 60;
return m + ":" + String(r).padStart(2, "0");
}
function renderVoicemail() {
let rows = lastVoicemail.slice();
if (vmSort.key) {
rows.sort((a, b) => {
let av = a[vmSort.key], bv = b[vmSort.key];
if (vmSort.key === "origtime" || vmSort.key === "duration") { av = parseInt(av, 10) || 0; bv = parseInt(bv, 10) || 0; }
else { av = (av || "").toString().toLowerCase(); bv = (bv || "").toString().toLowerCase(); }
if (av < bv) return -1 * vmSort.dir;
if (av > bv) return 1 * vmSort.dir;
return 0;
});
}
document.querySelectorAll("#vm-table th.sortable .arrow").forEach(a => a.remove());
if (vmSort.key) {
const th = document.querySelector(`#vm-table th[data-sort="${vmSort.key}"]`);
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${vmSort.dir === 1 ? "▲" : "▼"}</span>`);
}
const tbody = document.querySelector("#vm-table tbody");
tbody.innerHTML = rows.map(m => `<tr>
<td>${esc(fmtVmTime(m.origtime))}</td>
<td>${esc(m.ext)}</td>
<td>${esc(m.callerid || "Unknown")}</td>
<td>${esc(fmtVmDuration(m.duration))}</td>
<td><audio controls preload="none" style="height:2rem" src="/voicemail/audio?ext=${encodeURIComponent(m.ext)}&msg=${encodeURIComponent(m.msg)}"></audio></td>
</tr>`).join("") || `<tr><td colspan=5 class=muted>No voicemail messages. Enable voicemail for an extension on the Extensions tab.</td></tr>`;
}
document.querySelectorAll("#vm-table th.sortable").forEach(th => {
th.addEventListener("click", () => {
const key = th.dataset.sort;
vmSort.dir = (vmSort.key === key) ? -vmSort.dir : 1;
vmSort.key = key;
renderVoicemail();
});
});
async function loadVoicemail() {
const res = await fetch("/api/voicemail");
const data = await res.json();
lastVoicemail = data.messages || [];
renderVoicemail();
}
// ── Calls & Texts tab ───────────────────────────────────────────────────────
let lastCalls = [];
let callsSort = { key: null, dir: 1 };
@@ -4484,7 +4806,8 @@ async function loadExtensions() {
(permData.extensions || []).forEach(e => byExt.set(e.ext, {
ext: e.ext, name: e.name,
restrict: e.restrict, allowed_numbers: e.allowed_numbers,
messaging: e.messaging, ea: false, category: "", status: "", transport: "", encryption: "",
messaging: e.messaging, voicemail: e.voicemail, voicemail_pin: e.voicemail_pin,
ea: false, category: "", status: "", transport: "", encryption: "",
}));
if (eaInstalled) {
@@ -4551,6 +4874,7 @@ const RESTRICT_LABELS = [
function extSortValue(e, key) {
if (key === "ext") return parseInt(e.ext, 10);
if (key === "messaging") return e.messaging ? 1 : 0;
if (key === "voicemail") return e.voicemail ? 1 : 0;
if (key === "restrict") return RESTRICT_ORDER[e[key]] ?? -1;
return (e[key] || "").toString().toLowerCase();
}
@@ -4618,6 +4942,10 @@ function renderExtensions() {
<td class="pstn-only">${restrictSelect(e.restrict)}</td>
<td class="pstn-only"><input type="text" class="ext-numbers" value="${esc(e.allowed_numbers)}" placeholder="5551234567,5559876543" ${restrictUsesList(e.restrict) ? "" : "disabled"} style="width:16rem" aria-label="Whitelist for extension ${esc(e.ext)}"></td>
<td style="text-align:center"><input type="checkbox" class="ext-messaging" ${e.messaging ? "checked" : ""} aria-label="Messaging for extension ${esc(e.ext)}"></td>
<td style="text-align:center">
<input type="checkbox" class="ext-voicemail" ${e.voicemail ? "checked" : ""} aria-label="Voicemail for extension ${esc(e.ext)}">
${e.voicemail_pin ? `<span class="muted" style="font-size:0.85em" title="Voicemail PIN">PIN ${esc(e.voicemail_pin)}</span>` : ""}
</td>
<td class="actions">
<button class="icon ea-only" title="Connection details for ${esc(e.ext)}" onclick="toggleEaDeviceDetails('${esc(e.ext)}')">&#9432;</button>
<button class="icon ea-only" title="Delete extension ${esc(e.ext)}" onclick="deleteEaDevice('${esc(e.ext)}')">&times;</button>
@@ -4653,12 +4981,14 @@ function rowEdits(tr) {
const modeEl = tr.querySelector(".ext-restrict");
const numsEl = tr.querySelector(".ext-numbers");
const msgEl = tr.querySelector(".ext-messaging");
const vmEl = tr.querySelector(".ext-voicemail");
const edits = {};
if (nameEl && !nameEl.disabled && nameEl.value.trim() !== (model.name || "").trim()) edits.name = nameEl.value.trim();
if (mobileEl && mobileEl.checked !== (model.category === "mobile")) edits.category = mobileEl.checked ? "mobile" : "standard";
if (modeEl && modeEl.value !== model.restrict) edits.restrict = modeEl.value;
if (numsEl && numsEl.value !== model.allowed_numbers) edits.allowed_numbers = numsEl.value;
if (msgEl && msgEl.checked !== !!model.messaging) edits.messaging = msgEl.checked;
if (vmEl && vmEl.checked !== !!model.voicemail) edits.voicemail = vmEl.checked;
return {ext, model, edits, tr, count: Object.keys(edits).length};
}
@@ -4708,24 +5038,36 @@ document.getElementById("ext-save-all").addEventListener("click", async () => {
if (!r.ok) throw new Error(r.message || "category change failed");
}
const permKeys = ["restrict", "allowed_numbers", "messaging"];
if (permKeys.some(k => k in edits)) {
const messaging = "messaging" in edits ? edits.messaging : !!model.messaging;
let r;
if (pstnInstalled) {
if (pstnInstalled) {
if (permKeys.some(k => k in edits) || "voicemail" in edits) {
const messaging = "messaging" in edits ? edits.messaging : !!model.messaging;
const voicemail = "voicemail" in edits ? edits.voicemail : !!model.voicemail;
// Send the whole permission record, not just the changed fields —
// the endpoint rewrites all of it, so omitting an unchanged value
// would silently reset it.
r = await postJSON("/api/pstn-permissions", {
const r = await postJSON("/api/pstn-permissions", {
ext: ext,
restrict: "restrict" in edits ? edits.restrict : model.restrict,
allowed_numbers: "allowed_numbers" in edits ? edits.allowed_numbers : model.allowed_numbers,
messaging: messaging,
voicemail: voicemail,
});
} else {
r = await postJSON("/api/pstn-messaging", {ext: ext, enabled: messaging});
if (!r.ok) throw new Error(r.message || "permission save failed");
markPstnDirty(); touchedPstn = true;
}
} else {
// No PSTN trunk — messaging and voicemail are each independent of
// it, so they go through their own standalone endpoints instead of
// the combined one above.
if (permKeys.some(k => k in edits)) {
const messaging = "messaging" in edits ? edits.messaging : !!model.messaging;
const r = await postJSON("/api/pstn-messaging", {ext: ext, enabled: messaging});
if (!r.ok) throw new Error(r.message || "permission save failed");
}
if ("voicemail" in edits) {
const r = await postJSON("/api/pstn-voicemail", {ext: ext, enabled: edits.voicemail});
if (!r.ok) throw new Error(r.message || "voicemail save failed");
}
if (!r.ok) throw new Error(r.message || "permission save failed");
if (pstnInstalled) { markPstnDirty(); touchedPstn = true; }
}
saved++;
} catch (err) {
@@ -5331,10 +5673,15 @@ loadDecisions();
loadAsnExempt();
loadCrowdsecStatus();
initExtensionsTab();
loadVoicemail();
setInterval(loadSecurity, 30000);
setInterval(loadCalls, 30000);
setInterval(loadTexts, 30000);
setInterval(loadDecisions, 30000);
// No setInterval for voicemail (unlike the tabs above) — re-rendering the
// table while a message is mid-playback would yank the <audio> element out
// from under the user. Refreshed on tab click instead (see the .tab-btn
// listener above) and once here at page load.
</script>
</body></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})