feat: Tailscale resolution hardening, partnership offboard completion, Emby provisioning

common.sh:
- Add resolve_tailscale_ip() helper — tries `tailscale ip -4` first, falls back to
  parsing `tailscale status` output; handles hosts where MagicDNS short-name resolution
  is not active
- Add PARTNERSHIP_OWN_CONTAINERS alias in detect_hosts()
- Add aliasing for 4 Emby provisioning vars (PARTNERSHIP_PROVISION_EMBY_ADMIN,
  PARTNERSHIP_EMBY_ADMIN_USER, PARTNERSHIP_EMBY_ADMIN_PASS, PARTNERSHIP_EMBY_PORT)

Partnership/partnership_manager.sh:
- Replace 9 bare `tailscale ip -4` calls with resolve_tailscale_ip()
- Add read_remote_conf_var() and read_remote_conf_array() — SSH to mirror, source its
  own load_config.sh + detect_hosts(), return aliased variable; solves sparse-checkout
  problem where HOST1 cannot read master_host2.conf directly
- Add derive_short_name() — strips unraid- prefix, capitalises first char
- Add cleanup_partner_containers() — removes partner containers via FolderView3 folder
  if enabled, else falls back to FALLBACK_*_COVERS_*_TIER* arrays
- Add cleanup_owner_containers_on_mirror() — SSH to mirror, stops and removes containers
  matching *-${OWNER_SHORT} naming convention
- Add start_own_stack() and start_mirror_own_stack() — restart own containers locally
  or on mirror via SSH using PARTNERSHIP_OWN_CONTAINERS
- Add provision_emby_admin() — reads mirror credentials via read_remote_conf_var, checks
  for username collision, creates user + sets password + grants admin policy via Emby API
- Add revoke_emby_admin() — looks up mirror username on local Emby, deletes via REST API
- Wire offboard paths (both mirror-initiated and owner-initiated) to call container
  cleanup and stack restart; update --check finalisation paths accordingly
- Fix write_state_file in --onboard not gated on DRY_RUN (was writing ACTIVE state on
  dry runs)

master_host1.conf:
- Add HOST1_PARTNERSHIP_OWN_CONTAINERS array
- Add partnership Emby provisioning config (toggle + port + per-host credentials)

master_host2.conf:
- Add HOST2_PARTNERSHIP_OWN_CONTAINERS array
- Add HOST2_PARTNERSHIP_EMBY_ADMIN_USER and HOST2_PARTNERSHIP_EMBY_ADMIN_PASS

