diff --git a/services/asterisk-digital-ocean.sh b/services/asterisk-digital-ocean.sh index 94a9306..b529a6e 100755 --- a/services/asterisk-digital-ocean.sh +++ b/services/asterisk-digital-ocean.sh @@ -469,75 +469,6 @@ _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 @@ -836,9 +767,6 @@ 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" @@ -915,8 +843,6 @@ 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" @@ -981,12 +907,6 @@ 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 ──────────────────────────────────────────────────── @@ -1018,9 +938,6 @@ 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 @@ -1042,27 +959,6 @@ 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 a33575d..060a1c4 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -433,96 +433,6 @@ _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 @@ -816,9 +726,6 @@ 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" @@ -864,8 +771,6 @@ 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" @@ -936,12 +841,6 @@ 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 ──────────────────────────────────────────────────── @@ -972,9 +871,6 @@ 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 @@ -983,36 +879,17 @@ 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/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 + 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 - 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 98230d3..1dcc464 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -82,18 +82,19 @@ install_security-dashboard() { fi local ASTERISK_LOG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/logs}" local ASTERISK_CONFIG_DIR="${ASTERISK_EA_DIR:+$ASTERISK_EA_DIR/config/asterisk}" - - # 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 - 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-)" + # 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 "" @@ -108,7 +109,8 @@ install_security-dashboard() { if [ -z "$ASTERISK_EA_DIR" ]; then log_warning "No asterisk-digital-ocean or asterisk install detected." - log_warning "The Security Log and PSTN Trunk tabs will just be empty — CrowdSec's tab still works fine." + 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 @@ -133,19 +135,11 @@ install_security-dashboard() { 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" + _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" - - # 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" + _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" @@ -155,10 +149,7 @@ 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_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 + _secdash_configure_caddy "$DASHBOARD_PORT" fi return 0 ;; @@ -176,15 +167,15 @@ install_security-dashboard() { log_success "Created system user $SVC_USER" fi - _secdash_grant_asterisk_access "$SVC_USER" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" + _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" - _secdash_write_systemd_unit "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT" "$ASTERISK_LOG_DIR" "$ASTERISK_CONFIG_DIR" "false" + _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 @@ -201,12 +192,7 @@ 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_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 + _secdash_configure_caddy "$DASHBOARD_PORT" write_readme "$APP_DIR" << README_MD # Security Dashboard @@ -228,20 +214,24 @@ 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 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. +- **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 @@ -289,8 +279,9 @@ sudo journalctl -u security-dashboard -f ## Security notes - Runs as a dedicated, unprivileged system user (\`secdash\`), not root. -- Sudo access is scoped to exactly six commands via - \`/etc/sudoers.d/security-dashboard\`: \`cscli decisions delete --id \`, +- Sudo access is scoped to exact commands via + \`/etc/sudoers.d/security-dashboard\` — nothing else. CrowdSec: + \`cscli decisions delete --id \`, \`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 @@ -298,7 +289,13 @@ 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). Nothing else. + root-owned files directly and shouldn't). Asterisk Admin (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 + \`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. @@ -327,9 +324,9 @@ README_MD # dashboard (or an asterisk-digital-ocean/asterisk swap) reaches an existing # install on its next update instead of silently only applying to new ones. _secdash_grant_asterisk_access() { - local _svc_user="$1" _log_dir="$2" _config_dir="$3" + local _svc_user="$1" _log_dir="$2" _config_dir="$3" _ea_config_dir="${4:-}" local _dir - for _dir in "$_log_dir" "$_config_dir"; do + for _dir in "$_log_dir" "$_config_dir" "$_ea_config_dir"; do [ -n "$_dir" ] && [ -d "$_dir" ] || continue local _group _group="$(stat -c '%G' "$_dir" 2>/dev/null || echo "$ACTUAL_USER")" @@ -342,7 +339,11 @@ _secdash_grant_asterisk_access() { # directory also needs the group execute+write bit for a new file save # (configparser writes a fresh temp file then renames it into place) to # succeed. 770 only on the config dir, not the log dir (no reason for - # secdash to ever create files in the log dir). + # secdash to ever create files in the log dir). _ea_config_dir + # (categories.conf/rooms.conf) deliberately stays 750/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 this directory group-write at all. if [ -n "$_config_dir" ] && [ -d "$_config_dir" ]; then chmod 770 "$_config_dir" 2>/dev/null || true fi @@ -357,14 +358,15 @@ _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_proxied="$6" + 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) +Description=Security dashboard (Asterisk security log + CrowdSec decisions + PSTN trunk permissions + Asterisk admin) After=network.target [Service] @@ -374,7 +376,8 @@ Group=$_svc_user Environment=DASHBOARD_PORT=$_port Environment=ASTERISK_LOG=${_log_dir:+$_log_dir/full} Environment=ASTERISK_CONFIG_DIR=$_config_dir -Environment=ASTERISK_ADMIN_PROXIED=$_admin_proxied +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 @@ -399,7 +402,22 @@ SDSVC # 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" + local _svc_user="$1" _ea_container="${2:-}" + local _ea_lines="" + # Native Asterisk Admin tab (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 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 @@ -407,6 +425,7 @@ $_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 \ @@ -420,8 +439,7 @@ 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_PORT="${2:-}" ASTERISK_EA_DIR="${3:-}" ASTERISK_EA_DOMAIN="${4:-}" - SECDASH_ADMIN_PROXIED=false + local DASHBOARD_PORT="$1" echo "" if ! command -v docker &>/dev/null || ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^caddy$"; then @@ -429,13 +447,8 @@ _secdash_configure_caddy() { return 0 fi - # 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 + local _default_domain="" + if [ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ]; then _default_domain="security.${SITE_DOMAIN}" fi local SD_DOMAIN="" @@ -445,11 +458,6 @@ _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 @@ -524,21 +532,6 @@ _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 @@ -546,9 +539,7 @@ _secdash_configure_caddy() { # Security Dashboard ${SD_DOMAIN} { ${BASICAUTH_BLOCK}${EXTRA_BLOCK} -${ADMIN_HANDLE} handle { - reverse_proxy host.docker.internal:${DASHBOARD_PORT} - } + reverse_proxy host.docker.internal:${DASHBOARD_PORT} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -567,10 +558,6 @@ 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 @@ -583,28 +570,6 @@ CADDYBLOCK ufw_allow_from_caddy_net "${DASHBOARD_PORT}" fi fi - - # 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 - 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 - fi } # Removes the dashboard's existing Caddyfile site block (found via its @@ -619,12 +584,9 @@ _secdash_remove_caddy_block() { local caddy_file="$DOCKER_DIR/caddy/Caddyfile" [ -f "$caddy_file" ] || return 0 - # 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=" reverse_proxy host.docker.internal:${port}" local marker_line domain_line end_line - marker_line="$(grep -nE "^[[:space:]]*reverse_proxy host\.docker\.internal:${port}\$" "$caddy_file" | head -1 | cut -d: -f1)" + 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 @@ -747,7 +709,6 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer PORT = int(os.environ.get("DASHBOARD_PORT", "8092")) ASTERISK_LOG = os.environ.get("ASTERISK_LOG", "") -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", @@ -829,13 +790,17 @@ def parse_security_log(limit=200): return events[:limit] -def run_sudo(args, timeout=15): +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.""" + of what's in the arguments, on top of the sudoers-side restriction. + input_text feeds stdin (e.g. for `docker exec -i ... tee ` 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 + ["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: @@ -1523,6 +1488,630 @@ def remove_personal_did(did): 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 tee ` (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 = """ Security Dashboard @@ -1531,9 +2120,8 @@ 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, 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 { 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; } - 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; } @@ -1561,7 +2149,7 @@ INDEX_HTML = """

Security Dashboard