From cd33b7ce7194b98c323fe9b25746670b753aa521 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 19:56:27 +0000 Subject: [PATCH 1/5] =?UTF-8?q?Add=20Authelia=20SSO=20to=20Frigate=20?= =?UTF-8?q?=E2=80=94=20disables=20its=20own=20login,=20not=20just=20a=20ga?= =?UTF-8?q?te=20in=20front=20of=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frigate has its own built-in login separate from Authelia's session, so just adding `import authelia` in front of it (the pattern used for no-built-in-auth services) would leave two independent logins stacked, defeating the point of Authelia's "remember me" on mobile. Frigate has a `proxy` auth mode built for exactly this — trust Remote-User/Remote-Groups from an upstream forward_auth proxy and disable its own login entirely. - Extend configure_caddy_for_service() with an optional 5th arg for sub-directives inside the reverse_proxy block itself (header_up), needed to pin an X-Proxy-Secret header so Frigate's proxy-auth trust can't be spoofed by a request reaching its published port directly, bypassing Caddy/Authelia. Backward compatible — every other caller is unaffected. - services/frigate.sh: prompt to protect with Authelia when installed; wires import authelia + the X-Proxy-Secret header_up into Caddy, and only writes config.yml's auth.enabled: False + proxy block once Caddy actually confirms it's fronting the domain (never disables the native login with nothing else gating access). Reuses the secret across reinstalls instead of rotating it. Calls _authelia_scope_access() so access can be restricted to specific users instead of every Authelia account. Fixed a latent bug in the standalone-mode Caddy stub where the auth block was placed after reverse_proxy instead of before it (dead code — the same "Authelia never prompts" bug class CLAUDE.md documents for the real helper). - CLAUDE.md: document the new configure_caddy_for_service parameter and Frigate's hybrid built-in-auth/forward_auth pattern. Verified end-to-end against a local test harness (fake Authelia/Caddy dirs): config.yml, .env, and the generated Caddyfile block all agree on the shared secret and header names, auth is skipped cleanly when Caddy isn't configured, and the secret is reused on a second run. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc --- CLAUDE.md | 51 ++++++++++++++++++++--- lib/common.sh | 23 +++++++++-- services/frigate.sh | 98 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 156 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 139a009..6f661f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -191,13 +191,25 @@ pip_user_install PACKAGE... # pip3 --user with --break-system-packages o ### Caddy reverse proxy ```bash -configure_caddy_for_service "Display Name" "PORT" "default-subdomain" ["extra-block"] +configure_caddy_for_service "Display Name" "PORT" "default-subdomain" ["extra-block"] ["reverse_proxy-extra"] ``` Prompts the user for a domain, appends a site block to the Caddyfile, and reloads Caddy. No-ops silently if Caddy isn't installed. The fourth argument -is an optional string inserted verbatim inside the Caddy site block (use it -for `import authelia` or custom matchers). +is an optional string inserted verbatim inside the Caddy site block, before +`reverse_proxy` (use it for `import authelia` or custom matchers). The fifth +argument is a different thing — an optional string inserted **inside** the +`reverse_proxy` block itself, as sub-directives (e.g. +`" header_up X-Proxy-Secret abc123"`), for a backend that needs a +header only `reverse_proxy`'s own `header_up` can set — the fourth +argument's block runs *before* `reverse_proxy` and can't reach into it. +`services/frigate.sh` is the reference caller: Frigate's `proxy` auth mode +trusts `Remote-User`/`Remote-Groups` headers from Authelia's forward_auth, +but only if a matching `X-Proxy-Secret` header is also present — otherwise +those headers could be spoofed by a request that reaches Frigate's +published host port directly, bypassing Caddy/Authelia entirely. Omit the +fifth argument and the generated `reverse_proxy` line is the same bare form +as before — every other caller is unaffected. The function places that block **before** `reverse_proxy` in the generated site block — don't reorder this. `forward_auth` (what `import authelia` @@ -245,14 +257,19 @@ forward_auth https://auth.example.com { This only affects the remote-Authelia path — same-machine `authelia:9091` snippets (`services/authelia.sh`) are a single hop and don't need it. -Sets two out-params (not `local` — read them after the call returns) so the -caller can tell whether Caddy actually ended up fronting the service: +Sets three out-params (not `local` — read them after the call returns) so +the caller can tell whether Caddy actually ended up fronting the service: ```bash CADDY_SERVICE_CONFIGURED # true/false CADDY_SERVICE_MODE # "local" or "remote" (only meaningful if configured) +CADDY_SERVICE_DOMAIN # the domain actually configured (only meaningful if configured) ``` +`CADDY_SERVICE_DOMAIN` is what `_authelia_scope_access()` (see below) wants +as its `DOMAIN` argument — read it right after the call instead of +recomputing/guessing the domain a second time. + Use this to skip opening a host firewall port for a service Caddy already fronts *locally* (it reaches the service over `host.docker.internal`, not the network) — but still open it when `CADDY_SERVICE_MODE` is `"remote"`, @@ -461,6 +478,30 @@ for Authelia to protect. Removed from this list; if it grows a web UI in the future, add it back and wire up the same prompt other services here use. +**`frigate` — a third pattern, neither of the two above.** Frigate *does* +have built-in auth (username/password, `admin`/`viewer` roles, on by +default) so it isn't "no built-in auth" — but unlike the has-built-in-auth +list, that auth is designed to be handed off to an upstream proxy instead +of just living alongside it. Frigate has its own `proxy` auth mode built +specifically for Authelia/Authentik/oauth2_proxy/traefik-forward-auth: +given trusted `Remote-User`/`Remote-Groups` headers it can skip its own +login screen entirely (`auth.enabled: False`), rather than showing a +second, independently-expiring login *after* Authelia's. `services/frigate.sh` +wires this up: `import authelia` (fourth arg) plus a +`header_up X-Proxy-Secret ` (fifth arg, see +`configure_caddy_for_service` above) into the reverse_proxy block, with +the matching `proxy.auth_secret`/`header_map`/`default_role: admin` block +written into `config/config.yml` — and only written at all once +`CADDY_SERVICE_CONFIGURED` confirms Caddy actually ended up fronting the +domain, so Frigate's own login is never disabled with nothing else in +front of it. `default_role: admin` (default in this repo's install) means +anyone who passes Authelia gets full access, same as the login it +replaces; use `proxy.role_map`/Authelia groups instead if some users +should be view-only. Reuses the same `FRIGATE_PROXY_AUTH_SECRET` on +reinstall (from `.env` via `ENV_MAP`, the same array `_frigate_parse_existing` +already builds) rather than rotating it and breaking the existing Caddy +pairing. + For services without built-in auth, prompt the user before calling `configure_caddy_for_service` and pass `import authelia` as the extra block if Authelia is installed and the user wants SSO protection: diff --git a/lib/common.sh b/lib/common.sh index b339004..72b972c 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -841,11 +841,19 @@ find_free_coturn_range() { } # ── Caddy reverse-proxy wiring (shared by every web service) ───────────────── -# Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"] +# Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"] ["reverse_proxy-extra"] # UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"), # or plain port number for localhost fallback (e.g. "8085"). +# The optional 5th arg is inserted as sub-directives *inside* the +# reverse_proxy block itself (e.g. " header_up X-Proxy-Secret abc123") +# — for the rare case a backend needs a header only reverse_proxy's own +# header_up can set, as opposed to EXTRA_CONFIG's auth-gate directives that +# run before reverse_proxy entirely. See services/frigate.sh's Authelia +# integration for the reference caller (pins X-Proxy-Secret so Frigate's +# proxy-auth trust can't be spoofed by a request that reaches it directly, +# bypassing Caddy/Authelia). configure_caddy_for_service() { - local SERVICE_NAME="$1" SERVICE_UPSTREAM="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}" + local SERVICE_NAME="$1" SERVICE_UPSTREAM="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}" REVERSE_PROXY_EXTRA="${5:-}" # Out-params (not `local` — callers read these after the call returns) so # a caller can tell whether Caddy actually ended up fronting the service @@ -941,6 +949,15 @@ configure_caddy_for_service() { _BLOCK_UPSTREAM="${_THIS_IP}:${_DISPLAY_PORT}" fi + # Bare "reverse_proxy upstream" unless a caller needs sub-directives + # (header_up, etc.) inside it — see the REVERSE_PROXY_EXTRA comment above. + local _REVERSE_PROXY_LINE="reverse_proxy ${_BLOCK_UPSTREAM}" + if [ -n "$REVERSE_PROXY_EXTRA" ]; then + _REVERSE_PROXY_LINE="reverse_proxy ${_BLOCK_UPSTREAM} { +${REVERSE_PROXY_EXTRA} + }" + fi + local _SITE_BLOCK _SITE_BLOCK="$(cat << CADDY_BLOCK @@ -954,7 +971,7 @@ ${SERVICE_DOMAIN} { # after it would be dead code that never runs — full bypass regardless # of what the auth server's own rules say. ${EXTRA_CONFIG} - reverse_proxy ${_BLOCK_UPSTREAM} + ${_REVERSE_PROXY_LINE} # Security headers header { diff --git a/services/frigate.sh b/services/frigate.sh index 586fca9..c4763ca 100644 --- a/services/frigate.sh +++ b/services/frigate.sh @@ -83,7 +83,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then } configure_caddy_for_service() { - local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" _rp_extra="${5:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" local _display_port="${_upstream##*:}" @@ -126,12 +126,23 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi + local _rp_line="reverse_proxy ${_block_upstream}" + if [[ -n "$_rp_extra" ]]; then + _rp_line="reverse_proxy ${_block_upstream} { +${_rp_extra} + }" + fi + local _site_block _site_block="$(cat << CBLOCK # $_name ${_domain} { - reverse_proxy ${_block_upstream} + # Auth (if any) must come before reverse_proxy — see lib/common.sh's + # configure_caddy_for_service for why (reverse_proxy first would answer + # every request itself, making an auth block after it dead code). +${_extra} + ${_rp_line} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -144,7 +155,6 @@ ${_domain} { output file /var/log/caddy/${_domain}.log format json } -${_extra} } CBLOCK )" @@ -526,6 +536,10 @@ install_frigate() { echo " - Prompt to add cameras interactively (RTSP creds go in .env)" echo " or write a starter config.yml if none are added" echo " - Offer a Caddy reverse proxy and to start the container" + echo " - If Authelia is installed: offer to protect Frigate with it —" + echo " disables Frigate's own login (auth.enabled: False) and pins a" + echo " proxy.auth_secret/X-Proxy-Secret handshake so only Caddy can" + echo " satisfy Frigate's proxy-auth trust" return 0 fi @@ -646,6 +660,51 @@ FRIGATE_COMPOSE mkdir -p config mkdir -p "$FRIGATE_MEDIA" + # Authelia SSO — decided (and, if accepted, wired into Caddy) before + # config.yml is written, so the auth block baked into config.yml only + # ever reflects a gate that's actually in place (never "native login + # disabled, but nothing put in front of it instead"). Frigate has its + # own built-in login (username/password) separate from Authelia's — + # left alone it would show *after* Authelia's forward_auth already + # gated the domain: a redundant second login, and worse, a second + # session that can expire independently and force a re-login on its + # own schedule regardless of Authelia's "remember me" duration. The + # proxy.auth_secret/X-Proxy-Secret handshake (pinned into the Caddy + # reverse_proxy block) stops that trust from being spoofed by a + # request that reaches Frigate's published host port directly, + # bypassing Caddy/Authelia entirely. + local FRIGATE_USE_AUTHELIA="n" FRIGATE_PROXY_SECRET="" AUTH_CONFIG_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + echo "" + prompt_yn "Protect Frigate with Authelia SSO (disables Frigate's own login)? (y/n):" "y" FRIGATE_USE_AUTHELIA + fi + + if [[ "$FRIGATE_USE_AUTHELIA" =~ ^[Yy]$ ]]; then + FRIGATE_PROXY_SECRET="${ENV_MAP[FRIGATE_PROXY_AUTH_SECRET]:-$(generate_password 32)}" + configure_caddy_for_service "Frigate" "frigate:5000" "frigate" \ + " import authelia" \ + " header_up X-Proxy-Secret ${FRIGATE_PROXY_SECRET}" + if [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ]; then + AUTH_CONFIG_BLOCK="auth: + enabled: False # Authelia already gates the whole domain — its own login would be redundant + +proxy: + auth_secret: \"{FRIGATE_PROXY_AUTH_SECRET}\" # must match the X-Proxy-Secret header Caddy sends + header_map: + user: remote-user + role: remote-groups + default_role: admin # anyone who passes Authelia gets full access, same as the disabled local login did + +" + declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "frigate" "$CADDY_SERVICE_DOMAIN" + else + log_warning "Caddy wasn't configured for Frigate — leaving Frigate's own login enabled (nothing else is gating access)." + FRIGATE_PROXY_SECRET="" + fi + else + configure_caddy_for_service "Frigate" "frigate:5000" "frigate" + fi + # Credentials/IPs go in .env as FRIGATE_* variables; Frigate substitutes # any {FRIGATE_VAR} placeholder in config.yml from its container env at # startup, so RTSP secrets never need to be typed into the YAML directly. @@ -654,12 +713,12 @@ FRIGATE_COMPOSE if [ "${#CAM_NAME[@]}" -eq 0 ]; then # No cameras entered — write a starter config the operator edits by hand. - cat > config/config.yml << 'FRIGATE_CONFIG' + cat > config/config.yml << FRIGATE_CONFIG # Frigate Configuration — Docs: https://docs.frigate.video # # ⚠️ YOU MUST EDIT THIS FILE to add your cameras before starting Frigate. -mqtt: +${AUTH_CONFIG_BLOCK}mqtt: enabled: false # Set to true and configure if you use Home Assistant cameras: @@ -696,7 +755,7 @@ FRIGATE_CONFIG # RTSP credentials/IPs come from .env — Frigate substitutes {FRIGATE_VAR} # placeholders below from the container's environment at startup. -mqtt: +${AUTH_CONFIG_BLOCK}mqtt: enabled: false # Set to true and configure if you use Home Assistant go2rtc: @@ -724,6 +783,7 @@ FRIGATE_CONFIG cat > .env << FRIGATE_ENV FRIGATE_MEDIA=$FRIGATE_MEDIA CADDY_NET=$SITE_CADDY_NET +FRIGATE_PROXY_AUTH_SECRET=$FRIGATE_PROXY_SECRET ${ENV_CAM_VARS} FRIGATE_ENV chmod 600 .env @@ -732,7 +792,29 @@ FRIGATE_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_MEDIA" 2>/dev/null || true log_success "Frigate configured at $FRIGATE_DIR" - configure_caddy_for_service "Frigate" "frigate:5000" "frigate" + local AUTH_README_SECTION="" + if [ -n "$AUTH_CONFIG_BLOCK" ]; then + AUTH_README_SECTION=" +## Authelia SSO +Frigate's own login is disabled (\`auth.enabled: False\` in +\`config/config.yml\`) — Authelia gates the whole domain instead via Caddy's +\`import authelia\` plus a \`proxy.auth_secret\`/\`X-Proxy-Secret\` handshake +(the secret lives in \`.env\` as \`FRIGATE_PROXY_AUTH_SECRET\`) so that trust +can't be spoofed by a request that reaches Frigate's published port +directly, bypassing Caddy. + +Everyone who passes Authelia gets full (admin) access to Frigate — +adjust \`config/config.yml\`'s \`proxy.role_map\`/\`default_role\` plus +Authelia's own group assignments if you want to give some users +view-only access instead. + +To stop Authelia asking for a login again on repeat visits (e.g. from a +phone) for as long as possible, increase its \"remember me\" session +duration: \`sudo ./setup.sh authelia\` → \"Change 'remember me' session +duration\" (this affects every domain that instance protects, not just +Frigate). +" + fi write_readme "$FRIGATE_DIR" << MD # Frigate NVR @@ -746,7 +828,7 @@ security cameras. Detects people, cars, animals, and more. - Recordings: \`$FRIGATE_MEDIA\` - Config: \`config/config.yml\` — cameras configured during install (${#CAM_NAME[@]} total) - Credentials: \`.env\` — RTSP user/pass/IP per camera as FRIGATE_* variables - +${AUTH_README_SECTION} ## Manage \`\`\`bash cd $FRIGATE_DIR From be60f475c39a2481cede9d3a0c89aa9fdcc521d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 21:07:02 +0000 Subject: [PATCH 2/5] Add zero-click Authelia login to Gitea and Uptime Kuma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to Frigate's Authelia integration: both of these can also skip their own login entirely once Authelia is doing the gating, each with a different trust model appropriate to what the app actually supports. - gitea: new _gitea_offer_reverse_proxy_auth(), a second Authelia integration alongside the existing OIDC "Sign in with Authelia" button. Enables Gitea's own ENABLE_REVERSE_PROXY_AUTHENTICATION so it auto-logs in from a trusted Remote-User header — no click, no separate Gitea session to expire on its own. Trust is IP-range based (REVERSE_PROXY_TRUSTED_PROXIES), computed from caddy_net's real subnet the same way ufw_allow_from_caddy_net does; refuses to enable the feature at all if that can't be determined rather than fall back to a permissive default — Gitea's own Docker image has shipped an unscoped default before (GHSA-f75j-4cw6-rmx4, any IP could impersonate any user). Rewires Gitea onto caddy_net and re-points Caddy at gitea:3000, since it previously only reached Caddy via its published host port. Gitea's own login stays available as a fallback, so unlike Frigate there's no "native login off with nothing gating it" state to guard against. - uptimekuma: sets DISABLE_AUTH=true only once Caddy's "import authelia" gate is confirmed in front of it. Uptime Kuma already joined caddy_net unconditionally, so this only needed the env var plus moving the Authelia-gated Caddy call earlier (before docker-compose.yml is written); the existing unconditional call at the end now only runs as a fallback when the Authelia path wasn't used or wasn't completed. Kuma's DISABLE_AUTH has no IP-scoping or secret check left once set — the strictest of the three to get the ordering right on, since a mistake here means wide open, not just spoofable. Verified with a local test harness (fake Authelia/Caddy/docker-network state): both the happy path and the "Caddy declined" safety fallback produce the expected docker-compose.yml/.env/Caddyfile output for each service, and Gitea's subnet-detection refusal + idempotent-rerun guard were exercised directly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc --- CLAUDE.md | 50 ++++++++++++++++++++ services/gitea.sh | 105 +++++++++++++++++++++++++++++++++++++++++ services/uptimekuma.sh | 54 +++++++++++++++++++-- 3 files changed, 206 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6f661f0..0bd7821 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -502,6 +502,56 @@ reinstall (from `.env` via `ENV_MAP`, the same array `_frigate_parse_existing` already builds) rather than rotating it and breaking the existing Caddy pairing. +**`gitea` and `uptimekuma` — two more "disable/bypass built-in login, +Authelia is the only gate" integrations, each with its own trust model.** +Both are opt-in extras layered on top of the has-built-in-auth entries +those services already had; neither replaces the existing behavior for +anyone who doesn't ask for it. + +- `gitea`'s `_gitea_offer_reverse_proxy_auth()` is a *second*, stronger + Authelia integration alongside the OIDC "Sign in with Authelia" button + (`_gitea_offer_authelia_sso()`, unchanged): Gitea's own + `ENABLE_REVERSE_PROXY_AUTHENTICATION` mode auto-logs in as whatever + username arrives in a trusted header — no click, no separate Gitea + session with its own expiry. Unlike Frigate, Gitea's own login page + isn't disabled — it stays as a fallback for anyone not arriving through + the trusted path, so there's no "native login off with nothing gating + it" failure mode to guard against here. The trust boundary is + `REVERSE_PROXY_TRUSTED_PROXIES` (an IP range), not a shared secret — + Gitea's own Docker image has shipped this wildcarded before (a real CVE, + GHSA-f75j-4cw6-rmx4: any source IP could set `X-WEBAUTH-USER` and log in + as anyone), so this always computes the range from caddy_net's actual + subnet (`docker network inspect ... --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'`, + the same lookup `ufw_allow_from_caddy_net` uses) and refuses to enable + the feature at all if that can't be determined — never falls back to a + permissive default. `REVERSE_PROXY_AUTHENTICATION_USER`/`_EMAIL` are set + to `Remote-User`/`Remote-Email` to match Authelia's `import authelia` + snippet's own `copy_headers` output directly, rather than renaming + headers in Caddy to match Gitea's own `X-WEBAUTH-USER` default. Gitea + currently reaches Caddy over its published host port + (`host.docker.internal:PORT`), not caddy_net, because it predates this + feature — enabling it rewires Gitea onto caddy_net (like every other + locally-Caddy-fronted service) and re-points Caddy's upstream at + `gitea:3000`, replacing the old site block via + `configure_caddy_for_service`'s own existing "already exists — + overwrite?" prompt. Local Caddy only; a remote Caddy machine's source + address isn't a stable, narrowly-scopeable range the way caddy_net's + bridge subnet is. +- `uptimekuma`'s equivalent is much simpler: Uptime Kuma's `DISABLE_AUTH=true` + env var turns its own login off *completely*, with no IP-range or secret + check left at all — once set, anything that can reach its port is in, no + questions asked. That makes it the one of these three where getting the + ordering wrong is worst: `services/uptimekuma.sh` only ever sets + `DISABLE_AUTH=true` after `configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime" " import authelia"` + confirms `CADDY_SERVICE_CONFIGURED` — the same never-disable-native-auth- + without-a-confirmed-gate rule Frigate follows. Uptime Kuma already joined + caddy_net unconditionally before this (see its own `_CADDY_NET_BLOCK`), + so no networking change was needed here, just the env var and the + Authelia-gated Caddy call happening earlier (before `docker-compose.yml` + is written) instead of the plain unconditional call this file already + had at the end — which now only runs as a fallback when the Authelia + path wasn't used or wasn't completed. + For services without built-in auth, prompt the user before calling `configure_caddy_for_service` and pass `import authelia` as the extra block if Authelia is installed and the user wants SSO protection: diff --git a/services/gitea.sh b/services/gitea.sh index 30c4f50..aedbd97 100644 --- a/services/gitea.sh +++ b/services/gitea.sh @@ -240,6 +240,92 @@ _gitea_offer_authelia_sso() { declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "gitea" "$GITEA_OIDC_DOMAIN" } +# Offers Gitea's OTHER Authelia integration — not the OIDC button above, but +# ENABLE_REVERSE_PROXY_AUTHENTICATION: Gitea auto-logs in as whatever user +# name arrives in a trusted header, no click and no separate Gitea session +# to expire on its own schedule. This is genuinely stronger than the OIDC +# button (which still shows a login page, just with an extra option on it) +# and matches the pattern services/frigate.sh uses — except Gitea's own +# login form stays available as a fallback for anyone NOT arriving from a +# trusted source, so there's no "native login disabled with nothing gating +# it" failure mode to guard against here the way Frigate's had. +# +# The security boundary is REVERSE_PROXY_TRUSTED_PROXIES, not a shared +# secret: Gitea only honors the identity header from source IPs inside that +# range. Gitea's own Docker image shipped this wildcarded (GHSA-f75j-4cw6- +# rmx4 — any IP could set X-WEBAUTH-USER and log in as anyone), so this is +# always computed from caddy_net's real subnet (same lookup +# ufw_allow_from_caddy_net uses) and refuses to enable the feature at all if +# that can't be determined — never falls back to a permissive default. +# +# Requires Gitea to actually be reachable from an address inside that range, +# which means joining caddy_net like every other locally-Caddy-fronted +# service in this repo (Gitea currently reaches Caddy via its published +# host port instead — host.docker.internal upstream — because it predates +# this feature). Local Caddy only: a remote Caddy machine's source address +# isn't a stable, narrowly-scopeable range the way caddy_net's bridge subnet +# is, so this skips remote mode rather than guess at a trust range worth +# getting wrong. +_gitea_offer_reverse_proxy_auth() { + local DIR="$1" + + [ -d "$DOCKER_DIR/authelia" ] || return 0 + [ -d "$DOCKER_DIR/caddy" ] || return 0 + + if grep -q 'ENABLE_REVERSE_PROXY_AUTHENTICATION=true' "$DIR/docker-compose.yml" 2>/dev/null; then + log_info "Gitea's zero-click Authelia login (reverse-proxy auth) is already enabled — skipping." + return 0 + fi + + echo "" + local USE_RP="" + prompt_yn " Skip Gitea's own login entirely for anyone arriving via Authelia — fully transparent, no click, no separate Gitea session to re-expire? Rewires Gitea onto Caddy's internal network (Caddy must be on this same machine). (y/n):" "n" USE_RP + [[ "$USE_RP" =~ ^[Yy]$ ]] || return 0 + + local _subnet + _subnet="$(docker network inspect "${SITE_CADDY_NET:-caddy_net}" \ + --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' 2>/dev/null)" + if [ -z "$_subnet" ]; then + log_warning "Couldn't determine ${SITE_CADDY_NET:-caddy_net}'s subnet — refusing to enable" + log_warning "reverse-proxy auth without a scoped trust range. An unscoped default lets ANY" + log_warning "client impersonate ANY Gitea user via a spoofed header (this was a real Gitea" + log_warning "CVE — GHSA-f75j-4cw6-rmx4). Skipping." + return 1 + fi + + log_info "Wiring Gitea onto caddy_net and enabling reverse-proxy authentication..." + sed -i "/GITEA__security__INSTALL_LOCK=true/a\\ - GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION=true\\n - GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION=true\\n - GITEA__service__ENABLE_REVERSE_PROXY_EMAIL=true\\n - GITEA__security__REVERSE_PROXY_AUTHENTICATION_USER=Remote-User\\n - GITEA__security__REVERSE_PROXY_AUTHENTICATION_EMAIL=Remote-Email\\n - GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES=${_subnet}" \ + "$DIR/docker-compose.yml" + cat >> "$DIR/docker-compose.yml" << EOF + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${SITE_CADDY_NET:-caddy_net} +EOF + + _gitea_fix_ownership "$DIR" + (cd "$DIR" && docker compose up -d) \ + && log_success "Gitea restarted on caddy_net (trusted range: ${_subnet})." \ + || { log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs"; return 1; } + + # Re-point Caddy at the container (gitea:3000, now reachable over + # caddy_net) instead of the host-published port, with the auth gate in + # front. This replaces the plain block set up earlier in this install — + # configure_caddy_for_service's own "already exists — overwrite?" prompt + # covers that; nothing here bypasses it. + configure_caddy_for_service "Gitea" "gitea:3000" "git" " import authelia" + if [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ]; then + log_success "Gitea now signs in transparently via Authelia at https://${CADDY_SERVICE_DOMAIN} — its own login page is still there for anyone reaching it another way." + declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "gitea" "$CADDY_SERVICE_DOMAIN" + else + log_warning "Caddy wasn't reconfigured — env vars are set, but nothing is routing Gitea through Authelia yet." + log_warning "Point Gitea's Caddy entry at gitea:3000 (not the old host.docker.internal upstream) with 'import authelia' in front, or just re-run this offer." + fi +} + # Offers to enable Gitea Actions (Gitea's own CI, largely GitHub-Actions- # workflow-compatible) with a local runner — mainly useful as a fallback so # .gitea/workflows/*.yml can still run something like a GitHub Actions build @@ -445,6 +531,8 @@ install_gitea() { echo "[DRY-RUN] to install a systemd timer for automatic sync, or print manual instructions" echo "[DRY-RUN] Would offer to run a sync now (dry-run preview or for real), off-schedule" echo "[DRY-RUN] Would offer \"Sign in with Authelia\" (OIDC) if Authelia is installed" + echo "[DRY-RUN] Would offer zero-click Authelia login (reverse-proxy auth) if Authelia" + echo "[DRY-RUN] and local Caddy are both installed — rewires Gitea onto caddy_net" echo "[DRY-RUN] Would offer to enable Gitea Actions (CI) with a local act_runner container" echo "[DRY-RUN] Would write $DIR/README.md" return 0 @@ -473,6 +561,7 @@ install_gitea() { || log_warning "Restart failed — check: docker compose -f $DIR/docker-compose.yml logs" _gitea_run_sync_direction_step "$DIR" _gitea_offer_authelia_sso "$DIR" + _gitea_offer_reverse_proxy_auth "$DIR" _gitea_offer_actions_runner "$DIR" log_success "Existing .env (tokens) and web/SSH ports were left untouched." return 0 @@ -643,6 +732,7 @@ ENV configure_caddy_for_service "Gitea" "host.docker.internal:${WEB_PORT}" "git" _gitea_offer_authelia_sso "$DIR" + _gitea_offer_reverse_proxy_auth "$DIR" _gitea_offer_actions_runner "$DIR" write_readme "$DIR" << MD @@ -685,6 +775,21 @@ on Gitea's own login page. Local admin login keeps working exactly as before — this is additive, not a replacement. Managed in Gitea under Site Administration -> Authentication Sources (source name: \`authelia\`). +## Zero-click Authelia login (optional, stronger) + +A second, separate Authelia integration: instead of an extra button on +Gitea's login page, Gitea auto-logs in as whoever Authelia says you are — +no click, and no separate Gitea session that can expire on its own and +force a re-login later. Re-run \`sudo ./setup.sh gitea\` (Update mode) and +answer yes to the "Skip Gitea's own login entirely..." prompt. Requires +Authelia and Caddy on this same machine — it moves Gitea onto Caddy's +internal Docker network (\`caddy_net\`) and Gitea only trusts the identity +header from that network's address range, not from the internet or from +its own host-published port. Gitea's own login page keeps working for +anyone who reaches it any other way (e.g. directly on its port). New +users arriving this way get an ordinary (non-admin) Gitea account created +automatically the first time they show up. + ## Gitea Actions (CI) — optional local runner Re-run \`sudo ./setup.sh gitea\` (Update mode is fine) and answer yes to diff --git a/services/uptimekuma.sh b/services/uptimekuma.sh index dac8d28..67f9e08 100644 --- a/services/uptimekuma.sh +++ b/services/uptimekuma.sh @@ -206,6 +206,9 @@ install_uptimekuma() { if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $UPTIME_DIR" echo "[DRY-RUN] Would auto-scan for a free host port" + echo "[DRY-RUN] If Authelia is installed: would offer to protect Uptime Kuma with it —" + echo "[DRY-RUN] sets DISABLE_AUTH=true (Kuma's own login off) only once Caddy's" + echo "[DRY-RUN] 'import authelia' gate is actually confirmed in front of it" return 0 fi @@ -241,6 +244,39 @@ networks: " fi + # Authelia SSO — decided (and, if accepted, wired into Caddy) before + # docker-compose.yml is written, so DISABLE_AUTH only ever gets set once + # Caddy's "import authelia" gate is actually confirmed in front of Kuma. + # Unlike Frigate/Gitea, Uptime Kuma with DISABLE_AUTH=true has NO + # internal check left at all — it's not IP-scoped (Gitea) or secret- + # pinned (Frigate), just fully open to whatever reaches its port, so + # this is the one place getting the ordering wrong is worst: a login- + # disabled Kuma with nothing gating it is wide open to anyone who can + # reach the port, not just spoofable. + local UPTIME_USE_AUTHELIA="n" UPTIME_ENV_BLOCK="" _uptime_caddy_done=false + if [ -d "$DOCKER_DIR/authelia" ]; then + echo "" + prompt_yn "Protect Uptime Kuma with Authelia SSO (disables Kuma's own login entirely)? (y/n):" "y" UPTIME_USE_AUTHELIA + fi + + if [[ "$UPTIME_USE_AUTHELIA" =~ ^[Yy]$ ]]; then + configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime" " import authelia" + if [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ]; then + UPTIME_ENV_BLOCK=" - DISABLE_AUTH=true" + _uptime_caddy_done=true + declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "uptimekuma" "$CADDY_SERVICE_DOMAIN" + else + log_warning "Caddy wasn't configured — leaving Uptime Kuma's own login enabled (nothing else would be gating access)." + fi + fi + + local UPTIME_ENV_SECTION="" + if [ -n "$UPTIME_ENV_BLOCK" ]; then + UPTIME_ENV_SECTION=" environment: +${UPTIME_ENV_BLOCK} +" + fi + cat > docker-compose.yml << UPTIME_COMPOSE name: uptime-kuma @@ -250,7 +286,7 @@ services: container_name: uptime-kuma hostname: uptime-kuma restart: unless-stopped - volumes: +${UPTIME_ENV_SECTION} volumes: - ./data:/app/data - /var/run/docker.sock:/var/run/docker.sock:ro ports: @@ -282,6 +318,15 @@ Docker containers. If Caddy is installed, you can expose this via the prompt during install (see configure_caddy_for_service). Default subdomain: uptime. +## Authelia SSO (optional) +If Authelia is installed, the installer offers to protect Uptime Kuma with +it instead of Kuma's own login — this sets \`DISABLE_AUTH=true\` (Kuma's own +account/login screen goes away entirely) and puts Caddy's \`import authelia\` +gate in front instead, so Authelia is the only thing checking who you are. +This only gets set once Caddy confirms it's actually fronting the domain — +never with nothing else gating access. Re-run \`sudo ./setup.sh uptimekuma\` +to add or change this later. + ## Manage \`\`\` cd $UPTIME_DIR @@ -291,8 +336,11 @@ docker compose logs -f # logs \`\`\` MD - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime" + # Configure Caddy reverse proxy before starting (skip if the Authelia + # step above already did it) + if [ "$_uptime_caddy_done" != true ]; then + configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime" + fi local START_UPTIME="" prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME From b0d51f034444f3ff68893fcd3c7ce6af94fd3732 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 21:13:16 +0000 Subject: [PATCH 3/5] Fix sms-inbound.sh persisting an unusable "" webhook URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the "Public domain for the webhook" prompt was left blank (DNS not ready yet, or just missed), the installer built the Forward-to-URL as literal https:///sms/... and persisted that placeholder to settings.env as if it were real. "Update" mode never re-prompts for the domain (by design — it's meant to leave already-configured settings alone), so every later re-run silently re-served the same unusable placeholder, with nothing indicating anything was wrong. A DID provider (Anveo) correctly rejects it — it isn't a resolvable hostname. - Only build FORWARD_URL when a real domain was entered; leave it empty otherwise instead of substituting the placeholder. - Fresh-install summary and README now say plainly that setup isn't complete and how to finish it, instead of printing an empty/bogus URL. - Update-mode now detects a missing/placeholder domain and tells you to re-run with "Full reinstall" to be asked again, instead of reporting success with a broken URL. Verified with a direct test of _sms_write_readme() and the FORWARD_URL construction for both the blank- and real-domain cases. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc --- services/sms-inbound.sh | 67 ++++++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/services/sms-inbound.sh b/services/sms-inbound.sh index f8447b4..1681d88 100644 --- a/services/sms-inbound.sh +++ b/services/sms-inbound.sh @@ -629,17 +629,11 @@ CBLOCK _sms_write_readme() { local _url="$1" _relay_domain="$2" - write_readme "$SMS_APP_DIR" << MD -# Inbound SMS → Sipnetic (via AMI) -Gets SMS sent to one of your PSTN DIDs delivered into Asterisk as a SIP -MESSAGE, landing in Sipnetic the same way internal texting already does — -not a push notification, a real message in the softphone. - -## The URL to paste into your DID provider - -In the provider portal, open the DID's SMS settings and paste this into the -"Forward to URL" field (on Anveo: Phone Numbers → the DID → SMS tab, tick + local _url_section + if [ -n "$_url" ]; then + _url_section="In the provider portal, open the DID's SMS settings and paste this into the +\"Forward to URL\" field (on Anveo: Phone Numbers → the DID → SMS tab, tick the checkbox, paste, press SAVE — RETURN discards): \`\`\` @@ -652,7 +646,21 @@ query parameters; with the message last, everything after it can be read back verbatim. Treat this URL like a password — anyone holding it can trigger a message -delivery into your Asterisk. +delivery into your Asterisk." + else + _url_section="**Not set up yet — no public domain was entered.** Re-run \`sudo ./setup.sh sms-inbound\` and choose \"Full reinstall\" once DNS for the webhook's domain points at this box; nothing here works until then." + fi + + write_readme "$SMS_APP_DIR" << MD +# Inbound SMS → Sipnetic (via AMI) + +Gets SMS sent to one of your PSTN DIDs delivered into Asterisk as a SIP +MESSAGE, landing in Sipnetic the same way internal texting already does — +not a push notification, a real message in the softphone. + +## The URL to paste into your DID provider + +${_url_section} ## How delivery is decided @@ -770,8 +778,24 @@ install_sms-inbound() { && log_success "Relay refreshed and restarted." \ || log_warning "Restart failed — check: journalctl -u sms-inbound -n 50" echo "" - log_success "Settings, Caddy and firewall rules were left untouched." - echo " Provider URL: ${SMS_FORWARD_URL}" + # A missing/placeholder domain here means an earlier run was + # left with no real webhook URL (RELAY_DOMAIN entered blank, + # or DNS wasn't ready yet) — "update" mode never re-prompts + # for the domain (by design, same as every other service's + # non-destructive update path), so silently repeating that + # broken URL forever, looking like nothing is wrong, is worse + # than saying so plainly. Confirmed live: this is exactly + # what a DID provider like Anveo rejects — "" + # isn't a resolvable hostname. + if [[ -z "${SMS_RELAY_DOMAIN:-}" || "${SMS_FORWARD_URL:-}" == *""* ]]; then + log_warning "No real webhook domain was ever set for this install — the stored" + log_warning "provider URL is a placeholder, not something a DID provider can use." + log_warning "Re-run 'sudo ./setup.sh sms-inbound' and choose \"2) Full reinstall\"" + log_warning "to be asked for the domain again (needs DNS pointed at this box first)." + else + log_success "Settings, Caddy and firewall rules were left untouched." + echo " Provider URL: ${SMS_FORWARD_URL}" + fi echo "" return 0 ;; @@ -898,7 +922,14 @@ install_sms-inbound() { ensure_ufw_enabled fi - local FORWARD_URL="https://${RELAY_DOMAIN:-}/sms/${RELAY_TOKEN}?from=\$[from]\$&to=\$[to]\$&message=\$[message]\$" + # Empty (not a "" placeholder) when no domain was entered — + # a placeholder here used to get persisted to settings.env and silently + # re-served as-is on every later "update" run (which never re-prompts + # for the domain, by design), looking like a valid webhook URL right up + # until a DID provider like Anveo rejected it as an unresolvable host. + # Confirmed live. + local FORWARD_URL="" + [ -n "$RELAY_DOMAIN" ] && FORWARD_URL="https://${RELAY_DOMAIN}/sms/${RELAY_TOKEN}?from=\$[from]\$&to=\$[to]\$&message=\$[message]\$" # ── Persist settings ────────────────────────────────────────────────────── # Single-quoted values: this file gets `source`d again on the next @@ -929,6 +960,14 @@ ENV # ── Summary ─────────────────────────────────────────────────────────────── echo "" + if [ -z "$FORWARD_URL" ]; then + log_warning "Inbound SMS relay is running, but nothing can reach it yet — no domain was entered." + log_warning "Point an A record at this box, then re-run 'sudo ./setup.sh sms-inbound' and" + log_warning "choose \"2) Full reinstall\" to be asked for the domain again and get a real" + log_warning "\"Forward to URL\" to paste into your DID provider." + echo "" + return 0 + fi log_success "Inbound SMS → Sipnetic configured." echo "" echo " 1. In your DID provider's portal, open the number's SMS settings and" From da59b65cebade0afce4035427138b0bcda279cef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 21:37:52 +0000 Subject: [PATCH 4/5] Wire up MWI so voicemail notifications actually reach the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed live: neither this repo nor the vendored easy-asterisk script ever sets a PJSIP endpoint's `mailboxes=` field. add_device()'s own device_config template never writes it, and write_voicemail() only ever touched voicemail.conf — so recording a voicemail worked fine (voicemail.conf + the dialplan's VoiceMail() call), but no phone ever actually subscribed to be told about it, regardless of whether the voicemail flag was on. Matches the exact symptom of "voicemail records fine, but no notice comes up on the phone." Add _ea_set_endpoint_mailboxes(), called from write_voicemail(): adds/ updates mailboxes=@default in that extension's PJSIP endpoint stanza when voicemail is enabled, removes it when disabled, and reloads res_pjsip so it takes effect immediately. Bounded to just the type=endpoint stanza (pjsip.conf reuses the same [ext] bracket name for type=endpoint/type=auth/type=aor) the same way lib/common.sh's _remove_caddy_site_block is bounded for Caddy blocks — verified against a two-device pjsip.conf that editing one extension's mailboxes= never touches its own auth/aor stanzas or another extension's stanzas, that a repeat enable doesn't duplicate the line, and that disabling removes it cleanly. Existing extensions with voicemail already enabled won't get this retroactively — the Extensions tab's voicemail toggle has to actually run again (off then back on) to apply it, since this only fires on the enabled/disabled transition itself. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc --- services/security-dashboard.sh | 85 +++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/services/security-dashboard.sh b/services/security-dashboard.sh index 8344df4..6401e44 100644 --- a/services/security-dashboard.sh +++ b/services/security-dashboard.sh @@ -1956,6 +1956,79 @@ def ea_reload_voicemail(): run_sudo(["docker", "exec", ASTERISK_EA_CONTAINER, "asterisk", "-rx", "module reload app_voicemail.so"]) +def _ea_endpoint_stanza_bounds(lines, ext): + """Line-index range (start, end-exclusive) of the `[ext]\\ntype=endpoint` + PJSIP stanza for one extension, or None if not found. pjsip.conf reuses + the same [ext] bracket name for three separate stanzas per device + (type=endpoint, type=auth, type=aor — see easy-asterisk-v0.10.0.sh's + add_device()), so matching on the bracket alone would land in the wrong + one; this only matches the occurrence immediately followed by + "type=endpoint", bounded by the next blank line or next [section] the + same way lib/common.sh's _remove_caddy_site_block is bounded for Caddy + blocks — never an unbounded scan past this one device's own stanza.""" + target = "[%s]" % ext + i, n = 0, len(lines) + while i < n: + if lines[i].strip() == target and i + 1 < n and lines[i + 1].strip() == "type=endpoint": + j = i + 1 + while j < n and lines[j].strip() != "" and not lines[j].strip().startswith("["): + j += 1 + return i, j + i += 1 + return None + + +def _ea_set_endpoint_mailboxes(ext, enabled): + """Adds/updates (enabled) or removes (disabled) the extension's PJSIP + `mailboxes=` line, so a phone can actually SUBSCRIBE for MWI (the "new + voicemail" notice) on this extension. + + Confirmed live: nothing anywhere in this repo or the vendored + easy-asterisk script ever sets this. add_device()'s own device_config + template (easy-asterisk-v0.10.0.sh) never writes it, and until this, + write_voicemail() below only ever touched voicemail.conf — so recording + a voicemail worked fine (voicemail.conf + the dialplan's VoiceMail() + call), but no phone ever actually subscribed to be told about it, + regardless of whether the voicemail flag was on. `mailboxes=@default` + matches the "default" context name voicemail.conf's [default] section + uses (see _asterisk_write_voicemail_conf in services/asterisk.sh) — + same context, just referenced from the endpoint side instead of the + dialplan side.""" + path = _ea_pjsip_host_path() + if not path or not os.path.isfile(path): + return False, "No pjsip.conf found" + with open(path) as f: + lines = f.readlines() + + bounds = _ea_endpoint_stanza_bounds(lines, ext) + if not bounds: + return False, "No PJSIP endpoint found for extension %s" % ext + start, end = bounds + + existing_idx = None + for k in range(start, end): + if lines[k].lstrip().startswith("mailboxes="): + existing_idx = k + break + + if enabled: + mailbox_line = "mailboxes=%s@default\n" % ext + if existing_idx is not None: + lines[existing_idx] = mailbox_line + else: + lines.insert(end, mailbox_line) + elif existing_idx is not None: + del lines[existing_idx] + else: + return True, "" + + ok, err = ea_docker_write(EA_PJSIP_CONTAINER_PATH, "".join(lines)) + if not ok: + return False, err + ea_reload_pjsip() + return True, "" + + def write_voicemail(ext, enabled): """Sets/clears the voicemail flag for one extension, then regenerates voicemail.conf and reloads app_voicemail so the change takes effect @@ -1968,7 +2041,12 @@ def write_voicemail(ext, enabled): pstn-permissions.conf even after disabling — toggling it off and back on later reuses the same PIN instead of silently changing it on the user. Independent of pstn_installed() the same way messaging is: voicemail has - no PSTN/trunk dependency.""" + no PSTN/trunk dependency. + + Also wires up (or tears down) MWI via _ea_set_endpoint_mailboxes() — the + extension's PJSIP endpoint needs its own `mailboxes=` line for a phone + to ever be told about a new voicemail; voicemail.conf alone is only + enough for the recording itself, not the notification.""" if not ASTERISK_CONFIG_DIR: return False, "No Asterisk install detected on this box" ext = str(ext).strip() @@ -1990,6 +2068,11 @@ def write_voicemail(ext, enabled): return True, "Saved, but voicemail.conf couldn't be regenerated: %s" % err ea_reload_voicemail() + + mok, merr = _ea_set_endpoint_mailboxes(ext, enabled) + if not mok: + return True, "Saved, but couldn't wire up the phone's voicemail notification (MWI): %s" % merr + return True, "Saved" From b7691e6c1b24e4c65838f40f75a24fab9b6d3b69 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 21:55:15 +0000 Subject: [PATCH 5/5] asterisk.sh: offer to fix Caddy when DOMAIN_NAME is set but unwired, in update mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "update" mode never re-asks the domain/networking/Caddy questions, on the assumption there's already Caddy/Authelia config in place to leave alone. That assumption breaks for an install where a domain was set at some point (DOMAIN_NAME in .env) but Caddy never actually got a site block for it — declined at install time, DNS wasn't ready yet, or Caddy was reinstalled/reset separately since. Previously the only way back was a full reinstall, which re-generates a dedicated coturn container with new TURN credentials (every already-configured phone needs its QR re-scanned) — a lot of blast radius just to add one missing Caddy block, and enough that reaching for it risks the extensions/voicemail data a "fresh" reinstall can also wipe if the wrong prompt is answered. "update" mode now detects this specific gap (domain set, no matching Caddyfile block) and offers to run _asterisk_configure_caddy_public() right there — the same function "fresh" installs use, but it only ever touches the Caddyfile and .env's WEB_ADMIN_AUTH_DISABLED line, never coturn/extensions/anything else "update" already promises not to touch. Verified in isolation: offers and calls the fix when the domain is set with no matching Caddyfile block, stays silent when a block already exists. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SpKTLpwAgZNooTacWeQLuc --- services/asterisk.sh | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/services/asterisk.sh b/services/asterisk.sh index 6bbec0c..1aed406 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -2241,8 +2241,46 @@ install_asterisk() { local _EXISTING_DOMAIN _EXISTING_PORT _EXISTING_DOMAIN="$(grep -E '^DOMAIN_NAME=' .env | cut -d= -f2-)" _EXISTING_PORT="$(grep -E '^WEB_ADMIN_PORT=' .env | cut -d= -f2-)" + + # A domain was set at some point (DOMAIN_NAME in .env) but + # Caddy never ended up with a site block for it — declined + # at install time, DNS wasn't ready yet, or Caddy itself was + # reinstalled/reset since. "update" never re-asks the + # domain/networking/firewall questions (see this branch's + # own comment above), but leaving a configured-but-unwired + # domain broken forever with no way back short of a full + # reinstall (which rotates coturn/TURN credentials — see the + # "fresh" branch's own warning below) defeats the point of + # "update" being the safe, no-side-effects path. + # _asterisk_configure_caddy_public() only ever touches the + # Caddyfile and .env's WEB_ADMIN_AUTH_DISABLED line — never + # coturn, extensions, or anything a full reinstall would put + # at risk — so it's safe to offer here even though nothing + # else in "update" touches Caddy. + local _CADDY_JUST_CONFIGURED=false + if [[ -n "$_EXISTING_DOMAIN" ]] && [[ -d "$DOCKER_DIR/caddy" ]] \ + && ! grep -q "^${_EXISTING_DOMAIN}" "$DOCKER_DIR/caddy/Caddyfile" 2>/dev/null; then + echo "" + log_warning "DOMAIN_NAME (${_EXISTING_DOMAIN}) is set, but Caddy has no site" + log_warning "block for it — nothing is actually serving that domain." + local _FIX_CADDY="" + prompt_yn " Configure Caddy for ${_EXISTING_DOMAIN} now? (y/n):" "y" _FIX_CADDY + if [[ "$_FIX_CADDY" =~ ^[Yy]$ ]]; then + local _CURRENT_PUBLIC_IP="" + _CURRENT_PUBLIC_IP="$(curl -fsS --max-time 2 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null || true)" + [[ -z "$_CURRENT_PUBLIC_IP" ]] && _CURRENT_PUBLIC_IP="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || true)" + [[ -z "$_CURRENT_PUBLIC_IP" ]] && _CURRENT_PUBLIC_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" + _asterisk_configure_caddy_public "$_EXISTING_DOMAIN" "${_EXISTING_PORT:-8081}" "$_CURRENT_PUBLIC_IP" + _CADDY_JUST_CONFIGURED=true + fi + fi + echo "" - log_success "Existing .env, firewall rules, and Caddy/Authelia config were left untouched." + if [[ "$_CADDY_JUST_CONFIGURED" == true ]]; then + log_success "Existing .env and firewall rules were left untouched; Caddy was just configured above." + else + log_success "Existing .env, firewall rules, and Caddy/Authelia config were left untouched." + fi if [[ -n "$_EXISTING_DOMAIN" ]]; then echo " Web admin: https://${_EXISTING_DOMAIN}/" else