Tailscale fix applied to:
- Initial_run/ssh_setup.sh (2 callsites)
- unRAID_Essentials/rsync_stop.sh (1 callsite)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gmer4Lfe
2026-05-10 20:09:13 -04:00
co-authored by Claude Sonnet 4.6
parent 6948755c86
commit 0ae31b5fa6
33 changed files with 1241 additions and 155 deletions
+360 -31
View File
@@ -627,11 +627,317 @@ gather_partner_fallback_containers() {
done
}
# Read a scalar var from the mirror's own config via SSH.
# Sources load_config.sh + detect_hosts() on the remote so HOST* aliasing works.
read_remote_conf_var() {
local mirror_ip="$1" var_name="$2"
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
"source '$SCRIPT_DIR/../load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s' \"\${${var_name}:-}\"" 2>/dev/null
}
# Read an array var from the mirror's own config via SSH — one element per line.
read_remote_conf_array() {
local mirror_ip="$1" var_name="$2"
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" -o BatchMode=yes root@"$mirror_ip" \
"source '$SCRIPT_DIR/../load_config.sh' 2>/dev/null
detect_hosts 2>/dev/null
printf '%s\n' \"\${${var_name}[@]:-}\"" 2>/dev/null
}
# Returns just the short name portion: "unRAID-Gmer4Lfe" → "Gmer4Lfe"
derive_short_name() {
local hostname="$1"
local short="${hostname,,}"
[[ "$short" == unraid-* ]] && short="${short:7}"
echo "${short^}"
}
# Start this server's own parked containers after partnership ends.
start_own_stack() {
echo ""
echo "━━━ $ICON_START Restart Own Stack ━━━"
if [[ ${#PARTNERSHIP_OWN_CONTAINERS[@]} -eq 0 ]]; then
log "No PARTNERSHIP_OWN_CONTAINERS configured — skipping"
return 0
fi
for container in "${PARTNERSHIP_OWN_CONTAINERS[@]}"; do
[[ -z "$container" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would start: $container"
continue
fi
if timeout "${DOCKER_TIMEOUT:-30}" docker start "$container" >/dev/null 2>&1; then
log "$container started ✅"
else
warn "$container failed to start — check manually"
fi
done
}
# Remove partnership containers on this server.
# Uses FolderView3 folder if enabled (precise list), else falls back to FALLBACK_*_COVERS_* config.
cleanup_partner_containers() {
local folder_name="$1"
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
folderview3_remove_partner_folder "$folder_name"
else
declare -a containers=()
gather_partner_fallback_containers containers
if [[ ${#containers[@]} -eq 0 ]]; then
log "No partner containers found to remove"
return 0
fi
for container in "${containers[@]}"; do
[[ -z "$container" ]] && continue
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would stop + rm: $container"
continue
fi
if timeout "${DOCKER_TIMEOUT:-30}" docker inspect "$container" >/dev/null 2>&1; then
timeout "${DOCKER_TIMEOUT:-30}" docker stop "$container" >/dev/null 2>&1 || true
timeout "${DOCKER_TIMEOUT:-30}" docker rm "$container" >/dev/null 2>&1 && \
log "$container removed ✅" || warn "$container rm failed"
else
log "$container not found — skipping"
fi
done
fi
}
# SSH to mirror — remove all containers named *-${OWNER_SHORT} (owner's deployed containers).
cleanup_owner_containers_on_mirror() {
local mirror_ip="$1"
local owner_short
owner_short=$(derive_short_name "$OWNER")
log "Removing owner-deployed containers from $MIRROR..."
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would remove *-${owner_short} containers from $MIRROR"
return 0
fi
local container_list
container_list=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$mirror_ip" \
"docker ps -a --format '{{.Names}}' 2>/dev/null | grep -i -- '-${owner_short}$'" 2>/dev/null)
if [[ -z "$container_list" ]]; then
log "No *-${owner_short} containers found on $MIRROR"
return 0
fi
while IFS= read -r container; do
[[ -z "$container" ]] && continue
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$mirror_ip" \
"docker stop '$container' >/dev/null 2>&1
docker rm '$container' >/dev/null 2>&1 && echo removed" 2>/dev/null | \
grep -q removed && \
log "$container removed from $MIRROR" || \
warn "Failed to remove $container from $MIRROR"
done <<< "$container_list"
}
# SSH to mirror — start mirror's own parked containers.
# Reads PARTNERSHIP_OWN_CONTAINERS from the mirror's own conf via SSH.
start_mirror_own_stack() {
local mirror_ip="$1"
log "Reading own stack list from $MIRROR conf..."
local -a mirror_own=()
mapfile -t mirror_own < <(read_remote_conf_array "$mirror_ip" "PARTNERSHIP_OWN_CONTAINERS")
# Remove empty entries
local -a filtered=()
for c in "${mirror_own[@]}"; do [[ -n "$c" ]] && filtered+=("$c"); done
mirror_own=("${filtered[@]}")
if [[ ${#mirror_own[@]} -eq 0 ]]; then
log "No PARTNERSHIP_OWN_CONTAINERS configured on $MIRROR — skipping remote stack restart"
return 0
fi
log "Restarting own stack on $MIRROR: ${mirror_own[*]}"
for container in "${mirror_own[@]}"; do
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would start $container on $MIRROR"
continue
fi
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$mirror_ip" \
"docker start '$container' >/dev/null 2>&1 && echo started" 2>/dev/null | \
grep -q started && \
log "$container started on $MIRROR" || \
warn "$container failed to start on $MIRROR — check manually"
done
}
# Create the mirror's Emby admin account on the owner's deployed Emby.
# Credentials come from the MIRROR's conf (HOST*_PARTNERSHIP_EMBY_ADMIN_USER/PASS).
# Checks for username collision before creating — exits with guidance if taken.
provision_emby_admin() {
local mirror_ip="$1"
local emby_port="${PARTNERSHIP_EMBY_PORT:-8096}"
local emby_url="http://${mirror_ip}:${emby_port}"
echo ""
echo "━━━ $ICON_EMBY Emby Admin Provisioning ━━━"
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 Emby admin provisioning"
return 1
fi
# Read mirror's desired credentials from their own conf via SSH
log "Reading Emby credentials from $MIRROR conf..."
local username password
username=$(read_remote_conf_var "$mirror_ip" "PARTNERSHIP_EMBY_ADMIN_USER")
password=$(read_remote_conf_var "$mirror_ip" "PARTNERSHIP_EMBY_ADMIN_PASS")
[[ -z "$username" ]] && username="$(derive_short_name "$MIRROR")"
if [[ -z "$password" ]]; then
warn "PARTNERSHIP_EMBY_ADMIN_PASS is empty in $MIRROR conf"
warn "$MIRROR must set HOST${MIRROR_ID: -1}_PARTNERSHIP_EMBY_ADMIN_PASS before onboard"
return 1
fi
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would create Emby admin '$username' at $emby_url"
return 0
fi
# Collision check — username already exists?
local existing_users
existing_users=$(curl -sf --max-time 15 \
-H "X-Emby-Authorization: MediaBrowser Token=\"$EMBY_API_KEY\"" \
"${emby_url}/Users" 2>/dev/null)
if echo "$existing_users" | grep -q "\"Name\":\"${username}\""; then
error "Emby username '${username}' is already taken on the shared instance"
error "Options:"
error " 1. Sign in with that account — it may already be yours"
error " 2. Set a different name in ${user_var} and re-run --onboard"
return 1
fi
log "Creating Emby admin '$username' at $emby_url..."
local create_response http_code body
create_response=$(curl -sf --max-time 15 -w "\n%{http_code}" \
-X POST \
-H "X-Emby-Authorization: MediaBrowser Token=\"$EMBY_API_KEY\"" \
-H "Content-Type: application/json" \
-d "{\"Name\": \"$username\"}" \
"${emby_url}/Users/New" 2>/dev/null)
http_code=$(echo "$create_response" | tail -1)
body=$(echo "$create_response" | head -n -1)
if [[ "$http_code" != "200" ]] && [[ "$http_code" != "204" ]]; then
warn "Failed to create Emby user '$username' (HTTP $http_code)"
return 1
fi
local user_id
user_id=$(echo "$body" | grep -o '"Id":"[^"]*"' | head -1 | cut -d'"' -f4)
if [[ -z "$user_id" ]]; then
warn "Emby user created but could not parse user ID — set password manually"
return 1
fi
local pw_code
pw_code=$(curl -sf --max-time 15 -w "%{http_code}" -o /dev/null \
-X POST \
-H "X-Emby-Authorization: MediaBrowser Token=\"$EMBY_API_KEY\"" \
-H "Content-Type: application/json" \
-d "{\"NewPw\": \"$password\"}" \
"${emby_url}/Users/${user_id}/Password" 2>/dev/null)
if [[ "$pw_code" == "200" ]] || [[ "$pw_code" == "204" ]]; then
log "Emby admin '$username' created (id: $user_id) ✅"
else
warn "User created but password set failed (HTTP $pw_code) — set password manually"
fi
curl -sf --max-time 15 -o /dev/null \
-X POST \
-H "X-Emby-Authorization: MediaBrowser Token=\"$EMBY_API_KEY\"" \
-H "Content-Type: application/json" \
-d '{"IsAdministrator": true, "IsDisabled": false}' \
"${emby_url}/Users/${user_id}/Policy" 2>/dev/null && \
log "$username granted admin policy ✅" || \
warn "Could not set admin policy — grant manually in Emby dashboard"
}
# Delete the mirror's Emby admin account from the owner's deployed Emby.
revoke_emby_admin() {
local mirror_ip="$1"
local emby_port="${PARTNERSHIP_EMBY_PORT:-8096}"
local emby_url="http://${mirror_ip}:${emby_port}"
echo ""
echo "━━━ $ICON_EMBY Emby Admin Revocation ━━━"
local username
username=$(read_remote_conf_var "$mirror_ip" "PARTNERSHIP_EMBY_ADMIN_USER")
[[ -z "$username" ]] && username="$(derive_short_name "$MIRROR")"
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 Emby admin revocation"
return 1
fi
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
}
check_both_healthy() {
mountpoint -q /mnt/user 2>/dev/null || { error "Local array not healthy"; return 1; }
local mirror_ip
mirror_ip=$(tailscale ip -4 "${MIRROR,,}" 2>/dev/null)
mirror_ip=$(resolve_tailscale_ip "$MIRROR")
[[ -z "$mirror_ip" ]] && { error "Cannot resolve $MIRROR Tailscale IP"; return 1; }
timeout "$SSH_TIMEOUT" ssh -i "$MIRROR_SSH_KEY" \
@@ -717,8 +1023,8 @@ fi
if [[ "$MODE" == "status" ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY PARTNERSHIP STATUS ━━━━━"
OWNER_IP=$(tailscale ip -4 "${OWNER,,}" 2>/dev/null || echo "unreachable")
MIRROR_IP=$(tailscale ip -4 "${MIRROR,,}" 2>/dev/null || echo "unreachable")
OWNER_IP=$(resolve_tailscale_ip "$OWNER" || echo "unreachable")
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR" || echo "unreachable")
echo " $ICON_HOST My ID: $MY_ID ($LOCAL_SERVER_NAME)"
echo " Owner: $OWNER_ID ($OWNER$OWNER_IP)"
echo " Mirror: $MIRROR_ID ($MIRROR$MIRROR_IP)"
@@ -743,7 +1049,7 @@ if [[ "$MODE" == "status" ]]; then
echo ""
# Remote state
REMOTE_IP=$(tailscale ip -4 "${REMOTE_SERVER_NAME,,}" 2>/dev/null)
REMOTE_IP=$(resolve_tailscale_ip "$REMOTE_SERVER_NAME")
if [[ -n "$REMOTE_IP" ]]; then
REMOTE_CONTENT=$(read_remote_state "$REMOTE_IP" "$SSH_KEY" "$REMOTE_STATE_FILE")
if [[ -n "$REMOTE_CONTENT" ]]; then
@@ -851,7 +1157,7 @@ if [[ "$MODE" == "check" ]]; then
fi
# Read remote state file
REMOTE_IP=$(tailscale ip -4 "${REMOTE_SERVER_NAME,,}" 2>/dev/null)
REMOTE_IP=$(resolve_tailscale_ip "$REMOTE_SERVER_NAME")
if [[ -z "$REMOTE_IP" ]]; then
log "Partnership check — remote unreachable, skipping state check"
exit 0
@@ -884,6 +1190,10 @@ if [[ "$MODE" == "check" ]]; then
warn "Owner finalising offboard request from mirror..."
do_final_sync
# Remove owner's fallback containers, restart own stack
cleanup_partner_containers "$(derive_partner_folder_name "$MIRROR")"
start_own_stack
NOW=$(date '+%Y-%m-%d %H:%M:%S')
write_state_file "$LOCAL_STATE_FILE" \
"INACTIVE" "" "$NOW" "$REMOTE_SERVER_NAME" "mirror-requested"
@@ -915,6 +1225,8 @@ if [[ "$MODE" == "check" ]]; then
# Mirror sees owner is INACTIVE — clean up own side
warn "Owner has offboarded — cleaning up mirror side..."
reconfigure_local_webuis "localhost"
cleanup_partner_containers "$(derive_partner_folder_name "$OWNER")"
start_own_stack
NOW=$(date '+%Y-%m-%d %H:%M:%S')
write_state_file "$LOCAL_STATE_FILE" \
"INACTIVE" "" "$NOW" "$OWNER" "owner-offboarded"
@@ -973,8 +1285,8 @@ if [[ "$MODE" == "onboard" ]]; then
check_remote_array || exit 1
check_remote_docker_daemon || exit 1
OWNER_IP=$(tailscale ip -4 "${OWNER,,}" 2>/dev/null)
MIRROR_IP=$(tailscale ip -4 "${MIRROR,,}" 2>/dev/null)
OWNER_IP=$(resolve_tailscale_ip "$OWNER")
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
[[ -z "$OWNER_IP" ]] && { error "Cannot resolve $OWNER Tailscale IP"; exit 1; }
[[ -z "$MIRROR_IP" ]] && { error "Cannot resolve $MIRROR Tailscale IP"; exit 1; }
@@ -1016,13 +1328,15 @@ if [[ "$MODE" == "onboard" ]]; then
echo "━━━ $ICON_GEAR Write State ━━━"
NOW=$(date '+%Y-%m-%d %H:%M:%S')
write_state_file "$LOCAL_STATE_FILE" "ACTIVE" "$NOW" "" "$LOCAL_SERVER_NAME" "onboard"
log "Local state: ACTIVE ✅"
[[ "$DRY_RUN" == false ]] && remove_from_blocklist "$MIRROR"
push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY"
echo "0" > "$OFFLINE_COUNTER"
if [[ "$DRY_RUN" == false ]]; then
write_state_file "$LOCAL_STATE_FILE" "ACTIVE" "$NOW" "" "$LOCAL_SERVER_NAME" "onboard"
log "Local state: ACTIVE ✅"
remove_from_blocklist "$MIRROR"
push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY"
echo "0" > "$OFFLINE_COUNTER"
else
warn "DRY RUN — would write ACTIVE state and push to remote"
fi
# FolderView3 — create partner folder with this server's failover containers for remote
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
@@ -1038,6 +1352,9 @@ if [[ "$MODE" == "onboard" ]]; then
fi
fi
# Emby admin provisioning — runs after container deployment (deploy step not yet built)
provision_emby_admin "$MIRROR_IP"
# Summary
echo ""
echo "━━━━━ $ICON_SUMMARY ONBOARD SUMMARY ━━━━━"
@@ -1094,13 +1411,12 @@ if [[ "$MODE" == "offboard" ]]; then
echo "━━━ $ICON_CONTAINERS Reconfigure Local WebUIs → localhost ━━━"
reconfigure_local_webuis "localhost"
# FolderView3 — remove partner folder and clean containers
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
echo ""
echo "━━━ $ICON_CONTAINERS FolderView3 Cleanup ━━━"
PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$OWNER")
folderview3_remove_partner_folder "$PARTNER_FOLDER_NAME"
fi
# Remove owner's deployed containers + restart own stack
echo ""
echo "━━━ $ICON_CONTAINERS Cleanup Partner Containers ━━━"
PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$OWNER")
cleanup_partner_containers "$PARTNER_FOLDER_NAME"
start_own_stack
NOW=$(date '+%Y-%m-%d %H:%M:%S')
write_state_file "$LOCAL_STATE_FILE" \
@@ -1109,7 +1425,7 @@ if [[ "$MODE" == "offboard" ]]; then
[[ "$DRY_RUN" == false ]] && add_to_blocklist "$OWNER" "$REASON"
OWNER_IP=$(tailscale ip -4 "${OWNER,,}" 2>/dev/null)
OWNER_IP=$(resolve_tailscale_ip "$OWNER")
if [[ -n "$OWNER_IP" ]]; then
push_state_to_remote "$LOCAL_STATE_FILE" "$OWNER_IP" "$MIRROR_SSH_KEY"
notify "Partnership offboard requested by $MIRROR$OWNER will finalise on next check" \
@@ -1151,7 +1467,7 @@ if [[ "$MODE" == "offboard" ]]; then
echo "━━━ $ICON_SYNC Final Sync ━━━"
do_final_sync
MIRROR_IP=$(tailscale ip -4 "${MIRROR,,}" 2>/dev/null)
MIRROR_IP=$(resolve_tailscale_ip "$MIRROR")
MIRROR_REACHABLE=false
[[ -n "$MIRROR_IP" ]] && MIRROR_REACHABLE=true
@@ -1199,14 +1515,27 @@ if [[ "$MODE" == "offboard" ]]; then
push_state_to_remote "$LOCAL_STATE_FILE" "$MIRROR_IP" "$MIRROR_SSH_KEY"
fi
# FolderView3 — remove partner folder and clean containers on this (owner) side
if [[ "${PARTNERSHIP_FOLDERVIEW3:-false}" == true ]]; then
echo ""
echo "━━━ $ICON_CONTAINERS FolderView3 Cleanup ━━━"
PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$MIRROR")
folderview3_remove_partner_folder "$PARTNER_FOLDER_NAME"
# Local: remove fallback-coverage containers for mirror, restart own stack
echo ""
echo "━━━ $ICON_CONTAINERS Local Container Cleanup ━━━"
PARTNER_FOLDER_NAME=$(derive_partner_folder_name "$MIRROR")
cleanup_partner_containers "$PARTNER_FOLDER_NAME"
start_own_stack
# Remote: remove owner's deployed containers from mirror, restart mirror's own stack
echo ""
echo "━━━ $ICON_CONTAINERS Remote Container Cleanup ━━━"
if [[ "$MIRROR_REACHABLE" == true ]]; then
cleanup_owner_containers_on_mirror "$MIRROR_IP"
start_mirror_own_stack "$MIRROR_IP"
else
warn "$MIRROR unreachable — remote container cleanup skipped"
warn "Run 'partnership_manager.sh --offboard' on $MIRROR to clean up manually"
fi
# Emby admin revocation — before SSH key revocation while Emby still reachable
[[ "$MIRROR_REACHABLE" == true ]] && revoke_emby_admin "$MIRROR_IP"
# SSH key revocation — mutual, both directions
# Must run before Tailscale removal (SSH needs network) and after state is pushed
SSH_REVOKE_REMOTE_OK=false
@@ -1354,8 +1683,8 @@ if [[ "$MODE" == "transfer" ]]; then
NEW_OWNER_SSH_KEY="${!NEW_OWNER_SSH_KEY_VAR}"
NEW_MIRROR_SSH_KEY="${!NEW_MIRROR_SSH_KEY_VAR}"
NEW_OWNER_IP=$(tailscale ip -4 "${NEW_OWNER,,}" 2>/dev/null)
NEW_MIRROR_IP=$(tailscale ip -4 "${NEW_MIRROR,,}" 2>/dev/null)
NEW_OWNER_IP=$(resolve_tailscale_ip "$NEW_OWNER")
NEW_MIRROR_IP=$(resolve_tailscale_ip "$NEW_MIRROR")
[[ -z "$NEW_OWNER_IP" ]] && { error "Cannot resolve new owner Tailscale IP"; exit 1; }
[[ -z "$NEW_MIRROR_IP" ]] && { error "Cannot resolve new mirror Tailscale IP"; exit 1; }