diff --git a/Deployment/conf_populate.sh b/Deployment/conf_populate.sh index d27d13e..3e2bf77 100755 --- a/Deployment/conf_populate.sh +++ b/Deployment/conf_populate.sh @@ -55,6 +55,97 @@ # GITEA_CONTAINER fuzzy match from docker ps # # ============================================================================================== +# OPERATIONAL MODEL +# ============================================================================================== +# +# 1. Resolve this host's conf from MY_ID +# 2. Per service, locate its container and read its own config file: +# arrs → config.xml via the container's /config volume mount +# SABnzbd → sabnzbd.ini +# slskd → config.yml +# qBit → qBittorrent.conf (plaintext WebUI credentials only) +# Emby/JF → docker port bindings and /transcode mount +# 3. Write each value only if the conf field is EMPTY, unless --overwrite +# 4. Push the updated conf to partners via conf_sync.sh, unless --no-push +# +# Container names are resolved by _resolve_container(): an exact name match wins, otherwise +# a prefix match must be unambiguous or the field is skipped. +# +# ============================================================================================== +# DESIGN PRINCIPLES +# ============================================================================================== +# +# Never Overwrite a Human's Value +# Only empty fields are populated. A value already in the conf was either set deliberately +# or populated from a service that has since changed — either way, the file wins over +# detection. --overwrite exists for deliberate re-sync after a key rotation. +# +# Read From the Service, Not From Assumption +# Every value comes out of the service's own config file or docker metadata — ports from +# actual port bindings, paths from actual volume mounts. Nothing is derived from naming +# convention where the real value is readable. +# +# Refuse to Guess a Container +# An ambiguous prefix skips the field rather than picking one. Writing the wrong container +# name is worse than writing nothing: an empty field is visibly incomplete and gets fixed, +# while a wrong one silently points the whole stack at the wrong instance. This host has a +# live example — "authelia" prefix-matches both Authelia and Authelia-Secondary. +# +# Push Immediately After Populating +# Fresh credentials go to partners right away rather than waiting for the next scheduled +# conf sync, so a partner is never authenticating with a key this host has already rotated. +# +# ============================================================================================== +# OPERATIONAL SAFEGUARDS +# ============================================================================================== +# +# Root Enforcement +# Reads service config files owned by container users and writes the host conf. +# +# Host Detection +# detect_hosts() resolves MY_ID, which selects which host conf is written. Populating the +# wrong host's conf would write this machine's credentials into a partner's file. +# +# Conf Existence Guard +# Aborts if the resolved host conf does not exist, rather than creating a partial one. +# +# Empty-Field-Only Writes +# Existing values are preserved unless --overwrite is passed explicitly. +# +# Container Ambiguity Guard +# _resolve_container() refuses a prefix matching more than one container, warning with the +# full match list. Exact name matches short-circuit and are never treated as ambiguous. +# +# Missing Service Tolerance +# A service that is not installed on this host is skipped with a log line. Absence is a +# valid configuration, not a failure. +# +# Map Block Validation +# Associative-array entries are only inserted when the target map actually exists in the +# conf; a missing map warns and skips instead of appending an orphaned entry. +# +# Dry Run Support +# --dry-run reports every value it would write, truncated, and writes nothing. +# +# Credential Truncation in Output +# Detected secrets are printed truncated (first 8 chars) so a populate run can be pasted +# into a log or issue without leaking full API keys. +# +# ============================================================================================== +# CONFIGURATION +# ============================================================================================== +# +# Reads and writes: Configurations/.conf and Configurations/master.conf +# +# master.conf +# +# DOCKER_APPDATA_BASE +# Fallback appdata root when a container exposes no /config mount to read from. +# +# Everything else this script touches is a field it populates rather than one it consumes — +# see AUTO-DETECTED FIELDS above for the full list. +# +# ============================================================================================== # RUNTIME MODES # ============================================================================================== # @@ -174,11 +265,41 @@ _set_conf_map_entry() { (( UPDATED++ )) } +# ── Helper: resolve a container name from a prefix, refusing ambiguity ──────── +# grep -m1 silently returns whichever name docker happens to list first. On this very host +# "^authelia" matches both Authelia (9091, primary) and Authelia-Secondary (9092), and -m1 +# picks the secondary — writing the wrong container into the conf that every other script +# then trusts. An exact match wins outright; otherwise a prefix match must be unambiguous. +# Same rule detect_hosts() applies to host identity: exactly one candidate, or none. +_resolve_container() { + local pattern="$1" + local all exact matches count + + all=$(docker ps -a --format '{{.Names}}' 2>/dev/null) + [[ -z "$all" ]] && return 1 + + # Exact name match short-circuits — "Authelia" is not ambiguous with "Authelia-Secondary" + exact=$(printf '%s\n' "$all" | grep -ixm1 -- "$pattern") + [[ -n "$exact" ]] && { echo "$exact"; return 0; } + + matches=$(printf '%s\n' "$all" | grep -i -- "^${pattern}") + count=$(printf '%s\n' "$matches" | grep -c .) + + if [[ "$count" -gt 1 ]]; then + warn "Container prefix '${pattern}' is ambiguous — matches: $(printf '%s' "$matches" | tr '\n' ' ')" + warn " Refusing to guess. Set the container name manually in host*.conf." + return 1 + fi + + [[ "$count" -eq 1 ]] && { printf '%s\n' "$matches"; return 0; } + return 1 +} + # ── Helper: find arr config dir via docker volume mount ─────────────────────── _arr_config_dir() { local pattern="$1" local container_name - container_name=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${pattern}") + container_name=$(_resolve_container "$pattern") || return 1 [[ -z "$container_name" ]] && return 1 local config_path @@ -239,7 +360,7 @@ _set_conf_var "${MY_ID}_SSH_KEY" "/root/.ssh/${owner}_rsync_automation" " for arr in radarr sonarr lidarr; do arr_upper="${arr^^}" - arr_container=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${arr}") + arr_container=$(_resolve_container "$arr") config_dir=$(_arr_config_dir "$arr") || { log "${arr_upper}: no running container found — skipping" continue @@ -341,7 +462,7 @@ qbit_dir=$(_arr_config_dir "qbittorrent") && { transcode_dir="" for pattern in "emby" "jellyfin"; do - container=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -im1 "^${pattern}") + container=$(_resolve_container "$pattern") || continue [[ -z "$container" ]] && continue case "$pattern" in