Live trace confirmed: once trust_id_inbound=yes started surfacing real caller identity (previous commit), Anveo delivers it "+E.164" style (e.g. "+15551234567") instead of the bare digits the old callerid-fallback path produced. PSTN_CALLERID_NORM's existing "add a leading 1 if length is 10" check never fires for a 12-character "+"-prefixed value and never strips the "+" either, so it can never match an 11-digit, digits-only allowed_numbers entry no matter how correctly the number is whitelisted - a correctly-configured restricted-tier extension or group member would still always get busy. Fixed in three places: the inbound dialplan's PSTN_CALLERID_NORM computation (new PSTN_CID_RAW step strips a leading "+" first), the group-ring shell script's own defensive re-normalization, and the dashboard's admin-input-side normalizers (_normalize_nanp_number, _normalize_personal_did_input) so pasting a number straight out of a phone's call log (which naturally includes the "+") works too instead of being silently dropped.
3519 lines
153 KiB
Bash
3519 lines
153 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 + CrowdSec bans (Authelia-protected)" 8092
|
|
|
|
install_security-dashboard() {
|
|
local APP_DIR="/opt/security-dashboard"
|
|
local DASHBOARD_PORT=8092
|
|
local SVC_USER="secdash"
|
|
|
|
# Either Asterisk flavor works — prefer asterisk-digital-ocean if both
|
|
# happen to be installed, 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 native Asterisk Admin tab's writes/CLI
|
|
# calls (ea_* functions) — "easy-asterisk-do" for the droplet flavor,
|
|
# "easy-asterisk" for LAN, matching each service's own container_name.
|
|
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 + CrowdSec decisions + PSTN │"
|
|
echo "│ trunk permissions, one page. Runs natively on the host (not │"
|
|
echo "│ Docker) so it can call cscli and read Asterisk's files │"
|
|
echo "│ directly. Authelia-protected. │"
|
|
echo "└─────────────────────────────────────────────────────────────────┘"
|
|
echo ""
|
|
|
|
if [ -z "$ASTERISK_EA_DIR" ]; then
|
|
log_warning "No asterisk-digital-ocean or asterisk install detected."
|
|
log_warning "The Security Log, Extensions, Asterisk Admin, and PSTN Trunk tabs will just be"
|
|
log_warning "empty/hidden — CrowdSec's tab 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 PSTN Trunk 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
|
|
The nav only ever shows tabs for things actually present on this box — no
|
|
tab for a service you haven't installed. **Security Log** and **Extensions**
|
|
are always there (they only need Asterisk itself, detected once at install
|
|
time). **Asterisk Admin**, **PSTN Trunk**, and **CrowdSec** each check their
|
|
own live install state on every page load and hide their own nav button
|
|
entirely if not found, so this one page/URL scales from a bare LAN Asterisk
|
|
box (just those first two tabs) up to a full droplet with a trunk and
|
|
CrowdSec, without ever showing a tab for something that isn't set up.
|
|
|
|
- **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).
|
|
- **Asterisk Admin** — 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. Its nav button only appears once the
|
|
live \`/api/ea-status\` check confirms an Asterisk container is actually
|
|
reachable.
|
|
- **Devices** — add/rename/delete a SIP extension, reassign its category;
|
|
live registered/unregistered status per device.
|
|
- **Categories** — device profiles (an auto-answer default + description).
|
|
- **Rooms** — ring groups/paging groups; add/remove members per room.
|
|
- Every write reloads PJSIP and/or rebuilds the dialplan automatically, the
|
|
same way the vendored admin's own actions do.
|
|
- **Extensions** — always available, independent of any PSTN trunk. A
|
|
**Groups** card lets you 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.
|
|
An **Internal SIP messaging** card (a checkbox chip per known extension,
|
|
independent of PSTN calling entirely — no cost, no carrier, no DID, no
|
|
dependency on a PSTN trunk being installed) sits below it.
|
|
- **PSTN Trunk** — its nav button only appears once
|
|
\`services/pstn-trunk.sh\`'s dialplan is actually installed
|
|
(\`pstn-trunk-dialplan.conf\` present), so it never shows a
|
|
real-looking-but-unenforced editor. When present: the outbound/inbound
|
|
concurrent-call caps, and every known extension's permission tier
|
|
(internal / restricted / full) and, for restricted, its approved numbers —
|
|
all editable live, no Asterisk restart, no reinstall, sortable per column.
|
|
Also manages personal-number assignments (DID -> owner extension or
|
|
group), additive to the shared trunk DID. Writes 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). Asterisk Admin (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 native Asterisk Admin 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=""
|
|
# Native Asterisk Admin tab (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 six are exact commands, no
|
|
# wildcards, scoped to the one container actually installed on this box.
|
|
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"
|
|
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 standalone "Internal SIP messaging" card, which works whether or
|
|
not a PSTN trunk has ever 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 tab would show a real
|
|
extension list 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 Admin (native — 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 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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Security Dashboard</h1>
|
|
<nav>
|
|
<button class="tab-btn active" data-tab="security">Security Log</button>
|
|
<button class="tab-btn" id="asterisk-tab-btn" data-tab="asterisk" style="display:none">Asterisk Admin</button>
|
|
<button class="tab-btn" data-tab="extensions">Extensions</button>
|
|
<button class="tab-btn" id="pstn-tab-btn" data-tab="pstn" style="display:none">PSTN Trunk</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">
|
|
<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 checkbox above would, one time — it isn't a runtime concept the dialplan knows about, and membership changes never retroactively affect anything already applied.
|
|
</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">
|
|
<h3 style="margin-top:0">Internal SIP messaging</h3>
|
|
<p class="muted">
|
|
Asterisk's native SIP texting between extensions — no carrier SMS, no PSTN, no cost, and no dependency on a PSTN trunk being installed at all. Enforced live by a dedicated dialplan context (see services/asterisk-digital-ocean.sh's README) — install/rerun that service to pick up the dialplan wiring if this box predates it.
|
|
</p>
|
|
<div id="msg-chips" class="chip-row"></div>
|
|
<button class="action" id="msg-save-all" style="margin-top:0.75rem">Save changes</button>
|
|
<div id="msg-msg" class="muted" style="margin-top:0.5rem"></div>
|
|
</div>
|
|
</div>
|
|
<div id="tab-pstn" style="display:none">
|
|
<div class="card">
|
|
<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 apply live, on the next call.</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">
|
|
<h3 style="margin-top:0">PSTN permission tiers</h3>
|
|
<p class="muted">
|
|
<b>internal</b> — no PSTN, can still call/receive other extensions and internal ring groups.
|
|
<b>restricted</b> — internal, plus only pre-approved US numbers.
|
|
<b>full</b> — internal, plus any US number.
|
|
Changes apply live, on the next call — no Asterisk restart needed.
|
|
</p>
|
|
<p class="muted">
|
|
<b>Messaging</b> — the same internal SIP texting flag as the Extensions tab's checkboxes, independent of the calling tier; this column is just a convenience for setting it alongside tier/numbers on one row. Enforced live by a dedicated dialplan context — see services/asterisk-digital-ocean.sh's README for how, and its caveat on the sender-extraction logic still needing real-traffic confirmation.
|
|
</p>
|
|
<table id="pstn-table"><thead><tr>
|
|
<th class="sortable" data-sort="ext">Ext</th>
|
|
<th class="sortable" data-sort="name">Name</th>
|
|
<th class="sortable" data-sort="tier">Tier</th>
|
|
<th>Approved numbers (restricted only)</th>
|
|
<th class="sortable" data-sort="messaging">Messaging</th>
|
|
<th></th>
|
|
</tr></thead><tbody></tbody></table>
|
|
<div id="pstn-msg" class="muted" style="margin-top:0.5rem"></div>
|
|
</div>
|
|
<div class="card">
|
|
<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 on every call, so membership changes apply immediately); 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.
|
|
</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>
|
|
<div id="tab-asterisk" style="display:none">
|
|
<div class="card">
|
|
<h3 style="margin-top:0">Devices</h3>
|
|
<p class="muted">
|
|
SIP extensions/endpoints. Adding one generates a random password and reloads PJSIP + rebuilds the dialplan automatically — the password is shown once here, save it before it scrolls away.
|
|
</p>
|
|
<div class="row" 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 device</button>
|
|
</div>
|
|
<table id="ea-dev-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>Category</th>
|
|
<th class="sortable" data-sort="status">Status</th>
|
|
<th>Transport</th>
|
|
<th></th>
|
|
</tr></thead><tbody></tbody></table>
|
|
<div id="ea-dev-msg" class="muted" style="margin-top:0.5rem"></div>
|
|
</div>
|
|
<div class="card">
|
|
<h3 style="margin-top:0">Categories</h3>
|
|
<p class="muted">Device profiles — an auto-answer default and a description, assignable to any device 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">
|
|
<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>
|
|
</main>
|
|
<script>
|
|
function esc(s) { return (s || "").replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
|
|
|
const TABS = ["security", "asterisk", "extensions", "pstn", "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") { loadMessaging(); loadGroups(); }
|
|
if (btn.dataset.tab === "pstn") { loadPstnLimits(); loadPstnPermissions(); loadPersonalDids(); }
|
|
if (btn.dataset.tab === "asterisk") { loadEaAll(); }
|
|
});
|
|
});
|
|
|
|
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();
|
|
}
|
|
|
|
// Gates the tab button itself (not just its content) — called once at page
|
|
// load, same as loadCrowdsecStatus() below, so the nav never shows a tab for
|
|
// something that isn't actually set up on this box.
|
|
async function loadPstnStatus() {
|
|
const res = await fetch("/api/pstn-status");
|
|
const data = await res.json();
|
|
document.getElementById("pstn-tab-btn").style.display = data.installed ? "" : "none";
|
|
if (data.installed) { loadPstnLimits(); loadPstnPermissions(); loadPersonalDids(); }
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
// ── Easy Asterisk Admin (native — devices/categories/rooms) ────────────────
|
|
let eaDevices = [], eaCategories = [], eaRooms = [], eaStatusMap = {};
|
|
let eaDevSort = { key: null, dir: 1 };
|
|
let eaCatSort = { key: null, dir: 1 };
|
|
let eaRoomSort = { key: null, dir: 1 };
|
|
|
|
async function loadEaStatus() {
|
|
const res = await fetch("/api/ea-status");
|
|
const data = await res.json();
|
|
document.getElementById("asterisk-tab-btn").style.display = data.installed ? "" : "none";
|
|
if (data.installed) loadEaAll();
|
|
}
|
|
|
|
// Sequenced (not parallel) — device rows render a category <select> that
|
|
// needs eaCategories already populated, and room rows render a member-add
|
|
// picker that needs eaDevices already populated.
|
|
async function loadEaAll() {
|
|
await loadEaCategories();
|
|
await loadEaDevices();
|
|
await loadEaRooms();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
async function loadEaDevices() {
|
|
const res = await fetch("/api/ea-devices");
|
|
const data = await res.json();
|
|
eaDevices = data.devices || [];
|
|
eaStatusMap = data.status || {};
|
|
renderEaDevices();
|
|
}
|
|
|
|
function eaDevSortValue(d, key) {
|
|
if (key === "extension") return parseInt(d.extension, 10);
|
|
if (key === "status") return eaStatusMap[d.extension] || "";
|
|
return (d[key] || "").toString().toLowerCase();
|
|
}
|
|
|
|
function renderEaDevices() {
|
|
let rows = eaDevices.slice();
|
|
if (eaDevSort.key) {
|
|
rows.sort((a, b) => {
|
|
const av = eaDevSortValue(a, eaDevSort.key), bv = eaDevSortValue(b, eaDevSort.key);
|
|
if (av < bv) return -1 * eaDevSort.dir;
|
|
if (av > bv) return 1 * eaDevSort.dir;
|
|
return 0;
|
|
});
|
|
}
|
|
document.querySelectorAll("#ea-dev-table th.sortable .arrow").forEach(a => a.remove());
|
|
if (eaDevSort.key) {
|
|
const th = document.querySelector(`#ea-dev-table th[data-sort="${eaDevSort.key}"]`);
|
|
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${eaDevSort.dir === 1 ? "▲" : "▼"}</span>`);
|
|
}
|
|
const tbody = document.querySelector("#ea-dev-table tbody");
|
|
tbody.innerHTML = rows.map(d => {
|
|
const status = eaStatusMap[d.extension] || "unknown";
|
|
const catOptions = eaCategories.map(c =>
|
|
`<option value="${esc(c.id)}" ${c.id === d.category ? "selected" : ""}>${esc(c.name)}</option>`
|
|
).join("") || `<option value="${esc(d.category)}" selected>${esc(d.category)}</option>`;
|
|
return `<tr data-ext="${esc(d.extension)}">
|
|
<td>${esc(d.extension)}</td>
|
|
<td>${esc(d.name)}</td>
|
|
<td><select onchange="changeEaDeviceCategory('${esc(d.extension)}', this.value)">${catOptions}</select></td>
|
|
<td class="${status === "online" ? "sev-Informational" : "muted"}">${esc(status)}</td>
|
|
<td>${esc(d.transport)}${d.encryption && d.encryption !== "no" ? " / " + esc(d.encryption) : ""}</td>
|
|
<td>
|
|
<button class="action" onclick="renameEaDevice('${esc(d.extension)}')">Rename</button>
|
|
<button class="action" onclick="deleteEaDevice('${esc(d.extension)}')">Delete</button>
|
|
</td>
|
|
</tr>`;
|
|
}).join("") || '<tr><td colspan=6 class=muted>No devices yet.</td></tr>';
|
|
}
|
|
|
|
document.querySelectorAll("#ea-dev-table th.sortable").forEach(th => {
|
|
th.addEventListener("click", () => {
|
|
const key = th.dataset.sort;
|
|
eaDevSort.dir = (eaDevSort.key === key) ? -eaDevSort.dir : 1;
|
|
eaDevSort.key = key;
|
|
renderEaDevices();
|
|
});
|
|
});
|
|
|
|
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("ea-dev-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("ea-dev-msg").textContent = data.message || "Failed";
|
|
}
|
|
loadEaDevices();
|
|
});
|
|
|
|
async function renameEaDevice(ext) {
|
|
const cur = eaDevices.find(d => d.extension === 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("ea-dev-msg").textContent = data.message || (data.ok ? "Renamed" : "Failed");
|
|
loadEaDevices();
|
|
}
|
|
|
|
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("ea-dev-msg").textContent = data.message || (data.ok ? "Deleted" : "Failed");
|
|
loadEaDevices();
|
|
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("ea-dev-msg").textContent = data.message || (data.ok ? "Category changed" : "Failed");
|
|
loadEaDevices();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Independent of pstn_installed() — messaging has no dependency on a PSTN
|
|
// trunk existing, unlike everything else in this tab, so this loads/saves
|
|
// regardless of whether services/pstn-trunk.sh has ever been run.
|
|
async function loadMessaging() {
|
|
const res = await fetch("/api/pstn-permissions");
|
|
const data = await res.json();
|
|
const exts = data.extensions || [];
|
|
|
|
const grpMembers = document.getElementById("grp-members");
|
|
grpMembers.innerHTML = exts.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>';
|
|
|
|
const chips = document.getElementById("msg-chips");
|
|
chips.innerHTML = exts.map(e => `
|
|
<label><input type="checkbox" class="msg-chip" data-ext="${esc(e.ext)}" ${e.messaging ? "checked" : ""}> ${esc(e.ext)} — ${esc(e.name)}</label>
|
|
`).join("") || '<span class="muted">No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).</span>';
|
|
}
|
|
|
|
document.getElementById("msg-save-all").addEventListener("click", async () => {
|
|
const checkboxes = Array.from(document.querySelectorAll(".msg-chip"));
|
|
const results = await Promise.all(checkboxes.map(cb =>
|
|
fetch("/api/pstn-messaging", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({ext: cb.dataset.ext, enabled: cb.checked}),
|
|
}).then(r => r.json())
|
|
));
|
|
const failed = results.filter(r => !r.ok);
|
|
document.getElementById("msg-msg").textContent = failed.length
|
|
? `Saved with ${failed.length} error(s): ` + failed.map(r => r.message).join("; ")
|
|
: `Saved (${checkboxes.length} extension${checkboxes.length === 1 ? "" : "s"})`;
|
|
loadMessaging();
|
|
});
|
|
|
|
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();
|
|
}
|
|
|
|
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");
|
|
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");
|
|
loadMessaging();
|
|
}
|
|
|
|
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");
|
|
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");
|
|
loadPstnLimits();
|
|
});
|
|
|
|
let lastPstnExts = [];
|
|
let pstnSort = { key: null, dir: 1 };
|
|
|
|
function pstnSortValue(e, key) {
|
|
if (key === "ext") return parseInt(e.ext, 10);
|
|
if (key === "messaging") return e.messaging ? 1 : 0;
|
|
return (e[key] || "").toString().toLowerCase();
|
|
}
|
|
|
|
function renderPstnTable() {
|
|
const tbody = document.querySelector("#pstn-table tbody");
|
|
if (!lastPstnExts.length) {
|
|
tbody.innerHTML = '<tr><td colspan=6 class=muted>No extensions found (no Asterisk install detected, or pjsip.conf has no devices yet).</td></tr>';
|
|
return;
|
|
}
|
|
let rows = lastPstnExts.slice();
|
|
if (pstnSort.key) {
|
|
rows.sort((a, b) => {
|
|
const av = pstnSortValue(a, pstnSort.key), bv = pstnSortValue(b, pstnSort.key);
|
|
if (av < bv) return -1 * pstnSort.dir;
|
|
if (av > bv) return 1 * pstnSort.dir;
|
|
return 0;
|
|
});
|
|
}
|
|
document.querySelectorAll("#pstn-table th.sortable .arrow").forEach(a => a.remove());
|
|
if (pstnSort.key) {
|
|
const th = document.querySelector(`#pstn-table th[data-sort="${pstnSort.key}"]`);
|
|
if (th) th.insertAdjacentHTML("beforeend", `<span class="arrow">${pstnSort.dir === 1 ? "▲" : "▼"}</span>`);
|
|
}
|
|
tbody.innerHTML = rows.map(e => `<tr data-ext="${esc(e.ext)}">
|
|
<td>${esc(e.ext)}</td>
|
|
<td>${esc(e.name)}</td>
|
|
<td>
|
|
<select class="pstn-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><input type="text" class="pstn-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="pstn-messaging" ${e.messaging ? "checked" : ""}></td>
|
|
<td><button class="action" onclick="savePstnPermission('${esc(e.ext)}')">Save</button></td>
|
|
</tr>`).join("");
|
|
|
|
tbody.querySelectorAll("tr").forEach(row => {
|
|
const tierSel = row.querySelector(".pstn-tier");
|
|
const numsInput = row.querySelector(".pstn-numbers");
|
|
tierSel.addEventListener("change", () => { numsInput.disabled = tierSel.value !== "restricted"; });
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll("#pstn-table th.sortable").forEach(th => {
|
|
th.addEventListener("click", () => {
|
|
const key = th.dataset.sort;
|
|
pstnSort.dir = (pstnSort.key === key) ? -pstnSort.dir : 1;
|
|
pstnSort.key = key;
|
|
renderPstnTable();
|
|
});
|
|
});
|
|
|
|
async function loadPstnPermissions() {
|
|
const res = await fetch("/api/pstn-permissions");
|
|
const data = await res.json();
|
|
lastPstnExts = data.extensions || [];
|
|
|
|
const grpRes = await fetch("/api/pstn-groups");
|
|
const grpData = await grpRes.json();
|
|
const groups = grpData.groups || [];
|
|
|
|
const ownerSel = document.getElementById("pd-owner");
|
|
const extOptions = lastPstnExts.map(e => `<option value="${esc(e.ext)}">${esc(e.ext)} — ${esc(e.name)}</option>`).join("");
|
|
const groupOptions = groups.map(g => `<option value="@${esc(g.name)}">Group: ${esc(g.name)}</option>`).join("");
|
|
ownerSel.innerHTML = (extOptions + groupOptions) || '<option value="">No extensions found</option>';
|
|
|
|
renderPstnTable();
|
|
}
|
|
|
|
async function savePstnPermission(ext) {
|
|
const row = document.querySelector(`#pstn-table tr[data-ext="${ext}"]`);
|
|
const tier = row.querySelector(".pstn-tier").value;
|
|
const numbers = row.querySelector(".pstn-numbers").value;
|
|
const messaging = row.querySelector(".pstn-messaging").checked;
|
|
const res = await fetch("/api/pstn-permissions", {
|
|
method: "POST", headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({ext: ext, tier: tier, allowed_numbers: numbers, messaging: messaging}),
|
|
});
|
|
const data = await res.json();
|
|
document.getElementById("pstn-msg").textContent = (data.message || (data.ok ? "Saved" : "Failed")) + " (extension " + ext + ")";
|
|
loadPstnPermissions();
|
|
}
|
|
|
|
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 = "";
|
|
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");
|
|
loadPersonalDids();
|
|
}
|
|
|
|
loadSecurity();
|
|
loadDecisions();
|
|
loadAsnExempt();
|
|
loadPstnStatus();
|
|
loadCrowdsecStatus();
|
|
loadEaStatus();
|
|
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/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
|