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