diff --git a/services/asterisk-digital-ocean.sh b/services/asterisk-digital-ocean.sh index b529a6e..94a9306 100755 --- a/services/asterisk-digital-ocean.sh +++ b/services/asterisk-digital-ocean.sh @@ -469,6 +469,75 @@ _asterisk_do_patch_messaging_vendor_files() { log_success "Vendor generator functions patched for internal SIP messaging." } +# ── Shared: sub-path-aware web admin (for native Caddy path-proxying) ────── +# See services/asterisk.sh's own copy of this pair of functions for the full +# rationale (verified against the real vendored file: the admin's only +# absolute-path reference anywhere is `const API_BASE = '/api';`, no other +# hrefs/redirects, plain HTTP Basic Auth instead of a login-page flow) — +# identical here since both services vendor the exact same easy-asterisk +# source, just under this service's own `_asterisk_do_` naming. +_asterisk_do_patch_webadmin_base_path() { + local EA_DIR="$1" + local EASY1="$EA_DIR/easy-asterisk.sh" + local EASY2 + EASY2="$(find "$EA_DIR" -maxdepth 1 -name 'easy-asterisk-v*.sh' | head -1)" + [[ -z "$EASY2" ]] && EASY2="$EA_DIR/easy-asterisk-v0.10.0.sh" + + local BASE_PATH_LINE="BASE_PATH = os.environ.get('WEBADMIN_BASE_PATH', '').rstrip('/')" + local REPLACE_LINE + REPLACE_LINE=$(cat <<'PYLINE' +HTML_TEMPLATE = HTML_TEMPLATE.replace("const API_BASE = '/api';", "const API_BASE = '" + BASE_PATH + "/api';") +PYLINE +) + + local f TMP_FILE + for f in "$EASY1" "$EASY2"; do + [[ -f "$f" ]] || continue + grep -q "WEBADMIN_BASE_PATH" "$f" && continue # already patched + + if ! grep -qF "PORT = int(os.environ.get('WEBADMIN_PORT', 8080))" "$f"; then + log_warning "$(basename "$f"): WEBADMIN_PORT anchor not found — vendor template changed upstream." + log_warning " Sub-path proxying for the web admin won't work correctly until this is patched by hand." + continue + fi + if ! grep -qF "class WebAdminHandler(http.server.BaseHTTPRequestHandler):" "$f"; then + log_warning "$(basename "$f"): WebAdminHandler anchor not found — vendor template changed upstream." + log_warning " Sub-path proxying for the web admin won't work correctly until this is patched by hand." + continue + fi + + TMP_FILE="$(mktemp)" + awk -v base_line="$BASE_PATH_LINE" -v replace_line="$REPLACE_LINE" ' + index($0, "PORT = int(os.environ.get(") == 1 { + print + print base_line + next + } + index($0, "class WebAdminHandler(http.server.BaseHTTPRequestHandler):") == 1 { + print replace_line + print "" + } + { print } + ' "$f" > "$TMP_FILE" && mv "$TMP_FILE" "$f" + done + log_success "Vendor web admin script patched for sub-path proxying support." +} + +_asterisk_do_patch_webadmin_entrypoint_env() { + local EA_DIR="$1" + local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh" + [[ -f "$ENTRYPOINT" ]] || return 0 + grep -q "WEBADMIN_BASE_PATH=" "$ENTRYPOINT" && return 0 # already patched + + if grep -qF 'WEBADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}" \' "$ENTRYPOINT"; then + sed -i 's|WEBADMIN_AUTH_DISABLED="\${WEB_ADMIN_AUTH_DISABLED:-false}" \\|WEBADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}" \\\n WEBADMIN_BASE_PATH="${WEB_ADMIN_BASE_PATH:-}" \\|' "$ENTRYPOINT" + log_success "Live entrypoint.sh patched to pass WEBADMIN_BASE_PATH through to the web admin." + else + log_warning "$(basename "$ENTRYPOINT"): WEBADMIN_AUTH_DISABLED anchor not found — vendor template changed upstream." + log_warning " Sub-path proxying for the web admin won't work correctly until this is patched by hand." + fi +} + # Confirmed live (2026-07-23, via a real pstn-trunk.sh failure that hit this # same mechanism): the vendor-generator patch above only takes effect on a # FUTURE regeneration, and Easy Asterisk's own entrypoint only regenerates @@ -767,6 +836,9 @@ install_asterisk-digital-ocean() { _asterisk_do_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" _asterisk_do_ensure_live_messaging_include "$EA_DIR" _asterisk_do_migrate_existing_devices_message_context "$EA_DIR/config/asterisk/pjsip.conf" + _asterisk_do_patch_webadmin_base_path "$EA_DIR" + _asterisk_do_patch_webadmin_entrypoint_env "$EA_DIR" + grep -q '^WEB_ADMIN_BASE_PATH=' .env || echo 'WEB_ADMIN_BASE_PATH=' >> .env ensure_docker_dir_ownership "$EA_DIR/config/asterisk" chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" @@ -843,6 +915,8 @@ install_asterisk-digital-ocean() { _asterisk_do_patch_messaging_vendor_files "$EA_DIR" _asterisk_do_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" _asterisk_do_ensure_live_messaging_include "$EA_DIR" + _asterisk_do_patch_webadmin_base_path "$EA_DIR" + _asterisk_do_patch_webadmin_entrypoint_env "$EA_DIR" ensure_docker_dir_ownership "$EA_DIR/config/asterisk" chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" @@ -907,6 +981,12 @@ install_asterisk-digital-ocean() { log_info "Port 8081 was already taken — web admin will use ${WEB_ADMIN_PORT_VAL} instead." fi + # If the Security Dashboard is already installed, it's going to front + # this admin natively at /asterisk-admin/ (see services/security-dashboard.sh) — + # pre-set the base path now so it works immediately, no update cycle needed. + local WEB_ADMIN_BASE_PATH_VAL="" + [[ -d "$DOCKER_DIR/security-dashboard" ]] && WEB_ADMIN_BASE_PATH_VAL="/asterisk-admin" + # ── .env ────────────────────────────────────────────────────────────────── cat > .env << ENV # ── Domain ──────────────────────────────────────────────────── @@ -938,6 +1018,9 @@ VLAN_SUBNETS= # both firewall layers to match. WEB_ADMIN_PORT=${WEB_ADMIN_PORT_VAL} WEB_ADMIN_AUTH_DISABLED=false +# Set to /asterisk-admin by the Security Dashboard when it fronts this admin +# natively via Caddy path-proxying (no iframe) — leave empty otherwise. +WEB_ADMIN_BASE_PATH=${WEB_ADMIN_BASE_PATH_VAL} ENV chmod 600 .env @@ -959,6 +1042,27 @@ ENV log_info "No FQDN set — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL} (nothing for Caddy to do)." elif [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -z "${CADDY_REMOTE_HOST:-}" ]]; then log_info "Caddy not installed — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL}, SIP TLS stays self-signed." + elif [[ -d "$DOCKER_DIR/security-dashboard" ]]; then + # The dashboard owns fronting this admin instead — natively, at + # https:///asterisk-admin/, via Caddy path-proxying + # (see services/security-dashboard.sh's _secdash_configure_caddy) + # rather than a separate site block here. The SIP-TLS-cert-sync + # requirement above only needs SOME active Caddy site block for + # DOMAIN_NAME to exist — it doesn't require THIS service's own block + # specifically — and the dashboard's own domain prompt defaults to + # this exact DOMAIN_NAME when it detects this droplet, so the common + # case still ends up with Caddy serving DOMAIN_NAME (satisfying SIP + # TLS) with no separate admin domain needed at all. + log_info "Security Dashboard detected — it fronts the Asterisk web admin natively" + log_info "at https:///asterisk-admin/ (no iframe, one URL for both)." + log_info "Its own domain prompt defaults to this droplet's DOMAIN_NAME (${DOMAIN_NAME})," + log_info "so SIP TLS still gets a real cert as long as you accept that default." + log_info "Re-run 'sudo ./setup.sh security-dashboard' (update mode) to reconfigure that." + # Caddy (whichever domain the dashboard ends up using) reaches this + # over the host's internal network either way — no need to also keep + # the port open to the public internet, same as the local-Caddy case + # just below. + WEB_ADMIN_PUBLIC_ACCESS_NEEDED=false else local EXTRA_BLOCK="" if [ -d "$DOCKER_DIR/authelia" ]; then diff --git a/services/asterisk.sh b/services/asterisk.sh index 060a1c4..a33575d 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -433,6 +433,96 @@ _asterisk_patch_messaging_vendor_files() { log_success "Vendor generator functions patched for internal SIP messaging." } +# ── Shared: sub-path-aware web admin (for native Caddy path-proxying) ────── +# The vendored web admin's own JS hardcodes `const API_BASE = '/api';` — +# confirmed via grep against the actual vendored source: it's the ONLY +# absolute-path reference anywhere in the admin's HTML/JS (no other hrefs, +# no login-page redirect — it challenges with plain HTTP Basic Auth via a +# 401/WWW-Authenticate response instead of a redirect flow). That one +# hardcoded root path is what would break if this admin were ever reverse- +# proxied on a sub-path (e.g. Caddy's `handle_path /asterisk-admin/*`) +# instead of its own dedicated domain: the browser resolves each fetch()'s +# absolute path against the current origin's ROOT, not the sub-path it was +# actually served under, so every /api/... call 404s. This patches API_BASE +# to prefix itself with a WEBADMIN_BASE_PATH env var (empty string = today's +# behavior, completely unchanged) so a sub-path mount works correctly. +# Verified against the real vendored file: patched output is +# '/asterisk-admin/api' when the env var is set, and stays exactly '/api' +# when it's unset — both confirmed by executing the patched module's +# top-level code directly, not just eyeballing the diff. +# +# entrypoint.sh already regenerates this script UNCONDITIONALLY on every +# container start (`easy-asterisk --write-web-admin-script`, no +# `[[ ! -f ]]` guard unlike pjsip.conf/extensions.conf — confirmed in its +# own source), so patching only the generator source here is sufficient; +# no separate live-file patch is needed the way messaging needed one. +_asterisk_patch_webadmin_base_path() { + local EA_DIR="$1" + local EASY1="$EA_DIR/easy-asterisk.sh" + local EASY2 + EASY2="$(find "$EA_DIR" -maxdepth 1 -name 'easy-asterisk-v*.sh' | head -1)" + [[ -z "$EASY2" ]] && EASY2="$EA_DIR/easy-asterisk-v0.10.0.sh" + + local BASE_PATH_LINE="BASE_PATH = os.environ.get('WEBADMIN_BASE_PATH', '').rstrip('/')" + local REPLACE_LINE + REPLACE_LINE=$(cat <<'PYLINE' +HTML_TEMPLATE = HTML_TEMPLATE.replace("const API_BASE = '/api';", "const API_BASE = '" + BASE_PATH + "/api';") +PYLINE +) + + local f TMP_FILE + for f in "$EASY1" "$EASY2"; do + [[ -f "$f" ]] || continue + grep -q "WEBADMIN_BASE_PATH" "$f" && continue # already patched + + if ! grep -qF "PORT = int(os.environ.get('WEBADMIN_PORT', 8080))" "$f"; then + log_warning "$(basename "$f"): WEBADMIN_PORT anchor not found — vendor template changed upstream." + log_warning " Sub-path proxying for the web admin won't work correctly until this is patched by hand." + continue + fi + if ! grep -qF "class WebAdminHandler(http.server.BaseHTTPRequestHandler):" "$f"; then + log_warning "$(basename "$f"): WebAdminHandler anchor not found — vendor template changed upstream." + log_warning " Sub-path proxying for the web admin won't work correctly until this is patched by hand." + continue + fi + + TMP_FILE="$(mktemp)" + awk -v base_line="$BASE_PATH_LINE" -v replace_line="$REPLACE_LINE" ' + index($0, "PORT = int(os.environ.get(") == 1 { + print + print base_line + next + } + index($0, "class WebAdminHandler(http.server.BaseHTTPRequestHandler):") == 1 { + print replace_line + print "" + } + { print } + ' "$f" > "$TMP_FILE" && mv "$TMP_FILE" "$f" + done + log_success "Vendor web admin script patched for sub-path proxying support." +} + +# Companion to the above: entrypoint.sh explicitly passes only WEBADMIN_PORT +# and WEBADMIN_AUTH_DISABLED as env vars to the web admin script (see its own +# "Start Web Admin in background" step) — WEBADMIN_BASE_PATH needs the same +# explicit pass-through, or the container's own WEB_ADMIN_BASE_PATH (from +# .env) never actually reaches the Python process reading it. +_asterisk_patch_webadmin_entrypoint_env() { + local EA_DIR="$1" + local ENTRYPOINT="$EA_DIR/docker/entrypoint.sh" + [[ -f "$ENTRYPOINT" ]] || return 0 + grep -q "WEBADMIN_BASE_PATH=" "$ENTRYPOINT" && return 0 # already patched + + if grep -qF 'WEBADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}" \' "$ENTRYPOINT"; then + sed -i 's|WEBADMIN_AUTH_DISABLED="\${WEB_ADMIN_AUTH_DISABLED:-false}" \\|WEBADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}" \\\n WEBADMIN_BASE_PATH="${WEB_ADMIN_BASE_PATH:-}" \\|' "$ENTRYPOINT" + log_success "Live entrypoint.sh patched to pass WEBADMIN_BASE_PATH through to the web admin." + else + log_warning "$(basename "$ENTRYPOINT"): WEBADMIN_AUTH_DISABLED anchor not found — vendor template changed upstream." + log_warning " Sub-path proxying for the web admin won't work correctly until this is patched by hand." + fi +} + # Confirmed live (2026-07-23, via a real pstn-trunk.sh failure that hit this # same mechanism): the vendor-generator patch above only takes effect on a # FUTURE regeneration, and Easy Asterisk's own entrypoint only regenerates @@ -726,6 +816,9 @@ install_asterisk() { _asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" _asterisk_ensure_live_messaging_include "$EA_DIR" _asterisk_migrate_existing_devices_message_context "$EA_DIR/config/asterisk/pjsip.conf" + _asterisk_patch_webadmin_base_path "$EA_DIR" + _asterisk_patch_webadmin_entrypoint_env "$EA_DIR" + grep -q '^WEB_ADMIN_BASE_PATH=' .env || echo 'WEB_ADMIN_BASE_PATH=' >> .env ensure_docker_dir_ownership "$EA_DIR/config/asterisk" chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" @@ -771,6 +864,8 @@ install_asterisk() { _asterisk_refresh_vendor_files _asterisk_patch_messaging_vendor_files "$EA_DIR" _asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf" + _asterisk_patch_webadmin_base_path "$EA_DIR" + _asterisk_patch_webadmin_entrypoint_env "$EA_DIR" ensure_docker_dir_ownership "$EA_DIR/config/asterisk" chmod 644 "$EA_DIR/config/asterisk/messaging-dialplan.conf" @@ -841,6 +936,12 @@ install_asterisk() { log_info "Port 8081 was already taken — web admin will use ${WEB_ADMIN_PORT_VAL} instead." fi + # If the Security Dashboard is already installed, it's going to front + # this admin natively at /asterisk-admin/ (see services/security-dashboard.sh) — + # pre-set the base path now so it works immediately, no update cycle needed. + local WEB_ADMIN_BASE_PATH_VAL="" + [[ -d "$DOCKER_DIR/security-dashboard" ]] && WEB_ADMIN_BASE_PATH_VAL="/asterisk-admin" + # ── .env ────────────────────────────────────────────────────────────────── cat > .env << ENV # ── Domain ──────────────────────────────────────────────────── @@ -871,6 +972,9 @@ VLAN_SUBNETS=${VLAN_SUBNETS_VAL} # any firewall rules to match. WEB_ADMIN_PORT=${WEB_ADMIN_PORT_VAL} WEB_ADMIN_AUTH_DISABLED=false +# Set to /asterisk-admin by the Security Dashboard when it fronts this admin +# natively via Caddy path-proxying (no iframe) — leave empty otherwise. +WEB_ADMIN_BASE_PATH=${WEB_ADMIN_BASE_PATH_VAL} ENV chmod 600 .env @@ -879,17 +983,36 @@ ENV # correctly: if a local Caddy ends up fronting the web admin, there's no # reason to also expose it on the LAN — Caddy already reaches it over # the host's internal network (host.docker.internal). + # + # If the Security Dashboard is already here, it owns fronting this admin + # instead — natively, at https:///asterisk-admin/, via + # Caddy path-proxying (see services/security-dashboard.sh's + # _secdash_configure_caddy) rather than a separate dedicated domain. Two + # independent Caddy blocks both proxying the same port would just mean + # two working URLs instead of one, defeating the point — skip this + # service's own domain prompt entirely in that case. CADDY_SERVICE_MODE + # is still "local" either way (Caddy reaches it over host.docker.internal + # regardless of which Caddy block does the reaching), so the firewall + # scoping below stays correct without changes. local EXTRA_BLOCK="" - if [ -d "$DOCKER_DIR/authelia" ]; then - local _use_auth="" - prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth - if [[ "$_use_auth" =~ ^[Yy]$ ]]; then - EXTRA_BLOCK=" import authelia" - # Disable built-in auth since Authelia handles it - sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env + if [[ -d "$DOCKER_DIR/security-dashboard" ]]; then + log_info "Security Dashboard detected — it fronts the Asterisk web admin natively" + log_info "at https:///asterisk-admin/ (no iframe, one URL for both)." + log_info "Re-run 'sudo ./setup.sh security-dashboard' (update mode) to reconfigure that." + CADDY_SERVICE_CONFIGURED=true + CADDY_SERVICE_MODE=local + else + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth + if [[ "$_use_auth" =~ ^[Yy]$ ]]; then + EXTRA_BLOCK=" import authelia" + # Disable built-in auth since Authelia handles it + sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env + fi fi + configure_caddy_for_service "Asterisk Web Admin" "${WEB_ADMIN_PORT_VAL}" "asterisk" "$EXTRA_BLOCK" fi - configure_caddy_for_service "Asterisk Web Admin" "${WEB_ADMIN_PORT_VAL}" "asterisk" "$EXTRA_BLOCK" # ── UFW firewall rules ──────────────────────────────────────────────────── if command -v ufw &>/dev/null; then diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index fc8dcf5..98230d3 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -83,11 +83,17 @@ install_security-dashboard() { local ASTERISK_LOG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/logs}" local ASTERISK_CONFIG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/config/asterisk}" - local ASTERISK_ADMIN_URL="" + # Port to natively path-proxy at /asterisk-admin/ (see + # _secdash_configure_caddy) — no separate domain, no iframe. EA_DOMAIN is + # only used to default the dashboard's OWN domain prompt: on a droplet, + # DOMAIN_NAME is already required to match Caddy's cert for SIP TLS (see + # services/asterisk-digital-ocean.sh), so defaulting to the SAME domain + # here is what makes "one URL" the path of least resistance instead of + # something the user has to know to ask for. + local ASTERISK_ADMIN_PORT="" ASTERISK_EA_DOMAIN="" if [ -n "$ASTERISK_EA_DIR" ] && [ -f "$ASTERISK_EA_DIR/.env" ]; then - local _ea_domain - _ea_domain="$(grep -E '^DOMAIN_NAME=' "$ASTERISK_EA_DIR/.env" | cut -d= -f2-)" - [ -n "$_ea_domain" ] && ASTERISK_ADMIN_URL="https://${_ea_domain}" + ASTERISK_ADMIN_PORT="$(grep -E '^WEB_ADMIN_PORT=' "$ASTERISK_EA_DIR/.env" | cut -d= -f2-)" + ASTERISK_EA_DOMAIN="$(grep -E '^DOMAIN_NAME=' "$ASTERISK_EA_DIR/.env" | cut -d= -f2-)" fi echo "" @@ -131,7 +137,15 @@ install_security-dashboard() { _secdash_write_app "$APP_DIR" _secdash_write_asn_helper "$APP_DIR" _secdash_write_sudoers "$SVC_USER" - _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_ADMIN_URL" + + # Preserve whatever this box's ASTERISK_ADMIN_PROXIED already + # is (we're not touching Caddy in this branch) instead of + # silently defaulting it back to false on every plain update. + local _CUR_ADMIN_PROXIED="false" + if [ -f /etc/systemd/system/security-dashboard.service ]; then + _CUR_ADMIN_PROXIED="$(grep -oP '(?<=ASTERISK_ADMIN_PROXIED=)\S+' /etc/systemd/system/security-dashboard.service 2>/dev/null || echo false)" + fi + _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$_CUR_ADMIN_PROXIED" systemctl restart security-dashboard 2>/dev/null \ && log_success "security-dashboard restarted" \ || log_warning "Restart failed — check: systemctl status security-dashboard" @@ -141,7 +155,10 @@ install_security-dashboard() { 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" "$ASTERISK_ADMIN_URL" + SECDASH_ADMIN_PROXIED=false + _secdash_configure_caddy "$DASHBOARD_PORT" "$ASTERISK_ADMIN_PORT" "$ASTERISK_EA_DIR" "$ASTERISK_EA_DOMAIN" + _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$SECDASH_ADMIN_PROXIED" + systemctl restart security-dashboard 2>/dev/null || true fi return 0 ;; @@ -167,7 +184,7 @@ install_security-dashboard() { _secdash_write_asn_helper "$APP_DIR" _secdash_write_sudoers "$SVC_USER" - _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "$ASTERISK_ADMIN_URL" + _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "false" systemctl daemon-reload systemctl enable security-dashboard >/dev/null 2>&1 @@ -184,7 +201,12 @@ install_security-dashboard() { # _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" "$ASTERISK_ADMIN_URL" + SECDASH_ADMIN_PROXIED=false + _secdash_configure_caddy "$DASHBOARD_PORT" "$ASTERISK_ADMIN_PORT" "$ASTERISK_EA_DIR" "$ASTERISK_EA_DOMAIN" + if [[ "$SECDASH_ADMIN_PROXIED" == true ]]; then + _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "true" + systemctl restart security-dashboard 2>/dev/null || true + fi write_readme "$APP_DIR" << README_MD # Security Dashboard @@ -206,20 +228,20 @@ 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** — 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. 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 - is best-effort (it depends on matching the exact header line - `services/asterisk-digital-ocean.sh` itself writes, and hasn't been - 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. +- **Asterisk Admin** — a link to the real Asterisk web admin, proxied + natively at \`/asterisk-admin/\` on this SAME domain — no iframe, no + separate domain to log into. Wired up by \`_secdash_configure_caddy\`: Caddy + path-routes that address straight to the admin container + (\`host.docker.internal:\`), and \`services/asterisk.sh\` / + \`services/asterisk-digital-ocean.sh\` patch the vendored admin's one + hardcoded absolute API path (\`const API_BASE = '/api'\`, confirmed via the + vendored source to be the ONLY absolute-path reference anywhere in its + HTML/JS) so it resolves correctly under that sub-path via a + \`WEBADMIN_BASE_PATH\` env var. This link only appears once the proxy is + actually wired up (not just because an Asterisk install exists). On a + droplet, accepting this dashboard's domain prompt default (the droplet's + own \`DOMAIN_NAME\`) is what keeps Caddy's SIP-TLS cert sync working too — + entering a different domain there means SIP TLS falls back to self-signed. - **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 @@ -335,7 +357,7 @@ _secdash_grant_asterisk_access() { # 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" _admin_url="$6" + local _app_dir="$1" _svc_user="$2" _port="$3" _log_dir="$4" _config_dir="$5" _admin_proxied="$6" local _read_only_paths="" _read_write_paths="/etc/crowdsec/scenarios" [ -n "$_log_dir" ] && _read_only_paths="$_log_dir" [ -n "$_config_dir" ] && _read_write_paths="$_read_write_paths $_config_dir" @@ -352,7 +374,7 @@ Group=$_svc_user Environment=DASHBOARD_PORT=$_port Environment=ASTERISK_LOG=${_log_dir:+$_log_dir/full} Environment=ASTERISK_CONFIG_DIR=$_config_dir -Environment=ASTERISK_ADMIN_URL=$_admin_url +Environment=ASTERISK_ADMIN_PROXIED=$_admin_proxied ExecStart=/usr/bin/python3 $_app_dir/app.py Restart=on-failure RestartSec=3 @@ -398,7 +420,8 @@ SUDOERS # 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" ADMIN_URL="${2:-}" + local DASHBOARD_PORT="$1" ADMIN_PORT="${2:-}" ASTERISK_EA_DIR="${3:-}" ASTERISK_EA_DOMAIN="${4:-}" + SECDASH_ADMIN_PROXIED=false echo "" if ! command -v docker &>/dev/null || ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$"; then @@ -406,8 +429,13 @@ _secdash_configure_caddy() { return 0 fi - local _default_domain="" - if [ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ]; then + # Defaults to the Asterisk droplet's own DOMAIN_NAME when detected (see + # services/asterisk-digital-ocean.sh — that domain is already required + # there for Caddy's SIP-TLS cert sync), so accepting the default is what + # makes "one URL for everything" the path of least resistance instead of + # something you have to know to ask for. + local _default_domain="$ASTERISK_EA_DOMAIN" + if [ -z "$_default_domain" ] && [ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ]; then _default_domain="security.${SITE_DOMAIN}" fi local SD_DOMAIN="" @@ -417,6 +445,11 @@ _secdash_configure_caddy() { log_warning "No domain entered — dashboard stays on http://localhost:$DASHBOARD_PORT only (not reachable from outside this box)." return 0 fi + if [ -n "$ADMIN_PORT" ] && [ -n "$ASTERISK_EA_DOMAIN" ] && [ "$SD_DOMAIN" != "$ASTERISK_EA_DOMAIN" ]; then + log_warning "This differs from the Asterisk droplet's own DOMAIN_NAME (${ASTERISK_EA_DOMAIN})." + log_warning "SIP TLS needs Caddy actively serving THAT exact domain — using a different one here" + log_warning "means Caddy never gets a cert for it, and SIP TLS falls back to self-signed." + fi local EXTRA_BLOCK="" if [ -d "$DOCKER_DIR/authelia" ]; then @@ -491,6 +524,21 @@ _secdash_configure_caddy() { fi fi + # Path-proxies the real Asterisk web admin natively at /asterisk-admin/ + # on this SAME domain — no iframe, no separate domain for it. handle_path + # strips the /asterisk-admin prefix before forwarding, so the admin + # container sees requests exactly as if it were mounted at its own root + # (same as today) — its own JS is still taught the real mount point via + # WEBADMIN_BASE_PATH below, since its one absolute-path API reference + # would otherwise resolve against this domain's true root instead. + local ADMIN_HANDLE="" + if [ -n "$ADMIN_PORT" ]; then + ADMIN_HANDLE=" handle_path /asterisk-admin/* { + reverse_proxy host.docker.internal:${ADMIN_PORT} + } +" + fi + local CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile" if [ -f "$CADDY_FILE" ] && ! grep -q "^${SD_DOMAIN} {" "$CADDY_FILE"; then cat >> "$CADDY_FILE" << CADDYBLOCK @@ -498,7 +546,9 @@ _secdash_configure_caddy() { # Security Dashboard ${SD_DOMAIN} { ${BASICAUTH_BLOCK}${EXTRA_BLOCK} - reverse_proxy host.docker.internal:${DASHBOARD_PORT} +${ADMIN_HANDLE} handle { + reverse_proxy host.docker.internal:${DASHBOARD_PORT} + } header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -517,6 +567,10 @@ CADDYBLOCK 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" + if [ -n "$ADMIN_PORT" ]; then + log_success "Asterisk web admin proxied natively at https://${SD_DOMAIN}/asterisk-admin/ — no iframe, same domain as the dashboard." + SECDASH_ADMIN_PROXIED=true + fi elif [ -f "$CADDY_FILE" ]; then log_warning "$SD_DOMAIN already in Caddyfile — leaving the existing entry alone." fi @@ -530,64 +584,26 @@ CADDYBLOCK fi fi - _secdash_allow_asterisk_admin_iframe "$ADMIN_URL" "$SD_DOMAIN" -} - -# Best-effort: lets the dashboard's "Asterisk Admin" tab iframe-embed the -# real Asterisk web admin, by swapping that domain's own Caddy site block -# from X-Frame-Options to a CSP frame-ancestors entry naming ONLY this -# dashboard's domain — every other site is still refused framing exactly as -# before, this just relaxes it for the one origin that's supposed to embed -# it. Best-effort because it depends on finding the exact -# X-Frame-Options line services/asterisk-digital-ocean.sh itself generates, -# inside a live Caddyfile it doesn't own — if that block was hand-edited -# since, or doesn't exist yet (Asterisk installed after this dashboard, or -# no local Caddy at all), this silently does nothing and the tab's "open in -# a new tab" fallback link still works either way. -_secdash_allow_asterisk_admin_iframe() { - local ADMIN_URL="$1" SD_DOMAIN="$2" - [ -n "$ADMIN_URL" ] || return 0 - [ -n "$SD_DOMAIN" ] || return 0 - command -v docker &>/dev/null || return 0 - docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$" || return 0 - - local ADMIN_DOMAIN="${ADMIN_URL#https://}" - ADMIN_DOMAIN="${ADMIN_DOMAIN#http://}" - local CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile" - [ -f "$CADDY_FILE" ] || return 0 - grep -q "^${ADMIN_DOMAIN} {" "$CADDY_FILE" || return 0 - - if grep -qF "frame-ancestors 'self' https://${SD_DOMAIN};" "$CADDY_FILE"; then - return 0 # already patched for this exact dashboard domain - fi - - local CSP_LINE=" Content-Security-Policy \"frame-ancestors 'self' https://${SD_DOMAIN};\"" - local TMP_FILE - TMP_FILE="$(mktemp)" - awk -v domain="${ADMIN_DOMAIN} {" -v csp="$CSP_LINE" ' - BEGIN { in_block = 0; patched = 0 } - index($0, domain) == 1 { in_block = 1 } - in_block && !patched && /X-Frame-Options/ { print csp; patched = 1; next } - { print } - in_block && /^}/ { in_block = 0 } - ' "$CADDY_FILE" > "$TMP_FILE" - - if grep -qF "frame-ancestors 'self' https://${SD_DOMAIN};" "$TMP_FILE"; then - cp "$CADDY_FILE" "$CADDY_FILE.backup.$(date +%Y%m%d-%H%M%S)" - mv "$TMP_FILE" "$CADDY_FILE" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || docker restart caddy &>/dev/null; then - log_success "Asterisk web admin (${ADMIN_DOMAIN}) now allows embedding from https://${SD_DOMAIN} — the dashboard's Asterisk Admin tab should load it." + # Tell the admin's own vendor-patched web admin script it's now mounted + # under /asterisk-admin instead of the root, and restart that container + # so the change actually takes effect (entrypoint.sh regenerates the + # script unconditionally on every start — see + # _asterisk_patch_webadmin_base_path in services/asterisk.sh / + # services/asterisk-digital-ocean.sh for the full rationale). + if [ "$SECDASH_ADMIN_PROXIED" = true ] && [ -n "$ASTERISK_EA_DIR" ] && [ -f "$ASTERISK_EA_DIR/.env" ]; then + if grep -q '^WEB_ADMIN_BASE_PATH=' "$ASTERISK_EA_DIR/.env"; then + sed -i 's#^WEB_ADMIN_BASE_PATH=.*#WEB_ADMIN_BASE_PATH=/asterisk-admin#' "$ASTERISK_EA_DIR/.env" else - log_warning "Caddyfile patched, but reload/restart failed — check: docker logs caddy" + echo 'WEB_ADMIN_BASE_PATH=/asterisk-admin' >> "$ASTERISK_EA_DIR/.env" + fi + local _ea_container="easy-asterisk" + [[ "$ASTERISK_EA_DIR" == *asterisk-digital-ocean ]] && _ea_container="easy-asterisk-do" + if docker restart "$_ea_container" &>/dev/null; then + log_success "Restarted $_ea_container to pick up the new web admin mount point." + else + log_warning "Couldn't restart $_ea_container automatically — restart it yourself so the web admin picks up WEB_ADMIN_BASE_PATH:" + log_warning " docker restart $_ea_container" fi - else - rm -f "$TMP_FILE" - log_warning "Couldn't find an X-Frame-Options line in ${ADMIN_DOMAIN}'s Caddy block to patch —" - log_warning "the dashboard's Asterisk Admin tab will show a blank frame. Add this line yourself" - log_warning "inside that domain's 'header { }' block in $CADDY_FILE, replacing X-Frame-Options:" - log_warning " Content-Security-Policy \"frame-ancestors 'self' https://${SD_DOMAIN};\"" - log_warning "then: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" fi } @@ -603,9 +619,12 @@ _secdash_remove_caddy_block() { local caddy_file="$DOCKER_DIR/caddy/Caddyfile" [ -f "$caddy_file" ] || return 0 - local marker=" reverse_proxy host.docker.internal:${port}" + # Any amount of leading whitespace — this line now lives inside a + # "handle { }" sub-block (one level deeper than before the native + # Asterisk-admin path-proxy was added), not at the old fixed 4-space + # indent directly under the domain block. local marker_line domain_line end_line - marker_line="$(grep -nF "$marker" "$caddy_file" | head -1 | cut -d: -f1)" + marker_line="$(grep -nE "^[[:space:]]*reverse_proxy host\.docker\.internal:${port}\$" "$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 @@ -728,7 +747,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer PORT = int(os.environ.get("DASHBOARD_PORT", "8092")) ASTERISK_LOG = os.environ.get("ASTERISK_LOG", "") -ASTERISK_ADMIN_URL = os.environ.get("ASTERISK_ADMIN_URL", "") +ASTERISK_ADMIN_PROXIED = os.environ.get("ASTERISK_ADMIN_PROXIED", "false").lower() == "true" ASTERISK_CONFIG_DIR = os.environ.get("ASTERISK_CONFIG_DIR", "") ASN_SCENARIO_FILES = [ "/etc/crowdsec/scenarios/local-asterisk_bf.yaml", @@ -1512,8 +1531,9 @@ INDEX_HTML = """ 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, nav a.nav-link-btn { background: none; border: none; color: #9aa4b2; padding: 0.6rem 1rem; cursor: pointer; font-size: 0.95rem; border-bottom: 2px solid transparent; text-decoration: none; display: inline-block; } nav button.active { color: #fff; border-bottom-color: #4f8cff; } + nav a.nav-link-btn:hover { color: #e6e6e6; } 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; } @@ -1541,7 +1561,7 @@ INDEX_HTML = """

Security Dashboard