From 4f102b12c1135164b398edd7bb48186dae1088ea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 05:56:54 +0000 Subject: [PATCH 1/4] One dashboard to manage Asterisk: core tabs always on, optional tabs self-hide Restructure the nav so it reflects what's actually installed on this box, letting one dashboard URL cover everything from a bare LAN Asterisk box up to a full droplet with a trunk and CrowdSec: - Security Log and a new Extensions tab (Groups + Internal SIP messaging, split out of the old "PSTN Trunk" tab) are always available - they only need Asterisk itself, not a trunk or CrowdSec. - Asterisk Admin, PSTN Trunk, and CrowdSec each check their own live install state on every page load and hide their own nav button entirely when not present, instead of showing an empty/placeholder tab. - Add crowdsec_installed() (checks for /usr/bin/cscli) and a /api/crowdsec-status endpoint, mirroring the existing pstn_installed()/ /api/pstn-status pattern. This fixes the earlier design where messaging/groups management lived inside the PSTN Trunk tab even though both work with plain Asterisk and no trunk at all - hiding that tab would have taken them down with it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf --- services/security-dashboard.sh | 155 +++++++++++++++++++-------------- 1 file changed, 91 insertions(+), 64 deletions(-) diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 564e8e7..fc8dcf5 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -194,52 +194,24 @@ 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, - filterable per column (each header has its own text filter, live as you type). -- **CrowdSec** — current bans (\`cscli decisions list\`), a delete/unban button - per entry, carrier/ASN + country columns, 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. -- **PSTN Trunk** — a **Groups** card at the top (always available, whether - or not a PSTN trunk has ever been installed) 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. Below that, the rest of the tab detects - whether \`services/pstn-trunk.sh\`'s dialplan is - actually installed (\`pstn-trunk-dialplan.conf\` present) and shows a - clear "not installed" message instead of the calling-permissions editor - if not, so it never shows real-looking-but-unenforced defaults. When - installed: 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. Also manages personal-number assignments (DID -> - owner extension), 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. An "Internal SIP - messaging" card at the **bottom** of the tab (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) is always available - regardless of any of the above. + sortable per column (click a header to sort, click again to reverse). - **Asterisk Admin** — an embedded, lazy-loaded iframe of the real Asterisk web admin (only fetched the first time you open the tab), plus an - "open in a new tab" fallback link that's always there regardless. Only - shows up once an Asterisk install is detected. If a local Caddy install is - found for both this dashboard and the Asterisk admin's own domain, install - automatically patches the admin's Caddy site block from + "open in a new tab" fallback link that's always there regardless. Its nav + button only appears once an Asterisk install is detected. If a local Caddy + install is found for both this dashboard and the Asterisk admin's own + domain, install automatically patches the admin's Caddy site block from `X-Frame-Options` to a `Content-Security-Policy: frame-ancestors` entry naming only this dashboard's domain, so the browser actually allows the frame — every other site is still refused framing exactly as before. This @@ -248,6 +220,43 @@ not in Docker — it needs to call \`cscli\` and read Asterisk's log directly. confirmed against Authelia's own portal-framing behavior on a live install) — if the tab shows a blank frame, use the fallback link and check this service's own log output from install time for a manual one-line fix. +- **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 \`\`\` @@ -814,6 +823,14 @@ def run_sudo(args, timeout=15): 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(): @@ -1524,9 +1541,10 @@ INDEX_HTML = """

Security Dashboard

@@ -1566,7 +1584,7 @@ INDEX_HTML = """
-
@@ -2238,7 +3265,7 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/" or self.path == "": - html = INDEX_HTML.replace("__ASTERISK_ADMIN_PROXIED__", "true" if ASTERISK_ADMIN_PROXIED else "") + html = INDEX_HTML self._html(html) elif self.path == "/api/security-events": self._json(parse_security_log()) @@ -2270,6 +3297,14 @@ class Handler(BaseHTTPRequestHandler): 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) @@ -2316,6 +3351,54 @@ class Handler(BaseHTTPRequestHandler): 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) From ff61e8f733732bbbc153c58d2a65526ef8d3a344 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 12:38:45 +0000 Subject: [PATCH 4/4] Chain Security Dashboard + PSTN trunk setup into the Asterisk install flow asterisk.sh and asterisk-digital-ocean.sh now offer, at the end of both their fresh-install and update-mode paths, to also set up the Security Dashboard and configure a real PSTN trunk in the same run - one script walks through the whole stack instead of needing to separately remember and run `sudo ./setup.sh security-dashboard` / `sudo ./setup.sh pstn-trunk` afterward. Both target services keep their own register_service call and stay fully independently invocable - this is purely an additive convenience layer (_asterisk_offer_dashboard_and_trunk / _asterisk_do_offer_dashboard_and_trunk), not a replacement. An already-installed piece is silently refreshed (install_security-dashboard/install_pstn-trunk each already have their own update/fresh/cancel reinstall-mode gate, so calling them again just does the right thing); a not-yet-installed piece gets one y/n instead of every detailed prompt firing. Guarded with declare -F so a standalone `sudo bash asterisk.sh` copy (no sibling services/*.sh files sourced) skips both cleanly with an explanatory message instead of erroring on an undefined function. Verified: full sourcing simulation resolves all four install_* functions correctly, `setup.sh --dry-run --unattended asterisk` and `asterisk-digital-ocean` both complete cleanly end-to-end, and `setup.sh --list` still shows all four services as independently selectable. Documented the pattern in CLAUDE.md under a new "Chaining into another service from within your own" section for future contributors. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ho9mZgAkVpdz7S5wJkg8Nf --- CLAUDE.md | 35 ++++++++++++++++++++ services/asterisk-digital-ocean.sh | 44 +++++++++++++++++++++++++ services/asterisk.sh | 53 ++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index d418374..451aa3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -438,6 +438,41 @@ a reason (a stray Enter on a service you're just checking on shouldn't trigger anything). `fresh` runs the exact same flow a first-time install would, prompts included. +## Chaining into another service from within your own + +A service can call another service's `install_()` directly as a +convenience step at the end of its own flow, instead of making the user +remember to separately run `sudo ./setup.sh ` afterward. +`services/asterisk.sh`/`services/asterisk-digital-ocean.sh` do this for +`services/security-dashboard.sh` and `services/pstn-trunk.sh` — after +Asterisk itself is installed/updated, each asks once whether to also set up +the dashboard and/or a PSTN trunk (or, if either is already installed, +silently re-invokes it so it gets refreshed as part of the same run — its +own `prompt_reinstall_mode` gate decides update vs. skip, so this never +re-asks the target service's detailed prompts unless the user is actually +setting it up fresh). + +The target service **keeps its own `register_service` call** — it stays +independently selectable/invocable exactly as before (`sudo ./setup.sh +pstn-trunk` still works standalone). Chaining is purely additive, not a +replacement for the target's own entry point, so nothing breaks for anyone +already relying on running it directly. + +Guard every cross-file call with `declare -F`, since a service can also run +completely standalone (`sudo bash asterisk.sh`, no `setup.sh`, no sibling +`services/*.sh` files sourced at all): + +```bash +if declare -F install_security-dashboard >/dev/null 2>&1; then + install_security-dashboard +fi +``` + +Only chain in one direction, and only when the relationship is genuinely +one-way (the target is meaningless without the caller already installed — +`pstn-trunk.sh` itself says so in its own error message when Asterisk isn't +present). Don't have both sides call each other. + ## .env files and secrets Generate passwords with `generate_password` (never hardcode them). diff --git a/services/asterisk-digital-ocean.sh b/services/asterisk-digital-ocean.sh index b529a6e..ee6ce1b 100755 --- a/services/asterisk-digital-ocean.sh +++ b/services/asterisk-digital-ocean.sh @@ -645,6 +645,44 @@ ENV log_info "never alerts by itself, since there's no prior state to compare against yet." } +# See services/asterisk.sh's own copy for the full rationale — identical +# here, just calling into the same install_security-dashboard/ +# install_pstn-trunk entry points (still independently registered/ +# invocable; this is a convenience layer on top, not a replacement). +_asterisk_do_offer_dashboard_and_trunk() { + local EA_DIR="$1" + + if ! declare -F install_security-dashboard >/dev/null 2>&1 && ! declare -F install_pstn-trunk >/dev/null 2>&1; then + log_info "Run this from the full ubuntu-post-install repo (not a standalone copy) to also" + log_info "get prompts here for the Security Dashboard and a PSTN trunk — skipping both." + return 0 + fi + + if declare -F install_security-dashboard >/dev/null 2>&1; then + echo "" + if [[ -f "$DOCKER_DIR/security-dashboard/app.py" ]]; then + log_info "Security Dashboard already installed — refreshing it too..." + install_security-dashboard + else + local _WANT_DASH="" + prompt_yn "Set up the Security Dashboard (Security Log, Extensions, Asterisk Admin, PSTN Trunk, CrowdSec — one page)? (y/n):" "y" _WANT_DASH + [[ "$_WANT_DASH" =~ ^[Yy]$ ]] && install_security-dashboard + fi + fi + + if declare -F install_pstn-trunk >/dev/null 2>&1; then + echo "" + if [[ -f "$EA_DIR/config/asterisk/pstn-trunk-dialplan.conf" ]]; then + log_info "PSTN trunk already configured — refreshing it too..." + install_pstn-trunk + else + local _WANT_TRUNK="" + prompt_yn "Configure a real SIP/PSTN trunk (actual outside phone numbers, e.g. Anveo Direct/VoIP.ms)? (y/n):" "n" _WANT_TRUNK + [[ "$_WANT_TRUNK" =~ ^[Yy]$ ]] && install_pstn-trunk + fi + fi +} + # ── Shared: docker-compose.yml ───────────────────────────────────────────── # Same reasoning as above — one copy of the template used by both fresh # installs and updates. Must be called with $PWD already at $EA_DIR. @@ -740,6 +778,9 @@ install_asterisk-digital-ocean() { echo "[DRY-RUN] gated live on each sender's 'messaging' flag in pstn-permissions.conf (the" echo "[DRY-RUN] same file/flag the Security Dashboard's checkbox writes) — independent of" echo "[DRY-RUN] whether the PSTN trunk is installed; migrates any already-existing devices too" + echo "[DRY-RUN] Would offer to also set up the Security Dashboard and a PSTN trunk in this" + echo "[DRY-RUN] same run (calling services/security-dashboard.sh / services/pstn-trunk.sh" + echo "[DRY-RUN] directly — both stay independently invocable via their own service name too)" return 0 fi @@ -778,6 +819,7 @@ install_asterisk-digital-ocean() { fi _asterisk_do_run_presence_step "$EA_DIR" + _asterisk_do_offer_dashboard_and_trunk "$EA_DIR" local _EXISTING_DOMAIN _EXISTING_PORT _EXISTING_DOMAIN="$(grep -E '^DOMAIN_NAME=' .env | cut -d= -f2-)" @@ -1425,6 +1467,8 @@ MD || log_warning "Start failed — check: docker compose logs" fi + _asterisk_do_offer_dashboard_and_trunk "$EA_DIR" + # ── Summary ─────────────────────────────────────────────────────────────── echo "" log_success "Easy Asterisk (DigitalOcean edition) installed at $EA_DIR" diff --git a/services/asterisk.sh b/services/asterisk.sh index 060a1c4..ad5f4fb 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -609,6 +609,53 @@ ENV log_info "never alerts by itself, since there's no prior state to compare against yet." } +# Offers to add/refresh the Security Dashboard and a PSTN trunk as part of +# this SAME run, instead of needing to separately remember and run +# `sudo ./setup.sh security-dashboard` / `sudo ./setup.sh pstn-trunk` +# afterward. Neither loses its own independent registration/invocability — +# this is purely a convenience layer on top, called from both the fresh- +# install and update-mode paths below. An already-installed piece is just +# silently refreshed (install_security-dashboard/install_pstn-trunk each +# have their own update/fresh/cancel reinstall-mode gate, so calling them +# again here does the right thing automatically); a not-yet-installed piece +# gets a one-line y/n instead of every detailed prompt firing unconditionally. +_asterisk_offer_dashboard_and_trunk() { + local EA_DIR="$1" + + # Only available when run through the full repo's setup.sh (which + # sources every services/*.sh file, including these two) — a standalone + # `sudo bash asterisk.sh` copy has neither function defined at all. + if ! declare -F install_security-dashboard >/dev/null 2>&1 && ! declare -F install_pstn-trunk >/dev/null 2>&1; then + log_info "Run this from the full ubuntu-post-install repo (not a standalone copy) to also" + log_info "get prompts here for the Security Dashboard and a PSTN trunk — skipping both." + return 0 + fi + + if declare -F install_security-dashboard >/dev/null 2>&1; then + echo "" + if [[ -f "$DOCKER_DIR/security-dashboard/app.py" ]]; then + log_info "Security Dashboard already installed — refreshing it too..." + install_security-dashboard + else + local _WANT_DASH="" + prompt_yn "Set up the Security Dashboard (Security Log, Extensions, Asterisk Admin, PSTN Trunk, CrowdSec — one page)? (y/n):" "y" _WANT_DASH + [[ "$_WANT_DASH" =~ ^[Yy]$ ]] && install_security-dashboard + fi + fi + + if declare -F install_pstn-trunk >/dev/null 2>&1; then + echo "" + if [[ -f "$EA_DIR/config/asterisk/pstn-trunk-dialplan.conf" ]]; then + log_info "PSTN trunk already configured — refreshing it too..." + install_pstn-trunk + else + local _WANT_TRUNK="" + prompt_yn "Configure a real SIP/PSTN trunk (actual outside phone numbers, e.g. Anveo Direct/VoIP.ms)? (y/n):" "n" _WANT_TRUNK + [[ "$_WANT_TRUNK" =~ ^[Yy]$ ]] && install_pstn-trunk + fi + fi +} + # ── Shared: docker-compose.yml ───────────────────────────────────────────── # Same reasoning as above — one copy of the template used by both fresh # installs and updates. Must be called with $PWD already at $EA_DIR. @@ -700,6 +747,9 @@ install_asterisk() { echo "[DRY-RUN] Would offer optional ntfy alerts on extension registration going offline/online" echo "[DRY-RUN] (checked every 2 minutes via systemd timer, cron.d fallback; always asked," echo "[DRY-RUN] update mode included)" + echo "[DRY-RUN] Would offer to also set up the Security Dashboard and a PSTN trunk in this" + echo "[DRY-RUN] same run (calling services/security-dashboard.sh / services/pstn-trunk.sh" + echo "[DRY-RUN] directly — both stay independently invocable via their own service name too)" return 0 fi @@ -737,6 +787,7 @@ install_asterisk() { fi _asterisk_run_presence_step "$EA_DIR" + _asterisk_offer_dashboard_and_trunk "$EA_DIR" local _EXISTING_DOMAIN _EXISTING_PORT _EXISTING_DOMAIN="$(grep -E '^DOMAIN_NAME=' .env | cut -d= -f2-)" @@ -1041,6 +1092,8 @@ MD || log_warning "Start failed — check: docker compose logs" fi + _asterisk_offer_dashboard_and_trunk "$EA_DIR" + # ── Summary ─────────────────────────────────────────────────────────────── echo "" log_success "Easy Asterisk installed at $EA_DIR"