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:
+158
-3
@@ -579,6 +579,21 @@ _asterisk_patch_messaging_vendor_files() {
|
|||||||
# extensions.conf: same [intercom] anchor _pstn_patch_vendor_files uses,
|
# extensions.conf: same [intercom] anchor _pstn_patch_vendor_files uses,
|
||||||
# a SEPARATE #include so this coexists whether or not pstn-trunk is
|
# a SEPARATE #include so this coexists whether or not pstn-trunk is
|
||||||
# installed — messaging is independent of the PSTN trunk entirely.
|
# 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
|
for f in "$ENTRYPOINT" "$EASY1" "$EASY2"; do
|
||||||
[[ -f "$f" ]] || continue
|
[[ -f "$f" ]] || continue
|
||||||
if ! grep -q 'messaging-dialplan.conf' "$f"; then
|
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."
|
log_success "Patched the messaging #include directly into the live extensions.conf."
|
||||||
else
|
else
|
||||||
log_warning "Couldn't find '[intercom]' in the live extensions.conf — add"
|
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
|
||||||
fi
|
fi
|
||||||
docker exec "$CONTAINER_NAME" asterisk -rx "dialplan reload" &>/dev/null || true
|
docker exec "$CONTAINER_NAME" asterisk -rx "dialplan reload" &>/dev/null || true
|
||||||
@@ -701,6 +716,138 @@ exten => _X.,1,NoOp(SIP MESSAGE to ${EXTEN})
|
|||||||
EOF
|
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() {
|
_asterisk_remove_presence_timer() {
|
||||||
systemctl disable --now asterisk-presence-alert.timer 2>/dev/null || true
|
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/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_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf"
|
||||||
_asterisk_ensure_live_messaging_include "$EA_DIR" "$CONTAINER"
|
_asterisk_ensure_live_messaging_include "$EA_DIR" "$CONTAINER"
|
||||||
_asterisk_migrate_existing_devices_message_context "$EA_DIR/config/asterisk/pjsip.conf"
|
_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"
|
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..."
|
log_info "Rebuilding and restarting containers..."
|
||||||
if docker compose up -d --build --force-recreate; then
|
if docker compose up -d --build --force-recreate; then
|
||||||
@@ -1552,8 +1703,12 @@ install_asterisk() {
|
|||||||
_asterisk_patch_messaging_vendor_files "$EA_DIR"
|
_asterisk_patch_messaging_vendor_files "$EA_DIR"
|
||||||
_asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf"
|
_asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf"
|
||||||
_asterisk_ensure_live_messaging_include "$EA_DIR" "$CONTAINER"
|
_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"
|
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 ──────────────────────────────────────────────
|
# ── Domain / networking mode ──────────────────────────────────────────────
|
||||||
# A public cloud box is always reachable from anywhere, so there's no
|
# A public cloud box is always reachable from anywhere, so there's no
|
||||||
|
|||||||
+406
-36
@@ -93,6 +93,14 @@ install_security-dashboard() {
|
|||||||
# set up before the two Asterisk services merged, matching whichever
|
# set up before the two Asterisk services merged, matching whichever
|
||||||
# container_name services/asterisk.sh actually used there.
|
# container_name services/asterisk.sh actually used there.
|
||||||
local ASTERISK_EA_CONFIG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/config/easy-asterisk}"
|
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=""
|
local ASTERISK_EA_CONTAINER=""
|
||||||
if [[ "$ASTERISK_EA_DIR" == *asterisk-digital-ocean ]]; then
|
if [[ "$ASTERISK_EA_DIR" == *asterisk-digital-ocean ]]; then
|
||||||
ASTERISK_EA_CONTAINER="easy-asterisk-do"
|
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 /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 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/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"
|
echo "[DRY-RUN] Would configure Caddy + Authelia for a domain you'll be prompted for"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -139,12 +148,12 @@ install_security-dashboard() {
|
|||||||
case "$MODE" in
|
case "$MODE" in
|
||||||
update)
|
update)
|
||||||
log_info "Refreshing app code + sudoers rule + systemd unit (no Caddy/domain changes)..."
|
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_app "$APP_DIR"
|
||||||
_secdash_write_asn_helper "$APP_DIR"
|
_secdash_write_asn_helper "$APP_DIR"
|
||||||
_secdash_copy_kiosk_installer "$APP_DIR"
|
_secdash_copy_kiosk_installer "$APP_DIR"
|
||||||
_secdash_write_sudoers "$SVC_USER" "$ASTERISK_EA_CONTAINER"
|
_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
|
# systemd caches unit files; a plain restart re-runs the OLD
|
||||||
# one. Without this, any Environment= or ReadOnlyPaths line
|
# one. Without this, any Environment= or ReadOnlyPaths line
|
||||||
# added since the last FRESH install is written to disk and
|
# 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"
|
log_success "Created system user $SVC_USER"
|
||||||
fi
|
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"
|
mkdir -p "$APP_DIR"
|
||||||
_secdash_write_app "$APP_DIR"
|
_secdash_write_app "$APP_DIR"
|
||||||
@@ -188,7 +197,7 @@ install_security-dashboard() {
|
|||||||
_secdash_copy_kiosk_installer "$APP_DIR"
|
_secdash_copy_kiosk_installer "$APP_DIR"
|
||||||
|
|
||||||
_secdash_write_sudoers "$SVC_USER" "$ASTERISK_EA_CONTAINER"
|
_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 daemon-reload
|
||||||
systemctl enable security-dashboard >/dev/null 2>&1
|
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
|
## Tabs
|
||||||
|
|
||||||
Four tabs: **Security Log**, **Calls & Texts**, **Extensions**, **CrowdSec**.
|
Five tabs: **Security Log**, **Calls & Texts**, **Extensions**, **Voicemail**,
|
||||||
The first three are always there (they only need Asterisk itself, detected
|
**CrowdSec**. The first four are always there (they only need Asterisk
|
||||||
once at install time); CrowdSec checks its own live install state on every
|
itself, detected once at install time); CrowdSec checks its own live install
|
||||||
page load and hides its nav button if \`cscli\` isn't found.
|
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*
|
Extensions used to be three separate tabs — *Asterisk Admin*, *Extensions*
|
||||||
and *PSTN Trunk* — which between them listed the same extensions three times:
|
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
|
always works) and, when the Easy Asterisk container is reachable, its own
|
||||||
device list. Columns: Ext, Name, then Category/Status/Transport if that
|
device list. Columns: Ext, Name, then Category/Status/Transport if that
|
||||||
container is present, then **PSTN** + **Whitelist** if a trunk dialplan is
|
container is present, then **PSTN** + **Whitelist** if a trunk dialplan is
|
||||||
installed, then Messaging (always: internal SIP texting has no PSTN
|
installed, then Messaging and Voicemail (always: neither has a PSTN
|
||||||
dependency at all — no cost, no carrier, no DID).
|
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
|
Each extension has one whitelist and a mode saying which direction(s) it
|
||||||
applies to: **No PSTN**, **Unrestricted**, **Restrict outbound** (may only
|
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
|
# approach with a warning if the `acl` package isn't installed for some
|
||||||
# reason (should always be present — installed below).
|
# reason (should always be present — installed below).
|
||||||
_secdash_grant_asterisk_access() {
|
_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
|
command -v setfacl >/dev/null 2>&1 || run_cmd apt-get install -y acl >/dev/null 2>&1
|
||||||
local _have_acl=false
|
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."
|
[ "$_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
|
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
|
[ -n "$_dir" ] && [ -d "$_dir" ] || continue
|
||||||
_secdash_grant_ancestor_traversal "$_svc_user" "$_dir"
|
_secdash_grant_ancestor_traversal "$_svc_user" "$_dir"
|
||||||
if [ "$_have_acl" = true ]; then
|
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
|
# both layers (this AND the group access above) need to agree, or writes
|
||||||
# fail even when Unix permissions alone would have allowed them.
|
# fail even when Unix permissions alone would have allowed them.
|
||||||
_secdash_write_systemd_unit() {
|
_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"
|
local _read_only_paths="" _read_write_paths="/etc/crowdsec/scenarios"
|
||||||
[ -n "$_log_dir" ] && _read_only_paths="$_log_dir"
|
[ -n "$_log_dir" ] && _read_only_paths="$_log_dir"
|
||||||
[ -n "$_ea_config_dir" ] && _read_only_paths="$_read_only_paths $_ea_config_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
|
# 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.
|
# 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"
|
[ -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_CONFIG_DIR=$_ea_config_dir
|
||||||
Environment=ASTERISK_EA_CONTAINER=$_ea_container
|
Environment=ASTERISK_EA_CONTAINER=$_ea_container
|
||||||
Environment=ASTERISK_EA_ENV=${_config_dir%/config/asterisk}/.env
|
Environment=ASTERISK_EA_ENV=${_config_dir%/config/asterisk}/.env
|
||||||
|
Environment=ASTERISK_SPOOL_DIR=$_spool_dir
|
||||||
ExecStart=/usr/bin/python3 $_app_dir/app.py
|
ExecStart=/usr/bin/python3 $_app_dir/app.py
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
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 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 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 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 exec $_ea_container /usr/local/bin/easy-asterisk --rebuild-dialplan
|
||||||
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker restart $_ea_container"
|
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker restart $_ea_container"
|
||||||
fi
|
fi
|
||||||
@@ -883,6 +900,7 @@ Caddy, and CrowdSec, and shouldn't add a framework's worth of RAM overhead.
|
|||||||
import configparser
|
import configparser
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
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 ""
|
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 ""
|
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", "")
|
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 = [
|
ASN_SCENARIO_FILES = [
|
||||||
"/etc/crowdsec/scenarios/local-asterisk_bf.yaml",
|
"/etc/crowdsec/scenarios/local-asterisk_bf.yaml",
|
||||||
"/etc/crowdsec/scenarios/local-asterisk_user_enum.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"
|
"; 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"
|
"; 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"
|
"; 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"
|
"; independent per-extension axes: messaging (internal SIP MESSAGE\n"
|
||||||
"; texting) and personal_did (outbound Caller-ID override; inbound\n"
|
"; texting), personal_did (outbound Caller-ID override; inbound\n"
|
||||||
"; routing for personal DIDs lives in pstn-personal-dids.conf).\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"
|
"; Read LIVE by the dialplan on every call (AST_CONFIG()) - no\n"
|
||||||
"; Asterisk restart needed. Managed here (Security Dashboard); also\n"
|
"; Asterisk restart needed. Managed here (Security Dashboard); also\n"
|
||||||
"; safe to edit by hand. 'sudo ./setup.sh pstn-trunk' update mode\n"
|
"; safe to edit by hand. 'sudo ./setup.sh pstn-trunk' update mode\n"
|
||||||
@@ -1439,6 +1465,8 @@ def get_all_permissions():
|
|||||||
"restrict": restrict,
|
"restrict": restrict,
|
||||||
"allowed_numbers": numbers,
|
"allowed_numbers": numbers,
|
||||||
"messaging": cp.getboolean(section, "messaging", fallback=False),
|
"messaging": cp.getboolean(section, "messaging", fallback=False),
|
||||||
|
"voicemail": cp.getboolean(section, "voicemail", fallback=False),
|
||||||
|
"voicemail_pin": cp.get(section, "voicemail_pin", fallback=""),
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -1465,9 +1493,36 @@ def _set_or_clear(cp, ext, key, value):
|
|||||||
cp.remove_option(ext, key)
|
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
|
"""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
|
One list, not two: the whitelist is "the numbers this extension deals
|
||||||
with", and the mode says whether that constrains dialling out, being
|
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_in/allowed_in what the dialplan reads
|
||||||
tier, allowed_numbers rollback mirror for a pre-split installer
|
tier, allowed_numbers rollback mirror for a pre-split installer
|
||||||
|
|
||||||
Messaging is an independent axis (see pstn-trunk.sh's file-level comment:
|
Messaging and voicemail are both independent axes (see pstn-trunk.sh's
|
||||||
an extension can have no PSTN at all and still be messaging-enabled, or
|
file-level comment: an extension can have no PSTN at all and still be
|
||||||
vice versa), so it's set/cleared regardless of the mode.
|
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
|
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
|
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"):
|
elif cp.has_section(ext) and cp.has_option(ext, "messaging"):
|
||||||
cp.remove_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
|
# 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
|
# when the mode is internal, messaging and voicemail are both off, and
|
||||||
# ever assigned.
|
# no personal_did/voicemail_pin was ever assigned.
|
||||||
if cp.has_section(ext) and not cp.options(ext):
|
if cp.has_section(ext) and not cp.options(ext):
|
||||||
cp.remove_section(ext)
|
cp.remove_section(ext)
|
||||||
|
|
||||||
@@ -1541,6 +1599,8 @@ def write_permission(ext, restrict, numbers_raw, messaging_enabled=False):
|
|||||||
if not ok:
|
if not ok:
|
||||||
return False, err
|
return False, err
|
||||||
|
|
||||||
|
_sync_voicemail_conf_if_present()
|
||||||
|
|
||||||
if restrict not in ("internal", "full") and not clean:
|
if restrict not in ("internal", "full") and not clean:
|
||||||
return True, ("Saved, but the whitelist is EMPTY — with this mode that means no PSTN "
|
return True, ("Saved, but the whitelist is EMPTY — with this mode that means no PSTN "
|
||||||
"number is permitted in the restricted direction yet.")
|
"number is permitted in the restricted direction yet.")
|
||||||
@@ -1578,6 +1638,188 @@ def write_messaging(ext, enabled):
|
|||||||
return True, "Saved"
|
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}$")
|
GROUP_NAME_RE = re.compile(r"^[A-Za-z0-9_ -]{1,40}$")
|
||||||
|
|
||||||
GROUPS_HEADER = (
|
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="security">Security Log</button>
|
||||||
<button class="tab-btn" data-tab="comms">Calls & Texts</button>
|
<button class="tab-btn" data-tab="comms">Calls & Texts</button>
|
||||||
<button class="tab-btn active" data-tab="extensions">Extensions</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>
|
<button class="tab-btn" id="crowdsec-tab-btn" data-tab="crowdsec" style="display:none">CrowdSec</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -3417,6 +3660,7 @@ INDEX_HTML = """<!doctype html>
|
|||||||
</ul>
|
</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 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>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<ext></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>
|
</details>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -3429,6 +3673,7 @@ INDEX_HTML = """<!doctype html>
|
|||||||
<th class="sortable pstn-only" data-sort="restrict">PSTN</th>
|
<th class="sortable pstn-only" data-sort="restrict">PSTN</th>
|
||||||
<th class="pstn-only">Whitelist</th>
|
<th class="pstn-only">Whitelist</th>
|
||||||
<th class="sortable" data-sort="messaging">Messaging</th>
|
<th class="sortable" data-sort="messaging">Messaging</th>
|
||||||
|
<th class="sortable" data-sort="voicemail">Voicemail</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr></thead><tbody></tbody></table>
|
</tr></thead><tbody></tbody></table>
|
||||||
</div>
|
</div>
|
||||||
@@ -3506,6 +3751,23 @@ INDEX_HTML = """<!doctype html>
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
<div id="toasts"></div>
|
<div id="toasts"></div>
|
||||||
<script>
|
<script>
|
||||||
@@ -4130,13 +4392,14 @@ var QRCode;
|
|||||||
|
|
||||||
function esc(s) { return (s || "").replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
function esc(s) { return (s || "").replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
||||||
|
|
||||||
const TABS = ["security", "comms", "extensions", "crowdsec"];
|
const TABS = ["security", "comms", "extensions", "voicemail", "crowdsec"];
|
||||||
document.querySelectorAll(".tab-btn").forEach(btn => {
|
document.querySelectorAll(".tab-btn").forEach(btn => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
||||||
btn.classList.add("active");
|
btn.classList.add("active");
|
||||||
TABS.forEach(t => { document.getElementById("tab-" + t).style.display = btn.dataset.tab === t ? "" : "none"; });
|
TABS.forEach(t => { document.getElementById("tab-" + t).style.display = btn.dataset.tab === t ? "" : "none"; });
|
||||||
if (btn.dataset.tab === "extensions") refreshExtensionsTab();
|
if (btn.dataset.tab === "extensions") refreshExtensionsTab();
|
||||||
|
if (btn.dataset.tab === "voicemail") loadVoicemail();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4192,6 +4455,65 @@ async function loadSecurity() {
|
|||||||
renderSecurity();
|
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 ───────────────────────────────────────────────────────
|
// ── Calls & Texts tab ───────────────────────────────────────────────────────
|
||||||
let lastCalls = [];
|
let lastCalls = [];
|
||||||
let callsSort = { key: null, dir: 1 };
|
let callsSort = { key: null, dir: 1 };
|
||||||
@@ -4484,7 +4806,8 @@ async function loadExtensions() {
|
|||||||
(permData.extensions || []).forEach(e => byExt.set(e.ext, {
|
(permData.extensions || []).forEach(e => byExt.set(e.ext, {
|
||||||
ext: e.ext, name: e.name,
|
ext: e.ext, name: e.name,
|
||||||
restrict: e.restrict, allowed_numbers: e.allowed_numbers,
|
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) {
|
if (eaInstalled) {
|
||||||
@@ -4551,6 +4874,7 @@ const RESTRICT_LABELS = [
|
|||||||
function extSortValue(e, key) {
|
function extSortValue(e, key) {
|
||||||
if (key === "ext") return parseInt(e.ext, 10);
|
if (key === "ext") return parseInt(e.ext, 10);
|
||||||
if (key === "messaging") return e.messaging ? 1 : 0;
|
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;
|
if (key === "restrict") return RESTRICT_ORDER[e[key]] ?? -1;
|
||||||
return (e[key] || "").toString().toLowerCase();
|
return (e[key] || "").toString().toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -4618,6 +4942,10 @@ function renderExtensions() {
|
|||||||
<td class="pstn-only">${restrictSelect(e.restrict)}</td>
|
<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 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-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">
|
<td class="actions">
|
||||||
<button class="icon ea-only" title="Connection details for ${esc(e.ext)}" onclick="toggleEaDeviceDetails('${esc(e.ext)}')">ⓘ</button>
|
<button class="icon ea-only" title="Connection details for ${esc(e.ext)}" onclick="toggleEaDeviceDetails('${esc(e.ext)}')">ⓘ</button>
|
||||||
<button class="icon ea-only" title="Delete extension ${esc(e.ext)}" onclick="deleteEaDevice('${esc(e.ext)}')">×</button>
|
<button class="icon ea-only" title="Delete extension ${esc(e.ext)}" onclick="deleteEaDevice('${esc(e.ext)}')">×</button>
|
||||||
@@ -4653,12 +4981,14 @@ function rowEdits(tr) {
|
|||||||
const modeEl = tr.querySelector(".ext-restrict");
|
const modeEl = tr.querySelector(".ext-restrict");
|
||||||
const numsEl = tr.querySelector(".ext-numbers");
|
const numsEl = tr.querySelector(".ext-numbers");
|
||||||
const msgEl = tr.querySelector(".ext-messaging");
|
const msgEl = tr.querySelector(".ext-messaging");
|
||||||
|
const vmEl = tr.querySelector(".ext-voicemail");
|
||||||
const edits = {};
|
const edits = {};
|
||||||
if (nameEl && !nameEl.disabled && nameEl.value.trim() !== (model.name || "").trim()) edits.name = nameEl.value.trim();
|
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 (mobileEl && mobileEl.checked !== (model.category === "mobile")) edits.category = mobileEl.checked ? "mobile" : "standard";
|
||||||
if (modeEl && modeEl.value !== model.restrict) edits.restrict = modeEl.value;
|
if (modeEl && modeEl.value !== model.restrict) edits.restrict = modeEl.value;
|
||||||
if (numsEl && numsEl.value !== model.allowed_numbers) edits.allowed_numbers = numsEl.value;
|
if (numsEl && numsEl.value !== model.allowed_numbers) edits.allowed_numbers = numsEl.value;
|
||||||
if (msgEl && msgEl.checked !== !!model.messaging) edits.messaging = msgEl.checked;
|
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};
|
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");
|
if (!r.ok) throw new Error(r.message || "category change failed");
|
||||||
}
|
}
|
||||||
const permKeys = ["restrict", "allowed_numbers", "messaging"];
|
const permKeys = ["restrict", "allowed_numbers", "messaging"];
|
||||||
if (permKeys.some(k => k in edits)) {
|
if (pstnInstalled) {
|
||||||
const messaging = "messaging" in edits ? edits.messaging : !!model.messaging;
|
if (permKeys.some(k => k in edits) || "voicemail" in edits) {
|
||||||
let r;
|
const messaging = "messaging" in edits ? edits.messaging : !!model.messaging;
|
||||||
if (pstnInstalled) {
|
const voicemail = "voicemail" in edits ? edits.voicemail : !!model.voicemail;
|
||||||
// Send the whole permission record, not just the changed fields —
|
// Send the whole permission record, not just the changed fields —
|
||||||
// the endpoint rewrites all of it, so omitting an unchanged value
|
// the endpoint rewrites all of it, so omitting an unchanged value
|
||||||
// would silently reset it.
|
// would silently reset it.
|
||||||
r = await postJSON("/api/pstn-permissions", {
|
const r = await postJSON("/api/pstn-permissions", {
|
||||||
ext: ext,
|
ext: ext,
|
||||||
restrict: "restrict" in edits ? edits.restrict : model.restrict,
|
restrict: "restrict" in edits ? edits.restrict : model.restrict,
|
||||||
allowed_numbers: "allowed_numbers" in edits ? edits.allowed_numbers : model.allowed_numbers,
|
allowed_numbers: "allowed_numbers" in edits ? edits.allowed_numbers : model.allowed_numbers,
|
||||||
messaging: messaging,
|
messaging: messaging,
|
||||||
|
voicemail: voicemail,
|
||||||
});
|
});
|
||||||
} else {
|
if (!r.ok) throw new Error(r.message || "permission save failed");
|
||||||
r = await postJSON("/api/pstn-messaging", {ext: ext, enabled: messaging});
|
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++;
|
saved++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -5331,10 +5673,15 @@ loadDecisions();
|
|||||||
loadAsnExempt();
|
loadAsnExempt();
|
||||||
loadCrowdsecStatus();
|
loadCrowdsecStatus();
|
||||||
initExtensionsTab();
|
initExtensionsTab();
|
||||||
|
loadVoicemail();
|
||||||
setInterval(loadSecurity, 30000);
|
setInterval(loadSecurity, 30000);
|
||||||
setInterval(loadCalls, 30000);
|
setInterval(loadCalls, 30000);
|
||||||
setInterval(loadTexts, 30000);
|
setInterval(loadTexts, 30000);
|
||||||
setInterval(loadDecisions, 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>
|
</script>
|
||||||
</body></html>
|
</body></html>
|
||||||
"""
|
"""
|
||||||
@@ -5384,11 +5731,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
perms = get_all_permissions()
|
perms = get_all_permissions()
|
||||||
extensions = []
|
extensions = []
|
||||||
for e in list_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"],
|
extensions.append({"ext": e["ext"], "name": e["name"],
|
||||||
"restrict": p["restrict"],
|
"restrict": p["restrict"],
|
||||||
"allowed_numbers": p["allowed_numbers"],
|
"allowed_numbers": p["allowed_numbers"],
|
||||||
"messaging": p["messaging"]})
|
"messaging": p["messaging"],
|
||||||
|
"voicemail": p["voicemail"],
|
||||||
|
"voicemail_pin": p["voicemail_pin"]})
|
||||||
self._json({"extensions": extensions})
|
self._json({"extensions": extensions})
|
||||||
elif self.path == "/api/pstn-limits":
|
elif self.path == "/api/pstn-limits":
|
||||||
self._json(get_limits())
|
self._json(get_limits())
|
||||||
@@ -5453,6 +5803,22 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._json({"devices": ea_list_devices(), "status": ea_get_status()})
|
self._json({"devices": ea_list_devices(), "status": ea_get_status()})
|
||||||
elif self.path == "/api/ea-rooms":
|
elif self.path == "/api/ea-rooms":
|
||||||
self._json({"rooms": ea_list_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:
|
else:
|
||||||
self._json({"error": "not found"}, 404)
|
self._json({"error": "not found"}, 404)
|
||||||
|
|
||||||
@@ -5477,6 +5843,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
payload.get("ext", ""),
|
payload.get("ext", ""),
|
||||||
payload.get("restrict", ""), payload.get("allowed_numbers", ""),
|
payload.get("restrict", ""), payload.get("allowed_numbers", ""),
|
||||||
bool(payload.get("messaging", False)),
|
bool(payload.get("messaging", False)),
|
||||||
|
bool(payload.get("voicemail", False)),
|
||||||
)
|
)
|
||||||
self._json({"ok": ok, "message": message})
|
self._json({"ok": ok, "message": message})
|
||||||
elif self.path == "/api/pstn-limits":
|
elif self.path == "/api/pstn-limits":
|
||||||
@@ -5491,6 +5858,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
elif self.path == "/api/pstn-messaging":
|
elif self.path == "/api/pstn-messaging":
|
||||||
ok, message = write_messaging(payload.get("ext", ""), bool(payload.get("enabled", False)))
|
ok, message = write_messaging(payload.get("ext", ""), bool(payload.get("enabled", False)))
|
||||||
self._json({"ok": ok, "message": message})
|
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":
|
elif self.path == "/api/asterisk-restart":
|
||||||
ok, message = restart_asterisk_container()
|
ok, message = restart_asterisk_container()
|
||||||
self._json({"ok": ok, "message": message})
|
self._json({"ok": ok, "message": message})
|
||||||
|
|||||||
Reference in New Issue
Block a user