diff --git a/Deployment/conf_upgrade.sh b/Deployment/conf_upgrade.sh index c87685e..f8edde5 100755 --- a/Deployment/conf_upgrade.sh +++ b/Deployment/conf_upgrade.sh @@ -69,15 +69,30 @@ # OPERATIONAL SAFEGUARDS # ============================================================================================== # -# Unsubstituted HOSTN Placeholders Are Refused +# The Host Slot Must Be Declared, Never Inferred # host.conf.template ships HOSTN_ placeholders; a live host conf uses HOST1_ / HOST2_. Key # matching is literal, so merging the raw template against a real host conf classifies every # existing key as deprecated — KEPT 0 — and the install drops every credential in the file. # Verified on HOST1 2026-08-02: it would have removed HOST1_RADARR_API_KEY, -# HOST1_EMBY_API_KEY, HOST1_NPM_PASS and 137 others. The template is checked for HOSTN and -# the target for a HOST slot; if both are present the run aborts and prints the exact sed -# command to substitute. Refused rather than auto-substituted — this rewrites the file holding -# every secret on the host, and inferring the slot is not worth being wrong about. +# HOST1_EMBY_API_KEY, HOST1_NPM_PASS and 137 others. +# +# --host-slot HOST is how a caller says which slot the template is being resolved for, and +# it works for any slot — HOST1, HOST2, and whatever a third server would be. The slot is +# still never inferred from the target and applied silently: the caller declares it, and the +# target is read only to contradict a wrong answer. A declared slot that disagrees with the +# target's own keys aborts, because substituting for the wrong slot destroys the file just as +# thoroughly as not substituting at all. Without the flag, a template containing HOSTN is +# refused exactly as before. +# +# Both cases are substituted. HOSTN_ covers the key prefixes, bare HOSTN appears in section +# comments, and lowercase hostn is a real value — the hostn-appdata rsync profile keys. A +# substitution handling only HOSTN_ leaves a conf carrying a profile named hostn-appdata that +# nothing references. +# +# A Target Owning Two Slots Is Refused +# A host conf describes exactly one server. Finding both HOST1_ and HOST2_ key definitions in +# one target means it is not the file it claims to be, so the slot cross-check has nothing +# trustworthy to compare against and the run aborts rather than picking one. # # Total Mismatch Is Refused # Keeping nothing from a populated conf is never a real upgrade; it means the two files do @@ -120,10 +135,13 @@ # # No conf vars. All inputs are CLI flags. # -# --template New version conf file (source of structure and defaults) -# --target Existing user conf (source of real values — always preserved) -# --dry-run Show what would change without writing -# --backup Write a .bak copy of target before modifying +# --template New version conf file (source of structure and defaults) +# --target Existing user conf (source of real values — always preserved) +# --host-slot Resolve HOSTN/hostn placeholders to this slot before merging. +# Required for host.conf.template; meaningless for master.conf.template, +# which has no placeholders. Must match the target's own slot. +# --dry-run Show what would change without writing +# --backup Write a .bak copy of target before modifying # # ============================================================================================== # RUNTIME MODES @@ -138,6 +156,11 @@ # conf_upgrade.sh --template Deployment/master.conf.template --target Configurations/master.conf # Apply the upgrade in-place with no backup. # +# conf_upgrade.sh --template Deployment/host.conf.template --target Configurations/host1.conf \ +# --host-slot HOST1 --dry-run +# Preview a host conf upgrade. HOSTN/hostn are resolved to HOST1/host1 first. Swap in HOST2 +# and host2.conf for the other server — the template is the same file for every slot. +# # ============================================================================================== set -uo pipefail @@ -148,13 +171,16 @@ TEMPLATE="" TARGET="" DRY_RUN=false BACKUP=false +HOST_SLOT="" +_RESOLVED_TMPL="" # set only when --host-slot triggers a substitution; cleaned on exit while [[ $# -gt 0 ]]; do case "$1" in - --template) TEMPLATE="$2"; shift 2 ;; - --target) TARGET="$2"; shift 2 ;; - --dry-run) DRY_RUN=true; shift ;; - --backup) BACKUP=true; shift ;; + --template) TEMPLATE="$2"; shift 2 ;; + --target) TARGET="$2"; shift 2 ;; + --host-slot) HOST_SLOT="$2"; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + --backup) BACKUP=true; shift ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac done @@ -164,25 +190,60 @@ done [[ -f "$TEMPLATE" ]] || { echo "Error: template not found: $TEMPLATE" >&2; exit 1; } [[ -f "$TARGET" ]] || { echo "Error: target not found: $TARGET" >&2; exit 1; } -# ── Guard: unsubstituted HOSTN placeholders ────────────────────────────────────────────────── +# ── Host slot resolution ───────────────────────────────────────────────────────────────────── # # host.conf.template ships HOSTN_ placeholders; a live host conf uses HOST1_ / HOST2_. Key # matching below is literal, so merging the raw template against a real host conf classifies # EVERY existing key as deprecated and every template key as new — KEPT 0, and the install -# would drop every credential in the file. api/setup.php substitutes before writing; a direct -# invocation has no such step. Refused rather than auto-substituted: this rewrites the file -# that holds every secret on the host, and guessing the slot is not worth being wrong about. -if grep -q 'HOSTN' "$TEMPLATE" 2>/dev/null; then - _slot=$(grep -oEm1 '^[[:space:]]*(HOST[0-9]+)_' "$TARGET" 2>/dev/null | grep -oE 'HOST[0-9]+') - if [[ -n "$_slot" ]]; then - _lower=$(echo "$_slot" | tr '[:upper:]' '[:lower:]') - echo "Error: template still contains HOSTN placeholders, but the target uses ${_slot}_." >&2 - echo " Merging as-is would classify all ${_slot}_ keys as deprecated and remove" >&2 - echo " them — including every credential. Substitute the slot first:" >&2 +# would drop every credential in the file. +# +# --host-slot is how a caller declares which slot the template is for. The slot is never +# inferred from the target and silently applied: the caller states it, and the target is used +# only to contradict a wrong answer. Substituting for the wrong slot is the same catastrophe as +# not substituting at all, so a declared slot that disagrees with the target is refused. +# +# Both cases matter. HOSTN_ covers the 159 key prefixes; bare HOSTN appears in section comments, +# and lowercase hostn is a real value — the hostn-appdata rsync profile keys. A substitution +# that only handles HOSTN_ leaves a live conf with a profile named hostn-appdata that nothing +# references, which is what the pull script did before this flag existed. +_target_slots=$(grep -oE '^[[:space:]]*HOST[0-9]+_' "$TARGET" 2>/dev/null \ + | grep -oE 'HOST[0-9]+' | sort -u) +_target_slot=$(echo "$_target_slots" | head -1) +if [[ $(echo "$_target_slots" | grep -c .) -gt 1 ]]; then + echo "Error: '$TARGET' defines keys for more than one host slot:" >&2 + echo " $(echo "$_target_slots" | tr '\n' ' ')" >&2 + echo " A host conf owns exactly one slot. Refusing rather than picking one." >&2 + exit 1 +fi + +if [[ -n "$HOST_SLOT" ]]; then + if ! [[ "$HOST_SLOT" =~ ^HOST[0-9]+$ ]]; then + echo "Error: --host-slot must be HOST (e.g. HOST1, HOST2) — got '$HOST_SLOT'" >&2 + exit 1 + fi + if [[ -n "$_target_slot" && "$_target_slot" != "$HOST_SLOT" ]]; then + echo "Error: --host-slot says $HOST_SLOT but '$TARGET' defines ${_target_slot}_ keys." >&2 + echo " Substituting for the wrong slot removes every key the target actually has," >&2 + echo " credentials included. Refusing." >&2 + exit 1 + fi + if grep -q -e 'HOSTN' -e 'hostn' "$TEMPLATE" 2>/dev/null; then + _lower=$(echo "$HOST_SLOT" | tr '[:upper:]' '[:lower:]') + _RESOLVED_TMPL="$(mktemp)" + trap '[[ -n "${_RESOLVED_TMPL:-}" ]] && rm -f "$_RESOLVED_TMPL"' EXIT + sed -e "s/HOSTN/${HOST_SLOT}/g" -e "s/hostn/${_lower}/g" "$TEMPLATE" > "$_RESOLVED_TMPL" + TEMPLATE="$_RESOLVED_TMPL" + echo " Resolved HOSTN → ${HOST_SLOT} for this host" + fi +elif grep -q 'HOSTN' "$TEMPLATE" 2>/dev/null; then + # No slot declared and the template is still generic — the original refusal, unchanged. + if [[ -n "$_target_slot" ]]; then + _lower=$(echo "$_target_slot" | tr '[:upper:]' '[:lower:]') + echo "Error: template still contains HOSTN placeholders, but the target uses ${_target_slot}_." >&2 + echo " Merging as-is would classify all ${_target_slot}_ keys as deprecated and remove" >&2 + echo " them — including every credential. Declare the slot:" >&2 echo "" >&2 - echo " sed -e 's/HOSTN/${_slot}/g' -e 's/hostn/${_lower}/g' \\" >&2 - echo " $TEMPLATE > /tmp/${_lower}.conf.template" >&2 - echo " $0 --template /tmp/${_lower}.conf.template --target $TARGET --dry-run" >&2 + echo " $0 --template $TEMPLATE --target $TARGET --host-slot ${_target_slot} --dry-run" >&2 echo "" >&2 exit 1 fi @@ -355,7 +416,7 @@ fi # Unraid /tmp is rootfs while the confs live on flash — a cross-device mv silently degrades # to copy-then-unlink, which is exactly the torn write this is meant to prevent. TMPOUT="$(mktemp "${TARGET}.XXXXXX")" -trap 'rm -f "$TMPOUT"' EXIT +trap 'rm -f "$TMPOUT"; [[ -n "${_RESOLVED_TMPL:-}" ]] && rm -f "$_RESOLVED_TMPL"' EXIT # mktemp creates 0600; carry the target's existing mode/owner across so the installed conf # does not come back with different permissions than it went in with. @@ -374,4 +435,5 @@ fi # that constantly. A rename swaps the inode: readers get the old file or the new one. mv -f "$TMPOUT" "$TARGET" trap - EXIT +[[ -n "$_RESOLVED_TMPL" ]] && rm -f "$_RESOLVED_TMPL" echo "Updated: $TARGET" diff --git a/git_pull_execute.sh b/git_pull_execute.sh index 114a66f..469be96 100755 --- a/git_pull_execute.sh +++ b/git_pull_execute.sh @@ -343,15 +343,16 @@ elif [[ "$SYNC_SUCCESS" == true ]]; then # This server's host conf only — sparse checkout ensures we have it HOST_CONF="$CONF_DIR/${MY_ID,,}.conf" if [[ -f "$DEPLOY_DIR/host.conf.template" && -f "$HOST_CONF" ]]; then - # Template uses HOSTN_ as generic prefix. Substitute MY_ID before merging - # so keys match the target (HOST1_* or HOST2_*) and real values are preserved. - TMPL_RESOLVED=$(mktemp) - sed "s/HOSTN_/${MY_ID}_/g; s/REMOTE_ID/${REMOTE_ID}/g" "$DEPLOY_DIR/host.conf.template" > "$TMPL_RESOLVED" + # The template is slot-generic; conf_upgrade resolves HOSTN/hostn to MY_ID and refuses + # if that disagrees with the target's own keys. This used to be a local sed that only + # replaced HOSTN_, which left bare HOSTN in comments — enough to trip conf_upgrade's + # own guard, so the host conf silently never upgraded — and left lowercase hostn alone, + # which would have installed an unreferenced hostn-appdata rsync profile. bash "$UPGRADE_SCRIPT" \ - --template "$TMPL_RESOLVED" \ - --target "$HOST_CONF" \ - --backup $_DRY - rm -f "$TMPL_RESOLVED" + --template "$DEPLOY_DIR/host.conf.template" \ + --target "$HOST_CONF" \ + --host-slot "$MY_ID" \ + --backup $_DRY else warn "${MY_ID,,}.conf or host.conf.template not found — skipping" fi