diff --git a/Partnership/partnership_manager.sh b/Partnership/partnership_manager.sh index 8d88a75..ac534c7 100755 --- a/Partnership/partnership_manager.sh +++ b/Partnership/partnership_manager.sh @@ -885,7 +885,10 @@ do_final_sync() { warn "CRITICAL_SYNC_SHARES is empty — skipping final sync (configure in host*.conf)" fi else - warn "DRY RUN — would run final critical sync (${#CRITICAL_SYNC_SHARES[@]:-hardcoded} shares)" + # ${#ARR[@]} and :- cannot be combined — bash rejects the whole word as "bad substitution", + # so this line aborted Step 2 of every offboard --dry-run with a shell error instead of + # printing. A count of an unset array is already 0, which is the only default needed. + warn "DRY RUN — would run final critical sync (${#CRITICAL_SYNC_SHARES[@]} shares)" fi warn "Final sync complete — mirror has current state ✅" } diff --git a/Partnership/partnership_offboard.sh b/Partnership/partnership_offboard.sh index 317e611..2ba6d7e 100755 --- a/Partnership/partnership_offboard.sh +++ b/Partnership/partnership_offboard.sh @@ -17,7 +17,8 @@ # Step 1: Stop rsync — halt any running sync before state changes # Step 2: Final sync — mirror leaves with current Critical-Data state # Step 3: Reconfigure WebUIs — mirror's auth WebUIs → localhost -# Step 4: Disable sync — CRITICAL_RSYNC_ENABLED=false in master.conf +# Step 4: Disarm sync gates — RSYNC/CRITICAL_RSYNC/CONF_SYNC/ARR_SYNC=false in master.conf, +# the exact inverse of onboard Step 9c # Step 5: Local cleanup — remove fallback coverage containers + appdata # Step 6: Restart own stack — bring up owner's own parked containers # Step 7: Remote cleanup — remove auth/arr stack + fallback containers from mirror @@ -31,7 +32,7 @@ # Step 2: Reconfigure WebUIs — local auth WebUIs → localhost # Step 3: Remote stack clean — remove owner-deployed containers locally (auth/arr stack) # Step 4: Fallback cleanup — remove fallback coverage containers -# Step 5: Disable sync — CRITICAL_RSYNC_ENABLED=false in master.conf +# Step 5: Disarm sync gates — same four gates as the owner path # Step 6: Revoke Emby admin — remove own admin account from local Emby instance # Step 7: Restart own stack — bring up own parked containers # Step 8: SSH revocation — revoke keys both directions, write state, signal owner @@ -154,6 +155,7 @@ source "$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh" # ── Parse flags ─────────────────────────────────────────────────────────────────────────────── REASON="manual" +STEP_DISABLE_RSYNC_OK=true # both paths report it; only the mirror path re-initialised it FILTERED_ARGS=() for arg in "$@"; do @@ -214,6 +216,39 @@ echo " Reason: $REASON" echo "" [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made" +# ============================================================================================== +# ── HELPER: disarm every sync gate a completed onboard armed ───────────────────────────────── +# +# The exact inverse of partnership_onboard.sh Step 9c, which sets RSYNC_ENABLED, +# CONF_SYNC_ENABLED and ARR_SYNC_ENABLED to true on a completed onboard. Offboard used to turn +# off CRITICAL_RSYNC_ENABLED and nothing else, which left the far more consequential Tier 1 +# RSYNC_ENABLED switched on with no partner to sync to. +# +# That asymmetry read as safe and was not. Turning off one Tier 2 gate looks like "sync is off" +# in the summary, while INTERMEDIATE, DAILY, WEEKLY and FALLBACK rsync all stayed live behind an +# open Tier 1 — and CONF_SYNC_ENABLED kept a 4-hourly job reaching for a partner that had just +# been removed, failing and notifying each time. +# +# CRITICAL_RSYNC_ENABLED stays in the list. It is a Tier 2 gate and closing Tier 1 already stops +# it, but leaving it true would misreport the state to anyone reading the conf rather than the +# tier logic. +# +# FALLBACK_ENABLED is deliberately NOT here. Onboard does not arm it, so offboard has no business +# disarming it — it is the operator's switch, and the summary says so rather than moving it. +# ============================================================================================== +_VV_SYNC_GATES=(RSYNC_ENABLED CRITICAL_RSYNC_ENABLED CONF_SYNC_ENABLED ARR_SYNC_ENABLED) + +_disarm_sync_gates() { + local gate rc=0 conf="$SCRIPTS_ROOT/Configurations/master.conf" + for gate in "${_VV_SYNC_GATES[@]}"; do + # set_conf_bool, not update_master_conf: the latter rewrites the whole line and would + # strip the trailing comment that explains what each tier gates. Same helper onboard + # arms with, so arming and disarming are one operation in two directions. + set_conf_bool "$gate" "false" "$conf" || rc=1 + done + return "$rc" +} + # ============================================================================================== # ── HELPER: revoke own admin account from local Emby instance ──────────────────────────────── # @@ -335,16 +370,14 @@ if [[ "$AM_MIRROR" == true ]]; then cleanup_partner_containers || STEP_FALLBACK_CLEANUP_OK=false - # ── Step 5: Disable critical sync ───────────────────────────────────────────────────────── + # ── Step 5: Disarm the sync gates ───────────────────────────────────────────────────────── echo "" - echo "━━━ $ICON_GEAR Step 5/8 — Disable Critical Sync ━━━" + echo "━━━ $ICON_GEAR Step 5/8 — Disarm Sync Gates ━━━" if [[ "$DRY_RUN" == false ]]; then - update_master_conf "CRITICAL_RSYNC_ENABLED" "false" && \ - warn "CRITICAL_RSYNC_ENABLED=false ✅" || \ - { warn "Failed to update CRITICAL_RSYNC_ENABLED"; STEP_DISABLE_RSYNC_OK=false; } + _disarm_sync_gates || STEP_DISABLE_RSYNC_OK=false else - warn "DRY RUN — would set CRITICAL_RSYNC_ENABLED=false" + warn "DRY RUN — would disarm ${_VV_SYNC_GATES[*]}" fi # ── Step 6: Revoke Emby admin locally ───────────────────────────────────────────────────── @@ -402,7 +435,7 @@ if [[ "$AM_MIRROR" == true ]]; then echo " Step 2 — WebUIs: $(_ok "$STEP_WEBUI_OK")" echo " Step 3 — Stack cleanup: $(_ok "$STEP_STACK_CLEANUP_OK")" echo " Step 4 — Fallback cleanup: $(_ok "$STEP_FALLBACK_CLEANUP_OK")" - echo " Step 5 — Disable sync: $(_ok "$STEP_DISABLE_RSYNC_OK")" + echo " Step 5 — Sync gates: $(_ok "$STEP_DISABLE_RSYNC_OK") (${_VV_SYNC_GATES[*]} → false)" echo " Step 6 — Emby revoke: $(_ok "$STEP_EMBY_OK")" echo " Step 7 — Own stack: started" echo " Step 8 — Keys revoked: $(_revoke_status)" @@ -475,15 +508,14 @@ else (( WEBUI_FAILURES++ )) fi -# ── Step 4: Disable critical sync ───────────────────────────────────────────────────────────── +# ── Step 4: Disarm the sync gates ───────────────────────────────────────────────────────────── echo "" -echo "━━━ $ICON_GEAR Step 4/10 — Disable Critical Sync ━━━" +echo "━━━ $ICON_GEAR Step 4/10 — Disarm Sync Gates ━━━" if [[ "$DRY_RUN" == false ]]; then - update_master_conf "CRITICAL_RSYNC_ENABLED" "false" - warn "CRITICAL_RSYNC_ENABLED=false ✅" + _disarm_sync_gates || STEP_DISABLE_RSYNC_OK=false else - warn "DRY RUN — would set CRITICAL_RSYNC_ENABLED=false" + warn "DRY RUN — would disarm ${_VV_SYNC_GATES[*]}" fi # ── Step 5: Local container cleanup ─────────────────────────────────────────────────────────── @@ -597,7 +629,7 @@ _revoke_status() { echo " Step 1 — Stop rsync: $(_ok "$STEP_STOP_OK")" echo " Step 2 — Final sync: $(_ok "$STEP_SYNC_OK")" echo " Step 3 — WebUI failures: $WEBUI_FAILURES" -echo " Step 4 — Disable sync: ✅" +echo " Step 4 — Sync gates: $(_ok "$STEP_DISABLE_RSYNC_OK") (${_VV_SYNC_GATES[*]} → false)" echo " Step 5 — Local cleanup: ✅" echo " Step 6 — Own stack: started" echo " Step 7 — Remote cleanup: $( [[ "$MIRROR_REACHABLE" == true ]] && echo "✅" || echo "skipped (unreachable)" )" @@ -608,6 +640,10 @@ echo "" echo " Blocklist: $MIRROR blocked — re-onboard to permit access again ✅" [[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]] && \ echo " Tailscale: $MIRROR removed ✅" +# Named because it is the one partnership switch neither onboard nor offboard moves, so it +# survives an offboard still true and there is nothing else that would ever mention it. +[[ "${FALLBACK_ENABLED:-false}" == true ]] && \ + echo " FALLBACK_ENABLED is still true — nothing left to fail over to, turn it off yourself" echo "" echo " $MIRROR leaves with:" echo " ✓ Current auth config (final sync)" diff --git a/Partnership/partnership_onboard.sh b/Partnership/partnership_onboard.sh index 4bf6495..348e3de 100755 --- a/Partnership/partnership_onboard.sh +++ b/Partnership/partnership_onboard.sh @@ -254,32 +254,6 @@ write_onboard_phase() { platform_push_setup_state } -# ── Helper: set a boolean toggle in master.conf, in place ───────────────────────────────────── -# Booleans only. The value pattern stops at whitespace, so a quoted value containing spaces would -# be truncated — every gate this arms is true/false and nothing else should use it. -# -# Preserves what the line already looks like: leading indent, the existing quoting style, and the -# column the trailing comment sits in. master.conf is hand-aligned and read by a person; an edit -# that reflows a line makes a diff that looks bigger than the change actually is. -_arm_conf_var() { - local key="$1" val="$2" file="$3" cur pad spaces quoted - if ! grep -qE "^[[:space:]]*${key}=" "$file"; then - warn " ${key} is not in master.conf — skipped (add it to the template first)" - return 1 - fi - cur=$(grep -E "^[[:space:]]*${key}=" "$file" | head -1 | sed -E "s|^[[:space:]]*${key}=||; s|[[:space:]]*#.*$||") - quoted=""; [[ "$cur" == \"*\" ]] && quoted='"' - cur="${cur//\"/}" - [[ "$cur" == "$val" ]] && { echo " ${key} already ${val}"; return 0; } - # Pad only when a comment follows — otherwise a shorter value leaves trailing whitespace. - pad=0 - if grep -qE "^[[:space:]]*${key}=[^#]*#" "$file"; then - pad=$(( ${#cur} - ${#val} )); (( pad < 0 )) && pad=0 - fi - spaces=$(printf '%*s' "$pad" '') - sed -i -E "s|^([[:space:]]*)${key}=[^#[:space:]]*([[:space:]]*)(#.*)?$|\1${key}=${quoted}${val}${quoted}${spaces}\2\3|" "$file" - echo " ${key}: ${cur} → ${val}" -} echo "" echo "━━━ $ICON_FALLBACK Partnership Onboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━" @@ -635,7 +609,7 @@ if [[ "$PHASE1_ONLY" == true ]]; then elif [[ ! -f "$_conf_sync_script" ]]; then warn "conf_sync.sh not found — skipping partner conf cache" else - _arm_conf_var CONF_SYNC_ENABLED "true" "$SCRIPTS_ROOT/Configurations/master.conf" \ + set_conf_bool CONF_SYNC_ENABLED "true" "$SCRIPTS_ROOT/Configurations/master.conf" \ || warn "Could not arm CONF_SYNC_ENABLED — cache step may no-op" if bash "$_conf_sync_script" --push-only; then @@ -920,7 +894,7 @@ else else cp -a "$_master_conf" "${_master_conf}.bak-arm-$(date +%Y%m%d-%H%M%S)" for _gate in RSYNC_ENABLED CONF_SYNC_ENABLED ARR_SYNC_ENABLED; do - _arm_conf_var "$_gate" "true" "$_master_conf" || ARM_OK=false + set_conf_bool "$_gate" "true" "$_master_conf" || ARM_OK=false done # Tier 1 is now open, so say what that actually turned on rather than leaving it implied. echo " rsync tier 2 gates were left as configured — check them before the first run" diff --git a/common.sh b/common.sh index 5c354ce..ff0e669 100755 --- a/common.sh +++ b/common.sh @@ -939,9 +939,53 @@ is_own_conf_file() { [[ "${1,,}" == "${MY_ID,,}.conf" ]] } +# ── Set a boolean toggle in master.conf, in place ───────────────────────────────────────────── +# Booleans only. The value pattern stops at whitespace, so a quoted value containing spaces would +# be truncated — every gate this sets is true/false and nothing else should use it. +# +# Preserves what the line already looks like: leading indent, the existing quoting style, and the +# column the trailing comment sits in. master.conf is hand-aligned and read by a person; an edit +# that reflows a line makes a diff that looks bigger than the change actually is. +# +# Prefer this over update_master_conf() for toggles. That one rewrites the entire line as +# " KEY=VALUE", which drops the trailing comment — run it over the four sync gates and +# master.conf loses the notes explaining what each tier does. +# +# Lives here rather than in partnership_onboard.sh because onboard arming a gate and offboard +# disarming the same gate must be the same operation in both directions. +# Usage: set_conf_bool "$key" "true"|"false" "$master_conf_path" +set_conf_bool() { + local key="$1" val="$2" file="$3" cur pad spaces quoted gap + if ! grep -qE "^[[:space:]]*${key}=" "$file"; then + warn " ${key} is not in master.conf — skipped (add it to the template first)" + return 1 + fi + cur=$(grep -E "^[[:space:]]*${key}=" "$file" | head -1 | sed -E "s|^[[:space:]]*${key}=||; s|[[:space:]]*#.*$||") + quoted=""; [[ "$cur" == \"*\" ]] && quoted='"' + cur="${cur//\"/}" + [[ "$cur" == "$val" ]] && { echo " ${key} already ${val}"; return 0; } + + # Rewrite the whole gap rather than padding on top of the existing one. Adding + # (len(old) - len(new)) spaces in front of the gap that is already there holds the column for + # one edit and drifts on the next: false→true pads +1, true→false pads 0 but keeps that space, + # so every arm/disarm cycle pushes the comment one column right. An onboard and offboard pair + # is exactly that cycle, and master.conf is hand-aligned. + pad=0 + if grep -qE "^[[:space:]]*${key}=[^#]*#" "$file"; then + # Existing run of spaces between the value and the # + gap=$(grep -E "^[[:space:]]*${key}=" "$file" | head -1 \ + | sed -E "s|^[[:space:]]*${key}=[^#[:space:]]*||; s|#.*$||") + pad=$(( ${#gap} + ${#cur} - ${#val} )) + (( pad < 1 )) && pad=1 + fi + spaces=$(printf '%*s' "$pad" '') + sed -i -E "s|^([[:space:]]*)${key}=[^#[:space:]]*[[:space:]]*(#.*)?$|\1${key}=${quoted}${val}${quoted}${spaces}\2|" "$file" + echo " ${key}: ${cur} → ${val}" +} + # Sets key=value in a flat state file (setup.db style) — updates in place if the -# key exists, appends if not. No indentation handling — for master.conf use -# partnership_manager.sh's update_master_conf() instead. +# key exists, appends if not. No indentation handling — for master.conf toggles use +# set_conf_bool() above. # Usage: set_state_var "$state_file" "$key" "$value" set_state_var() { local file="$1" key="$2" value="$3"