From 5fbc1f2ec5bb65e200751b45965bf37b45f87deb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 13:02:29 +0000 Subject: [PATCH] Serve a keep-alive page instead of Asterisk's own web admin publicly _asterisk_configure_caddy_public() used to reverse-proxy Asterisk's own web admin at its public domain, optionally gated by local or remote Authelia, flipping WEB_ADMIN_AUTH_DISABLED=true in .env to hand auth off to it. That coupling was the root cause of a real live exposure: a box where Authelia protection was accepted once, but the Authelia import/forward_auth block itself later went missing from the Caddyfile (e.g. lost on a restore), was left with the web admin's own login off and nothing else gating it -- extension/device data reachable with no password at all. A remote Authelia's forward_auth also proved fragile in practice for something that only ever needed to keep a domain's cert alive (DNS/routing/access-rule mismatches spanning two boxes, hard to diagnose from either one alone). This domain now just serves a minimal keep-alive page (a bare "OK" 200 response) so Caddy can still issue/renew the SIP TLS cert -- cert issuance only needs Caddy to own the site block, it's unrelated to what the block serves. Auth is now optional Basic Auth handled entirely inside Caddy itself, no external subrequest, so it can't fail this way. Asterisk's own web admin is no longer exposed publicly by this function at all -- reachable only via the CLI: docker exec -it easy-asterisk The Security Dashboard's own domain-takeover offer (_secdash_offer_asterisk_domain in security-dashboard.sh) is the supported way to put something meaningful on this domain instead. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc --- services/asterisk.sh | 163 +++++++++++++++++++------------------------ 1 file changed, 71 insertions(+), 92 deletions(-) diff --git a/services/asterisk.sh b/services/asterisk.sh index 7265000..36b88f5 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -1663,115 +1663,93 @@ EOF _asterisk_configure_caddy_public() { local DOMAIN_NAME="$1" WEB_ADMIN_PORT_VAL="$2" PUBLIC_IP="$3" - WEB_ADMIN_PUBLIC_ACCESS_NEEDED=true + # Asterisk's own web admin is never exposed publicly by this function — + # see services/security-dashboard.sh's _secdash_offer_asterisk_domain + # for the actual public-facing use of this domain instead. This only + # exists to get DOMAIN_NAME a trusted Caddy-issued TLS cert for SIP TLS, + # via a minimal keep-alive page. Cert issuance only needs Caddy to own + # the domain's site block and answer the ACME challenge there — it's + # unrelated to what the block actually serves. + # + # An earlier version of this function reverse-proxied Asterisk's own + # web admin here, gated (optionally) by Authelia, with WEB_ADMIN_AUTH_DISABLED + # flipped to true in .env to hand auth off to it. That coupling was the + # root cause of a real live exposure: a box where Authelia protection + # was accepted once, but the Authelia import/forward_auth block itself + # later went missing from the Caddyfile (e.g. lost on a restore) or a + # remote Authelia instance became unreachable/misconfigured, was left + # with Asterisk's own login OFF and nothing else gating it — extension/ + # device data sitting on the public internet with no password at all. + # A remote Authelia's forward_auth also proved fragile in practice + # (DNS/routing/access-rule mismatches that are hard to diagnose from + # this box alone) for something that's only ever meant to keep a + # domain's cert alive. A Basic Auth login handled entirely inside Caddy + # itself — no external subrequest, no dependency on another box being + # correctly configured — is simpler and can't fail this way. Asterisk's + # own web admin stays reachable via the CLI only: + # docker exec -it easy-asterisk + # + # Left at the caller's own default (true) here — the web admin's raw + # IP:port still needs to be reachable when there's no Caddy in the + # picture at all to front this domain instead. Only flipped to false + # once we actually confirm Caddy is fronting it (below). if [[ -z "$DOMAIN_NAME" ]]; then - log_info "No FQDN set — web admin stays on http://${PUBLIC_IP:-localhost}:${WEB_ADMIN_PORT_VAL} (nothing for Caddy to do)." + log_info "No FQDN set — nothing for Caddy to do (SIP TLS stays self-signed)." return 0 fi if [[ ! -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." + log_info "Caddy not installed — SIP TLS stays self-signed." return 0 fi - 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 - fi - else - # No local Authelia — offer one running elsewhere (e.g. a homelab). - # There's no shared "(authelia)" Caddy snippet to import in that - # case (authelia.sh only writes one when installing locally), so - # this builds the same forward_auth block inline, targeting the - # remote instance directly instead of the local "authelia:9091" - # container reference. - local _use_remote_auth="" - prompt_yn "Protect the web admin with a remote Authelia instance (e.g. on a homelab)? (y/n):" "n" _use_remote_auth - if [[ "$_use_remote_auth" =~ ^[Yy]$ ]]; then - local _remote_authelia="" - prompt_text " Remote Authelia address — a bare host:port over a private network (e.g. a NetBird mesh IP:9091), or a full https:// URL if it's on its own public domain+TLS:" "" _remote_authelia - if [[ -n "$_remote_authelia" ]]; then - # header_up lines are required here (unlike the local - # "authelia:9091" snippet in services/authelia.sh) because - # this upstream is reached over a second Caddy hop when - # given as a scheme-qualified URL (https://auth.example.com). - # Caddy rewrites the outgoing request's Host header to that - # upstream host so the remote Caddy can route/SNI-match it — - # and without an explicit override, X-Forwarded-Host picks up - # that rewritten value instead of the original site's host. - # Confirmed live: Authelia was evaluating every request as - # if it were for auth.example.com itself (which has - # policy: bypass in access_control.rules), so every domain - # silently passed through with no 2FA prompt regardless of - # its own policy. Pinning these to the original request's - # values fixes it regardless of hop count. - # - # X-Forwarded-Host uses a literal domain, NOT the {host} - # placeholder. Confirmed live: {host} still evaluated to - # the upstream's own hostname (auth.example.com) rather - # than the original site's — Caddy appears to rewrite the - # outgoing request's Host to the upstream target before - # header_up placeholders are resolved for a scheme- - # qualified upstream, so {host} echoes back the already- - # rewritten value instead of the original client-facing - # host. Since this site block only ever serves one domain - # (DOMAIN_NAME), hardcoding it sidesteps the ambiguity - # entirely instead of depending on Caddy's internal - # header-mutation ordering. - EXTRA_BLOCK=" forward_auth ${_remote_authelia} { - uri /api/authz/forward-auth - copy_headers Remote-User Remote-Groups Remote-Name Remote-Email - header_up X-Forwarded-Method {method} - header_up X-Forwarded-Proto {scheme} - header_up X-Forwarded-Host ${DOMAIN_NAME} - header_up X-Forwarded-Uri {uri} - }" - sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env - log_info "Using remote Authelia at ${_remote_authelia}." - log_info "Verify it's reachable from this box before relying on it — e.g.:" - log_info " curl -I ${_remote_authelia}" - else - log_info "No address entered — skipping Authelia protection." - fi - fi - fi - echo "" local WANT_CADDY_PROXY="" - prompt_yn "Reverse-proxy the web admin at https://${DOMAIN_NAME}/ via Caddy? (also gets Asterisk a trusted TLS cert for SIP instead of self-signed) (y/n):" "y" WANT_CADDY_PROXY + prompt_yn "Get ${DOMAIN_NAME} a trusted TLS cert via Caddy for SIP TLS? (serves a minimal keep-alive page there — not Asterisk's own web admin, which stays reachable only via 'docker exec -it easy-asterisk') (y/n):" "y" WANT_CADDY_PROXY [[ "$WANT_CADDY_PROXY" =~ ^[Yy]$ ]] || return 0 + # Caddy is fronting this domain now either way (locally or via a remote + # machine) — the keep-alive page doesn't reverse_proxy to anything on + # this box in either mode, so the web admin's raw port never needs to + # be reachable from the internet for this to work. + WEB_ADMIN_PUBLIC_ACCESS_NEEDED=false + local _CADDY_MODE="local" [[ ! -d "$DOCKER_DIR/caddy" ]] && [[ -n "${CADDY_REMOTE_HOST:-}" ]] && _CADDY_MODE="remote" - # Asterisk runs with network_mode: host, so whatever proxies to it - # needs a way to reach the host, not "localhost" (which resolves - # to the proxying container's own netns). A local Caddy container - # reaches the host via host.docker.internal (wired up in - # services/caddy.sh's compose file); a remote Caddy machine needs - # this box's actual public IP instead. - local _PROXY_TARGET="host.docker.internal:${WEB_ADMIN_PORT_VAL}" - [[ "$_CADDY_MODE" == "remote" ]] && _PROXY_TARGET="${PUBLIC_IP}:${WEB_ADMIN_PORT_VAL}" + # Basic Auth handled entirely by Caddy — same generate/hash pattern as + # services/security-dashboard.sh's own independent Basic Auth layer. + local BASICAUTH_BLOCK="" + local _use_basicauth="" + prompt_yn " Add a Basic Auth login on this keep-alive page? (y/n):" "y" _use_basicauth + if [[ "$_use_basicauth" =~ ^[Yy]$ ]]; then + local BA_USER="" BA_PASS="" BA_HASH="" + prompt_text " Basic Auth username [admin]:" "admin" BA_USER + BA_PASS="$(generate_password 20)" + if [[ "$_CADDY_MODE" == "local" ]]; then + BA_HASH="$(docker exec caddy caddy hash-password --plaintext "$BA_PASS" 2>/dev/null)" + fi + if [ -z "$BA_HASH" ]; then + log_warning "Could not generate the Basic Auth hash — keep-alive page will be unauthenticated." + else + BASICAUTH_BLOCK=" basicauth { + ${BA_USER} ${BA_HASH} + } +" + log_success "Basic Auth username: ${BA_USER}" + log_success "Basic Auth password: ${BA_PASS}" + log_warning "Save that password now — only the bcrypt hash is written to the Caddyfile, it is not stored anywhere in plaintext." + fi + fi local _SITE_BLOCK _SITE_BLOCK="$(cat << CADDY_BLOCK -# Asterisk Web Admin +# Asterisk domain — keep-alive page only, for the SIP TLS cert. Asterisk's +# own web admin is intentionally not served here — use the CLI instead: +# docker exec -it easy-asterisk ${DOMAIN_NAME} { - # Auth (if any) must come before reverse_proxy — forward_auth is the - # same directive family as reverse_proxy internally, and Caddy doesn't - # reorder repeats of the same directive within a block; it runs them in - # the order they're written. With reverse_proxy first, it would handle - # and terminate every request immediately, so an auth check written - # after it would be dead code that never runs — full bypass regardless - # of what the auth server's own rules say. -${EXTRA_BLOCK} - reverse_proxy ${_PROXY_TARGET} +${BASICAUTH_BLOCK} respond "OK" 200 header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -1810,10 +1788,10 @@ CADDY_BLOCK # actually works here. Try it anyway, fall back to a # restart — confirmed necessary on a real deployment. if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "Web admin accessible at: https://${DOMAIN_NAME}" + log_success "Keep-alive page live at: https://${DOMAIN_NAME}" elif docker restart caddy &>/dev/null; then log_success "Caddy restarted to apply changes (reload API is disabled by default)" - log_success "Web admin should be accessible at: https://${DOMAIN_NAME}" + log_success "Keep-alive page should be live at: https://${DOMAIN_NAME}" else log_warning "Reload/restart failed — check: docker logs caddy" log_info "Manual fix: docker restart caddy" @@ -1826,7 +1804,8 @@ CADDY_BLOCK chown "$ACTUAL_USER:$ACTUAL_USER" "$_SNIPPET_DIR/asterisk.caddy" 2>/dev/null || true log_success "Snippet saved: $_SNIPPET_DIR/asterisk.caddy" log_info "Copy to your Caddy machine: scp $_SNIPPET_DIR/asterisk.caddy caddy-host:~/caddy-snippets/" - log_info "Remote Caddy reaches this box over its public IP, so the web admin port stays open below." + log_info "This is just a keep-alive page (for the cert) — the remote Caddy machine doesn't need" + log_info "to reach anything on this box for it, so no port needs to stay open here for this." fi }