#!/bin/bash # Configuration validation. # --------------------------------------------------------------------------- # Every check here exists because the bug it catches shipped at least once, and # none of them announced themselves at runtime — a wrong config key does not # crash anything, it just quietly stops working: # # * two keys sharing one RANDOMIZED placeholder produced the SAME secret # for Gitea's metrics token and its admin password # * an auth adapter writing CFG__ADMIN_PASSWORD while the config declared # ADMIN_PASSWORD_1 made every password reset a silent no-op # * a compose annotation whose value did not match the line body meant the tag # never substituted, and Mastodon shipped its placeholder as a live password # # So the rule is: fail loudly here, in a command someone can run, rather than # discover it months later. # # Checks run against the deployed install where it exists and fall back to the # install templates, because both can be wrong in different ways: a template # carries placeholders, a deployed file carries the substituted values. # Collected during a run. _lpv_issues=0 _lpv_checked=0 _lpvFail() { isError " $*"; ((_lpv_issues++)); } _lpvWarn() { isNotice " $*"; } # Resolve an app's config / compose, preferring the deployed copy. _lpvAppFiles() { local app="$1" _lpv_cfg_live="${containers_dir}${app}/${app}.config" _lpv_cfg_tmpl="${install_containers_dir}${app}/${app}.config" _lpv_comp_live="${containers_dir}${app}/docker-compose.yml" _lpv_comp_tmpl="${install_containers_dir}${app}/docker-compose.yml" [[ -f "$_lpv_cfg_live" ]] || _lpv_cfg_live="" [[ -f "$_lpv_cfg_tmpl" ]] || _lpv_cfg_tmpl="" [[ -f "$_lpv_comp_live" ]] || _lpv_comp_live="" [[ -f "$_lpv_comp_tmpl" ]] || _lpv_comp_tmpl="" } # --- config file: syntax, duplicates, prefix ------------------------------- _lpvCheckConfigFile() { local app="$1" file="$2" label="$3" [[ -n "$file" ]] || return 0 local up="${app^^}"; up="${up//-/_}" bash -n "$file" 2>/dev/null || _lpvFail "$app: $label does not parse as shell." local dup dup=$(grep -oE '^CFG_[A-Z0-9_]+=' "$file" | sort | uniq -d | tr -d '=') [[ -n "$dup" ]] && while IFS= read -r k; do _lpvFail "$app: $k is defined more than once in $label." done <<< "$dup" local wrong wrong=$(grep -oE '^CFG_[A-Z0-9_]+=' "$file" | tr -d '=' | grep -v "^CFG_${up}_" || true) [[ -n "$wrong" ]] && while IFS= read -r k; do [[ -n "$k" ]] && _lpvFail "$app: $k in $label does not use this app's CFG_${up}_ prefix." done <<< "$wrong" } # --- template config: placeholder hygiene ---------------------------------- # A generated value must carry a slot number, and no two keys may share one # placeholder — the replacer mints one value per DISTINCT placeholder and # substitutes every occurrence, so sharing means sharing the secret. _lpvCheckPlaceholders() { local app="$1" file="$2" [[ -n "$file" ]] || return 0 local k while IFS= read -r k; do [[ -n "$k" ]] || continue [[ "$k" =~ _[0-9]+$ ]] || \ _lpvFail "$app: $k holds a generated value but has no slot number (expected ${k}_1)." done < <(grep -oE '^CFG_[A-Z0-9_]+=RANDOMIZED' "$file" | sed 's/=RANDOMIZED//') local ph while IFS= read -r ph; do [[ -n "$ph" ]] || continue local keys keys=$(grep -E "^CFG_[A-Z0-9_]+=${ph}$" "$file" | cut -d= -f1 | tr '\n' ' ') _lpvFail "$app: $ph is shared by ${keys}— they would all receive the same secret." done < <(grep -oE '=RANDOMIZED[A-Z]*[0-9]+$' "$file" | tr -d '=' | sort | uniq -d) } # --- deployed config: nothing left unfilled, no accidental twins ------------ _lpvCheckDeployedSecrets() { local app="$1" file="$2" [[ -n "$file" ]] || return 0 local k while IFS= read -r k; do [[ -n "$k" ]] && _lpvFail "$app: $k still holds its RANDOMIZED placeholder — generation never ran." done < <(grep -oE '^CFG_[A-Z0-9_]+="?RANDOMIZED' "$file" | sed 's/="\?RANDOMIZED//') # Two different keys holding one value is what a shared placeholder looks # like after substitution. Short values are skipped: "true", "admin" and # friends repeat legitimately. # The value arrives here unquoted (the sed/tr below strip them) while the # file stores it quoted, so the key lookup has to put the quotes back — # grepping for = against ="" matches nothing and the message # names no keys, which is worse than useless in a failure report. local v while IFS= read -r v; do [[ ${#v} -ge 12 ]] || continue local keys keys=$(grep -F "=\"$v\"" "$file" | cut -d= -f1 | tr '\n' ' ') _lpvFail "$app: ${keys}share one value — a generated secret should never be reused." done < <(grep -oE '^CFG_[A-Z0-9_]+="[^"]{12,}"$' "$file" \ | sed 's/^[^=]*=//' | tr -d '"' | sort | uniq -d) } # --- deployed: does the container actually run what the config advertises? -- # Only meaningful on a live install, where both sides hold real values: in the # templates one side is a placeholder and the other a RANDOMIZED token, so they # would never match. # # This is the symptom that matters. Speedtest's config said one password while # its container ran another, so the WebUI credentials card showed a login that # could not work — and nothing detected it, because the tag-name check only # caught it by accident (the rename left a stale tag behind). Had the rename kept # the name, the divergence would have been invisible. # # Slot resolution: a deployed compose written before a key gained its _ slot # still carries the old tag, so fall back to the numbered variant rather than # giving up — but say so, because that compose is due a re-template. _lpvCheckDeployedValues() { local app="$1" cfg="$2" comp="$3" [[ -n "$cfg" && -n "$comp" ]] || return 0 local up="${app^^}"; up="${up//-/_}" local line tag val key slot cfgval while IFS='|' read -r tag val; do [[ -n "$tag" && -n "$val" ]] || continue key="CFG_${tag%_TAG}" cfgval=$(grep -m1 "^${key}=" "$cfg" 2>/dev/null | cut -d= -f2-) if [[ -z "$cfgval" ]]; then # No exact key — try the slot the rename introduced. slot=$(grep -m1 -oE "^CFG_${tag%_TAG}_[0-9]+=" "$cfg" 2>/dev/null | tr -d '=') [[ -n "$slot" ]] || continue _lpvWarn "$app: compose still uses ${tag}, but the config declares ${slot} — re-template ('libreportal app install $app') so the name matches." key="$slot" cfgval=$(grep -m1 "^${key}=" "$cfg" 2>/dev/null | cut -d= -f2-) fi cfgval="${cfgval%%#*}" cfgval="${cfgval//\"/}" cfgval="${cfgval//[[:space:]]/}" # Not generated yet, or the compose still holds its placeholder: other # checks own those cases. [[ -z "$cfgval" || "$cfgval" == RANDOMIZED* ]] && continue [[ "$val" =~ ^[A-Z][A-Z0-9_]*_DATA(_[0-9]+)?$ ]] && continue if [[ "$cfgval" != "$val" ]]; then _lpvFail "$app: $key in the config does not match what the container runs (tag $tag) — anything showing this value, the WebUI card included, is advertising something that will not work." fi done < <(grep -oE "#LIBREPORTAL\|${up}_[A-Z0-9_]+_TAG\|[^|[:space:]]+" "$comp" 2>/dev/null \ | sed 's/#LIBREPORTAL|//' | sed 's/_TAG|/_TAG|/' | awk -F'|' '!seen[$1]++ {print $1"|"$2}') } # --- compose: annotations substitutable, tags backed ----------------------- _lpvCheckCompose() { local app="$1" file="$2" label="$3" cfg="$4" [[ -n "$file" ]] || return 0 local up="${app^^}"; up="${up//-/_}" # The annotation value is the literal the tag manager searches for on that # line. If it does not appear in the line body, nothing can ever substitute. # index() rather than split(): awk reads split's separator as a regex, and # "#LIBREPORTAL|" ends in an alternation operator with nothing after it. local bad bad=$(awk ' { marker = "#LIBREPORTAL|" first = index($0, marker) if (first == 0) next body = substr($0, 1, first - 1) rest = $0 while ((p = index(rest, marker)) > 0) { rest = substr(rest, p + length(marker)) q = index(rest, "|") if (q == 0) break tag = substr(rest, 1, q - 1) val = substr(rest, q + 1) sub(/[ \t\r].*$/, "", val) h = index(val, "#") if (h > 0) val = substr(val, 1, h - 1) if (val != "" && index(body, val) == 0) printf "line %d: %s (value %s)\n", NR, tag, val } }' "$file") [[ -n "$bad" ]] && while IFS= read -r l; do _lpvFail "$app: $label $l never substitutes — the value is not in the line body." done <<< "$bad" # Every app-prefixed tag needs a filler: a CFG key of the same name, or a # script that writes the tag directly (version bumps, per-app hooks). # *_VERSION_TAG is exempt: the updater builds both the CFG name and the tag # name from the app slug at runtime (CFG_${up}_VERSION), so neither the key # nor the literal tag appears anywhere to be found. local tag key while IFS= read -r tag; do [[ -n "$tag" ]] || continue [[ "$tag" == *_VERSION_TAG ]] && continue key="CFG_${tag%_TAG}" [[ -n "$cfg" ]] && grep -q "^${key}=" "$cfg" 2>/dev/null && continue grep -q "$tag" <<< "$_lpv_src" && continue _lpvFail "$app: $label tag $tag has no ${key} and no script that fills it." done < <(grep -oE "#LIBREPORTAL\|${up}_[A-Z0-9_]+_TAG\|" "$file" | tr -d '#|' | sed 's/LIBREPORTAL//' | sort -u) } # --- auth adapters: the key they write must exist -------------------------- # authPersistCfg resolves a bare name to its numbered slot, but if neither form # is declared the write is a silent no-op and the WebUI keeps showing a password # that no longer works. _lpvCheckAuthAdapter() { local app="$1" cfg="$2" local adapter="${install_containers_dir}${app}/scripts/${app}_auth.sh" [[ -f "$adapter" && -n "$cfg" ]] || return 0 local up="${up_override:-${app^^}}"; up="${up//-/_}" local key while IFS= read -r key; do [[ -n "$key" ]] || continue grep -qE "^CFG_${up}_${key}=" "$cfg" && continue grep -qE "^CFG_${up}_${key}_[0-9]+=" "$cfg" && continue _lpvFail "$app: its auth adapter persists ${key}, but neither CFG_${up}_${key} nor a numbered slot is declared." done < <(grep -oE "authPersistCfg ${app} [A-Z_]+" "$adapter" | awk '{print $3}' | sort -u) } # validateAppConfiguration — every check for one app. validateAppConfiguration() { local app="$1" [[ -n "$app" ]] || { isError "No app name given."; return 1; } _lpvAppFiles "$app" if [[ -z "$_lpv_cfg_live" && -z "$_lpv_cfg_tmpl" ]]; then isError "No config found for '$app'." return 1 fi # Called on its own rather than from the all-apps loop: own the counters and # build the source index here. Without the index every tag filled by a hook # instead of a CFG key reads as unbacked — `validation app matrix` reported # RUN_UID/RUN_GID as failures that `validation all` correctly did not. local standalone="" [[ -z "$_lpv_in_all" ]] && { standalone=1; _lpv_issues=0; _lpv_checked=0; } _lpvLoadSources ((_lpv_checked++)) local before=$_lpv_issues _lpvCheckConfigFile "$app" "${_lpv_cfg_live:-$_lpv_cfg_tmpl}" "$( [[ -n "$_lpv_cfg_live" ]] && echo "deployed config" || echo "config template" )" _lpvCheckPlaceholders "$app" "$_lpv_cfg_tmpl" _lpvCheckDeployedSecrets "$app" "$_lpv_cfg_live" _lpvCheckCompose "$app" "${_lpv_comp_live:-$_lpv_comp_tmpl}" \ "$( [[ -n "$_lpv_comp_live" ]] && echo "deployed compose" || echo "compose template" )" \ "${_lpv_cfg_live:-$_lpv_cfg_tmpl}" _lpvCheckDeployedValues "$app" "$_lpv_cfg_live" "$_lpv_comp_live" _lpvCheckAuthAdapter "$app" "${_lpv_cfg_live:-$_lpv_cfg_tmpl}" # A single-app run that says nothing is indistinguishable from one that did # not run, so report either way. The all-apps loop keeps its own summary. if [[ -n "$standalone" ]]; then if [[ $_lpv_issues -eq $before ]]; then isSuccessful "$app: no configuration problems found." else isError "$app: $((_lpv_issues - before)) configuration problem(s)." return 1 fi fi return 0 } # Load every .sh once so the "is this tag filled by a script?" test is a string # search rather than a find+grep per tag. _lpvLoadSources() { [[ -n "$_lpv_src" ]] && return 0 _lpv_src=$(cat \ $(find "${install_scripts_dir%/}" "${install_containers_dir%/}" \ -name '*.sh' ! -name 'function_manifest.sh' -type f 2>/dev/null) 2>/dev/null) } # validateSystemConfiguration — the shared config tree, not any one app. validateSystemConfiguration() { isHeader "Validating system configuration" local before=$_lpv_issues local f while IFS= read -r f; do [[ -f "$f" ]] || continue bash -n "$f" 2>/dev/null || _lpvFail "$(basename "$f") does not parse as shell." local dup dup=$(grep -oE '^CFG_[A-Z0-9_]+=' "$f" | sort | uniq -d | tr -d '=') [[ -n "$dup" ]] && while IFS= read -r k; do [[ -n "$k" ]] && _lpvFail "$k is defined more than once in $(basename "$f")." done <<< "$dup" done < <(find "${configs_dir%/}" -maxdepth 2 -type f ! -name '*.category' ! -name '.*' ! -name '*.bak' 2>/dev/null) if [[ $_lpv_issues -eq $before ]]; then isSuccessful "System configuration is consistent." fi return 0 } # validateAllConfigurations — every app plus the system tree. validateAllConfigurations() { _lpv_issues=0; _lpv_checked=0 _lpvLoadSources local _lpv_in_all=1 isHeader "Validating application configuration" local d app for d in "${install_containers_dir%/}"/*/; do app="$(basename "$d")" [[ -f "$d$app.config" ]] || continue validateAppConfiguration "$app" done validateSystemConfiguration echo "" if [[ $_lpv_issues -eq 0 ]]; then isSuccessful "$_lpv_checked apps validated — no configuration problems found." return 0 fi isError "$_lpv_issues configuration problem(s) across $_lpv_checked apps." isNotice "These do not surface at runtime: a mis-declared key silently stops working." return 1 } # tagsValidateShowValidationStatus — the summary `libreportal validation status` # prints. Same checks, counts only. tagsValidateShowValidationStatus() { validateAllConfigurations >/dev/null 2>&1 local rc=$? isHeader "Configuration validation status" echo " Apps checked : $_lpv_checked" echo " Problems : $_lpv_issues" echo "" if [[ $_lpv_issues -eq 0 ]]; then isSuccessful "No configuration problems found." else isNotice "Run 'libreportal validation all' for the detail." fi return $rc }