Exit trap (_pm_trap_restart_stopped) in partnership_manager.sh: restarts locally stopped containers if script crashes mid-cleanup; partnership_offboard.sh registers same trap. echo/log audit: step result lines (stack deployed counts, state writes, onboard/offboard complete) → echo; check mode ACTIVE/INACTIVE status → echo; ssh_setup.sh key-exists and install/verify results → echo. Manual-Partnership.md: added Output Tiers section.
687 lines
32 KiB
Bash
Executable File
687 lines
32 KiB
Bash
Executable File
#!/bin/bash
|
|
# ==============================================================================================
|
|
# ============================= Partnership Offboard ===========================================
|
|
# ==============================================================================================
|
|
#
|
|
# PURPOSE
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Cleanly ends a partnership. Role is detected automatically — run on either server.
|
|
# Owner path runs the full sequence including remote cleanup and final sync.
|
|
# Mirror path handles the local side and signals the owner to complete its own cleanup.
|
|
#
|
|
# ==============================================================================================
|
|
# OPERATIONAL MODEL
|
|
# ==============================================================================================
|
|
#
|
|
# OWNER PATH (10 steps)
|
|
# Step 1: Stop rsync — halt any running sync before state changes
|
|
# Step 2: Final sync — mirror leaves with current Critical-Data state
|
|
# Step 3: Reconfigure WebUIs — mirror's auth WebUIs → localhost
|
|
# Step 4: Disable sync — CRITICAL_RSYNC_ENABLED=false in master.conf
|
|
# Step 5: Write state — INACTIVE locally + pushed to mirror, mirror blocklisted
|
|
# Step 6: Local cleanup — remove fallback coverage containers + appdata
|
|
# Step 7: Restart own stack — bring up owner's own parked containers
|
|
# Step 8: Remote cleanup — remove auth/arr stack + fallback containers from mirror
|
|
# Step 9: Restart mirror — bring up mirror's own parked containers
|
|
# Step 10: Revocation — Emby admin, SSH keys, Tailscale device
|
|
#
|
|
# MIRROR PATH (8 steps)
|
|
# Step 1: Stop rsync — halt any running sync
|
|
# Step 2: Reconfigure WebUIs — local auth WebUIs → localhost
|
|
# Step 3: Remote stack clean — remove owner-deployed containers locally (auth/arr stack)
|
|
# Step 4: Fallback cleanup — remove fallback coverage containers
|
|
# Step 5: Disable sync — CRITICAL_RSYNC_ENABLED=false in master.conf
|
|
# Step 6: Revoke Emby admin — remove own admin account from local Emby instance
|
|
# Step 7: Restart own stack — bring up own parked containers
|
|
# Step 8: SSH revocation — revoke keys both directions, write state, signal owner
|
|
#
|
|
# ==============================================================================================
|
|
# CONFIGURATION
|
|
# ==============================================================================================
|
|
#
|
|
# master_host*.conf
|
|
#
|
|
# HOST*_PARTNERSHIP_AUTH_STACK
|
|
# Auth container XMLs to push during onboard — used on offboard to identify what
|
|
# to remove. Owner's PARTNERSHIP_AUTH_STACK determines which containers get removed
|
|
# from the mirror on both owner-initiated and mirror-initiated offboard.
|
|
#
|
|
# HOST*_PARTNERSHIP_ARR_STACK
|
|
# Arr container XMLs — same cleanup logic as auth stack.
|
|
#
|
|
# ==============================================================================================
|
|
# RUNTIME MODES
|
|
# ==============================================================================================
|
|
#
|
|
# Partnership/partnership_offboard.sh
|
|
# Full offboard — role detected automatically
|
|
#
|
|
# Partnership/partnership_offboard.sh --dry-run
|
|
# Preview all steps without executing
|
|
#
|
|
# Partnership/partnership_offboard.sh --log
|
|
# Verbose per-step output
|
|
#
|
|
# Partnership/partnership_offboard.sh --reason=<string>
|
|
# Tag the offboard reason in state file and blocklist (default: manual)
|
|
# Called by partnership_manager.sh --offboard (reason passed through)
|
|
#
|
|
# ==============================================================================================
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
SCRIPTS_ROOT="$SCRIPT_DIR/.."
|
|
TEMPLATES_DIR="/boot/config/plugins/dockerMan/templates-user"
|
|
SSH_TIMEOUT=15
|
|
|
|
source "$SCRIPTS_ROOT/load_config.sh"
|
|
|
|
# ── Parse flags ───────────────────────────────────────────────────────────────────────────────
|
|
REASON="manual"
|
|
FILTERED_ARGS=()
|
|
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--reason=*) REASON="${arg#--reason=}" ;;
|
|
*) FILTERED_ARGS+=("$arg") ;;
|
|
esac
|
|
done
|
|
|
|
parse_args "${FILTERED_ARGS[@]}"
|
|
|
|
# ── Source partnership_manager.sh for shared helpers ──────────────────────────────────────────
|
|
# PARTNERSHIP_LIB_MODE=1 skips mode dispatch — functions are defined, nothing is executed.
|
|
PARTNERSHIP_LIB_MODE=1 source "$SCRIPT_DIR/partnership_manager.sh"
|
|
|
|
# ==============================================================================================
|
|
# ━━━ Setup ━━━
|
|
# ==============================================================================================
|
|
[[ "$EUID" -ne 0 ]] && { error "Must be run as root"; exit 1; }
|
|
|
|
if ! command -v docker &>/dev/null; then
|
|
error "Docker command not found"
|
|
exit 1
|
|
fi
|
|
|
|
detect_hosts
|
|
|
|
OWNER_ID="${PARTNERSHIP_OWNER_HOST:-HOST1}"
|
|
MIRROR_ID=$( [[ "$OWNER_ID" == "HOST1" ]] && echo "HOST2" || echo "HOST1" )
|
|
OWNER="${!OWNER_ID}"
|
|
MIRROR="${!MIRROR_ID}"
|
|
MIRROR_SSH_KEY="$SSH_KEY"
|
|
OWNER_SSH_KEY="$SSH_KEY"
|
|
|
|
AM_OWNER=false
|
|
AM_MIRROR=false
|
|
[[ "$MY_ID" == "$OWNER_ID" ]] && AM_OWNER=true
|
|
[[ "$MY_ID" == "$MIRROR_ID" ]] && AM_MIRROR=true
|
|
|
|
LOCAL_STATE_FILE="/boot/config/partnership_${LOCAL_SERVER_NAME}.db"
|
|
REMOTE_STATE_FILE="/boot/config/partnership_${REMOTE_SERVER_NAME}.db"
|
|
OWNER_STATE_FILE="/boot/config/partnership_${OWNER}.db"
|
|
MIRROR_STATE_FILE="/boot/config/partnership_${MIRROR}.db"
|
|
OFFLINE_COUNTER="/boot/config/partnership_offline_days.db"
|
|
|
|
acquire_lock "strict"
|
|
|
|
trap _pm_trap_restart_stopped EXIT
|
|
|
|
# Check already offboarded
|
|
if [[ -f "$LOCAL_STATE_FILE" ]]; then
|
|
CURRENT_STATE=$(read_state_file "$LOCAL_STATE_FILE" "state")
|
|
if [[ "$CURRENT_STATE" == "INACTIVE" ]]; then
|
|
warn "Partnership already INACTIVE — use partnership_manager.sh --status to verify both servers agree"
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
START=$(date +%s)
|
|
|
|
echo ""
|
|
echo "━━━ $ICON_FALLBACK Partnership Offboard — $MY_ID ($LOCAL_SERVER_NAME) — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
|
|
echo ""
|
|
echo " Role: $( [[ "$AM_OWNER" == true ]] && echo "OWNER" || echo "MIRROR" )"
|
|
echo " This: $MY_ID ($LOCAL_SERVER_NAME)"
|
|
echo " Partner: $( [[ "$AM_OWNER" == true ]] && echo "$MIRROR_ID ($MIRROR)" || echo "$OWNER_ID ($OWNER)" )"
|
|
echo " Reason: $REASON"
|
|
echo ""
|
|
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no permanent changes will be made"
|
|
|
|
# ==============================================================================================
|
|
# ── HELPER: remove owner-deployed containers from a remote host ───────────────────────────────
|
|
#
|
|
# Uses PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK arrays (owner's conf) to derive
|
|
# container names from local XML templates. SSHes to remote to stop, remove, and delete
|
|
# appdata. Appdata paths are collected via docker inspect before removal so they aren't
|
|
# lost once the container is gone. Safety gate: only /mnt/*/appdata* paths are deleted.
|
|
# ==============================================================================================
|
|
cleanup_deployed_stack_on_remote() {
|
|
local remote_ip="$1" ssh_key="$2"
|
|
local -a xml_names=()
|
|
[[ ${#PARTNERSHIP_AUTH_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_AUTH_STACK[@]}")
|
|
[[ ${#PARTNERSHIP_ARR_STACK[@]} -gt 0 ]] && xml_names+=("${PARTNERSHIP_ARR_STACK[@]}")
|
|
|
|
if [[ ${#xml_names[@]} -eq 0 ]]; then
|
|
log "No auth/arr stack arrays configured — skipping deployed stack cleanup"
|
|
return 0
|
|
fi
|
|
|
|
log "Removing owner-deployed containers (auth/arr stacks) from $MIRROR..."
|
|
for xml_name in "${xml_names[@]}"; do
|
|
[[ -z "$xml_name" ]] && continue
|
|
local xml_file="${TEMPLATES_DIR}/${xml_name}"
|
|
if [[ ! -f "$xml_file" ]]; then
|
|
warn " $xml_name not found in local $TEMPLATES_DIR — skipping"
|
|
continue
|
|
fi
|
|
|
|
local cname
|
|
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
|
|
[[ -z "$cname" ]] && continue
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn " DRY RUN — would stop + rm $cname on $MIRROR"
|
|
warn " DRY RUN — would delete appdata for $cname on $MIRROR"
|
|
continue
|
|
fi
|
|
|
|
# Collect appdata paths via docker inspect before removal
|
|
local appdata_paths
|
|
appdata_paths=$(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
|
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$remote_ip" \
|
|
"docker inspect --format '{{range .HostConfig.Binds}}{{println .}}{{end}}' '$cname' 2>/dev/null \
|
|
| awk -F: '{print \$1}' | grep '^/mnt/.*/appdata'" 2>/dev/null)
|
|
|
|
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
|
-o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
|
|
"docker stop '$cname' >/dev/null 2>&1
|
|
docker rm '$cname' >/dev/null 2>&1 && echo removed" 2>/dev/null | \
|
|
grep -q removed && \
|
|
log " $cname removed from $MIRROR ✅" || \
|
|
log " $cname not found on $MIRROR — skipping"
|
|
|
|
while IFS= read -r path; do
|
|
[[ -z "$path" ]] && continue
|
|
timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
|
-o ConnectTimeout="$SSH_TIMEOUT" root@"$remote_ip" \
|
|
"rm -rf '$path' && echo removed" 2>/dev/null | grep -q removed && \
|
|
log " Appdata removed on $MIRROR: $path ✅" || \
|
|
warn " Failed to remove appdata on $MIRROR: $path"
|
|
done <<< "$appdata_paths"
|
|
done
|
|
}
|
|
|
|
# ==============================================================================================
|
|
# ── HELPER: remove owner-deployed containers locally (mirror-initiated offboard) ─────────────
|
|
#
|
|
# SSHes to owner to read PARTNERSHIP_AUTH_STACK + PARTNERSHIP_ARR_STACK, then uses the
|
|
# local templates-user/ copies (SCPed there during onboard) to get container names and
|
|
# appdata paths. Appdata collected before removal. Skips gracefully if owner unreachable.
|
|
# ==============================================================================================
|
|
cleanup_deployed_stack_locally() {
|
|
local owner_ip="$1" ssh_key="$2"
|
|
local -a xml_names=()
|
|
|
|
if [[ -n "$owner_ip" ]]; then
|
|
local -a auth_arr arr_arr
|
|
mapfile -t auth_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
|
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
|
|
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
|
|
detect_hosts 2>/dev/null
|
|
printf '%s\n' \"\${PARTNERSHIP_AUTH_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
|
|
mapfile -t arr_arr < <(timeout "$SSH_TIMEOUT" ssh -i "$ssh_key" \
|
|
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$owner_ip" \
|
|
"source '$SCRIPTS_ROOT/load_config.sh' 2>/dev/null
|
|
detect_hosts 2>/dev/null
|
|
printf '%s\n' \"\${PARTNERSHIP_ARR_STACK[@]:-}\"" 2>/dev/null | grep -v '^$')
|
|
xml_names=("${auth_arr[@]}" "${arr_arr[@]}")
|
|
fi
|
|
|
|
if [[ ${#xml_names[@]} -eq 0 ]]; then
|
|
log "Could not read deployed stack from owner — skipping auth/arr cleanup"
|
|
return 0
|
|
fi
|
|
|
|
log "Removing owner-deployed containers (auth/arr stacks) locally..."
|
|
for xml_name in "${xml_names[@]}"; do
|
|
[[ -z "$xml_name" ]] && continue
|
|
local xml_file="${TEMPLATES_DIR}/${xml_name}"
|
|
if [[ ! -f "$xml_file" ]]; then
|
|
warn " $xml_name not found locally — skipping"
|
|
continue
|
|
fi
|
|
|
|
local cname
|
|
cname=$(awk 'match($0,/<Name>([^<]+)<\/Name>/,a){print a[1];exit}' "$xml_file")
|
|
[[ -z "$cname" ]] && continue
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn " DRY RUN — would stop + rm $cname"
|
|
warn " DRY RUN — would delete appdata for $cname"
|
|
continue
|
|
fi
|
|
|
|
local appdata_paths=""
|
|
if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$cname" >/dev/null 2>&1; then
|
|
appdata_paths=$(docker inspect \
|
|
--format '{{range .HostConfig.Binds}}{{println .}}{{end}}' \
|
|
"$cname" 2>/dev/null | awk -F: '{print $1}' | grep '^/mnt/.*/appdata')
|
|
timeout "${DOCKER_TIMEOUT:-30}" docker stop "$cname" >/dev/null 2>&1 || true
|
|
_PM_TRAP_STOPPED+=("$cname")
|
|
timeout "${DOCKER_TIMEOUT:-30}" docker rm "$cname" >/dev/null 2>&1 && \
|
|
log " $cname removed ✅" || warn " $cname rm failed"
|
|
else
|
|
log " $cname not found locally — skipping"
|
|
fi
|
|
|
|
while IFS= read -r path; do
|
|
[[ -z "$path" ]] && continue
|
|
rm -rf "$path" && log " Appdata removed: $path ✅" || warn " Failed to remove: $path"
|
|
done <<< "$appdata_paths"
|
|
done
|
|
}
|
|
|
|
# ==============================================================================================
|
|
# ── HELPER: revoke own admin account from local Emby instance ────────────────────────────────
|
|
#
|
|
# Mirror-initiated path only. Called before start_own_stack so Emby is still running.
|
|
# Uses local EMBY_API_KEY and the mirror's own short name as the username to delete.
|
|
# ==============================================================================================
|
|
revoke_local_emby_admin() {
|
|
local emby_port="${PARTNERSHIP_EMBY_PORT:-8096}"
|
|
local emby_url="http://127.0.0.1:${emby_port}"
|
|
|
|
echo ""
|
|
echo "━━━ $ICON_EMBY Emby Admin Revocation ━━━"
|
|
|
|
if [[ "${PARTNERSHIP_PROVISION_EMBY_ADMIN:-false}" != true ]]; then
|
|
log "PARTNERSHIP_PROVISION_EMBY_ADMIN=false — skipping"
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z "${EMBY_API_KEY:-}" ]]; then
|
|
warn "EMBY_API_KEY not set — skipping local Emby admin revocation"
|
|
return 1
|
|
fi
|
|
|
|
# The account to revoke is this server's own short name (the mirror user's account)
|
|
local username="${PARTNERSHIP_EMBY_ADMIN_USER:-$(derive_short_name "$LOCAL_SERVER_NAME")}"
|
|
|
|
if [[ "$DRY_RUN" == true ]]; then
|
|
warn "DRY RUN — would delete Emby admin '$username' at $emby_url"
|
|
return 0
|
|
fi
|
|
|
|
log "Looking up Emby user '$username' at $emby_url..."
|
|
|
|
local users_json user_id
|
|
users_json=$(curl -sf --max-time 15 \
|
|
-H "X-Emby-Authorization: MediaBrowser Token=\"$EMBY_API_KEY\"" \
|
|
"${emby_url}/Users" 2>/dev/null)
|
|
|
|
user_id=$(echo "$users_json" | \
|
|
grep -o "\"Id\":\"[^\"]*\"[^}]*\"Name\":\"${username}\"" | \
|
|
grep -o '"Id":"[^"]*"' | cut -d'"' -f4 | head -1)
|
|
|
|
if [[ -z "$user_id" ]]; then
|
|
warn "Emby user '$username' not found at $emby_url — may already be removed"
|
|
return 0
|
|
fi
|
|
|
|
local del_code
|
|
del_code=$(curl -sf --max-time 15 -w "%{http_code}" -o /dev/null \
|
|
-X DELETE \
|
|
-H "X-Emby-Authorization: MediaBrowser Token=\"$EMBY_API_KEY\"" \
|
|
"${emby_url}/Users/${user_id}" 2>/dev/null)
|
|
|
|
if [[ "$del_code" == "200" ]] || [[ "$del_code" == "204" ]] || [[ "$del_code" == "404" ]]; then
|
|
log "Emby admin '$username' removed ✅"
|
|
else
|
|
warn "Failed to delete Emby user '$username' (HTTP $del_code) — remove manually"
|
|
fi
|
|
}
|
|
|
|
# ==============================================================================================
|
|
# ── MIRROR PATH ───────────────────────────────────────────────────────────────────────────────
|
|
# ==============================================================================================
|
|
if [[ "$AM_MIRROR" == true ]]; then
|
|
warn "$MIRROR_ID ($MIRROR) is initiating offboard"
|
|
warn "Owner ($OWNER) will see INACTIVE state on its next --check cycle and finalize"
|
|
|
|
if [[ "$DRY_RUN" == false ]]; then
|
|
echo ""
|
|
echo "You have 10 seconds to cancel (Ctrl+C)..."
|
|
sleep 10
|
|
fi
|
|
|
|
OWNER_IP=$(resolve_tailscale_ip "$OWNER")
|
|
OWNER_REACHABLE=false
|
|
[[ -n "$OWNER_IP" ]] && OWNER_REACHABLE=true
|
|
|
|
STEP_STOP_RSYNC_OK=true
|
|
STEP_WEBUI_OK=true
|
|
STEP_STACK_CLEANUP_OK=true
|
|
STEP_FALLBACK_CLEANUP_OK=true
|
|
STEP_DISABLE_RSYNC_OK=true
|
|
STEP_EMBY_OK=true
|
|
SSH_REVOKE_REMOTE_OK=false
|
|
SSH_REVOKE_LOCAL_OK=false
|
|
|
|
# ── Step 1: Stop rsync ────────────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_STOP Step 1/8 — Stop Rsync ━━━"
|
|
|
|
if [[ "$DRY_RUN" == false ]]; then
|
|
bash "$SCRIPTS_ROOT/Rsync/rsync_stop.sh" --rsync-only 2>/dev/null || true
|
|
log "Rsync stopped ✅"
|
|
else
|
|
warn "DRY RUN — would stop rsync"
|
|
fi
|
|
|
|
# ── Step 2: Reconfigure local WebUIs → localhost ──────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_CONTAINERS Step 2/8 — Reconfigure Local WebUIs → localhost ━━━"
|
|
|
|
reconfigure_local_webuis "localhost" || STEP_WEBUI_OK=false
|
|
|
|
# ── Step 3: Remove owner-deployed containers (auth/arr stack) locally ─────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_CONTAINERS Step 3/8 — Remove Owner-Deployed Containers ━━━"
|
|
|
|
if [[ "$OWNER_REACHABLE" == true ]]; then
|
|
cleanup_deployed_stack_locally "$OWNER_IP" "$OWNER_SSH_KEY" || STEP_STACK_CLEANUP_OK=false
|
|
else
|
|
warn "Owner unreachable — cannot read deployed stack list"
|
|
warn "Auth/arr containers will remain — remove manually or re-run when owner is reachable"
|
|
STEP_STACK_CLEANUP_OK=false
|
|
fi
|
|
|
|
# ── Step 4: Remove fallback coverage containers ───────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_CONTAINERS Step 4/8 — Fallback Container Cleanup ━━━"
|
|
|
|
PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$OWNER")
|
|
cleanup_partner_containers "$PARTNER_FOLDER_NAME" || STEP_FALLBACK_CLEANUP_OK=false
|
|
|
|
# ── Step 5: Disable critical sync ─────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_GEAR Step 5/8 — Disable Critical Sync ━━━"
|
|
|
|
if [[ "$DRY_RUN" == false ]]; then
|
|
update_master_conf "CRITICAL_RSYNC_ENABLED" "false" && \
|
|
warn "CRITICAL_RSYNC_ENABLED=false ✅" || \
|
|
{ warn "Failed to update CRITICAL_RSYNC_ENABLED"; STEP_DISABLE_RSYNC_OK=false; }
|
|
else
|
|
warn "DRY RUN — would set CRITICAL_RSYNC_ENABLED=false"
|
|
fi
|
|
|
|
# ── Step 6: Revoke Emby admin locally ─────────────────────────────────────────────────────
|
|
revoke_local_emby_admin || STEP_EMBY_OK=false
|
|
|
|
# ── Step 7: Restart own stack ─────────────────────────────────────────────────────────────
|
|
start_own_stack
|
|
|
|
# ── Step 8: SSH key revocation, write state, signal owner ────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_SHIELD Step 8/8 — SSH Revocation + State ━━━"
|
|
|
|
do_ssh_key_revocation "${OWNER_IP:-}"
|
|
|
|
NOW=$(date '+%Y-%m-%d %H:%M:%S')
|
|
if [[ "$DRY_RUN" == false ]]; then
|
|
write_state_file "$LOCAL_STATE_FILE" \
|
|
"INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
|
|
echo "Local state: INACTIVE ✅"
|
|
add_to_blocklist "$OWNER" "$REASON"
|
|
else
|
|
warn "DRY RUN — would write INACTIVE state and blocklist $OWNER"
|
|
fi
|
|
|
|
if [[ "$OWNER_REACHABLE" == true ]]; then
|
|
push_state_to_remote "$LOCAL_STATE_FILE" "$OWNER_IP" "$OWNER_SSH_KEY"
|
|
notify "Partnership offboard requested by $MIRROR — $OWNER will finalise on next check" \
|
|
"Partnership" "normal"
|
|
else
|
|
warn "$OWNER unreachable — state written locally, owner will see it when reachable"
|
|
fi
|
|
|
|
# ── Summary ───────────────────────────────────────────────────────────────────────────────
|
|
END=$(date +%s)
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY OFFBOARD SUMMARY (Mirror) ━━━━━"
|
|
echo " Mirror: $MY_ID ($LOCAL_SERVER_NAME)"
|
|
echo " Owner: $OWNER_ID ($OWNER)"
|
|
echo " Reason: $REASON"
|
|
echo " Duration: $(format_duration $(( END - START )))"
|
|
echo ""
|
|
_ok() { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
|
|
_skip() { [[ "$1" == true ]] && echo "skipped" || echo "$(_ok "$2")"; }
|
|
_revoke_status() {
|
|
if [[ "${SSH_REVOKE_REMOTE_OK:-false}" == true && "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
|
|
echo "both directions ✅"
|
|
elif [[ "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
|
|
echo "local only ✅ — remote failed (revoke manually on $OWNER)"
|
|
else
|
|
echo "⚠️ failed — check warnings above"
|
|
fi
|
|
}
|
|
|
|
echo " Step 1 — Stop rsync: $(_ok "$STEP_STOP_RSYNC_OK")"
|
|
echo " Step 2 — WebUIs: $(_ok "$STEP_WEBUI_OK")"
|
|
echo " Step 3 — Stack cleanup: $(_ok "$STEP_STACK_CLEANUP_OK")"
|
|
echo " Step 4 — Fallback cleanup: $(_ok "$STEP_FALLBACK_CLEANUP_OK")"
|
|
echo " Step 5 — Disable sync: $(_ok "$STEP_DISABLE_RSYNC_OK")"
|
|
echo " Step 6 — Emby revoke: $(_ok "$STEP_EMBY_OK")"
|
|
echo " Step 7 — Own stack: started"
|
|
echo " Step 8 — Keys revoked: $(_revoke_status)"
|
|
echo ""
|
|
echo " State: INACTIVE ✅"
|
|
echo " Blocklist: $OWNER blocked ✅"
|
|
echo " Owner: will finalise + final sync on next --check"
|
|
echo ""
|
|
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
|
|
warn "$ICON_DONE DONE — mirror separation complete ✅"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
|
exit 0
|
|
fi
|
|
|
|
# ==============================================================================================
|
|
# ── OWNER PATH ────────────────────────────────────────────────────────────────────────────────
|
|
# ==============================================================================================
|
|
warn "Offboarding $MIRROR_ID ($MIRROR) from partnership"
|
|
warn "Final sync will run — mirror leaves with current state"
|
|
|
|
if [[ "$DRY_RUN" == false ]]; then
|
|
echo ""
|
|
echo "You have 10 seconds to cancel (Ctrl+C)..."
|
|
sleep 10
|
|
echo "Proceeding..."
|
|
fi
|
|
|
|
WEBUI_FAILURES=0
|
|
SSH_REVOKE_REMOTE_OK=false
|
|
SSH_REVOKE_LOCAL_OK=false
|
|
|
|
# ── Step 1: Stop rsync ────────────────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_STOP Step 1/10 — Stop Rsync ━━━"
|
|
|
|
if [[ "$DRY_RUN" == false ]]; then
|
|
bash "$SCRIPTS_ROOT/Rsync/rsync_stop.sh" --rsync-only 2>/dev/null || true
|
|
log "Rsync stopped ✅"
|
|
else
|
|
warn "DRY RUN — would stop rsync"
|
|
fi
|
|
|
|
# ── Step 2: Final sync ────────────────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_SYNC Step 2/10 — Final Sync ━━━"
|
|
|
|
do_final_sync
|
|
|
|
# ── Step 3: Reconfigure mirror WebUIs → localhost ─────────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_CONTAINERS Step 3/10 — Reconfigure Mirror WebUIs → localhost ━━━"
|
|
|
|
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
|
|
MIRROR_REACHABLE=false
|
|
[[ -n "$MIRROR_IP" ]] && MIRROR_REACHABLE=true
|
|
|
|
if [[ "$MIRROR_REACHABLE" == true ]]; then
|
|
for entry in "${PARTNERSHIP_AUTH_WEBUIS[@]}"; do
|
|
[[ -z "$entry" ]] && continue
|
|
container="${entry%%|*}"
|
|
port="${entry##*|}"
|
|
reconfigure_webui "$container" "$port" "localhost" \
|
|
"$MIRROR_SSH_KEY" "$MIRROR_IP" "$MIRROR" || (( WEBUI_FAILURES++ ))
|
|
done
|
|
else
|
|
warn "$MIRROR unreachable — WebUI reconfiguration skipped"
|
|
warn "$MIRROR will reconfigure its own WebUIs when it sees INACTIVE state on --check"
|
|
(( WEBUI_FAILURES++ ))
|
|
fi
|
|
|
|
# ── Step 4: Disable critical sync ─────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_GEAR Step 4/10 — Disable Critical Sync ━━━"
|
|
|
|
if [[ "$DRY_RUN" == false ]]; then
|
|
update_master_conf "CRITICAL_RSYNC_ENABLED" "false"
|
|
warn "CRITICAL_RSYNC_ENABLED=false ✅"
|
|
else
|
|
warn "DRY RUN — would set CRITICAL_RSYNC_ENABLED=false"
|
|
fi
|
|
|
|
# ── Step 5: Write state, push to mirror, blocklist ────────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_GEAR Step 5/10 — Write State ━━━"
|
|
|
|
NOW=$(date '+%Y-%m-%d %H:%M:%S')
|
|
|
|
if [[ "$DRY_RUN" == false ]]; then
|
|
write_state_file "$LOCAL_STATE_FILE" \
|
|
"INACTIVE" "" "$NOW" "$LOCAL_SERVER_NAME" "$REASON"
|
|
log "Local state: INACTIVE ✅"
|
|
add_to_blocklist "$MIRROR" "$REASON"
|
|
[[ "$MIRROR_REACHABLE" == true ]] && \
|
|
push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY"
|
|
else
|
|
warn "DRY RUN — would write INACTIVE state, blocklist $MIRROR, push to remote"
|
|
fi
|
|
|
|
# ── Step 6: Local container cleanup ───────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_CONTAINERS Step 6/10 — Local Container Cleanup ━━━"
|
|
|
|
PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$MIRROR")
|
|
cleanup_partner_containers "$PARTNER_FOLDER_NAME"
|
|
|
|
# ── Step 7: Restart own stack ─────────────────────────────────────────────────────────────────
|
|
start_own_stack
|
|
|
|
# ── Step 8: Remote container cleanup ──────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_CONTAINERS Step 8/10 — Remote Container Cleanup ━━━"
|
|
|
|
if [[ "$MIRROR_REACHABLE" == true ]]; then
|
|
# Remove auth/arr stack containers deployed during onboard (by config array)
|
|
cleanup_deployed_stack_on_remote "$MIRROR_IP" "$MIRROR_SSH_KEY"
|
|
# Remove fallback coverage containers (by *-owner_short naming pattern)
|
|
cleanup_owner_containers_on_mirror "$MIRROR_IP"
|
|
else
|
|
warn "$MIRROR unreachable — remote container cleanup skipped"
|
|
warn "Run 'partnership_manager.sh --offboard' on $MIRROR to clean up manually"
|
|
fi
|
|
|
|
# ── Step 9: Restart mirror's own stack ────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_START Step 9/10 — Restart Mirror Stack ━━━"
|
|
|
|
[[ "$MIRROR_REACHABLE" == true ]] && start_mirror_own_stack "$MIRROR_IP"
|
|
|
|
# ── Step 10: Revocation ───────────────────────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "━━━ $ICON_SHIELD Step 10/10 — Revocation ━━━"
|
|
|
|
# Emby admin — before SSH key revocation while Emby still reachable
|
|
[[ "$MIRROR_REACHABLE" == true ]] && revoke_emby_admin "$MIRROR_IP"
|
|
|
|
# SSH key revocation — mutual, both directions; must run while Tailscale still active
|
|
do_ssh_key_revocation "${MIRROR_IP:-}"
|
|
|
|
# Tailscale removal — after SSH revocation, guard with grace window
|
|
if [[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_NET Tailscale Separation ━━━"
|
|
if [[ "$MIRROR_REACHABLE" == true ]]; then
|
|
grace_seconds=$(( ${PARTNERSHIP_GRACE_HOURS:-6} * 3600 ))
|
|
warn "Waiting ${PARTNERSHIP_GRACE_HOURS:-6}hr grace — mirror can collect backups..."
|
|
if [[ "$DRY_RUN" == false ]]; then
|
|
trap 'warn "Offboard interrupted during grace sleep"; exit 0' SIGTERM SIGINT
|
|
sleep "$grace_seconds"
|
|
trap - SIGTERM SIGINT
|
|
fi
|
|
fi
|
|
remove_tailscale_device "$MIRROR"
|
|
fi
|
|
|
|
# Backup handover notification
|
|
if [[ ${#PARTNERSHIP_MIRROR_BACKUPS[@]} -gt 0 ]]; then
|
|
echo ""
|
|
echo "━━━ $ICON_DISK Backup Handover ━━━"
|
|
echo "Backups available for $MIRROR:"
|
|
for path in "${PARTNERSHIP_MIRROR_BACKUPS[@]}"; do
|
|
[[ -z "$path" ]] && continue
|
|
echo " $path"
|
|
done
|
|
notify "$MIRROR offboard complete — backups available for ${PARTNERSHIP_GRACE_HOURS:-6}hr. Tailscale access expires then." \
|
|
"Partnership" "warning"
|
|
fi
|
|
|
|
# ── Summary ───────────────────────────────────────────────────────────────────────────────────
|
|
END=$(date +%s)
|
|
echo ""
|
|
echo "━━━━━ $ICON_SUMMARY OFFBOARD SUMMARY (Owner) ━━━━━"
|
|
echo " Owner: $MY_ID ($LOCAL_SERVER_NAME)"
|
|
echo " Mirror: $MIRROR_ID ($MIRROR)"
|
|
echo " Reason: $REASON"
|
|
echo " Duration: $(format_duration $(( END - START )))"
|
|
echo ""
|
|
_ok() { [[ "$1" == true ]] && echo "✅" || echo "❌"; }
|
|
_revoke_status() {
|
|
if [[ "${SSH_REVOKE_REMOTE_OK:-false}" == true && "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
|
|
echo "both directions ✅"
|
|
elif [[ "${SSH_REVOKE_LOCAL_OK:-false}" == true ]]; then
|
|
echo "local only ✅ — remote failed (revoke manually on $MIRROR)"
|
|
else
|
|
echo "⚠️ failed — check warnings above"
|
|
fi
|
|
}
|
|
|
|
echo " Step 1 — Stop rsync: ✅"
|
|
echo " Step 2 — Final sync: ✅"
|
|
echo " Step 3 — WebUI failures: $WEBUI_FAILURES"
|
|
echo " Step 4 — Disable sync: ✅"
|
|
echo " Step 5 — State: INACTIVE ✅"
|
|
echo " Step 6 — Local cleanup: ✅"
|
|
echo " Step 7 — Own stack: started"
|
|
echo " Step 8 — Remote cleanup: $( [[ "$MIRROR_REACHABLE" == true ]] && echo "✅" || echo "skipped (unreachable)" )"
|
|
echo " Step 9 — Mirror stack: $( [[ "$MIRROR_REACHABLE" == true ]] && echo "started" || echo "skipped (unreachable)" )"
|
|
echo " Step 10 — Keys revoked: $(_revoke_status)"
|
|
echo ""
|
|
echo " Blocklist: $MIRROR blocked — re-onboard to permit access again ✅"
|
|
[[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]] && \
|
|
echo " FolderView3: ${PARTNER_FOLDER_NAME:-} cleaned ✅"
|
|
[[ "${PARTNERSHIP_REMOVE_TAILSCALE:-true}" == true ]] && \
|
|
echo " Tailscale: $MIRROR removed ✅"
|
|
echo ""
|
|
echo " $MIRROR leaves with:"
|
|
echo " ✓ Current auth config (final sync)"
|
|
echo " ✓ Auth WebUIs → localhost"
|
|
echo " ✓ ${PARTNERSHIP_GRACE_HOURS:-6}hr to collect backups"
|
|
echo ""
|
|
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes made" || \
|
|
warn "$ICON_DONE DONE — clean separation complete ✅"
|
|
echo "━━━━━━━━━━━━━━━━━━━━━━━"
|
|
exit 0
|