Three tabs listed the same extensions three different ways: Asterisk Admin as devices with category/status/transport, Extensions as a row of messaging checkboxes, PSTN Trunk as permission tiers with a duplicate Messaging column that wrote the same flag. Changing one extension meant knowing which of the three owned the setting you wanted. There is now one Extensions tab with one extensions table, merged from pjsip.conf (via /api/pstn-permissions, which always works) and /api/ea-devices where the Easy Asterisk container is reachable, keyed by extension so a row known to only one source still shows. Capabilities add columns rather than nav buttons: Category/Status/Transport are .ea-only, Tier/Approved-numbers are .pstn-only, and both classes start on <body> so nothing flashes before /api/ea-status and /api/pstn-status answer. Categories, Rooms, Groups, Concurrent-call caps and Personal numbers are cards under the same tab, gated the same way. Per-row Save picks its write path: tier + approved numbers + messaging via /api/pstn-permissions with a trunk installed, messaging alone via /api/pstn-messaging without one — which is what that endpoint has always been for. No backend changes; the standalone messaging-chips card and the duplicate Messaging column are both gone. Verified in Chromium against a fixture Asterisk config: full layout renders nine columns and six cards, the bare layout collapses to Ext/Name/Messaging with two cards, and both save paths write pstn-permissions.conf correctly (messaging-only leaves tier and allowed_numbers untouched). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh
3586 lines
158 KiB
Bash
3586 lines
158 KiB
Bash
#!/bin/bash
|
|
# services/security-dashboard.sh — Security dashboard: Asterisk failed-connection
|
|
# log + CrowdSec decisions (view/unban/ASN-exempt management), Authelia-protected.
|
|
# Part of the modular post-install system (sourced by setup.sh).
|
|
#
|
|
# Can also be run standalone on any machine:
|
|
# sudo bash security-dashboard.sh
|
|
# (Docker must already be installed when run standalone — Caddy fronts this,
|
|
# even though the dashboard itself runs natively on the host, not in Docker)
|
|
#
|
|
# Why native, not Docker: it needs to run `cscli` (a host binary — CrowdSec is
|
|
# a system service, not a container, see services/crowdsec.sh) and read
|
|
# Asterisk's security log directly off disk. Running natively avoids bridging
|
|
# the container/host boundary entirely — no LAPI credentials to expose to a
|
|
# containerized frontend, no Docker socket mount. Same reasoning as why
|
|
# CrowdSec itself is a system service in this repo, not a docker-compose one.
|
|
|
|
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
|
|
|
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
_COMMON="$_SELF_DIR/../lib/common.sh"
|
|
|
|
if [[ -f "$_COMMON" ]]; then
|
|
# shellcheck source=../lib/common.sh
|
|
source "$_COMMON"
|
|
else
|
|
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
|
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
|
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
|
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
|
|
|
prompt_text() {
|
|
local _q="$1" _def="$2" _var="$3" _r
|
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
|
read -r -p " $_q " _r
|
|
eval "$_var='${_r:-$_def}'"
|
|
}
|
|
|
|
prompt_yn() {
|
|
local _q="$1" _def="$2" _var="$3" _r
|
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
|
read -r -p " $_q " _r
|
|
eval "$_var='${_r:-$_def}'"
|
|
}
|
|
|
|
write_readme() {
|
|
local _dir="$1"; shift
|
|
mkdir -p "$_dir"
|
|
cat > "$_dir/README.md"
|
|
}
|
|
fi
|
|
|
|
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
|
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
|
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
|
DRY_RUN="${DRY_RUN:-false}"
|
|
UNATTENDED="${UNATTENDED:-false}"
|
|
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
|
|
|
register_service() { :; }
|
|
_RUN_STANDALONE=1
|
|
fi
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
register_service security-dashboard homelab "Security dashboard: Asterisk failed-connections + extension/trunk management + CrowdSec bans (Authelia-protected)" 8092
|
|
|
|
install_security-dashboard() {
|
|
local APP_DIR="/opt/security-dashboard"
|
|
local DASHBOARD_PORT=8092
|
|
local SVC_USER="secdash"
|
|
|
|
# Either install layout works — prefer ~/docker/asterisk-digital-ocean
|
|
# (a droplet from before that service was merged into `asterisk`) if both
|
|
# happen to exist, matching services/pstn-trunk.sh's own preference order
|
|
# for consistency.
|
|
local ASTERISK_EA_DIR=""
|
|
if [ -d "$DOCKER_DIR/asterisk-digital-ocean" ]; then
|
|
ASTERISK_EA_DIR="$DOCKER_DIR/asterisk-digital-ocean"
|
|
elif [ -d "$DOCKER_DIR/asterisk" ]; then
|
|
ASTERISK_EA_DIR="$DOCKER_DIR/asterisk"
|
|
fi
|
|
local ASTERISK_LOG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/logs}"
|
|
local ASTERISK_CONFIG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/config/asterisk}"
|
|
# categories.conf/rooms.conf live in a SEPARATE directory from
|
|
# pjsip.conf — see vendor/easy-asterisk/easy-asterisk-v0.10.0.sh's own
|
|
# CATEGORIES_FILE/ROOMS_FILE constants (/etc/easy-asterisk/*, not
|
|
# /etc/asterisk/*). ASTERISK_EA_CONTAINER names the actual container to
|
|
# `docker exec` into for the Extensions tab's device writes/CLI calls
|
|
# (ea_* functions) — "easy-asterisk", or "easy-asterisk-do" for a droplet
|
|
# 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}"
|
|
local ASTERISK_EA_CONTAINER=""
|
|
if [[ "$ASTERISK_EA_DIR" == *asterisk-digital-ocean ]]; then
|
|
ASTERISK_EA_CONTAINER="easy-asterisk-do"
|
|
elif [ -n "$ASTERISK_EA_DIR" ]; then
|
|
ASTERISK_EA_CONTAINER="easy-asterisk"
|
|
fi
|
|
|
|
echo ""
|
|
echo "┌─────────────────────────────────────────────────────────────────┐"
|
|
echo "│ SECURITY DASHBOARD │"
|
|
echo "│ Asterisk failed-connection log + one Extensions tab (devices, │"
|
|
echo "│ categories, rooms, groups, PSTN tiers, DIDs) + CrowdSec bans, │"
|
|
echo "│ one page. Runs natively on the host (not Docker) so it can call │"
|
|
echo "│ cscli and read Asterisk's files directly. Authelia-protected. │"
|
|
echo "└─────────────────────────────────────────────────────────────────┘"
|
|
echo ""
|
|
|
|
if [ -z "$ASTERISK_EA_DIR" ]; then
|
|
log_warning "No Asterisk install detected."
|
|
log_warning "The Security Log and Extensions tabs will just be empty — CrowdSec's tab"
|
|
log_warning "still works fine."
|
|
fi
|
|
|
|
if [ "$DRY_RUN" = true ]; then
|
|
echo "[DRY-RUN] Would create system user $SVC_USER"
|
|
echo "[DRY-RUN] Would write $APP_DIR/app.py"
|
|
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 configure Caddy + Authelia for a domain you'll be prompted for"
|
|
return 0
|
|
fi
|
|
|
|
if [ -f "$APP_DIR/app.py" ]; then
|
|
local MODE=""
|
|
prompt_reinstall_mode MODE 2>/dev/null || {
|
|
# prompt_reinstall_mode isn't defined in the standalone stub — fall
|
|
# back to a plain yes/no when run outside the full repo.
|
|
local _r=""
|
|
prompt_yn " Security dashboard already exists at $APP_DIR — reconfigure? (y/n):" "n" _r
|
|
[ "$_r" = "y" ] || [ "$_r" = "Y" ] && MODE="fresh" || MODE="cancel"
|
|
}
|
|
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_write_app "$APP_DIR"
|
|
_secdash_write_asn_helper "$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"
|
|
systemctl restart security-dashboard 2>/dev/null \
|
|
&& log_success "security-dashboard restarted" \
|
|
|| log_warning "Restart failed — check: systemctl status security-dashboard"
|
|
|
|
echo ""
|
|
local _reconf=""
|
|
prompt_yn "Reconfigure this dashboard's Caddy protection (Authelia domain, or add/rotate an independent Basic Auth layer)? (y/n):" "n" _reconf
|
|
if [[ "$_reconf" =~ ^[Yy]$ ]]; then
|
|
_secdash_remove_caddy_block "$DASHBOARD_PORT"
|
|
_secdash_configure_caddy "$DASHBOARD_PORT"
|
|
fi
|
|
return 0
|
|
;;
|
|
cancel)
|
|
log_info "Leaving the existing install as-is."
|
|
return 0
|
|
;;
|
|
fresh) ;;
|
|
esac
|
|
fi
|
|
|
|
# ── System user (no login, no home directory needed) ────────────────────
|
|
if ! id "$SVC_USER" &>/dev/null; then
|
|
useradd --system --no-create-home --shell /usr/sbin/nologin "$SVC_USER"
|
|
log_success "Created system user $SVC_USER"
|
|
fi
|
|
|
|
_secdash_grant_asterisk_access "$SVC_USER" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_EA_CONFIG_DIR"
|
|
|
|
mkdir -p "$APP_DIR"
|
|
_secdash_write_app "$APP_DIR"
|
|
chown -R "$SVC_USER:$SVC_USER" "$APP_DIR"
|
|
_secdash_write_asn_helper "$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"
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable security-dashboard >/dev/null 2>&1
|
|
if systemctl restart security-dashboard; then
|
|
log_success "security-dashboard started on port $DASHBOARD_PORT (all interfaces — UFW scopes actual access)"
|
|
else
|
|
log_warning "Failed to start — check: systemctl status security-dashboard"
|
|
fi
|
|
|
|
# ── Caddy + Authelia (+ optional independent Basic Auth) ────────────────
|
|
# This is deliberately more insistent about auth than most services — it
|
|
# can delete active CrowdSec bans, so an unauthenticated exposure here is
|
|
# a real security hole, not just an inconvenience. Factored into
|
|
# _secdash_configure_caddy so "update" mode can also offer to reconfigure
|
|
# it later (e.g. to add Basic Auth to an already-deployed dashboard)
|
|
# without duplicating this logic — see that function for the rest.
|
|
_secdash_configure_caddy "$DASHBOARD_PORT"
|
|
|
|
write_readme "$APP_DIR" << README_MD
|
|
# Security Dashboard
|
|
|
|
Asterisk failed-connection log + CrowdSec ban management, one Authelia-
|
|
protected page. Runs natively on the host (systemd service \`security-dashboard\`),
|
|
not in Docker — it needs to call \`cscli\` and read Asterisk's log directly.
|
|
|
|
## Tabs
|
|
|
|
Three tabs: **Security Log**, **Extensions**, **CrowdSec**. The first two 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:
|
|
once as devices with a category/status, once as a row of messaging
|
|
checkboxes, and once as permission tiers. They're now one tab with one
|
|
extensions table, and each capability adds columns and cards to it instead of
|
|
a nav button of its own. That means the page still scales from a bare LAN
|
|
Asterisk box up to a full droplet with a trunk, without ever showing a
|
|
control for something that isn't set up — you just don't have to remember
|
|
which tab a given extension's settings live on.
|
|
|
|
- **Security Log** — parses \`$ASTERISK_LOG_DIR/full\` for SIP auth failures
|
|
(wrong password, unknown extension, etc.) with timestamp/account/remote IP,
|
|
sortable per column (click a header to sort, click again to reverse).
|
|
- **Extensions** — one row per extension, merged from \`pjsip.conf\` (which
|
|
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 Tier/Approved-numbers if a PSTN trunk dialplan
|
|
is installed, then Messaging (always — internal SIP texting has no PSTN
|
|
dependency at all: no cost, no carrier, no DID) and a per-row Save. Save
|
|
writes tier + approved numbers + messaging together when there's a trunk,
|
|
and messaging alone when there isn't.
|
|
- **Extensions** — add/rename/delete a SIP extension, reassign its category;
|
|
live registered/unregistered status per device. This is a native
|
|
reimplementation of Easy Asterisk's own vendored web admin
|
|
(\`vendor/easy-asterisk/easy-asterisk-v0.10.0.sh\`'s device/category/room
|
|
management), not a link or an iframe to that separate process — one page,
|
|
one login. Reads \`pjsip.conf\`/\`categories.conf\`/\`rooms.conf\` directly
|
|
(same formats the vendor's own \`easy-asterisk --rebuild-dialplan\` CLI
|
|
still generates the dialplan from); writes go through
|
|
\`docker exec ... tee\` (root, sudo-gated) instead of a direct host-side
|
|
file write, since Easy Asterisk's container writes these as its own
|
|
internal user and a host-side write would just be fighting that ownership
|
|
again on the next restart. Every write reloads PJSIP and/or rebuilds the
|
|
dialplan automatically, the same way the vendored admin's own actions do.
|
|
- **Categories** — device profiles (an auto-answer default + description).
|
|
- **Rooms** — ring groups/paging groups; add/remove members per room.
|
|
- **Groups** — name a set of extensions and bulk-enable/disable messaging
|
|
for all of them at once. A management convenience only, not a runtime
|
|
concept: applying an action just writes the same per-extension
|
|
\`pstn-permissions.conf\` key each member's own checkbox would, and
|
|
membership changes never retroactively affect anything already applied.
|
|
(A group owning a personal DID *is* evaluated live against current
|
|
membership, though — see below.)
|
|
- **Concurrent-call caps** and **Personal numbers** appear only once
|
|
\`services/pstn-trunk.sh\`'s dialplan is actually installed
|
|
(\`pstn-trunk-dialplan.conf\` present), so the page never shows a
|
|
real-looking-but-unenforced editor. Caps are the outbound/inbound
|
|
concurrent-call limits; personal numbers map a DID to an owner extension
|
|
or group, additive to the shared trunk DID. Writes go directly to
|
|
\`pstn-limits.conf\` / \`pstn-permissions.conf\` / \`pstn-personal-dids.conf\`,
|
|
which the dialplan reads fresh on every call. The spend-cap kill-switch
|
|
and international-calling allow-list are deliberately **not** managed
|
|
here — CLI-only, via \`sudo ./setup.sh pstn-trunk\` — since both are more
|
|
security-sensitive than what this tab already exposes.
|
|
- **CrowdSec** — its nav button only appears once \`cscli\` is detected on
|
|
this host. Current bans (\`cscli decisions list\`), a delete/unban button
|
|
per entry, carrier/ASN + country columns (sortable per column), and
|
|
management of the ASN-exempt Asterisk brute-force scenarios (see
|
|
\`services/crowdsec.sh\`'s "Exempt specific carrier ASNs" option) without
|
|
SSHing in:
|
|
- **Currently-exempt ASNs** are listed with carrier name (resolved from
|
|
current bans, falling back to alert history for ASNs with no active ban
|
|
right now) regardless of when they were added.
|
|
- **Unwhitelist** removes an ASN from the exemption list — future Asterisk
|
|
auth failures from it are evaluated normally again.
|
|
- **Unwhitelist + Ban** does that *and* immediately bans (24h) every IP
|
|
CrowdSec has ever recorded for that ASN, for accidental-whitelist cases
|
|
where you don't want to wait for it to misbehave again.
|
|
|
|
## Manage
|
|
\`\`\`
|
|
sudo systemctl status security-dashboard
|
|
sudo systemctl restart security-dashboard
|
|
sudo journalctl -u security-dashboard -f
|
|
\`\`\`
|
|
|
|
## Security notes
|
|
- Runs as a dedicated, unprivileged system user (\`secdash\`), not root.
|
|
- Sudo access is scoped to exact commands via
|
|
\`/etc/sudoers.d/security-dashboard\` — nothing else. CrowdSec:
|
|
\`cscli decisions delete --id <digits>\`,
|
|
\`cscli decisions list -o json\`, \`cscli alerts list -o json\` (read-only,
|
|
used to label ASN exemptions with a carrier name from past alerts and to
|
|
find known offending IPs for the "Ban" action), \`cscli decisions add --ip
|
|
<ip> --duration <dur> --type ban --reason <text>\` (used only by "Ban"),
|
|
\`systemctl restart crowdsec\`, and \`set-asn-exempt.sh\` (root:root, mode
|
|
700, installed alongside \`app.py\` — the one thing that edits CrowdSec's
|
|
Asterisk-scenario YAMLs, since \`secdash\` has no write access to those
|
|
root-owned files directly and shouldn't). Extension/device management (only
|
|
added if an Asterisk install is detected): \`docker exec -i <container> tee\` against
|
|
exactly \`pjsip.conf\`/\`categories.conf\`/\`rooms.conf\`, plus
|
|
\`asterisk -rx "module reload res_pjsip.so"\`,
|
|
\`asterisk -rx "pjsip show endpoints"\`, and
|
|
\`easy-asterisk --rebuild-dialplan\` — all scoped to the one Asterisk
|
|
container actually installed on this box, none of it a wildcard.
|
|
- Listens on all interfaces (Caddy reaches it via \`host.docker.internal\`, a
|
|
Docker bridge IP — a loopback-only bind refuses that). Access is scoped by
|
|
UFW instead, allowed only from Caddy's internal network, not the internet.
|
|
- **This page can delete active security bans.** It's protected by Authelia
|
|
(or a remote instance) by default, and the installer offers a second,
|
|
independent HTTP Basic Auth layer in front of that — a request must pass
|
|
Basic Auth *and* Authelia before it ever reaches the app, so an Authelia
|
|
bug or misconfiguration alone isn't enough to expose this page. Re-run the
|
|
installer ("update" mode → reconfigure Caddy protection) to add, rotate, or
|
|
remove that Basic Auth layer later.
|
|
README_MD
|
|
|
|
echo ""
|
|
echo " Local access: http://localhost:$DASHBOARD_PORT"
|
|
echo " README: $APP_DIR/README.md"
|
|
echo ""
|
|
}
|
|
|
|
# Grants secdash execute-only traversal (via a POSIX ACL, not chmod) on
|
|
# every ancestor directory between the filesystem root and _leaf, stopping
|
|
# early once an ancestor is already reachable. Needed because DOCKER_DIR can
|
|
# be /root/docker (any root-run droplet — a fully supported setup, not a
|
|
# mistake) and some cloud images ship /root at a bare 700: no matter what
|
|
# access the LEAF directory itself grants, secdash (a non-root system user)
|
|
# can never reach through a blocking ancestor to get there. setfacl here
|
|
# grants ONLY the ability to pass through a path already known in advance —
|
|
# it does not grant listing that directory's contents or reading anything
|
|
# else inside it.
|
|
_secdash_grant_ancestor_traversal() {
|
|
local _svc_user="$1" _leaf="$2"
|
|
command -v setfacl >/dev/null 2>&1 || return 0
|
|
local _dir
|
|
_dir="$(dirname "$_leaf")"
|
|
while [[ "$_dir" != "/" && -n "$_dir" ]]; do
|
|
sudo -u "$_svc_user" test -x "$_dir" 2>/dev/null && break
|
|
setfacl -m "u:${_svc_user}:x" "$_dir" 2>/dev/null || true
|
|
_dir="$(dirname "$_dir")"
|
|
done
|
|
}
|
|
|
|
# Grants secdash read/write access to wherever Asterisk's config lives
|
|
# without running the dashboard as root or the actual user. Separate
|
|
# function, called from both "update" and fresh-install, so a PSTN trunk
|
|
# installed *after* this dashboard (or an asterisk-digital-ocean/asterisk
|
|
# swap) reaches an existing install on its next update instead of silently
|
|
# only applying to new ones.
|
|
#
|
|
# Uses POSIX ACLs (setfacl), not chmod + group membership. Confirmed live:
|
|
# the Asterisk container's own entrypoint runs `chown -R asterisk:asterisk
|
|
# /etc/asterisk` on every container start/restart — and the numeric UID/GID
|
|
# that resolves to inside the container can coincidentally collide with
|
|
# unrelated system accounts on the host (observed: config/asterisk ending up
|
|
# owned by messagebus:uuidd, neither of which secdash has any relationship
|
|
# to), silently reverting whatever group grant was applied at install time.
|
|
# `chown` does not touch ACL entries (only `chmod` recalculates the ACL
|
|
# mask, and nothing in this flow calls chmod after install) — so an
|
|
# ACL-based grant survives that reset instead of quietly breaking again on
|
|
# the next container restart. `-d` (default ACL) makes new files/directories
|
|
# created later (a regenerated dialplan file, a fresh personal-DID entry)
|
|
# inherit the same grant automatically. Falls back to the old chmod/group
|
|
# 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:-}"
|
|
|
|
command -v setfacl >/dev/null 2>&1 || run_cmd apt-get install -y acl >/dev/null 2>&1
|
|
local _have_acl=false
|
|
command -v setfacl >/dev/null 2>&1 && _have_acl=true
|
|
[ "$_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
|
|
[ -n "$_dir" ] && [ -d "$_dir" ] || continue
|
|
_secdash_grant_ancestor_traversal "$_svc_user" "$_dir"
|
|
if [ "$_have_acl" = true ]; then
|
|
setfacl -R -m "u:${_svc_user}:rX" "$_dir" 2>/dev/null || true
|
|
setfacl -R -d -m "u:${_svc_user}:rX" "$_dir" 2>/dev/null || true
|
|
else
|
|
local _group
|
|
_group="$(stat -c '%G' "$_dir" 2>/dev/null || echo "$ACTUAL_USER")"
|
|
usermod -aG "$_group" "$_svc_user" 2>/dev/null || true
|
|
chmod 750 "$_dir" 2>/dev/null || true
|
|
fi
|
|
done
|
|
|
|
# pstn-permissions.conf/pstn-limits.conf/pstn-personal-dids.conf need
|
|
# WRITE access on the containing directory too (configparser writes a
|
|
# fresh temp file then renames it into place) — only on the config dir,
|
|
# not the log dir (no reason for secdash to ever create files there).
|
|
# _ea_config_dir (categories.conf/rooms.conf) deliberately stays
|
|
# read-only — the Extensions tab writes those through
|
|
# `docker exec ... tee` instead (see the ea_* functions), not a direct
|
|
# host-side write, so there's no reason to grant it write access at all.
|
|
if [ -n "$_config_dir" ] && [ -d "$_config_dir" ]; then
|
|
if [ "$_have_acl" = true ]; then
|
|
setfacl -m "u:${_svc_user}:rwx" "$_config_dir" 2>/dev/null || true
|
|
setfacl -d -m "u:${_svc_user}:rwx" "$_config_dir" 2>/dev/null || true
|
|
else
|
|
chmod 770 "$_config_dir" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Systemd unit — separate function so "update" mode can refresh it too
|
|
# (Environment= vars and ReadWritePaths depend on which Asterisk flavor is
|
|
# detected, which can change between installs — e.g. a PSTN trunk or a
|
|
# different Asterisk flavor installed after this dashboard's first setup).
|
|
# ProtectSystem=strict makes the whole filesystem read-only for this unit
|
|
# except the paths explicitly listed below, regardless of Unix permissions —
|
|
# 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 _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 "$_config_dir" ] && _read_write_paths="$_read_write_paths $_config_dir"
|
|
|
|
cat > /etc/systemd/system/security-dashboard.service << SDSVC
|
|
[Unit]
|
|
Description=Security dashboard (Asterisk security log + CrowdSec decisions + PSTN trunk permissions + Asterisk admin)
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=$_svc_user
|
|
Group=$_svc_user
|
|
Environment=DASHBOARD_PORT=$_port
|
|
Environment=ASTERISK_LOG=${_log_dir:+$_log_dir/full}
|
|
Environment=ASTERISK_CONFIG_DIR=$_config_dir
|
|
Environment=ASTERISK_EA_CONFIG_DIR=$_ea_config_dir
|
|
Environment=ASTERISK_EA_CONTAINER=$_ea_container
|
|
ExecStart=/usr/bin/python3 $_app_dir/app.py
|
|
Restart=on-failure
|
|
RestartSec=3
|
|
NoNewPrivileges=false
|
|
ProtectSystem=strict
|
|
ReadOnlyPaths=$_read_only_paths
|
|
ReadWritePaths=$_read_write_paths
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
SDSVC
|
|
}
|
|
|
|
# Scoped sudo — only the exact commands the app needs, nothing else. Numeric-
|
|
# only glob on the decision ID; Python subprocess calls always pass args as a
|
|
# list (no shell=True anywhere), so there's no shell-metachar injection
|
|
# surface even before sudoers' own pattern match kicks in — the server-side
|
|
# ID validation (must be all-digits) happens before this is ever reached,
|
|
# this is defense in depth, not the only check. Separate function, called
|
|
# from both "update" and fresh-install, so adding a new permission later
|
|
# (like alerts list, added after ASN-exempt entries with no currently-active
|
|
# ban had no carrier name to show) reaches existing installs on their next
|
|
# update instead of silently only applying to new ones.
|
|
_secdash_write_sudoers() {
|
|
local _svc_user="$1" _ea_container="${2:-}"
|
|
local _ea_lines=""
|
|
# Extensions tab device management (ea_* functions) — every write goes through
|
|
# `docker exec -i <container> tee <exact path>` instead of a direct
|
|
# host-side file write (see _secdash_grant_asterisk_access's comment on
|
|
# why), plus the two Asterisk CLI calls needed after a change and the
|
|
# live registration-status check. All seven are exact commands, no
|
|
# wildcards, scoped to the one container actually installed on this box.
|
|
# The last line (docker restart) backs the Extensions tab's "Commit
|
|
# Changes" button — see restart_asterisk_container()'s comment for why
|
|
# that exists (AST_CONFIG() live-reads not always picking up dashboard
|
|
# edits without a full container restart).
|
|
if [ -n "$_ea_container" ]; then
|
|
_ea_lines="$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec -i $_ea_container tee /etc/asterisk/pjsip.conf
|
|
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec -i $_ea_container tee /etc/easy-asterisk/categories.conf
|
|
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker exec -i $_ea_container tee /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 /usr/local/bin/easy-asterisk --rebuild-dialplan
|
|
$_svc_user ALL=(root) NOPASSWD: /usr/bin/docker restart $_ea_container"
|
|
fi
|
|
cat > /etc/sudoers.d/security-dashboard << SUDOERS
|
|
$_svc_user ALL=(root) NOPASSWD: /usr/bin/cscli decisions delete --id [0-9]*
|
|
$_svc_user ALL=(root) NOPASSWD: /usr/bin/cscli decisions list -o json
|
|
$_svc_user ALL=(root) NOPASSWD: /usr/bin/cscli alerts list -o json
|
|
$_svc_user ALL=(root) NOPASSWD: /usr/bin/cscli decisions add --ip * --duration * --type ban --reason *
|
|
$_svc_user ALL=(root) NOPASSWD: /usr/bin/systemctl restart crowdsec
|
|
$_svc_user ALL=(root) NOPASSWD: /opt/security-dashboard/set-asn-exempt.sh *
|
|
${_ea_lines}
|
|
SUDOERS
|
|
chmod 440 /etc/sudoers.d/security-dashboard
|
|
visudo -c -f /etc/sudoers.d/security-dashboard >/dev/null 2>&1 \
|
|
&& log_success "Sudoers rule installed and validated" \
|
|
|| { log_error "Sudoers rule failed validation — removing it (dashboard's CrowdSec tab won't work until fixed)"; rm -f /etc/sudoers.d/security-dashboard; }
|
|
}
|
|
|
|
# Caddy + Authelia (+ optional independent Basic Auth) for the dashboard.
|
|
# Separate function so "update" mode can call _secdash_remove_caddy_block +
|
|
# this to reconfigure an already-deployed dashboard (e.g. to add Basic Auth
|
|
# retroactively) using the exact same code path as a fresh install, instead
|
|
# of hand-patching a live Caddyfile block in place.
|
|
_secdash_configure_caddy() {
|
|
local DASHBOARD_PORT="$1"
|
|
|
|
echo ""
|
|
if ! command -v docker &>/dev/null || ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$"; then
|
|
log_info "Caddy not running — dashboard stays on http://localhost:$DASHBOARD_PORT until you set it up."
|
|
return 0
|
|
fi
|
|
|
|
local _default_domain=""
|
|
if [ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
|
|
_default_domain="security.${SITE_DOMAIN}"
|
|
fi
|
|
local SD_DOMAIN=""
|
|
prompt_text " Domain for the dashboard (e.g. security.yourdomain.com), you'll need to point DNS at this droplet yourself [${_default_domain:-required}]:" "$_default_domain" SD_DOMAIN
|
|
|
|
if [ -z "$SD_DOMAIN" ]; then
|
|
log_warning "No domain entered — dashboard stays on http://localhost:$DASHBOARD_PORT only (not reachable from outside this box)."
|
|
return 0
|
|
fi
|
|
|
|
local EXTRA_BLOCK=""
|
|
if [ -d "$DOCKER_DIR/authelia" ]; then
|
|
EXTRA_BLOCK=" import authelia"
|
|
log_info "Local Authelia detected — protecting with it."
|
|
else
|
|
log_warning "No local Authelia found. This dashboard can delete active security"
|
|
log_warning "bans — strongly recommend protecting it before exposing it publicly."
|
|
local _use_remote=""
|
|
prompt_yn " Protect with a remote Authelia instance (e.g. on a homelab)? (y/n):" "y" _use_remote
|
|
if [[ "$_use_remote" =~ ^[Yy]$ ]]; then
|
|
local _remote_authelia=""
|
|
prompt_text " Remote Authelia address (bare host:port on a private network, or a full https:// URL on its own public domain+TLS):" "" _remote_authelia
|
|
if [ -n "$_remote_authelia" ]; then
|
|
# See services/asterisk-digital-ocean.sh for why
|
|
# X-Forwarded-Host must be a literal domain here, not
|
|
# the {host} placeholder — confirmed live that the
|
|
# placeholder still evaluates to the upstream
|
|
# Authelia's own hostname for a scheme-qualified
|
|
# remote upstream, not the original site's.
|
|
EXTRA_BLOCK=" forward_auth ${_remote_authelia} {
|
|
uri /api/authz/forward-auth
|
|
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
|
header_up X-Forwarded-Method {method}
|
|
header_up X-Forwarded-Proto {scheme}
|
|
header_up X-Forwarded-Host ${SD_DOMAIN}
|
|
header_up X-Forwarded-Uri {uri}
|
|
}"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# ── Independent Basic Auth layer (defense-in-depth on top of Authelia) ──
|
|
# Authelia already gates this page, but it's still one piece of software
|
|
# this dashboard trusts completely — this repo already hit one real
|
|
# Authelia forward_auth header bypass (see services/authelia.sh's
|
|
# header_up X-Forwarded-Host fix). This dashboard can delete active
|
|
# security bans, so it's worth a second, genuinely independent gate that
|
|
# doesn't depend on Authelia (or its session store, or its config) at
|
|
# all. basicauth is written before EXTRA_BLOCK below, so a request must
|
|
# clear it before ever reaching Authelia's forward_auth call.
|
|
local BASICAUTH_BLOCK=""
|
|
local _use_basicauth=""
|
|
prompt_yn " Add an independent Basic Auth login in front of Authelia, as a second, separate layer? (y/n):" "y" _use_basicauth
|
|
if [[ "$_use_basicauth" =~ ^[Yy]$ ]]; then
|
|
local BA_USER="" BA_PASS="" BA_HASH=""
|
|
prompt_text " Basic Auth username [admin]:" "admin" BA_USER
|
|
BA_PASS="$(generate_password 20)"
|
|
log_info "Generating Basic Auth password hash (via the running Caddy container)..."
|
|
BA_HASH="$(docker exec caddy caddy hash-password --plaintext "$BA_PASS" 2>/dev/null)"
|
|
if [ -z "$BA_HASH" ]; then
|
|
log_warning "Could not generate the Basic Auth hash — skipping this layer. Authelia alone will protect the dashboard."
|
|
else
|
|
BASICAUTH_BLOCK=" basicauth {
|
|
${BA_USER} ${BA_HASH}
|
|
}
|
|
"
|
|
log_success "Basic Auth username: ${BA_USER}"
|
|
log_success "Basic Auth password: ${BA_PASS}"
|
|
log_warning "Save that password now — only the bcrypt hash is written to the Caddyfile, it is not stored anywhere in plaintext."
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$EXTRA_BLOCK" ] && [ -z "$BASICAUTH_BLOCK" ]; then
|
|
log_error "Proceeding WITHOUT any auth protection — anyone who finds this domain"
|
|
log_error "can view and delete active security bans. Strongly reconsider."
|
|
local _confirm_unsafe=""
|
|
prompt_yn " Really continue without auth protection? (y/n):" "n" _confirm_unsafe
|
|
if [[ ! "$_confirm_unsafe" =~ ^[Yy]$ ]]; then
|
|
log_info "Skipping Caddy setup. Re-run this installer once Authelia is available."
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
local CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile"
|
|
if [ -f "$CADDY_FILE" ] && ! grep -q "^${SD_DOMAIN} {" "$CADDY_FILE"; then
|
|
cat >> "$CADDY_FILE" << CADDYBLOCK
|
|
|
|
# Security Dashboard
|
|
${SD_DOMAIN} {
|
|
${BASICAUTH_BLOCK}${EXTRA_BLOCK}
|
|
reverse_proxy host.docker.internal:${DASHBOARD_PORT}
|
|
|
|
header {
|
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
|
X-Content-Type-Options "nosniff"
|
|
X-Frame-Options "DENY"
|
|
Referrer-Policy "strict-origin-when-cross-origin"
|
|
}
|
|
|
|
log {
|
|
output file /var/log/caddy/${SD_DOMAIN}.log
|
|
format json
|
|
}
|
|
}
|
|
CADDYBLOCK
|
|
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
|
|
docker compose -f "$DOCKER_DIR/caddy/docker-compose.yml" restart caddy 2>/dev/null \
|
|
&& log_success "Caddy restarted — dashboard at https://${SD_DOMAIN}" \
|
|
|| log_warning "Restart Caddy manually: cd $DOCKER_DIR/caddy && docker compose restart"
|
|
elif [ -f "$CADDY_FILE" ]; then
|
|
log_warning "$SD_DOMAIN already in Caddyfile — leaving the existing entry alone."
|
|
fi
|
|
|
|
# This port never needs to be open to the internet — only Caddy (local,
|
|
# via host.docker.internal) ever needs to reach it.
|
|
if command -v ufw &>/dev/null; then
|
|
ufw delete allow "${DASHBOARD_PORT}/tcp" 2>/dev/null || true
|
|
if declare -f ufw_allow_from_caddy_net >/dev/null 2>&1; then
|
|
ufw_allow_from_caddy_net "${DASHBOARD_PORT}"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Removes the dashboard's existing Caddyfile site block (found via its
|
|
# unique reverse_proxy line, walking backward to the nearest "<domain> {"
|
|
# open and forward to the matching unindented "}" close) so
|
|
# _secdash_configure_caddy can regenerate it fresh on "update" mode's
|
|
# reconfigure path, rather than trying to surgically patch a live Caddyfile
|
|
# in place — a whole-block delete-and-regenerate is much harder to get
|
|
# subtly wrong than in-place editing of a file this security-critical.
|
|
_secdash_remove_caddy_block() {
|
|
local port="$1"
|
|
local caddy_file="$DOCKER_DIR/caddy/Caddyfile"
|
|
[ -f "$caddy_file" ] || return 0
|
|
|
|
local marker=" reverse_proxy host.docker.internal:${port}"
|
|
local marker_line domain_line end_line
|
|
marker_line="$(grep -nF "$marker" "$caddy_file" | head -1 | cut -d: -f1)"
|
|
if [ -z "$marker_line" ]; then
|
|
return 0 # nothing deployed yet — fine, the fresh flow will just append
|
|
fi
|
|
|
|
domain_line="$(head -n "$marker_line" "$caddy_file" | grep -nE '^[^[:space:]#].* \{$' | tail -1 | cut -d: -f1)"
|
|
if [ -z "$domain_line" ]; then
|
|
log_warning "Could not find the start of the existing dashboard Caddy block — leaving it as-is."
|
|
return 1
|
|
fi
|
|
# Pull in the "# Security Dashboard" comment line right above it too, if present
|
|
if [ "$domain_line" -gt 1 ] && sed -n "$((domain_line - 1))p" "$caddy_file" | grep -qx '# Security Dashboard'; then
|
|
domain_line=$((domain_line - 1))
|
|
fi
|
|
|
|
end_line="$(tail -n "+$marker_line" "$caddy_file" | grep -nx '}' | head -1 | cut -d: -f1)"
|
|
if [ -z "$end_line" ]; then
|
|
log_warning "Could not find the end of the existing dashboard Caddy block — leaving it as-is."
|
|
return 1
|
|
fi
|
|
end_line=$((marker_line + end_line - 1))
|
|
|
|
sed -i "${domain_line},${end_line}d" "$caddy_file"
|
|
log_info "Removed the existing dashboard Caddy block (regenerating it fresh)."
|
|
}
|
|
|
|
# Root-owned helper for editing CrowdSec's Asterisk-scenario YAMLs — the
|
|
# secdash service user (--shell /usr/sbin/nologin, no special file grants)
|
|
# cannot write /etc/crowdsec/scenarios/*.yaml directly (root:root, mode
|
|
# 644): confirmed live, a direct write from app.py failed with "[Errno 13]
|
|
# Permission denied". Rather than loosen those files' own permissions,
|
|
# route the edit through this one whitelisted root helper via sudo — same
|
|
# pattern every other CrowdSec-touching action here already uses (cscli via
|
|
# run_sudo), just for a plain file edit instead of a cscli subcommand.
|
|
# Mode 700 root:root: secdash can still invoke it (sudoers grants running
|
|
# it AS root regardless of the file's own permission bits), but nothing
|
|
# else on the box can execute it directly.
|
|
_secdash_write_asn_helper() {
|
|
local _app_dir="$1"
|
|
cat > "$_app_dir/set-asn-exempt.sh" << 'ASNHELPER'
|
|
#!/bin/bash
|
|
# Auto-generated by services/security-dashboard.sh — do not edit directly,
|
|
# re-run the installer instead. Invoked ONLY via sudo, by app.py's
|
|
# set_asn_exempt() (see /etc/sudoers.d/security-dashboard for the exact
|
|
# grant). Args are ASN numbers (already validated by the caller, but
|
|
# re-validated here too since this runs as root — never trust the caller
|
|
# alone for a root-executed script).
|
|
set -uo pipefail
|
|
|
|
SCENARIO_FILES=(
|
|
/etc/crowdsec/scenarios/local-asterisk_bf.yaml
|
|
/etc/crowdsec/scenarios/local-asterisk_user_enum.yaml
|
|
)
|
|
|
|
clean_asns=()
|
|
for a in "$@"; do
|
|
[[ "$a" =~ ^[0-9]+$ ]] && clean_asns+=("$a")
|
|
done
|
|
|
|
expr="" sep=""
|
|
for a in "${clean_asns[@]}"; do
|
|
expr="${expr}${sep}'${a}'"
|
|
sep=", "
|
|
done
|
|
|
|
found=0
|
|
for f in "${SCENARIO_FILES[@]}"; do
|
|
if [[ -f "$f" ]]; then
|
|
found=1
|
|
sed -i "s/ASNNumber in \[[^]]*\])/ASNNumber in [${expr}])/" "$f" || exit 1
|
|
fi
|
|
done
|
|
|
|
if [[ "$found" != "1" ]]; then
|
|
echo "No CrowdSec scenario files found to update" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Self-healing: the hub-original crowdsecurity/asterisk_bf /
|
|
# asterisk_user_enum scenarios have no ASN awareness at all, so if they're
|
|
# still enabled alongside the exempt forks above, they independently ban
|
|
# the same traffic regardless of anything just written — the exemption
|
|
# above would silently do nothing. crowdsec.sh's original install is
|
|
# supposed to disable them (--force, since they're crowdsecurity/asterisk
|
|
# collection members), but an install from before that fix shipped (or one
|
|
# where that step failed silently) would still have them active. Re-assert
|
|
# it on every save rather than trusting it was ever done correctly once —
|
|
# confirmed live: an install where this step had silently failed kept
|
|
# banning an exempted ASN under the hub-original scenario name.
|
|
cscli scenarios remove crowdsecurity/asterisk_bf crowdsecurity/asterisk_user_enum --force 2>/dev/null || true
|
|
|
|
if ! systemctl restart crowdsec; then
|
|
echo "Wrote ASN list but failed to restart CrowdSec" >&2
|
|
exit 2
|
|
fi
|
|
|
|
echo "OK"
|
|
ASNHELPER
|
|
chown root:root "$_app_dir/set-asn-exempt.sh"
|
|
chmod 700 "$_app_dir/set-asn-exempt.sh"
|
|
}
|
|
|
|
# Writes the Python app. Separate function so "update" mode (refresh code,
|
|
# keep config) and fresh installs share one copy instead of drifting apart.
|
|
_secdash_write_app() {
|
|
local _app_dir="$1"
|
|
mkdir -p "$_app_dir"
|
|
cat > "$_app_dir/app.py" << 'PYAPP'
|
|
#!/usr/bin/env python3
|
|
"""Security dashboard: Asterisk failed-connection log + CrowdSec decisions.
|
|
|
|
Stdlib only, deliberately — this runs on a small droplet alongside Asterisk,
|
|
Caddy, and CrowdSec, and shouldn't add a framework's worth of RAM overhead.
|
|
"""
|
|
import configparser
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
PORT = int(os.environ.get("DASHBOARD_PORT", "8092"))
|
|
ASTERISK_LOG = os.environ.get("ASTERISK_LOG", "")
|
|
ASTERISK_CONFIG_DIR = os.environ.get("ASTERISK_CONFIG_DIR", "")
|
|
ASN_SCENARIO_FILES = [
|
|
"/etc/crowdsec/scenarios/local-asterisk_bf.yaml",
|
|
"/etc/crowdsec/scenarios/local-asterisk_user_enum.yaml",
|
|
]
|
|
# Root-owned helper for the one write (edit + crowdsec restart) — this
|
|
# service user (--shell /usr/sbin/nologin) has no write access to
|
|
# ASN_SCENARIO_FILES (root:root, mode 644) and shouldn't; see
|
|
# _secdash_write_asn_helper in services/security-dashboard.sh for why this
|
|
# goes through sudo instead of loosening those files' permissions.
|
|
ASN_HELPER_SCRIPT = "/opt/security-dashboard/set-asn-exempt.sh"
|
|
|
|
TS_RE = re.compile(r"^\[([^\]]+)\]")
|
|
KV_RE = re.compile(r'(\w+)="([^"]*)"')
|
|
ASN_FILTER_RE = re.compile(r"ASNNumber in \[([^\]]*)\]\)")
|
|
ID_RE = re.compile(r"^\d+$")
|
|
ASN_RE = re.compile(r"^\d+$")
|
|
IP_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
|
|
DEVICE_MARKER_RE = re.compile(r"^; === Device: (.+?)(?:\s*\[AA:(?:yes|no)\])?\s*\((.+?)\)\s*===\s*$")
|
|
EXT_HEADER_RE = re.compile(r"^\[(\d+)\]")
|
|
EXTEN_RE = re.compile(r"^\d+$")
|
|
TIER_RE = re.compile(r"^(internal|restricted|full)$")
|
|
NUMBER_RE = re.compile(r"^\d{11}$")
|
|
NUMBER_RE_10 = re.compile(r"^\d{10}$")
|
|
|
|
|
|
def _normalize_nanp_number(token):
|
|
"""Accepts a bare 10-digit NANP number (the natural way to type a US
|
|
number), an already-11-digit one (leading "1" country code), or either
|
|
of those with a leading "+" (the natural way to paste a number straight
|
|
out of a phone's call log) and returns the canonical 11-digit,
|
|
digits-only form allowed_numbers is always stored in - REGEX()
|
|
comparisons against CALLERID(num) require an exact digit-count match,
|
|
and this used to silently DROP a plain 10-digit entry instead of
|
|
normalizing it, the admin-input-side twin of the bug that was also
|
|
failing inbound calls whose Caller-ID itself arrived without a leading
|
|
"1", or arrived "+E.164" style with a leading "+" Asterisk never
|
|
stripped (see PSTN_CALLERID_NORM / PSTN_CID_RAW in pstn-trunk.sh)."""
|
|
token = token.lstrip("+")
|
|
if NUMBER_RE_10.match(token):
|
|
return "1" + token
|
|
if NUMBER_RE.match(token):
|
|
return token
|
|
return None
|
|
|
|
|
|
SECURITY_LOG_TAIL_BYTES = 2 * 1024 * 1024 # comfortably enough for 5000 lines
|
|
|
|
|
|
def parse_security_log(limit=200):
|
|
"""Tail ASTERISK_LOG and return the most recent SecurityEvent lines,
|
|
newest first, as dicts. Missing file / no lines -> empty list, never an
|
|
error — this is a convenience view, not load-bearing.
|
|
|
|
Reads only a bounded byte window from the END of the file, not the whole
|
|
thing — this log is Asterisk's unrotated console/security output and can
|
|
grow to multiple GB. The previous version did f.readlines() (loads the
|
|
ENTIRE file into memory) before slicing the last 5000 lines, and this
|
|
tab polls every 30 seconds from the browser. Confirmed live: on a 1GB-RAM
|
|
droplet with a 1.4GB log file, that ballooned this "stdlib only,
|
|
deliberately lightweight" process to 677MB RSS / 1.8GB peak swap, which
|
|
left CrowdSec unable to even start (boot timeout) and contributed
|
|
directly to the droplet becoming unresponsive. Bounding this to a fixed
|
|
~2MB window keeps memory use constant regardless of how large the log
|
|
file grows.
|
|
"""
|
|
if not ASTERISK_LOG or not os.path.isfile(ASTERISK_LOG):
|
|
return []
|
|
events = []
|
|
try:
|
|
with open(ASTERISK_LOG, "rb") as f:
|
|
f.seek(0, os.SEEK_END)
|
|
size = f.tell()
|
|
start = max(0, size - SECURITY_LOG_TAIL_BYTES)
|
|
f.seek(start)
|
|
data = f.read()
|
|
except OSError:
|
|
return []
|
|
text = data.decode("utf-8", errors="replace")
|
|
lines = text.splitlines()
|
|
if start > 0 and lines:
|
|
lines = lines[1:] # first line is likely truncated mid-line
|
|
lines = lines[-5000:]
|
|
for line in lines:
|
|
if "SecurityEvent=" not in line:
|
|
continue
|
|
ts_match = TS_RE.match(line)
|
|
fields = dict(KV_RE.findall(line))
|
|
if not fields.get("SecurityEvent"):
|
|
continue
|
|
events.append({
|
|
"timestamp": ts_match.group(1) if ts_match else "",
|
|
"event": fields.get("SecurityEvent", ""),
|
|
"severity": fields.get("Severity", ""),
|
|
"account": fields.get("AccountID", ""),
|
|
"remote": fields.get("RemoteAddress", ""),
|
|
"reason": fields.get("SecurityEvent", ""),
|
|
})
|
|
events.reverse()
|
|
return events[:limit]
|
|
|
|
|
|
def run_sudo(args, timeout=15, input_text=None):
|
|
"""Runs a whitelisted sudo command. Always list-form args, never
|
|
shell=True — no shell metacharacter interpretation is possible regardless
|
|
of what's in the arguments, on top of the sudoers-side restriction.
|
|
input_text feeds stdin (e.g. for `docker exec -i ... tee <file>` writes —
|
|
see the ea_* Easy Asterisk admin functions) instead of a command-line
|
|
argument, so file content never has to survive sudoers pattern matching."""
|
|
try:
|
|
result = subprocess.run(
|
|
["sudo"] + args, capture_output=True, text=True, timeout=timeout,
|
|
input=input_text
|
|
)
|
|
return result.returncode == 0, result.stdout, result.stderr
|
|
except (subprocess.TimeoutExpired, OSError) as e:
|
|
return False, "", str(e)
|
|
|
|
|
|
def crowdsec_installed():
|
|
"""True if cscli is actually present on this host — mirrors
|
|
pstn_installed()'s approach of checking for the real thing rather than a
|
|
stored flag, so the CrowdSec tab tracks live state without needing this
|
|
dashboard reinstalled after CrowdSec is added or removed."""
|
|
return os.path.isfile("/usr/bin/cscli")
|
|
|
|
|
|
def get_decisions():
|
|
ok, out, err = run_sudo(["/usr/bin/cscli", "decisions", "list", "-o", "json"])
|
|
if not ok or not out.strip():
|
|
return []
|
|
try:
|
|
data = json.loads(out)
|
|
except json.JSONDecodeError:
|
|
return []
|
|
decisions = []
|
|
for alert in data or []:
|
|
# AS number/name and country live on the parent alert's "source"
|
|
# object, not on the individual decision — confirmed against real
|
|
# output (source.as_number, source.as_name, source.cn) rather than
|
|
# guessed, after getting evt.Enriched.ASNNumber's type wrong earlier
|
|
# tonight for the same underlying data.
|
|
source = alert.get("source") or {}
|
|
for d in alert.get("decisions") or []:
|
|
decisions.append({
|
|
"id": d.get("id"),
|
|
"value": d.get("value"),
|
|
"scenario": d.get("scenario"),
|
|
"duration": d.get("duration"),
|
|
"origin": d.get("origin"),
|
|
"as_number": source.get("as_number", ""),
|
|
"as_name": source.get("as_name", ""),
|
|
"country": source.get("cn", ""),
|
|
})
|
|
return decisions
|
|
|
|
|
|
def delete_decision(decision_id):
|
|
if not ID_RE.match(str(decision_id)):
|
|
return False, "Invalid decision ID"
|
|
ok, out, err = run_sudo(["/usr/bin/cscli", "decisions", "delete", "--id", str(decision_id)])
|
|
return ok, (err or out or ("deleted" if ok else "failed"))
|
|
|
|
|
|
def get_alert_history_names():
|
|
"""ASN -> as_name map built from historical alerts (cscli alerts list,
|
|
unlike decisions list, includes expired/resolved ones). A successfully
|
|
exempted ASN (e.g. T-Mobile once its bans stop firing) has no *active*
|
|
decision left to source a name from — this is the fallback that still
|
|
finds one, from the alert that was raised before the exemption took
|
|
effect."""
|
|
ok, out, err = run_sudo(["/usr/bin/cscli", "alerts", "list", "-o", "json"])
|
|
if not ok or not out.strip():
|
|
return {}
|
|
try:
|
|
data = json.loads(out)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
names = {}
|
|
for alert in data or []:
|
|
source = alert.get("source") or {}
|
|
asn = source.get("as_number")
|
|
name = source.get("as_name")
|
|
if asn and name:
|
|
names.setdefault(str(asn), name)
|
|
return names
|
|
|
|
|
|
def get_asn_exempt(known_names=None):
|
|
"""known_names: optional {asn: as_name} lookup, built from current
|
|
decisions, to label already-exempt ASNs that aren't actively generating
|
|
bans right now (and so wouldn't otherwise have a name available)."""
|
|
known_names = known_names or {}
|
|
asns = set()
|
|
for path in ASN_SCENARIO_FILES:
|
|
try:
|
|
with open(path) as f:
|
|
content = f.read()
|
|
except OSError:
|
|
continue
|
|
m = ASN_FILTER_RE.search(content)
|
|
if m:
|
|
for tok in m.group(1).split(","):
|
|
tok = tok.strip().strip("'").strip('"')
|
|
if tok:
|
|
asns.add(tok)
|
|
ordered = sorted(asns, key=lambda x: int(x) if x.isdigit() else 0)
|
|
return [{"asn": a, "name": known_names.get(a, "")} for a in ordered]
|
|
|
|
|
|
def set_asn_exempt(asn_list):
|
|
# Empty is valid and means "no ASNs exempted" — ASNNumber in [] is valid
|
|
# expr-language and always evaluates false, so the exclusion filter
|
|
# !(... in []) is always true and every Asterisk auth failure is
|
|
# evaluated normally again. Needed so removing the last remaining
|
|
# exempt ASN (the "unwhitelist" action) can actually reach zero instead
|
|
# of being stuck refusing an empty save.
|
|
clean = sorted(set(a.strip() for a in asn_list if ASN_RE.match(a.strip())))
|
|
# Editing ASN_SCENARIO_FILES directly from this process used to fail
|
|
# with "[Errno 13] Permission denied" (root:root, mode 644, this
|
|
# service user has no write grant) — every ASN whitelist attempt was
|
|
# silently a no-op as far as CrowdSec was concerned. Routed through the
|
|
# sudoers-whitelisted root helper instead, same pattern every other
|
|
# CrowdSec-touching action here already uses.
|
|
ok, out, err = run_sudo([ASN_HELPER_SCRIPT] + clean)
|
|
if not ok:
|
|
return False, "Failed updating ASN exemption: %s" % (err or out or "unknown error")
|
|
if not clean:
|
|
return True, "Cleared — no ASNs exempted, all Asterisk traffic is evaluated normally again."
|
|
return True, "Updated: %s" % ", ".join(clean)
|
|
|
|
|
|
def get_asn_source_ips(asn):
|
|
"""Every source IP CrowdSec has ever recorded for a given ASN, from alert
|
|
history (includes expired/resolved alerts) — used so "ban" can act on
|
|
previously-seen offenders immediately, not just future ones."""
|
|
ok, out, err = run_sudo(["/usr/bin/cscli", "alerts", "list", "-o", "json"])
|
|
if not ok or not out.strip():
|
|
return []
|
|
try:
|
|
data = json.loads(out)
|
|
except json.JSONDecodeError:
|
|
return []
|
|
ips = set()
|
|
for alert in data or []:
|
|
source = alert.get("source") or {}
|
|
if str(source.get("as_number", "")) == str(asn):
|
|
ip = source.get("ip")
|
|
if ip and IP_RE.match(ip):
|
|
ips.add(ip)
|
|
return sorted(ips)
|
|
|
|
|
|
def ban_ip(ip, reason, duration="24h"):
|
|
if not IP_RE.match(ip):
|
|
return False, "Invalid IP"
|
|
ok, out, err = run_sudo([
|
|
"/usr/bin/cscli", "decisions", "add",
|
|
"--ip", ip, "--duration", duration, "--type", "ban", "--reason", reason,
|
|
])
|
|
return ok, (err or out or ("banned" if ok else "failed"))
|
|
|
|
|
|
def ban_asn(asn):
|
|
"""For an accidental whitelist: drop the ASN from the exempt list (so
|
|
future traffic from it is evaluated normally again) and immediately ban
|
|
every IP CrowdSec has on record for it, so the response isn't limited to
|
|
"wait for it to misbehave again."""
|
|
asn = str(asn).strip()
|
|
if not ASN_RE.match(asn):
|
|
return {"ok": False, "message": "Invalid ASN"}
|
|
|
|
current = [d["asn"] for d in get_asn_exempt()]
|
|
if asn in current:
|
|
remaining = [a for a in current if a != asn]
|
|
unexempt_ok, unexempt_message = set_asn_exempt(remaining)
|
|
else:
|
|
unexempt_ok, unexempt_message = True, "ASN was not currently exempt"
|
|
|
|
banned, failed = [], []
|
|
for ip in get_asn_source_ips(asn):
|
|
ok, _msg = ban_ip(ip, "manual: AS%s exemption removed, known offender re-banned" % asn)
|
|
(banned if ok else failed).append(ip)
|
|
|
|
return {
|
|
"ok": unexempt_ok,
|
|
"unexempt_message": unexempt_message,
|
|
"banned_ips": banned,
|
|
"failed_ips": failed,
|
|
}
|
|
|
|
|
|
def list_extensions():
|
|
"""Extension numbers + display names, parsed from pjsip.conf the same
|
|
way Easy Asterisk's own rebuild_dialplan() finds them: a
|
|
"; === Device: NAME (category) ===" comment immediately followed (once
|
|
other lines are skipped) by that device's "[extnum]" section header.
|
|
Read-only, best-effort — an unparseable/missing file just means an empty
|
|
list, not an error, same convention as parse_security_log."""
|
|
if not ASTERISK_CONFIG_DIR:
|
|
return []
|
|
path = os.path.join(ASTERISK_CONFIG_DIR, "pjsip.conf")
|
|
if not os.path.isfile(path):
|
|
return []
|
|
try:
|
|
with open(path, "r", errors="replace") as f:
|
|
lines = f.readlines()
|
|
except OSError:
|
|
return []
|
|
extensions = []
|
|
pending_name = None
|
|
for line in lines:
|
|
line = line.rstrip("\n")
|
|
m = DEVICE_MARKER_RE.match(line)
|
|
if m:
|
|
pending_name = m.group(1).strip()
|
|
continue
|
|
m = EXT_HEADER_RE.match(line)
|
|
if m and pending_name is not None:
|
|
extensions.append({"ext": m.group(1), "name": pending_name})
|
|
pending_name = None
|
|
return extensions
|
|
|
|
|
|
def _write_ini_cp(path, header, cp):
|
|
"""Shared temp-write-then-rename for every live-editable PSTN conf file —
|
|
one copy of the atomic-write/error-handling logic instead of repeating
|
|
it per file. Returns (ok, error_message_or_None)."""
|
|
if not path:
|
|
return False, "No Asterisk install detected on this box"
|
|
tmp_path = path + ".tmp"
|
|
try:
|
|
with open(tmp_path, "w") as f:
|
|
f.write(header)
|
|
cp.write(f)
|
|
os.replace(tmp_path, path)
|
|
except OSError as e:
|
|
try:
|
|
os.remove(tmp_path)
|
|
except OSError:
|
|
pass
|
|
return False, "Failed writing %s: %s" % (path, e)
|
|
return True, None
|
|
|
|
|
|
PERMISSIONS_HEADER = (
|
|
"; PSTN permission tiers - internal / restricted / full - PLUS two\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"
|
|
"; 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"
|
|
"; never touches this file, only a fresh reinstall does.\n"
|
|
"; Any extension not listed here is internal-only (no PSTN) by default.\n\n"
|
|
)
|
|
|
|
PERSONAL_DIDS_HEADER = (
|
|
"; Personal DID -> owner-extension mapping. Read LIVE by the dialplan\n"
|
|
"; (AST_CONFIG()) on every inbound call - no restart needed. Managed here\n"
|
|
"; (Security Dashboard); also safe to edit by hand. Kept in sync with\n"
|
|
"; pstn-permissions.conf's personal_did= field automatically by\n"
|
|
"; write_personal_did()/remove_personal_did() below - editing this file\n"
|
|
"; by hand also requires updating that field yourself to match.\n"
|
|
"; 'sudo ./setup.sh pstn-trunk' update mode never touches this file, only\n"
|
|
"; a fresh reinstall does.\n\n"
|
|
)
|
|
|
|
|
|
def _permissions_path():
|
|
return os.path.join(ASTERISK_CONFIG_DIR, "pstn-permissions.conf") if ASTERISK_CONFIG_DIR else None
|
|
|
|
|
|
def _read_permissions_cp():
|
|
cp = configparser.ConfigParser(delimiters=("=",))
|
|
path = _permissions_path()
|
|
if path and os.path.isfile(path):
|
|
try:
|
|
cp.read(path)
|
|
except configparser.Error:
|
|
pass
|
|
return cp
|
|
|
|
|
|
def get_all_permissions():
|
|
"""{ext: {"tier": ..., "allowed_numbers": "num|num|...", "messaging":
|
|
bool}} for every extension with a non-default record. Extensions with
|
|
no section are implicitly "internal"/messaging-disabled — the
|
|
dialplan's AST_CONFIG() lookup treats a missing section/key as empty/
|
|
denied the same way, so there's nothing to return for them here; the
|
|
UI fills in the defaults for any known extension (from
|
|
list_extensions()) not present in this dict."""
|
|
cp = _read_permissions_cp()
|
|
result = {}
|
|
for section in cp.sections():
|
|
if not EXTEN_RE.match(section):
|
|
continue
|
|
result[section] = {
|
|
"tier": cp.get(section, "tier", fallback="internal"),
|
|
"allowed_numbers": cp.get(section, "allowed_numbers", fallback=""),
|
|
"messaging": cp.getboolean(section, "messaging", fallback=False),
|
|
}
|
|
return result
|
|
|
|
|
|
def write_permission(ext, tier, numbers_raw, messaging_enabled=False):
|
|
"""Saves one extension's tier + (for restricted) approved-number list +
|
|
messaging flag in one action — messaging is an independent axis from
|
|
the calling tier (see pstn-trunk.sh's file-level comment: an extension
|
|
can be internal-tier for calling and still messaging-enabled, or vice
|
|
versa), so it's set/cleared regardless of which tier branch runs below.
|
|
Numbers are normalized to a pipe-separated list of 11-digit US numbers
|
|
(a bare 10-digit entry gets a leading "1" added, not dropped — see
|
|
_normalize_nanp_number) — pipe, not comma, because the dialplan uses
|
|
this value directly as a REGEX() alternation pattern (see
|
|
services/pstn-trunk.sh's file-level comment on why the untrusted call
|
|
data is always the string being tested, never interpolated into the
|
|
pattern side)."""
|
|
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"
|
|
if not TIER_RE.match(tier):
|
|
return False, "Invalid tier"
|
|
|
|
tokens = re.split(r"[,\s|]+", (numbers_raw or "").strip())
|
|
clean_numbers = [n for n in (_normalize_nanp_number(t) for t in tokens if t) if n]
|
|
numbers = "|".join(clean_numbers)
|
|
|
|
cp = _read_permissions_cp()
|
|
if tier == "internal":
|
|
# Only drop the tier/allowed_numbers keys, NOT the whole section —
|
|
# an extension can independently have messaging=yes and/or a
|
|
# personal_did assigned, and those must survive a tier change back
|
|
# to internal. Confirmed live as a real bug: cp.remove_section(ext)
|
|
# here used to silently discard both whenever tier was set to
|
|
# internal.
|
|
if cp.has_section(ext):
|
|
if cp.has_option(ext, "tier"):
|
|
cp.remove_option(ext, "tier")
|
|
if cp.has_option(ext, "allowed_numbers"):
|
|
cp.remove_option(ext, "allowed_numbers")
|
|
else:
|
|
if not cp.has_section(ext):
|
|
cp.add_section(ext)
|
|
cp.set(ext, "tier", tier)
|
|
if tier == "restricted":
|
|
cp.set(ext, "allowed_numbers", numbers)
|
|
elif cp.has_option(ext, "allowed_numbers"):
|
|
cp.remove_option(ext, "allowed_numbers")
|
|
|
|
if messaging_enabled:
|
|
if not cp.has_section(ext):
|
|
cp.add_section(ext)
|
|
cp.set(ext, "messaging", "yes")
|
|
elif cp.has_section(ext) and cp.has_option(ext, "messaging"):
|
|
cp.remove_option(ext, "messaging")
|
|
|
|
# Drop the section entirely once nothing (tier, numbers, messaging,
|
|
# personal_did) is left in it — only reached this way when tier is
|
|
# internal, messaging is off, and no personal_did was ever assigned.
|
|
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
|
|
|
|
if tier == "restricted" and not clean_numbers:
|
|
return True, "Saved as restricted with an EMPTY approved list — no PSTN number can reach/be reached by it yet."
|
|
return True, "Saved"
|
|
|
|
|
|
def write_messaging(ext, enabled):
|
|
"""Sets/clears just the messaging flag for one extension, leaving any
|
|
tier/allowed_numbers/personal_did untouched. This is the write path for
|
|
the Extensions tab's Messaging column when there's no PSTN trunk to
|
|
save alongside — messaging works whether or not one has been installed — messaging has no dependency
|
|
on one (no cost, no carrier, no DID), unlike the calling-permissions
|
|
table this dashboard otherwise gates behind pstn_installed(). Creates
|
|
pstn-permissions.conf from scratch if it doesn't exist yet."""
|
|
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()
|
|
if enabled:
|
|
if not cp.has_section(ext):
|
|
cp.add_section(ext)
|
|
cp.set(ext, "messaging", "yes")
|
|
elif cp.has_section(ext) and cp.has_option(ext, "messaging"):
|
|
cp.remove_option(ext, "messaging")
|
|
|
|
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
|
|
return True, "Saved"
|
|
|
|
|
|
GROUP_NAME_RE = re.compile(r"^[A-Za-z0-9_ -]{1,40}$")
|
|
|
|
GROUPS_HEADER = (
|
|
"; Named extension groups - a management convenience only, NEVER read by\n"
|
|
"; the dialplan itself (which only ever looks at per-extension keys in\n"
|
|
"; pstn-permissions.conf - see that file). Applying a group action (e.g.\n"
|
|
"; \"enable messaging\") writes those same per-extension keys for every\n"
|
|
"; CURRENT member, exactly as if each had been checked individually - it's\n"
|
|
"; a one-time bulk write, not an ongoing binding. Editing membership here\n"
|
|
"; does not retroactively change anything already applied to former\n"
|
|
"; members, and adding someone to a group does not automatically apply\n"
|
|
"; the group's settings - use the dashboard's \"Enable/Disable\" actions\n"
|
|
"; for that, any time membership changes.\n\n"
|
|
)
|
|
|
|
|
|
def _groups_path():
|
|
return os.path.join(ASTERISK_CONFIG_DIR, "pstn-groups.conf") if ASTERISK_CONFIG_DIR else None
|
|
|
|
|
|
def _read_groups_cp():
|
|
cp = configparser.ConfigParser(delimiters=("=",))
|
|
path = _groups_path()
|
|
if path and os.path.isfile(path):
|
|
try:
|
|
cp.read(path)
|
|
except configparser.Error:
|
|
pass
|
|
return cp
|
|
|
|
|
|
def list_groups():
|
|
"""[{"name": ..., "members": [ext, ...]}], sorted by name."""
|
|
cp = _read_groups_cp()
|
|
result = []
|
|
for section in cp.sections():
|
|
members_raw = cp.get(section, "members", fallback="")
|
|
members = [m.strip() for m in members_raw.split(",") if m.strip()]
|
|
result.append({"name": section, "members": members})
|
|
result.sort(key=lambda g: g["name"].lower())
|
|
return result
|
|
|
|
|
|
def write_group(name, members):
|
|
if not ASTERISK_CONFIG_DIR:
|
|
return False, "No Asterisk install detected on this box"
|
|
name = str(name).strip()
|
|
if not GROUP_NAME_RE.match(name):
|
|
return False, "Group name must be 1-40 characters (letters, digits, spaces, - or _)"
|
|
clean_members = sorted(set(str(m).strip() for m in members if EXTEN_RE.match(str(m).strip())))
|
|
|
|
cp = _read_groups_cp()
|
|
if not cp.has_section(name):
|
|
cp.add_section(name)
|
|
cp.set(name, "members", ",".join(clean_members))
|
|
|
|
ok, err = _write_ini_cp(_groups_path(), GROUPS_HEADER, cp)
|
|
if not ok:
|
|
return False, err
|
|
return True, "Saved group '%s' with %d member(s)" % (name, len(clean_members))
|
|
|
|
|
|
def delete_group(name):
|
|
if not ASTERISK_CONFIG_DIR:
|
|
return False, "No Asterisk install detected on this box"
|
|
name = str(name).strip()
|
|
cp = _read_groups_cp()
|
|
if cp.has_section(name):
|
|
cp.remove_section(name)
|
|
ok, err = _write_ini_cp(_groups_path(), GROUPS_HEADER, cp)
|
|
if not ok:
|
|
return False, err
|
|
return True, "Deleted group '%s' (members' own settings were not changed)" % name
|
|
|
|
|
|
def apply_group_messaging(name, enabled):
|
|
"""Sets messaging=<enabled> for every CURRENT member of the group, one
|
|
at a time via write_messaging() - the exact same write path an
|
|
individual checkbox uses. Returns a summary of how many succeeded."""
|
|
groups = {g["name"]: g["members"] for g in list_groups()}
|
|
if name not in groups:
|
|
return False, "Group not found"
|
|
members = groups[name]
|
|
if not members:
|
|
return True, "Group '%s' has no members - nothing to change" % name
|
|
failed = []
|
|
for ext in members:
|
|
ok, _msg = write_messaging(ext, enabled)
|
|
if not ok:
|
|
failed.append(ext)
|
|
if failed:
|
|
return False, "Applied to %d/%d member(s) - failed: %s" % (
|
|
len(members) - len(failed), len(members), ", ".join(failed))
|
|
return True, "Messaging %s for all %d member(s) of '%s'" % (
|
|
"enabled" if enabled else "disabled", len(members), name)
|
|
|
|
|
|
LIMIT_RE = re.compile(r"^\d+$")
|
|
|
|
|
|
def pstn_installed():
|
|
"""True only once services/pstn-trunk.sh has actually wired the dialplan
|
|
in (pstn-trunk-dialplan.conf existing), not just because base Asterisk is
|
|
present — pjsip.conf/extensions.conf exist either way, so extension names
|
|
alone can't tell us this. Without this check the Extensions tab would
|
|
show tier columns and a default-but-unenforced 10/10 cap even when there
|
|
is no PSTN trunk at all."""
|
|
if not ASTERISK_CONFIG_DIR:
|
|
return False
|
|
return os.path.isfile(os.path.join(ASTERISK_CONFIG_DIR, "pstn-trunk-dialplan.conf"))
|
|
|
|
|
|
def get_limits():
|
|
"""Current outbound/inbound concurrent-call caps. Defaults (10/10) match
|
|
what the dialplan itself falls back to (via AST_CONFIG()+IF()) if this
|
|
file is missing or a key is absent, so a display here is never wrong
|
|
even before pstn-limits.conf exists."""
|
|
if not ASTERISK_CONFIG_DIR:
|
|
return {"max_outbound": 10, "max_inbound": 10}
|
|
path = os.path.join(ASTERISK_CONFIG_DIR, "pstn-limits.conf")
|
|
cp = configparser.ConfigParser(delimiters=("=",))
|
|
if os.path.isfile(path):
|
|
try:
|
|
cp.read(path)
|
|
except configparser.Error:
|
|
pass
|
|
return {
|
|
"max_outbound": cp.getint("limits", "max_outbound", fallback=10),
|
|
"max_inbound": cp.getint("limits", "max_inbound", fallback=10),
|
|
}
|
|
|
|
|
|
def write_limits(max_outbound, max_inbound):
|
|
if not ASTERISK_CONFIG_DIR:
|
|
return False, "No Asterisk install detected on this box"
|
|
max_outbound, max_inbound = str(max_outbound).strip(), str(max_inbound).strip()
|
|
if not LIMIT_RE.match(max_outbound) or not LIMIT_RE.match(max_inbound):
|
|
return False, "Both caps must be whole numbers"
|
|
|
|
path = os.path.join(ASTERISK_CONFIG_DIR, "pstn-limits.conf")
|
|
tmp_path = path + ".tmp"
|
|
try:
|
|
with open(tmp_path, "w") as f:
|
|
f.write(
|
|
"; PSTN concurrent-call caps, both directions.\n"
|
|
"; Read LIVE by the dialplan on every call (AST_CONFIG()) - no Asterisk\n"
|
|
"; restart needed. Managed here (Security Dashboard); also safe to edit\n"
|
|
"; by hand. 'sudo ./setup.sh pstn-trunk' update mode never touches this\n"
|
|
"; file, only a fresh reinstall does.\n\n"
|
|
"[limits]\n"
|
|
"max_outbound=%s\n"
|
|
"max_inbound=%s\n" % (max_outbound, max_inbound)
|
|
)
|
|
os.replace(tmp_path, path)
|
|
except OSError as e:
|
|
try:
|
|
os.remove(tmp_path)
|
|
except OSError:
|
|
pass
|
|
return False, "Failed writing %s: %s" % (path, e)
|
|
return True, "Saved"
|
|
|
|
|
|
PERSONAL_DID_RE = re.compile(r"^\d{10}$")
|
|
PERSONAL_DID_RE_11 = re.compile(r"^1\d{10}$")
|
|
|
|
|
|
def _normalize_personal_did_input(did):
|
|
"""write_personal_did()'s DID field is hand-typed, same footgun as
|
|
allowed_numbers - accept the canonical bare 10-digit form, an 11-digit
|
|
one with the NANP "1" prefix, or either with a leading "+" (pasted
|
|
straight from a call log), returning the canonical 10-digit form either
|
|
way instead of rejecting a plainly-valid entry."""
|
|
did = did.lstrip("+")
|
|
if PERSONAL_DID_RE.match(did):
|
|
return did
|
|
if PERSONAL_DID_RE_11.match(did):
|
|
return did[1:]
|
|
return None
|
|
|
|
|
|
def _personal_dids_path():
|
|
return os.path.join(ASTERISK_CONFIG_DIR, "pstn-personal-dids.conf") if ASTERISK_CONFIG_DIR else None
|
|
|
|
|
|
def _read_personal_dids_cp():
|
|
cp = configparser.ConfigParser(delimiters=("=",))
|
|
path = _personal_dids_path()
|
|
if path and os.path.isfile(path):
|
|
try:
|
|
cp.read(path)
|
|
except configparser.Error:
|
|
pass
|
|
return cp
|
|
|
|
|
|
def list_personal_dids():
|
|
"""[{"did": ..., "owner": ...}] for every currently-assigned personal
|
|
DID, sorted by DID."""
|
|
cp = _read_personal_dids_cp()
|
|
result = []
|
|
for section in cp.sections():
|
|
if not PERSONAL_DID_RE.match(section):
|
|
continue
|
|
result.append({"did": section, "owner": cp.get(section, "owner", fallback="")})
|
|
result.sort(key=lambda d: d["did"])
|
|
return result
|
|
|
|
|
|
def write_personal_did(did, owner):
|
|
"""Assigns did -> owner, keeping pstn-personal-dids.conf (inbound
|
|
routing, read by the dialplan) and pstn-permissions.conf's
|
|
personal_did= (outbound Caller-ID override) in sync. One owner has at
|
|
most one personal_did (AST_CONFIG() returns a single value per key), so
|
|
reassigning a DID to a new owner drops the previous owner's claim on
|
|
it, and giving an extension a new personal DID drops whichever one it
|
|
had before — this always leaves a clean 1:1 mapping in both files,
|
|
rather than requiring the caller to clean up the old assignment
|
|
itself.
|
|
|
|
owner may also be a group reference, written as "@GroupName" (the '@'
|
|
makes it unambiguous against a same-named numeric extension - group
|
|
names are free text and could otherwise collide, e.g. a group literally
|
|
named "201"). A group-owned DID rings every CURRENT member whose own
|
|
tier/approved-numbers authorize the caller, computed fresh on every
|
|
call (see pstn-personal-group-ring.sh) rather than baked in at
|
|
assignment time - membership changes take effect immediately, unlike
|
|
the Groups card's other bulk actions. Group ownership has no single
|
|
extension to hang an outbound Caller-ID override on, so it never
|
|
touches pstn-permissions.conf the way a single-extension owner does."""
|
|
if not ASTERISK_CONFIG_DIR:
|
|
return False, "No Asterisk install detected on this box"
|
|
did = str(did).strip()
|
|
owner = str(owner).strip()
|
|
norm_did = _normalize_personal_did_input(did)
|
|
if norm_did is None:
|
|
return False, "DID must be a 10-digit US number (11-digit with a leading 1 also accepted)"
|
|
did = norm_did
|
|
|
|
is_group = owner.startswith("@")
|
|
group_name = owner[1:] if is_group else ""
|
|
if is_group:
|
|
if not group_name or not _read_groups_cp().has_section(group_name):
|
|
return False, "Group '%s' not found" % group_name
|
|
elif not EXTEN_RE.match(owner):
|
|
return False, "Invalid owner extension"
|
|
|
|
dids_cp = _read_personal_dids_cp()
|
|
perms_cp = _read_permissions_cp()
|
|
|
|
if not is_group:
|
|
for section in perms_cp.sections():
|
|
if section != owner and perms_cp.get(section, "personal_did", fallback="") == did:
|
|
perms_cp.remove_option(section, "personal_did")
|
|
if not perms_cp.options(section):
|
|
perms_cp.remove_section(section)
|
|
|
|
for section in list(dids_cp.sections()):
|
|
if section != did and dids_cp.get(section, "owner", fallback="") == owner:
|
|
dids_cp.remove_section(section)
|
|
|
|
if not dids_cp.has_section(did):
|
|
dids_cp.add_section(did)
|
|
dids_cp.set(did, "owner", owner)
|
|
|
|
ok, err = _write_ini_cp(_personal_dids_path(), PERSONAL_DIDS_HEADER, dids_cp)
|
|
if not ok:
|
|
return False, err
|
|
|
|
if is_group:
|
|
return True, "Assigned %s to group %s" % (did, group_name)
|
|
|
|
if not perms_cp.has_section(owner):
|
|
perms_cp.add_section(owner)
|
|
perms_cp.set(owner, "personal_did", did)
|
|
|
|
ok, err = _write_ini_cp(_permissions_path(), PERMISSIONS_HEADER, perms_cp)
|
|
if not ok:
|
|
return False, err
|
|
|
|
owner_tier = perms_cp.get(owner, "tier", fallback="internal")
|
|
if owner_tier not in ("full", "restricted"):
|
|
return True, "Assigned %s to extension %s - note: %s is internal-tier, so it won't actually receive calls on this DID until you also grant it full or restricted tier." % (did, owner, owner)
|
|
return True, "Assigned %s to extension %s" % (did, owner)
|
|
|
|
|
|
def remove_personal_did(did):
|
|
if not ASTERISK_CONFIG_DIR:
|
|
return False, "No Asterisk install detected on this box"
|
|
did = str(did).strip()
|
|
if not PERSONAL_DID_RE.match(did):
|
|
return False, "Invalid DID"
|
|
|
|
dids_cp = _read_personal_dids_cp()
|
|
perms_cp = _read_permissions_cp()
|
|
|
|
if dids_cp.has_section(did):
|
|
dids_cp.remove_section(did)
|
|
|
|
for section in perms_cp.sections():
|
|
if perms_cp.get(section, "personal_did", fallback="") == did:
|
|
perms_cp.remove_option(section, "personal_did")
|
|
if not perms_cp.options(section):
|
|
perms_cp.remove_section(section)
|
|
|
|
ok, err = _write_ini_cp(_personal_dids_path(), PERSONAL_DIDS_HEADER, dids_cp)
|
|
if not ok:
|
|
return False, err
|
|
ok, err = _write_ini_cp(_permissions_path(), PERMISSIONS_HEADER, perms_cp)
|
|
if not ok:
|
|
return False, err
|
|
return True, "Removed %s" % did
|
|
|
|
|
|
# ── Easy Asterisk device management (devices, categories, rooms/ring-groups) ──
|
|
# Full reimplementation of vendor/easy-asterisk/easy-asterisk-v0.10.0.sh's
|
|
# vendored web admin (its own separate process, normally reached via its own
|
|
# port/domain) as native code here instead — one tab, one process, no
|
|
# separate app to proxy or embed. Keeps writing the EXACT same file formats
|
|
# (pjsip.conf's "; === Device: Name (category) [AA:yes/no] ===" comment +
|
|
# bracket-section convention, categories.conf/rooms.conf's pipe-delimited
|
|
# rows) the vendor's own `easy-asterisk --rebuild-dialplan` CLI still reads
|
|
# to generate the dialplan — this is a new front door onto the same
|
|
# underlying config, not a fork of dialplan generation itself.
|
|
#
|
|
# Reads go straight through the host-side bind-mounted files (same as
|
|
# list_extensions() already does for pjsip.conf) — cheap, and this dashboard
|
|
# already has working read access there. WRITES go through `docker exec ...
|
|
# tee` instead of writing the host-side file directly: Easy Asterisk's own
|
|
# container writes these files as ITS OWN internal user, and a host-side
|
|
# write here would be fighting that ownership — liable to silently break
|
|
# again the next time the container restarts and re-asserts it. Routing
|
|
# through docker exec (root, via a narrowly scoped sudoers entry — see
|
|
# _secdash_write_sudoers) sidesteps the host/container UID mismatch
|
|
# entirely, the same way this file already does for CrowdSec's cscli.
|
|
ASTERISK_EA_CONFIG_DIR = os.environ.get("ASTERISK_EA_CONFIG_DIR", "")
|
|
ASTERISK_EA_CONTAINER = os.environ.get("ASTERISK_EA_CONTAINER", "")
|
|
|
|
EA_PJSIP_CONTAINER_PATH = "/etc/asterisk/pjsip.conf"
|
|
EA_CATEGORIES_CONTAINER_PATH = "/etc/easy-asterisk/categories.conf"
|
|
EA_ROOMS_CONTAINER_PATH = "/etc/easy-asterisk/rooms.conf"
|
|
EA_EXT_RE = re.compile(r"^\d{1,10}$")
|
|
EA_CATID_RE = re.compile(r"^[a-z0-9]+$")
|
|
|
|
|
|
def ea_installed():
|
|
return bool(ASTERISK_EA_CONTAINER)
|
|
|
|
|
|
def _ea_pjsip_host_path():
|
|
return os.path.join(ASTERISK_CONFIG_DIR, "pjsip.conf") if ASTERISK_CONFIG_DIR else None
|
|
|
|
|
|
def _ea_categories_host_path():
|
|
return os.path.join(ASTERISK_EA_CONFIG_DIR, "categories.conf") if ASTERISK_EA_CONFIG_DIR else None
|
|
|
|
|
|
def _ea_rooms_host_path():
|
|
return os.path.join(ASTERISK_EA_CONFIG_DIR, "rooms.conf") if ASTERISK_EA_CONFIG_DIR else None
|
|
|
|
|
|
def ea_docker_write(container_path, content):
|
|
"""Writes content to a file INSIDE the Easy Asterisk container via
|
|
`docker exec -i <container> tee <path>` (root, sudo-gated) — see the
|
|
module-level comment above for why this isn't a direct host-side write."""
|
|
ok, _out, err = run_sudo(
|
|
["docker", "exec", "-i", ASTERISK_EA_CONTAINER, "tee", container_path],
|
|
input_text=content,
|
|
)
|
|
return ok, ("" if ok else (err or "Write failed"))
|
|
|
|
|
|
def ea_reload_pjsip():
|
|
run_sudo(["docker", "exec", ASTERISK_EA_CONTAINER, "asterisk", "-rx", "module reload res_pjsip.so"])
|
|
|
|
|
|
def ea_rebuild_dialplan():
|
|
run_sudo(["docker", "exec", ASTERISK_EA_CONTAINER, "/usr/local/bin/easy-asterisk", "--rebuild-dialplan"])
|
|
|
|
|
|
def restart_asterisk_container():
|
|
"""Restarts the Easy Asterisk container - the "Commit Changes" button on
|
|
the Extensions tab. Confirmed live: dashboard writes to
|
|
pstn-permissions.conf/pstn-groups.conf/pstn-personal-dids.conf land on
|
|
disk immediately (readable via a plain `cat` right after saving), but
|
|
AST_CONFIG() in the dialplan sometimes kept returning a stale value
|
|
until the container was fully restarted - not just a `dialplan reload`
|
|
or `module reload`, an actual container restart. Root cause not fully
|
|
understood (contradicts AST_CONFIG's whole "reads fresh every call, no
|
|
restart needed" design premise, which this codebase otherwise relies on
|
|
throughout), but the restart reliably clears it, so this button exists
|
|
instead of requiring every admin to rediscover "just restart it" the
|
|
hard way. Uses the same ASTERISK_EA_CONTAINER/run_sudo mechanism as the
|
|
Extensions tab's own docker exec calls - no new sudoers scope
|
|
needed beyond the one line added for this."""
|
|
if not ASTERISK_EA_CONTAINER:
|
|
return False, "No Asterisk container detected on this box"
|
|
ok, _out, err = run_sudo(["docker", "restart", ASTERISK_EA_CONTAINER], timeout=30)
|
|
return ok, ("" if ok else (err or "Restart failed"))
|
|
|
|
|
|
def ea_get_status():
|
|
"""Registered/unregistered per extension — same 'pjsip show endpoints'
|
|
parsing as the vendored get_registered_endpoints()."""
|
|
ok, out, _err = run_sudo(["docker", "exec", ASTERISK_EA_CONTAINER, "asterisk", "-rx", "pjsip show endpoints"])
|
|
if not ok:
|
|
return {}
|
|
endpoints = {}
|
|
current = None
|
|
for line in out.split("\n"):
|
|
m = re.match(r"\s*Endpoint:\s+(\d+)/", line)
|
|
if m:
|
|
current = m.group(1)
|
|
endpoints[current] = "offline"
|
|
if current and "Contact:" in line and ("Avail" in line or "NonQual" in line):
|
|
endpoints[current] = "online"
|
|
return endpoints
|
|
|
|
|
|
def ea_list_devices():
|
|
"""Same comment+bracket parsing as the vendored get_devices() so this
|
|
reads pjsip.conf identically regardless of which admin wrote it."""
|
|
path = _ea_pjsip_host_path()
|
|
devices = []
|
|
if not path or not os.path.isfile(path):
|
|
return devices
|
|
with open(path) as f:
|
|
lines = f.readlines()
|
|
dev_name = dev_cat = dev_aa = None
|
|
for line in lines:
|
|
line = line.strip()
|
|
if "; === Device:" in line:
|
|
temp = line.split("; === Device:")[1].split("===")[0].strip()
|
|
dev_aa = None
|
|
if "[AA:yes]" in temp:
|
|
dev_aa = "yes"
|
|
temp = temp.replace("[AA:yes]", "").strip()
|
|
elif "[AA:no]" in temp:
|
|
dev_aa = "no"
|
|
temp = temp.replace("[AA:no]", "").strip()
|
|
if "(" in temp and ")" in temp:
|
|
dev_cat = temp[temp.rfind("(") + 1:temp.rfind(")")]
|
|
dev_name = temp[:temp.rfind("(")].strip()
|
|
else:
|
|
dev_name = temp
|
|
dev_cat = "unknown"
|
|
elif dev_name and re.match(r"^\[(\d+)\]$", line):
|
|
ext = re.match(r"^\[(\d+)\]$", line).group(1)
|
|
devices.append({"name": dev_name, "category": dev_cat, "extension": ext,
|
|
"auto_answer": dev_aa, "transport": "udp", "encryption": "no"})
|
|
dev_name = dev_cat = dev_aa = None
|
|
elif devices and line.startswith("transport=transport-"):
|
|
devices[-1]["transport"] = line.split("transport-")[1]
|
|
elif devices and line.startswith("media_encryption="):
|
|
val = line.split("=")[1]
|
|
if val in ("sdes", "dtls"):
|
|
devices[-1]["encryption"] = val
|
|
if devices[-1]["transport"] == "udp":
|
|
devices[-1]["transport"] = "tls"
|
|
elif val != "no":
|
|
devices[-1]["encryption"] = val
|
|
return devices
|
|
|
|
|
|
def _ea_generate_password(length=16):
|
|
import secrets
|
|
import string
|
|
chars = string.ascii_letters + string.digits
|
|
return "".join(secrets.choice(chars) for _ in range(length))
|
|
|
|
|
|
def ea_add_device(name, category, extension, conn_type="lan", auto_answer=None):
|
|
path = _ea_pjsip_host_path()
|
|
if not path:
|
|
return False, "No Asterisk install detected on this box"
|
|
extension = str(extension).strip()
|
|
if not EA_EXT_RE.match(extension):
|
|
return False, "Invalid extension"
|
|
name = (name or "").strip()
|
|
if not name:
|
|
return False, "Name required"
|
|
if not os.path.isfile(path):
|
|
return False, "Config file not found"
|
|
|
|
with open(path) as f:
|
|
current = f.read()
|
|
if "[%s]" % extension in current:
|
|
return False, "Extension already exists"
|
|
|
|
password = _ea_generate_password(16)
|
|
|
|
if conn_type == "fqdn":
|
|
transport = "transport=transport-tls"
|
|
encryption = "media_encryption=sdes"
|
|
ice = "ice_support=yes"
|
|
else:
|
|
transport = "transport=transport-udp"
|
|
encryption = "media_encryption=no"
|
|
ice = ""
|
|
|
|
aa_tag = ""
|
|
if auto_answer == "yes":
|
|
aa_tag = "[AA:yes] "
|
|
elif auto_answer == "no":
|
|
aa_tag = "[AA:no] "
|
|
|
|
keepalive = ""
|
|
if category == "mobile":
|
|
keepalive = "rtp_keepalive=15\nrtp_timeout=120\nrtp_timeout_hold=120"
|
|
|
|
# Built as a filtered line list, not positional %s blanks — keepalive and
|
|
# ice are both empty for a plain non-mobile LAN device, and leaving them
|
|
# as literal blank template lines produces TWO consecutive blank lines
|
|
# inside the endpoint stanza instead of one. ea_delete_device/
|
|
# ea_rename_device/ea_change_device_category all use "blank line ends
|
|
# this device's block" as their boundary heuristic (matching the
|
|
# vendored admin's own logic) — an extra internal blank line there is a
|
|
# latent bug inherited from the vendor template, confirmed live against
|
|
# a synthetic fixture (delete_device left an orphaned tail of lines
|
|
# behind). Filtering empty lines out entirely avoids it regardless of
|
|
# which optional pieces are present.
|
|
endpoint_lines = [
|
|
"type=endpoint",
|
|
"context=intercom",
|
|
transport,
|
|
"disallow=all",
|
|
"allow=opus",
|
|
"allow=ulaw",
|
|
"allow=alaw",
|
|
"allow=g722",
|
|
encryption,
|
|
"direct_media=no",
|
|
"rtp_symmetric=yes",
|
|
"force_rport=yes",
|
|
"rewrite_contact=yes",
|
|
]
|
|
if keepalive:
|
|
endpoint_lines.append(keepalive)
|
|
if ice:
|
|
endpoint_lines.append(ice)
|
|
endpoint_lines += [
|
|
"auth=%s" % extension,
|
|
"aors=%s" % extension,
|
|
'callerid="%s" <%s>' % (name, extension),
|
|
]
|
|
|
|
device_config = "\n; === Device: %s (%s) %s===\n[%s]\n%s\n\n[%s]\ntype=auth\nauth_type=userpass\nusername=%s\npassword=%s\n\n[%s]\ntype=aor\nmax_contacts=5\nremove_existing=yes\nqualify_frequency=30\n" % (
|
|
name, category, aa_tag, extension, "\n".join(endpoint_lines),
|
|
extension, extension, password, extension,
|
|
)
|
|
|
|
ok, err = ea_docker_write(EA_PJSIP_CONTAINER_PATH, current + device_config)
|
|
if not ok:
|
|
return False, err
|
|
ea_reload_pjsip()
|
|
ea_rebuild_dialplan()
|
|
return True, {
|
|
"extension": extension, "password": password, "name": name,
|
|
"transport": "tls" if conn_type == "fqdn" else "udp",
|
|
"port": 5061 if conn_type == "fqdn" else 5060,
|
|
}
|
|
|
|
|
|
def ea_delete_device(extension):
|
|
"""Same block-removal logic as the vendored delete_device()."""
|
|
path = _ea_pjsip_host_path()
|
|
if not path or not os.path.isfile(path):
|
|
return False, "Config file not found"
|
|
with open(path) as f:
|
|
lines = f.readlines()
|
|
|
|
new_lines = []
|
|
found = False
|
|
skip = False
|
|
pending_comment = None
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped.startswith("; === Device:"):
|
|
pending_comment = line
|
|
continue
|
|
if re.match(r"^\[%s\]$" % re.escape(extension), stripped):
|
|
if pending_comment:
|
|
found = True
|
|
skip = True
|
|
pending_comment = None
|
|
continue
|
|
elif found:
|
|
skip = True
|
|
continue
|
|
if pending_comment:
|
|
new_lines.append(pending_comment)
|
|
pending_comment = None
|
|
if skip and stripped == "":
|
|
skip = False
|
|
continue
|
|
if not skip:
|
|
new_lines.append(line)
|
|
|
|
if not found:
|
|
return False, "Device not found"
|
|
ok, err = ea_docker_write(EA_PJSIP_CONTAINER_PATH, "".join(new_lines))
|
|
if not ok:
|
|
return False, err
|
|
ea_reload_pjsip()
|
|
ea_rebuild_dialplan()
|
|
return True, "Device deleted"
|
|
|
|
|
|
def ea_rename_device(extension, new_name):
|
|
"""Same comment+callerid rewrite as the vendored rename_device()."""
|
|
path = _ea_pjsip_host_path()
|
|
if not path or not os.path.isfile(path):
|
|
return False, "Config file not found"
|
|
new_name = (new_name or "").strip()
|
|
if not new_name:
|
|
return False, "Name required"
|
|
with open(path) as f:
|
|
lines = f.readlines()
|
|
|
|
new_lines = []
|
|
found = False
|
|
in_device = False
|
|
pending_comment = None
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped.startswith("; === Device:"):
|
|
temp = stripped.split("; === Device:")[1].split("===")[0].strip()
|
|
aa_tag = ""
|
|
if "[AA:yes]" in temp:
|
|
aa_tag = " [AA:yes]"
|
|
temp = temp.replace("[AA:yes]", "").strip()
|
|
elif "[AA:no]" in temp:
|
|
aa_tag = " [AA:no]"
|
|
temp = temp.replace("[AA:no]", "").strip()
|
|
cat = temp[temp.rfind("(") + 1:temp.rfind(")")] if "(" in temp else "unknown"
|
|
pending_comment = (line, cat, aa_tag)
|
|
continue
|
|
if pending_comment:
|
|
m = re.match(r"^\[(\d+)\]$", stripped)
|
|
if m and m.group(1) == extension:
|
|
_old_line, cat, aa_tag = pending_comment
|
|
new_lines.append("; === Device: %s (%s)%s ===\n" % (new_name, cat, aa_tag))
|
|
new_lines.append(line)
|
|
found = True
|
|
in_device = True
|
|
pending_comment = None
|
|
continue
|
|
else:
|
|
new_lines.append(pending_comment[0])
|
|
pending_comment = None
|
|
if in_device and stripped.startswith("callerid="):
|
|
new_lines.append('callerid="%s" <%s>\n' % (new_name, extension))
|
|
continue
|
|
if in_device and stripped == "":
|
|
in_device = False
|
|
new_lines.append(line)
|
|
|
|
if not found:
|
|
return False, "Device not found"
|
|
ok, err = ea_docker_write(EA_PJSIP_CONTAINER_PATH, "".join(new_lines))
|
|
if not ok:
|
|
return False, err
|
|
ea_reload_pjsip()
|
|
ea_rebuild_dialplan()
|
|
return True, "Device renamed"
|
|
|
|
|
|
def ea_change_device_category(extension, new_category):
|
|
"""Same comment-line category rewrite as the vendored
|
|
change_device_category()."""
|
|
path = _ea_pjsip_host_path()
|
|
if not path or not os.path.isfile(path):
|
|
return False, "Config file not found"
|
|
new_category = (new_category or "").strip()
|
|
if not new_category:
|
|
return False, "Category required"
|
|
with open(path) as f:
|
|
lines = f.readlines()
|
|
|
|
new_lines = []
|
|
found = False
|
|
pending_comment = None
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped.startswith("; === Device:"):
|
|
pending_comment = (line, stripped)
|
|
continue
|
|
if pending_comment:
|
|
m = re.match(r"^\[(\d+)\]$", stripped)
|
|
if m and m.group(1) == extension:
|
|
cm = re.match(r"^; === Device: (.+?) \(([^)]+)\)(.*?)===", pending_comment[1])
|
|
if cm:
|
|
dev_name, _old_cat, rest = cm.group(1), cm.group(2), cm.group(3)
|
|
new_lines.append("; === Device: %s (%s)%s===\n" % (dev_name, new_category, rest))
|
|
found = True
|
|
else:
|
|
new_lines.append(pending_comment[0])
|
|
new_lines.append(line)
|
|
pending_comment = None
|
|
continue
|
|
else:
|
|
new_lines.append(pending_comment[0])
|
|
pending_comment = None
|
|
new_lines.append(line)
|
|
|
|
if not found:
|
|
return False, "Device not found"
|
|
ok, err = ea_docker_write(EA_PJSIP_CONTAINER_PATH, "".join(new_lines))
|
|
if not ok:
|
|
return False, err
|
|
ea_reload_pjsip()
|
|
ea_rebuild_dialplan()
|
|
return True, "Category changed"
|
|
|
|
|
|
def ea_list_categories():
|
|
path = _ea_categories_host_path()
|
|
categories = []
|
|
if not path or not os.path.isfile(path):
|
|
return categories
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line and not line.startswith("#"):
|
|
parts = line.split("|")
|
|
if len(parts) >= 3:
|
|
categories.append({"id": parts[0], "name": parts[1], "auto_answer": parts[2],
|
|
"description": parts[3] if len(parts) > 3 else ""})
|
|
return categories
|
|
|
|
|
|
def ea_create_category(cat_id, name, auto_answer="", description=""):
|
|
path = _ea_categories_host_path()
|
|
if not path:
|
|
return False, "No Asterisk install detected on this box"
|
|
cat_id = (cat_id or "").strip().lower()
|
|
name = (name or "").strip()
|
|
if not EA_CATID_RE.match(cat_id):
|
|
return False, "Category ID must be lowercase letters/digits only"
|
|
if not name:
|
|
return False, "Name required"
|
|
|
|
current = ""
|
|
if os.path.isfile(path):
|
|
with open(path) as f:
|
|
current = f.read()
|
|
else:
|
|
current = "# Format: id|name|auto_answer|description\n"
|
|
|
|
for line in current.splitlines():
|
|
line = line.strip()
|
|
if line and not line.startswith("#") and line.split("|")[0] == cat_id:
|
|
return False, "Category ID already exists"
|
|
|
|
if not current.endswith("\n"):
|
|
current += "\n"
|
|
new_content = current + "%s|%s|%s|%s\n" % (cat_id, name, auto_answer, description)
|
|
ok, err = ea_docker_write(EA_CATEGORIES_CONTAINER_PATH, new_content)
|
|
return (True, "Category created") if ok else (False, err)
|
|
|
|
|
|
def ea_delete_category(cat_id):
|
|
path = _ea_categories_host_path()
|
|
if not path or not os.path.isfile(path):
|
|
return False, "Categories file not found"
|
|
with open(path) as f:
|
|
lines = f.readlines()
|
|
new_lines = []
|
|
found = False
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped and not stripped.startswith("#") and stripped.split("|")[0] == cat_id:
|
|
found = True
|
|
continue
|
|
new_lines.append(line)
|
|
if not found:
|
|
return False, "Category not found"
|
|
ok, err = ea_docker_write(EA_CATEGORIES_CONTAINER_PATH, "".join(new_lines))
|
|
return (True, "Category deleted") if ok else (False, err)
|
|
|
|
|
|
def ea_rename_category(cat_id, new_name):
|
|
path = _ea_categories_host_path()
|
|
if not path or not os.path.isfile(path):
|
|
return False, "Categories file not found"
|
|
new_name = (new_name or "").strip()
|
|
if not new_name:
|
|
return False, "Name required"
|
|
with open(path) as f:
|
|
lines = f.readlines()
|
|
new_lines = []
|
|
found = False
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped and not stripped.startswith("#"):
|
|
parts = stripped.split("|")
|
|
if len(parts) >= 2 and parts[0] == cat_id:
|
|
parts[1] = new_name
|
|
new_lines.append("|".join(parts) + "\n")
|
|
found = True
|
|
continue
|
|
new_lines.append(line)
|
|
if not found:
|
|
return False, "Category not found"
|
|
ok, err = ea_docker_write(EA_CATEGORIES_CONTAINER_PATH, "".join(new_lines))
|
|
return (True, "Category renamed") if ok else (False, err)
|
|
|
|
|
|
def ea_list_rooms():
|
|
path = _ea_rooms_host_path()
|
|
rooms = []
|
|
if not path or not os.path.isfile(path):
|
|
return rooms
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line and not line.startswith("#"):
|
|
parts = line.split("|")
|
|
if len(parts) >= 5:
|
|
rooms.append({"extension": parts[0], "name": parts[1], "members": parts[2],
|
|
"timeout": parts[3], "type": parts[4]})
|
|
return rooms
|
|
|
|
|
|
def ea_create_room(extension, name, room_type="ring", timeout="60"):
|
|
path = _ea_rooms_host_path()
|
|
if not path:
|
|
return False, "No Asterisk install detected on this box"
|
|
extension = str(extension).strip()
|
|
name = (name or "").strip()
|
|
if not EA_EXT_RE.match(extension):
|
|
return False, "Invalid extension"
|
|
if not name:
|
|
return False, "Name required"
|
|
|
|
current = ""
|
|
if os.path.isfile(path):
|
|
with open(path) as f:
|
|
current = f.read()
|
|
else:
|
|
current = "# Format: ext|name|members|timeout|type(ring/page)\n"
|
|
|
|
for line in current.splitlines():
|
|
line = line.strip()
|
|
if line and not line.startswith("#") and line.split("|")[0] == extension:
|
|
return False, "Room extension already exists"
|
|
|
|
if not current.endswith("\n"):
|
|
current += "\n"
|
|
new_content = current + "%s|%s||%s|%s\n" % (extension, name, timeout, room_type)
|
|
ok, err = ea_docker_write(EA_ROOMS_CONTAINER_PATH, new_content)
|
|
if not ok:
|
|
return False, err
|
|
ea_rebuild_dialplan()
|
|
return True, "Room created"
|
|
|
|
|
|
def ea_delete_room(extension):
|
|
path = _ea_rooms_host_path()
|
|
if not path or not os.path.isfile(path):
|
|
return False, "Rooms file not found"
|
|
with open(path) as f:
|
|
lines = f.readlines()
|
|
new_lines = []
|
|
found = False
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped and not stripped.startswith("#") and stripped.split("|")[0] == extension:
|
|
found = True
|
|
continue
|
|
new_lines.append(line)
|
|
if not found:
|
|
return False, "Room not found"
|
|
ok, err = ea_docker_write(EA_ROOMS_CONTAINER_PATH, "".join(new_lines))
|
|
if not ok:
|
|
return False, err
|
|
ea_rebuild_dialplan()
|
|
return True, "Room deleted"
|
|
|
|
|
|
def ea_rename_room(extension, new_name):
|
|
path = _ea_rooms_host_path()
|
|
if not path or not os.path.isfile(path):
|
|
return False, "Rooms file not found"
|
|
new_name = (new_name or "").strip()
|
|
if not new_name:
|
|
return False, "Name required"
|
|
with open(path) as f:
|
|
lines = f.readlines()
|
|
new_lines = []
|
|
found = False
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped and not stripped.startswith("#"):
|
|
parts = stripped.split("|")
|
|
if len(parts) >= 5 and parts[0] == extension:
|
|
parts[1] = new_name
|
|
new_lines.append("|".join(parts) + "\n")
|
|
found = True
|
|
continue
|
|
new_lines.append(line)
|
|
if not found:
|
|
return False, "Room not found"
|
|
ok, err = ea_docker_write(EA_ROOMS_CONTAINER_PATH, "".join(new_lines))
|
|
if not ok:
|
|
return False, err
|
|
ea_rebuild_dialplan()
|
|
return True, "Room renamed"
|
|
|
|
|
|
def _ea_update_room_members(extension, new_members):
|
|
path = _ea_rooms_host_path()
|
|
if not path or not os.path.isfile(path):
|
|
return False, "Rooms file not found"
|
|
with open(path) as f:
|
|
lines = f.readlines()
|
|
new_lines = []
|
|
found = False
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if stripped and not stripped.startswith("#"):
|
|
parts = stripped.split("|")
|
|
if len(parts) >= 5 and parts[0] == extension:
|
|
parts[2] = new_members
|
|
new_lines.append("|".join(parts) + "\n")
|
|
found = True
|
|
continue
|
|
new_lines.append(line)
|
|
if not found:
|
|
return False, "Room not found"
|
|
ok, err = ea_docker_write(EA_ROOMS_CONTAINER_PATH, "".join(new_lines))
|
|
if not ok:
|
|
return False, err
|
|
ea_rebuild_dialplan()
|
|
return True, "Room members updated"
|
|
|
|
|
|
def ea_add_room_member(room_ext, device_ext):
|
|
for room in ea_list_rooms():
|
|
if room["extension"] == room_ext:
|
|
members = [m for m in room["members"].split(",") if m]
|
|
if device_ext in members:
|
|
return False, "Device already in room"
|
|
members.append(device_ext)
|
|
return _ea_update_room_members(room_ext, ",".join(members))
|
|
return False, "Room not found"
|
|
|
|
|
|
def ea_remove_room_member(room_ext, device_ext):
|
|
for room in ea_list_rooms():
|
|
if room["extension"] == room_ext:
|
|
members = [m for m in room["members"].split(",") if m]
|
|
if device_ext not in members:
|
|
return False, "Device not in room"
|
|
members.remove(device_ext)
|
|
return _ea_update_room_members(room_ext, ",".join(members))
|
|
return False, "Room not found"
|
|
|
|
|
|
INDEX_HTML = """<!doctype html>
|
|
<html><head><meta charset="utf-8">
|
|
<title>Security Dashboard</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
body { font-family: system-ui, sans-serif; margin: 0; background: #0f1115; color: #e6e6e6; }
|
|
header { padding: 1rem 1.5rem; background: #171a21; border-bottom: 1px solid #2a2e38; display: flex; align-items: center; gap: 1rem; }
|
|
header h1 { font-size: 1.1rem; margin: 0; flex: 1; }
|
|
nav button { background: none; border: none; color: #9aa4b2; padding: 0.6rem 1rem; cursor: pointer; font-size: 0.95rem; border-bottom: 2px solid transparent; }
|
|
nav button.active { color: #fff; border-bottom-color: #4f8cff; }
|
|
main { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
|
th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #23262f; }
|
|
th { color: #9aa4b2; font-weight: 600; }
|
|
th.sortable { cursor: pointer; user-select: none; }
|
|
th.sortable:hover { color: #e6e6e6; }
|
|
th.sortable .arrow { opacity: 0.5; font-size: 0.75em; margin-left: 0.25em; }
|
|
.chip-row { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; }
|
|
.chip-row label { white-space: nowrap; font-size: 0.85rem; color: #9aa4b2; }
|
|
.sev-Error { color: #ff6b6b; }
|
|
.sev-Warning { color: #f5b342; }
|
|
.sev-Informational { color: #7fbf7f; }
|
|
button.action { background: #2a2e38; color: #e6e6e6; border: 1px solid #3a3f4b; border-radius: 4px; padding: 0.3rem 0.7rem; cursor: pointer; }
|
|
button.action:hover { background: #3a3f4b; }
|
|
.card { background: #171a21; border: 1px solid #2a2e38; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
|
|
input[type=text] { background: #0f1115; border: 1px solid #3a3f4b; color: #e6e6e6; padding: 0.4rem 0.6rem; border-radius: 4px; width: 100%; box-sizing: border-box; }
|
|
.row { display: flex; gap: 0.5rem; align-items: center; }
|
|
.muted { color: #9aa4b2; font-size: 0.85rem; }
|
|
a { color: #4f8cff; }
|
|
#msg { margin-top: 0.5rem; font-size: 0.85rem; }
|
|
/* Capability gating for the Extensions tab. Everything that needs the Easy
|
|
Asterisk container (device/category/room writes) is .ea-only; everything
|
|
that needs a PSTN trunk dialplan is .pstn-only. Both classes start ON the
|
|
body so nothing flashes before /api/ea-status and /api/pstn-status answer,
|
|
and they're removed once those confirm. Marking cells rather than juggling
|
|
column indices keeps the one extensions table honest as columns come and
|
|
go. */
|
|
body.no-ea .ea-only { display: none !important; }
|
|
body.no-pstn .pstn-only { display: none !important; }
|
|
</style>
|
|
</head>
|
|
<body class="no-ea no-pstn">
|
|
<header>
|
|
<h1>Security Dashboard</h1>
|
|
<nav>
|
|
<button class="tab-btn active" data-tab="security">Security Log</button>
|
|
<button class="tab-btn" data-tab="extensions">Extensions</button>
|
|
<button class="tab-btn" id="crowdsec-tab-btn" data-tab="crowdsec" style="display:none">CrowdSec</button>
|
|
</nav>
|
|
</header>
|
|
<main>
|
|
<div id="tab-security">
|
|
<div class="card">
|
|
<p class="muted">Recent Asterisk SIP security events, newest first. Errors/warnings are real auth failures; informational lines are normal registration traffic.</p>
|
|
<table id="sec-table"><thead><tr>
|
|
<th class="sortable" data-sort="timestamp">Time</th>
|
|
<th class="sortable" data-sort="event">Event</th>
|
|
<th class="sortable" data-sort="account">Account</th>
|
|
<th class="sortable" data-sort="remote">Remote</th>
|
|
<th class="sortable" data-sort="severity">Severity</th>
|
|
</tr></thead><tbody></tbody></table>
|
|
</div>
|
|
</div>
|
|
<div id="tab-crowdsec" style="display:none">
|
|
<div class="card">
|
|
<h3 style="margin-top:0">Active bans</h3>
|
|
<table id="dec-table"><thead><tr>
|
|
<th class="sortable" data-sort="value">IP/Range</th>
|
|
<th class="sortable" data-sort="scenario">Scenario</th>
|
|
<th class="sortable" data-sort="carrier">Network / Carrier</th>
|
|
<th class="sortable" data-sort="country">Country</th>
|
|
<th class="sortable" data-sort="duration">Duration</th>
|
|
<th class="sortable" data-sort="origin">Origin</th>
|
|
<th></th>
|
|
</tr></thead><tbody></tbody></table>
|
|
</div>
|
|
<div class="card">
|
|
<h3 style="margin-top:0">Asterisk brute-force ASN exemptions</h3>
|
|
<p class="muted">Carrier ASNs exempted from the Asterisk brute-force scenarios only — SSH/web/geo protection is unaffected. See CLAUDE.md / services/crowdsec.sh for background.</p>
|
|
<div class="row">
|
|
<input type="text" id="asn-input" placeholder="e.g. 21928, 14593">
|
|
<button class="action" id="asn-save">Save</button>
|
|
</div>
|
|
<table id="asn-table" style="margin-top:0.75rem"><thead><tr><th>ASN</th><th>Carrier</th><th></th></tr></thead><tbody></tbody></table>
|
|
<div id="msg"></div>
|
|
</div>
|
|
</div>
|
|
<div id="tab-extensions" style="display:none">
|
|
<div class="card pstn-only" id="pstn-restart-banner" style="display:none; border-left:4px solid #d9822b">
|
|
<b>Unsaved changes may not be live yet.</b>
|
|
<p class="muted" style="margin:0.25rem 0 0.5rem">
|
|
Edits here are written to disk immediately, but Asterisk doesn't always pick them up without a restart — confirmed on personal-DID group reassignment specifically. Existing calls are never affected.
|
|
</p>
|
|
<button class="action" id="pstn-restart-btn">Commit Changes (Restart Asterisk)</button>
|
|
<span id="pstn-restart-msg" class="muted" style="margin-left:0.5rem"></span>
|
|
</div>
|
|
<div class="card">
|
|
<h3 style="margin-top:0">Extensions</h3>
|
|
<p class="muted">
|
|
Every SIP extension on this box, one row each. Adding one generates a random password and reloads PJSIP + rebuilds the dialplan automatically — the password is shown once below the table, save it before it scrolls away.
|
|
</p>
|
|
<p class="muted">
|
|
<b>Tier</b> — <b>internal</b>: no PSTN, can still call/receive other extensions and internal ring groups. <b>restricted</b>: internal, plus only the pre-approved US numbers in the next column. <b>full</b>: internal, plus any US number. Changes are usually live on the next call; if one doesn't seem to be taking 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 for how, and its caveat on the sender-extraction logic still needing real-traffic confirmation. If this box predates that wiring, rerun <code>sudo ./setup.sh asterisk</code> to pick it up.
|
|
</p>
|
|
<div class="row ea-only" style="flex-wrap:wrap">
|
|
<input type="text" id="ea-dev-name" placeholder="Name, e.g. Front Desk" style="width:10rem">
|
|
<input type="text" id="ea-dev-ext" placeholder="Extension, e.g. 202" style="width:8rem">
|
|
<select id="ea-dev-category"></select>
|
|
<select id="ea-dev-conn">
|
|
<option value="lan">LAN (UDP)</option>
|
|
<option value="fqdn">Remote/FQDN (TLS)</option>
|
|
</select>
|
|
<select id="ea-dev-aa">
|
|
<option value="">Auto-answer: category default</option>
|
|
<option value="yes">Auto-answer: yes</option>
|
|
<option value="no">Auto-answer: no</option>
|
|
</select>
|
|
<button class="action" id="ea-dev-save">Add extension</button>
|
|
</div>
|
|
<table id="ext-table" style="margin-top:0.75rem"><thead><tr>
|
|
<th class="sortable" data-sort="ext">Ext</th>
|
|
<th class="sortable" data-sort="name">Name</th>
|
|
<th class="ea-only">Category</th>
|
|
<th class="sortable ea-only" data-sort="status">Status</th>
|
|
<th class="ea-only">Transport</th>
|
|
<th class="sortable pstn-only" data-sort="tier">Tier</th>
|
|
<th class="pstn-only">Approved numbers (restricted only)</th>
|
|
<th class="sortable" data-sort="messaging">Messaging</th>
|
|
<th></th>
|
|
</tr></thead><tbody></tbody></table>
|
|
<div id="ext-msg" class="muted" style="margin-top:0.5rem"></div>
|
|
</div>
|
|
<div class="card ea-only">
|
|
<h3 style="margin-top:0">Categories</h3>
|
|
<p class="muted">Device profiles — an auto-answer default and a description, assignable to any extension above.</p>
|
|
<div class="row" style="flex-wrap:wrap">
|
|
<input type="text" id="ea-cat-id" placeholder="ID, e.g. desk (lowercase, no spaces)" style="width:12rem">
|
|
<input type="text" id="ea-cat-name" placeholder="Display name" style="width:10rem">
|
|
<select id="ea-cat-aa">
|
|
<option value="">Auto-answer default: no</option>
|
|
<option value="yes">Auto-answer default: yes</option>
|
|
</select>
|
|
<input type="text" id="ea-cat-desc" placeholder="Description (optional)" style="width:12rem">
|
|
<button class="action" id="ea-cat-save">Add category</button>
|
|
</div>
|
|
<table id="ea-cat-table" style="margin-top:0.75rem"><thead><tr>
|
|
<th class="sortable" data-sort="id">ID</th>
|
|
<th class="sortable" data-sort="name">Name</th>
|
|
<th>Auto-answer</th>
|
|
<th>Description</th>
|
|
<th></th>
|
|
</tr></thead><tbody></tbody></table>
|
|
<div id="ea-cat-msg" class="muted" style="margin-top:0.5rem"></div>
|
|
</div>
|
|
<div class="card ea-only">
|
|
<h3 style="margin-top:0">Rooms (ring groups)</h3>
|
|
<p class="muted">A shared extension that rings (or pages) every member device at once.</p>
|
|
<div class="row" style="flex-wrap:wrap">
|
|
<input type="text" id="ea-room-ext" placeholder="Extension, e.g. 500" style="width:8rem">
|
|
<input type="text" id="ea-room-name" placeholder="Name, e.g. All Ring" style="width:10rem">
|
|
<select id="ea-room-type">
|
|
<option value="ring">Ring (simultaneous)</option>
|
|
<option value="page">Page (intercom)</option>
|
|
</select>
|
|
<input type="text" id="ea-room-timeout" placeholder="Timeout (s)" value="60" style="width:6rem">
|
|
<button class="action" id="ea-room-save">Add room</button>
|
|
</div>
|
|
<table id="ea-room-table" style="margin-top:0.75rem"><thead><tr>
|
|
<th class="sortable" data-sort="extension">Ext</th>
|
|
<th class="sortable" data-sort="name">Name</th>
|
|
<th>Members</th>
|
|
<th>Timeout</th>
|
|
<th>Type</th>
|
|
<th></th>
|
|
</tr></thead><tbody></tbody></table>
|
|
<div id="ea-room-msg" class="muted" style="margin-top:0.5rem"></div>
|
|
</div>
|
|
<div class="card">
|
|
<h3 style="margin-top:0">Groups</h3>
|
|
<p class="muted">
|
|
Named sets of extensions for bulk actions — e.g. enable messaging for everyone in "Sales" at once. A management convenience only: applying an action writes the same per-extension setting each member's own Messaging checkbox above would, one time — it isn't a runtime concept the dialplan knows about, and membership changes never retroactively affect anything already applied. A group can also own a personal number below, which <i>is</i> evaluated live against current membership on every call.
|
|
</p>
|
|
<div class="row">
|
|
<input type="text" id="grp-name" placeholder="Group name, e.g. Sales" style="width:12rem">
|
|
<button class="action" id="grp-save">Save group</button>
|
|
</div>
|
|
<div id="grp-members" class="row" style="flex-wrap:wrap;margin-top:0.5rem"></div>
|
|
<table id="grp-table" style="margin-top:0.75rem"><thead><tr>
|
|
<th class="sortable" data-sort="name">Group</th>
|
|
<th class="sortable" data-sort="members">Members</th>
|
|
<th></th>
|
|
</tr></thead><tbody></tbody></table>
|
|
<div id="grp-msg" class="muted" style="margin-top:0.5rem"></div>
|
|
</div>
|
|
<div class="card pstn-only">
|
|
<h3 style="margin-top:0">Concurrent-call caps</h3>
|
|
<p class="muted">A call over either cap gets a busy signal (and an ntfy alert, if enabled) — existing calls are never affected. Changes are usually live on the next call; if a call doesn't reflect a recent change, use "Commit Changes" at the top.</p>
|
|
<div class="row">
|
|
<label class="muted" style="white-space:nowrap">Max outbound<br><input type="text" id="limit-out" style="width:5rem"></label>
|
|
<label class="muted" style="white-space:nowrap">Max inbound<br><input type="text" id="limit-in" style="width:5rem"></label>
|
|
<button class="action" id="limits-save" style="align-self:flex-end">Save</button>
|
|
</div>
|
|
<div id="limits-msg" class="muted" style="margin-top:0.5rem"></div>
|
|
</div>
|
|
<div class="card pstn-only">
|
|
<h3 style="margin-top:0">Personal numbers</h3>
|
|
<p class="muted">
|
|
Multiple DIDs can share this one trunk. Assigning a DID to an extension routes inbound calls to that DID straight to its owner (still gated by the owner's own tier/approved-numbers above — no ring-group fallback), and makes that extension's outbound calls show this DID as Caller-ID instead of the shared trunk DID. You can also assign a DID to a <b>group</b> instead of a single extension — every current member whose own tier/approved-numbers authorize the caller rings, checked fresh against the group's current membership on every call; a group has no single extension to hang the outbound Caller-ID override on, so that part only applies to single-extension assignments. The shared DID/ring-group keeps working regardless. Reassigning a DID's owner has been confirmed to sometimes need "Commit Changes" (at the top) before Asterisk actually uses the new owner.
|
|
</p>
|
|
<div class="row">
|
|
<input type="text" id="pd-did" placeholder="DID, e.g. 5551234567 (10 digits, no leading 1)" style="width:12rem">
|
|
<select id="pd-owner"></select>
|
|
<button class="action" id="pd-save">Assign</button>
|
|
</div>
|
|
<table id="pd-table" style="margin-top:0.75rem"><thead><tr>
|
|
<th class="sortable" data-sort="did">DID</th>
|
|
<th class="sortable" data-sort="owner">Owner</th>
|
|
<th></th>
|
|
</tr></thead><tbody></tbody></table>
|
|
<div id="pd-msg" class="muted" style="margin-top:0.5rem"></div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
<script>
|
|
function esc(s) { return (s || "").replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
|
|
|
const TABS = ["security", "extensions", "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();
|
|
});
|
|
});
|
|
|
|
// What this box can actually do, resolved once per page load. Everything the
|
|
// Extensions tab shows is gated on these two rather than on separate nav
|
|
// buttons: the extension list itself only needs pjsip.conf, so it's always
|
|
// worth showing, while device/category/room editing needs a reachable Easy
|
|
// Asterisk container and tier/DID editing needs a PSTN trunk dialplan. One
|
|
// tab that grows columns and cards as those appear beats three tabs that
|
|
// each list the same extensions from a different angle.
|
|
let eaInstalled = false, pstnInstalled = false;
|
|
|
|
let lastSecurityEvents = [];
|
|
let secSort = { key: null, dir: 1 };
|
|
|
|
function renderSecurity() {
|
|
let rows = lastSecurityEvents.slice();
|
|
if (secSort.key) {
|
|
rows.sort((a, b) => {
|
|
const av = (a[secSort.key] || "").toLowerCase(), bv = (b[secSort.key] || "").toLowerCase();
|
|
if (av < bv) return -1 * secSort.dir;
|
|
if (av > bv) return 1 * secSort.dir;
|
|
return 0;
|
|
});
|
|
}
|
|
document.querySelectorAll("#sec-table th.sortable .arrow").forEach(a => a.remove());
|
|
if (secSort.key) {
|
|
const th = document.querySelector(`#sec-table th[data-sort="${secSort.key}"]`);
|
|
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${secSort.dir === 1 ? "▲" : "▼"}</span>`);
|
|
}
|
|
const tbody = document.querySelector("#sec-table tbody");
|
|
tbody.innerHTML = rows.map(e => `<tr>
|
|
<td>${esc(e.timestamp)}</td>
|
|
<td>${esc(e.event)}</td>
|
|
<td>${esc(e.account)}</td>
|
|
<td>${esc(e.remote)}</td>
|
|
<td class="sev-${esc(e.severity)}">${esc(e.severity)}</td>
|
|
</tr>`).join("") || `<tr><td colspan=5 class=muted>No events found.</td></tr>`;
|
|
}
|
|
|
|
document.querySelectorAll("#sec-table th.sortable").forEach(th => {
|
|
th.addEventListener("click", () => {
|
|
const key = th.dataset.sort;
|
|
secSort.dir = (secSort.key === key) ? -secSort.dir : 1;
|
|
secSort.key = key;
|
|
renderSecurity();
|
|
});
|
|
});
|
|
|
|
async function loadSecurity() {
|
|
const res = await fetch("/api/security-events");
|
|
lastSecurityEvents = await res.json();
|
|
renderSecurity();
|
|
}
|
|
|
|
let lastDecisions = [];
|
|
let decSort = { key: null, dir: 1 };
|
|
|
|
// Go-style duration strings ("3h59m59.62s", "-1" for permanent) don't sort
|
|
// correctly as text, so parse to seconds for the Duration column; permanent
|
|
// bans (-1 or unparseable) sort as Infinity, i.e. last in ascending order.
|
|
function durationSeconds(s) {
|
|
if (!s || s === "-1") return Infinity;
|
|
const m = String(s).match(/^(-?\d+h)?(\d+m)?(\d+(?:\.\d+)?s)?$/);
|
|
if (!m || !(m[1] || m[2] || m[3])) return Infinity;
|
|
const h = parseFloat(m[1]) || 0, mi = parseFloat(m[2]) || 0, se = parseFloat(m[3]) || 0;
|
|
return h * 3600 + mi * 60 + se;
|
|
}
|
|
|
|
function decSortValue(d, key) {
|
|
switch (key) {
|
|
case "carrier": return (d.as_name || d.as_number || "").toLowerCase();
|
|
case "duration": return durationSeconds(d.duration);
|
|
default: return (d[key] || "").toString().toLowerCase();
|
|
}
|
|
}
|
|
|
|
function renderDecisions() {
|
|
let rows = lastDecisions.slice();
|
|
if (decSort.key) {
|
|
rows.sort((a, b) => {
|
|
const av = decSortValue(a, decSort.key), bv = decSortValue(b, decSort.key);
|
|
if (av < bv) return -1 * decSort.dir;
|
|
if (av > bv) return 1 * decSort.dir;
|
|
return 0;
|
|
});
|
|
}
|
|
document.querySelectorAll("#dec-table th.sortable .arrow").forEach(a => a.remove());
|
|
if (decSort.key) {
|
|
const th = document.querySelector(`#dec-table th[data-sort="${decSort.key}"]`);
|
|
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${decSort.dir === 1 ? "▲" : "▼"}</span>`);
|
|
}
|
|
const tbody = document.querySelector("#dec-table tbody");
|
|
tbody.innerHTML = rows.map(d => `<tr>
|
|
<td>${esc(d.value)}</td>
|
|
<td>${esc(d.scenario)}</td>
|
|
<td>${d.as_number ? esc(d.as_number) + (d.as_name ? " — " + esc(d.as_name) : "") : ""}</td>
|
|
<td>${esc(d.country)}</td>
|
|
<td>${esc(d.duration)}</td>
|
|
<td>${esc(d.origin)}</td>
|
|
<td>
|
|
<button class="action" onclick="unban(${d.id})">Unban</button>
|
|
${d.as_number ? `<button class="action" onclick="exemptAsn('${esc(d.as_number)}')">Exempt ASN</button>` : ""}
|
|
</td>
|
|
</tr>`).join("") || "<tr><td colspan=7 class=muted>No active bans.</td></tr>";
|
|
}
|
|
|
|
document.querySelectorAll("#dec-table th.sortable").forEach(th => {
|
|
th.addEventListener("click", () => {
|
|
const key = th.dataset.sort;
|
|
decSort.dir = (decSort.key === key) ? -decSort.dir : 1;
|
|
decSort.key = key;
|
|
renderDecisions();
|
|
});
|
|
});
|
|
|
|
async function loadDecisions() {
|
|
const res = await fetch("/api/decisions");
|
|
lastDecisions = await res.json();
|
|
renderDecisions();
|
|
}
|
|
|
|
async function unban(id) {
|
|
if (!confirm("Unban decision #" + id + "?")) return;
|
|
const res = await fetch("/api/decisions/delete", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({id: id})});
|
|
const data = await res.json();
|
|
alert(data.message || (data.ok ? "Unbanned" : "Failed"));
|
|
loadDecisions();
|
|
}
|
|
|
|
async function exemptAsn(asn) {
|
|
const current = document.getElementById("asn-input").value.split(",").map(s => s.trim()).filter(Boolean);
|
|
if (current.includes(asn)) { alert("ASN " + asn + " is already exempt."); return; }
|
|
if (!confirm("Add ASN " + asn + " to the Asterisk brute-force exemption list? This only affects Asterisk auth-failure detection — SSH/web/geo protection is unaffected.")) return;
|
|
current.push(asn);
|
|
document.getElementById("asn-input").value = current.join(", ");
|
|
document.getElementById("asn-save").click();
|
|
}
|
|
|
|
async function loadAsnExempt() {
|
|
const res = await fetch("/api/asn-exempt");
|
|
const data = await res.json();
|
|
const asns = data.asns || [];
|
|
document.getElementById("asn-input").value = asns.map(a => a.asn).join(", ");
|
|
const tbody = document.querySelector("#asn-table tbody");
|
|
tbody.innerHTML = asns.map(a => `<tr>
|
|
<td>${esc(a.asn)}</td>
|
|
<td>${esc(a.name) || '<span class="muted">(unknown)</span>'}</td>
|
|
<td>
|
|
<button class="action" onclick="unexemptAsn('${esc(a.asn)}')">Unwhitelist</button>
|
|
<button class="action" onclick="banAsn('${esc(a.asn)}')">Unwhitelist + Ban</button>
|
|
</td>
|
|
</tr>`).join("") || "<tr><td colspan=3 class=muted>No ASNs currently exempted.</td></tr>";
|
|
}
|
|
|
|
document.getElementById("asn-save").addEventListener("click", async () => {
|
|
const raw = document.getElementById("asn-input").value;
|
|
const asns = raw.split(",").map(s => s.trim()).filter(Boolean);
|
|
const res = await fetch("/api/asn-exempt", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({asns: asns})});
|
|
const data = await res.json();
|
|
document.getElementById("msg").textContent = data.message || (data.ok ? "Saved" : "Failed");
|
|
loadAsnExempt();
|
|
});
|
|
|
|
async function unexemptAsn(asn) {
|
|
if (!confirm("Remove ASN " + asn + " from the exemption list? Future Asterisk auth failures from it will be evaluated normally again (no immediate ban of past offenders).")) return;
|
|
const current = (document.getElementById("asn-input").value || "").split(",").map(s => s.trim()).filter(s => s && s !== asn);
|
|
const res = await fetch("/api/asn-exempt", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({asns: current})});
|
|
const data = await res.json();
|
|
document.getElementById("msg").textContent = data.message || (data.ok ? "Saved" : "Failed");
|
|
loadAsnExempt();
|
|
}
|
|
|
|
async function banAsn(asn) {
|
|
if (!confirm("Remove ASN " + asn + " from the exemption list AND immediately ban (24h) every IP CrowdSec has ever recorded for it? Use this for an accidental whitelist.")) return;
|
|
const res = await fetch("/api/asn-exempt/ban", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({asn: asn})});
|
|
const data = await res.json();
|
|
const parts = [data.unexempt_message || (data.ok ? "Unwhitelisted" : "Unwhitelist failed")];
|
|
if (data.banned_ips && data.banned_ips.length) parts.push("Banned: " + data.banned_ips.join(", "));
|
|
if (data.failed_ips && data.failed_ips.length) parts.push("Failed to ban: " + data.failed_ips.join(", "));
|
|
if (!data.banned_ips || !data.banned_ips.length) parts.push("No previously-recorded IPs found for this ASN to ban.");
|
|
document.getElementById("msg").textContent = parts.join(" — ");
|
|
loadAsnExempt();
|
|
loadDecisions();
|
|
}
|
|
|
|
// CrowdSec is the one thing still worth its own tab — it's about banned IPs,
|
|
// not the phone system — so its nav button is still gated the old way.
|
|
async function loadCrowdsecStatus() {
|
|
const res = await fetch("/api/crowdsec-status");
|
|
const data = await res.json();
|
|
document.getElementById("crowdsec-tab-btn").style.display = data.installed ? "" : "none";
|
|
}
|
|
|
|
// ── Extensions tab (extensions + categories + rooms + groups + trunk) ──────
|
|
let eaDevices = [], eaCategories = [], eaRooms = [], eaStatusMap = {};
|
|
let eaCatSort = { key: null, dir: 1 };
|
|
let eaRoomSort = { key: null, dir: 1 };
|
|
|
|
async function initExtensionsTab() {
|
|
const [ea, pstn] = await Promise.all([
|
|
fetch("/api/ea-status").then(r => r.json()),
|
|
fetch("/api/pstn-status").then(r => r.json()),
|
|
]);
|
|
eaInstalled = !!ea.installed;
|
|
pstnInstalled = !!pstn.installed;
|
|
document.body.classList.toggle("no-ea", !eaInstalled);
|
|
document.body.classList.toggle("no-pstn", !pstnInstalled);
|
|
await refreshExtensionsTab();
|
|
}
|
|
|
|
// Sequenced (not parallel) — extension rows render a category <select> that
|
|
// needs eaCategories already populated, room rows render a member-add picker
|
|
// that needs eaDevices (populated by loadExtensions), and the personal-DID
|
|
// owner picker needs both extensions and groups.
|
|
async function refreshExtensionsTab() {
|
|
if (eaInstalled) await loadEaCategories();
|
|
await loadExtensions();
|
|
if (eaInstalled) await loadEaRooms();
|
|
await loadGroups();
|
|
if (pstnInstalled) { await loadPstnLimits(); await loadPersonalDids(); }
|
|
}
|
|
|
|
function renderEaDeviceCategoryOptions() {
|
|
const sel = document.getElementById("ea-dev-category");
|
|
sel.innerHTML = eaCategories.map(c => `<option value="${esc(c.id)}">${esc(c.name)}</option>`).join("")
|
|
|| '<option value="">No categories yet — add one below first</option>';
|
|
}
|
|
|
|
async function loadEaCategories() {
|
|
const res = await fetch("/api/ea-categories");
|
|
const data = await res.json();
|
|
eaCategories = data.categories || [];
|
|
renderEaCategories();
|
|
renderEaDeviceCategoryOptions();
|
|
if (extRows.length) renderExtensions();
|
|
}
|
|
|
|
function eaCatSortValue(c, key) { return (c[key] || "").toString().toLowerCase(); }
|
|
|
|
function renderEaCategories() {
|
|
let rows = eaCategories.slice();
|
|
if (eaCatSort.key) {
|
|
rows.sort((a, b) => {
|
|
const av = eaCatSortValue(a, eaCatSort.key), bv = eaCatSortValue(b, eaCatSort.key);
|
|
if (av < bv) return -1 * eaCatSort.dir;
|
|
if (av > bv) return 1 * eaCatSort.dir;
|
|
return 0;
|
|
});
|
|
}
|
|
document.querySelectorAll("#ea-cat-table th.sortable .arrow").forEach(a => a.remove());
|
|
if (eaCatSort.key) {
|
|
const th = document.querySelector(`#ea-cat-table th[data-sort="${eaCatSort.key}"]`);
|
|
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${eaCatSort.dir === 1 ? "▲" : "▼"}</span>`);
|
|
}
|
|
const tbody = document.querySelector("#ea-cat-table tbody");
|
|
tbody.innerHTML = rows.map(c => `<tr data-id="${esc(c.id)}">
|
|
<td>${esc(c.id)}</td>
|
|
<td>${esc(c.name)}</td>
|
|
<td>${c.auto_answer === "yes" ? "yes" : (c.auto_answer === "no" ? "no" : "—")}</td>
|
|
<td>${esc(c.description)}</td>
|
|
<td>
|
|
<button class="action" onclick="renameEaCategory('${esc(c.id)}')">Rename</button>
|
|
<button class="action" onclick="deleteEaCategory('${esc(c.id)}')">Delete</button>
|
|
</td>
|
|
</tr>`).join("") || '<tr><td colspan=5 class=muted>No categories yet.</td></tr>';
|
|
}
|
|
|
|
document.querySelectorAll("#ea-cat-table th.sortable").forEach(th => {
|
|
th.addEventListener("click", () => {
|
|
const key = th.dataset.sort;
|
|
eaCatSort.dir = (eaCatSort.key === key) ? -eaCatSort.dir : 1;
|
|
eaCatSort.key = key;
|
|
renderEaCategories();
|
|
});
|
|
});
|
|
|
|
document.getElementById("ea-cat-save").addEventListener("click", async () => {
|
|
const id = document.getElementById("ea-cat-id").value.trim();
|
|
const name = document.getElementById("ea-cat-name").value.trim();
|
|
const auto_answer = document.getElementById("ea-cat-aa").value;
|
|
const description = document.getElementById("ea-cat-desc").value.trim();
|
|
const res = await fetch("/api/ea-categories", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({id, name, auto_answer, description}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("ea-cat-msg").textContent = data.message || (data.ok ? "Saved" : "Failed");
|
|
if (data.ok) {
|
|
document.getElementById("ea-cat-id").value = "";
|
|
document.getElementById("ea-cat-name").value = "";
|
|
document.getElementById("ea-cat-desc").value = "";
|
|
}
|
|
loadEaCategories();
|
|
});
|
|
|
|
async function renameEaCategory(id) {
|
|
const cur = eaCategories.find(c => c.id === id);
|
|
const name = prompt("New name for category " + id + ":", cur ? cur.name : "");
|
|
if (name === null || !name.trim()) return;
|
|
const res = await fetch("/api/ea-categories/rename", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({id, name: name.trim()}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("ea-cat-msg").textContent = data.message || (data.ok ? "Renamed" : "Failed");
|
|
loadEaCategories();
|
|
}
|
|
|
|
async function deleteEaCategory(id) {
|
|
if (!confirm("Delete category " + id + "? Devices already using it keep their current setting, but it won't be selectable for new ones.")) return;
|
|
const res = await fetch("/api/ea-categories/delete", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({id}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("ea-cat-msg").textContent = data.message || (data.ok ? "Deleted" : "Failed");
|
|
loadEaCategories();
|
|
}
|
|
|
|
// One row per extension, merged from two sources that used to drive two
|
|
// separate tables (plus a third card of checkboxes): pjsip.conf via
|
|
// /api/pstn-permissions — which always works, with or without the Easy
|
|
// Asterisk container — and /api/ea-devices for category/status/transport.
|
|
// Keyed by extension number, so an extension known to only one of them still
|
|
// gets a row rather than silently vanishing.
|
|
async function loadExtensions() {
|
|
const permData = await fetch("/api/pstn-permissions").then(r => r.json());
|
|
const byExt = new Map();
|
|
(permData.extensions || []).forEach(e => byExt.set(e.ext, {
|
|
ext: e.ext, name: e.name, tier: e.tier, allowed_numbers: e.allowed_numbers,
|
|
messaging: e.messaging, ea: false, category: "", status: "", transport: "", encryption: "",
|
|
}));
|
|
|
|
if (eaInstalled) {
|
|
const devData = await fetch("/api/ea-devices").then(r => r.json());
|
|
eaDevices = devData.devices || [];
|
|
eaStatusMap = devData.status || {};
|
|
eaDevices.forEach(d => {
|
|
const row = byExt.get(d.extension) || {
|
|
ext: d.extension, name: d.name, tier: "internal", allowed_numbers: "", messaging: false,
|
|
};
|
|
row.ea = true;
|
|
row.name = row.name || d.name;
|
|
row.category = d.category;
|
|
row.transport = d.transport;
|
|
row.encryption = d.encryption;
|
|
row.status = eaStatusMap[d.extension] || "unknown";
|
|
byExt.set(d.extension, row);
|
|
});
|
|
}
|
|
|
|
extRows = Array.from(byExt.values());
|
|
renderExtensions();
|
|
renderGroupMemberPickers();
|
|
renderPersonalDidOwnerOptions();
|
|
}
|
|
|
|
let extRows = [];
|
|
let extSort = { key: null, dir: 1 };
|
|
|
|
function extSortValue(e, key) {
|
|
if (key === "ext") return parseInt(e.ext, 10);
|
|
if (key === "messaging") return e.messaging ? 1 : 0;
|
|
return (e[key] || "").toString().toLowerCase();
|
|
}
|
|
|
|
function renderExtensions() {
|
|
const tbody = document.querySelector("#ext-table tbody");
|
|
if (!extRows.length) {
|
|
tbody.innerHTML = '<tr><td colspan=9 class=muted>No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).</td></tr>';
|
|
return;
|
|
}
|
|
let rows = extRows.slice();
|
|
if (extSort.key) {
|
|
rows.sort((a, b) => {
|
|
const av = extSortValue(a, extSort.key), bv = extSortValue(b, extSort.key);
|
|
if (av < bv) return -1 * extSort.dir;
|
|
if (av > bv) return 1 * extSort.dir;
|
|
return 0;
|
|
});
|
|
}
|
|
document.querySelectorAll("#ext-table th.sortable .arrow").forEach(a => a.remove());
|
|
if (extSort.key) {
|
|
const th = document.querySelector(`#ext-table th[data-sort="${extSort.key}"]`);
|
|
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${extSort.dir === 1 ? "▲" : "▼"}</span>`);
|
|
}
|
|
|
|
tbody.innerHTML = rows.map(e => {
|
|
const status = e.status || "unknown";
|
|
const catOptions = eaCategories.map(c =>
|
|
`<option value="${esc(c.id)}" ${c.id === e.category ? "selected" : ""}>${esc(c.name)}</option>`
|
|
).join("") || `<option value="${esc(e.category)}" selected>${esc(e.category)}</option>`;
|
|
// Rows for an extension the Easy Asterisk container doesn't know about
|
|
// (in pjsip.conf but not its device list) still get tier/messaging —
|
|
// only the container-backed controls are dropped, since every one of
|
|
// them writes through `docker exec` into that container.
|
|
const catCell = e.ea
|
|
? `<select onchange="changeEaDeviceCategory('${esc(e.ext)}', this.value)">${catOptions}</select>`
|
|
: '<span class="muted">—</span>';
|
|
const deviceActions = e.ea
|
|
? `<button class="action ea-only" onclick="renameEaDevice('${esc(e.ext)}')">Rename</button>
|
|
<button class="action ea-only" onclick="deleteEaDevice('${esc(e.ext)}')">Delete</button>`
|
|
: "";
|
|
return `<tr data-ext="${esc(e.ext)}">
|
|
<td>${esc(e.ext)}</td>
|
|
<td>${esc(e.name)}</td>
|
|
<td class="ea-only">${catCell}</td>
|
|
<td class="ea-only ${e.ea && status === "online" ? "sev-Informational" : "muted"}">${e.ea ? esc(status) : "—"}</td>
|
|
<td class="ea-only">${esc(e.transport)}${e.encryption && e.encryption !== "no" ? " / " + esc(e.encryption) : ""}</td>
|
|
<td class="pstn-only">
|
|
<select class="ext-tier">
|
|
<option value="internal" ${e.tier === "internal" ? "selected" : ""}>internal</option>
|
|
<option value="restricted" ${e.tier === "restricted" ? "selected" : ""}>restricted</option>
|
|
<option value="full" ${e.tier === "full" ? "selected" : ""}>full</option>
|
|
</select>
|
|
</td>
|
|
<td class="pstn-only"><input type="text" class="ext-numbers" value="${esc(e.allowed_numbers)}" placeholder="5551234567,5559876543 (leading 1 optional)" ${e.tier === "restricted" ? "" : "disabled"}></td>
|
|
<td style="text-align:center"><input type="checkbox" class="ext-messaging" ${e.messaging ? "checked" : ""}></td>
|
|
<td>
|
|
<button class="action" onclick="saveExtension('${esc(e.ext)}')">Save</button>
|
|
${deviceActions}
|
|
</td>
|
|
</tr>`;
|
|
}).join("");
|
|
|
|
tbody.querySelectorAll("tr").forEach(row => {
|
|
const tierSel = row.querySelector(".ext-tier");
|
|
const numsInput = row.querySelector(".ext-numbers");
|
|
if (tierSel && numsInput) {
|
|
tierSel.addEventListener("change", () => { numsInput.disabled = tierSel.value !== "restricted"; });
|
|
}
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll("#ext-table th.sortable").forEach(th => {
|
|
th.addEventListener("click", () => {
|
|
const key = th.dataset.sort;
|
|
extSort.dir = (extSort.key === key) ? -extSort.dir : 1;
|
|
extSort.key = key;
|
|
renderExtensions();
|
|
});
|
|
});
|
|
|
|
// Two write paths, one button. With a trunk installed the row owns tier +
|
|
// approved numbers + messaging, so it saves all three through the
|
|
// permissions endpoint. Without one there is no tier to speak of and those
|
|
// columns are hidden, so it falls back to the messaging-only endpoint —
|
|
// which exists precisely because messaging has no PSTN dependency.
|
|
async function saveExtension(ext) {
|
|
const row = document.querySelector(`#ext-table tr[data-ext="${ext}"]`);
|
|
const messaging = row.querySelector(".ext-messaging").checked;
|
|
let res;
|
|
if (pstnInstalled) {
|
|
res = await fetch("/api/pstn-permissions", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({
|
|
ext: ext,
|
|
tier: row.querySelector(".ext-tier").value,
|
|
allowed_numbers: row.querySelector(".ext-numbers").value,
|
|
messaging: messaging,
|
|
}),
|
|
});
|
|
} else {
|
|
res = await fetch("/api/pstn-messaging", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({ext: ext, enabled: messaging}),
|
|
});
|
|
}
|
|
const data = await res.json();
|
|
document.getElementById("ext-msg").textContent =
|
|
(data.message || (data.ok ? "Saved" : "Failed")) + " (extension " + ext + ")";
|
|
if (data.ok && pstnInstalled) markPstnDirty();
|
|
loadExtensions();
|
|
}
|
|
|
|
document.getElementById("ea-dev-save").addEventListener("click", async () => {
|
|
const name = document.getElementById("ea-dev-name").value.trim();
|
|
const extension = document.getElementById("ea-dev-ext").value.trim();
|
|
const category = document.getElementById("ea-dev-category").value;
|
|
const conn_type = document.getElementById("ea-dev-conn").value;
|
|
const aa = document.getElementById("ea-dev-aa").value;
|
|
const res = await fetch("/api/ea-devices", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({name, extension, category, conn_type, auto_answer: aa || null}),
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
document.getElementById("ext-msg").textContent =
|
|
`Added ${data.data.extension} — password: ${data.data.password} (shown once, save it now) — SIP ${data.data.transport} on port ${data.data.port}`;
|
|
document.getElementById("ea-dev-name").value = "";
|
|
document.getElementById("ea-dev-ext").value = "";
|
|
} else {
|
|
document.getElementById("ext-msg").textContent = data.message || "Failed";
|
|
}
|
|
loadExtensions();
|
|
});
|
|
|
|
async function renameEaDevice(ext) {
|
|
const cur = extRows.find(e => e.ext === ext);
|
|
const name = prompt("New name for extension " + ext + ":", cur ? cur.name : "");
|
|
if (name === null || !name.trim()) return;
|
|
const res = await fetch("/api/ea-devices/rename", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({extension: ext, name: name.trim()}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("ext-msg").textContent = data.message || (data.ok ? "Renamed" : "Failed");
|
|
loadExtensions();
|
|
}
|
|
|
|
async function deleteEaDevice(ext) {
|
|
if (!confirm("Delete device " + ext + "? This cannot be undone.")) return;
|
|
const res = await fetch("/api/ea-devices/delete", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({extension: ext}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("ext-msg").textContent = data.message || (data.ok ? "Deleted" : "Failed");
|
|
loadExtensions();
|
|
loadEaRooms();
|
|
}
|
|
|
|
async function changeEaDeviceCategory(ext, category) {
|
|
const res = await fetch("/api/ea-devices/category", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({extension: ext, category}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("ext-msg").textContent = data.message || (data.ok ? "Category changed" : "Failed");
|
|
loadExtensions();
|
|
}
|
|
|
|
async function loadEaRooms() {
|
|
const res = await fetch("/api/ea-rooms");
|
|
const data = await res.json();
|
|
eaRooms = data.rooms || [];
|
|
renderEaRooms();
|
|
}
|
|
|
|
function eaRoomSortValue(r, key) {
|
|
if (key === "extension") return parseInt(r.extension, 10);
|
|
return (r[key] || "").toString().toLowerCase();
|
|
}
|
|
|
|
function renderEaRooms() {
|
|
let rows = eaRooms.slice();
|
|
if (eaRoomSort.key) {
|
|
rows.sort((a, b) => {
|
|
const av = eaRoomSortValue(a, eaRoomSort.key), bv = eaRoomSortValue(b, eaRoomSort.key);
|
|
if (av < bv) return -1 * eaRoomSort.dir;
|
|
if (av > bv) return 1 * eaRoomSort.dir;
|
|
return 0;
|
|
});
|
|
}
|
|
document.querySelectorAll("#ea-room-table th.sortable .arrow").forEach(a => a.remove());
|
|
if (eaRoomSort.key) {
|
|
const th = document.querySelector(`#ea-room-table th[data-sort="${eaRoomSort.key}"]`);
|
|
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${eaRoomSort.dir === 1 ? "▲" : "▼"}</span>`);
|
|
}
|
|
const tbody = document.querySelector("#ea-room-table tbody");
|
|
tbody.innerHTML = rows.map(r => {
|
|
const memberExts = (r.members || "").split(",").filter(Boolean);
|
|
const memberChips = memberExts.map(ext => {
|
|
const dev = eaDevices.find(d => d.extension === ext);
|
|
const label = dev ? `${esc(ext)} — ${esc(dev.name)}` : esc(ext);
|
|
return `<span style="display:inline-flex;gap:0.25rem;align-items:center;margin:0.1rem;padding:0.1rem 0.4rem;background:#0f1115;border:1px solid #2a2e38;border-radius:4px;font-size:0.8rem">
|
|
${label}
|
|
<button class="action" style="padding:0 0.3rem" onclick="removeEaRoomMember('${esc(r.extension)}','${esc(ext)}')">×</button>
|
|
</span>`;
|
|
}).join("");
|
|
const available = eaDevices.filter(d => !memberExts.includes(d.extension));
|
|
const addPicker = available.length
|
|
? `<select style="width:auto">${available.map(d => `<option value="${esc(d.extension)}">${esc(d.extension)} — ${esc(d.name)}</option>`).join("")}</select>
|
|
<button class="action" onclick="addEaRoomMemberFromRow('${esc(r.extension)}', this)">+</button>`
|
|
: '<span class="muted">no more devices</span>';
|
|
return `<tr data-ext="${esc(r.extension)}">
|
|
<td>${esc(r.extension)}</td>
|
|
<td>${esc(r.name)}</td>
|
|
<td>${memberChips || '<span class="muted">none</span>'}<br>${addPicker}</td>
|
|
<td>${esc(r.timeout)}</td>
|
|
<td>${esc(r.type)}</td>
|
|
<td>
|
|
<button class="action" onclick="renameEaRoom('${esc(r.extension)}')">Rename</button>
|
|
<button class="action" onclick="deleteEaRoom('${esc(r.extension)}')">Delete</button>
|
|
</td>
|
|
</tr>`;
|
|
}).join("") || '<tr><td colspan=6 class=muted>No rooms yet.</td></tr>';
|
|
}
|
|
|
|
document.querySelectorAll("#ea-room-table th.sortable").forEach(th => {
|
|
th.addEventListener("click", () => {
|
|
const key = th.dataset.sort;
|
|
eaRoomSort.dir = (eaRoomSort.key === key) ? -eaRoomSort.dir : 1;
|
|
eaRoomSort.key = key;
|
|
renderEaRooms();
|
|
});
|
|
});
|
|
|
|
document.getElementById("ea-room-save").addEventListener("click", async () => {
|
|
const extension = document.getElementById("ea-room-ext").value.trim();
|
|
const name = document.getElementById("ea-room-name").value.trim();
|
|
const type = document.getElementById("ea-room-type").value;
|
|
const timeout = document.getElementById("ea-room-timeout").value.trim() || "60";
|
|
const res = await fetch("/api/ea-rooms", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({extension, name, type, timeout}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("ea-room-msg").textContent = data.message || (data.ok ? "Saved" : "Failed");
|
|
if (data.ok) {
|
|
document.getElementById("ea-room-ext").value = "";
|
|
document.getElementById("ea-room-name").value = "";
|
|
}
|
|
loadEaRooms();
|
|
});
|
|
|
|
async function renameEaRoom(ext) {
|
|
const cur = eaRooms.find(r => r.extension === ext);
|
|
const name = prompt("New name for room " + ext + ":", cur ? cur.name : "");
|
|
if (name === null || !name.trim()) return;
|
|
const res = await fetch("/api/ea-rooms/rename", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({extension: ext, name: name.trim()}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("ea-room-msg").textContent = data.message || (data.ok ? "Renamed" : "Failed");
|
|
loadEaRooms();
|
|
}
|
|
|
|
async function deleteEaRoom(ext) {
|
|
if (!confirm("Delete room " + ext + "? This cannot be undone.")) return;
|
|
const res = await fetch("/api/ea-rooms/delete", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({extension: ext}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("ea-room-msg").textContent = data.message || (data.ok ? "Deleted" : "Failed");
|
|
loadEaRooms();
|
|
}
|
|
|
|
async function addEaRoomMemberFromRow(roomExt, btn) {
|
|
const select = btn.previousElementSibling;
|
|
const device = select.value;
|
|
if (!device) return;
|
|
const res = await fetch("/api/ea-rooms/members/add", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({room: roomExt, device}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("ea-room-msg").textContent = data.message || (data.ok ? "Added" : "Failed");
|
|
loadEaRooms();
|
|
}
|
|
|
|
async function removeEaRoomMember(roomExt, device) {
|
|
const res = await fetch("/api/ea-rooms/members/remove", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({room: roomExt, device}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("ea-room-msg").textContent = data.message || (data.ok ? "Removed" : "Failed");
|
|
loadEaRooms();
|
|
}
|
|
|
|
async function loadPstnLimits() {
|
|
const res = await fetch("/api/pstn-limits");
|
|
const data = await res.json();
|
|
document.getElementById("limit-out").value = data.max_outbound;
|
|
document.getElementById("limit-in").value = data.max_inbound;
|
|
}
|
|
|
|
// The Groups card's membership picker. Driven by the same merged extension
|
|
// list the table above renders, so a group can never offer an extension the
|
|
// table doesn't show (or miss one it does).
|
|
function renderGroupMemberPickers() {
|
|
const grpMembers = document.getElementById("grp-members");
|
|
grpMembers.innerHTML = extRows.map(e => `
|
|
<label class="muted" style="white-space:nowrap">
|
|
<input type="checkbox" class="grp-member-cb" value="${esc(e.ext)}"> ${esc(e.ext)} — ${esc(e.name)}
|
|
</label>
|
|
`).join("") || '<span class="muted">No extensions found</span>';
|
|
}
|
|
|
|
let lastGroups = [];
|
|
let grpSort = { key: null, dir: 1 };
|
|
|
|
function grpSortValue(g, key) {
|
|
if (key === "members") return g.members.join(", ").toLowerCase();
|
|
return (g.name || "").toLowerCase();
|
|
}
|
|
|
|
function renderGroups() {
|
|
let rows = lastGroups.slice();
|
|
if (grpSort.key) {
|
|
rows.sort((a, b) => {
|
|
const av = grpSortValue(a, grpSort.key), bv = grpSortValue(b, grpSort.key);
|
|
if (av < bv) return -1 * grpSort.dir;
|
|
if (av > bv) return 1 * grpSort.dir;
|
|
return 0;
|
|
});
|
|
}
|
|
document.querySelectorAll("#grp-table th.sortable .arrow").forEach(a => a.remove());
|
|
if (grpSort.key) {
|
|
const th = document.querySelector(`#grp-table th[data-sort="${grpSort.key}"]`);
|
|
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${grpSort.dir === 1 ? "▲" : "▼"}</span>`);
|
|
}
|
|
const tbody = document.querySelector("#grp-table tbody");
|
|
tbody.innerHTML = rows.map(g => `<tr data-group="${esc(g.name)}">
|
|
<td>${esc(g.name)}</td>
|
|
<td>${g.members.map(esc).join(", ") || '<span class="muted">none</span>'}</td>
|
|
<td>
|
|
<button class="action" onclick="editGroup('${esc(g.name)}')">Edit</button>
|
|
<button class="action" onclick="applyGroupMessaging('${esc(g.name)}', true)">Enable messaging</button>
|
|
<button class="action" onclick="applyGroupMessaging('${esc(g.name)}', false)">Disable messaging</button>
|
|
<button class="action" onclick="deleteGroup('${esc(g.name)}')">Delete</button>
|
|
</td>
|
|
</tr>`).join("") || '<tr><td colspan=3 class=muted>No groups yet.</td></tr>';
|
|
}
|
|
|
|
document.querySelectorAll("#grp-table th.sortable").forEach(th => {
|
|
th.addEventListener("click", () => {
|
|
const key = th.dataset.sort;
|
|
grpSort.dir = (grpSort.key === key) ? -grpSort.dir : 1;
|
|
grpSort.key = key;
|
|
renderGroups();
|
|
});
|
|
});
|
|
|
|
async function loadGroups() {
|
|
const res = await fetch("/api/pstn-groups");
|
|
const data = await res.json();
|
|
lastGroups = data.groups || [];
|
|
renderGroups();
|
|
renderPersonalDidOwnerOptions();
|
|
}
|
|
|
|
function editGroup(name) {
|
|
const g = lastGroups.find(x => x.name === name);
|
|
if (!g) return;
|
|
document.getElementById("grp-name").value = g.name;
|
|
document.querySelectorAll(".grp-member-cb").forEach(cb => { cb.checked = g.members.includes(cb.value); });
|
|
}
|
|
|
|
document.getElementById("grp-save").addEventListener("click", async () => {
|
|
const name = document.getElementById("grp-name").value.trim();
|
|
const members = Array.from(document.querySelectorAll(".grp-member-cb:checked")).map(cb => cb.value);
|
|
const res = await fetch("/api/pstn-groups", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({name: name, members: members}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("grp-msg").textContent = data.message || (data.ok ? "Saved" : "Failed");
|
|
if (data.ok && pstnInstalled) markPstnDirty();
|
|
loadGroups();
|
|
});
|
|
|
|
async function applyGroupMessaging(name, enabled) {
|
|
if (!confirm(`${enabled ? "Enable" : "Disable"} messaging for every current member of "${name}"?`)) return;
|
|
const res = await fetch("/api/pstn-groups/apply-messaging", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({name: name, enabled: enabled}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("grp-msg").textContent = data.message || (data.ok ? "Applied" : "Failed");
|
|
if (data.ok && pstnInstalled) markPstnDirty();
|
|
loadExtensions();
|
|
}
|
|
|
|
async function deleteGroup(name) {
|
|
if (!confirm(`Delete group "${name}"? This does not change any member's current settings.`)) return;
|
|
const res = await fetch("/api/pstn-groups/delete", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({name: name}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("grp-msg").textContent = data.message || (data.ok ? "Deleted" : "Failed");
|
|
if (data.ok && pstnInstalled) markPstnDirty();
|
|
loadGroups();
|
|
}
|
|
|
|
document.getElementById("limits-save").addEventListener("click", async () => {
|
|
const maxOut = document.getElementById("limit-out").value;
|
|
const maxIn = document.getElementById("limit-in").value;
|
|
const res = await fetch("/api/pstn-limits", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({max_outbound: maxOut, max_inbound: maxIn}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("limits-msg").textContent = data.message || (data.ok ? "Saved" : "Failed");
|
|
if (data.ok) markPstnDirty();
|
|
loadPstnLimits();
|
|
});
|
|
|
|
// "Commit Changes" — see restart_asterisk_container()'s comment in app.py
|
|
// for why this button exists: AST_CONFIG() live-reads of these PSTN config
|
|
// files have been confirmed to sometimes stay stale (returning what was on
|
|
// disk at last container start, not the freshly-saved value) until a full
|
|
// container restart, contradicting the "no restart needed" premise the
|
|
// rest of this tab's copy otherwise relies on. pstnDirty tracks whether
|
|
// ANY save on this tab succeeded since the last restart (or page load),
|
|
// shows a persistent banner, and warns on tab-close/navigation so a change
|
|
// doesn't silently sit uncommitted.
|
|
let pstnDirty = false;
|
|
function markPstnDirty() {
|
|
pstnDirty = true;
|
|
document.getElementById("pstn-restart-banner").style.display = "";
|
|
}
|
|
window.addEventListener("beforeunload", (e) => {
|
|
if (!pstnDirty) return;
|
|
e.preventDefault();
|
|
e.returnValue = "";
|
|
});
|
|
document.getElementById("pstn-restart-btn").addEventListener("click", async () => {
|
|
const msg = document.getElementById("pstn-restart-msg");
|
|
msg.textContent = "Restarting Asterisk…";
|
|
const res = await fetch("/api/asterisk-restart", { method: "POST" });
|
|
const data = await res.json();
|
|
msg.textContent = data.message || (data.ok ? "Restarted" : "Failed");
|
|
if (data.ok) {
|
|
pstnDirty = false;
|
|
document.getElementById("pstn-restart-banner").style.display = "none";
|
|
}
|
|
});
|
|
|
|
// The personal-DID owner picker spans both lists, so it re-renders from
|
|
// whichever of the two finished last rather than being owned by either.
|
|
function renderPersonalDidOwnerOptions() {
|
|
const ownerSel = document.getElementById("pd-owner");
|
|
if (!ownerSel) return;
|
|
const extOptions = extRows.map(e => `<option value="${esc(e.ext)}">${esc(e.ext)} — ${esc(e.name)}</option>`).join("");
|
|
const groupOptions = lastGroups.map(g => `<option value="@${esc(g.name)}">Group: ${esc(g.name)}</option>`).join("");
|
|
ownerSel.innerHTML = (extOptions + groupOptions) || '<option value="">No extensions found</option>';
|
|
}
|
|
|
|
let lastPersonalDids = [];
|
|
let pdSort = { key: null, dir: 1 };
|
|
|
|
function pdSortValue(d, key) {
|
|
if (key === "did") return parseInt(d.did, 10);
|
|
if (key === "owner") return d.owner.startsWith("@") ? d.owner.slice(1).toLowerCase() : d.owner.toLowerCase();
|
|
return "";
|
|
}
|
|
|
|
function renderPersonalDids() {
|
|
let rows = lastPersonalDids.slice();
|
|
if (pdSort.key) {
|
|
rows.sort((a, b) => {
|
|
const av = pdSortValue(a, pdSort.key), bv = pdSortValue(b, pdSort.key);
|
|
if (av < bv) return -1 * pdSort.dir;
|
|
if (av > bv) return 1 * pdSort.dir;
|
|
return 0;
|
|
});
|
|
}
|
|
document.querySelectorAll("#pd-table th.sortable .arrow").forEach(a => a.remove());
|
|
if (pdSort.key) {
|
|
const th = document.querySelector(`#pd-table th[data-sort="${pdSort.key}"]`);
|
|
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${pdSort.dir === 1 ? "▲" : "▼"}</span>`);
|
|
}
|
|
const tbody = document.querySelector("#pd-table tbody");
|
|
tbody.innerHTML = rows.map(d => {
|
|
const ownerDisplay = d.owner.startsWith("@")
|
|
? "Group: " + esc(d.owner.slice(1))
|
|
: esc(d.owner) + (d.owner_name ? " — " + esc(d.owner_name) : "");
|
|
return `<tr>
|
|
<td>${esc(d.did)}</td>
|
|
<td>${ownerDisplay}</td>
|
|
<td><button class="action" onclick="removePersonalDid('${esc(d.did)}')">Remove</button></td>
|
|
</tr>`;
|
|
}).join("") || "<tr><td colspan=3 class=muted>No personal numbers assigned — every extension shares the main trunk DID.</td></tr>";
|
|
}
|
|
|
|
document.querySelectorAll("#pd-table th.sortable").forEach(th => {
|
|
th.addEventListener("click", () => {
|
|
const key = th.dataset.sort;
|
|
pdSort.dir = (pdSort.key === key) ? -pdSort.dir : 1;
|
|
pdSort.key = key;
|
|
renderPersonalDids();
|
|
});
|
|
});
|
|
|
|
async function loadPersonalDids() {
|
|
const res = await fetch("/api/pstn-personal-dids");
|
|
const data = await res.json();
|
|
lastPersonalDids = data.dids || [];
|
|
renderPersonalDids();
|
|
}
|
|
|
|
document.getElementById("pd-save").addEventListener("click", async () => {
|
|
const did = document.getElementById("pd-did").value.trim();
|
|
const owner = document.getElementById("pd-owner").value;
|
|
const res = await fetch("/api/pstn-personal-dids", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({did: did, owner: owner}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("pd-msg").textContent = data.message || (data.ok ? "Saved" : "Failed");
|
|
if (data.ok) {
|
|
document.getElementById("pd-did").value = "";
|
|
markPstnDirty();
|
|
}
|
|
loadPersonalDids();
|
|
});
|
|
|
|
async function removePersonalDid(did) {
|
|
if (!confirm("Remove personal number " + did + "? Its owner falls back to the shared trunk DID for outbound Caller-ID, and this DID stops routing anywhere until reassigned.")) return;
|
|
const res = await fetch("/api/pstn-personal-dids/delete", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({did: did}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("pd-msg").textContent = data.message || (data.ok ? "Removed" : "Failed");
|
|
if (data.ok) markPstnDirty();
|
|
loadPersonalDids();
|
|
}
|
|
|
|
loadSecurity();
|
|
loadDecisions();
|
|
loadAsnExempt();
|
|
loadCrowdsecStatus();
|
|
initExtensionsTab();
|
|
setInterval(loadSecurity, 30000);
|
|
setInterval(loadDecisions, 30000);
|
|
</script>
|
|
</body></html>
|
|
"""
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def _json(self, obj, status=200):
|
|
body = json.dumps(obj).encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _html(self, html, status=200):
|
|
body = html.encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self):
|
|
if self.path == "/" or self.path == "":
|
|
html = INDEX_HTML
|
|
self._html(html)
|
|
elif self.path == "/api/security-events":
|
|
self._json(parse_security_log())
|
|
elif self.path == "/api/decisions":
|
|
self._json(get_decisions())
|
|
elif self.path == "/api/asn-exempt":
|
|
decisions = get_decisions()
|
|
known_names = {d["as_number"]: d["as_name"] for d in decisions if d.get("as_number")}
|
|
for asn, name in get_alert_history_names().items():
|
|
known_names.setdefault(asn, name)
|
|
self._json({"asns": get_asn_exempt(known_names)})
|
|
elif self.path == "/api/pstn-permissions":
|
|
perms = get_all_permissions()
|
|
extensions = []
|
|
for e in list_extensions():
|
|
p = perms.get(e["ext"], {"tier": "internal", "allowed_numbers": "", "messaging": False})
|
|
extensions.append({"ext": e["ext"], "name": e["name"], "tier": p["tier"],
|
|
"allowed_numbers": p["allowed_numbers"], "messaging": p["messaging"]})
|
|
self._json({"extensions": extensions})
|
|
elif self.path == "/api/pstn-limits":
|
|
self._json(get_limits())
|
|
elif self.path == "/api/pstn-personal-dids":
|
|
names = {e["ext"]: e["name"] for e in list_extensions()}
|
|
dids = [dict(d, owner_name=names.get(d["owner"], "")) for d in list_personal_dids()]
|
|
self._json({"dids": dids})
|
|
elif self.path == "/api/pstn-status":
|
|
self._json({"installed": pstn_installed()})
|
|
elif self.path == "/api/pstn-groups":
|
|
self._json({"groups": list_groups()})
|
|
elif self.path == "/api/crowdsec-status":
|
|
self._json({"installed": crowdsec_installed()})
|
|
elif self.path == "/api/ea-status":
|
|
self._json({"installed": ea_installed()})
|
|
elif self.path == "/api/ea-devices":
|
|
self._json({"devices": ea_list_devices(), "status": ea_get_status()})
|
|
elif self.path == "/api/ea-categories":
|
|
self._json({"categories": ea_list_categories()})
|
|
elif self.path == "/api/ea-rooms":
|
|
self._json({"rooms": ea_list_rooms()})
|
|
else:
|
|
self._json({"error": "not found"}, 404)
|
|
|
|
def do_POST(self):
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
raw = self.rfile.read(length) if length else b"{}"
|
|
try:
|
|
payload = json.loads(raw or b"{}")
|
|
except json.JSONDecodeError:
|
|
payload = {}
|
|
|
|
if self.path == "/api/decisions/delete":
|
|
ok, message = delete_decision(payload.get("id", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/asn-exempt":
|
|
ok, message = set_asn_exempt(payload.get("asns", []))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/asn-exempt/ban":
|
|
self._json(ban_asn(payload.get("asn", "")))
|
|
elif self.path == "/api/pstn-permissions":
|
|
ok, message = write_permission(
|
|
payload.get("ext", ""), payload.get("tier", ""), payload.get("allowed_numbers", ""),
|
|
bool(payload.get("messaging", False))
|
|
)
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/pstn-limits":
|
|
ok, message = write_limits(payload.get("max_outbound", ""), payload.get("max_inbound", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/pstn-personal-dids":
|
|
ok, message = write_personal_did(payload.get("did", ""), payload.get("owner", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/pstn-personal-dids/delete":
|
|
ok, message = remove_personal_did(payload.get("did", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
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-groups":
|
|
ok, message = write_group(payload.get("name", ""), payload.get("members", []))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/pstn-groups/delete":
|
|
ok, message = delete_group(payload.get("name", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/pstn-groups/apply-messaging":
|
|
ok, message = apply_group_messaging(payload.get("name", ""), 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})
|
|
elif self.path == "/api/ea-devices":
|
|
ok, result = ea_add_device(
|
|
payload.get("name", ""), payload.get("category", ""), payload.get("extension", ""),
|
|
payload.get("conn_type", "lan"), payload.get("auto_answer")
|
|
)
|
|
if ok:
|
|
self._json({"ok": True, "data": result})
|
|
else:
|
|
self._json({"ok": False, "message": result})
|
|
elif self.path == "/api/ea-devices/delete":
|
|
ok, message = ea_delete_device(payload.get("extension", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/ea-devices/rename":
|
|
ok, message = ea_rename_device(payload.get("extension", ""), payload.get("name", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/ea-devices/category":
|
|
ok, message = ea_change_device_category(payload.get("extension", ""), payload.get("category", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/ea-categories":
|
|
ok, message = ea_create_category(
|
|
payload.get("id", ""), payload.get("name", ""),
|
|
payload.get("auto_answer", ""), payload.get("description", "")
|
|
)
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/ea-categories/delete":
|
|
ok, message = ea_delete_category(payload.get("id", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/ea-categories/rename":
|
|
ok, message = ea_rename_category(payload.get("id", ""), payload.get("name", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/ea-rooms":
|
|
ok, message = ea_create_room(
|
|
payload.get("extension", ""), payload.get("name", ""),
|
|
payload.get("type", "ring"), payload.get("timeout", "60")
|
|
)
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/ea-rooms/delete":
|
|
ok, message = ea_delete_room(payload.get("extension", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/ea-rooms/rename":
|
|
ok, message = ea_rename_room(payload.get("extension", ""), payload.get("name", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/ea-rooms/members/add":
|
|
ok, message = ea_add_room_member(payload.get("room", ""), payload.get("device", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
elif self.path == "/api/ea-rooms/members/remove":
|
|
ok, message = ea_remove_room_member(payload.get("room", ""), payload.get("device", ""))
|
|
self._json({"ok": ok, "message": message})
|
|
else:
|
|
self._json({"error": "not found"}, 404)
|
|
|
|
def log_message(self, fmt, *args):
|
|
pass # systemd journal captures stdout/stderr already; keep it quiet
|
|
|
|
|
|
def main():
|
|
ThreadingHTTPServer.allow_reuse_address = True
|
|
# 0.0.0.0, not 127.0.0.1: Caddy runs in a container and reaches this via
|
|
# host.docker.internal (a Docker bridge gateway IP, not localhost) — a
|
|
# loopback-only bind refuses that connection outright. Confirmed live:
|
|
# "dial tcp 172.17.0.1:8092: connect: connection refused" even though
|
|
# curl from the host itself worked fine on 127.0.0.1. Access is scoped by
|
|
# UFW (see install_security-dashboard), not by which interface this binds
|
|
# to — same pattern every other host-network service in this repo uses.
|
|
with ThreadingHTTPServer(("0.0.0.0", PORT), Handler) as httpd:
|
|
print(f"Security dashboard running on 0.0.0.0:{PORT}")
|
|
httpd.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
PYAPP
|
|
}
|
|
|
|
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_security-dashboard
|