The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.
Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.
Three places needed judgement rather than substitution:
db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.
instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.
peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.
Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.
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="$(appDir "$app")/${app}.config"
|
|
_lpv_cfg_tmpl="${install_containers_dir}${app}/${app}.config"
|
|
_lpv_comp_live="$(appDir "$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
|
|
}
|