From d1d234b4d22af9fe2fb33c13b6fea085163ccae3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 13:39:02 +0000 Subject: [PATCH 1/4] Fix Gatus false-positive red on Authelia-protected and stale-synced sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-sync condition "[STATUS] < 400" reads as red for any site behind Authelia's forward_auth: Gatus's probe is never logged in, so it correctly gets a 401 back every time — the site is completely healthy, Authelia is just doing its job, but that 401 fails the condition. Confirmed live: every site the user actually logs into showed permanently red. That single condition also had the opposite bug in reserve: on a genuine outage (connection refused, DNS failure, TLS failure), Gatus reports [STATUS] as 0, and 0 < 400 is true — a fully unreachable site would have silently read as "up". Fixed to two conditions together: "[CONNECTED] == true" (catches the actual outage case) and "[STATUS] < 500" (accepts any real response, including 401/403/redirects from an auth gate, only failing on Caddy's own 502/503/504 when the backend itself is unreachable). Also changed the sync loop to refresh conditions on already-synced endpoints, not just add-missing-ones — the old add-if-missing-only logic meant this fix would only apply to newly discovered domains, leaving every already-synced site (which is most of them, on a live box) stuck on the broken condition forever until removed and re-added by hand. Now every sync run (every 15 minutes via the existing systemd timer, or the one that happens immediately on a Gatus reinstall) self-heals all of them. Verified end-to-end against the real mikefarah/yq binary: an existing caddy-sync entry gets its conditions rewritten in place, an unrelated manually-added endpoint is left untouched, and a newly-discovered domain gets the corrected conditions from the start. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn --- services/gatus.sh | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/services/gatus.sh b/services/gatus.sh index 16aab0c..0d10068 100644 --- a/services/gatus.sh +++ b/services/gatus.sh @@ -262,14 +262,34 @@ EXISTING_SYNCED="$(yq e '.endpoints[] | select(.group == "caddy-sync") | .name' ADDED=0 REMOVED=0 +# [CONNECTED] == true catches an actual outage (refused/timed-out +# connection, DNS failure, TLS failure — [STATUS] reads 0 in all of those, +# which is < 400 too, so that condition ALONE would have silently reported +# a fully unreachable site as "up"). [STATUS] < 500 accepts anything the +# server actually answered with, including 401/403/redirects from an +# Authelia-protected site — Gatus's own probe is never logged in, so an +# Authelia-gated site correctly returns 401 to it every time, which is the +# site working exactly as designed, not an outage. Only Caddy's own +# 502/503/504 (backend unreachable) or a connection failure should ever +# read as down here. Confirmed live: with the old "[STATUS] < 400" alone, +# every Authelia-protected site the user actually logs into showed red +# permanently, and a would-be-real outage (STATUS=0) would have shown green. +CONDITIONS='["[CONNECTED] == true", "[STATUS] < 500"]' + while IFS= read -r domain; do [ -z "$domain" ] && continue # Domains only — a defensive filter, not strictly needed since these # come from our own Caddyfile, but cheap insurance against ever # building a yq expression out of anything unexpected. [[ "$domain" =~ ^[a-zA-Z0-9.-]+$ ]] || continue - if ! grep -qxF "$domain" <<< "$EXISTING_SYNCED"; then - yq e -i ".endpoints += [{\"name\": \"${domain}\", \"group\": \"caddy-sync\", \"url\": \"https://${domain}\", \"interval\": \"5m\", \"conditions\": [\"[STATUS] < 400\"]}]" "$GATUS_CONFIG" \ + if grep -qxF "$domain" <<< "$EXISTING_SYNCED"; then + # Already synced — refresh its conditions too, not just add-if- + # missing, so a template fix like this one reaches every + # already-added endpoint on the very next sync instead of leaving + # them stuck on whatever conditions they were first created with. + yq e -i "(.endpoints[] | select(.group == \"caddy-sync\" and .name == \"${domain}\") | .conditions) = ${CONDITIONS}" "$GATUS_CONFIG" + else + yq e -i ".endpoints += [{\"name\": \"${domain}\", \"group\": \"caddy-sync\", \"url\": \"https://${domain}\", \"interval\": \"5m\", \"conditions\": ${CONDITIONS}}]" "$GATUS_CONFIG" \ && ADDED=$((ADDED + 1)) fi done <<< "$CURRENT_DOMAINS" From 59f82c57a62ecfc86bf20a280258031b98efd1a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:04:13 +0000 Subject: [PATCH 2/4] Self-heal a half-set SMTP_HOST/SMTP_FROM in Vaultwarden's .env Vaultwarden crash-loops outright if exactly one of SMTP_HOST/SMTP_FROM is set ("Both SMTP_HOST and SMTP_FROM need to be set for email support without USE_SENDMAIL"). The fresh-install prompt flow already avoids ever writing that half-state, but "update" mode deliberately never touches .env (same rule as everywhere else in this repo), so a box whose .env was written before that prompt-side fix existed - or hand-edited since - stays stuck crash-looping on every future update too, since nothing ever re-checked it. Confirmed live on a real box. New _vaultwarden_fix_smtp_halfstate() detects the half-set state and blanks the whole SMTP block (matching what the fresh-install prompt does when SMTP is skipped) rather than leaving it broken. Called right before every docker compose up this file does - the update path (previously unguarded) and the fresh-install start prompt (defense in depth, since that path is already safe by construction) - so it self-heals regardless of how a box got into this state. Audited every other services/*.sh for the same half-set-required-pair pattern (SMTP, MAIL_*, SMTP_HOST-style naming) - Vaultwarden is the only one that actually writes paired config where a partial state crashes the container. Authelia's SMTP is mandatory-with-defaults (a different, non-crashing risk); Mattermost/frigate-notify only mention SMTP in generated docs, never in config they write. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn --- services/vaultwarden.sh | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/services/vaultwarden.sh b/services/vaultwarden.sh index d7734c3..cf2c66d 100644 --- a/services/vaultwarden.sh +++ b/services/vaultwarden.sh @@ -214,6 +214,29 @@ fi register_service vaultwarden utilities "Bitwarden-compatible password manager (Vaultwarden)" 80 +# Vaultwarden refuses to start at all — crash-loops — if exactly one of +# SMTP_HOST/SMTP_FROM is set in .env ("Both SMTP_HOST and SMTP_FROM need to +# be set for email support without USE_SENDMAIL"). The fresh-install prompt +# flow below never writes that half-state, but "update" mode deliberately +# never touches .env (same non-destructive-update rule as everywhere else +# in this repo), so a box whose .env was written before that prompt-side +# fix existed — or hand-edited since — stays stuck crash-looping forever, +# on every future update too, since nothing ever re-checks it. Confirmed +# live. Called right before every `docker compose up` this file does, not +# just at install time, so it self-heals regardless of how the box got into +# this state. +_vaultwarden_fix_smtp_halfstate() { + local env_file="$1" + [ -f "$env_file" ] || return 0 + local host from + host="$(grep '^SMTP_HOST=' "$env_file" 2>/dev/null | cut -d= -f2-)" + from="$(grep '^SMTP_FROM=' "$env_file" 2>/dev/null | cut -d= -f2-)" + if { [ -n "$host" ] && [ -z "$from" ]; } || { [ -z "$host" ] && [ -n "$from" ]; }; then + log_warning "SMTP_HOST/SMTP_FROM in $env_file are half-set (Vaultwarden requires both or neither) — disabling SMTP so the container can actually start. Edit $env_file (or run a fresh reinstall) to set up email properly." + sed -i -E 's/^SMTP_HOST=.*/SMTP_HOST=/; s/^SMTP_FROM=.*/SMTP_FROM=/; s/^SMTP_PORT=.*/SMTP_PORT=/; s/^SMTP_SECURITY=.*/SMTP_SECURITY=/; s/^SMTP_USERNAME=.*/SMTP_USERNAME=/; s/^SMTP_PASSWORD=.*/SMTP_PASSWORD=/' "$env_file" + fi +} + install_vaultwarden() { require_docker || return 1 log_info "Installing Vaultwarden..." @@ -275,6 +298,7 @@ install_vaultwarden() { case "$MODE" in update) log_info "Refreshing the Vaultwarden image only — existing config, port, and Caddy setup are left as-is." + _vaultwarden_fix_smtp_halfstate "$VW_DIR/.env" ( cd "$VW_DIR" && docker compose pull && docker compose up -d ) \ && log_success "Vaultwarden image refreshed" \ || log_warning "Refresh failed — check: docker compose -f $VW_DIR/docker-compose.yml logs" @@ -463,6 +487,7 @@ MD local START_VW="" prompt_yn "Start Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_VW if [ "$START_VW" = "y" ] || [ "$START_VW" = "Y" ]; then + _vaultwarden_fix_smtp_halfstate "$VW_DIR/.env" docker compose up -d \ && log_success "Vaultwarden${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \ || log_warning "Failed to start — check: docker compose logs" From 2a2aba0c9df997c5aacbbe123e912b37329fc953 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:13:52 +0000 Subject: [PATCH 3/4] Add Authelia OIDC provider + register-a-client flow for ActualBudget/Vaultwarden/other apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authelia's forward_auth (what this repo already sets up) gates a whole site behind a login page before the request reaches it. This is the opposite direction: an app with its own "Enable OpenID"/SSO setting delegating ITS login to Authelia, via Authelia's separate OIDC PROVIDER feature, which this repo had no support for at all. _authelia_ensure_oidc_provider() enables it once, idempotently: generates an HMAC secret (injected via a _FILE env var, same convention as the existing jwt/session/storage secrets) and an RSA signing keypair, then writes identity_providers.oidc into configuration.yml. The RSA private key has to be inlined as PEM directly in that file — Authelia's jwks schema has no file-path or env-var option for it — so configuration.yml gets chmod 600 once OIDC is enabled, unlike before when it held no raw secrets. _authelia_add_oidc_client() registers an app: presets for ActualBudget (/openid/callback) and Vaultwarden (/identity/connect/oidc-signin, and confirmed its SSO support is now native/upstream, not fork-only) fill in the redirect URI automatically; "Other/custom" covers anything else. Each app gets its own Client ID and a random secret (shown once, only the pbkdf2 hash is stored), and the output tells the operator exactly what to paste back into that app's own OpenID dialog or .env — including Vaultwarden's exact SSO_* env vars, not just generic OIDC endpoint URLs. Wired into the existing "Authelia already exists" menu as a new option, alongside "add another protected domain" and "reconfigure from scratch". Exact CLI output formats, default filenames, and YAML schema were verified against Authelia's own CLI source/docs (crypto rand's "Random Value: " label, crypto hash generate pbkdf2's "Random Password:"/"Digest:" labels, crypto pair rsa generate's private.pem/public.pem defaults) rather than guessed, since a wrong assumption here means a cryptic startup failure or broken secret extraction. The YAML manipulation (client-list insertion, domain extraction from session.cookies) was tested end-to-end against the real mikefarah/yq binary against a realistic mock config, which caught a real bug (extracting the wrong awk field for the domain, "domain:" instead of the actual value) before it shipped. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn --- services/authelia.sh | 275 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 272 insertions(+), 3 deletions(-) diff --git a/services/authelia.sh b/services/authelia.sh index 365c1fb..454987b 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -225,18 +225,24 @@ install_authelia() { echo "" echo " 1) Add another protected domain to this instance (non-destructive —" echo " one Authelia+Redis, multiple independent apex domains/logins)" - echo " 2) Reconfigure from scratch (regenerates secrets/users — breaks" + echo " 2) Register an app to log in VIA Authelia (OIDC/SSO — e.g. ActualBudget," + echo " Vaultwarden, or any other app with its own \"Enable OpenID\" setting)" + echo " 3) Reconfigure from scratch (regenerates secrets/users — breaks" echo " existing sessions for every domain already on this instance)" - echo " 3) Leave as-is" + echo " 4) Leave as-is" echo "" local EXISTING_CHOICE="" - prompt_text " Choice [1/2/3]:" "3" EXISTING_CHOICE + prompt_text " Choice [1/2/3/4]:" "4" EXISTING_CHOICE case "$EXISTING_CHOICE" in 1) add_authelia_domain return 0 ;; 2) + _authelia_add_oidc_client + return 0 + ;; + 3) : # fall through to the full reinstall flow below ;; *) @@ -521,6 +527,30 @@ container and one shared user database, not a second full stack. Cheaper than standing up an entirely separate instance, and the right way to protect multiple unrelated domains from the same box. +## Letting other apps log in via Authelia (OIDC/SSO) +Different from \`import authelia\` above: that gates a whole site behind a +login page before the request reaches it. This is for an app with its OWN +"Enable OpenID"/SSO setting (ActualBudget, Vaultwarden, etc.) that should +delegate ITS login to Authelia instead of a separate app-specific password. + +Re-run this installer and choose **"Register an app to log in VIA +Authelia"** when it detects the existing install. Presets exist for +ActualBudget and Vaultwarden (their exact redirect URI is filled in +automatically); anything else works too via "Other/custom" — check that +app's own OIDC/SSO docs for its redirect URI path first. + +First time this runs it also enables Authelia's OIDC provider itself +(generates a signing key + HMAC secret, one-time, automatic). Each +registered app gets its own Client ID/Secret under +\`identity_providers.oidc.clients\` in \`config/configuration.yml\` — the +secret is shown once at registration time and only the hash is kept. + +Endpoints (needed if an app asks for them instead of a discovery URL): +- Discovery: \`https://auth.${AUTHELIA_DOMAIN}/.well-known/openid-configuration\` +- Authorization: \`https://auth.${AUTHELIA_DOMAIN}/api/oidc/authorization\` +- Token: \`https://auth.${AUTHELIA_DOMAIN}/api/oidc/token\` +- UserInfo: \`https://auth.${AUTHELIA_DOMAIN}/api/oidc/userinfo\` + ## Manage \`\`\` cd $AUTHELIA_DIR @@ -668,5 +698,244 @@ CADDY_AUTH_BLOCK2 echo "" } +# Enables Authelia's OIDC PROVIDER feature — a distinct thing from the +# forward_auth (proxy-auth) setup install_authelia() already does. forward_auth +# gates a whole Caddy site behind an Authelia login page before the request +# ever reaches the app; OIDC provider mode is the opposite direction — an app +# with its OWN "Enable OpenID"/SSO setting (ActualBudget, Vaultwarden, etc.) +# delegates ITS login to Authelia instead of asking a user for a +# service-specific password. Neither replaces the other; a service can use +# either, both, or neither. +# +# One-time, idempotent (checked via the identity_providers: key already being +# present) — every _authelia_add_oidc_client() call runs this first so OIDC +# just works the first time an app is registered, no separate "enable OIDC" +# step to remember. +_authelia_ensure_oidc_provider() { + local AUTHELIA_DIR="$1" + local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml" + local SECRETS_DIR="$AUTHELIA_DIR/config/secrets" + + grep -q '^identity_providers:' "$CONFIG_FILE" 2>/dev/null && return 0 + + log_info "Enabling Authelia's OIDC provider (one-time — lets other apps log in via Authelia)..." + + # hmac_secret: injected via a _FILE env var in docker-compose.yml, same + # convention as jwt/session/storage secrets above — configuration.yml + # itself never holds this one as a raw string. "Random Value: " + # is the exact (and only) line this subcommand prints — confirmed + # against Authelia's own CLI source, not assumed. + local _rand_out + _rand_out="$(docker run --rm authelia/authelia:4.39.20 \ + authelia crypto rand --length 64 --charset alphanumeric 2>/dev/null)" + echo "${_rand_out#Random Value: }" > "$SECRETS_DIR/oidc_hmac_secret" + if [ ! -s "$SECRETS_DIR/oidc_hmac_secret" ]; then + log_warning "Couldn't generate the OIDC HMAC secret — skipping OIDC provider setup. Re-run to try again." + return 1 + fi + chmod 600 "$SECRETS_DIR/oidc_hmac_secret" + + # RSA keypair for signing OIDC tokens (jwks). Authelia's schema requires + # the private key inlined as PEM directly in configuration.yml — no + # file-path or _FILE-env-var option for this specific nested field + # (confirmed against the current identity_providers.oidc.jwks schema) — + # so this generates into config/secrets/ for safe permissions, then reads + # it back in below. "private.pem"/"public.pem" are the CLI's own default + # output filenames (confirmed against Authelia's CLI reference), not + # guessed. + docker run --rm -u "$(id -u):$(id -g)" -v "$SECRETS_DIR":/keys \ + authelia/authelia:4.39.20 authelia crypto pair rsa generate --directory /keys >/dev/null 2>&1 + if [ ! -f "$SECRETS_DIR/private.pem" ]; then + log_warning "Couldn't generate the OIDC signing key — skipping OIDC provider setup. Re-run to try again." + return 1 + fi + chmod 600 "$SECRETS_DIR/private.pem" "$SECRETS_DIR/public.pem" 2>/dev/null + + { + echo "" + echo "identity_providers:" + echo " oidc:" + echo " jwks:" + echo " - key_id: 'main'" + echo " algorithm: 'RS256'" + echo " use: 'sig'" + echo " key: |" + sed 's/^/ /' "$SECRETS_DIR/private.pem" + echo " clients: []" + } >> "$CONFIG_FILE" + + if ! grep -q 'AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE' "$AUTHELIA_DIR/docker-compose.yml"; then + sed -i '/AUTHELIA_NOTIFIER_SMTP_SENDER/a\ - AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE=/config/secrets/oidc_hmac_secret' \ + "$AUTHELIA_DIR/docker-compose.yml" + fi + + chown -R 1000:1000 "$AUTHELIA_DIR/config" + chmod 600 "$CONFIG_FILE" + log_success "OIDC provider enabled (signing key + HMAC secret generated)" +} + +# Registers an OIDC client for another app to log in via Authelia — the +# "Other" provider option in an app's own "Enable OpenID"/SSO dialog. Presets +# below hand back the app's own known redirect URI path and the exact fields +# to paste where; "Other/custom" covers anything not listed (the app's own +# OIDC/SSO docs will say what redirect URI it expects). +_authelia_add_oidc_client() { + local AUTHELIA_DIR="$DOCKER_DIR/authelia" + local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml" + + if [ ! -f "$CONFIG_FILE" ]; then + log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first." + return 1 + fi + + _authelia_ensure_oidc_provider "$AUTHELIA_DIR" || return 1 + + # The apex domain this Authelia instance already serves — read back from + # its own session.cookies (same structure install_authelia()/ + # add_authelia_domain() write), rather than asking again or assuming a + # variable set earlier in this run is still in scope (this flow can be + # reached standalone from the "already exists" menu with none of + # install_authelia()'s own locals ever having run this session). + local AUTHELIA_DOMAIN + AUTHELIA_DOMAIN="$(awk '/^ cookies:$/{f=1; next} f && /domain:/{print $3; exit}' "$CONFIG_FILE")" + if [ -z "$AUTHELIA_DOMAIN" ]; then + log_warning "Couldn't determine this Authelia instance's domain from $CONFIG_FILE — aborting." + return 1 + fi + + echo "" + echo " Register another app to log in via Authelia (OIDC/SSO)." + echo "" + echo " 1) ActualBudget" + echo " 2) Vaultwarden" + echo " 3) Other / custom app" + echo "" + local APP_CHOICE="" + prompt_text " Choice [1/2/3]:" "3" APP_CHOICE + + local APP_NAME="" CLIENT_ID="" REDIRECT_PATH="" + case "$APP_CHOICE" in + 1) APP_NAME="ActualBudget"; CLIENT_ID="actualbudget"; REDIRECT_PATH="/openid/callback" ;; + 2) APP_NAME="Vaultwarden"; CLIENT_ID="vaultwarden"; REDIRECT_PATH="/identity/connect/oidc-signin" ;; + *) + prompt_text " App name (for your reference):" "" APP_NAME + [ -z "$APP_NAME" ] && { log_warning "No app name entered — nothing to do."; return 0; } + CLIENT_ID="$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//;s/-*$//')" + prompt_text " Client ID [${CLIENT_ID}]:" "$CLIENT_ID" CLIENT_ID + echo " Check ${APP_NAME}'s own OIDC/SSO docs for its exact redirect URI path" + echo " (often something like /oauth/callback, /auth/callback, /sso/callback)." + prompt_text " Redirect URI path (starting with /):" "" REDIRECT_PATH + ;; + esac + if [ -z "$CLIENT_ID" ] || [ -z "$REDIRECT_PATH" ]; then + log_warning "Missing client ID or redirect path — nothing to do." + return 0 + fi + + if grep -qF "client_id: '${CLIENT_ID}'" "$CONFIG_FILE" 2>/dev/null; then + log_warning "A client with ID '$CLIENT_ID' is already registered in $CONFIG_FILE." + log_warning "Pick a different app, or edit that entry by hand." + return 0 + fi + + local APP_DOMAIN_DEFAULT="" APP_DOMAIN="" + [ -n "${SITE_DOMAIN:-}" ] && [ "$SITE_DOMAIN" != "example.com" ] && APP_DOMAIN_DEFAULT="${CLIENT_ID}.${SITE_DOMAIN}" + prompt_text " Domain ${APP_NAME} is reachable at [${APP_DOMAIN_DEFAULT:-required}]:" "$APP_DOMAIN_DEFAULT" APP_DOMAIN + if [ -z "$APP_DOMAIN" ]; then + log_warning "No domain entered — nothing to do." + return 0 + fi + local REDIRECT_URI="https://${APP_DOMAIN}${REDIRECT_PATH}" + + local _2fa="" AUTH_POLICY="two_factor" + prompt_yn " Require two-factor for ${APP_NAME} logins too? (y/n):" "y" _2fa + [[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor" + + log_info "Generating client secret..." + local _hash_out CLIENT_SECRET_PLAIN CLIENT_SECRET_HASH + _hash_out="$(docker run --rm authelia/authelia:4.39.20 \ + authelia crypto hash generate pbkdf2 --variant sha512 --random \ + --random.length 72 --random.charset rfc3986 2>/dev/null)" + CLIENT_SECRET_PLAIN="$(echo "$_hash_out" | sed -n 's/^Random Password: //p')" + CLIENT_SECRET_HASH="$(echo "$_hash_out" | sed -n 's/^Digest: //p')" + if [ -z "$CLIENT_SECRET_PLAIN" ] || [ -z "$CLIENT_SECRET_HASH" ]; then + log_warning "Couldn't generate the client secret automatically. Run manually, then add the" + log_warning "client to $CONFIG_FILE's identity_providers.oidc.clients by hand:" + echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72 --random.charset rfc3986" + return 1 + fi + + grep -q '^ clients: \[\]$' "$CONFIG_FILE" && sed -i 's/^ clients: \[\]$/ clients:/' "$CONFIG_FILE" + + local CLIENT_BLOCK=" - client_id: '${CLIENT_ID}' + client_name: '${APP_NAME}' + client_secret: '${CLIENT_SECRET_HASH}' + public: false + authorization_policy: '${AUTH_POLICY}' + redirect_uris: + - '${REDIRECT_URI}' + scopes: + - 'openid' + - 'profile' + - 'email' + grant_types: + - 'authorization_code' + response_types: + - 'code' + response_modes: + - 'query' + userinfo_signed_response_alg: 'none'" + + awk -v block="$CLIENT_BLOCK" ' + { print } + /^ clients:$/ && !done { print block; done=1 } + ' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" + chown 1000:1000 "$CONFIG_FILE" 2>/dev/null || true + + local RESTART_AUTH="" + prompt_yn " Restart Authelia to apply? (y/n):" "y" RESTART_AUTH + if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then + (cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \ + && log_success "Authelia restarted" \ + || log_warning "Restart failed — check: docker compose logs authelia" + fi + + echo "" + echo " ${APP_NAME} is registered. Paste these into its OpenID/SSO settings" + echo " (choose \"Other\" as the provider if it's not listed by name):" + echo "" + echo " Client ID: ${CLIENT_ID}" + echo " Client Secret: ${CLIENT_SECRET_PLAIN}" + echo " Discovery URL: https://auth.${AUTHELIA_DOMAIN}/.well-known/openid-configuration" + echo "" + echo " If it asks for individual endpoints instead of a discovery URL:" + echo " Authorization: https://auth.${AUTHELIA_DOMAIN}/api/oidc/authorization" + echo " Token: https://auth.${AUTHELIA_DOMAIN}/api/oidc/token" + echo " UserInfo: https://auth.${AUTHELIA_DOMAIN}/api/oidc/userinfo" + echo " Scopes: openid profile email" + echo "" + case "$APP_CHOICE" in + 1) + echo " ActualBudget's \"Enable OpenID\" dialog → provider \"Other\": paste the" + echo " Discovery URL, Client ID, and Client Secret above." + echo " First OIDC login becomes the ActualBudget server owner." + echo "" + ;; + 2) + echo " Add these to Vaultwarden's .env, then: cd \$VAULTWARDEN_DIR && docker compose up -d" + echo " SSO_ENABLED=true" + echo " SSO_AUTHORITY=https://auth.${AUTHELIA_DOMAIN}" + echo " SSO_CLIENT_ID=${CLIENT_ID}" + echo " SSO_CLIENT_SECRET=${CLIENT_SECRET_PLAIN}" + echo " SSO_SCOPES=profile email" + echo " Enabling SSO changes Vaultwarden's login flow for everyone on this" + echo " instance — see Vaultwarden's own SSO docs before turning this on for" + echo " a vault other people already use." + echo "" + ;; + esac + log_warning "The Client Secret above is shown once — it isn't stored in plaintext anywhere. Save it now." +} + # Run immediately when executed directly (deferred until after function definition) [[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_authelia From 88aac103c9d8fd89e29d52707ef4d79835dc7a0b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 15:08:39 +0000 Subject: [PATCH 4/4] Fix FMD crash-loop: bind-mounted db dir needs UID 1000, not $ACTUAL_USER fmd-server's image runs as a fixed, non-configurable UID:GID 1000:1000 baked into its own Dockerfile (useradd --uid 1000 fmd-server) - nothing like PUID/PGID to override it. The install script chowned the bind-mounted ./data dir to $ACTUAL_USER instead, which only happens to work when that user's host UID is coincidentally 1000. Confirmed live: the container crash-loops forever on "permission denied" creating its sqlite db otherwise - same root-cause shape as the Mattermost UID/GID bug fixed earlier this session, different fixed UID. Fixed at both points a container start can happen: the fresh-install path (chown -R 1000:1000 "$FMD_DIR/data" right after the existing $ACTUAL_USER chown, ordered after it since that one is recursive over the whole directory and would otherwise overwrite this) and the update path (previously unguarded - re-asserted before every docker compose up so a box already stuck in this state self-heals on next update instead of staying broken forever, same self-heal precedent as the Vaultwarden SMTP fix earlier this session). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn --- services/fmd.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/services/fmd.sh b/services/fmd.sh index 78465e8..a911bdb 100644 --- a/services/fmd.sh +++ b/services/fmd.sh @@ -277,6 +277,17 @@ install_fmd() { case "$MODE" in update) log_info "Refreshing the FindMyDevice image only — token, port, and Caddy config are left as-is." + # fmd-server's image runs as a fixed, non-configurable + # UID:GID 1000:1000 (confirmed against its own + # Dockerfile — not something PUID/PGID or similar can + # override). The bind-mounted ./data dir needs that + # exact numeric ownership regardless of what host user + # actually owns it; re-assert it on every update too, + # not just at install time, so a box whose data/ was + # ever chowned to something else (e.g. $ACTUAL_USER + # not being UID 1000) self-heals instead of staying + # stuck crash-looping on "permission denied" forever. + [ -d "$FMD_DIR/data" ] && chown -R 1000:1000 "$FMD_DIR/data" ( cd "$FMD_DIR" && docker compose pull && docker compose up -d ) \ && log_success "FindMyDevice image refreshed" \ || log_warning "Refresh failed — check: docker compose -f $FMD_DIR/docker-compose.yml logs" @@ -353,6 +364,16 @@ FMD_ENV mkdir -p data chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR" + # fmd-server's image runs as a fixed, non-configurable UID:GID 1000:1000 + # (confirmed against its own Dockerfile: `useradd --uid 1000 fmd-server`, + # baked in, not something an env var can override) — the bind mount at + # ./data:/var/lib/fmd-server/db needs that exact numeric ownership on the + # host side regardless of $ACTUAL_USER's actual UID, or the container + # crash-loops on "permission denied" trying to create its sqlite db. + # Confirmed live. Must run AFTER the chown above, not before, since that + # one is recursive over the whole directory and would otherwise + # overwrite this. + chown -R 1000:1000 "$FMD_DIR/data" log_success "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $FMD_DIR (port $WEB_PORT)" configure_caddy_for_service "FindMyDevice${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${CONTAINER}:8080" "fmd${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"