From bde7e9d12d0a8cdc5de8cef9baf8c25ed570d92b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 01:14:15 +0000 Subject: [PATCH] Merge dashboard Asterisk Admin, Extensions and PSTN Trunk into one tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tabs listed the same extensions three different ways: Asterisk Admin as devices with category/status/transport, Extensions as a row of messaging checkboxes, PSTN Trunk as permission tiers with a duplicate Messaging column that wrote the same flag. Changing one extension meant knowing which of the three owned the setting you wanted. There is now one Extensions tab with one extensions table, merged from pjsip.conf (via /api/pstn-permissions, which always works) and /api/ea-devices where the Easy Asterisk container is reachable, keyed by extension so a row known to only one source still shows. Capabilities add columns rather than nav buttons: Category/Status/Transport are .ea-only, Tier/Approved-numbers are .pstn-only, and both classes start on so nothing flashes before /api/ea-status and /api/pstn-status answer. Categories, Rooms, Groups, Concurrent-call caps and Personal numbers are cards under the same tab, gated the same way. Per-row Save picks its write path: tier + approved numbers + messaging via /api/pstn-permissions with a trunk installed, messaging alone via /api/pstn-messaging without one — which is what that endpoint has always been for. No backend changes; the standalone messaging-chips card and the duplicate Messaging column are both gone. Verified in Chromium against a fixture Asterisk config: full layout renders nine columns and six cards, the bare layout collapses to Ext/Name/Messaging with two cards, and both save paths write pstn-permissions.conf correctly (messaging-only leaves tier and allowed_numbers untouched). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NAddJGE1G6eGaPzmScG5Vh --- docs/pstn-calling-voipms-plan.md | 4 +- services/pstn-trunk.sh | 6 +- services/security-dashboard.sh | 720 +++++++++++++++---------------- 3 files changed, 360 insertions(+), 370 deletions(-) diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index 667da84..3d68c6a 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -39,7 +39,7 @@ hardcoded to it — any provider supporting IP authentication works. Covers: - A configurable **inbound ring-group** (one extension or several), each member's live tier/approved-numbers checked per inbound call via an unrolled per-member dialplan block (no AGI needed). -- **`services/security-dashboard.sh` integration** — a "PSTN Trunk" tab +- **`services/security-dashboard.sh` integration** — its Extensions tab shows both concurrency caps and every extension (parsed from `pjsip.conf`) with its live tier and approved numbers, all editable with no restart. This is what makes the tier model and caps actually @@ -212,7 +212,7 @@ separately from that hourly check. admin-controlled approved-list in the pattern position and the live call data in the tested-string position — worth keeping that direction if this is ever refactored. -- **Web UI — implemented.** `services/security-dashboard.sh`'s "PSTN Trunk" +- **Web UI — implemented.** `services/security-dashboard.sh`'s Extensions tab lists every extension (parsed from `pjsip.conf`, the same marker format Easy Asterisk's own `rebuild_dialplan()` uses) with a tier dropdown and approved-numbers field, saving straight to `pstn-permissions.conf`. diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index 7e8d565..eeab1bc 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -2091,7 +2091,7 @@ access specifically: Stored in \`config/asterisk/pstn-permissions.conf\`, read **live** by the dialplan via Asterisk's \`AST_CONFIG()\` on every call — editing this file -(by hand, or via the Security Dashboard's "PSTN Trunk" tab, if that service +(by hand, or via the Security Dashboard's Extensions tab, if that service is installed) takes effect on the next call, no restart needed. Re-running this installer in "update" mode never touches this file — only a "fresh" reinstall (with confirmation) or the web UI change it, the same protection @@ -2243,11 +2243,11 @@ Stored in \`config/asterisk/pstn-personal-dids.conf\` (DID -> owner, read live by the dialplan for inbound routing) and a \`personal_did=\` field per extension in \`pstn-permissions.conf\` (the outbound Caller-ID override) — both kept in sync automatically by the CLI installer and the Security -Dashboard's "PSTN Trunk" tab, live, no restart needed. +Dashboard's Extensions tab, live, no restart needed. ## Managing this from a web UI -If \`services/security-dashboard.sh\` is installed, its "PSTN Trunk" tab +If \`services/security-dashboard.sh\` is installed, its Extensions tab shows the per-extension permission tiers, the outbound/inbound concurrency caps, and personal-number assignments, all editable live — no restart, no reinstall. Install/update it any time with \`sudo ./setup.sh diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 0438726..a524027 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -64,16 +64,17 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then fi # ───────────────────────────────────────────────────────────────────────────── -register_service security-dashboard homelab "Security dashboard: Asterisk failed-connections + CrowdSec bans (Authelia-protected)" 8092 +register_service security-dashboard homelab "Security dashboard: Asterisk failed-connections + extension/trunk management + CrowdSec bans (Authelia-protected)" 8092 install_security-dashboard() { local APP_DIR="/opt/security-dashboard" local DASHBOARD_PORT=8092 local SVC_USER="secdash" - # Either Asterisk flavor works — prefer asterisk-digital-ocean if both - # happen to be installed, matching services/pstn-trunk.sh's own - # preference order for consistency. + # Either install layout works — prefer ~/docker/asterisk-digital-ocean + # (a droplet from before that service was merged into `asterisk`) if both + # happen to exist, matching services/pstn-trunk.sh's own preference order + # for consistency. local ASTERISK_EA_DIR="" if [ -d "$DOCKER_DIR/asterisk-digital-ocean" ]; then ASTERISK_EA_DIR="$DOCKER_DIR/asterisk-digital-ocean" @@ -86,9 +87,10 @@ install_security-dashboard() { # 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. + # `docker exec` into for the Extensions tab's device writes/CLI calls + # (ea_* functions) — "easy-asterisk", or "easy-asterisk-do" for a droplet + # set up before the two Asterisk services merged, matching whichever + # container_name services/asterisk.sh actually used there. local ASTERISK_EA_CONFIG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/config/easy-asterisk}" local ASTERISK_EA_CONTAINER="" if [[ "$ASTERISK_EA_DIR" == *asterisk-digital-ocean ]]; then @@ -100,17 +102,17 @@ install_security-dashboard() { 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 "│ Asterisk failed-connection log + one Extensions tab (devices, │" + echo "│ categories, rooms, groups, PSTN tiers, DIDs) + CrowdSec bans, │" + echo "│ one page. Runs natively on the host (not Docker) so it can call │" + echo "│ cscli and read Asterisk's files directly. Authelia-protected. │" echo "└─────────────────────────────────────────────────────────────────┘" echo "" if [ -z "$ASTERISK_EA_DIR" ]; then - log_warning "No asterisk-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." + log_warning "No Asterisk install detected." + log_warning "The Security Log and Extensions tabs will just be empty — CrowdSec's tab" + log_warning "still works fine." fi if [ "$DRY_RUN" = true ]; then @@ -118,7 +120,7 @@ install_security-dashboard() { 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 grant read/write access to the detected Asterisk config dir (for the Extensions tab)" echo "[DRY-RUN] Would configure Caddy + Authelia for a domain you'll be prompted for" return 0 fi @@ -202,59 +204,66 @@ 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. + +Three tabs: **Security Log**, **Extensions**, **CrowdSec**. The first two are +always there (they only need Asterisk itself, detected once at install time); +CrowdSec checks its own live install state on every page load and hides its +nav button if \`cscli\` isn't found. + +Extensions used to be three separate tabs — *Asterisk Admin*, *Extensions* +and *PSTN Trunk* — which between them listed the same extensions three times: +once as devices with a category/status, once as a row of messaging +checkboxes, and once as permission tiers. They're now one tab with one +extensions table, and each capability adds columns and cards to it instead of +a nav button of its own. That means the page still scales from a bare LAN +Asterisk box up to a full droplet with a trunk, without ever showing a +control for something that isn't set up — you just don't have to remember +which tab a given extension's settings live on. - **Security Log** — parses \`$ASTERISK_LOG_DIR/full\` for SIP auth failures (wrong password, unknown extension, etc.) with timestamp/account/remote IP, sortable per column (click a header to sort, click again to reverse). -- **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. +- **Extensions** — one row per extension, merged from \`pjsip.conf\` (which + always works) and, when the Easy Asterisk container is reachable, its own + device list. Columns: Ext, Name, then Category/Status/Transport if that + container is present, then Tier/Approved-numbers if a PSTN trunk dialplan + is installed, then Messaging (always — internal SIP texting has no PSTN + dependency at all: no cost, no carrier, no DID) and a per-row Save. Save + writes tier + approved numbers + messaging together when there's a trunk, + and messaging alone when there isn't. + - **Extensions** — add/rename/delete a SIP extension, reassign its category; + live registered/unregistered status per device. This is a native + reimplementation of Easy Asterisk's own vendored web admin + (\`vendor/easy-asterisk/easy-asterisk-v0.10.0.sh\`'s device/category/room + management), not a link or an iframe to that separate process — one page, + one login. Reads \`pjsip.conf\`/\`categories.conf\`/\`rooms.conf\` directly + (same formats the vendor's own \`easy-asterisk --rebuild-dialplan\` CLI + still generates the dialplan from); writes go through + \`docker exec ... tee\` (root, sudo-gated) instead of a direct host-side + file write, since Easy Asterisk's container writes these as its own + internal user and a host-side write would just be fighting that ownership + again on the next restart. Every write reloads PJSIP and/or rebuilds the + dialplan automatically, the same way the vendored admin's own actions do. - **Categories** — device profiles (an auto-answer default + description). - **Rooms** — ring groups/paging groups; add/remove members per room. - - 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. + - **Groups** — name a set of extensions and bulk-enable/disable messaging + for all of them at once. A management convenience only, not a runtime + concept: applying an action just writes the same per-extension + \`pstn-permissions.conf\` key each member's own checkbox would, and + membership changes never retroactively affect anything already applied. + (A group owning a personal DID *is* evaluated live against current + membership, though — see below.) + - **Concurrent-call caps** and **Personal numbers** appear only once + \`services/pstn-trunk.sh\`'s dialplan is actually installed + (\`pstn-trunk-dialplan.conf\` present), so the page never shows a + real-looking-but-unenforced editor. Caps are the outbound/inbound + concurrent-call limits; personal numbers map a DID to an owner extension + or group, additive to the shared trunk DID. Writes go directly to + \`pstn-limits.conf\` / \`pstn-permissions.conf\` / \`pstn-personal-dids.conf\`, + which the dialplan reads fresh on every call. The spend-cap kill-switch + and international-calling allow-list are deliberately **not** managed + here — CLI-only, via \`sudo ./setup.sh pstn-trunk\` — since both are more + security-sensitive than what this tab already exposes. - **CrowdSec** — its nav button only appears once \`cscli\` is detected on this host. Current bans (\`cscli decisions list\`), a delete/unban button per entry, carrier/ASN + country columns (sortable per column), and @@ -289,8 +298,8 @@ sudo journalctl -u security-dashboard -f \`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 tee\` against + root-owned files directly and shouldn't). Extension/device management (only + added if an Asterisk install is detected): \`docker exec -i tee\` against exactly \`pjsip.conf\`/\`categories.conf\`/\`rooms.conf\`, plus \`asterisk -rx "module reload res_pjsip.so"\`, \`asterisk -rx "pjsip show endpoints"\`, and @@ -386,7 +395,7 @@ _secdash_grant_asterisk_access() { # 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 + # read-only — the Extensions tab writes those through # `docker exec ... tee` instead (see the ea_* functions), not a direct # host-side write, so there's no reason to grant it write access at all. if [ -n "$_config_dir" ] && [ -d "$_config_dir" ]; then @@ -454,13 +463,13 @@ SDSVC _secdash_write_sudoers() { local _svc_user="$1" _ea_container="${2:-}" local _ea_lines="" - # Native Asterisk Admin tab (ea_* functions) — every write goes through + # Extensions tab device management (ea_* functions) — every write goes through # `docker exec -i tee ` instead of a direct # host-side file write (see _secdash_grant_asterisk_access's comment on # why), plus the two Asterisk CLI calls needed after a change and the # live registration-status check. All seven are exact commands, no # wildcards, scoped to the one container actually installed on this box. - # The last line (docker restart) backs the PSTN Trunk tab's "Commit + # The last line (docker restart) backs the Extensions tab's "Commit # Changes" button — see restart_asterisk_container()'s comment for why # that exists (AST_CONFIG() live-reads not always picking up dashboard # edits without a full container restart). @@ -1241,8 +1250,8 @@ def write_permission(ext, tier, numbers_raw, messaging_enabled=False): 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 + the Extensions tab's Messaging column when there's no PSTN trunk to + save alongside — messaging works whether or not one has been installed — messaging has no dependency on one (no cost, no carrier, no DID), unlike the calling-permissions table this dashboard otherwise gates behind pstn_installed(). Creates pstn-permissions.conf from scratch if it doesn't exist yet.""" @@ -1373,9 +1382,9 @@ 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.""" + alone can't tell us this. Without this check the Extensions tab would + show tier columns and a default-but-unenforced 10/10 cap even when there + is no PSTN trunk at all.""" if not ASTERISK_CONFIG_DIR: return False return os.path.isfile(os.path.join(ASTERISK_CONFIG_DIR, "pstn-trunk-dialplan.conf")) @@ -1583,7 +1592,7 @@ def remove_personal_did(did): return True, "Removed %s" % did -# ── Easy Asterisk Admin (native — devices, categories, rooms/ring-groups) ── +# ── Easy Asterisk device management (devices, categories, rooms/ring-groups) ── # Full reimplementation of vendor/easy-asterisk/easy-asterisk-v0.10.0.sh's # vendored web admin (its own separate process, normally reached via its own # port/domain) as native code here instead — one tab, one process, no @@ -1651,7 +1660,7 @@ def ea_rebuild_dialplan(): def restart_asterisk_container(): """Restarts the Easy Asterisk container - the "Commit Changes" button on - the PSTN Trunk tab. Confirmed live: dashboard writes to + the Extensions tab. Confirmed live: dashboard writes to pstn-permissions.conf/pstn-groups.conf/pstn-personal-dids.conf land on disk immediately (readable via a plain `cat` right after saving), but AST_CONFIG() in the dialplan sometimes kept returning a stale value @@ -1662,7 +1671,7 @@ def restart_asterisk_container(): throughout), but the restart reliably clears it, so this button exists instead of requiring every admin to rediscover "just restart it" the hard way. Uses the same ASTERISK_EA_CONTAINER/run_sudo mechanism as the - Easy Asterisk Admin tab's own docker exec calls - no new sudoers scope + Extensions tab's own docker exec calls - no new sudoers scope needed beyond the one line added for this.""" if not ASTERISK_EA_CONTAINER: return False, "No Asterisk container detected on this box" @@ -2258,16 +2267,23 @@ INDEX_HTML = """ .muted { color: #9aa4b2; font-size: 0.85rem; } a { color: #4f8cff; } #msg { margin-top: 0.5rem; font-size: 0.85rem; } + /* Capability gating for the Extensions tab. Everything that needs the Easy + Asterisk container (device/category/room writes) is .ea-only; everything + that needs a PSTN trunk dialplan is .pstn-only. Both classes start ON the + body so nothing flashes before /api/ea-status and /api/pstn-status answer, + and they're removed once those confirm. Marking cells rather than juggling + column indices keeps the one extensions table honest as columns come and + go. */ + body.no-ea .ea-only { display: none !important; } + body.no-pstn .pstn-only { display: none !important; } - +

Security Dashboard

@@ -2309,35 +2325,7 @@ INDEX_HTML = """ -