diff --git a/CLAUDE.md b/CLAUDE.md index 139a009..0bd7821 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,80 @@ 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. + +**`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/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/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 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 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/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" 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" 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