Speedtest's config held one password while its container ran another, so the WebUI credentials card advertised a login that could not work. Validation only caught it by accident: the rename left a stale tag behind, and the tag-name check fired on that. Had the rename kept the name, the divergence would have been invisible — and it is the divergence, not the tag, that actually breaks someone's login. So compare them directly: for every app-prefixed tag in the DEPLOYED compose, check the substituted value against the deployed config's. Live files only — in the templates one side is a placeholder and the other a RANDOMIZED token, so they could never agree. Resolves the slot rather than giving up: a compose written before a key gained its _<n> suffix still carries the old tag, so fall back to the numbered variant and compare anyway. That is warned about, not passed over — the compose is due a re-template — but the warning is separate from the failure, so a stale name with matching values reports only the warning. Verified both ways against a fixture of speedtest's real pre-fix state (warning plus failure), the same fixture with values agreed (warning only), and the live install across 39 apps (silent). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
360 lines
15 KiB
Bash
360 lines
15 KiB
Bash
#!/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<n> placeholder produced the SAME secret
|
|
# for Gitea's metrics token and its admin password
|
|
# * an auth adapter writing CFG_<APP>_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 =<value> against ="<value>" 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 _<n> 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 <app> — 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
|
|
}
|