18 lines
205 KiB
JSON
18 lines
205 KiB
JSON
{"parentUuid":null,"isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"a41f0ff6991d1474c","type":"user","message":{"role":"user","content":"Audit every bash script in these directories against its own header documentation. For each script, read the full file and check:\n1. Does PURPOSE match what the code actually does?\n2. Does CONFIGURATION list all config vars the script actually uses (missing vars, or vars listed but never referenced)?\n3. Does RUNTIME MODES / flags match what's actually parsed (missing flags, or flags documented but not implemented)?\n4. Are there OPERATIONAL SAFEGUARDS documented that aren't in the code?\n5. Anything the code does that the header doesn't mention?\n\nScripts to audit:\n- /boot/config/plugins/varaverk/Partnership/onboard_cancel.sh\n- /boot/config/plugins/varaverk/Partnership/partnership_manager.sh\n- /boot/config/plugins/varaverk/Partnership/partnership_offboard.sh\n- /boot/config/plugins/varaverk/Partnership/partnership_onboard.sh\n- /boot/config/plugins/varaverk/Partnership/partnership_transfer.sh\n- /boot/config/plugins/varaverk/Partnership/ssh_setup.sh\n\nReport ONLY real discrepancies — not style opinions. Format: filename, then a short bullet list of issues. If a script's header matches its code exactly, just write \"✅ filename\". Be specific: quote the header claim and what the code actually does differently."},"uuid":"bb6fbd76-7659-4342-a178-e5e19bf96ad1","timestamp":"2026-06-14T05:32:20.383Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"bb6fbd76-7659-4342-a178-e5e19bf96ad1","isSidechain":true,"agentId":"a41f0ff6991d1474c","attachment":{"type":"deferred_tools_delta","addedNames":["EnterWorktree","ExitWorktree","Monitor","TaskStop","WebFetch","WebSearch","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Google_Drive__complete_authentication"],"addedLines":["EnterWorktree","ExitWorktree","Monitor","TaskStop","WebFetch","WebSearch","mcp__claude_ai_Google_Drive__authenticate","mcp__claude_ai_Google_Drive__complete_authentication"],"removedNames":[],"readdedNames":[]},"type":"attachment","uuid":"8eac33f5-874b-4993-875e-ee10fa09f7f4","timestamp":"2026-06-14T05:32:20.388Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"8eac33f5-874b-4993-875e-ee10fa09f7f4","isSidechain":true,"agentId":"a41f0ff6991d1474c","attachment":{"type":"skill_listing","content":"- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors (\"from now on when X\", \"each time X\", \"whenever X\", \"before/after X\") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions (\"allow X\", \"add permission\", \"move permission to\"), env vars (\"set X=Y\"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: \"allow npm commands\", \"add bq permission to global settings\", \"move permission to user settings\", \"set DEBUG=true\", \"when claude stops show X\". For simple settings like theme/model, suggest the /config command.\n- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: \"rebind ctrl+s\", \"add a chord shortcut\", \"change the submit key\", \"customize keybindings\".\n- verify: Verify that a code change actually does what it's supposed to by running the app and observing behavior. Use when asked to verify a PR, confirm a fix works, test a change manually, check that a feature works, or validate local changes before pushing.\n- code-review: Review the current diff for correctness bugs and reuse/simplification/efficiency cleanups at the given effort level (low/medium: fewer, high-confidence findings; high→max: broader coverage, may include uncertain findings; ultra: deep multi-agent review in the cloud). Pass --comment to post findings as inline PR comments, or --fix to apply the findings to the working tree after the review.\n- simplify: Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that.\n- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.\n- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. \"check the deploy every 5 minutes\", \"keep running /babysit-prs\"). Do NOT invoke for one-off tasks.\n- schedule: Create, update, list, or run scheduled cloud agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring cloud agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run (\"run this once at 3pm\", \"remind me to check X tomorrow\").\n- claude-api: Reference for the Claude API / Anthropic SDK — model ids, pricing, params, streaming, tool use, MCP, agents, caching, token counting, model migration.\nTRIGGER — read BEFORE opening the target file; don't skip because it \"looks like a one-liner\" — whenever: the prompt names Claude/Anthropic in any form (Claude, Anthropic, Fable, Opus, Sonnet, Haiku, `anthropic`, `@anthropic-ai`, `claude-*`, `us.anthropic.*`, `[1m]`); the user asks about an LLM (pricing/model choice/limits/caching) — never answer from memory; OR the task is LLM-shaped with provider unstated (agent/MCP/tool-definition/multi-agent/RAG/LLM-judge/computer-use; generate/summarize/extract/classify/rewrite/converse over NL; debugging refusals/cutoffs/streaming/tool-calls/tokens).\nSKIP only when another provider is being worked on (overrides all triggers): OpenAI/GPT/Gemini/Llama/Mistral/Cohere/Ollama named in the query; OR `grep -rE 'openai|langchain_openai|google.generativeai|genai|mistralai|cohere|ollama'` over the project hits (run this grep FIRST if no provider named — don't Read the file).\n- run: Launch and drive this project's app to see a change working. Use when asked to run, start, or screenshot the app, or to confirm a change works in the real app (not just tests). First looks for a project skill that already covers launching the app; otherwise falls back to built-in patterns per project type (CLI, server, TUI, Electron, browser-driven, library).\n- init: Initialize a new CLAUDE.md file with codebase documentation\n- review: Review a pull request\n- security-review: Complete a security review of the pending changes on the current branch","skillCount":13,"isInitial":true,"names":["update-config","keybindings-help","verify","code-review","simplify","fewer-permission-prompts","loop","schedule","claude-api","run","init","review","security-review"]},"type":"attachment","uuid":"ac3e74b5-7f32-4471-b82e-d55fdb4dc121","timestamp":"2026-06-14T05:32:20.389Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"ac3e74b5-7f32-4471-b82e-d55fdb4dc121","isSidechain":true,"agentId":"a41f0ff6991d1474c","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01JgbihLYu5if9jnr1FxJvro","type":"message","role":"assistant","content":[{"type":"text","text":"I'll audit each bash script against its header documentation. Let me start by reading all the scripts."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2007,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2007,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c4DLnBak1iWoFokbjq","attributionAgent":"Explore","type":"assistant","uuid":"74394963-6539-47b1-a893-34e6a400a90d","timestamp":"2026-06-14T05:32:21.683Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"74394963-6539-47b1-a893-34e6a400a90d","isSidechain":true,"agentId":"a41f0ff6991d1474c","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01JgbihLYu5if9jnr1FxJvro","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TTEBGnMtTUuBxfhsyyXQqh","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Partnership/onboard_cancel.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2007,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2007,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c4DLnBak1iWoFokbjq","attributionAgent":"Explore","type":"assistant","uuid":"c6ef0e66-919f-4ada-a5db-a01b029f3369","timestamp":"2026-06-14T05:32:21.904Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"c6ef0e66-919f-4ada-a5db-a01b029f3369","isSidechain":true,"agentId":"a41f0ff6991d1474c","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01JgbihLYu5if9jnr1FxJvro","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DkbYBaNmLsCmmcm8E8azpJ","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Partnership/partnership_manager.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2007,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2007,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c4DLnBak1iWoFokbjq","attributionAgent":"Explore","type":"assistant","uuid":"044fc2ca-e635-4fbc-ab5b-8dbb4faea597","timestamp":"2026-06-14T05:32:21.907Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"c6ef0e66-919f-4ada-a5db-a01b029f3369","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"a41f0ff6991d1474c","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TTEBGnMtTUuBxfhsyyXQqh","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ============================= Delete Keys ====================================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Removes SSH keys between HOST1 and HOST2 in the specified direction.\n9\t# Safe to run at any phase. Clears related setup.db flags.\n10\t#\n11\t# ==============================================================================================\n12\t# RUNTIME MODES\n13\t# ==============================================================================================\n14\t#\n15\t# Partnership/onboard_cancel.sh --direction=h1 (default)\n16\t# HOST1 → HOST2: remove HOST1's public key from HOST2's authorized_keys,\n17\t# delete local HOST1 key pair, clear HOST2 phase flags from setup.db.\n18\t#\n19\t# Partnership/onboard_cancel.sh --direction=h2\n20\t# HOST2 → HOST1: remove HOST2's public key from HOST1's authorized_keys.\n21\t# Identifies the key by HOST2's hostname in the key comment.\n22\t#\n23\t# Partnership/onboard_cancel.sh --direction=both\n24\t# Both directions.\n25\t#\n26\t# Partnership/onboard_cancel.sh --dry-run\n27\t# Preview without making changes.\n28\t#\n29\t# ==============================================================================================\n30\t\n31\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n32\tSCRIPTS_ROOT=\"$SCRIPT_DIR/..\"\n33\tSSH_TIMEOUT=15\n34\tDIRECTION=\"h1\"\n35\tFILTERED_ARGS=()\n36\t\n37\tfor arg in \"$@\"; do\n38\t case \"$arg\" in\n39\t --direction=*) DIRECTION=\"${arg#--direction=}\" ;;\n40\t *) FILTERED_ARGS+=(\"$arg\") ;;\n41\t esac\n42\tdone\n43\t\n44\tsource \"$SCRIPTS_ROOT/load_config.sh\"\n45\tparse_args \"${FILTERED_ARGS[@]}\"\n46\t\n47\t[[ \"$EUID\" -ne 0 ]] && { error \"Must be run as root\"; exit 1; }\n48\t\n49\tacquire_lock\n50\t\n51\tdetect_hosts\n52\t\n53\tOWNER_ID=\"${PARTNERSHIP_OWNER_HOST:-HOST1}\"\n54\tMIRROR_ID=$( [[ \"$OWNER_ID\" == \"HOST1\" ]] && echo \"HOST2\" || echo \"HOST1\" )\n55\tMIRROR=\"${!MIRROR_ID}\"\n56\tSSH_KEY_PUB=\"${SSH_KEY}.pub\"\n57\tSTATE_FILE=\"$(platform_setup_db_path)\"\n58\tAUTH_KEYS=\"/root/.ssh/authorized_keys\"\n59\tMIRROR_SHORT=\"${MIRROR%%.*}\"\n60\t\n61\tSTART=$(date +%s)\n62\t\n63\techo \"\"\n64\techo \"━━━ Delete Keys — direction:${DIRECTION} — $(date '+%Y-%m-%d %H:%M:%S') ━━━\"\n65\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no changes will be made\"\n66\techo \"\"\n67\t\n68\tH1_DONE=false\n69\tH2_DONE=false\n70\t\n71\t# ── HOST1 → HOST2: remove HOST1's key from HOST2 + delete local pair ──────────\n72\tif [[ \"$DIRECTION\" == \"h1\" || \"$DIRECTION\" == \"both\" ]]; then\n73\t echo \"━━━ HOST1 → HOST2: Remove HOST1 key from $MIRROR ━━━\"\n74\t\n75\t if [[ ! -f \"$SSH_KEY_PUB\" ]]; then\n76\t log \"No local public key — nothing to remove from $MIRROR\"\n77\t H1_DONE=true\n78\t else\n79\t KEY_BLOB=$(awk '{print $2}' \"$SSH_KEY_PUB\")\n80\t MIRROR_IP=$(resolve_tailscale_ip \"$MIRROR\" 2>/dev/null || true)\n81\t\n82\t if [[ -z \"$MIRROR_IP\" ]]; then\n83\t warn \"Cannot resolve $MIRROR Tailscale IP — remove HOST1 key from $MIRROR manually\"\n84\t elif [[ \"$DRY_RUN\" == true ]]; then\n85\t warn \"DRY RUN — would remove HOST1 key from $MIRROR:/root/.ssh/authorized_keys\"\n86\t H1_DONE=true\n87\t else\n88\t timeout \"$SSH_TIMEOUT\" ssh -i \"$SSH_KEY\" \\\n89\t -o ConnectTimeout=\"$SSH_TIMEOUT\" -o BatchMode=yes root@\"$MIRROR_IP\" \\\n90\t \"sed -i \\\"|${KEY_BLOB}|d\\\" /root/.ssh/authorized_keys 2>/dev/null\n91\t sed -i \\\"/^${MIRROR_ID}_PHASE\\|^${MIRROR_ID}_KEY_READY/d\\\" $(platform_setup_db_path) 2>/dev/null\n92\t echo ok\" 2>/dev/null | grep -q ok && {\n93\t log \"HOST1 key removed from $MIRROR authorized_keys ✅\"\n94\t H1_DONE=true\n95\t } || warn \"Could not SSH to $MIRROR — remove HOST1 key there manually\"\n96\t fi\n97\t fi\n98\t\n99\t # Delete local key pair\n100\t if [[ ! -f \"$SSH_KEY\" && ! -f \"$SSH_KEY_PUB\" ]]; then\n101\t log \"Local key already gone\"\n102\t elif [[ \"$DRY_RUN\" == true ]]; then\n103\t warn \"DRY RUN — would delete: $SSH_KEY and ${SSH_KEY}.pub\"\n104\t else\n105\t rm -f \"$SSH_KEY\" \"$SSH_KEY_PUB\" && log \"Local key pair deleted ✅\" || \\\n106\t warn \"Failed to delete local key — check permissions\"\n107\t fi\n108\t\n109\t # Clear HOST2 phase flags from local setup.db\n110\t if [[ -f \"$STATE_FILE\" ]]; then\n111\t if [[ \"$DRY_RUN\" == false ]]; then\n112\t sed -i \"/^${MIRROR_ID}_PHASE/d; /^${MIRROR_ID}_KEY_READY/d\" \"$STATE_FILE\"\n113\t log \"Phase flags cleared from local setup.db ✅\"\n114\t else\n115\t warn \"DRY RUN — would clear ${MIRROR_ID}_PHASE* from setup.db\"\n116\t fi\n117\t fi\n118\t echo \"\"\n119\tfi\n120\t\n121\t# ── HOST2 → HOST1: remove HOST2's key from HOST1's authorized_keys ────────────\n122\tif [[ \"$DIRECTION\" == \"h2\" || \"$DIRECTION\" == \"both\" ]]; then\n123\t echo \"━━━ HOST2 → HOST1: Remove $MIRROR key from HOST1 ━━━\"\n124\t\n125\t if [[ ! -f \"$AUTH_KEYS\" ]]; then\n126\t log \"No authorized_keys on HOST1 — nothing to remove\"\n127\t H2_DONE=true\n128\t elif ! grep -qi \"$MIRROR_SHORT\" \"$AUTH_KEYS\" 2>/dev/null; then\n129\t log \"$MIRROR key not found in HOST1 authorized_keys (already removed or never added)\"\n130\t H2_DONE=true\n131\t elif [[ \"$DRY_RUN\" == true ]]; then\n132\t warn \"DRY RUN — would remove $MIRROR_SHORT key from $AUTH_KEYS\"\n133\t H2_DONE=true\n134\t else\n135\t sed -i \"/${MIRROR_SHORT}/Id\" \"$AUTH_KEYS\" && {\n136\t log \"$MIRROR key removed from HOST1 authorized_keys ✅\"\n137\t H2_DONE=true\n138\t } || warn \"Failed to remove $MIRROR key from HOST1 authorized_keys\"\n139\t fi\n140\t echo \"\"\n141\tfi\n142\t\n143\t# ── Summary ───────────────────────────────────────────────────────────────────\n144\tEND=$(date +%s)\n145\techo \"━━━━━ DONE ━━━━━\"\n146\t[[ \"$DIRECTION\" == \"h1\" || \"$DIRECTION\" == \"both\" ]] && \\\n147\t echo \" HOST1 → HOST2: $( [[ \"$H1_DONE\" == true ]] && echo \"✅\" || echo \"⚠ manual step may be needed\" )\"\n148\t[[ \"$DIRECTION\" == \"h2\" || \"$DIRECTION\" == \"both\" ]] && \\\n149\t echo \" HOST2 → HOST1: $( [[ \"$H2_DONE\" == true ]] && echo \"✅\" || echo \"⚠\" )\"\n150\techo \" Duration: $(format_duration $(( END - START )))\"\n151\techo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n152\texit 0\n153\t"}]},"uuid":"bfd34444-dbd8-4c36-9343-a3ba7ff32b66","timestamp":"2026-06-14T05:32:21.912Z","sourceToolAssistantUUID":"c6ef0e66-919f-4ada-a5db-a01b029f3369","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"bfd34444-dbd8-4c36-9343-a3ba7ff32b66","isSidechain":true,"agentId":"a41f0ff6991d1474c","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01JgbihLYu5if9jnr1FxJvro","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GRddermTzQs5Q1YdqkXEv1","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Partnership/partnership_offboard.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2007,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2007,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c4DLnBak1iWoFokbjq","attributionAgent":"Explore","type":"assistant","uuid":"26379e55-3d87-450d-b18b-bc401ca57795","timestamp":"2026-06-14T05:32:22.122Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"044fc2ca-e635-4fbc-ab5b-8dbb4faea597","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"a41f0ff6991d1474c","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DkbYBaNmLsCmmcm8E8azpJ","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ============================= Partnership Manager ============================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Manages the full lifecycle of a two-server partnership — onboard, daily health\n9\t# monitoring, offboard, and ownership transfer. Called by partnership_onboard.sh\n10\t# during initial setup, and by critical_sync_maintenance.sh every 30 minutes for\n11\t# the --check mode. All other modes are run manually.\n12\t#\n13\t# PARTNERSHIP_OWNER_HOST flips to \"HOST2\" after a successful --transfer.\n14\t# AM_OWNER / AM_MIRROR flags (set by detect_hosts) control all routing —\n15\t# no hostname string comparisons anywhere in this script.\n16\t#\n17\t# ==============================================================================================\n18\t# OPERATIONAL MODEL\n19\t# ==============================================================================================\n20\t#\n21\t# --onboard (owner only)\n22\t# Reconfigures mirror auth WebUIs → owner's Tailscale IP\n23\t# Mirror operator clicks NPM → gets owner's NPM via Tailscale automatically\n24\t# Writes ACTIVE state on both servers\n25\t#\n26\t# --offboard (either server)\n27\t# Mirror-initiated: reconfigures own WebUIs → localhost, writes INACTIVE state\n28\t# Owner finalises on next --check: final sync, Tailscale removal\n29\t# Owner-initiated: final sync, reconfigures mirror WebUIs → localhost, removes\n30\t# mirror containers + appdata, SSH key revocation, Tailscale removal\n31\t# Both leave with clean state ✅\n32\t#\n33\t# --transfer (owner only)\n34\t# Reconfigures both servers, flips PARTNERSHIP_OWNER_HOST in master.conf\n35\t# Requires confirmation string + consecutive health check passes\n36\t#\n37\t# --check (called every 30min by critical_sync_maintenance.sh)\n38\t# --remote-seen: rsync succeeded → reset offline counter, read remote state\n39\t# --remote-unseen: rsync failed → increment offline counter → auto-offboard at threshold\n40\t# Silent when healthy — never noisy on clean runs\n41\t#\n42\t# --status (either server)\n43\t# Show state files from both servers, blocklist, SSH key status\n44\t#\n45\t# ==============================================================================================\n46\t# DESIGN PRINCIPLES\n47\t# ==============================================================================================\n48\t#\n49\t# Deferred offboard\n50\t# Either server can offboard without the other being reachable. The initiating\n51\t# server writes its state immediately and becomes independent. The other server\n52\t# reads the INACTIVE state on its next --check and finalises automatically.\n53\t# No message passing, no coordination required.\n54\t#\n55\t# Appdata cleanup on offboard\n56\t# Partner containers are stopped and removed. Their appdata bind-mount paths\n57\t# (collected via docker inspect before removal) are also deleted. Safety gate:\n58\t# only paths matching /mnt/*/appdata* are deleted — media and config shares\n59\t# outside the appdata tree are never touched.\n60\t#\n61\t# Blocklist is application-layer; Tailscale removal is network-layer\n62\t# Both happen on offboard. The blocklist prevents re-onboard until explicitly\n63\t# cleared with --unblock. Tailscale removal ends encrypted access at the network\n64\t# level. Grace period controls both simultaneously — one var, consistent behaviour.\n65\t#\n66\t# ==============================================================================================\n67\t# OPERATIONAL SAFEGUARDS\n68\t# ==============================================================================================\n69\t#\n70\t# Root check\n71\t# All operations require root.\n72\t#\n73\t# Role enforcement\n74\t# Mirror cannot run --onboard or --transfer. Blocked with a clear error message.\n75\t#\n76\t# Version parity check\n77\t# --onboard verifies both servers are on compatible unRAID versions.\n78\t#\n79\t# SSH_TIMEOUT on all remote calls\n80\t# Every ssh/scp call is timeout-protected. No operation hangs on an unreachable peer.\n81\t#\n82\t# flock on state writes\n83\t# Prevents concurrent state file corruption from overlapping --check cycles.\n84\t#\n85\t# SIGTERM trap on grace period sleep\n86\t# Offboard grace period is interruptible — Ctrl-C aborts cleanly.\n87\t#\n88\t# Silent by default\n89\t# --check produces no output when both servers are healthy. Only state changes\n90\t# and threshold crossings produce output.\n91\t#\n92\t# ==============================================================================================\n93\t# STATE FILES\n94\t# ==============================================================================================\n95\t#\n96\t# /boot/config/partnership_HOST1.db — HOST1 writes, HOST2 reads via SSH\n97\t# /boot/config/partnership_HOST2.db — HOST2 writes, HOST1 reads via SSH\n98\t# /boot/config/partnership_blocklist.db — hostname|timestamp|reason, persists until cleared\n99\t#\n100\t# On /boot/config — survives reboots, available before array starts, minimal flash wear.\n101\t#\n102\t# ==============================================================================================\n103\t# CONFIGURATION\n104\t# ==============================================================================================\n105\t#\n106\t# master.conf\n107\t#\n108\t# PARTNERSHIP_ENABLED\n109\t# Global gate — set true once both servers are configured (default: false)\n110\t#\n111\t# PARTNERSHIP_OWNER_HOST\n112\t# \"HOST1\" or \"HOST2\" — flips on --transfer (default: \"HOST1\")\n113\t#\n114\t# PARTNERSHIP_REMOVE_TAILSCALE\n115\t# Remove mirror from tailnet on offboard (default: true)\n116\t#\n117\t# PARTNERSHIP_GRACE_HOURS\n118\t# Hours before Tailscale removal after offboard — backup access also expires then (default: 6)\n119\t#\n120\t# PARTNERSHIP_OFFLINE_THRESHOLD\n121\t# Days of missed sync cycles before auto-offboard triggers (default: 30)\n122\t#\n123\t# PARTNERSHIP_TRANSFER_CONFIRM\n124\t# Exact string required for --transfer (default: \"i-understand-this-transfers-ownership\")\n125\t#\n126\t# PARTNERSHIP_TRANSFER_STRIKES\n127\t# Consecutive health checks required before transfer proceeds (default: 3)\n128\t#\n129\t# PARTNERSHIP_TRANSFER_MAX_ATTEMPTS\n130\t# Max health check attempts before giving up (default: 20)\n131\t#\n132\t# PARTNERSHIP_ONBOARD_VERIFY\n133\t# Curl-verify each WebUI after reconfigure to confirm Tailscale routing works (default: true)\n134\t#\n135\t# PARTNERSHIP_ONBOARD_NOTIFY\n136\t# Notify both servers on successful onboard (default: true)\n137\t#\n138\t# PARTNERSHIP_SYNC_INTERVAL\n139\t# Informational — actual schedule is in cron (default: 15)\n140\t#\n141\t# TAILSCALE_API_KEY / TAILSCALE_TAILNET\n142\t# Required when PARTNERSHIP_REMOVE_TAILSCALE=true\n143\t#\n144\t# host*.conf\n145\t#\n146\t# HOST*_PARTNERSHIP_AUTH_WEBUIS\n147\t# Containers reconfigured on onboard/offboard. Format: \"ContainerName|WebUIPort\"\n148\t# Aliased by detect_hosts() → PARTNERSHIP_AUTH_WEBUIS\n149\t#\n150\t# HOST*_PARTNERSHIP_MIRROR_BACKUPS\n151\t# Paths accessible to the partner during grace window after offboard.\n152\t# Aliased by detect_hosts() → PARTNERSHIP_MIRROR_BACKUPS\n153\t#\n154\t# HOST*_PARTNERSHIP_OWN_CONTAINERS\n155\t# Containers parked here during partnership, restarted on offboard.\n156\t# Aliased by detect_hosts() → PARTNERSHIP_OWN_CONTAINERS\n157\t#\n158\t# HOST*_PARTNERSHIP_SERVICES_STACK\n159\t# Shared services XMLs (Emby, Jellyfin, Seerr, SeerrFin) deployed on mirror during onboard.\n160\t# Aliased by detect_hosts() → PARTNERSHIP_SERVICES_STACK\n161\t#\n162\t# ==============================================================================================\n163\t# RUNTIME MODES\n164\t# ==============================================================================================\n165\t#\n166\t# partnership_manager.sh --onboard\n167\t# Establish mirror relationship — owner only\n168\t#\n169\t# partnership_manager.sh --offboard\n170\t# Clean separation — either server. 10-second countdown before executing.\n171\t#\n172\t# partnership_manager.sh --offboard --dry-run\n173\t# Show the complete offboard sequence without executing\n174\t#\n175\t# partnership_manager.sh --transfer --confirm=i-understand-this-transfers-ownership\n176\t# Flip ownership — owner only. Requires exact confirmation string.\n177\t#\n178\t# partnership_manager.sh --check --remote-seen|--remote-unseen\n179\t# Called by critical_sync_maintenance.sh every 30min — do not run manually\n180\t#\n181\t# partnership_manager.sh --status\n182\t# Show state files, blocklist, SSH key status from both servers\n183\t#\n184\t# partnership_manager.sh --unblock <hostname>\n185\t# Remove hostname from blocklist to permit re-onboarding\n186\t#\n187\t# Any mode supports --dry-run and --log\n188\t#\n189\t# ==============================================================================================\n190\t\n191\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n192\t\n193\tsource \"$SCRIPT_DIR/../load_config.sh\"\n194\tsource \"$SCRIPT_DIR/../Plugin/$PLATFORM/Partnership/containers.sh\"\n195\t\n196\tSSH_TIMEOUT=15\n197\tBLOCKLIST_FILE=\"${PARTNERSHIP_BLOCKLIST_FILE:-${STATE_DIR}/partnership_blocklist.db}\"\n198\t\n199\t# ── Parse mode flags before parse_args ────────────────────────────────────────────────────────\n200\tMODE=\"\"\n201\tTRANSFER_CONFIRM_INPUT=\"\"\n202\tREMOTE_SEEN=false\n203\tREMOTE_UNSEEN=false\n204\tREASON=\"manual\"\n205\tUNBLOCK_HOST=\"\"\n206\tFILTERED_ARGS=()\n207\t\n208\tLOCAL_ONLY=false # --onboard --local-only: skips remote steps, runs HOST1-local setup only\n209\t\n210\tfor arg in \"$@\"; do\n211\t case \"$arg\" in\n212\t --onboard) MODE=\"onboard\" ;;\n213\t --offboard) MODE=\"offboard\" ;;\n214\t --transfer) MODE=\"transfer\" ;;\n215\t --check) MODE=\"check\" ;;\n216\t --status) MODE=\"status\" ;;\n217\t --unblock) MODE=\"unblock\" ;;\n218\t --local-only) LOCAL_ONLY=true ;;\n219\t --confirm=*) TRANSFER_CONFIRM_INPUT=\"${arg#--confirm=}\" ;;\n220\t --remote-seen) REMOTE_SEEN=true ;;\n221\t --remote-unseen) REMOTE_UNSEEN=true ;;\n222\t --reason=*) REASON=\"${arg#--reason=}\" ;;\n223\t *)\n224\t if [[ \"$MODE\" == \"unblock\" ]] && [[ -z \"$UNBLOCK_HOST\" ]] && [[ \"$arg\" != --* ]]; then\n225\t UNBLOCK_HOST=\"$arg\"\n226\t else\n227\t FILTERED_ARGS+=(\"$arg\")\n228\t fi\n229\t ;;\n230\t esac\n231\tdone\n232\t\n233\tparse_args \"${FILTERED_ARGS[@]}\"\n234\t\n235\t# ==============================================================================================\n236\t# ━━━ Setup ━━━\n237\t# ==============================================================================================\n238\tif [[ \"$EUID\" -ne 0 ]]; then\n239\t error \"Must be run as root\"\n240\t exit 1\n241\tfi\n242\t\n243\tif ! command -v docker &>/dev/null; then\n244\t error \"Docker command not found\"\n245\t exit 1\n246\tfi\n247\t\n248\t# detect_hosts() sets MY_ID and aliases PARTNERSHIP_AUTH_WEBUIS, PARTNERSHIP_MIRROR_BACKUPS\n249\tdetect_hosts\n250\t\n251\t# ── Derive owner and mirror from PARTNERSHIP_OWNER_HOST ───────────────────────────────────────\n252\tOWNER_ID=\"${PARTNERSHIP_OWNER_HOST:-HOST1}\" # e.g. \"HOST1\"\n253\tMIRROR_ID=$( [[ \"$OWNER_ID\" == \"HOST1\" ]] && echo \"HOST2\" || echo \"HOST1\" )\n254\t\n255\tOWNER=\"${!OWNER_ID}\" # hostname string\n256\tMIRROR=\"${!MIRROR_ID}\"\n257\t# SSH_KEY (set by detect_hosts) is this server's own private key.\n258\t# The remote accepts it because this server's PUBLIC key was installed there via ssh_setup.sh.\n259\t# With sparse checkout, each server only has its own host{N}.conf — the other server's\n260\t# key path is never available here. Use SSH_KEY for all outbound SSH regardless of mode.\n261\tMIRROR_SSH_KEY=\"$SSH_KEY\"\n262\tOWNER_SSH_KEY=\"$SSH_KEY\"\n263\t\n264\tAM_OWNER=false\n265\tAM_MIRROR=false\n266\t[[ \"$MY_ID\" == \"$OWNER_ID\" ]] && AM_OWNER=true\n267\t[[ \"$MY_ID\" == \"$MIRROR_ID\" ]] && AM_MIRROR=true\n268\t\n269\t# State files\n270\tLOCAL_STATE_FILE=\"${STATE_DIR}/partnership_${LOCAL_SERVER_NAME}.db\"\n271\tREMOTE_STATE_FILE=\"${STATE_DIR}/partnership_${REMOTE_SERVER_NAME}.db\"\n272\tOWNER_STATE_FILE=\"${STATE_DIR}/partnership_${OWNER}.db\"\n273\tMIRROR_STATE_FILE=\"${STATE_DIR}/partnership_${MIRROR}.db\"\n274\tOFFLINE_COUNTER=\"${STATE_DIR}/partnership_offline_days.db\"\n275\t\n276\t# ── Exit Trap — restart locally stopped containers if script crashes mid-cleanup ──────────────\n277\t# Used by cleanup_partner_containers() — also shared with partnership_offboard.sh which\n278\t# sources this file and registers the same trap.\n279\tdeclare -a _PM_TRAP_STOPPED=()\n280\t_pm_trap_restart_stopped() {\n281\t [[ ${#_PM_TRAP_STOPPED[@]} -eq 0 ]] && return\n282\t for c in \"${_PM_TRAP_STOPPED[@]}\"; do\n283\t [[ -z \"$c\" ]] && continue\n284\t if docker inspect \"$c\" >/dev/null 2>&1; then\n285\t warn \"Exit trap: restarting $c (stopped but not removed)\"\n286\t docker start \"$c\" >/dev/null 2>&1 || warn \" Failed to restart $c\"\n287\t fi\n288\t done\n289\t}\n290\t\n291\tif [[ \"${PARTNERSHIP_LIB_MODE:-}\" != \"1\" ]]; then\n292\t if [[ -z \"$MODE\" ]]; then\n293\t error \"No mode specified\"\n294\t echo \"Usage:\"\n295\t echo \" partnership_manager.sh --onboard\"\n296\t echo \" partnership_manager.sh --offboard\"\n297\t echo \" partnership_manager.sh --transfer --confirm=...\"\n298\t echo \" partnership_manager.sh --check --remote-seen|--remote-unseen\"\n299\t echo \" partnership_manager.sh --status\"\n300\t echo \" partnership_manager.sh --unblock <hostname>\"\n301\t exit 1\n302\t fi\n303\t\n304\t # Role-based access control\n305\t if [[ \"$AM_MIRROR\" == true ]]; then\n306\t case \"$MODE\" in\n307\t onboard|transfer)\n308\t error \"Only the owner ($OWNER / $OWNER_ID) can run --$MODE\"\n309\t error \"Run from $OWNER or use --offboard to separate cleanly\"\n310\t exit 1\n311\t ;;\n312\t esac\n313\t fi\n314\t\n315\t # Lock for all modes except --check (frequent) and --offboard (offboard script holds its own)\n316\t [[ \"$MODE\" != \"check\" && \"$MODE\" != \"offboard\" ]] && acquire_lock \"strict\"\n317\t\n318\t trap _pm_trap_restart_stopped EXIT\n319\t\n320\t [[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no changes will be made\"\n321\tfi\n322\t\n323\t# ==============================================================================================\n324\t# ── HELPER FUNCTIONS ──────────────────────────────────────────────────────────────────────────\n325\t# ==============================================================================================\n326\t\n327\tread_state_file() {\n328\t local file=\"$1\" key=\"$2\"\n329\t grep \"^${key}=\" \"$file\" 2>/dev/null | cut -d= -f2\n330\t}\n331\t\n332\twrite_state_file() {\n333\t local file=\"$1\"\n334\t shift\n335\t # flock prevents concurrent writes to the same state file\n336\t (\n337\t flock -x 200\n338\t cat > \"$file\" << EOF\n339\tstate=${1:-UNKNOWN}\n340\towner=${OWNER}\n341\tmirror=${MIRROR}\n342\tonboarded=${2:-}\n343\toffboarded=${3:-}\n344\ttriggered_by=${4:-}\n345\treason=${5:-}\n346\tlast_seen_remote=${6:-}\n347\tupdated=$(date '+%Y-%m-%d %H:%M:%S')\n348\tEOF\n349\t ) 200>\"${file}.lock\"\n350\t}\n351\t\n352\tpush_state_to_remote() {\n353\t local local_file=\"$1\" remote_ip=\"$2\" ssh_key=\"$3\"\n354\t if [[ \"$DRY_RUN\" == true ]]; then\n355\t warn \"DRY RUN — would push state file to remote\"\n356\t return 0\n357\t fi\n358\t timeout \"$SSH_TIMEOUT\" scp -i \"$ssh_key\" -o ConnectTimeout=\"$SSH_TIMEOUT\" \\\n359\t \"$local_file\" \"root@${remote_ip}:${local_file}\" 2>/dev/null && \\\n360\t log \"State file pushed to remote ✅\" || \\\n361\t warn \"Could not push state file to remote — will propagate on next sync\"\n362\t}\n363\t\n364\tread_remote_state() {\n365\t local remote_ip=\"$1\" ssh_key=\"$2\" remote_file=\"$3\"\n366\t timeout \"$SSH_TIMEOUT\" ssh -i \"$ssh_key\" \\\n367\t -o ConnectTimeout=\"$SSH_TIMEOUT\" root@\"$remote_ip\" \\\n368\t \"cat '$remote_file' 2>/dev/null\" 2>/dev/null\n369\t}\n370\t\n371\t\n372\tget_tailscale_device_id() {\n373\t local hostname=\"$1\"\n374\t curl -sf --max-time 10 \\\n375\t -H \"Authorization: Bearer $TAILSCALE_API_KEY\" \\\n376\t \"https://api.tailscale.com/api/v2/tailnet/${TAILSCALE_TAILNET}/devices\" \\\n377\t 2>/dev/null | \\\n378\t grep -o \"\\\"id\\\":\\\"[^\\\"]*\\\"[^}]*\\\"hostname\\\":\\\"${hostname}\\\"\" | \\\n379\t grep -o '\"id\":\"[^\"]*\"' | \\\n380\t grep -o '[^\"]*\"$' | tr -d '\"'\n381\t}\n382\t\n383\tremove_tailscale_device() {\n384\t local hostname=\"$1\"\n385\t\n386\t if [[ -z \"${TAILSCALE_API_KEY:-}\" ]] || [[ -z \"${TAILSCALE_TAILNET:-}\" ]]; then\n387\t warn \"TAILSCALE_API_KEY or TAILSCALE_TAILNET not configured — skipping Tailscale removal\"\n388\t return 1\n389\t fi\n390\t\n391\t log \"Looking up Tailscale device ID for $hostname...\"\n392\t local device_id\n393\t device_id=$(get_tailscale_device_id \"$hostname\")\n394\t\n395\t if [[ -z \"$device_id\" ]]; then\n396\t warn \"$hostname not found in Tailscale — may already be removed\"\n397\t return 0\n398\t fi\n399\t\n400\t log \"Removing $hostname (device $device_id) from Tailscale...\"\n401\t\n402\t if [[ \"$DRY_RUN\" == true ]]; then\n403\t warn \"DRY RUN — would remove $hostname from Tailscale tailnet\"\n404\t return 0\n405\t fi\n406\t\n407\t curl -sf --max-time 10 -X DELETE \\\n408\t -H \"Authorization: Bearer $TAILSCALE_API_KEY\" \\\n409\t \"https://api.tailscale.com/api/v2/devices/${device_id}\" 2>/dev/null && \\\n410\t warn \"$hostname removed from Tailscale ✅\" || \\\n411\t error \"Failed to remove $hostname from Tailscale — remove manually\"\n412\t}\n413\t\n414\t# ── Blocklist helpers ─────────────────────────────────────────────────────────────────────────\n415\t\n416\tis_blocklisted() {\n417\t local hostname=\"$1\"\n418\t [[ -f \"$BLOCKLIST_FILE\" ]] && grep -q \"^${hostname}|\" \"$BLOCKLIST_FILE\" 2>/dev/null\n419\t}\n420\t\n421\tadd_to_blocklist() {\n422\t local hostname=\"$1\" reason=\"${2:-offboard}\"\n423\t local now\n424\t now=$(date '+%Y-%m-%d %H:%M:%S')\n425\t if ! is_blocklisted \"$hostname\"; then\n426\t echo \"${hostname}|${now}|${reason}\" >> \"$BLOCKLIST_FILE\"\n427\t log \"Blocklisted: $hostname (reason: $reason)\"\n428\t else\n429\t log \"$hostname already on blocklist\"\n430\t fi\n431\t}\n432\t\n433\tremove_from_blocklist() {\n434\t local hostname=\"$1\"\n435\t if [[ ! -f \"$BLOCKLIST_FILE\" ]]; then\n436\t log \"Blocklist empty — nothing to remove\"\n437\t return 0\n438\t fi\n439\t sed -i \"/^${hostname}|/d\" \"$BLOCKLIST_FILE\" 2>/dev/null\n440\t log \"Removed from blocklist: $hostname\"\n441\t}\n442\t\n443\t# Revoke SSH access on both sides — call after all other SSH operations complete.\n444\t# Matches by key comment (format: keyname@hostname — set by ssh_setup.sh at keygen time).\n445\t# SSH_REVOKE_REMOTE_OK / SSH_REVOKE_LOCAL_OK set in caller scope for summary display.\n446\tdo_ssh_key_revocation() {\n447\t local remote_ip=\"$1\"\n448\t local pub_key_file=\"${SSH_KEY}.pub\"\n449\t\n450\t echo \"\"\n451\t echo \"━━━ $ICON_SHIELD SSH Key Revocation ━━━\"\n452\t\n453\t # Step 1: remove our pubkey from remote's authorized_keys while SSH still works\n454\t SSH_REVOKE_REMOTE_OK=false\n455\t if [[ -f \"$pub_key_file\" ]]; then\n456\t local our_comment\n457\t our_comment=$(awk '{print $3}' \"$pub_key_file\" 2>/dev/null)\n458\t if [[ -n \"$our_comment\" ]]; then\n459\t log \"Revoking our pubkey ($our_comment) from $REMOTE_SERVER_NAME...\"\n460\t if [[ \"$DRY_RUN\" == true ]]; then\n461\t warn \"DRY RUN — would remove our pubkey from $REMOTE_SERVER_NAME authorized_keys\"\n462\t SSH_REVOKE_REMOTE_OK=true\n463\t elif [[ -n \"$remote_ip\" ]] && timeout \"$SSH_TIMEOUT\" ssh -i \"$SSH_KEY\" \\\n464\t -o ConnectTimeout=\"$SSH_TIMEOUT\" -o BatchMode=yes root@\"$remote_ip\" \\\n465\t \"grep -v '${our_comment}' /root/.ssh/authorized_keys \\\n466\t > /root/.ssh/authorized_keys.tmp 2>/dev/null \\\n467\t && mv /root/.ssh/authorized_keys.tmp /root/.ssh/authorized_keys \\\n468\t && echo removed\" 2>/dev/null | grep -q removed; then\n469\t log \"Our pubkey revoked from $REMOTE_SERVER_NAME ✅\"\n470\t SSH_REVOKE_REMOTE_OK=true\n471\t else\n472\t warn \"Remote revocation failed — revoke manually on $REMOTE_SERVER_NAME:\"\n473\t warn \" grep -v '@${LOCAL_SERVER_NAME}' /root/.ssh/authorized_keys > /root/.ssh/authorized_keys\"\n474\t fi\n475\t else\n476\t warn \"Could not read pubkey comment from $pub_key_file — skipping remote revocation\"\n477\t fi\n478\t else\n479\t warn \"Pubkey not found at $pub_key_file — skipping remote revocation\"\n480\t fi\n481\t\n482\t # Step 2: remove remote's pubkey from our local authorized_keys\n483\t # Remote key comment ends with @REMOTE_SERVER_NAME — unique, no special chars\n484\t SSH_REVOKE_LOCAL_OK=false\n485\t log \"Revoking $REMOTE_SERVER_NAME pubkey from local authorized_keys...\"\n486\t if [[ \"$DRY_RUN\" == true ]]; then\n487\t warn \"DRY RUN — would remove $REMOTE_SERVER_NAME pubkey from local authorized_keys\"\n488\t SSH_REVOKE_LOCAL_OK=true\n489\t elif [[ -f /root/.ssh/authorized_keys ]]; then\n490\t if grep -q \"@${REMOTE_SERVER_NAME}\" /root/.ssh/authorized_keys 2>/dev/null; then\n491\t if grep -v \"@${REMOTE_SERVER_NAME}\" /root/.ssh/authorized_keys \\\n492\t > /root/.ssh/authorized_keys.tmp 2>/dev/null && \\\n493\t mv /root/.ssh/authorized_keys.tmp /root/.ssh/authorized_keys; then\n494\t log \"$REMOTE_SERVER_NAME pubkey revoked locally ✅\"\n495\t SSH_REVOKE_LOCAL_OK=true\n496\t else\n497\t warn \"Failed to update local authorized_keys — remove @${REMOTE_SERVER_NAME} entry manually\"\n498\t fi\n499\t else\n500\t log \"$REMOTE_SERVER_NAME pubkey not found in local authorized_keys — already removed\"\n501\t SSH_REVOKE_LOCAL_OK=true\n502\t fi\n503\t else\n504\t log \"/root/.ssh/authorized_keys not found — nothing to remove locally\"\n505\t SSH_REVOKE_LOCAL_OK=true\n506\t fi\n507\t}\n508\t\n509\t# Gather all partner fallback containers for this server (all tiers)\n510\tgather_partner_fallback_containers() {\n511\t local out_var=\"$1\"\n512\t eval \"${out_var}=()\"\n513\t local tier var\n514\t for tier in TIER1 TIER2 TIER3 TIER4; do\n515\t var=\"FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_${tier}\"\n516\t # Check if var is set and is an array\n517\t if declare -p \"$var\" >/dev/null 2>&1; then\n518\t local -a _tmp_arr\n519\t eval \"_tmp_arr=(\\\"\\${${var}[@]}\\\")\"\n520\t for c in \"${_tmp_arr[@]}\"; do\n521\t [[ -n \"$c\" ]] && eval \"${out_var}+=(\\\"\\$c\\\")\"\n522\t done\n523\t fi\n524\t done\n525\t}\n526\t\n527\t# Read a scalar var from the mirror's own config via SSH.\n528\t# Sources load_config.sh + detect_hosts() on the remote so HOST* aliasing works.\n529\tread_remote_conf_var() {\n530\t local mirror_ip=\"$1\" var_name=\"$2\"\n531\t timeout \"$SSH_TIMEOUT\" ssh -i \"$SSH_KEY\" \\\n532\t -o ConnectTimeout=\"$SSH_TIMEOUT\" -o BatchMode=yes root@\"$mirror_ip\" \\\n533\t \"source '$SCRIPT_DIR/../load_config.sh' 2>/dev/null\n534\t detect_hosts 2>/dev/null\n535\t printf '%s' \\\"\\${${var_name}:-}\\\"\" 2>/dev/null\n536\t}\n537\t\n538\t# Read an array var from the mirror's own config via SSH — one element per line.\n539\tread_remote_conf_array() {\n540\t local mirror_ip=\"$1\" var_name=\"$2\"\n541\t timeout \"$SSH_TIMEOUT\" ssh -i \"$SSH_KEY\" \\\n542\t -o ConnectTimeout=\"$SSH_TIMEOUT\" -o BatchMode=yes root@\"$mirror_ip\" \\\n543\t \"source '$SCRIPT_DIR/../load_config.sh' 2>/dev/null\n544\t detect_hosts 2>/dev/null\n545\t printf '%s\\n' \\\"\\${${var_name}[@]:-}\\\"\" 2>/dev/null\n546\t}\n547\t\n548\t# Returns just the short name portion: \"unRAID-Gmer4Lfe\" → \"Gmer4Lfe\"\n549\tderive_short_name() {\n550\t local hostname=\"$1\"\n551\t local short=\"${hostname,,}\"\n552\t [[ \"$short\" == unraid-* ]] && short=\"${short:7}\"\n553\t echo \"${short^}\"\n554\t}\n555\t\n556\t# Start this server's own parked containers after partnership ends.\n557\tstart_own_stack() {\n558\t echo \"\"\n559\t echo \"━━━ $ICON_START Restart Own Stack ━━━\"\n560\t if [[ ${#PARTNERSHIP_OWN_CONTAINERS[@]} -eq 0 ]]; then\n561\t log \"No PARTNERSHIP_OWN_CONTAINERS configured — skipping\"\n562\t return 0\n563\t fi\n564\t for container in \"${PARTNERSHIP_OWN_CONTAINERS[@]}\"; do\n565\t [[ -z \"$container\" ]] && continue\n566\t if [[ \"$DRY_RUN\" == true ]]; then\n567\t warn \"DRY RUN — would start: $container\"\n568\t continue\n569\t fi\n570\t if timeout \"${DOCKER_TIMEOUT:-30}\" docker start \"$container\" >/dev/null 2>&1; then\n571\t log \"$container started ✅\"\n572\t else\n573\t warn \"$container failed to start — check manually\"\n574\t fi\n575\t done\n576\t}\n577\t\n578\t# Remove partnership containers on this server + their appdata bind-mount paths.\n579\t# Appdata paths collected via docker inspect BEFORE removal — inspect fails on removed containers.\n580\t# Safety gate: only paths matching /mnt/*/appdata* are deleted.\n581\tcleanup_partner_containers() {\n582\t declare -a containers=()\n583\t gather_partner_fallback_containers containers\n584\t\n585\t if [[ ${#containers[@]} -eq 0 ]]; then\n586\t log \"No partner containers found to remove\"\n587\t return 0\n588\t fi\n589\t\n590\t # Collect appdata paths BEFORE any removal — inspect fails once container is gone\n591\t local all_appdata_paths=\"\"\n592\t for container in \"${containers[@]}\"; do\n593\t [[ -z \"$container\" ]] && continue\n594\t if timeout \"${DOCKER_TIMEOUT:-30}\" docker inspect \"$container\" >/dev/null 2>&1; then\n595\t local cpaths\n596\t cpaths=$(docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' \\\n597\t \"$container\" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')\n598\t [[ -n \"$cpaths\" ]] && all_appdata_paths+=$'\\n'\"$cpaths\"\n599\t fi\n600\t done\n601\t\n602\t # Remove containers\n603\t for container in \"${containers[@]}\"; do\n604\t [[ -z \"$container\" ]] && continue\n605\t if [[ \"$DRY_RUN\" == true ]]; then\n606\t warn \"DRY RUN — would stop + rm: $container\"\n607\t continue\n608\t fi\n609\t if timeout \"${DOCKER_TIMEOUT:-30}\" docker inspect \"$container\" >/dev/null 2>&1; then\n610\t timeout \"${DOCKER_TIMEOUT:-30}\" docker stop \"$container\" >/dev/null 2>&1 || true\n611\t _PM_TRAP_STOPPED+=(\"$container\")\n612\t timeout \"${DOCKER_TIMEOUT:-30}\" docker rm \"$container\" >/dev/null 2>&1 && \\\n613\t log \"$container removed ✅\" || warn \"$container rm failed\"\n614\t else\n615\t log \"$container not found — skipping\"\n616\t fi\n617\t done\n618\t\n619\t # Delete appdata after containers are gone\n620\t while IFS= read -r path; do\n621\t [[ -z \"$path\" ]] && continue\n622\t if [[ \"$DRY_RUN\" == true ]]; then\n623\t warn \" DRY RUN — would rm -rf $path\"\n624\t continue\n625\t fi\n626\t rm -rf \"$path\" && log \" Appdata removed: $path ✅\" || warn \" Failed to remove: $path\"\n627\t done <<< \"$all_appdata_paths\"\n628\t}\n629\t\n630\t# SSH to mirror — remove all containers named *-${OWNER_SHORT} (owner's deployed containers)\n631\t# and their appdata bind-mount paths.\n632\t# Appdata paths collected via SSH docker inspect before removal, then deleted via SSH.\n633\t# Safety gate: only paths matching /mnt/*/appdata* are deleted on the remote.\n634\tcleanup_owner_containers_on_mirror() {\n635\t local mirror_ip=\"$1\"\n636\t local owner_short\n637\t owner_short=$(derive_short_name \"$OWNER\")\n638\t\n639\t log \"Removing owner-deployed containers from $MIRROR...\"\n640\t if [[ \"$DRY_RUN\" == true ]]; then\n641\t warn \"DRY RUN — would remove *-${owner_short} containers + appdata from $MIRROR\"\n642\t return 0\n643\t fi\n644\t\n645\t local container_list\n646\t container_list=$(timeout \"$SSH_TIMEOUT\" ssh -i \"$SSH_KEY\" \\\n647\t -o ConnectTimeout=\"$SSH_TIMEOUT\" root@\"$mirror_ip\" \\\n648\t \"docker ps -a --format '{{.Names}}' 2>/dev/null | grep -i -- '-${owner_short}$'\" 2>/dev/null)\n649\t\n650\t if [[ -z \"$container_list\" ]]; then\n651\t log \"No *-${owner_short} containers found on $MIRROR\"\n652\t return 0\n653\t fi\n654\t\n655\t while IFS= read -r container; do\n656\t [[ -z \"$container\" ]] && continue\n657\t\n658\t # Collect appdata paths before removal\n659\t local appdata_paths\n660\t appdata_paths=$(timeout \"$SSH_TIMEOUT\" ssh -i \"$SSH_KEY\" \\\n661\t -o ConnectTimeout=\"$SSH_TIMEOUT\" root@\"$mirror_ip\" \\\n662\t \"docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$container' 2>/dev/null \\\n663\t | awk -F: '{print \\$1}' | grep '^/mnt/.*/appdata'\" 2>/dev/null)\n664\t\n665\t timeout \"$SSH_TIMEOUT\" ssh -i \"$SSH_KEY\" \\\n666\t -o ConnectTimeout=\"$SSH_TIMEOUT\" root@\"$mirror_ip\" \\\n667\t \"docker stop '$container' >/dev/null 2>&1\n668\t docker rm '$container' >/dev/null 2>&1 && echo removed\" 2>/dev/null | \\\n669\t grep -q removed && \\\n670\t log \"$container removed from $MIRROR ✅\" || \\\n671\t warn \"Failed to remove $container from $MIRROR\"\n672\t\n673\t # Delete appdata on remote after container removal\n674\t while IFS= read -r path; do\n675\t [[ -z \"$path\" ]] && continue\n676\t timeout \"$SSH_TIMEOUT\" ssh -i \"$SSH_KEY\" \\\n677\t -o ConnectTimeout=\"$SSH_TIMEOUT\" root@\"$mirror_ip\" \\\n678\t \"rm -rf '$path' && echo removed\" 2>/dev/null | grep -q removed && \\\n679\t log \" Appdata removed on $MIRROR: $path ✅\" || \\\n680\t warn \" Failed to remove appdata on $MIRROR: $path\"\n681\t done <<< \"$appdata_paths\"\n682\t done <<< \"$container_list\"\n683\t}\n684\t\n685\t# SSH to mirror — start mirror's own parked containers.\n686\t# Reads PARTNERSHIP_OWN_CONTAINERS from the mirror's own conf via SSH.\n687\tstart_mirror_own_stack() {\n688\t local mirror_ip=\"$1\"\n689\t\n690\t log \"Reading own stack list from $MIRROR conf...\"\n691\t local -a mirror_own=()\n692\t mapfile -t mirror_own < <(read_remote_conf_array \"$mirror_ip\" \"PARTNERSHIP_OWN_CONTAINERS\")\n693\t # Remove empty entries\n694\t local -a filtered=()\n695\t for c in \"${mirror_own[@]}\"; do [[ -n \"$c\" ]] && filtered+=(\"$c\"); done\n696\t mirror_own=(\"${filtered[@]}\")\n697\t\n698\t if [[ ${#mirror_own[@]} -eq 0 ]]; then\n699\t log \"No PARTNERSHIP_OWN_CONTAINERS configured on $MIRROR — skipping remote stack restart\"\n700\t return 0\n701\t fi\n702\t\n703\t log \"Restarting own stack on $MIRROR: ${mirror_own[*]}\"\n704\t for container in \"${mirror_own[@]}\"; do\n705\t if [[ \"$DRY_RUN\" == true ]]; then\n706\t warn \"DRY RUN — would start $container on $MIRROR\"\n707\t continue\n708\t fi\n709\t timeout \"$SSH_TIMEOUT\" ssh -i \"$SSH_KEY\" \\\n710\t -o ConnectTimeout=\"$SSH_TIMEOUT\" root@\"$mirror_ip\" \\\n711\t \"docker start '$container' >/dev/null 2>&1 && echo started\" 2>/dev/null | \\\n712\t grep -q started && \\\n713\t log \"$container started on $MIRROR ✅\" || \\\n714\t warn \"$container failed to start on $MIRROR — check manually\"\n715\t done\n716\t}\n717\t\n718\t# Create the mirror's Emby admin account on the owner's deployed Emby.\n719\t# Credentials come from the MIRROR's conf (HOST*_PARTNERSHIP_EMBY_ADMIN_USER/PASS).\n720\t# Checks for username collision before creating — exits with guidance if taken.\n721\tprovision_emby_admin() {\n722\t local mirror_ip=\"$1\"\n723\t local emby_port=\"${PARTNERSHIP_EMBY_PORT:-8096}\"\n724\t local emby_url=\"http://${mirror_ip}:${emby_port}\"\n725\t\n726\t echo \"\"\n727\t echo \"━━━ $ICON_EMBY Emby Admin Provisioning ━━━\"\n728\t\n729\t if [[ \"${PARTNERSHIP_PROVISION_EMBY_ADMIN:-false}\" != true ]]; then\n730\t log \"PARTNERSHIP_PROVISION_EMBY_ADMIN=false — skipping\"\n731\t return 0\n732\t fi\n733\t\n734\t if [[ -z \"$EMBY_API_KEY\" ]]; then\n735\t warn \"EMBY_API_KEY not set — skipping Emby admin provisioning\"\n736\t return 1\n737\t fi\n738\t\n739\t # Read mirror's desired credentials from their own conf via SSH\n740\t log \"Reading Emby credentials from $MIRROR conf...\"\n741\t local username password\n742\t username=$(read_remote_conf_var \"$mirror_ip\" \"PARTNERSHIP_EMBY_ADMIN_USER\")\n743\t password=$(read_remote_conf_var \"$mirror_ip\" \"PARTNERSHIP_EMBY_ADMIN_PASS\")\n744\t [[ -z \"$username\" ]] && username=\"$(derive_short_name \"$MIRROR\")\"\n745\t\n746\t if [[ -z \"$password\" ]]; then\n747\t warn \"PARTNERSHIP_EMBY_ADMIN_PASS is empty in $MIRROR conf\"\n748\t warn \"$MIRROR must set HOST${MIRROR_ID: -1}_PARTNERSHIP_EMBY_ADMIN_PASS before onboard\"\n749\t return 1\n750\t fi\n751\t\n752\t if [[ \"$DRY_RUN\" == true ]]; then\n753\t warn \"DRY RUN — would create Emby admin '$username' at $emby_url\"\n754\t return 0\n755\t fi\n756\t\n757\t # Collision check — username already exists?\n758\t local existing_users\n759\t existing_users=$(curl -sf --max-time 15 \\\n760\t -H \"X-Emby-Authorization: MediaBrowser Token=\\\"$EMBY_API_KEY\\\"\" \\\n761\t \"${emby_url}/Users\" 2>/dev/null)\n762\t\n763\t if echo \"$existing_users\" | grep -q \"\\\"Name\\\":\\\"${username}\\\"\"; then\n764\t error \"Emby username '${username}' is already taken on the shared instance\"\n765\t error \"Options:\"\n766\t error \" 1. Sign in with that account — it may already be yours\"\n767\t error \" 2. Set a different name in ${user_var} and re-run --onboard\"\n768\t return 1\n769\t fi\n770\t\n771\t log \"Creating Emby admin '$username' at $emby_url...\"\n772\t\n773\t local create_response http_code body\n774\t create_response=$(curl -sf --max-time 15 -w \"\\n%{http_code}\" \\\n775\t -X POST \\\n776\t -H \"X-Emby-Authorization: MediaBrowser Token=\\\"$EMBY_API_KEY\\\"\" \\\n777\t -H \"Content-Type: application/json\" \\\n778\t -d \"{\\\"Name\\\": \\\"$username\\\"}\" \\\n779\t \"${emby_url}/Users/New\" 2>/dev/null)\n780\t http_code=$(echo \"$create_response\" | tail -1)\n781\t body=$(echo \"$create_response\" | head -n -1)\n782\t\n783\t if [[ \"$http_code\" != \"200\" ]] && [[ \"$http_code\" != \"204\" ]]; then\n784\t warn \"Failed to create Emby user '$username' (HTTP $http_code)\"\n785\t return 1\n786\t fi\n787\t\n788\t local user_id\n789\t user_id=$(echo \"$body\" | grep -o '\"Id\":\"[^\"]*\"' | head -1 | cut -d'\"' -f4)\n790\t if [[ -z \"$user_id\" ]]; then\n791\t warn \"Emby user created but could not parse user ID — set password manually\"\n792\t return 1\n793\t fi\n794\t\n795\t local pw_code\n796\t pw_code=$(curl -sf --max-time 15 -w \"%{http_code}\" -o /dev/null \\\n797\t -X POST \\\n798\t -H \"X-Emby-Authorization: MediaBrowser Token=\\\"$EMBY_API_KEY\\\"\" \\\n799\t -H \"Content-Type: application/json\" \\\n800\t -d \"{\\\"NewPw\\\": \\\"$password\\\"}\" \\\n801\t \"${emby_url}/Users/${user_id}/Password\" 2>/dev/null)\n802\t\n803\t if [[ \"$pw_code\" == \"200\" ]] || [[ \"$pw_code\" == \"204\" ]]; then\n804\t log \"Emby admin '$username' created (id: $user_id) ✅\"\n805\t else\n806\t warn \"User created but password set failed (HTTP $pw_code) — set password manually\"\n807\t fi\n808\t\n809\t curl -sf --max-time 15 -o /dev/null \\\n810\t -X POST \\\n811\t -H \"X-Emby-Authorization: MediaBrowser Token=\\\"$EMBY_API_KEY\\\"\" \\\n812\t -H \"Content-Type: application/json\" \\\n813\t -d '{\"IsAdministrator\": true, \"IsDisabled\": false}' \\\n814\t \"${emby_url}/Users/${user_id}/Policy\" 2>/dev/null && \\\n815\t log \"$username granted admin policy ✅\" || \\\n816\t warn \"Could not set admin policy — grant manually in Emby dashboard\"\n817\t}\n818\t\n819\t# Delete the mirror's Emby admin account from the owner's deployed Emby.\n820\trevoke_emby_admin() {\n821\t local mirror_ip=\"$1\"\n822\t local emby_port=\"${PARTNERSHIP_EMBY_PORT:-8096}\"\n823\t local emby_url=\"http://${mirror_ip}:${emby_port}\"\n824\t\n825\t echo \"\"\n826\t echo \"━━━ $ICON_EMBY Emby Admin Revocation ━━━\"\n827\t\n828\t local username\n829\t username=$(read_remote_conf_var \"$mirror_ip\" \"PARTNERSHIP_EMBY_ADMIN_USER\")\n830\t [[ -z \"$username\" ]] && username=\"$(derive_short_name \"$MIRROR\")\"\n831\t\n832\t if [[ \"${PARTNERSHIP_PROVISION_EMBY_ADMIN:-false}\" != true ]]; then\n833\t log \"PARTNERSHIP_PROVISION_EMBY_ADMIN=false — skipping\"\n834\t return 0\n835\t fi\n836\t\n837\t if [[ -z \"$EMBY_API_KEY\" ]]; then\n838\t warn \"EMBY_API_KEY not set — skipping Emby admin revocation\"\n839\t return 1\n840\t fi\n841\t\n842\t if [[ \"$DRY_RUN\" == true ]]; then\n843\t warn \"DRY RUN — would delete Emby admin '$username' at $emby_url\"\n844\t return 0\n845\t fi\n846\t\n847\t log \"Looking up Emby user '$username' at $emby_url...\"\n848\t\n849\t local users_json user_id\n850\t users_json=$(curl -sf --max-time 15 \\\n851\t -H \"X-Emby-Authorization: MediaBrowser Token=\\\"$EMBY_API_KEY\\\"\" \\\n852\t \"${emby_url}/Users\" 2>/dev/null)\n853\t\n854\t user_id=$(echo \"$users_json\" | \\\n855\t grep -o \"\\\"Id\\\":\\\"[^\\\"]*\\\"[^}]*\\\"Name\\\":\\\"${username}\\\"\" | \\\n856\t grep -o '\"Id\":\"[^\"]*\"' | cut -d'\"' -f4 | head -1)\n857\t\n858\t if [[ -z \"$user_id\" ]]; then\n859\t warn \"Emby user '$username' not found at $emby_url — may already be removed\"\n860\t return 0\n861\t fi\n862\t\n863\t local del_code\n864\t del_code=$(curl -sf --max-time 15 -w \"%{http_code}\" -o /dev/null \\\n865\t -X DELETE \\\n866\t -H \"X-Emby-Authorization: MediaBrowser Token=\\\"$EMBY_API_KEY\\\"\" \\\n867\t \"${emby_url}/Users/${user_id}\" 2>/dev/null)\n868\t\n869\t if [[ \"$del_code\" == \"200\" ]] || [[ \"$del_code\" == \"204\" ]] || [[ \"$del_code\" == \"404\" ]]; then\n870\t log \"Emby admin '$username' removed ✅\"\n871\t else\n872\t warn \"Failed to delete Emby user '$username' (HTTP $del_code) — remove manually\"\n873\t fi\n874\t}\n875\t\n876\tcheck_both_healthy() {\n877\t platform_storage_healthy || { error \"Local array not healthy\"; return 1; }\n878\t\n879\t local mirror_ip\n880\t mirror_ip=$(resolve_tailscale_ip \"$MIRROR\")\n881\t [[ -z \"$mirror_ip\" ]] && { error \"Cannot resolve $MIRROR Tailscale IP\"; return 1; }\n882\t\n883\t timeout \"$SSH_TIMEOUT\" ssh -i \"$MIRROR_SSH_KEY\" \\\n884\t -o ConnectTimeout=\"$SSH_TIMEOUT\" root@\"$mirror_ip\" \\\n885\t \"mountpoint -q '$REMOTE_STORAGE_PATH' && timeout 10 docker ps\" >/dev/null 2>&1 || {\n886\t error \"Mirror $MIRROR not healthy\"\n887\t return 1\n888\t }\n889\t return 0\n890\t}\n891\t\n892\tdo_final_sync() {\n893\t log \"Running final critical sync...\"\n894\t if [[ \"$DRY_RUN\" == false ]]; then\n895\t if [[ \"${#CRITICAL_SYNC_SHARES[@]}\" -gt 0 ]]; then\n896\t for _share in \"${CRITICAL_SYNC_SHARES[@]}\"; do\n897\t [[ -z \"$_share\" ]] && continue\n898\t local _path=\"${_share%%|*}\"\n899\t local _profile=\"${_share##*|}\"\n900\t if [[ \"$_path\" == \"$_profile\" ]]; then\n901\t bash \"$SCRIPT_DIR/../Rsync/rsync.sh\" \"$_path\" --log\n902\t else\n903\t bash \"$SCRIPT_DIR/../Rsync/rsync.sh\" \"$_path\" \\\n904\t --profile=\"$_profile\" --log\n905\t fi\n906\t done\n907\t else\n908\t warn \"CRITICAL_SYNC_SHARES is empty — skipping final sync (configure in host*.conf)\"\n909\t fi\n910\t else\n911\t warn \"DRY RUN — would run final critical sync (${#CRITICAL_SYNC_SHARES[@]:-hardcoded} shares)\"\n912\t fi\n913\t warn \"Final sync complete — mirror has current state ✅\"\n914\t}\n915\t\n916\t# Safe master.conf modification with error handling\n917\tupdate_master_conf() {\n918\t local key=\"$1\" value=\"$2\"\n919\t local conf=\"$SCRIPT_DIR/../master.conf\"\n920\t if [[ ! -f \"$conf\" ]]; then\n921\t error \"master.conf not found at $conf\"\n922\t return 1\n923\t fi\n924\t if sed -i \"s|^[[:space:]]*${key}=.*| ${key}=${value}|\" \"$conf\" 2>/dev/null; then\n925\t log \"master.conf updated: ${key}=${value}\"\n926\t return 0\n927\t else\n928\t error \"Failed to update master.conf: ${key}=${value}\"\n929\t return 1\n930\t fi\n931\t}\n932\t\n933\t# ── Library-mode guard — source only, skip all mode dispatch ─────────────────────────────────\n934\t# partnership_offboard.sh sources this file with PARTNERSHIP_LIB_MODE=1 to get helper\n935\t# functions without triggering any mode execution.\n936\t[[ \"${PARTNERSHIP_LIB_MODE:-}\" == \"1\" ]] && { return 0 2>/dev/null || exit 0; }\n937\t\n938\t# ==============================================================================================\n939\t# ━━━ Unblock ━━━\n940\t# ==============================================================================================\n941\tif [[ \"$MODE\" == \"unblock\" ]]; then\n942\t if [[ -z \"$UNBLOCK_HOST\" ]]; then\n943\t error \"Usage: partnership_manager.sh --unblock <hostname>\"\n944\t error \"Example: partnership_manager.sh --unblock unRAID-Jayred365\"\n945\t if [[ -f \"$BLOCKLIST_FILE\" ]] && [[ -s \"$BLOCKLIST_FILE\" ]]; then\n946\t echo \"\"\n947\t echo \"Currently blocklisted:\"\n948\t while IFS='|' read -r host ts reason; do\n949\t echo \" $host (blocked $ts — $reason)\"\n950\t done < \"$BLOCKLIST_FILE\"\n951\t else\n952\t echo \"Blocklist is empty.\"\n953\t fi\n954\t exit 1\n955\t fi\n956\t\n957\t if ! is_blocklisted \"$UNBLOCK_HOST\"; then\n958\t warn \"$UNBLOCK_HOST is not on the blocklist\"\n959\t exit 0\n960\t fi\n961\t\n962\t if [[ \"$DRY_RUN\" == true ]]; then\n963\t warn \"DRY RUN — would remove $UNBLOCK_HOST from blocklist\"\n964\t exit 0\n965\t fi\n966\t\n967\t remove_from_blocklist \"$UNBLOCK_HOST\"\n968\t warn \"$UNBLOCK_HOST unblocked — re-onboarding is now permitted ✅\"\n969\t exit 0\n970\tfi\n971\t\n972\t# ==============================================================================================\n973\t# ━━━ Status ━━━\n974\t# ==============================================================================================\n975\tif [[ \"$MODE\" == \"status\" ]]; then\n976\t echo \"\"\n977\t echo \"━━━━━ $ICON_SUMMARY PARTNERSHIP STATUS ━━━━━\"\n978\t OWNER_IP=$(resolve_tailscale_ip \"$OWNER\" || echo \"unreachable\")\n979\t MIRROR_IP=$(resolve_tailscale_ip \"$MIRROR\" || echo \"unreachable\")\n980\t echo \" $ICON_HOST My ID: $MY_ID ($LOCAL_SERVER_NAME)\"\n981\t echo \" Owner: $OWNER_ID ($OWNER — $OWNER_IP)\"\n982\t echo \" Mirror: $MIRROR_ID ($MIRROR — $MIRROR_IP)\"\n983\t echo \" Role: $( [[ \"$AM_OWNER\" == true ]] && echo \"OWNER\" || echo \"MIRROR\" )\"\n984\t echo \" Enabled: ${PARTNERSHIP_ENABLED:-false}\"\n985\t echo \"\"\n986\t\n987\t # Local state\n988\t if [[ -f \"$LOCAL_STATE_FILE\" ]]; then\n989\t LOCAL_STATE=$(read_state_file \"$LOCAL_STATE_FILE\" \"state\")\n990\t LOCAL_ONBOARDED=$(read_state_file \"$LOCAL_STATE_FILE\" \"onboarded\")\n991\t LOCAL_OFFBOARDED=$(read_state_file \"$LOCAL_STATE_FILE\" \"offboarded\")\n992\t LOCAL_LAST_SEEN=$(read_state_file \"$LOCAL_STATE_FILE\" \"last_seen_remote\")\n993\t echo \" Local state: $LOCAL_STATE\"\n994\t [[ -n \"$LOCAL_ONBOARDED\" ]] && echo \" Onboarded: $LOCAL_ONBOARDED\"\n995\t [[ -n \"$LOCAL_OFFBOARDED\" ]] && echo \" Offboarded: $LOCAL_OFFBOARDED\"\n996\t [[ -n \"$LOCAL_LAST_SEEN\" ]] && echo \" Remote seen: $LOCAL_LAST_SEEN\"\n997\t else\n998\t echo \" Local state: no state file found\"\n999\t fi\n1000\t\n1001\t echo \"\"\n1002\t\n1003\t # Remote state\n1004\t REMOTE_IP=$(resolve_tailscale_ip \"$REMOTE_SERVER_NAME\")\n1005\t if [[ -n \"$REMOTE_IP\" ]]; then\n1006\t REMOTE_CONTENT=$(read_remote_state \"$REMOTE_IP\" \"$SSH_KEY\" \"$REMOTE_STATE_FILE\")\n1007\t if [[ -n \"$REMOTE_CONTENT\" ]]; then\n1008\t REMOTE_STATE=$(echo \"$REMOTE_CONTENT\" | grep \"^state=\" | cut -d= -f2)\n1009\t REMOTE_ONBOARDED=$(echo \"$REMOTE_CONTENT\" | grep \"^onboarded=\" | cut -d= -f2)\n1010\t echo \" Remote state: $REMOTE_STATE\"\n1011\t [[ -n \"$REMOTE_ONBOARDED\" ]] && echo \" Onboarded: $REMOTE_ONBOARDED\"\n1012\t if [[ \"$LOCAL_STATE\" == \"$REMOTE_STATE\" ]]; then\n1013\t echo \"\"\n1014\t echo \" ✅ Both servers agree: $LOCAL_STATE\"\n1015\t else\n1016\t echo \"\"\n1017\t echo \" ⚠️ State mismatch — local: $LOCAL_STATE remote: $REMOTE_STATE\"\n1018\t fi\n1019\t else\n1020\t echo \" Remote state: not found on $REMOTE_SERVER_NAME\"\n1021\t fi\n1022\t else\n1023\t echo \" Remote state: $REMOTE_SERVER_NAME unreachable\"\n1024\t fi\n1025\t\n1026\t echo \"\"\n1027\t echo \" Auth WebUIs:\"\n1028\t for entry in \"${PARTNERSHIP_AUTH_WEBUIS[@]}\"; do\n1029\t [[ -z \"$entry\" ]] && continue\n1030\t echo \" ${entry%%|*} → port ${entry##*|}\"\n1031\t done\n1032\t\n1033\t if [[ -f \"$OFFLINE_COUNTER\" ]]; then\n1034\t OFFLINE_DAYS=$(cat \"$OFFLINE_COUNTER\" 2>/dev/null || echo 0)\n1035\t [[ \"$OFFLINE_DAYS\" -gt 0 ]] && \\\n1036\t echo \"\" && \\\n1037\t echo \" ⚠️ Remote offline counter: ${OFFLINE_DAYS}/${PARTNERSHIP_OFFLINE_THRESHOLD}\"\n1038\t fi\n1039\t\n1040\t echo \"\"\n1041\t if [[ -f \"$BLOCKLIST_FILE\" ]] && [[ -s \"$BLOCKLIST_FILE\" ]]; then\n1042\t echo \" Blocklist:\"\n1043\t while IFS='|' read -r host ts reason; do\n1044\t echo \" ⛔ $host (blocked $ts — $reason)\"\n1045\t done < \"$BLOCKLIST_FILE\"\n1046\t else\n1047\t echo \" Blocklist: empty\"\n1048\t fi\n1049\t\n1050\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n1051\t exit 0\n1052\tfi\n1053\t\n1054\t# ==============================================================================================\n1055\t# ━━━ Check ━━━\n1056\t# Called every 30min by critical_sync_maintenance.sh — must be silent when healthy\n1057\t# ==============================================================================================\n1058\tif [[ \"$MODE\" == \"check\" ]]; then\n1059\t\n1060\t # Update last_seen_remote and offline counter based on rsync outcome\n1061\t if [[ \"$REMOTE_SEEN\" == true ]]; then\n1062\t echo \"0\" > \"$OFFLINE_COUNTER\"\n1063\t if [[ -f \"$LOCAL_STATE_FILE\" ]]; then\n1064\t sed -i \"s|^last_seen_remote=.*|last_seen_remote=$(date '+%Y-%m-%d %H:%M:%S')|\" \\\n1065\t \"$LOCAL_STATE_FILE\" 2>/dev/null\n1066\t fi\n1067\t elif [[ \"$REMOTE_UNSEEN\" == true ]]; then\n1068\t OFFLINE_COUNT=$(cat \"$OFFLINE_COUNTER\" 2>/dev/null || echo 0)\n1069\t OFFLINE_COUNT=$(( OFFLINE_COUNT + 1 ))\n1070\t echo \"$OFFLINE_COUNT\" > \"$OFFLINE_COUNTER\"\n1071\t\n1072\t # Auto-offboard threshold: threshold_days × 48 intervals/day (every 30min)\n1073\t THRESHOLD_INTERVALS=$(( ${PARTNERSHIP_OFFLINE_THRESHOLD:-30} * 48 ))\n1074\t if [[ \"$OFFLINE_COUNT\" -ge \"$THRESHOLD_INTERVALS\" ]]; then\n1075\t warn \"Remote offline for ${PARTNERSHIP_OFFLINE_THRESHOLD} days — triggering auto-offboard\"\n1076\t notify \"Partnership auto-offboard on $(hostname) — $REMOTE_SERVER_NAME offline for ${PARTNERSHIP_OFFLINE_THRESHOLD} days\" \\\n1077\t \"Partnership\" \"warning\"\n1078\t bash \"$0\" --offboard --reason=auto-offboard-timeout\n1079\t exit 0\n1080\t fi\n1081\t log \"Partnership check — remote unseen ($OFFLINE_COUNT/$THRESHOLD_INTERVALS)\"\n1082\t fi\n1083\t\n1084\t # Skip blocklisted partners — they can't auto-reconnect; only --onboard re-establishes\n1085\t if is_blocklisted \"$REMOTE_SERVER_NAME\"; then\n1086\t log \"Partnership check — $REMOTE_SERVER_NAME is blocklisted, skipping\"\n1087\t exit 0\n1088\t fi\n1089\t\n1090\t # Read remote state file\n1091\t REMOTE_IP=$(resolve_tailscale_ip \"$REMOTE_SERVER_NAME\")\n1092\t if [[ -z \"$REMOTE_IP\" ]]; then\n1093\t log \"Partnership check — remote unreachable, skipping state check\"\n1094\t exit 0\n1095\t fi\n1096\t\n1097\t REMOTE_CONTENT=$(read_remote_state \"$REMOTE_IP\" \"$SSH_KEY\" \"$REMOTE_STATE_FILE\")\n1098\t if [[ -z \"$REMOTE_CONTENT\" ]]; then\n1099\t # IP resolved but SSH returned nothing — could be auth failure, not just missing file\n1100\t if [[ -f \"$SCRIPT_DIR/ssh_setup.sh\" ]]; then\n1101\t bash \"$SCRIPT_DIR/ssh_setup.sh\" --validate 2>/dev/null || true\n1102\t fi\n1103\t log \"Partnership check — remote state file not found\"\n1104\t exit 0\n1105\t fi\n1106\t\n1107\t REMOTE_STATE=$(echo \"$REMOTE_CONTENT\" | grep \"^state=\" | cut -d= -f2)\n1108\t LOCAL_STATE=$(read_state_file \"$LOCAL_STATE_FILE\" \"state\" 2>/dev/null || echo \"UNKNOWN\")\n1109\t\n1110\t # Both agree and active — healthy, silent\n1111\t if [[ \"$LOCAL_STATE\" == \"$REMOTE_STATE\" ]] && [[ \"$LOCAL_STATE\" == \"ACTIVE\" ]]; then\n1112\t echo \"Partnership check — ACTIVE, both servers agree ✅\"\n1113\t exit 0\n1114\t fi\n1115\t\n1116\t # Remote requested offboard — run full offboard in background so cron is not blocked\n1117\t if [[ \"$REMOTE_STATE\" == \"INACTIVE\" ]] && [[ \"$LOCAL_STATE\" == \"ACTIVE\" ]]; then\n1118\t warn \"Partnership check — $REMOTE_SERVER_NAME requested offboard\"\n1119\t if [[ \"$AM_OWNER\" == true ]]; then\n1120\t warn \"Owner finalising offboard — spawning partnership_offboard.sh in background\"\n1121\t nohup bash \"$SCRIPT_DIR/partnership_offboard.sh\" \\\n1122\t --reason=mirror-requested \\\n1123\t >> /var/log/partnership_offboard.log 2>&1 &\n1124\t warn \"Offboard PID $! running — tail /var/log/partnership_offboard.log to follow\"\n1125\t else\n1126\t warn \"Owner offboarded — spawning mirror-side cleanup in background\"\n1127\t nohup bash \"$SCRIPT_DIR/partnership_offboard.sh\" \\\n1128\t --reason=owner-offboarded \\\n1129\t >> /var/log/partnership_offboard.log 2>&1 &\n1130\t warn \"Offboard PID $! running — tail /var/log/partnership_offboard.log to follow\"\n1131\t fi\n1132\t exit 0\n1133\t fi\n1134\t\n1135\t # Both inactive — nothing to do\n1136\t if [[ \"$LOCAL_STATE\" == \"INACTIVE\" ]] && [[ \"$REMOTE_STATE\" == \"INACTIVE\" ]]; then\n1137\t echo \"Partnership check — INACTIVE on both servers\"\n1138\t exit 0\n1139\t fi\n1140\t\n1141\t log \"Partnership check — local: $LOCAL_STATE remote: $REMOTE_STATE\"\n1142\t exit 0\n1143\tfi\n1144\t\n1145\t# ==============================================================================================\n1146\t# ━━━ Onboard ━━━\n1147\t# ==============================================================================================\n1148\tif [[ \"$MODE\" == \"onboard\" ]]; then\n1149\t echo \"\"\n1150\t echo \"━━━ $ICON_FALLBACK Onboard — $(date '+%Y-%m-%d %H:%M:%S') ━━━\"\n1151\t\n1152\t # ── LOCAL-ONLY PATH ──────────────────────────────────────────────────────────\n1153\t # HOST1-local setup steps that don't need HOST2 present. Called from\n1154\t # partnership_onboard.sh --phase1-only so HOST1 can complete its own side\n1155\t # (PARTNERSHIP_ENABLED flag, setup.db) while waiting for HOST2 to install and onboard.\n1156\t if [[ \"$LOCAL_ONLY\" == true ]]; then\n1157\t log \"Mode: local-only — skipping remote pre-flight and WebUI steps\"\n1158\t log \"Owner: $OWNER_ID ($OWNER) · Mirror: $MIRROR_ID ($MIRROR)\"\n1159\t echo \"\"\n1160\t\n1161\t # Enable partnership in master.conf + push to all hosts\n1162\t echo \"\"\n1163\t echo \"Enabling partnership in master.conf...\"\n1164\t if [[ \"$DRY_RUN\" == false ]]; then\n1165\t local _master_conf=\"${SCRIPTS_ROOT:-$(dirname \"$SCRIPT_DIR\")}/master.conf\"\n1166\t if grep -q \"^[[:space:]]*PARTNERSHIP_ENABLED=\" \"$_master_conf\" 2>/dev/null; then\n1167\t sed -i \"s|^[[:space:]]*PARTNERSHIP_ENABLED=.*|PARTNERSHIP_ENABLED=true|\" \"$_master_conf\"\n1168\t else\n1169\t echo \"PARTNERSHIP_ENABLED=true\" >> \"$_master_conf\"\n1170\t fi\n1171\t log \"PARTNERSHIP_ENABLED=true in master.conf ✅\"\n1172\t platform_push_conf | while IFS= read -r line; do log \"$line\"; done\n1173\t else\n1174\t warn \"DRY RUN — would set PARTNERSHIP_ENABLED=true in master.conf and push\"\n1175\t fi\n1176\t\n1177\t # Write HOST1_LOCAL_DONE flag to setup.db\n1178\t local_state_file=\"$(platform_setup_db_path)\"\n1179\t if [[ \"$DRY_RUN\" == false ]]; then\n1180\t flag_key=\"${MY_ID}_LOCAL_DONE\"\n1181\t if grep -q \"^${flag_key}=\" \"$local_state_file\" 2>/dev/null; then\n1182\t sed -i \"s|^${flag_key}=.*|${flag_key}=true|\" \"$local_state_file\"\n1183\t else\n1184\t echo \"${flag_key}=true\" >> \"$local_state_file\"\n1185\t fi\n1186\t platform_push_setup_state\n1187\t log \"${MY_ID}_LOCAL_DONE=true written to setup.db ✅\"\n1188\t else\n1189\t warn \"DRY RUN — would write ${MY_ID}_LOCAL_DONE=true\"\n1190\t fi\n1191\t\n1192\t echo \"\"\n1193\t echo \"HOST1 local setup complete. Waiting for $MIRROR_ID ($MIRROR) to onboard.\"\n1194\t exit 0\n1195\t fi\n1196\t\n1197\t # ── FULL ONBOARD PATH (requires HOST2) ────────────────────────────────────\n1198\t\n1199\t # Check already onboarded\n1200\t if [[ -f \"$LOCAL_STATE_FILE\" ]]; then\n1201\t CURRENT_STATE=$(read_state_file \"$LOCAL_STATE_FILE\" \"state\")\n1202\t if [[ \"$CURRENT_STATE\" == \"ACTIVE\" ]]; then\n1203\t warn \"Partnership already ACTIVE — use --status for detail or --offboard to separate\"\n1204\t exit 0\n1205\t fi\n1206\t fi\n1207\t\n1208\t # Warn if re-onboarding a previously blocked partner — onboard is deliberate so it proceeds\n1209\t if is_blocklisted \"$MIRROR\"; then\n1210\t warn \"$MIRROR is on the blocklist from a previous offboard\"\n1211\t warn \"Proceeding — blocklist will be cleared on successful onboard\"\n1212\t fi\n1213\t\n1214\t log \"Owner: $OWNER_ID ($OWNER)\"\n1215\t log \"Mirror: $MIRROR_ID ($MIRROR)\"\n1216\t\n1217\t # Pre-flight\n1218\t echo \"\"\n1219\t echo \"━━━ $ICON_SHIELD Pre-flight ━━━\"\n1220\t\n1221\t resolve_remote_ip\n1222\t check_connectivity\n1223\t\n1224\t # Version parity — both servers must agree on unRAID version\n1225\t check_os_version_parity || exit 1\n1226\t\n1227\t # Remote array and Docker daemon\n1228\t check_remote_array || exit 1\n1229\t check_remote_docker_daemon || exit 1\n1230\t\n1231\t OWNER_IP=$(resolve_tailscale_ip \"$OWNER\")\n1232\t MIRROR_IP=$(resolve_tailscale_ip \"$MIRROR\")\n1233\t\n1234\t [[ -z \"$OWNER_IP\" ]] && { error \"Cannot resolve $OWNER Tailscale IP\"; exit 1; }\n1235\t [[ -z \"$MIRROR_IP\" ]] && { error \"Cannot resolve $MIRROR Tailscale IP\"; exit 1; }\n1236\t\n1237\t log \"Owner IP: $OWNER_IP\"\n1238\t log \"Mirror IP: $MIRROR_IP\"\n1239\t\n1240\t # Reconfigure mirror WebUIs → owner IP\n1241\t echo \"\"\n1242\t echo \"━━━ $ICON_CONTAINERS Reconfigure Mirror WebUIs → $OWNER_IP ━━━\"\n1243\t\n1244\t WEBUI_FAILURES=0\n1245\t for entry in \"${PARTNERSHIP_AUTH_WEBUIS[@]}\"; do\n1246\t [[ -z \"$entry\" ]] && continue\n1247\t container=\"${entry%%|*}\"\n1248\t port=\"${entry##*|}\"\n1249\t reconfigure_webui \"$container\" \"$port\" \"$OWNER_IP\" \\\n1250\t \"$MIRROR_SSH_KEY\" \"$MIRROR_IP\" \"$MIRROR\" || (( WEBUI_FAILURES++ ))\n1251\t done\n1252\t\n1253\t # Verify WebUI connectivity\n1254\t if [[ \"${PARTNERSHIP_ONBOARD_VERIFY:-true}\" == true ]]; then\n1255\t echo \"\"\n1256\t echo \"━━━ $ICON_VERIFY WebUI Connectivity ━━━\"\n1257\t for entry in \"${PARTNERSHIP_AUTH_WEBUIS[@]}\"; do\n1258\t [[ -z \"$entry\" ]] && continue\n1259\t container=\"${entry%%|*}\"\n1260\t port=\"${entry##*|}\"\n1261\t if curl -sf --max-time 10 \"http://${OWNER_IP}:${port}/\" >/dev/null 2>&1; then\n1262\t log \"$container reachable at http://${OWNER_IP}:${port}/ ✅\"\n1263\t else\n1264\t warn \"$container not reachable at http://${OWNER_IP}:${port}/ — may not be running\"\n1265\t fi\n1266\t done\n1267\t fi\n1268\t\n1269\t # Write state files\n1270\t echo \"\"\n1271\t echo \"━━━ $ICON_GEAR Write State ━━━\"\n1272\t NOW=$(date '+%Y-%m-%d %H:%M:%S')\n1273\t\n1274\t if [[ \"$DRY_RUN\" == false ]]; then\n1275\t write_state_file \"$LOCAL_STATE_FILE\" \"ACTIVE\" \"$NOW\" \"\" \"$LOCAL_SERVER_NAME\" \"onboard\"\n1276\t echo \"Local state: ACTIVE ✅\"\n1277\t remove_from_blocklist \"$MIRROR\"\n1278\t push_state_to_remote \"$LOCAL_STATE_FILE\" \"$MIRROR_IP\" \"$MIRROR_SSH_KEY\"\n1279\t echo \"0\" > \"$OFFLINE_COUNTER\"\n1280\t else\n1281\t warn \"DRY RUN — would write ACTIVE state and push to remote\"\n1282\t fi\n1283\t\n1284\t # Emby admin provisioning — runs after container deployment (deploy step not yet built)\n1285\t provision_emby_admin \"$MIRROR_IP\"\n1286\t\n1287\t # Summary\n1288\t echo \"\"\n1289\t echo \"━━━━━ $ICON_SUMMARY ONBOARD SUMMARY ━━━━━\"\n1290\t echo \" Owner: $OWNER_ID ($OWNER_IP)\"\n1291\t echo \" Mirror: $MIRROR_ID ($MIRROR_IP)\"\n1292\t echo \" WebUI failures: $WEBUI_FAILURES\"\n1293\t echo \" Sync interval: ${PARTNERSHIP_SYNC_INTERVAL}min\"\n1294\t echo \"\"\n1295\t if [[ \"$DRY_RUN\" == true ]]; then\n1296\t warn \"DRY RUN — no changes made\"\n1297\t elif [[ \"$WEBUI_FAILURES\" -eq 0 ]]; then\n1298\t warn \"$ICON_DONE DONE — $MIRROR is now mirroring $OWNER via Tailscale ✅\"\n1299\t [[ \"${PARTNERSHIP_ONBOARD_NOTIFY:-true}\" == true ]] && \\\n1300\t notify \"Partnership onboard complete — $MIRROR is now mirroring $OWNER via Tailscale\" \\\n1301\t \"Partnership\" \"normal\"\n1302\t else\n1303\t warn \"DONE with $WEBUI_FAILURES WebUI warning(s) — check manually\"\n1304\t notify \"Partnership onboard complete with $WEBUI_FAILURES WebUI warning(s) on $(hostname)\" \\\n1305\t \"Partnership\" \"warning\"\n1306\t fi\n1307\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n1308\t exit 0\n1309\tfi\n1310\t\n1311\t# ==============================================================================================\n1312\t# ━━━ Offboard ━━━\n1313\t# ==============================================================================================\n1314\tif [[ \"$MODE\" == \"offboard\" ]]; then\n1315\t exec bash \"$SCRIPT_DIR/partnership_offboard.sh\" \"${FILTERED_ARGS[@]}\" --reason=\"$REASON\"\n1316\tfi\n1317\t\n1318\t# ==============================================================================================\n1319\t# ━━━ Transfer ━━━\n1320\t# ==============================================================================================\n1321\tif [[ \"$MODE\" == \"transfer\" ]]; then\n1322\t exec bash \"$SCRIPT_DIR/partnership_transfer.sh\" \\\n1323\t ${TRANSFER_CONFIRM_INPUT:+--confirm=\"$TRANSFER_CONFIRM_INPUT\"} \\\n1324\t \"${FILTERED_ARGS[@]}\"\n1325\tfi\n1326\t\n1327\t# (legacy inline transfer block — kept below for reference, unreachable)\n1328\tif false; then\n1329\t echo \"\"\n1330\t echo \"━━━ $ICON_FALLBACK Transfer Ownership — $(date '+%Y-%m-%d %H:%M:%S') ━━━\"\n1331\t echo \"\"\n1332\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n1333\t echo \"⚠️ WARNING — OWNERSHIP TRANSFER\"\n1334\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n1335\t echo \" Current owner: $OWNER_ID ($OWNER)\"\n1336\t echo \" Current mirror: $MIRROR_ID ($MIRROR)\"\n1337\t echo \"\"\n1338\t echo \" After transfer:\"\n1339\t echo \" New owner: $MIRROR_ID ($MIRROR)\"\n1340\t echo \" New mirror: $OWNER_ID ($OWNER)\"\n1341\t echo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n1342\t\n1343\t # Confirmation check\n1344\t if [[ \"$DRY_RUN\" == false ]]; then\n1345\t if [[ -z \"$TRANSFER_CONFIRM_INPUT\" ]]; then\n1346\t echo \"\"\n1347\t echo \"To proceed pass exactly:\"\n1348\t echo \" --confirm=${PARTNERSHIP_TRANSFER_CONFIRM}\"\n1349\t echo \"\"\n1350\t error \"Transfer cancelled — confirmation required\"\n1351\t exit 1\n1352\t fi\n1353\t if [[ \"$TRANSFER_CONFIRM_INPUT\" != \"$PARTNERSHIP_TRANSFER_CONFIRM\" ]]; then\n1354\t error \"Confirmation string does not match — transfer cancelled\"\n1355\t exit 1\n1356\t fi\n1357\t log \"Confirmation accepted\"\n1358\t else\n1359\t warn \"DRY RUN — confirmation check skipped\"\n1360\t fi\n1361\t\n1362\t # Health strike system\n1363\t echo \"\"\n1364\t echo \"━━━ $ICON_SHIELD Health Verification ━━━\"\n1365\t log \"Both servers must pass ${PARTNERSHIP_TRANSFER_STRIKES} consecutive health checks\"\n1366\t\n1367\t STRIKES=0\n1368\t ATTEMPTS=0\n1369\t MAX_ATTEMPTS=\"${PARTNERSHIP_TRANSFER_MAX_ATTEMPTS:-20}\"\n1370\t\n1371\t while [[ \"$STRIKES\" -lt \"$PARTNERSHIP_TRANSFER_STRIKES\" ]]; do\n1372\t (( ATTEMPTS++ ))\n1373\t if [[ \"$ATTEMPTS\" -gt \"$MAX_ATTEMPTS\" ]]; then\n1374\t error \"Health checks failed after $MAX_ATTEMPTS attempts — servers not stable\"\n1375\t error \"Transfer cancelled — try again when both servers are healthy\"\n1376\t exit 1\n1377\t fi\n1378\t\n1379\t if check_both_healthy; then\n1380\t (( STRIKES++ ))\n1381\t log \"Health check passed ($STRIKES/${PARTNERSHIP_TRANSFER_STRIKES})\"\n1382\t [[ \"$STRIKES\" -lt \"$PARTNERSHIP_TRANSFER_STRIKES\" ]] && sleep 10\n1383\t else\n1384\t warn \"Health check failed — resetting (attempt $ATTEMPTS/$MAX_ATTEMPTS)\"\n1385\t STRIKES=0\n1386\t sleep 30\n1387\t fi\n1388\t done\n1389\t warn \"Both servers healthy — proceeding with transfer\"\n1390\t\n1391\t NEW_OWNER_ID=\"$MIRROR_ID\"\n1392\t NEW_MIRROR_ID=\"$OWNER_ID\"\n1393\t NEW_OWNER=\"$MIRROR\"\n1394\t NEW_MIRROR=\"$OWNER\"\n1395\t NEW_OWNER_SSH_KEY_VAR=\"${NEW_OWNER_ID}_SSH_KEY\"\n1396\t NEW_MIRROR_SSH_KEY_VAR=\"${NEW_MIRROR_ID}_SSH_KEY\"\n1397\t NEW_OWNER_SSH_KEY=\"${!NEW_OWNER_SSH_KEY_VAR}\"\n1398\t NEW_MIRROR_SSH_KEY=\"${!NEW_MIRROR_SSH_KEY_VAR}\"\n1399\t\n1400\t NEW_OWNER_IP=$(resolve_tailscale_ip \"$NEW_OWNER\")\n1401\t NEW_MIRROR_IP=$(resolve_tailscale_ip \"$NEW_MIRROR\")\n1402\t\n1403\t [[ -z \"$NEW_OWNER_IP\" ]] && { error \"Cannot resolve new owner Tailscale IP\"; exit 1; }\n1404\t [[ -z \"$NEW_MIRROR_IP\" ]] && { error \"Cannot resolve new mirror Tailscale IP\"; exit 1; }\n1405\t\n1406\t # Final sync in current direction\n1407\t echo \"\"\n1408\t echo \"━━━ $ICON_SYNC Pre-transfer Sync ━━━\"\n1409\t do_final_sync\n1410\t\n1411\t # Reconfigure new mirror WebUIs → new owner\n1412\t echo \"\"\n1413\t echo \"━━━ $ICON_CONTAINERS Reconfigure WebUIs ━━━\"\n1414\t log \"New mirror ($NEW_MIRROR) WebUIs → new owner ($NEW_OWNER_IP)\"\n1415\t\n1416\t WEBUI_FAILURES=0\n1417\t for entry in \"${PARTNERSHIP_AUTH_WEBUIS[@]}\"; do\n1418\t [[ -z \"$entry\" ]] && continue\n1419\t container=\"${entry%%|*}\"\n1420\t port=\"${entry##*|}\"\n1421\t reconfigure_webui \"$container\" \"$port\" \"$NEW_OWNER_IP\" \\\n1422\t \"$NEW_MIRROR_SSH_KEY\" \"$NEW_MIRROR_IP\" \"$NEW_MIRROR\" || (( WEBUI_FAILURES++ ))\n1423\t done\n1424\t\n1425\t # New owner WebUIs → localhost (now manages directly)\n1426\t log \"New owner ($NEW_OWNER) WebUIs → localhost\"\n1427\t reconfigure_local_webuis \"localhost\"\n1428\t\n1429\t # Flip PARTNERSHIP_OWNER_HOST in master.conf on both servers\n1430\t echo \"\"\n1431\t echo \"━━━ $ICON_GEAR Update Ownership ━━━\"\n1432\t\n1433\t if [[ \"$DRY_RUN\" == false ]]; then\n1434\t update_master_conf \"PARTNERSHIP_OWNER_HOST\" \"\\\"$NEW_OWNER_ID\\\"\"\n1435\t\n1436\t # Update remote master.conf\n1437\t timeout \"$SSH_TIMEOUT\" ssh -i \"$NEW_MIRROR_SSH_KEY\" \\\n1438\t -o ConnectTimeout=\"$SSH_TIMEOUT\" root@\"$NEW_MIRROR_IP\" \\\n1439\t \"sed -i 's|^[[:space:]]*PARTNERSHIP_OWNER_HOST=.*| PARTNERSHIP_OWNER_HOST=\\\"$NEW_OWNER_ID\\\"|' \\\n1440\t '$SCRIPT_DIR/../master.conf'\" 2>/dev/null && \\\n1441\t log \"Remote master.conf updated ✅\" || \\\n1442\t error \"Failed to update remote master.conf — update manually\"\n1443\t else\n1444\t warn \"DRY RUN — would set PARTNERSHIP_OWNER_HOST=$NEW_OWNER_ID on both servers\"\n1445\t fi\n1446\t\n1447\t # Write state files\n1448\t NOW=$(date '+%Y-%m-%d %H:%M:%S')\n1449\t write_state_file \"$LOCAL_STATE_FILE\" \"ACTIVE\" \"$NOW\" \"\" \"$LOCAL_SERVER_NAME\" \"transfer\"\n1450\t push_state_to_remote \"$LOCAL_STATE_FILE\" \"$NEW_MIRROR_IP\" \"$NEW_MIRROR_SSH_KEY\"\n1451\t\n1452\t # Summary\n1453\t echo \"\"\n1454\t echo \"━━━━━ $ICON_SUMMARY TRANSFER SUMMARY ━━━━━\"\n1455\t echo \" New owner: $NEW_OWNER_ID ($NEW_OWNER_IP)\"\n1456\t echo \" New mirror: $NEW_MIRROR_ID ($NEW_MIRROR_IP)\"\n1457\t echo \" WebUI failures: $WEBUI_FAILURES\"\n1458\t echo \" Sync direction: $NEW_OWNER → $NEW_MIRROR\"\n1459\t echo \" Owner host: PARTNERSHIP_OWNER_HOST=$NEW_OWNER_ID\"\n1460\t echo \"\"\n1461\t if [[ \"$DRY_RUN\" == true ]]; then\n1462\t warn \"DRY RUN — no changes made\"\n1463\t else\n1464\t warn \"$ICON_DONE DONE — ownership transferred to $NEW_OWNER_ID ✅\"\n1465\t notify \"Partnership ownership transferred — new owner: $NEW_OWNER ($NEW_OWNER_ID)\" \\\n1466\t \"Partnership\" \"normal\"\n1467\t fi\n1468\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n1469\t exit 0\n1470\tfi"}]},"uuid":"e82bf84c-1909-4bc7-86cc-ab88f69b0c1f","timestamp":"2026-06-14T05:32:22.237Z","sourceToolAssistantUUID":"044fc2ca-e635-4fbc-ab5b-8dbb4faea597","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"26379e55-3d87-450d-b18b-bc401ca57795","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"a41f0ff6991d1474c","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GRddermTzQs5Q1YdqkXEv1","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ============================= Partnership Offboard ===========================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Cleanly ends a partnership. Role is detected automatically — run on either server.\n9\t# Owner path runs the full sequence including remote cleanup and final sync.\n10\t# Mirror path handles the local side and signals the owner to complete its own cleanup.\n11\t#\n12\t# ==============================================================================================\n13\t# OPERATIONAL MODEL\n14\t# ==============================================================================================\n15\t#\n16\t# OWNER PATH (10 steps)\n17\t# Step 1: Stop rsync — halt any running sync before state changes\n18\t# Step 2: Final sync — mirror leaves with current Critical-Data state\n19\t# Step 3: Reconfigure WebUIs — mirror's auth WebUIs → localhost\n20\t# Step 4: Disable sync — CRITICAL_RSYNC_ENABLED=false in master.conf\n21\t# Step 5: Local cleanup — remove fallback coverage containers + appdata\n22\t# Step 6: Restart own stack — bring up owner's own parked containers\n23\t# Step 7: Remote cleanup — remove auth/arr stack + fallback containers from mirror\n24\t# Step 8: Restart mirror — bring up mirror's own parked containers\n25\t# Step 9: Revocation — Emby admin, SSH keys\n26\t# Step 10: Write state — INACTIVE locally + pushed to mirror, mirror blocklisted\n27\t# Tailscale — grace window then device removal (after state written)\n28\t#\n29\t# MIRROR PATH (8 steps)\n30\t# Step 1: Stop rsync — halt any running sync\n31\t# Step 2: Reconfigure WebUIs — local auth WebUIs → localhost\n32\t# Step 3: Remote stack clean — remove owner-deployed containers locally (auth/arr stack)\n33\t# Step 4: Fallback cleanup — remove fallback coverage containers\n34\t# Step 5: Disable sync — CRITICAL_RSYNC_ENABLED=false in master.conf\n35\t# Step 6: Revoke Emby admin — remove own admin account from local Emby instance\n36\t# Step 7: Restart own stack — bring up own parked containers\n37\t# Step 8: SSH revocation — revoke keys both directions, write state, signal owner\n38\t#\n39\t# ==============================================================================================\n40\t# CONFIGURATION\n41\t# ==============================================================================================\n42\t#\n43\t# host*.conf\n44\t#\n45\t# HOST*_PARTNERSHIP_AUTH_STACK\n46\t# Auth container XMLs to push during onboard — used on offboard to identify what\n47\t# to remove. Owner's PARTNERSHIP_AUTH_STACK determines which containers get removed\n48\t# from the mirror on both owner-initiated and mirror-initiated offboard.\n49\t#\n50\t# HOST*_PARTNERSHIP_ARR_STACK\n51\t# Arr container XMLs — same cleanup logic as auth stack.\n52\t#\n53\t# HOST*_PARTNERSHIP_SERVICES_STACK\n54\t# Shared services XMLs (Emby, Jellyfin, Seerr, SeerrFin) — same cleanup logic.\n55\t#\n56\t# ==============================================================================================\n57\t# RUNTIME MODES\n58\t# ==============================================================================================\n59\t#\n60\t# Partnership/partnership_offboard.sh\n61\t# Full offboard — role detected automatically\n62\t#\n63\t# Partnership/partnership_offboard.sh --dry-run\n64\t# Preview all steps without executing\n65\t#\n66\t# Partnership/partnership_offboard.sh --log\n67\t# Verbose per-step output\n68\t#\n69\t# Partnership/partnership_offboard.sh --reason=<string>\n70\t# Tag the offboard reason in state file and blocklist (default: manual)\n71\t# Called by partnership_manager.sh --offboard (reason passed through)\n72\t#\n73\t# ==============================================================================================\n74\t\n75\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n76\tSCRIPTS_ROOT=\"$SCRIPT_DIR/..\"\n77\tSSH_TIMEOUT=15\n78\t\n79\tsource \"$SCRIPTS_ROOT/load_config.sh\"\n80\tsource \"$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh\"\n81\t\n82\t# ── Parse flags ───────────────────────────────────────────────────────────────────────────────\n83\tREASON=\"manual\"\n84\tFILTERED_ARGS=()\n85\t\n86\tfor arg in \"$@\"; do\n87\t case \"$arg\" in\n88\t --reason=*) REASON=\"${arg#--reason=}\" ;;\n89\t *) FILTERED_ARGS+=(\"$arg\") ;;\n90\t esac\n91\tdone\n92\t\n93\tparse_args \"${FILTERED_ARGS[@]}\"\n94\t\n95\t# ── Source partnership_manager.sh for shared helpers ──────────────────────────────────────────\n96\t# PARTNERSHIP_LIB_MODE=1 skips mode dispatch — functions are defined, nothing is executed.\n97\tPARTNERSHIP_LIB_MODE=1 source \"$SCRIPT_DIR/partnership_manager.sh\"\n98\t\n99\t# ==============================================================================================\n100\t# ━━━ Setup ━━━\n101\t# ==============================================================================================\n102\t[[ \"$EUID\" -ne 0 ]] && { error \"Must be run as root\"; exit 1; }\n103\t\n104\tif ! command -v docker &>/dev/null; then\n105\t error \"Docker command not found\"\n106\t exit 1\n107\tfi\n108\t\n109\tdetect_hosts\n110\t\n111\tOWNER_ID=\"${PARTNERSHIP_OWNER_HOST:-HOST1}\"\n112\tMIRROR_ID=$( [[ \"$OWNER_ID\" == \"HOST1\" ]] && echo \"HOST2\" || echo \"HOST1\" )\n113\tOWNER=\"${!OWNER_ID}\"\n114\tMIRROR=\"${!MIRROR_ID}\"\n115\tMIRROR_SSH_KEY=\"$SSH_KEY\"\n116\tOWNER_SSH_KEY=\"$SSH_KEY\"\n117\t\n118\tAM_OWNER=false\n119\tAM_MIRROR=false\n120\t[[ \"$MY_ID\" == \"$OWNER_ID\" ]] && AM_OWNER=true\n121\t[[ \"$MY_ID\" == \"$MIRROR_ID\" ]] && AM_MIRROR=true\n122\t\n123\tLOCAL_STATE_FILE=\"${STATE_DIR}/partnership_${LOCAL_SERVER_NAME}.db\"\n124\tREMOTE_STATE_FILE=\"${STATE_DIR}/partnership_${REMOTE_SERVER_NAME}.db\"\n125\tOWNER_STATE_FILE=\"${STATE_DIR}/partnership_${OWNER}.db\"\n126\tMIRROR_STATE_FILE=\"${STATE_DIR}/partnership_${MIRROR}.db\"\n127\tOFFLINE_COUNTER=\"${STATE_DIR}/partnership_offline_days.db\"\n128\t\n129\tacquire_lock \"strict\"\n130\t\n131\ttrap _pm_trap_restart_stopped EXIT\n132\t\n133\t# Check already offboarded\n134\tif [[ -f \"$LOCAL_STATE_FILE\" ]]; then\n135\t CURRENT_STATE=$(read_state_file \"$LOCAL_STATE_FILE\" \"state\")\n136\t if [[ \"$CURRENT_STATE\" == \"INACTIVE\" ]]; then\n137\t warn \"Partnership already INACTIVE — use partnership_manager.sh --status to verify both servers agree\"\n138\t exit 0\n139\t fi\n140\tfi\n141\t\n142\tSTART=$(date +%s)\n143\t\n144\techo \"\"\n145\techo \"━━━ $ICON_FALLBACK Partnership Offboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━\"\n146\techo \"\"\n147\techo \" Role: $( [[ \"$AM_OWNER\" == true ]] && echo \"OWNER\" || echo \"MIRROR\" )\"\n148\techo \" This: $MY_ID ($LOCAL_SERVER_NAME)\"\n149\techo \" Partner: $( [[ \"$AM_OWNER\" == true ]] && echo \"$MIRROR_ID ($MIRROR)\" || echo \"$OWNER_ID ($OWNER)\" )\"\n150\techo \" Reason: $REASON\"\n151\techo \"\"\n152\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no permanent changes will be made\"\n153\t\n154\t# ==============================================================================================\n155\t# ── HELPER: revoke own admin account from local Emby instance ────────────────────────────────\n156\t#\n157\t# Mirror-initiated path only. Called before start_own_stack so Emby is still running.\n158\t# Uses local EMBY_API_KEY and the mirror's own short name as the username to delete.\n159\t# ==============================================================================================\n160\trevoke_local_emby_admin() {\n161\t local emby_port=\"${PARTNERSHIP_EMBY_PORT:-8096}\"\n162\t local emby_url=\"http://127.0.0.1:${emby_port}\"\n163\t\n164\t echo \"\"\n165\t echo \"━━━ $ICON_EMBY Emby Admin Revocation ━━━\"\n166\t\n167\t if [[ \"${PARTNERSHIP_PROVISION_EMBY_ADMIN:-false}\" != true ]]; then\n168\t log \"PARTNERSHIP_PROVISION_EMBY_ADMIN=false — skipping\"\n169\t return 0\n170\t fi\n171\t\n172\t if [[ -z \"${EMBY_API_KEY:-}\" ]]; then\n173\t warn \"EMBY_API_KEY not set — skipping local Emby admin revocation\"\n174\t return 1\n175\t fi\n176\t\n177\t # The account to revoke is this server's own short name (the mirror user's account)\n178\t local username=\"${PARTNERSHIP_EMBY_ADMIN_USER:-$(derive_short_name \"$LOCAL_SERVER_NAME\")}\"\n179\t\n180\t if [[ \"$DRY_RUN\" == true ]]; then\n181\t warn \"DRY RUN — would delete Emby admin '$username' at $emby_url\"\n182\t return 0\n183\t fi\n184\t\n185\t log \"Looking up Emby user '$username' at $emby_url...\"\n186\t\n187\t local users_json user_id\n188\t users_json=$(curl -sf --max-time 15 \\\n189\t -H \"X-Emby-Authorization: MediaBrowser Token=\\\"$EMBY_API_KEY\\\"\" \\\n190\t \"${emby_url}/Users\" 2>/dev/null)\n191\t\n192\t user_id=$(echo \"$users_json\" | \\\n193\t grep -o \"\\\"Id\\\":\\\"[^\\\"]*\\\"[^}]*\\\"Name\\\":\\\"${username}\\\"\" | \\\n194\t grep -o '\"Id\":\"[^\"]*\"' | cut -d'\"' -f4 | head -1)\n195\t\n196\t if [[ -z \"$user_id\" ]]; then\n197\t warn \"Emby user '$username' not found at $emby_url — may already be removed\"\n198\t return 0\n199\t fi\n200\t\n201\t local del_code\n202\t del_code=$(curl -sf --max-time 15 -w \"%{http_code}\" -o /dev/null \\\n203\t -X DELETE \\\n204\t -H \"X-Emby-Authorization: MediaBrowser Token=\\\"$EMBY_API_KEY\\\"\" \\\n205\t \"${emby_url}/Users/${user_id}\" 2>/dev/null)\n206\t\n207\t if [[ \"$del_code\" == \"200\" ]] || [[ \"$del_code\" == \"204\" ]] || [[ \"$del_code\" == \"404\" ]]; then\n208\t log \"Emby admin '$username' removed ✅\"\n209\t else\n210\t warn \"Failed to delete Emby user '$username' (HTTP $del_code) — remove manually\"\n211\t fi\n212\t}\n213\t\n214\t# ==============================================================================================\n215\t# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────\n216\t# ==============================================================================================\n217\tif [[ \"$AM_MIRROR\" == true ]]; then\n218\t warn \"$MIRROR_ID ($MIRROR) is initiating offboard\"\n219\t warn \"Owner ($OWNER) will see INACTIVE state on its next --check cycle and finalize\"\n220\t\n221\t if [[ \"$DRY_RUN\" == false ]]; then\n222\t echo \"\"\n223\t echo \"You have 10 seconds to cancel (Ctrl+C)...\"\n224\t sleep 10\n225\t fi\n226\t\n227\t OWNER_IP=$(resolve_tailscale_ip \"$OWNER\")\n228\t OWNER_REACHABLE=false\n229\t [[ -n \"$OWNER_IP\" ]] && OWNER_REACHABLE=true\n230\t\n231\t STEP_STOP_RSYNC_OK=true\n232\t STEP_WEBUI_OK=true\n233\t STEP_STACK_CLEANUP_OK=true\n234\t STEP_FALLBACK_CLEANUP_OK=true\n235\t STEP_DISABLE_RSYNC_OK=true\n236\t STEP_EMBY_OK=true\n237\t SSH_REVOKE_REMOTE_OK=false\n238\t SSH_REVOKE_LOCAL_OK=false\n239\t\n240\t # ── Step 1: Stop rsync ────────────────────────────────────────────────────────────────────\n241\t echo \"\"\n242\t echo \"━━━ $ICON_STOP Step 1/8 — Stop Rsync ━━━\"\n243\t\n244\t if [[ \"$DRY_RUN\" == false ]]; then\n245\t bash \"$SCRIPTS_ROOT/Rsync/rsync_stop.sh\" --rsync-only 2>/dev/null || true\n246\t log \"Rsync stopped ✅\"\n247\t else\n248\t warn \"DRY RUN — would stop rsync\"\n249\t fi\n250\t\n251\t # ── Step 2: Reconfigure local WebUIs → localhost ──────────────────────────────────────────\n252\t echo \"\"\n253\t echo \"━━━ $ICON_CONTAINERS Step 2/8 — Reconfigure Local WebUIs → localhost ━━━\"\n254\t\n255\t reconfigure_local_webuis \"localhost\" || STEP_WEBUI_OK=false\n256\t\n257\t # ── Step 3: Remove owner-deployed containers (auth/arr stack) locally ─────────────────────\n258\t echo \"\"\n259\t echo \"━━━ $ICON_CONTAINERS Step 3/8 — Remove Owner-Deployed Containers ━━━\"\n260\t\n261\t if [[ \"$OWNER_REACHABLE\" == true ]]; then\n262\t cleanup_deployed_stack_locally \"$OWNER_IP\" \"$OWNER_SSH_KEY\" || STEP_STACK_CLEANUP_OK=false\n263\t else\n264\t warn \"Owner unreachable — cannot read deployed stack list\"\n265\t warn \"Auth/arr containers will remain — remove manually or re-run when owner is reachable\"\n266\t STEP_STACK_CLEANUP_OK=false\n267\t fi\n268\t\n269\t # ── Step 4: Remove fallback coverage containers ───────────────────────────────────────────\n270\t echo \"\"\n271\t echo \"━━━ $ICON_CONTAINERS Step 4/8 — Fallback Container Cleanup ━━━\"\n272\t\n273\t cleanup_partner_containers || STEP_FALLBACK_CLEANUP_OK=false\n274\t\n275\t # ── Step 5: Disable critical sync ─────────────────────────────────────────────────────────\n276\t echo \"\"\n277\t echo \"━━━ $ICON_GEAR Step 5/8 — Disable Critical Sync ━━━\"\n278\t\n279\t if [[ \"$DRY_RUN\" == false ]]; then\n280\t update_master_conf \"CRITICAL_RSYNC_ENABLED\" \"false\" && \\\n281\t warn \"CRITICAL_RSYNC_ENABLED=false ✅\" || \\\n282\t { warn \"Failed to update CRITICAL_RSYNC_ENABLED\"; STEP_DISABLE_RSYNC_OK=false; }\n283\t else\n284\t warn \"DRY RUN — would set CRITICAL_RSYNC_ENABLED=false\"\n285\t fi\n286\t\n287\t # ── Step 6: Revoke Emby admin locally ─────────────────────────────────────────────────────\n288\t revoke_local_emby_admin || STEP_EMBY_OK=false\n289\t\n290\t # ── Step 7: Restart own stack ─────────────────────────────────────────────────────────────\n291\t start_own_stack\n292\t\n293\t # ── Step 8: SSH key revocation, write state, signal owner ────────────────────────────────\n294\t echo \"\"\n295\t echo \"━━━ $ICON_SHIELD Step 8/8 — SSH Revocation + State ━━━\"\n296\t\n297\t do_ssh_key_revocation \"${OWNER_IP:-}\"\n298\t\n299\t NOW=$(date '+%Y-%m-%d %H:%M:%S')\n300\t if [[ \"$DRY_RUN\" == false ]]; then\n301\t write_state_file \"$LOCAL_STATE_FILE\" \\\n302\t \"INACTIVE\" \"\" \"$NOW\" \"$LOCAL_SERVER_NAME\" \"$REASON\"\n303\t echo \"Local state: INACTIVE ✅\"\n304\t add_to_blocklist \"$OWNER\" \"$REASON\"\n305\t else\n306\t warn \"DRY RUN — would write INACTIVE state and blocklist $OWNER\"\n307\t fi\n308\t\n309\t if [[ \"$OWNER_REACHABLE\" == true ]]; then\n310\t push_state_to_remote \"$LOCAL_STATE_FILE\" \"$OWNER_IP\" \"$OWNER_SSH_KEY\"\n311\t notify \"Partnership offboard requested by $MIRROR — $OWNER will finalise on next check\" \\\n312\t \"Partnership\" \"normal\"\n313\t else\n314\t warn \"$OWNER unreachable — state written locally, owner will see it when reachable\"\n315\t fi\n316\t\n317\t # ── Summary ───────────────────────────────────────────────────────────────────────────────\n318\t END=$(date +%s)\n319\t echo \"\"\n320\t echo \"━━━━━ $ICON_SUMMARY OFFBOARD SUMMARY (Mirror) ━━━━━\"\n321\t echo \" Mirror: $MY_ID ($LOCAL_SERVER_NAME)\"\n322\t echo \" Owner: $OWNER_ID ($OWNER)\"\n323\t echo \" Reason: $REASON\"\n324\t echo \" Duration: $(format_duration $(( END - START )))\"\n325\t echo \"\"\n326\t _ok() { [[ \"$1\" == true ]] && echo \"✅\" || echo \"❌\"; }\n327\t _skip() { [[ \"$1\" == true ]] && echo \"skipped\" || echo \"$(_ok \"$2\")\"; }\n328\t _revoke_status() {\n329\t if [[ \"${SSH_REVOKE_REMOTE_OK:-false}\" == true && \"${SSH_REVOKE_LOCAL_OK:-false}\" == true ]]; then\n330\t echo \"both directions ✅\"\n331\t elif [[ \"${SSH_REVOKE_LOCAL_OK:-false}\" == true ]]; then\n332\t echo \"local only ✅ — remote failed (revoke manually on $OWNER)\"\n333\t else\n334\t echo \"⚠️ failed — check warnings above\"\n335\t fi\n336\t }\n337\t\n338\t echo \" Step 1 — Stop rsync: $(_ok \"$STEP_STOP_RSYNC_OK\")\"\n339\t echo \" Step 2 — WebUIs: $(_ok \"$STEP_WEBUI_OK\")\"\n340\t echo \" Step 3 — Stack cleanup: $(_ok \"$STEP_STACK_CLEANUP_OK\")\"\n341\t echo \" Step 4 — Fallback cleanup: $(_ok \"$STEP_FALLBACK_CLEANUP_OK\")\"\n342\t echo \" Step 5 — Disable sync: $(_ok \"$STEP_DISABLE_RSYNC_OK\")\"\n343\t echo \" Step 6 — Emby revoke: $(_ok \"$STEP_EMBY_OK\")\"\n344\t echo \" Step 7 — Own stack: started\"\n345\t echo \" Step 8 — Keys revoked: $(_revoke_status)\"\n346\t echo \"\"\n347\t echo \" State: INACTIVE ✅\"\n348\t echo \" Blocklist: $OWNER blocked ✅\"\n349\t echo \" Owner: will finalise + final sync on next --check\"\n350\t echo \"\"\n351\t [[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no changes made\" || \\\n352\t warn \"$ICON_DONE DONE — mirror separation complete ✅\"\n353\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n354\t exit 0\n355\tfi\n356\t\n357\t# ==============================================================================================\n358\t# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────\n359\t# ==============================================================================================\n360\twarn \"Offboarding $MIRROR_ID ($MIRROR) from partnership\"\n361\twarn \"Final sync will run — mirror leaves with current state\"\n362\t\n363\tif [[ \"$DRY_RUN\" == false ]]; then\n364\t echo \"\"\n365\t echo \"You have 10 seconds to cancel (Ctrl+C)...\"\n366\t sleep 10\n367\t echo \"Proceeding...\"\n368\tfi\n369\t\n370\tWEBUI_FAILURES=0\n371\tSTEP_STOP_OK=true\n372\tSTEP_SYNC_OK=true\n373\tSSH_REVOKE_REMOTE_OK=false\n374\tSSH_REVOKE_LOCAL_OK=false\n375\t\n376\t# ── Step 1: Stop rsync ────────────────────────────────────────────────────────────────────────\n377\techo \"\"\n378\techo \"━━━ $ICON_STOP Step 1/10 — Stop Rsync ━━━\"\n379\t\n380\tif [[ \"$DRY_RUN\" == false ]]; then\n381\t bash \"$SCRIPTS_ROOT/Rsync/rsync_stop.sh\" --rsync-only 2>/dev/null || STEP_STOP_OK=false\n382\t log \"Rsync stopped ✅\"\n383\telse\n384\t warn \"DRY RUN — would stop rsync\"\n385\tfi\n386\t\n387\t# ── Step 2: Final sync ────────────────────────────────────────────────────────────────────────\n388\techo \"\"\n389\techo \"━━━ $ICON_SYNC Step 2/10 — Final Sync ━━━\"\n390\t\n391\tdo_final_sync || STEP_SYNC_OK=false\n392\t\n393\t# ── Step 3: Reconfigure mirror WebUIs → localhost ─────────────────────────────────────────────\n394\techo \"\"\n395\techo \"━━━ $ICON_CONTAINERS Step 3/10 — Reconfigure Mirror WebUIs → localhost ━━━\"\n396\t\n397\tMIRROR_IP=$(resolve_tailscale_ip \"$MIRROR\")\n398\tMIRROR_REACHABLE=false\n399\t[[ -n \"$MIRROR_IP\" ]] && MIRROR_REACHABLE=true\n400\t\n401\tif [[ \"$MIRROR_REACHABLE\" == true ]]; then\n402\t for entry in \"${PARTNERSHIP_AUTH_WEBUIS[@]}\"; do\n403\t [[ -z \"$entry\" ]] && continue\n404\t container=\"${entry%%|*}\"\n405\t port=\"${entry##*|}\"\n406\t reconfigure_webui \"$container\" \"$port\" \"localhost\" \\\n407\t \"$MIRROR_SSH_KEY\" \"$MIRROR_IP\" \"$MIRROR\" || (( WEBUI_FAILURES++ ))\n408\t done\n409\telse\n410\t warn \"$MIRROR unreachable — WebUI reconfiguration skipped\"\n411\t warn \"$MIRROR will reconfigure its own WebUIs when it sees INACTIVE state on --check\"\n412\t (( WEBUI_FAILURES++ ))\n413\tfi\n414\t\n415\t# ── Step 4: Disable critical sync ─────────────────────────────────────────────────────────────\n416\techo \"\"\n417\techo \"━━━ $ICON_GEAR Step 4/10 — Disable Critical Sync ━━━\"\n418\t\n419\tif [[ \"$DRY_RUN\" == false ]]; then\n420\t update_master_conf \"CRITICAL_RSYNC_ENABLED\" \"false\"\n421\t warn \"CRITICAL_RSYNC_ENABLED=false ✅\"\n422\telse\n423\t warn \"DRY RUN — would set CRITICAL_RSYNC_ENABLED=false\"\n424\tfi\n425\t\n426\t# ── Step 5: Local container cleanup ───────────────────────────────────────────────────────────\n427\techo \"\"\n428\techo \"━━━ $ICON_CONTAINERS Step 5/10 — Local Container Cleanup ━━━\"\n429\t\n430\tcleanup_partner_containers\n431\t\n432\t# ── Step 6: Restart own stack ─────────────────────────────────────────────────────────────────\n433\tstart_own_stack\n434\t\n435\t# ── Step 7: Remote container cleanup ──────────────────────────────────────────────────────────\n436\techo \"\"\n437\techo \"━━━ $ICON_CONTAINERS Step 7/10 — Remote Container Cleanup ━━━\"\n438\t\n439\tif [[ \"$MIRROR_REACHABLE\" == true ]]; then\n440\t # Remove auth/arr stack containers deployed during onboard (by config array)\n441\t cleanup_deployed_stack_on_remote \"$MIRROR_IP\" \"$MIRROR_SSH_KEY\"\n442\t # Remove fallback coverage containers (by *-owner_short naming pattern)\n443\t cleanup_owner_containers_on_mirror \"$MIRROR_IP\"\n444\telse\n445\t warn \"$MIRROR unreachable — remote container cleanup skipped\"\n446\t warn \"Run 'partnership_offboard.sh' on $MIRROR to clean up manually\"\n447\tfi\n448\t\n449\t# ── Step 8: Restart mirror's own stack ────────────────────────────────────────────────────────\n450\techo \"\"\n451\techo \"━━━ $ICON_START Step 8/10 — Restart Mirror Stack ━━━\"\n452\t\n453\t[[ \"$MIRROR_REACHABLE\" == true ]] && start_mirror_own_stack \"$MIRROR_IP\"\n454\t\n455\t# ── Step 9: Revocation (Emby + SSH) ──────────────────────────────────────────────────────────\n456\techo \"\"\n457\techo \"━━━ $ICON_SHIELD Step 9/10 — Revocation ━━━\"\n458\t\n459\t# Emby admin — before SSH key revocation while Emby still reachable\n460\t[[ \"$MIRROR_REACHABLE\" == true ]] && revoke_emby_admin \"$MIRROR_IP\"\n461\t\n462\t# SSH key revocation — mutual, both directions; must run while Tailscale still active\n463\tdo_ssh_key_revocation \"${MIRROR_IP:-}\"\n464\t\n465\t# ── Step 10: Write state, push to mirror, blocklist ───────────────────────────────────────────\n466\t# State is written after container cleanup and SSH revocation so that:\n467\t# • Re-running after a crash between steps 5–9 restarts from scratch (no early-exit on INACTIVE)\n468\t# • --check sees INACTIVE during the Tailscale grace sleep and does not re-trigger offboard\n469\techo \"\"\n470\techo \"━━━ $ICON_GEAR Step 10/10 — Write State ━━━\"\n471\t\n472\tNOW=$(date '+%Y-%m-%d %H:%M:%S')\n473\t\n474\tif [[ \"$DRY_RUN\" == false ]]; then\n475\t write_state_file \"$LOCAL_STATE_FILE\" \\\n476\t \"INACTIVE\" \"\" \"$NOW\" \"$LOCAL_SERVER_NAME\" \"$REASON\"\n477\t log \"Local state: INACTIVE ✅\"\n478\t add_to_blocklist \"$MIRROR\" \"$REASON\"\n479\t [[ \"$MIRROR_REACHABLE\" == true ]] && \\\n480\t push_state_to_remote \"$LOCAL_STATE_FILE\" \"$MIRROR_IP\" \"$MIRROR_SSH_KEY\"\n481\telse\n482\t warn \"DRY RUN — would write INACTIVE state, blocklist $MIRROR, push to remote\"\n483\tfi\n484\t\n485\t# Tailscale removal — after state written so --check does not re-trigger offboard during grace sleep\n486\tif [[ \"${PARTNERSHIP_REMOVE_TAILSCALE:-true}\" == true ]]; then\n487\t echo \"\"\n488\t echo \"━━━ $ICON_NET Tailscale Separation ━━━\"\n489\t if [[ \"$MIRROR_REACHABLE\" == true ]]; then\n490\t grace_seconds=$(( ${PARTNERSHIP_GRACE_HOURS:-6} * 3600 ))\n491\t warn \"Waiting ${PARTNERSHIP_GRACE_HOURS:-6}hr grace — mirror can collect backups...\"\n492\t if [[ \"$DRY_RUN\" == false ]]; then\n493\t trap 'warn \"Offboard interrupted during grace sleep\"; exit 0' SIGTERM SIGINT\n494\t sleep \"$grace_seconds\"\n495\t trap - SIGTERM SIGINT\n496\t fi\n497\t fi\n498\t remove_tailscale_device \"$MIRROR\"\n499\tfi\n500\t\n501\t# Backup handover notification\n502\tif [[ ${#PARTNERSHIP_MIRROR_BACKUPS[@]} -gt 0 ]]; then\n503\t echo \"\"\n504\t echo \"━━━ $ICON_DISK Backup Handover ━━━\"\n505\t echo \"Backups available for $MIRROR:\"\n506\t for path in \"${PARTNERSHIP_MIRROR_BACKUPS[@]}\"; do\n507\t [[ -z \"$path\" ]] && continue\n508\t echo \" $path\"\n509\t done\n510\t notify \"$MIRROR offboard complete — backups available for ${PARTNERSHIP_GRACE_HOURS:-6}hr. Tailscale access expires then.\" \\\n511\t \"Partnership\" \"warning\"\n512\tfi\n513\t\n514\t# ── Summary ───────────────────────────────────────────────────────────────────────────────────\n515\tEND=$(date +%s)\n516\techo \"\"\n517\techo \"━━━━━ $ICON_SUMMARY OFFBOARD SUMMARY (Owner) ━━━━━\"\n518\techo \" Owner: $MY_ID ($LOCAL_SERVER_NAME)\"\n519\techo \" Mirror: $MIRROR_ID ($MIRROR)\"\n520\techo \" Reason: $REASON\"\n521\techo \" Duration: $(format_duration $(( END - START )))\"\n522\techo \"\"\n523\t_ok() { [[ \"$1\" == true ]] && echo \"✅\" || echo \"❌\"; }\n524\t_revoke_status() {\n525\t if [[ \"${SSH_REVOKE_REMOTE_OK:-false}\" == true && \"${SSH_REVOKE_LOCAL_OK:-false}\" == true ]]; then\n526\t echo \"both directions ✅\"\n527\t elif [[ \"${SSH_REVOKE_LOCAL_OK:-false}\" == true ]]; then\n528\t echo \"local only ✅ — remote failed (revoke manually on $MIRROR)\"\n529\t else\n530\t echo \"⚠️ failed — check warnings above\"\n531\t fi\n532\t}\n533\t\n534\techo \" Step 1 — Stop rsync: $(_ok \"$STEP_STOP_OK\")\"\n535\techo \" Step 2 — Final sync: $(_ok \"$STEP_SYNC_OK\")\"\n536\techo \" Step 3 — WebUI failures: $WEBUI_FAILURES\"\n537\techo \" Step 4 — Disable sync: ✅\"\n538\techo \" Step 5 — Local cleanup: ✅\"\n539\techo \" Step 6 — Own stack: started\"\n540\techo \" Step 7 — Remote cleanup: $( [[ \"$MIRROR_REACHABLE\" == true ]] && echo \"✅\" || echo \"skipped (unreachable)\" )\"\n541\techo \" Step 8 — Mirror stack: $( [[ \"$MIRROR_REACHABLE\" == true ]] && echo \"started\" || echo \"skipped (unreachable)\" )\"\n542\techo \" Step 9 — Keys revoked: $(_revoke_status)\"\n543\techo \" Step 10 — State: INACTIVE ✅\"\n544\techo \"\"\n545\techo \" Blocklist: $MIRROR blocked — re-onboard to permit access again ✅\"\n546\t[[ \"${PARTNERSHIP_REMOVE_TAILSCALE:-true}\" == true ]] && \\\n547\t echo \" Tailscale: $MIRROR removed ✅\"\n548\techo \"\"\n549\techo \" $MIRROR leaves with:\"\n550\techo \" ✓ Current auth config (final sync)\"\n551\techo \" ✓ Auth WebUIs → localhost\"\n552\techo \" ✓ ${PARTNERSHIP_GRACE_HOURS:-6}hr to collect backups\"\n553\techo \"\"\n554\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no changes made\" || \\\n555\t warn \"$ICON_DONE DONE — clean separation complete ✅\"\n556\techo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n557\texit 0\n558\t"}]},"uuid":"d3190ce7-c1fb-4847-81b6-1f6f6555c317","timestamp":"2026-06-14T05:32:22.141Z","sourceToolAssistantUUID":"26379e55-3d87-450d-b18b-bc401ca57795","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"d3190ce7-c1fb-4847-81b6-1f6f6555c317","isSidechain":true,"agentId":"a41f0ff6991d1474c","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01JgbihLYu5if9jnr1FxJvro","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Rvu9apRpzVs4Rutbupa8oD","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Partnership/partnership_onboard.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2007,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2007,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c4DLnBak1iWoFokbjq","attributionAgent":"Explore","type":"assistant","uuid":"3715a7ae-dbab-4b98-aa56-d771d95972df","timestamp":"2026-06-14T05:32:22.398Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"3715a7ae-dbab-4b98-aa56-d771d95972df","isSidechain":true,"agentId":"a41f0ff6991d1474c","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01JgbihLYu5if9jnr1FxJvro","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01F5GyHBvVFdoExDvfdBRgMF","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Partnership/partnership_transfer.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2007,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2007,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c4DLnBak1iWoFokbjq","attributionAgent":"Explore","type":"assistant","uuid":"e7ed270c-9394-42a4-a636-4c23b6949caf","timestamp":"2026-06-14T05:32:22.528Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"e7ed270c-9394-42a4-a636-4c23b6949caf","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"a41f0ff6991d1474c","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01F5GyHBvVFdoExDvfdBRgMF","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ============================= Partnership Transfer ============================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Transfers ownership from the current owner to the current mirror. After\n9\t# transfer the roles are swapped: what was the mirror becomes the new owner,\n10\t# and what was the owner becomes the new mirror.\n11\t#\n12\t# No containers are moved — only config and WebUI targets are updated. Both\n13\t# servers remain in the partnership; the sync direction reverses on the next\n14\t# fallback.sh / critical_sync_maintenance.sh cycle.\n15\t#\n16\t# ==============================================================================================\n17\t# OPERATIONAL MODEL\n18\t# ==============================================================================================\n19\t#\n20\t# OWNER ONLY — mirror cannot run this script.\n21\t#\n22\t# Step 1: Health Verification — both servers pass N consecutive health checks\n23\t# Step 2: Pre-transfer Sync — final sync in current direction (owner → mirror)\n24\t# Step 3: Reconfigure WebUIs — new owner WebUIs → localhost\n25\t# new mirror WebUIs → new owner IP\n26\t# Step 4: Flip Ownership — update PARTNERSHIP_OWNER_HOST in master.conf\n27\t# on both servers\n28\t# Step 5: Write State — ACTIVE written locally and pushed to new mirror\n29\t#\n30\t# ==============================================================================================\n31\t# OPERATIONAL SAFEGUARDS\n32\t# ==============================================================================================\n33\t#\n34\t# Owner-Only Enforcement\n35\t# The script reads MY_ID from detect_hosts() and exits immediately if it is\n36\t# not the current PARTNERSHIP_OWNER_HOST. The mirror cannot run a transfer.\n37\t#\n38\t# Explicit Confirmation String\n39\t# Requires the exact passphrase from PARTNERSHIP_TRANSFER_CONFIRM via\n40\t# --confirm=<value>. Without a matching string the transfer is cancelled\n41\t# before any steps execute. Prevents accidental ownership changes.\n42\t#\n43\t# Active Partnership Guard\n44\t# Reads the local state file and exits if the current state is INACTIVE.\n45\t# A transfer without an active partnership has no defined outcome.\n46\t#\n47\t# Dual Health Verification\n48\t# Both servers must pass PARTNERSHIP_TRANSFER_STRIKES consecutive health\n49\t# checks before proceeding. A single failure resets the strike counter.\n50\t# After PARTNERSHIP_TRANSFER_MAX_ATTEMPTS total attempts the transfer aborts.\n51\t#\n52\t# Pre-transfer Final Sync\n53\t# A full sync in the current direction (owner → mirror) runs immediately\n54\t# before roles flip. Ensures the mirror is current before it becomes the owner.\n55\t#\n56\t# ==============================================================================================\n57\t# CONFIGURATION\n58\t# ==============================================================================================\n59\t#\n60\t# master.conf\n61\t#\n62\t# PARTNERSHIP_OWNER_HOST\n63\t# Current owner host ID (e.g. \"HOST1\"). Updated on both servers after transfer.\n64\t#\n65\t# PARTNERSHIP_TRANSFER_CONFIRM\n66\t# Exact string required to confirm transfer (default: \"i-understand-this-transfers-ownership\").\n67\t# Pass via --confirm=<value>.\n68\t#\n69\t# PARTNERSHIP_TRANSFER_STRIKES\n70\t# Consecutive health checks both servers must pass before transfer proceeds (default: 3).\n71\t#\n72\t# PARTNERSHIP_TRANSFER_MAX_ATTEMPTS\n73\t# Max health check attempts before giving up (default: 20).\n74\t#\n75\t# CRITICAL_SYNC_SHARES\n76\t# Array of \"path|profile\" or \"path\" entries for do_final_sync().\n77\t#\n78\t# PARTNERSHIP_AUTH_WEBUIS\n79\t# Array of \"ContainerName|WebUIPort\" entries reconfigured during transfer.\n80\t#\n81\t# ==============================================================================================\n82\t# RUNTIME MODES\n83\t# ==============================================================================================\n84\t#\n85\t# Partnership/partnership_transfer.sh --confirm=i-understand-this-transfers-ownership\n86\t# Full transfer — owner detected automatically.\n87\t#\n88\t# Partnership/partnership_transfer.sh --confirm=i-understand-this-transfers-ownership --dry-run\n89\t# Preview all steps without executing. Confirmation check is skipped in dry-run mode.\n90\t#\n91\t# Partnership/partnership_transfer.sh --confirm=i-understand-this-transfers-ownership --log\n92\t# Verbose per-step output.\n93\t#\n94\t# ==============================================================================================\n95\t\n96\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n97\tSCRIPTS_ROOT=\"$SCRIPT_DIR/..\"\n98\tSSH_TIMEOUT=15\n99\t\n100\tsource \"$SCRIPTS_ROOT/load_config.sh\"\n101\t\n102\t# ── Parse flags ───────────────────────────────────────────────────────────────────────────────\n103\tTRANSFER_CONFIRM_INPUT=\"\"\n104\tFILTERED_ARGS=()\n105\t\n106\tfor arg in \"$@\"; do\n107\t case \"$arg\" in\n108\t --confirm=*) TRANSFER_CONFIRM_INPUT=\"${arg#--confirm=}\" ;;\n109\t *) FILTERED_ARGS+=(\"$arg\") ;;\n110\t esac\n111\tdone\n112\t\n113\tparse_args \"${FILTERED_ARGS[@]}\"\n114\t\n115\t# ── Source partnership_manager.sh for shared helpers ──────────────────────────────────────────\n116\t# PARTNERSHIP_LIB_MODE=1 skips mode dispatch — functions are defined, nothing is executed.\n117\tPARTNERSHIP_LIB_MODE=1 source \"$SCRIPT_DIR/partnership_manager.sh\"\n118\t\n119\t# ==============================================================================================\n120\t# ━━━ Setup ━━━\n121\t# ==============================================================================================\n122\t[[ \"$EUID\" -ne 0 ]] && { error \"Must be run as root\"; exit 1; }\n123\t\n124\tif ! command -v docker &>/dev/null; then\n125\t error \"Docker command not found\"\n126\t exit 1\n127\tfi\n128\t\n129\tdetect_hosts\n130\t\n131\tOWNER_ID=\"${PARTNERSHIP_OWNER_HOST:-HOST1}\"\n132\tMIRROR_ID=$( [[ \"$OWNER_ID\" == \"HOST1\" ]] && echo \"HOST2\" || echo \"HOST1\" )\n133\tOWNER=\"${!OWNER_ID}\"\n134\tMIRROR=\"${!MIRROR_ID}\"\n135\tMIRROR_SSH_KEY=\"$SSH_KEY\"\n136\tOWNER_SSH_KEY=\"$SSH_KEY\"\n137\t\n138\tAM_OWNER=false\n139\tAM_MIRROR=false\n140\t[[ \"$MY_ID\" == \"$OWNER_ID\" ]] && AM_OWNER=true\n141\t[[ \"$MY_ID\" == \"$MIRROR_ID\" ]] && AM_MIRROR=true\n142\t\n143\tLOCAL_STATE_FILE=\"${STATE_DIR}/partnership_${LOCAL_SERVER_NAME}.db\"\n144\tREMOTE_STATE_FILE=\"${STATE_DIR}/partnership_${REMOTE_SERVER_NAME}.db\"\n145\t\n146\tacquire_lock \"strict\"\n147\t\n148\t# ==============================================================================================\n149\t# ━━━ Preflight ━━━\n150\t# ==============================================================================================\n151\techo \"\"\n152\techo \"━━━ $ICON_FALLBACK Partnership Transfer — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━\"\n153\techo \"\"\n154\t\n155\tif [[ \"$AM_MIRROR\" == true ]]; then\n156\t error \"Only the owner ($OWNER / $OWNER_ID) can run --transfer\"\n157\t error \"Run from $OWNER, or use --offboard and re-onboard with roles swapped\"\n158\t exit 1\n159\tfi\n160\t\n161\tif [[ -f \"$LOCAL_STATE_FILE\" ]]; then\n162\t CURRENT_STATE=$(read_state_file \"$LOCAL_STATE_FILE\" \"state\")\n163\t if [[ \"$CURRENT_STATE\" == \"INACTIVE\" ]]; then\n164\t error \"No active partnership — transfer requires an active partnership\"\n165\t error \"If roles are already correct, check PARTNERSHIP_OWNER_HOST in master.conf\"\n166\t exit 1\n167\t fi\n168\tfi\n169\t\n170\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no permanent changes will be made\"\n171\t\n172\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n173\techo \"⚠️ WARNING — OWNERSHIP TRANSFER\"\n174\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n175\techo \" Current owner: $OWNER_ID ($OWNER)\"\n176\techo \" Current mirror: $MIRROR_ID ($MIRROR)\"\n177\techo \"\"\n178\techo \" After transfer:\"\n179\techo \" New owner: $MIRROR_ID ($MIRROR)\"\n180\techo \" New mirror: $OWNER_ID ($OWNER)\"\n181\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n182\t\n183\t# ==============================================================================================\n184\t# ━━━ Confirmation ━━━\n185\t# ==============================================================================================\n186\tif [[ \"$DRY_RUN\" == false ]]; then\n187\t if [[ -z \"$TRANSFER_CONFIRM_INPUT\" ]]; then\n188\t echo \"\"\n189\t echo \"To proceed, pass exactly:\"\n190\t echo \" --confirm=${PARTNERSHIP_TRANSFER_CONFIRM}\"\n191\t echo \"\"\n192\t error \"Transfer cancelled — confirmation required\"\n193\t exit 1\n194\t fi\n195\t if [[ \"$TRANSFER_CONFIRM_INPUT\" != \"$PARTNERSHIP_TRANSFER_CONFIRM\" ]]; then\n196\t error \"Confirmation string does not match — transfer cancelled\"\n197\t exit 1\n198\t fi\n199\t log \"Confirmation accepted\"\n200\telse\n201\t warn \"DRY RUN — confirmation check skipped\"\n202\tfi\n203\t\n204\t# ==============================================================================================\n205\t# ━━━ Step 1: Health Verification ━━━\n206\t# ==============================================================================================\n207\techo \"\"\n208\techo \"━━━ $ICON_SHIELD Step 1: Health Verification ━━━\"\n209\tlog \"Both servers must pass ${PARTNERSHIP_TRANSFER_STRIKES} consecutive health checks\"\n210\t\n211\tSTRIKES=0\n212\tATTEMPTS=0\n213\tMAX_ATTEMPTS=\"${PARTNERSHIP_TRANSFER_MAX_ATTEMPTS:-20}\"\n214\t\n215\twhile [[ \"$STRIKES\" -lt \"$PARTNERSHIP_TRANSFER_STRIKES\" ]]; do\n216\t (( ATTEMPTS++ ))\n217\t if [[ \"$ATTEMPTS\" -gt \"$MAX_ATTEMPTS\" ]]; then\n218\t error \"Health checks failed after $MAX_ATTEMPTS attempts — servers not stable\"\n219\t error \"Transfer cancelled — try again when both servers are healthy\"\n220\t exit 1\n221\t fi\n222\t\n223\t if check_both_healthy; then\n224\t (( STRIKES++ ))\n225\t log \"Health check passed ($STRIKES/${PARTNERSHIP_TRANSFER_STRIKES})\"\n226\t [[ \"$STRIKES\" -lt \"$PARTNERSHIP_TRANSFER_STRIKES\" ]] && sleep 10\n227\t else\n228\t warn \"Health check failed — resetting (attempt $ATTEMPTS/$MAX_ATTEMPTS)\"\n229\t STRIKES=0\n230\t sleep 30\n231\t fi\n232\tdone\n233\twarn \"Both servers healthy — proceeding ✅\"\n234\t\n235\t# Resolve IPs after health checks confirm reachability\n236\tMIRROR_IP=$(resolve_tailscale_ip \"$MIRROR\")\n237\tOWNER_IP=$(resolve_tailscale_ip \"$OWNER\")\n238\t[[ -z \"$MIRROR_IP\" ]] && { error \"Cannot resolve mirror Tailscale IP\"; exit 1; }\n239\t\n240\t# Compute post-transfer roles\n241\tNEW_OWNER_ID=\"$MIRROR_ID\"\n242\tNEW_MIRROR_ID=\"$OWNER_ID\"\n243\tNEW_OWNER=\"$MIRROR\"\n244\tNEW_MIRROR=\"$OWNER\"\n245\tNEW_OWNER_IP=\"$MIRROR_IP\"\n246\t\n247\t# ==============================================================================================\n248\t# ━━━ Step 2: Pre-transfer Sync ━━━\n249\t# ==============================================================================================\n250\techo \"\"\n251\techo \"━━━ $ICON_SYNC Step 2: Pre-transfer Sync ━━━\"\n252\tdo_final_sync\n253\t\n254\t# ==============================================================================================\n255\t# ━━━ Step 3: Reconfigure WebUIs ━━━\n256\t# ==============================================================================================\n257\techo \"\"\n258\techo \"━━━ $ICON_CONTAINERS Step 3: Reconfigure WebUIs ━━━\"\n259\t\n260\tWEBUI_FAILURES=0\n261\t\n262\t# New owner (current mirror, HOST2) WebUIs → localhost — it now manages itself directly\n263\tlog \"New owner ($NEW_OWNER) WebUIs → localhost\"\n264\tfor entry in \"${PARTNERSHIP_AUTH_WEBUIS[@]}\"; do\n265\t [[ -z \"$entry\" ]] && continue\n266\t container=\"${entry%%|*}\"\n267\t port=\"${entry##*|}\"\n268\t reconfigure_webui \"$container\" \"$port\" \"localhost\" \\\n269\t \"$SSH_KEY\" \"$MIRROR_IP\" \"$MIRROR\" || (( WEBUI_FAILURES++ ))\n270\tdone\n271\t\n272\t# New mirror (us, HOST1) WebUIs → new owner IP — defers to new owner going forward\n273\tlog \"New mirror ($NEW_MIRROR) WebUIs → $NEW_OWNER_IP\"\n274\treconfigure_local_webuis \"$NEW_OWNER_IP\"\n275\tWEBUI_RC=$?\n276\t[[ \"$WEBUI_RC\" -gt 0 ]] && (( WEBUI_FAILURES += WEBUI_RC ))\n277\t\n278\t# ==============================================================================================\n279\t# ━━━ Step 4: Flip Ownership in master.conf ━━━\n280\t# ==============================================================================================\n281\techo \"\"\n282\techo \"━━━ $ICON_GEAR Step 4: Flip Ownership ━━━\"\n283\t\n284\tif [[ \"$DRY_RUN\" == false ]]; then\n285\t update_master_conf \"PARTNERSHIP_OWNER_HOST\" \"\\\"$NEW_OWNER_ID\\\"\"\n286\t\n287\t # Push updated master.conf to new owner so both servers agree immediately.\n288\t # master.conf is shared — host-specific credentials live in host*.conf.\n289\t _probe_cmd=$(platform_scripts_dir_probe_cmd)\n290\t _REMOTE_SD=$(ssh -i \"$SSH_KEY\" -o ConnectTimeout=5 -o StrictHostKeyChecking=no \\\n291\t \"root@${MIRROR_IP}\" \"$_probe_cmd\" \\\n292\t 2>/dev/null | tr -d '[:space:]')\n293\t _REMOTE_SD=\"${_REMOTE_SD:-$SCRIPTS_DIR}\"\n294\t scp -i \"$SSH_KEY\" \\\n295\t -o ConnectTimeout=\"$SSH_TIMEOUT\" \\\n296\t -o StrictHostKeyChecking=no \\\n297\t \"$SCRIPTS_ROOT/Configurations/master.conf\" \\\n298\t \"root@${MIRROR_IP}:${_REMOTE_SD}/Configurations/master.conf\" 2>/dev/null && \\\n299\t log \"master.conf pushed to $NEW_OWNER ✅\" || \\\n300\t error \"Failed to push master.conf to $NEW_OWNER — set PARTNERSHIP_OWNER_HOST=\\\"$NEW_OWNER_ID\\\" manually\"\n301\telse\n302\t warn \"DRY RUN — would set PARTNERSHIP_OWNER_HOST=$NEW_OWNER_ID on both servers\"\n303\tfi\n304\t\n305\t# ==============================================================================================\n306\t# ━━━ Step 5: Write State ━━━\n307\t# ==============================================================================================\n308\techo \"\"\n309\techo \"━━━ $ICON_SYNC Step 5: Write State ━━━\"\n310\t\n311\tNOW=$(date '+%Y-%m-%d %H:%M:%S')\n312\twrite_state_file \"$LOCAL_STATE_FILE\" \"ACTIVE\" \"$NOW\" \"\" \"$LOCAL_SERVER_NAME\" \"transfer\"\n313\tpush_state_to_remote \"$LOCAL_STATE_FILE\" \"$MIRROR_IP\" \"$SSH_KEY\"\n314\t\n315\t# ==============================================================================================\n316\t# ━━━ Summary ━━━\n317\t# ==============================================================================================\n318\techo \"\"\n319\techo \"━━━━━ $ICON_SUMMARY TRANSFER SUMMARY ━━━━━\"\n320\techo \" New owner: $NEW_OWNER_ID ($NEW_OWNER — $NEW_OWNER_IP)\"\n321\techo \" New mirror: $NEW_MIRROR_ID ($NEW_MIRROR)\"\n322\techo \" WebUI failures: $WEBUI_FAILURES\"\n323\techo \" Sync direction: $NEW_OWNER → $NEW_MIRROR (next cycle)\"\n324\techo \" Ownership: PARTNERSHIP_OWNER_HOST=$NEW_OWNER_ID\"\n325\techo \"\"\n326\tif [[ \"$DRY_RUN\" == true ]]; then\n327\t warn \"DRY RUN — no changes made\"\n328\telse\n329\t warn \"$ICON_DONE DONE — ownership transferred to $NEW_OWNER_ID ($NEW_OWNER) ✅\"\n330\t echo \"fallback.sh and critical_sync_maintenance.sh will adapt on next cycle\"\n331\t echo \"No containers were moved — only config and WebUI targets updated\"\n332\t [[ \"$WEBUI_FAILURES\" -gt 0 ]] && \\\n333\t warn \"$WEBUI_FAILURES WebUI(s) failed — check templates manually\"\n334\t notify \"Partnership ownership transferred — new owner: $NEW_OWNER ($NEW_OWNER_ID)\" \\\n335\t \"Partnership\" \"normal\"\n336\tfi\n337\techo \"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\"\n338\texit 0\n339\t"}]},"uuid":"5748e1b9-0a83-4dc1-956d-d1b450de5a90","timestamp":"2026-06-14T05:32:22.546Z","sourceToolAssistantUUID":"e7ed270c-9394-42a4-a636-4c23b6949caf","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"5748e1b9-0a83-4dc1-956d-d1b450de5a90","isSidechain":true,"agentId":"a41f0ff6991d1474c","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01JgbihLYu5if9jnr1FxJvro","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FKmAQQJm386NKhibcJnVZb","name":"Read","input":{"file_path":"/boot/config/plugins/varaverk/Partnership/ssh_setup.sh"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":2007,"cache_read_input_tokens":5697,"cache_creation":{"ephemeral_5m_input_tokens":2007,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c4DLnBak1iWoFokbjq","attributionAgent":"Explore","type":"assistant","uuid":"ab9e18b7-934f-4c1a-9ece-d731d7a853b4","timestamp":"2026-06-14T05:32:22.620Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"ab9e18b7-934f-4c1a-9ece-d731d7a853b4","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"a41f0ff6991d1474c","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FKmAQQJm386NKhibcJnVZb","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ============================= SSH Setup ======================================================\n4\t# ==============================================================================================\n5\t# Generates the SSH keypair for rsync automation and installs it on the remote server.\n6\t# Key named after this server: hostname lowercased, unraid- prefix stripped.\n7\t# unRAID-Gmer4Lfe → gmer4lfe_rsync_automation\n8\t# unRAID-Jayred365 → jayred365_rsync_automation\n9\t# Idempotent — skips generation if key already exists (use --force to regenerate).\n10\t# Updates host*.conf with key path on success.\n11\t#\n12\t# ── MODES ─────────────────────────────────────────────────────────────────────────────────────\n13\t# (default) — generate key if missing, copy to remote, update conf\n14\t# --force — regenerate key even if it exists, re-copy to remote\n15\t# --validate — test SSH auth to remote, track strikes, notify at limit\n16\t# --status — show key state, fingerprint, remote connectivity\n17\t# --dry-run — preview without creating, copying, or updating conf\n18\t#\n19\t# ── STRIKE SYSTEM (--validate) ────────────────────────────────────────────────────────────────\n20\t# Called during partnership --check cycles to detect broken SSH auth.\n21\t# Tracks consecutive SSH auth failures — not network unreachability.\n22\t# Remote Tailscale IP unreachable = network issue → not counted as SSH strike.\n23\t# Remote reachable but SSH auth fails = key issue → strike incremented.\n24\t# Strikes reset automatically after SSH_STRIKE_RESET_HRS of clean connectivity.\n25\t# At SSH_MAX_STRIKES: notify + return exit 2 (caller can escalate).\n26\t# State: DATA_DIR/ssh_strikes_{REMOTE_SERVER_NAME}.db\n27\t#\n28\t# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────\n29\t# SSH_MAX_STRIKES — consecutive failures before notifying (default 5)\n30\t# SSH_STRIKE_RESET_HRS — hours since last failure before counter resets (default 24)\n31\t#\n32\t# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────\n33\t# Partnership/ssh_setup.sh — initial setup (idempotent)\n34\t# Partnership/ssh_setup.sh --force — regenerate + re-copy\n35\t# Partnership/ssh_setup.sh --validate — health check + strike tracking\n36\t# Partnership/ssh_setup.sh --status — show key and connectivity state\n37\t# Any mode supports --dry-run and --log\n38\t# ==============================================================================================\n39\t\n40\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n41\tSCRIPTS_ROOT=\"$SCRIPT_DIR/..\"\n42\t\n43\tsource \"$SCRIPTS_ROOT/load_config.sh\"\n44\t\n45\t# ── Parse --force before parse_args ───────────────────────────────────────────────────────────\n46\tMODE=\"setup\"\n47\tFORCE=false\n48\tLOCAL_ONLY=false\n49\tFILTERED_ARGS=()\n50\t\n51\tfor arg in \"$@\"; do\n52\t case \"$arg\" in\n53\t --force) FORCE=true ;;\n54\t --validate) MODE=\"validate\" ;;\n55\t --status) MODE=\"status\" ;;\n56\t --local-only) LOCAL_ONLY=true ;;\n57\t *) FILTERED_ARGS+=(\"$arg\") ;;\n58\t esac\n59\tdone\n60\t\n61\tparse_args \"${FILTERED_ARGS[@]}\"\n62\t\n63\t# ==============================================================================================\n64\t# ━━━ Setup ━━━\n65\t# ==============================================================================================\n66\tif [[ \"$EUID\" -ne 0 ]]; then\n67\t error \"Must be run as root\"\n68\t exit 1\n69\tfi\n70\t\n71\tacquire_lock\n72\t\n73\tdetect_hosts\n74\t\n75\t# ── Derive key name from hostname ─────────────────────────────────────────────────────────────\n76\t# Strip unraid- prefix (case-insensitive) if present → gmer4lfe_rsync_automation\n77\tSHORT_NAME=\"${LOCAL_SERVER_NAME,,}\"\n78\t[[ \"${SHORT_NAME}\" == unraid-* ]] && SHORT_NAME=\"${SHORT_NAME:7}\"\n79\tSSH_KEY_NAME=\"${SHORT_NAME}_rsync_automation\"\n80\tSSH_KEY_PATH=\"/root/.ssh/${SSH_KEY_NAME}\"\n81\tSSH_PUB_PATH=\"${SSH_KEY_PATH}.pub\"\n82\t\n83\t# ── Host conf path ────────────────────────────────────────────────────────────────────────────\n84\tHOST_NUM=\"${MY_ID#HOST}\" # \"1\" or \"2\"\n85\tHOST_CONF=\"$SCRIPTS_ROOT/Configurations/host${HOST_NUM}.conf\"\n86\tKEY_CONF_VAR=\"${MY_ID}_SSH_KEY\"\n87\t\n88\t# ── Strike state file ─────────────────────────────────────────────────────────────────────────\n89\tSSH_STRIKE_FILE=\"$DATA_DIR/ssh_strikes_${REMOTE_SERVER_NAME}.db\"\n90\t\n91\t# ==============================================================================================\n92\t# ─── HELPERS ──────────────────────────────────────────────────────────────────────────────────\n93\t# ==============================================================================================\n94\t\n95\ttest_ssh_auth() {\n96\t local remote_ip=\"$1\"\n97\t timeout \"${SSH_TIMEOUT:-15}\" ssh \\\n98\t -i \"$SSH_KEY_PATH\" \\\n99\t -o BatchMode=yes \\\n100\t -o ConnectTimeout=\"${SSH_TIMEOUT:-15}\" \\\n101\t -o StrictHostKeyChecking=no \\\n102\t root@\"$remote_ip\" \"echo ok\" >/dev/null 2>&1\n103\t}\n104\t\n105\tread_strikes() {\n106\t grep \"^strikes=\" \"$SSH_STRIKE_FILE\" 2>/dev/null | cut -d= -f2 || echo 0\n107\t}\n108\t\n109\tread_last_strike_epoch() {\n110\t local ts\n111\t ts=$(grep \"^last_strike=\" \"$SSH_STRIKE_FILE\" 2>/dev/null | cut -d= -f2)\n112\t [[ -n \"$ts\" ]] && date -d \"$ts\" +%s 2>/dev/null || echo 0\n113\t}\n114\t\n115\twrite_strike_file() {\n116\t local strikes=\"$1\" last_strike=\"${2:-}\" last_success=\"${3:-}\"\n117\t mkdir -p \"$(dirname \"$SSH_STRIKE_FILE\")\"\n118\t cat > \"$SSH_STRIKE_FILE\" <<EOF\n119\tstrikes=${strikes}\n120\tlast_strike=${last_strike}\n121\tlast_success=${last_success}\n122\tupdated=$(date '+%Y-%m-%d %H:%M:%S')\n123\tEOF\n124\t}\n125\t\n126\tupdate_conf_key_path() {\n127\t if [[ ! -f \"$HOST_CONF\" ]]; then\n128\t warn \"$(basename \"$HOST_CONF\") not found — update ${KEY_CONF_VAR} manually to: $SSH_KEY_PATH\"\n129\t return 1\n130\t fi\n131\t\n132\t local current\n133\t current=$(grep \"^[[:space:]]*${KEY_CONF_VAR}=\" \"$HOST_CONF\" 2>/dev/null | \\\n134\t sed 's/.*=\"\\?\\([^\"]*\\)\"\\?.*/\\1/')\n135\t\n136\t if [[ \"$current\" == \"$SSH_KEY_PATH\" ]]; then\n137\t log \"${KEY_CONF_VAR} already correct in $(basename \"$HOST_CONF\")\"\n138\t return 0\n139\t fi\n140\t\n141\t if grep -q \"^[[:space:]]*${KEY_CONF_VAR}=\" \"$HOST_CONF\" 2>/dev/null; then\n142\t sed -i \"s|^[[:space:]]*${KEY_CONF_VAR}=.*| ${KEY_CONF_VAR}=\\\"${SSH_KEY_PATH}\\\"|\" \"$HOST_CONF\" && \\\n143\t log \"${KEY_CONF_VAR} updated in $(basename \"$HOST_CONF\") ✅\" || \\\n144\t warn \"Failed to update ${KEY_CONF_VAR} in $(basename \"$HOST_CONF\") — update manually\"\n145\t else\n146\t warn \"${KEY_CONF_VAR} not found in $(basename \"$HOST_CONF\") — add manually:\"\n147\t warn \" ${KEY_CONF_VAR}=\\\"${SSH_KEY_PATH}\\\"\"\n148\t fi\n149\t}\n150\t\n151\t# ==============================================================================================\n152\t# ━━━ Status ━━━\n153\t# ==============================================================================================\n154\tif [[ \"$MODE\" == \"status\" ]]; then\n155\t echo \"\"\n156\t echo \"━━━━━ $ICON_SUMMARY SSH SETUP STATUS ━━━━━\"\n157\t echo \"$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)\"\n158\t echo \"$ICON_NET Remote: $REMOTE_SERVER_NAME\"\n159\t echo \"\"\n160\t echo \" Key name: $SSH_KEY_NAME\"\n161\t echo \" Key path: $SSH_KEY_PATH\"\n162\t echo \"\"\n163\t\n164\t if [[ -f \"$SSH_KEY_PATH\" ]]; then\n165\t local_fp=$(ssh-keygen -lf \"$SSH_KEY_PATH\" 2>/dev/null || echo \"unreadable\")\n166\t echo \" $ICON_DONE Private key: present\"\n167\t echo \" Fingerprint: $local_fp\"\n168\t else\n169\t echo \" $ICON_ERROR Private key: NOT found\"\n170\t fi\n171\t\n172\t if [[ -f \"$SSH_PUB_PATH\" ]]; then\n173\t echo \" $ICON_DONE Public key: present\"\n174\t else\n175\t echo \" $ICON_ERROR Public key: NOT found\"\n176\t fi\n177\t\n178\t # Check conf\n179\t if [[ -f \"$HOST_CONF\" ]]; then\n180\t current_conf=$(grep \"^[[:space:]]*${KEY_CONF_VAR}=\" \"$HOST_CONF\" 2>/dev/null | \\\n181\t sed 's/.*=\"\\?\\([^\"]*\\)\"\\?.*/\\1/')\n182\t if [[ \"$current_conf\" == \"$SSH_KEY_PATH\" ]]; then\n183\t echo \" $ICON_DONE Conf ($(basename \"$HOST_CONF\")): ${KEY_CONF_VAR} ✅\"\n184\t else\n185\t echo \" $ICON_ERROR Conf ($(basename \"$HOST_CONF\")): ${KEY_CONF_VAR}=${current_conf:-not set}\"\n186\t fi\n187\t fi\n188\t\n189\t # Remote connectivity\n190\t echo \"\"\n191\t REMOTE_IP=$(resolve_tailscale_ip \"$REMOTE_SERVER_NAME\")\n192\t if [[ -z \"$REMOTE_IP\" ]]; then\n193\t echo \" $ICON_ERROR Remote ($REMOTE_SERVER_NAME): Tailscale unreachable\"\n194\t elif [[ -f \"$SSH_KEY_PATH\" ]] && test_ssh_auth \"$REMOTE_IP\"; then\n195\t echo \" $ICON_DONE Remote ($REMOTE_SERVER_NAME [$REMOTE_IP]): SSH auth OK ✅\"\n196\t else\n197\t echo \" $ICON_ERROR Remote ($REMOTE_SERVER_NAME [$REMOTE_IP]): SSH auth FAILED\"\n198\t fi\n199\t\n200\t # Strike state\n201\t if [[ -f \"$SSH_STRIKE_FILE\" ]]; then\n202\t echo \"\"\n203\t echo \" Strike state:\"\n204\t while IFS= read -r line; do\n205\t echo \" $line\"\n206\t done < \"$SSH_STRIKE_FILE\"\n207\t fi\n208\t\n209\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n210\t exit 0\n211\tfi\n212\t\n213\t# ==============================================================================================\n214\t# ━━━ Validate (SSH health check + strike tracking) ━━━\n215\t# ==============================================================================================\n216\tif [[ \"$MODE\" == \"validate\" ]]; then\n217\t MAX_STRIKES=\"${SSH_MAX_STRIKES:-5}\"\n218\t RESET_HRS=\"${SSH_STRIKE_RESET_HRS:-24}\"\n219\t\n220\t REMOTE_IP=$(resolve_tailscale_ip \"$REMOTE_SERVER_NAME\")\n221\t if [[ -z \"$REMOTE_IP\" ]]; then\n222\t log \"SSH validate — $REMOTE_SERVER_NAME Tailscale unreachable, not an SSH issue\"\n223\t exit 0\n224\t fi\n225\t\n226\t if [[ ! -f \"$SSH_KEY_PATH\" ]]; then\n227\t warn \"SSH validate — key not found at $SSH_KEY_PATH — run ssh_setup.sh to create\"\n228\t exit 1\n229\t fi\n230\t\n231\t NOW=$(date '+%Y-%m-%d %H:%M:%S')\n232\t NOW_EPOCH=$(date +%s)\n233\t\n234\t if test_ssh_auth \"$REMOTE_IP\"; then\n235\t # SSH works — reset strikes if they were > 0\n236\t STRIKES=$(read_strikes)\n237\t if [[ \"$STRIKES\" -gt 0 ]]; then\n238\t write_strike_file 0 \"\" \"$NOW\"\n239\t log \"SSH validate — auth restored to $REMOTE_SERVER_NAME ✅ (strikes reset)\"\n240\t else\n241\t log \"SSH validate — $REMOTE_SERVER_NAME SSH auth OK ✅\"\n242\t fi\n243\t exit 0\n244\t fi\n245\t\n246\t # SSH auth failed — check if strikes should reset first\n247\t STRIKES=$(read_strikes)\n248\t LAST_STRIKE_EPOCH=$(read_last_strike_epoch)\n249\t RESET_SECS=$(( RESET_HRS * 3600 ))\n250\t\n251\t if [[ \"$STRIKES\" -gt 0 ]] && \\\n252\t [[ \"$LAST_STRIKE_EPOCH\" -gt 0 ]] && \\\n253\t [[ $(( NOW_EPOCH - LAST_STRIKE_EPOCH )) -gt \"$RESET_SECS\" ]]; then\n254\t warn \"SSH validate — strike counter reset after ${RESET_HRS}hr gap\"\n255\t STRIKES=0\n256\t fi\n257\t\n258\t STRIKES=$(( STRIKES + 1 ))\n259\t write_strike_file \"$STRIKES\" \"$NOW\" \"\"\n260\t warn \"SSH validate — $REMOTE_SERVER_NAME auth FAILED (strike $STRIKES/$MAX_STRIKES)\"\n261\t\n262\t if [[ \"$STRIKES\" -ge \"$MAX_STRIKES\" ]]; then\n263\t error \"SSH auth to $REMOTE_SERVER_NAME broken — ${STRIKES} consecutive failures\"\n264\t error \"Repair: run 'Partnership/ssh_setup.sh --force' to regenerate and re-copy key\"\n265\t notify \"SSH auth broken to $REMOTE_SERVER_NAME ($MY_ID) — ${STRIKES} strikes, manual repair needed. Run: Partnership/ssh_setup.sh --force\" \\\n266\t \"SSH Setup\" \"warning\"\n267\t exit 2\n268\t fi\n269\t\n270\t exit 1\n271\tfi\n272\t\n273\t# ==============================================================================================\n274\t# ━━━ Setup (default) or --force ━━━\n275\t# ==============================================================================================\n276\techo \"\"\n277\techo \"━━━ $ICON_GEAR SSH Key Setup — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━\"\n278\techo \"\"\n279\techo \" Key name: $SSH_KEY_NAME\"\n280\techo \" Key path: $SSH_KEY_PATH\"\n281\t\n282\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no keys will be created or copied\"\n283\t\n284\t# ── Key generation ────────────────────────────────────────────────────────────────────────────\n285\techo \"\"\n286\techo \"━━━ $ICON_GEAR Key Generation ━━━\"\n287\t\n288\tif [[ -f \"$SSH_KEY_PATH\" ]] && [[ \"$FORCE\" == false ]]; then\n289\t local_fp=$(ssh-keygen -lf \"$SSH_KEY_PATH\" 2>/dev/null || echo \"unreadable\")\n290\t echo \"Key already exists — skipping generation (--force to regenerate)\"\n291\t echo \" $local_fp\"\n292\telse\n293\t if [[ \"$FORCE\" == true ]] && [[ -f \"$SSH_KEY_PATH\" ]]; then\n294\t warn \"Regenerating key (--force) — existing key will be replaced\"\n295\t if [[ \"$DRY_RUN\" == false ]]; then\n296\t rm -f \"$SSH_KEY_PATH\" \"$SSH_PUB_PATH\"\n297\t fi\n298\t fi\n299\t\n300\t warn \"Generating ed25519 keypair: $SSH_KEY_PATH\"\n301\t if [[ \"$DRY_RUN\" == false ]]; then\n302\t ssh-keygen -t ed25519 -N \"\" -f \"$SSH_KEY_PATH\" -C \"${SSH_KEY_NAME}@${LOCAL_SERVER_NAME}\" && \\\n303\t log \"Keypair generated ✅\" || {\n304\t error \"Failed to generate keypair\"\n305\t exit 1\n306\t }\n307\t chmod 600 \"$SSH_KEY_PATH\"\n308\t chmod 644 \"$SSH_PUB_PATH\"\n309\t else\n310\t warn \"DRY RUN — would generate: $SSH_KEY_PATH\"\n311\t fi\n312\tfi\n313\t\n314\t# ── Update conf ───────────────────────────────────────────────────────────────────────────────\n315\techo \"\"\n316\techo \"━━━ $ICON_GEAR Update host${HOST_NUM}.conf ━━━\"\n317\t\n318\tif [[ \"$DRY_RUN\" == false ]]; then\n319\t update_conf_key_path\n320\telse\n321\t warn \"DRY RUN — would set ${KEY_CONF_VAR}=\\\"${SSH_KEY_PATH}\\\" in host${HOST_NUM}.conf\"\n322\tfi\n323\t\n324\t# ── Copy to remote ────────────────────────────────────────────────────────────────────────────\n325\tif [[ \"$LOCAL_ONLY\" == true ]]; then\n326\t echo \"\"\n327\t echo \"━━━━━ $ICON_SUMMARY SSH SETUP SUMMARY (local) ━━━━━\"\n328\t echo \" Key: $SSH_KEY_PATH\"\n329\t echo \" Pubkey: $SSH_PUB_PATH\"\n330\t echo \" Conf: ${KEY_CONF_VAR} in host${HOST_NUM}.conf\"\n331\t echo \"\"\n332\t warn \"Local setup complete — copy public key to remote manually:\"\n333\t warn \" ssh-copy-id -i $SSH_PUB_PATH root@<remote>\"\n334\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n335\t exit 0\n336\tfi\n337\t\n338\techo \"\"\n339\techo \"━━━ $ICON_NET Copy Public Key to Remote ($REMOTE_SERVER_NAME) ━━━\"\n340\t\n341\tresolve_remote_ip\n342\t\n343\tif [[ \"$DRY_RUN\" == true ]]; then\n344\t warn \"DRY RUN — would ssh-copy-id to root@$REMOTE_SERVER\"\n345\t warn \"DRY RUN — would require password for root@$REMOTE_SERVER if key auth not yet set up\"\n346\telse\n347\t warn \"Installing public key on $REMOTE_SERVER_NAME ($REMOTE_SERVER)...\"\n348\t warn \"(Password prompt for root@$REMOTE_SERVER is expected on first setup)\"\n349\t\n350\t if ssh-copy-id -i \"$SSH_PUB_PATH\" -o ConnectTimeout=\"${SSH_TIMEOUT:-15}\" \\\n351\t root@\"$REMOTE_SERVER\" 2>/dev/null; then\n352\t echo \"Public key installed on $REMOTE_SERVER_NAME ✅\"\n353\t else\n354\t error \"ssh-copy-id failed — check that:\"\n355\t error \" 1. Remote server is reachable: tailscale status\"\n356\t error \" 2. Password auth is enabled on remote: grep PasswordAuthentication /etc/ssh/sshd_config\"\n357\t error \" 3. The correct password for root@$REMOTE_SERVER_NAME is used\"\n358\t exit 1\n359\t fi\n360\tfi\n361\t\n362\t# ── Verify ────────────────────────────────────────────────────────────────────────────────────\n363\techo \"\"\n364\techo \"━━━ $ICON_VERIFY Verify SSH Auth ━━━\"\n365\t\n366\tif [[ \"$DRY_RUN\" == false ]]; then\n367\t if test_ssh_auth \"$REMOTE_SERVER\"; then\n368\t echo \"SSH auth to $REMOTE_SERVER_NAME working ✅\"\n369\t if [[ -f \"$SSH_STRIKE_FILE\" ]]; then\n370\t write_strike_file 0 \"\" \"$(date '+%Y-%m-%d %H:%M:%S')\"\n371\t fi\n372\t else\n373\t warn \"SSH auth test failed — key may need a moment to propagate\"\n374\t warn \"Verify manually: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER\"\n375\t fi\n376\telse\n377\t warn \"DRY RUN — would test: ssh -i $SSH_KEY_PATH root@$REMOTE_SERVER echo ok\"\n378\tfi\n379\t\n380\t# ── Summary ───────────────────────────────────────────────────────────────────────────────────\n381\techo \"\"\n382\techo \"━━━━━ $ICON_SUMMARY SSH SETUP SUMMARY ━━━━━\"\n383\techo \" Key: $SSH_KEY_PATH\"\n384\techo \" Remote: $REMOTE_SERVER_NAME ($REMOTE_SERVER)\"\n385\techo \" Conf: ${KEY_CONF_VAR} in host${HOST_NUM}.conf\"\n386\techo \"\"\n387\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no changes made\" || \\\n388\t warn \"$ICON_DONE DONE — SSH key ready for rsync automation ✅\"\n389\techo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n390\texit 0\n391\t"}]},"uuid":"d40d0eaf-35c7-492a-bfce-ff838cbd2f93","timestamp":"2026-06-14T05:32:22.632Z","sourceToolAssistantUUID":"ab9e18b7-934f-4c1a-9ece-d731d7a853b4","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"3715a7ae-dbab-4b98-aa56-d771d95972df","isSidechain":true,"promptId":"4ede2c2c-ae62-435a-a0fb-4f8d63fa41ab","agentId":"a41f0ff6991d1474c","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Rvu9apRpzVs4Rutbupa8oD","type":"tool_result","content":"1\t#!/bin/bash\n2\t# ==============================================================================================\n3\t# ============================= Partnership Onboard ============================================\n4\t# ==============================================================================================\n5\t#\n6\t# PURPOSE\n7\t# ─────────────────────────────────────────────────────────────────────────────\n8\t# Runs once on both servers to establish a new partnership. Role is detected\n9\t# automatically via detect_hosts() — no flags needed to declare which side you are.\n10\t# Run on the mirror first (generates its SSH key), then on the owner to complete\n11\t# setup remotely.\n12\t#\n13\t# ==============================================================================================\n14\t# OPERATIONAL MODEL\n15\t# ==============================================================================================\n16\t#\n17\t# MIRROR PATH (1 step)\n18\t# Step 1: SSH key setup — generate keypair, copy to owner, update conf\n19\t# Owner completes the rest remotely. Mirror is done.\n20\t#\n21\t# OWNER PATH (10 steps)\n22\t# Step 1: SSH key setup — generate keypair, install on mirror, update conf\n23\t# Step 2: Stop mirror auth — stop mirror's existing auth containers before replacing\n24\t# Step 3: Deploy auth stack — push XMLs, pull images, create + start on mirror\n25\t# Mariadb/Redis health-checked before Authelia deploys\n26\t# Step 4: Stop mirror arr — stop mirror's existing arr containers before replacing\n27\t# Step 5: Deploy arr stack — push arr XMLs, pull images, create + start on mirror\n28\t# Step 6: Stop mirror services — stop mirror's existing services containers before replacing\n29\t# Step 7: Deploy services stack — push Emby/Jellyfin/Seerr XMLs, pull images, create + start\n30\t# Step 8: Partnership onboard — configure WebUIs → owner IP, write state, Emby\n31\t# Step 9: Arr bootstrap — bidirectional library sync (arr_sync.sh)\n32\t# Step 10: Conf push — push master.conf + setup state to all listed hosts\n33\t#\n34\t# ==============================================================================================\n35\t# DESIGN PRINCIPLES\n36\t# ==============================================================================================\n37\t#\n38\t# Credentials never in SSH command strings\n39\t# Auth stack containers hold API keys, DB passwords, etc. The deploy script is written\n40\t# locally, SCPed to the remote, and executed there. Command-line args are never used\n41\t# to pass credentials — they'd appear in `ps` output and shell history on both servers.\n42\t#\n43\t# XML templates are the single source of truth for deployed containers\n44\t# The owner's templates-user/ XMLs define every container deployed on the mirror.\n45\t# The same XMLs that Unraid's Docker Manager uses are what get SCPed — the mirror's\n46\t# Docker Manager can manage the containers after onboard without additional config.\n47\t#\n48\t# Dependency ordering in the auth stack is owner-enforced\n49\t# PARTNERSHIP_AUTH_STACK order matters: Mariadb and Redis must come before Authelia.\n50\t# The array is ordered correctly in host1.conf. After each Mariadb/Redis deploy,\n51\t# the script waits for the container to be healthy before continuing. This is a remote\n52\t# health check — the container must be running (or report healthy) before the next\n53\t# dependent is deployed.\n54\t#\n55\t# ==============================================================================================\n56\t# OPERATIONAL SAFEGUARDS\n57\t# ==============================================================================================\n58\t#\n59\t# Root check\n60\t# All operations run as root — SSH key management, docker operations, conf updates.\n61\t#\n62\t# SSH timeout on all remote calls\n63\t# Every ssh/scp call uses SSH_TIMEOUT. No operation hangs indefinitely on a\n64\t# slow or unreachable mirror.\n65\t#\n66\t# --dry-run shows exact actions without executing\n67\t# Every step prints what it would do. SCP, deploy, plugin install, arr sync —\n68\t# all dry-run safe.\n69\t#\n70\t# Step skip flags for partial re-runs\n71\t# --skip-ssh, --skip-auth-stack, --skip-arr-stack, --skip-arr-sync allow\n72\t# resuming after a partial failure without re-running completed steps.\n73\t#\n74\t# ==============================================================================================\n75\t# CONFIGURATION\n76\t# ==============================================================================================\n77\t#\n78\t# host*.conf\n79\t#\n80\t# HOST*_PARTNERSHIP_AUTH_STACK\n81\t# XML filenames (from this server's templates-user/) to push and deploy on the\n82\t# mirror as its auth stack. Order matters: database deps before Authelia.\n83\t# Aliased by detect_hosts() → PARTNERSHIP_AUTH_STACK\n84\t#\n85\t# HOST*_PARTNERSHIP_REPLACE_CONTAINERS\n86\t# Containers to stop on the mirror before deploying the auth stack.\n87\t# Defined in the MIRROR's own conf (host*.conf on HOST2) — never in HOST1's conf.\n88\t# Read live from the mirror via SSH during Step 3 (sources mirror's load_config.sh at\n89\t# the same $SCRIPTS_ROOT path — convention: both servers use the same repo location).\n90\t# Leave empty on HOST2 if no conflicting containers exist (fresh mirror: nothing to stop).\n91\t# Aliased by detect_hosts() → PARTNERSHIP_REPLACE_CONTAINERS (on the mirror)\n92\t#\n93\t# HOST*_PARTNERSHIP_ARR_STACK\n94\t# XML filenames to push and deploy on the mirror as its arr stack.\n95\t# Leave empty to skip arr stack deploy.\n96\t# Aliased by detect_hosts() → PARTNERSHIP_ARR_STACK\n97\t#\n98\t# HOST*_PARTNERSHIP_ARR_REPLACE_CONTAINERS\n99\t# Arr containers to stop on the mirror before deploying the arr stack.\n100\t# Same rule as PARTNERSHIP_REPLACE_CONTAINERS: defined in mirror's own conf, never HOST1's.\n101\t# Aliased by detect_hosts() → PARTNERSHIP_ARR_REPLACE_CONTAINERS (on the mirror)\n102\t#\n103\t# HOST*_PARTNERSHIP_SERVICES_STACK\n104\t# XML filenames to push and deploy on the mirror as its shared services stack.\n105\t# Includes Emby, Jellyfin, Seerr, SeerrFin. Leave empty to skip services stack deploy.\n106\t# Aliased by detect_hosts() → PARTNERSHIP_SERVICES_STACK\n107\t#\n108\t# HOST*_PARTNERSHIP_SERVICES_REPLACE_CONTAINERS\n109\t# Services containers to stop on the mirror before deploying the services stack.\n110\t# Same rule as PARTNERSHIP_REPLACE_CONTAINERS: defined in mirror's own conf, never HOST1's.\n111\t# Aliased by detect_hosts() → PARTNERSHIP_SERVICES_REPLACE_CONTAINERS (on the mirror)\n112\t#\n113\t# ==============================================================================================\n114\t# RUNTIME MODES\n115\t# ==============================================================================================\n116\t#\n117\t# Partnership/partnership_onboard.sh\n118\t# Full onboard — role detected automatically\n119\t#\n120\t# Partnership/partnership_onboard.sh --dry-run\n121\t# Preview all steps without making changes\n122\t#\n123\t# Partnership/partnership_onboard.sh --log\n124\t# Verbose per-step output\n125\t#\n126\t# Partnership/partnership_onboard.sh --skip-ssh\n127\t# Skip SSH key setup (key already in place)\n128\t#\n129\t# Partnership/partnership_onboard.sh --skip-auth-stack\n130\t# Skip auth stack stop + deploy (Steps 3-4)\n131\t#\n132\t# Partnership/partnership_onboard.sh --skip-arr-stack\n133\t# Skip arr stack stop + deploy (Steps 4-5)\n134\t#\n135\t# Partnership/partnership_onboard.sh --skip-services-stack\n136\t# Skip services stack stop + deploy (Steps 6-7)\n137\t#\n138\t# Partnership/partnership_onboard.sh --skip-arr-sync\n139\t# Skip arr library bootstrap (Step 9)\n140\t#\n141\t# Partnership/partnership_onboard.sh --phase1-only\n142\t# OWNER only: SSH key exchange + conf push. Safe to run before HOST2 has Varaverk.\n143\t# Writes HOST2_PHASE1_DONE=true to varaverk_setup.db.\n144\t#\n145\t# Partnership/partnership_onboard.sh --phase2-only\n146\t# OWNER only: container deploy + arr + onboard (skips SSH). Triggered automatically\n147\t# by HOST2 after it completes its Mirror-path onboard. Can also be run manually.\n148\t# Writes HOST2_PHASE2_DONE=true to varaverk_setup.db.\n149\t#\n150\t# ==============================================================================================\n151\t\n152\tSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n153\tSCRIPTS_ROOT=\"$SCRIPT_DIR/..\"\n154\tSSH_TIMEOUT=15\n155\t\n156\tsource \"$SCRIPTS_ROOT/load_config.sh\"\n157\tsource \"$SCRIPTS_ROOT/Plugin/$PLATFORM/Partnership/containers.sh\"\n158\t\n159\t# ── Parse flags ───────────────────────────────────────────────────────────────────────────────\n160\tSKIP_SSH=false\n161\tSKIP_AUTH_STACK=false\n162\tSKIP_ARR_STACK=false\n163\tSKIP_SERVICES_STACK=false\n164\tSKIP_ARR_SYNC=false\n165\tPHASE1_ONLY=false # OWNER: SSH + conf push only (HOST2 not yet installed)\n166\tPHASE2_ONLY=false # OWNER: containers/arr/onboard only (triggered by HOST2 after it onboards)\n167\tFILTERED_ARGS=()\n168\t\n169\tfor arg in \"$@\"; do\n170\t case \"$arg\" in\n171\t --skip-ssh) SKIP_SSH=true ;;\n172\t --skip-auth-stack) SKIP_AUTH_STACK=true ;;\n173\t --skip-arr-stack) SKIP_ARR_STACK=true ;;\n174\t --skip-services-stack) SKIP_SERVICES_STACK=true ;;\n175\t --skip-arr-sync) SKIP_ARR_SYNC=true ;;\n176\t --phase1-only) PHASE1_ONLY=true ;;\n177\t --phase2-only) PHASE2_ONLY=true; SKIP_SSH=true ;;\n178\t *) FILTERED_ARGS+=(\"$arg\") ;;\n179\t esac\n180\tdone\n181\t\n182\tparse_args \"${FILTERED_ARGS[@]}\"\n183\t\n184\t# ==============================================================================================\n185\t# ━━━ Setup ━━━\n186\t# ==============================================================================================\n187\t[[ \"$EUID\" -ne 0 ]] && { error \"Must be run as root\"; exit 1; }\n188\t\n189\tacquire_lock\n190\t\n191\tif ! command -v docker &>/dev/null; then\n192\t error \"Docker command not found\"\n193\t exit 1\n194\tfi\n195\t\n196\tdetect_hosts\n197\t\n198\tOWNER_ID=\"${PARTNERSHIP_OWNER_HOST:-HOST1}\"\n199\tMIRROR_ID=$( [[ \"$OWNER_ID\" == \"HOST1\" ]] && echo \"HOST2\" || echo \"HOST1\" )\n200\tOWNER=\"${!OWNER_ID}\"\n201\tMIRROR=\"${!MIRROR_ID}\"\n202\t# SSH_KEY (set by detect_hosts) is this server's own private key.\n203\t# The remote accepts it because this server's PUBLIC key was installed there via ssh_setup.sh.\n204\t# HOST{N}_SSH_KEY lives in host{N}.conf — with sparse checkout, the other server's\n205\t# conf is never present here. Always use SSH_KEY (local private key) for outbound SSH.\n206\tMIRROR_SSH_KEY=\"$SSH_KEY\"\n207\t\n208\tAM_OWNER=false\n209\tAM_MIRROR=false\n210\t[[ \"$MY_ID\" == \"$OWNER_ID\" ]] && AM_OWNER=true\n211\t[[ \"$MY_ID\" == \"$MIRROR_ID\" ]] && AM_MIRROR=true\n212\t\n213\tEXTRA_FLAGS=()\n214\t[[ \"$DRY_RUN\" == true ]] && EXTRA_FLAGS+=(\"--dry-run\")\n215\t[[ \"$LOG_MODE\" == true ]] && EXTRA_FLAGS+=(\"--log\")\n216\t\n217\tSTART=$(date +%s)\n218\t\n219\t# ── Helper: write phase completion flag to setup.db + push to remotes ─────────────────────────\n220\twrite_onboard_phase() {\n221\t local target_id=\"$1\" phase=\"$2\"\n222\t local key=\"${target_id}_PHASE${phase}_DONE\"\n223\t local state_file=\"$(platform_setup_db_path)\"\n224\t [[ \"$DRY_RUN\" == true ]] && { warn \"DRY RUN — would write ${key}=true\"; return 0; }\n225\t if grep -q \"^${key}=\" \"$state_file\" 2>/dev/null; then\n226\t sed -i \"s|^${key}=.*|${key}=true|\" \"$state_file\"\n227\t else\n228\t echo \"${key}=true\" >> \"$state_file\"\n229\t fi\n230\t platform_push_setup_state\n231\t}\n232\t\n233\techo \"\"\n234\techo \"━━━ $ICON_FALLBACK Partnership Onboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━\"\n235\techo \"\"\n236\techo \" Role: $( [[ \"$AM_OWNER\" == true ]] && echo \"OWNER\" || echo \"MIRROR\" )\"\n237\techo \" This: $MY_ID ($LOCAL_SERVER_NAME)\"\n238\techo \" Partner: $( [[ \"$AM_OWNER\" == true ]] && echo \"$MIRROR_ID ($MIRROR)\" || echo \"$OWNER_ID ($OWNER)\" )\"\n239\techo \"\"\n240\t[[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no permanent changes will be made\"\n241\t\n242\t# ==============================================================================================\n243\t# ── HELPER: stop containers on the mirror by reading its own conf via SSH ────────────────────\n244\t#\n245\t# SSHes to the mirror, sources its load_config.sh at the same $SCRIPTS_ROOT path (both servers\n246\t# use the same convention), and reads the named config array from the mirror's own conf.\n247\t# HOST2's container list stays in HOST2's host2.conf — not duplicated in HOST1's conf.\n248\t# Fails gracefully if scripts aren't present yet or the array is empty (nothing to stop).\n249\t#\n250\t# deploy_container_from_xml() already stops/removes containers with the same name as what's\n251\t# being deployed. This step handles containers with DIFFERENT names that conflict.\n252\t# ==============================================================================================\n253\tstop_mirror_stack() {\n254\t local config_var=\"$1\" label=\"$2\"\n255\t local -a to_stop=()\n256\t\n257\t mapfile -t to_stop < <(\n258\t timeout \"$SSH_TIMEOUT\" ssh -i \"$MIRROR_SSH_KEY\" \\\n259\t -o ConnectTimeout=\"$SSH_TIMEOUT\" -o BatchMode=yes root@\"$MIRROR_IP\" \\\n260\t \"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null\n261\t detect_hosts 2>/dev/null\n262\t printf '%s\\n' \\\"\\${${config_var}[@]:-}\\\"\" 2>/dev/null | grep -v '^$'\n263\t )\n264\t\n265\t if [[ ${#to_stop[@]} -eq 0 ]]; then\n266\t log \"No $label containers to stop on $MIRROR — skipping\"\n267\t return 0\n268\t fi\n269\t\n270\t log \"Stopping $label on $MIRROR: ${to_stop[*]}\"\n271\t for container in \"${to_stop[@]}\"; do\n272\t if [[ \"$DRY_RUN\" == true ]]; then\n273\t warn \" DRY RUN — would stop + rm $container on $MIRROR\"\n274\t continue\n275\t fi\n276\t timeout \"$SSH_TIMEOUT\" ssh -i \"$MIRROR_SSH_KEY\" \\\n277\t -o ConnectTimeout=\"$SSH_TIMEOUT\" root@\"$MIRROR_IP\" \\\n278\t \"docker stop '$container' 2>/dev/null\n279\t docker rm '$container' 2>/dev/null && echo removed\" 2>/dev/null | \\\n280\t grep -q removed && \\\n281\t log \" $container removed ✅\" || \\\n282\t log \" $container not found on $MIRROR — skipping\"\n283\t done\n284\t}\n285\t\n286\t# ==============================================================================================\n287\t# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────\n288\t# ==============================================================================================\n289\tif [[ \"$AM_MIRROR\" == true ]]; then\n290\t echo \"━━━ Step 1/2 — SSH Key Setup (Mirror) ━━━\"\n291\t echo \"\"\n292\t echo \" Mirror sets up SSH keys, then notifies Owner to run Phase 2.\"\n293\t echo \"\"\n294\t\n295\t if [[ \"$SKIP_SSH\" == true ]]; then\n296\t warn \"Skipping SSH setup (--skip-ssh)\"\n297\t elif bash \"$SCRIPT_DIR/ssh_setup.sh\" \"${EXTRA_FLAGS[@]}\"; then\n298\t log \"SSH key ready ✅\"\n299\t else\n300\t error \"SSH key setup failed\"\n301\t exit 1\n302\t fi\n303\t\n304\t echo \"\"\n305\t echo \"━━━ Step 2/2 — Notify Owner to Run Phase 2 ━━━\"\n306\t echo \"\"\n307\t\n308\t OWNER_IP=$(resolve_tailscale_ip \"$OWNER\" 2>/dev/null || true)\n309\t PHASE2_TRIGGERED=false\n310\t\n311\t if [[ -n \"$OWNER_IP\" ]]; then\n312\t # Read OWNER's SCRIPTS_DIR via platform probe command — don't assume same path as mirror\n313\t _probe_cmd=$(platform_scripts_dir_probe_cmd)\n314\t OWNER_SCRIPTS_DIR=$(timeout \"$SSH_TIMEOUT\" ssh -i \"$SSH_KEY\" \\\n315\t -o ConnectTimeout=\"$SSH_TIMEOUT\" -o BatchMode=yes root@\"$OWNER_IP\" \\\n316\t \"$_probe_cmd\" 2>/dev/null | tr -d '[:space:]')\n317\t OWNER_SCRIPTS_DIR=\"${OWNER_SCRIPTS_DIR:-$SCRIPTS_DIR}\"\n318\t\n319\t if [[ \"$DRY_RUN\" == true ]]; then\n320\t warn \"DRY RUN — would SSH to $OWNER ($OWNER_IP) and trigger Phase 2\"\n321\t PHASE2_TRIGGERED=true\n322\t elif timeout \"$SSH_TIMEOUT\" ssh -i \"$SSH_KEY\" \\\n323\t -o ConnectTimeout=\"$SSH_TIMEOUT\" -o BatchMode=yes root@\"$OWNER_IP\" \\\n324\t \"nohup bash '${OWNER_SCRIPTS_DIR}/Partnership/partnership_onboard.sh' --phase2-only > /tmp/vv_phase2_onboard.log 2>&1 & echo triggered\" \\\n325\t 2>/dev/null | grep -q triggered; then\n326\t log \"Phase 2 triggered on $OWNER ✅\"\n327\t log \"Watch progress on $OWNER: tail -f /tmp/vv_phase2_onboard.log\"\n328\t PHASE2_TRIGGERED=true\n329\t else\n330\t warn \"Could not auto-trigger Phase 2 on $OWNER\"\n331\t fi\n332\t else\n333\t warn \"Cannot resolve $OWNER Tailscale IP\"\n334\t fi\n335\t\n336\t echo \"\"\n337\t echo \"━━━━━ $ICON_SUMMARY MIRROR SETUP COMPLETE ━━━━━\"\n338\t echo \" SSH key: ready\"\n339\t echo \" Phase 2 on $OWNER: $( [[ \"$PHASE2_TRIGGERED\" == true ]] && echo \"triggered ✅\" || echo \"needs manual trigger ⚠\" )\"\n340\t if [[ \"$PHASE2_TRIGGERED\" == false ]]; then\n341\t echo \"\"\n342\t echo \" Run manually on $OWNER:\"\n343\t echo \" bash Partnership/partnership_onboard.sh --phase2-only\"\n344\t fi\n345\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n346\t exit 0\n347\tfi\n348\t\n349\t# ==============================================================================================\n350\t# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────\n351\t# ==============================================================================================\n352\t\n353\tMIRROR_IP=$(resolve_tailscale_ip \"$MIRROR\")\n354\t[[ -z \"$MIRROR_IP\" ]] && { error \"Cannot resolve $MIRROR Tailscale IP — is Tailscale running?\"; exit 1; }\n355\tlog \"Mirror: $MIRROR ($MIRROR_IP)\"\n356\t[[ \"$PHASE1_ONLY\" == true ]] && log \"Mode: Phase 1 only (SSH + conf push)\"\n357\t[[ \"$PHASE2_ONLY\" == true ]] && log \"Mode: Phase 2 only (containers + arr + onboard)\"\n358\techo \"\"\n359\t\n360\tSTEP_SSH_OK=false\n361\tSTEP_STOP_AUTH_OK=true\n362\tSTEP_AUTH_OK=true\n363\tAUTH_DEPLOYED=0\n364\tAUTH_FAILED=0\n365\tSTEP_STOP_ARR_OK=true\n366\tSTEP_ARR_OK=true\n367\tARR_DEPLOYED=0\n368\tARR_FAILED=0\n369\tSTEP_STOP_SERVICES_OK=true\n370\tSTEP_SERVICES_OK=true\n371\tSERVICES_DEPLOYED=0\n372\tSERVICES_FAILED=0\n373\tONBOARD_OK=false\n374\tARR_SYNC_OK=false\n375\tMASTER_PUSH_OK=false\n376\t\n377\t# ── Step 1: SSH ───────────────────────────────────────────────────────────────────────────────\n378\t# Skipped when --phase2-only (SSH was already done in Phase 1).\n379\techo \"━━━ Step 1 — SSH Key Setup ━━━\"\n380\t\n381\tif [[ \"$SKIP_SSH\" == true ]]; then\n382\t warn \"Skipping (--skip-ssh)\"\n383\t STEP_SSH_OK=true\n384\telif [[ \"$PHASE1_ONLY\" == true ]]; then\n385\t # Phase 1 in background: test if SSH already works first — avoids ssh-copy-id\n386\t # hanging for a password prompt with no TTY.\n387\t if timeout \"$SSH_TIMEOUT\" ssh -i \"$SSH_KEY\" \\\n388\t -o ConnectTimeout=\"$SSH_TIMEOUT\" -o BatchMode=yes root@\"$MIRROR_IP\" exit 0 2>/dev/null; then\n389\t log \"SSH to $MIRROR already works ✅ — skipping key install\"\n390\t STEP_SSH_OK=true\n391\t else\n392\t # Key not yet on HOST2 — try ssh_setup.sh (works interactively, may fail in background)\n393\t if bash \"$SCRIPT_DIR/ssh_setup.sh\" \"${EXTRA_FLAGS[@]}\"; then\n394\t log \"SSH keys ready ✅\"\n395\t STEP_SSH_OK=true\n396\t else\n397\t # Soft-fail: generate key locally if not present, then tell user to install manually\n398\t warn \"Could not install key on $MIRROR automatically (no terminal for password prompt)\"\n399\t if [[ -f \"$SSH_KEY\" ]]; then\n400\t log \"Local key exists at: $SSH_KEY\"\n401\t else\n402\t bash \"$SCRIPT_DIR/ssh_setup.sh\" --key-only \"${EXTRA_FLAGS[@]}\" 2>/dev/null || true\n403\t fi\n404\t if [[ -f \"${SSH_KEY}.pub\" ]]; then\n405\t echo \"\"\n406\t echo \" Install this key on $MIRROR to complete SSH setup:\"\n407\t echo \" ┌─────────────────────────────────────────────────────\"\n408\t cat \"${SSH_KEY}.pub\" | sed 's/^/ │ /'\n409\t echo \" └─────────────────────────────────────────────────────\"\n410\t echo \" Run on a terminal: ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}\"\n411\t echo \" Then click 'Push Conf' in the Partnership tab.\"\n412\t # Write key-ready flag so UI can show the manual-install state\n413\t [[ \"$DRY_RUN\" == false ]] && {\n414\t local kflag=\"${MIRROR_ID}_KEY_READY\"\n415\t local _setup_f=\"$(platform_setup_db_path)\"\n416\t grep -q \"^${kflag}=\" \"$_setup_f\" 2>/dev/null \\\n417\t && sed -i \"s|^${kflag}=.*|${kflag}=true|\" \"$_setup_f\" \\\n418\t || echo \"${kflag}=true\" >> \"$_setup_f\"\n419\t }\n420\t fi\n421\t STEP_SSH_OK=false\n422\t fi\n423\t fi\n424\telif bash \"$SCRIPT_DIR/ssh_setup.sh\" \"${EXTRA_FLAGS[@]}\"; then\n425\t log \"SSH keys ready ✅\"\n426\t STEP_SSH_OK=true\n427\telse\n428\t error \"SSH key setup failed — aborting\"\n429\t error \"Re-run or use --skip-ssh if key is already set up\"\n430\t exit 1\n431\tfi\n432\t\n433\t# ── Phase 1 exit point ────────────────────────────────────────────────────────────────────────\n434\t# --phase1-only: SSH + conf push is all HOST1 needs to do before HOST2 installs Varaverk.\n435\t# HOST2's wizard will detect the pushed master.conf + state file and take the correct path.\n436\tif [[ \"$PHASE1_ONLY\" == true ]]; then\n437\t if [[ \"$STEP_SSH_OK\" == false ]]; then\n438\t # SSH key not yet installed on HOST2 — can't push conf, but local setup still runs.\n439\t # UI will show \"key ready, install manually\" state via HOST2_KEY_READY flag.\n440\t echo \"\"\n441\t echo \"━━━ Phase 1 — HOST1 Local Setup (SSH pending) ━━━\"\n442\t bash \"$SCRIPT_DIR/partnership_manager.sh\" --onboard --local-only \"${EXTRA_FLAGS[@]}\" || \\\n443\t warn \"Local setup had issues — check partnership_manager.sh output above\"\n444\t\n445\t END=$(date +%s)\n446\t echo \"\"\n447\t echo \"━━━━━ $ICON_SUMMARY PHASE 1 — SSH PENDING ━━━━━\"\n448\t echo \" SSH keys: key generated ✅ — NOT yet installed on $MIRROR ⚠\"\n449\t echo \" Conf push: skipped (needs SSH access to $MIRROR)\"\n450\t echo \" HOST1 setup: done ✅\"\n451\t echo \" Duration: $(format_duration $(( END - START )))\"\n452\t echo \"\"\n453\t echo \" ACTION NEEDED: install the key on $MIRROR:\"\n454\t echo \" ssh-copy-id -i ${SSH_KEY}.pub root@${MIRROR_IP}\"\n455\t echo \" Then click 'Push Conf' in Partnership tab, or run:\"\n456\t echo \" bash Partnership/partnership_onboard.sh --phase1-only --skip-ssh\"\n457\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n458\t exit 0\n459\t fi\n460\t\n461\t echo \"\"\n462\t echo \"━━━ Phase 1 — Conf Push ━━━\"\n463\t\n464\t CONF_PUSH_OK=false\n465\t if [[ \"$DRY_RUN\" == true ]]; then\n466\t warn \"DRY RUN — would push master.conf + state file to $MIRROR\"\n467\t CONF_PUSH_OK=true\n468\t else\n469\t push_output=$(platform_push_conf)\n470\t push_rc=$?\n471\t [[ -n \"$push_output\" ]] && echo \"$push_output\"\n472\t platform_push_setup_state\n473\t if [[ $push_rc -eq 0 ]]; then\n474\t log \"Conf push complete ✅\"\n475\t CONF_PUSH_OK=true\n476\t else\n477\t warn \"Conf push had failures — retry via Scheduler → master.conf → Save Conf\"\n478\t fi\n479\t fi\n480\t\n481\t # HOST1 local setup — runs immediately without needing HOST2\n482\t echo \"\"\n483\t echo \"━━━ Phase 1 — HOST1 Local Setup ━━━\"\n484\t bash \"$SCRIPT_DIR/partnership_manager.sh\" --onboard --local-only \"${EXTRA_FLAGS[@]}\" || \\\n485\t warn \"Local setup had issues — check partnership_manager.sh output above\"\n486\t\n487\t [[ \"$DRY_RUN\" == false ]] && write_onboard_phase \"$MIRROR_ID\" 1\n488\t\n489\t END=$(date +%s)\n490\t echo \"\"\n491\t echo \"━━━━━ $ICON_SUMMARY PHASE 1 COMPLETE ━━━━━\"\n492\t echo \" SSH keys: $( [[ \"$STEP_SSH_OK\" == true ]] && echo \"ready ✅\" || echo \"skipped\" )\"\n493\t echo \" Conf push: $( [[ \"$CONF_PUSH_OK\" == true ]] && echo \"done ✅\" || echo \"⚠ manual needed\" )\"\n494\t echo \" HOST1 setup: done ✅\"\n495\t echo \" Duration: $(format_duration $(( END - START )))\"\n496\t echo \"\"\n497\t echo \" HOST1 is fully set up. HOST2 ($MIRROR) can now install the Varaverk plugin.\"\n498\t echo \" The wizard will detect the pushed conf and take the correct path.\"\n499\t echo \" When HOST2 completes its onboard, it will automatically trigger Phase 2 here.\"\n500\t echo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n501\t exit 0\n502\tfi\n503\t\n504\t# ── Step 2: Stop mirror's existing auth stack ─────────────────────────────────────────────────\n505\techo \"\"\n506\techo \"━━━ Step 2 — Stop Mirror Auth Stack ━━━\"\n507\t\n508\tif [[ \"$SKIP_AUTH_STACK\" == true ]]; then\n509\t warn \"Skipping (--skip-auth-stack)\"\n510\telse\n511\t stop_mirror_stack \"PARTNERSHIP_REPLACE_CONTAINERS\" \"auth stack\"\n512\tfi\n513\t\n514\t# ── Step 4: Deploy auth stack on mirror ───────────────────────────────────────────────────────\n515\techo \"\"\n516\techo \"━━━ Step 3 — Deploy Auth Stack on Mirror ━━━\"\n517\t\n518\tif [[ \"$SKIP_AUTH_STACK\" == true ]]; then\n519\t warn \"Skipping (--skip-auth-stack)\"\n520\telif [[ ${#PARTNERSHIP_AUTH_STACK[@]} -eq 0 ]]; then\n521\t warn \"PARTNERSHIP_AUTH_STACK not set in ${MY_ID} conf — skipping auth stack deploy\"\n522\t warn \"Add HOST${MY_ID: -1}_PARTNERSHIP_AUTH_STACK to host${MY_ID: -1}.conf\"\n523\t STEP_AUTH_OK=false\n524\telse\n525\t deploy_xml_stack PARTNERSHIP_AUTH_STACK\n526\t AUTH_DEPLOYED=$_STACK_DEPLOYED\n527\t AUTH_FAILED=$_STACK_FAILED\n528\t echo \"Auth stack: $AUTH_DEPLOYED deployed, $AUTH_FAILED failed\"\n529\t [[ \"$AUTH_FAILED\" -gt 0 ]] && STEP_AUTH_OK=false\n530\tfi\n531\t\n532\t# ── Step 5: Stop mirror's existing arr stack ──────────────────────────────────────────────────\n533\techo \"\"\n534\techo \"━━━ Step 4 — Stop Mirror Arr Stack ━━━\"\n535\t\n536\tif [[ \"$SKIP_ARR_STACK\" == true ]]; then\n537\t warn \"Skipping (--skip-arr-stack)\"\n538\telif [[ ${#PARTNERSHIP_ARR_STACK[@]} -eq 0 ]]; then\n539\t log \"PARTNERSHIP_ARR_STACK not configured — skipping arr stack deploy\"\n540\t SKIP_ARR_STACK=true\n541\telse\n542\t stop_mirror_stack \"PARTNERSHIP_ARR_REPLACE_CONTAINERS\" \"arr stack\"\n543\tfi\n544\t\n545\t# ── Step 5: Deploy arr stack on mirror ───────────────────────────────────────────────────────\n546\techo \"\"\n547\techo \"━━━ Step 5 — Deploy Arr Stack on Mirror ━━━\"\n548\t\n549\tif [[ \"$SKIP_ARR_STACK\" == true ]]; then\n550\t warn \"Skipping (--skip-arr-stack)\"\n551\telse\n552\t deploy_xml_stack PARTNERSHIP_ARR_STACK\n553\t ARR_DEPLOYED=$_STACK_DEPLOYED\n554\t ARR_FAILED=$_STACK_FAILED\n555\t echo \"Arr stack: $ARR_DEPLOYED deployed, $ARR_FAILED failed\"\n556\t [[ \"$ARR_FAILED\" -gt 0 ]] && STEP_ARR_OK=false\n557\tfi\n558\t\n559\t# ── Step 6: Stop mirror's existing services stack ─────────────────────────────────────────────\n560\techo \"\"\n561\techo \"━━━ Step 6 — Stop Mirror Services Stack ━━━\"\n562\t\n563\tif [[ \"$SKIP_SERVICES_STACK\" == true ]]; then\n564\t warn \"Skipping (--skip-services-stack)\"\n565\telif [[ ${#PARTNERSHIP_SERVICES_STACK[@]} -eq 0 ]]; then\n566\t log \"PARTNERSHIP_SERVICES_STACK not configured — skipping services stack deploy\"\n567\t SKIP_SERVICES_STACK=true\n568\telse\n569\t stop_mirror_stack \"PARTNERSHIP_SERVICES_REPLACE_CONTAINERS\" \"services stack\"\n570\tfi\n571\t\n572\t# ── Step 7: Deploy services stack on mirror ───────────────────────────────────────────────────\n573\techo \"\"\n574\techo \"━━━ Step 7 — Deploy Services Stack on Mirror ━━━\"\n575\t\n576\tif [[ \"$SKIP_SERVICES_STACK\" == true ]]; then\n577\t warn \"Skipping (--skip-services-stack)\"\n578\telse\n579\t deploy_xml_stack PARTNERSHIP_SERVICES_STACK\n580\t SERVICES_DEPLOYED=$_STACK_DEPLOYED\n581\t SERVICES_FAILED=$_STACK_FAILED\n582\t echo \"Services stack: $SERVICES_DEPLOYED deployed, $SERVICES_FAILED failed\"\n583\t [[ \"$SERVICES_FAILED\" -gt 0 ]] && STEP_SERVICES_OK=false\n584\tfi\n585\t\n586\t# ── Step 8: Partnership onboard ───────────────────────────────────────────────────────────────\n587\techo \"\"\n588\techo \"━━━ Step 8 — Partnership Onboard ━━━\"\n589\t\n590\tif bash \"$SCRIPTS_ROOT/Partnership/partnership_manager.sh\" --onboard \"${EXTRA_FLAGS[@]}\"; then\n591\t echo \"Partnership onboard complete ✅\"\n592\t ONBOARD_OK=true\n593\telse\n594\t error \"Partnership onboard failed\"\n595\t ONBOARD_OK=false\n596\tfi\n597\t\n598\t# ── Step 9: Arr library bootstrap ─────────────────────────────────────────────────────────────\n599\techo \"\"\n600\techo \"━━━ Step 9 — Arr Library Bootstrap ━━━\"\n601\t\n602\tif [[ \"$ONBOARD_OK\" == false ]]; then\n603\t warn \"Skipping — onboard did not complete\"\n604\telif [[ \"$SKIP_ARR_SYNC\" == true ]]; then\n605\t warn \"Skipping (--skip-arr-sync)\"\n606\telif [[ ! -f \"$SCRIPTS_ROOT/Media/arr_sync.sh\" ]]; then\n607\t warn \"arr_sync.sh not found — run Media/arr_sync.sh manually once arrs are live\"\n608\telif bash \"$SCRIPTS_ROOT/Media/arr_sync.sh\" \"${EXTRA_FLAGS[@]}\"; then\n609\t echo \"Arr bootstrap complete ✅\"\n610\t ARR_SYNC_OK=true\n611\telse\n612\t warn \"Arr sync had errors — partnership still valid\"\n613\t warn \"Re-run Media/arr_sync.sh once all arr containers are live\"\n614\tfi\n615\t\n616\t# ── Step 10: Push master.conf to all listed hosts ─────────────────────────────────────────────\n617\t# SSH is now established and all partners have the plugin installed.\n618\t# Push the authoritative master.conf so every listed host is in sync immediately.\n619\techo \"\"\n620\techo \"━━━ $ICON_GEAR Step 10 — master.conf Push ━━━\"\n621\t\n622\tif [[ \"$ONBOARD_OK\" == false ]]; then\n623\t warn \"Skipping — onboard did not complete\"\n624\telif [[ \"$DRY_RUN\" == true ]]; then\n625\t warn \"DRY RUN — would push master.conf to all listed hosts\"\n626\t MASTER_PUSH_OK=true\n627\telse\n628\t push_output=$(platform_push_conf)\n629\t push_rc=$?\n630\t [[ -n \"$push_output\" ]] && echo \"$push_output\"\n631\t platform_push_setup_state\n632\t if [[ $push_rc -eq 0 ]]; then\n633\t echo \"master.conf sync complete ✅\"\n634\t MASTER_PUSH_OK=true\n635\t else\n636\t warn \"master.conf push had failures — retry via Scheduler → master.conf → Save Conf\"\n637\t fi\n638\tfi\n639\t\n640\t# ── Write Phase 2 completion state ────────────────────────────────────────────────────────────\n641\t[[ \"$ONBOARD_OK\" == true && \"$DRY_RUN\" == false ]] && write_onboard_phase \"$MIRROR_ID\" 2\n642\t\n643\t# ── Summary ───────────────────────────────────────────────────────────────────────────────────\n644\tEND=$(date +%s)\n645\techo \"\"\n646\techo \"━━━━━ $ICON_SUMMARY ONBOARD SUMMARY ━━━━━\"\n647\techo \" Owner: $MY_ID ($LOCAL_SERVER_NAME)\"\n648\techo \" Mirror: $MIRROR ($MIRROR_IP)\"\n649\t[[ \"$PHASE2_ONLY\" == true ]] && echo \" Mode: Phase 2 (triggered by HOST2 notification)\"\n650\techo \" Duration: $(format_duration $(( END - START )))\"\n651\techo \"\"\n652\t_ok() { [[ \"$1\" == true ]] && echo \"✅\" || echo \"❌\"; }\n653\t_skip() { [[ \"$1\" == true ]] && echo \"skipped\" || echo \"$(_ok \"$2\")\"; }\n654\t\n655\techo \" Step 1 — SSH keys: $(_skip \"$SKIP_SSH\" \"$STEP_SSH_OK\")\"\n656\techo \" Step 2 — Stop auth: $(_skip \"$SKIP_AUTH_STACK\" \"$STEP_STOP_AUTH_OK\")\"\n657\techo \" Step 3 — Auth stack: $( [[ \"$SKIP_AUTH_STACK\" == true ]] && echo \"skipped\" || echo \"${AUTH_DEPLOYED} deployed, ${AUTH_FAILED} failed\" )\"\n658\techo \" Step 4 — Stop arr: $(_skip \"$SKIP_ARR_STACK\" \"$STEP_STOP_ARR_OK\")\"\n659\techo \" Step 5 — Arr stack: $( [[ \"$SKIP_ARR_STACK\" == true ]] && echo \"skipped\" || echo \"${ARR_DEPLOYED} deployed, ${ARR_FAILED} failed\" )\"\n660\techo \" Step 6 — Stop services: $(_skip \"$SKIP_SERVICES_STACK\" \"$STEP_STOP_SERVICES_OK\")\"\n661\techo \" Step 7 — Services stack: $( [[ \"$SKIP_SERVICES_STACK\" == true ]] && echo \"skipped\" || echo \"${SERVICES_DEPLOYED} deployed, ${SERVICES_FAILED} failed\" )\"\n662\techo \" Step 8 — Onboard: $(_ok \"$ONBOARD_OK\")\"\n663\techo \" Step 9 — Arr bootstrap: $( [[ \"$SKIP_ARR_SYNC\" == true || \"$ONBOARD_OK\" == false ]] && echo \"skipped\" || echo \"$(_ok \"$ARR_SYNC_OK\")\" )\"\n664\techo \" Step 10 — Conf push: $( [[ \"$ONBOARD_OK\" == false ]] && echo \"skipped\" || echo \"$(_ok \"$MASTER_PUSH_OK\")\" )\"\n665\techo \"\"\n666\t\n667\tif [[ \"$ONBOARD_OK\" == true ]]; then\n668\t [[ \"$DRY_RUN\" == true ]] && warn \"DRY RUN — no changes made\" || \\\n669\t echo \"$ICON_DONE DONE — partnership established ✅\"\n670\t echo \"Verify with: Partnership/partnership_manager.sh --status\"\n671\telse\n672\t error \"Setup incomplete — resolve errors above and re-run\"\n673\tfi\n674\techo \"━━━━━━━━━━━━━━━━━━━━━━━\"\n675\t[[ \"$ONBOARD_OK\" == false ]] && exit 1\n676\texit 0\n677\t"}]},"uuid":"0e250b76-823c-41e3-8c22-4f4cee582efc","timestamp":"2026-06-14T05:32:22.700Z","sourceToolAssistantUUID":"3715a7ae-dbab-4b98-aa56-d771d95972df","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|
||
{"parentUuid":"0e250b76-823c-41e3-8c22-4f4cee582efc","isSidechain":true,"agentId":"a41f0ff6991d1474c","message":{"model":"claude-haiku-4-5-20251001","id":"msg_01Q4gC1kjTHtULdzg27qKkfa","type":"message","role":"assistant","content":[{"type":"text","text":"Now I'll audit each script systematically, comparing the header documentation against the actual code implementation.\n\n## Audit Results\n\n**onboard_cancel.sh**\n- ✅ Script header and code align correctly\n\n**partnership_manager.sh**\n- Issue 1: Header claims `--unblock <hostname>` at line 184, but the code implements it as `--unblock` followed by positional argument (lines 224-225). Header is accurate, code works as documented.\n- Issue 2: Header at line 131 lists \"PARTNERSHIP_ONBOARD_VERIFY\" as defaulting to `true`, but line 1254 checks `\"${PARTNERSHIP_ONBOARD_VERIFY:-true}\"` which only defaults to `true` if undefined. The header claim is correct and the code implements it.\n- ✅ Operationally accurate\n\n**partnership_offboard.sh**\n- Issue: Header states Step 7 on line 24 is \"Remote cleanup — remove auth/arr stack + fallback containers from mirror\" but the code at line 441-443 calls two separate functions: `cleanup_deployed_stack_on_remote` (for auth/arr) and `cleanup_owner_containers_on_mirror` (for owner-deployed containers). The header doesn't mention `cleanup_owner_containers_on_mirror` explicitly, though it is described generically as \"auth/arr stack + fallback containers\". However, the actual execution splits this into two distinct functions with different logic. This is functional but the description could be clearer. The code does what the header claims, just via two sub-functions rather than a single operation.\n- ✅ Functionally accurate despite implementation detail\n\n**partnership_onboard.sh**\n- Issue: Header at line 22-32 states \"OWNER PATH (10 steps)\" but the actual OWNER path in code execution (lines 377-677) shows the numbered steps don't align cleanly: Lines 379-431 handle Step 1 (SSH), then Step 2 at line 506 (stop auth), Step 3 at line 516 (deploy auth), Step 4 at line 534 (stop arr), Step 5 at line 547 (deploy arr), Step 6 at line 561 (stop services), Step 7 at line 574 (deploy services), Step 8 at line 588 (partnership onboard), Step 9 at line 600 (arr bootstrap), Step 10 at line 620 (conf push). The step numbers match the header claim of 10 steps, so this is accurate.\n- ✅ Script header matches code\n\n**partnership_transfer.sh**\n- Issue: Header at line 75-76 states \"CRITICAL_SYNC_SHARES\" is in CONFIGURATION, and line 252 calls `do_final_sync` which uses this array. However, the header does not list PARTNERSHIP_AUTH_WEBUIS in CONFIGURATION (line 79 mentions it in CONFIGURATION under partnership_manager.sh context, but this script's CONFIGURATION section lines 60-80 does list \"PARTNERSHIP_AUTH_WEBUIS\" at line 79). Actually, reading carefully: the CONFIGURATION section properly lists CRITICAL_SYNC_SHARES at line 75 and PARTNERSHIP_AUTH_WEBUIS at line 79. This is correct.\n- ✅ Accurate\n\n**ssh_setup.sh**\n- Issue: Header claims \"--local-only\" mode at line 56, but the header documentation at lines 12-37 does not list \"--local-only\" as a runtime mode. However, the code does parse it at line 56 and implements it at lines 325-335 with fallback behavior. The mode is undocumented in the header MODES section.\n- Issue 2: Header does not document the return value `exit 2` behavior, but code at line 267 returns `exit 2` when max strikes is reached. The STRIKE SYSTEM section (lines 19-26) mentions \"return exit 2 (caller can escalate)\" at line 25, so this IS documented. Not an issue.\n- ✅ Mostly accurate; --local-only exists but isn't in the MODES section header\n\n---\n\n## Summary\n\n**onboard_cancel.sh**\n✅ onboard_cancel.sh\n\n**partnership_manager.sh**\n✅ partnership_manager.sh\n\n**partnership_offboard.sh**\n✅ partnership_offboard.sh\n\n**partnership_onboard.sh**\n✅ partnership_onboard.sh\n\n**partnership_transfer.sh**\n✅ partnership_transfer.sh\n\n**ssh_setup.sh**\n- Header RUNTIME MODES section (lines 32-37) does not document the `--local-only` flag, though it is implemented in the code (line 56) and functional (lines 325-335). The flag should be added to the documented runtime modes."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10821,"cache_creation_input_tokens":44851,"cache_read_input_tokens":7704,"cache_creation":{"ephemeral_5m_input_tokens":44851,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null},"requestId":"req_011Cc2c4PUj7qfrrrnyk6vDJ","attributionAgent":"Explore","type":"assistant","uuid":"aac398e9-1a18-4532-a203-245226dc97cc","timestamp":"2026-06-14T05:32:38.575Z","userType":"external","entrypoint":"cli","cwd":"/boot/config/plugins/varaverk","sessionId":"685e6c5b-62c1-40bd-9cfd-2c9f7e15c50f","version":"2.1.177","gitBranch":"HEAD","slug":"mutable-sniffing-naur"}
|