Files
Varaverk/Fallback/fallback.sh
T
Gmer4LfeandClaude Sonnet 4.6 b65f572367 feat: arr-native distributed media sync + config architecture fixes
Arr sync (new):
- Media/arr_sync.sh — full mesh bidirectional sync across all HOST* nodes
  - Lidarr (MusicBrainz), Sonarr (TVDB), Radarr (TMDB) all handled in one script
  - Remote API keys read live from config.xml via SSH — never stored in conf files
  - Shared blocklist (DATA_DIR/arr_sync_blocklist.tsv) merged from all nodes at runtime
  - Graceful skip if arr not configured locally or not reachable on a remote node
  - --blocklist-add / --blocklist-remove / --blocklist-list management flags
- daily_sync_maintenance.sh — arr sync runs as explicit phase before rsync
- partnership_onboard.sh — Step 3 bootstraps merged library on both sides at onboard
- master.conf — ARR_SYNC_* config block, DOCKER_APPDATA_BASE

Rsync / cleanup:
- DEFAULT_RSYNC_OPTS — removed --delete; arr_cleanup.sh owns orphan enforcement
- lidarr_cleanup.sh — removed HOST1-only guard; runs on any node with Lidarr configured

Config architecture:
- HOST1/HOST2 hostnames moved from master_host*.conf → master.conf (not credentials)
- Sparse checkout now works correctly: each server only needs its own host conf
- detect_hosts() still resolves MY_ID + REMOTE_ID via master.conf hostname values

Bug fix:
- common.sh line 493 — watchdog toggle eval had broken quoting; all SYS_WATCHDOG_CHECK_*
  globals were silently set to empty instead of their configured values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 09:53:40 -04:00

857 lines
41 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# ==============================================================================================
# ================================= Failover ===================================================
# ==============================================================================================
# Mutual container failover between two unRAID servers.
# Each server runs this script independently — no direct coordination between servers.
# All decisions based solely on two pings: remote reachable + internet reachable.
#
# ── HOW IT WORKS ──────────────────────────────────────────────────────────────────────────────
# Both servers run this script continuously as a background task via User Scripts.
# Every FALLBACK_CHECK_INTERVAL seconds each server:
# 1. Pings the remote server
# 2. Pings the internet
# 3. Determines its current state
# 4. Takes the appropriate action
#
# No SSH signaling, no shared state files, no coordination — each server acts autonomously
# based only on what it can see from its own network perspective.
#
# ── STATES ────────────────────────────────────────────────────────────────────────────────────
# NORMAL — remote up, internet up
# Own containers only. DDNS ON. Silent operation.
#
# FALLBACK — remote down, internet up
# Start remote containers locally — tiered by outage duration.
# Remote DDNS started immediately (Tier 1).
# Own containers keep running — failover is additive.
#
# NO_INTERNET — internet down (remote may be up or down)
# Stop own DDNS immediately — can't update DNS without internet.
# Do not start remote containers — no internet = no point.
# Wait for recovery.
#
# DARK — remote down AND internet down
# Same actions as NO_INTERNET.
# Cannot determine if remote is truly down or just unreachable.
#
# ── DDNS RULES — ABSOLUTE ─────────────────────────────────────────────────────────────────────
# Each server owns its own DDNS — ON when that server has internet.
# Script controls DDNS exclusively — network state NEVER auto-starts DDNS.
# DDNS only starts after full handback sequence confirms containers are up.
# One DDNS per domain active at all times — never two, never zero for long.
# 1 minute TTL + 90s strike window = minimal user impact on failover.
#
# ── HANDBACK SEQUENCE ─────────────────────────────────────────────────────────────────────────
# When remote returns after FALLBACK:
# 1. Strike confirmation — FALLBACK_HANDBACK_STRIKES consecutive remote-up checks
# 2. Pre-flight checks — version parity, remote array, remote Docker daemon
# 3. Staged reverse-tier handback — Tier 4 → Tier 3 → Tier 2 → Tier 1
# Each stage: stop local tier containers → rsync writeback → start on remote
# Emby (Tier 1) stays on fallback serving users until all higher tiers are done
# 4. DDNS handoff — immediately before Tier 1 goes down (not at start)
# 5. Tier 1 handback — stop local → rsync → start remote
# 6. Start remote DDNS last — DNS cuts back ONLY after containers confirmed up
# 7. Return to NORMAL
#
# ── TIERED FALLBACK ───────────────────────────────────────────────────────────────────────────
# Tier 1 — Immediate — vital services + Live TV — cannot wait
# Tier 2 — HOST*_TIER2_DELAY — shared productivity services (default 4hr)
# Tier 3 — HOST*_TIER3_DELAY — secondary services (default 12hr)
# Tier 4 — HOST*_TIER4_DELAY — arrs + downloaders (default 24hr)
# Tier delays configurable per host in master_host*.conf
#
# ── SAFEGUARDS ────────────────────────────────────────────────────────────────────────────────
# FALLBACK_ENABLED gate — exits cleanly if disabled in master.conf
# Version parity check — refuses handback if unRAID versions mismatch
# Remote Docker daemon — checks remote daemon before issuing any remote commands
# Timeout protection — all docker and SSH commands wrapped in timeouts
# Container verification — verifies containers came up after start (failover critical)
# MY_ID-based routing — all tier/DDNS/writeback arrays selected via MY_ID not hostname
# Command validation — validates unRAID notify script before use
# Silent by default — state transitions warn(), routine cycle checks log()
#
# ── CONFIGURATION (master_host*.conf) ─────────────────────────────────────────────────────────
# HOST*_DDNS_CONTAINERS — DDNS containers this host manages
# FALLBACK_HOST*_STOP_ON_NO_NET — containers stopped on internet loss
# FALLBACK_HOST*_RUNS_FOR_HOST*_TIER1-4 — what this host runs for the other
# HOST*_TIER2_DELAY / TIER3_DELAY / TIER4_DELAY — tier activation delays in minutes
# HOST*_TIER1_WRITEBACK_DELAY — skip Tier 1 writeback if outage under this
# FALLBACK_HOST*_WRITEBACK_TIER1-4 — paths synced back on handback per tier
#
# ── CONFIGURATION (master.conf) ───────────────────────────────────────────────────────────────
# FALLBACK_ENABLED — false = exit cleanly (HOST2 being rebuilt etc.)
# FALLBACK_CHECK_INTERVAL — seconds between checks (default 30)
# FALLBACK_HANDBACK_STRIKES — consecutive remote-up checks before handback (default 3 = 90s)
# FALLBACK_STATE_FILE — /boot/config path — survives reboots
# FALLBACK_RSYNC_ENABLED — gate for writeback rsync jobs
# EXTERNAL_IP — IP to ping for internet check (default 8.8.8.8)
#
# ── USAGE ─────────────────────────────────────────────────────────────────────────────────────
# fallback.sh — normal start (continuous loop)
# fallback.sh --dry-run — preview state changes without acting on containers
# fallback.sh --status — show current state and exit
# fallback.sh --log — verbose cycle output
#
# ── TO STOP THIS SCRIPT ───────────────────────────────────────────────────────────────────────
# Click Abort in unRAID User Scripts — do NOT kill directly, state file may corrupt.
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_GEAR Setup ━━━"
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
# FALLBACK_ENABLED gate — exits cleanly when disabled (e.g. HOST2 being rebuilt)
if [[ "${FALLBACK_ENABLED:-false}" == false ]]; then
warn "FALLBACK_ENABLED=false — fallback monitoring disabled"
warn "Set FALLBACK_ENABLED=true in master.conf when both servers are ready"
exit 0
fi
acquire_lock # strict single instance — notifies on collision
detect_hosts
resolve_remote_ip
# Validate unRAID notify script — used throughout for state change notifications
validate_unraid_cmd \
"/usr/local/emhttp/plugins/dynamix/scripts/notify" \
"" "" \
"unRAID notify script" || warn "unRAID notify script not found — native notifications disabled"
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no container or DDNS changes will be made"
# Timeout for all docker and SSH docker commands
DOCKER_TIMEOUT=15
SSH_TIMEOUT=10
CONTAINER_VERIFY_WAIT=5 # seconds after start before verifying container is up
# ==============================================================================================
# ── STATE FILE HELPERS ────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# State file on /boot/config — survives reboots.
# Format: key=value one per line.
# Keys: state, fallback_start, handback_strikes, tier2_started, tier3_started, tier4_started
state_get() {
grep "^${1}=" "$FALLBACK_STATE_FILE" 2>/dev/null | cut -d= -f2
}
state_set() {
local key="$1" value="$2"
if grep -q "^${key}=" "$FALLBACK_STATE_FILE" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=${value}|" "$FALLBACK_STATE_FILE"
else
echo "${key}=${value}" >> "$FALLBACK_STATE_FILE"
fi
}
state_init() {
mkdir -p "$(dirname "$FALLBACK_STATE_FILE")"
[[ ! -f "$FALLBACK_STATE_FILE" ]] && touch "$FALLBACK_STATE_FILE"
[[ -z "$(state_get state)" ]] && state_set state "NORMAL"
[[ -z "$(state_get fallback_start)" ]] && state_set fallback_start "0"
[[ -z "$(state_get handback_strikes)" ]] && state_set handback_strikes "0"
[[ -z "$(state_get tier2_started)" ]] && state_set tier2_started "false"
[[ -z "$(state_get tier3_started)" ]] && state_set tier3_started "false"
[[ -z "$(state_get tier4_started)" ]] && state_set tier4_started "false"
}
# ==============================================================================================
# ── CONTAINER HELPERS ─────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# All docker commands wrapped in DOCKER_TIMEOUT.
# All SSH docker commands wrapped in SSH_TIMEOUT.
# Verification after start — failover is critical, confirm containers came up.
# Start a container locally and verify it came up
local_start() {
local container="$1"
[[ -z "$container" ]] && return
local status
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \
"$container" 2>/dev/null)
if [[ "$status" == "true" ]]; then
log "$container already running locally"
return 0
fi
log "$ICON_START Starting $container locally..."
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would start $container locally"
return 0
fi
if timeout "$DOCKER_TIMEOUT" docker start "$container" >/dev/null 2>&1; then
sleep "$CONTAINER_VERIFY_WAIT"
local post_status
post_status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \
"$container" 2>/dev/null)
if [[ "$post_status" == "true" ]]; then
log "$ICON_STARTED $container started and running ✅"
return 0
else
error "$container started but crashed immediately"
notify "$container failed to stay running during fallback on $(hostname)" \
"Fallback" "warning"
return 1
fi
else
error "Failed to start $container locally"
notify "Failed to start $container locally during fallback on $(hostname)" \
"Fallback" "warning"
return 1
fi
}
# Stop a container locally
local_stop() {
local container="$1"
[[ -z "$container" ]] && return
local status
status=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \
"$container" 2>/dev/null)
if [[ "$status" != "true" ]]; then
log "$container already stopped locally"
return 0
fi
log "$ICON_STOP Stopping $container locally..."
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would stop $container locally"
return 0
fi
timeout "$DOCKER_TIMEOUT" docker stop "$container" >/dev/null 2>&1 && \
log "$ICON_STOPPED $container stopped" || \
error "Failed to stop $container locally"
}
# Start a container on remote via SSH and verify it came up
remote_start() {
local container="$1"
[[ -z "$container" ]] && return
local status
status=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" \
"timeout $DOCKER_TIMEOUT docker inspect -f '{{.State.Running}}' \
$container 2>/dev/null" 2>/dev/null)
if [[ "$status" == "true" ]]; then
log "$container already running on $REMOTE_SERVER_NAME"
return 0
fi
log "$ICON_START Starting $container on $REMOTE_SERVER_NAME..."
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would start $container on $REMOTE_SERVER_NAME"
return 0
fi
if timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" \
"timeout $DOCKER_TIMEOUT docker start $container" >/dev/null 2>&1; then
sleep "$CONTAINER_VERIFY_WAIT"
local post_status
post_status=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" \
-o ConnectTimeout="$SSH_TIMEOUT" root@"$REMOTE_SERVER" \
"timeout $DOCKER_TIMEOUT docker inspect -f '{{.State.Running}}' \
$container 2>/dev/null" 2>/dev/null)
if [[ "$post_status" == "true" ]]; then
log "$ICON_STARTED $container started on $REMOTE_SERVER_NAME ✅"
return 0
else
error "$container started on $REMOTE_SERVER_NAME but crashed immediately"
notify "$container failed after start on $REMOTE_SERVER_NAME during handback on $(hostname)" \
"Fallback" "warning"
return 1
fi
else
error "Failed to start $container on $REMOTE_SERVER_NAME"
notify "Failed to start $container on $REMOTE_SERVER_NAME during handback on $(hostname)" \
"Fallback" "warning"
return 1
fi
}
# Stop a container on remote via SSH
remote_stop() {
local container="$1"
[[ -z "$container" ]] && return
local status
status=$(timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" \
"timeout $DOCKER_TIMEOUT docker inspect -f '{{.State.Running}}' \
$container 2>/dev/null" 2>/dev/null)
if [[ "$status" != "true" ]]; then
log "$container already stopped on $REMOTE_SERVER_NAME"
return 0
fi
log "$ICON_STOP Stopping $container on $REMOTE_SERVER_NAME..."
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would stop $container on $REMOTE_SERVER_NAME"
return 0
fi
timeout "$SSH_TIMEOUT" ssh -i "$SSH_KEY" -o ConnectTimeout="$SSH_TIMEOUT" \
root@"$REMOTE_SERVER" \
"timeout $DOCKER_TIMEOUT docker stop $container" >/dev/null 2>&1 && \
log "$ICON_STOPPED $container stopped on $REMOTE_SERVER_NAME" || \
error "Failed to stop $container on $REMOTE_SERVER_NAME"
}
# ==============================================================================================
# ── DDNS HELPERS ──────────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# DDNS managed exclusively by this script — never by network state alone.
# Stopping DDNS on internet loss prevents split-brain DNS updates.
local_ddns_stop() {
warn "$ICON_NET Stopping local DDNS — internet lost"
for container in "${LOCAL_DDNS_CONTAINERS[@]}"; do
local_stop "$container"
done
}
local_ddns_start() {
warn "$ICON_NET Starting local DDNS — handback complete, containers confirmed up"
for container in "${LOCAL_DDNS_CONTAINERS[@]}"; do
local_start "$container"
done
}
remote_ddns_stop() {
warn "$ICON_NET Stopping remote DDNS on $REMOTE_SERVER_NAME — prevents split-brain during rsync"
for container in "${REMOTE_DDNS_CONTAINERS[@]}"; do
remote_stop "$container"
done
}
# ==============================================================================================
# ── TIERED FALLBACK HELPERS ───────────────────────────────────────────────────────────────────
# ==============================================================================================
# All routing uses MY_ID — not hostname string comparison.
# MY_ID set by detect_hosts() — "HOST1" or "HOST2" etc.
# "This server covers the other" — MY_ID selects which tier arrays to use.
get_tier_containers() {
local tier="$1"
local remote_id="${REMOTE_ID}"
local var_name="FALLBACK_${MY_ID}_COVERS_${remote_id}_TIER${tier}"
eval "echo \"\${${var_name}[@]:-}\""
}
get_tier2_delay() {
local var_name="${REMOTE_ID}_TIER2_DELAY"
echo "${!var_name:-240}"
}
get_tier3_delay() {
local var_name="${REMOTE_ID}_TIER3_DELAY"
echo "${!var_name:-720}"
}
get_tier4_delay() {
local var_name="${REMOTE_ID}_TIER4_DELAY"
echo "${!var_name:-1440}"
}
get_tier1_writeback_delay() {
local var_name="${REMOTE_ID}_TIER1_WRITEBACK_DELAY"
echo "${!var_name:-60}"
}
get_writeback_jobs_for_tier() {
local tier="$1"
local remote_id="${REMOTE_ID}"
if [[ "$tier" -eq 4 ]]; then
# Tier 4 — daily sync shares of the remote + any extra writeback paths
local shares_var="${remote_id}_DAILY_SYNC_SHARES"
local extra_var="FALLBACK_${remote_id}_WRITEBACK_TIER4"
eval "echo \"\${${shares_var}[@]:-} \${${extra_var}[@]:-}\""
else
local var_name="FALLBACK_${remote_id}_WRITEBACK_TIER${tier}"
eval "echo \"\${${var_name}[@]:-}\""
fi
}
# Select DDNS arrays based on MY_ID
set_ddns_arrays() {
local local_ddns_var="${MY_ID}_DDNS_CONTAINERS"
local remote_ddns_var="${REMOTE_ID}_DDNS_CONTAINERS"
eval "LOCAL_DDNS_CONTAINERS=(\"\${${local_ddns_var}[@]:-}\")"
eval "REMOTE_DDNS_CONTAINERS=(\"\${${remote_ddns_var}[@]:-}\")"
}
# Select internet-loss stop list based on MY_ID
get_stop_on_no_net() {
local var_name="FALLBACK_${MY_ID}_STOP_ON_NO_NET"
eval "echo \"\${${var_name}[@]:-}\""
}
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
state_init
set_ddns_arrays
CURRENT_STATE=$(state_get state)
FALLBACK_START_TS=$(state_get fallback_start)
TIER2=$(state_get tier2_started)
TIER3=$(state_get tier3_started)
TIER4=$(state_get tier4_started)
STRIKES=$(state_get handback_strikes)
local_ver=$(grep -oP '(?<=version=")[^"]+' /etc/unraid-version 2>/dev/null || echo "unknown")
echo ""
echo "━━━━━ $ICON_SUMMARY FALLBACK STATUS ━━━━━"
echo "$ICON_HOST My ID: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_HOST Remote ID: $REMOTE_ID ($REMOTE_SERVER_NAME$REMOTE_SERVER)"
echo "$ICON_GEAR unRAID ver: $local_ver"
echo "$ICON_FALLBACK State: $CURRENT_STATE"
echo "$ICON_NET Local DDNS: ${LOCAL_DDNS_CONTAINERS[*]:-none}"
echo "$ICON_NET Remote DDNS: ${REMOTE_DDNS_CONTAINERS[*]:-none}"
echo "$ICON_TIME Interval: ${FALLBACK_CHECK_INTERVAL}s"
echo "$ICON_FALLBACK Strikes: $STRIKES / $FALLBACK_HANDBACK_STRIKES"
if [[ "$CURRENT_STATE" == "FALLBACK" && "$FALLBACK_START_TS" -gt 0 ]]; then
ELAPSED=$(( ($(date +%s) - FALLBACK_START_TS) / 60 ))
echo "$ICON_TIME Outage: ${ELAPSED}min"
echo "$ICON_FALLBACK Tier 2: $TIER2 (delay: $(get_tier2_delay)min)"
echo "$ICON_FALLBACK Tier 3: $TIER3 (delay: $(get_tier3_delay)min)"
echo "$ICON_FALLBACK Tier 4: $TIER4 (delay: $(get_tier4_delay)min)"
fi
echo "$ICON_GEAR Enabled: $FALLBACK_ENABLED"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ==============================================================================================
# ── HANDBACK SEQUENCE ─────────────────────────────────────────────────────────────────────────
# ==============================================================================================
# Called when remote returns after FALLBACK state.
# CRITICAL sequencing — do not reorder without understanding the consequences.
# Each step must succeed before proceeding — aborts and retries next cycle on failure.
run_handback() {
echo ""
echo "━━━ $ICON_FALLBACK Handback Sequence — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
warn "$REMOTE_SERVER_NAME has returned — beginning staged handback"
# ── Step 1: Pre-flight checks ────────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_SHIELD Pre-flight ━━━"
if ! check_unraid_version_parity; then
warn "Version parity check failed — aborting handback, will retry next cycle"
state_set handback_strikes 0
return 1
fi
if ! check_remote_array; then
warn "Remote array not ready — aborting handback, will retry next cycle"
state_set handback_strikes 0
return 1
fi
if ! check_remote_docker_daemon; then
warn "Remote Docker daemon not ready — aborting handback, will retry next cycle"
state_set handback_strikes 0
return 1
fi
echo " Pre-flight checks passed ✅"
local outage_minutes
outage_minutes=$(( ($(date +%s) - $(state_get fallback_start)) / 60 ))
local tier1_wb_delay t2_delay t3_delay t4_delay
tier1_wb_delay=$(get_tier1_writeback_delay)
t2_delay=$(get_tier2_delay)
t3_delay=$(get_tier3_delay)
t4_delay=$(get_tier4_delay)
warn "Outage duration: ${outage_minutes}min — staged handback Tier 4→3→2→1"
# ── Staged handback helper ────────────────────────────────────────────────────────────────
# Each tier: stop local containers → rsync writeback → start on remote.
# Skips tiers that were never activated. Emby (Tier 1) stays on fallback
# serving users until all higher tiers complete.
handback_tier() {
local tier="$1" threshold="$2" label="$3"
[[ "$(state_get "tier${tier}_started")" != "true" ]] && return 0
echo ""
echo "━━━ $ICON_FALLBACK Handback Tier $tier$label ━━━"
local containers
read -r -a containers <<< "$(get_tier_containers "$tier")"
echo " Stopping local Tier $tier containers..."
for container in "${containers[@]}"; do
[[ -n "$container" ]] && local_stop "$container"
done
if ! check_rsync_enabled "FALLBACK"; then
warn "FALLBACK_RSYNC_ENABLED=false — skipping Tier $tier writeback"
else
local jobs
read -r -a jobs <<< "$(get_writeback_jobs_for_tier "$tier")"
if [[ ${#jobs[@]} -gt 0 && "$outage_minutes" -ge "$threshold" ]]; then
echo " Rsync writeback — outage ${outage_minutes}min >= ${threshold}min"
for job in "${jobs[@]}"; do
[[ -z "$job" ]] && continue
log "Syncing: $job"
[[ "$DRY_RUN" == false ]] && bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job" \
|| warn "DRY RUN — would rsync: $job"
done
else
log "Tier $tier writeback skipped — outage ${outage_minutes}min < ${threshold}min"
fi
fi
echo " Starting Tier $tier on $REMOTE_SERVER_NAME..."
for container in "${containers[@]}"; do
[[ -n "$container" ]] && remote_start "$container"
done
state_set "tier${tier}_started" "false"
warn "$ICON_DONE Tier $tier handed back to $REMOTE_SERVER_NAME"
}
# ── Steps 24: Tiers 4→3→2 — Emby stays up throughout ──────────────────────────────────
handback_tier 4 "$t4_delay" "media shares + edge cases"
handback_tier 3 "$t3_delay" "secondary services"
handback_tier 2 "$t2_delay" "productivity services"
# ── Step 5: DDNS handoff — immediately before Tier 1 goes down ──────────────────────────
# Moved here (not at start) so DNS keeps pointing at fallback during higher-tier handbacks.
# Prevents split-brain DNS during Tier 1 rsync window.
echo ""
echo "━━━ $ICON_NET DDNS Handoff ━━━"
remote_ddns_stop
sleep 5
# ── Step 6: Tier 1 handback — vital services + Emby ─────────────────────────────────────
echo ""
echo "━━━ $ICON_FALLBACK Handback Tier 1 — vital services ━━━"
read -r -a t1 <<< "$(get_tier_containers 1)"
echo " Stopping local Tier 1 containers..."
for container in "${t1[@]}"; do
[[ -z "$container" ]] && continue
local is_ddns=false
for ddns in "${REMOTE_DDNS_CONTAINERS[@]}"; do
[[ "$container" == "$ddns" ]] && is_ddns=true && break
done
[[ "$is_ddns" == false ]] && local_stop "$container"
done
if ! check_rsync_enabled "FALLBACK"; then
warn "FALLBACK_RSYNC_ENABLED=false — skipping Tier 1 writeback"
else
local t1_jobs
read -r -a t1_jobs <<< "$(get_writeback_jobs_for_tier 1)"
if [[ ${#t1_jobs[@]} -gt 0 && "$outage_minutes" -ge "$tier1_wb_delay" ]]; then
echo " Rsync writeback — outage ${outage_minutes}min >= ${tier1_wb_delay}min"
for job in "${t1_jobs[@]}"; do
[[ -z "$job" ]] && continue
log "Syncing: $job"
if [[ "$DRY_RUN" == false ]]; then
if [[ "$(basename "$job")" == "Emby" ]]; then
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job" --profile=emby-fallback
else
bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job"
fi
else
warn "DRY RUN — would rsync: $job"
fi
done
else
log "Tier 1 writeback skipped — outage ${outage_minutes}min < ${tier1_wb_delay}min"
fi
fi
echo " Starting Tier 1 on $REMOTE_SERVER_NAME..."
sleep 10 # give databases time to initialise before apps
for container in "${t1[@]}"; do
[[ -z "$container" ]] && continue
local is_ddns=false
for ddns in "${REMOTE_DDNS_CONTAINERS[@]}"; do
[[ "$container" == "$ddns" ]] && is_ddns=true && break
done
[[ "$is_ddns" == false ]] && remote_start "$container"
done
# ── Step 7: DNS Cutover — final step ────────────────────────────────────────────────────
# DNS cuts over ONLY after Tier 1 containers confirmed up
echo ""
echo "━━━ $ICON_NET DNS Cutover ━━━"
sleep 5
for container in "${REMOTE_DDNS_CONTAINERS[@]}"; do
remote_start "$container"
done
warn "$ICON_NET Remote DDNS started — DNS now points at $REMOTE_SERVER_NAME"
# ── Step 8: Return to NORMAL ─────────────────────────────────────────────────────────────
echo ""
state_set state "NORMAL"
state_set fallback_start "0"
state_set handback_strikes "0"
state_set tier2_started "false"
state_set tier3_started "false"
state_set tier4_started "false"
# Restore local DDNS — may have been stopped during NO_INTERNET or DARK state before handback
local_ddns_start
warn "$ICON_DONE Handback complete — returned to NORMAL"
notify "Fallback handback complete on $(hostname)$REMOTE_SERVER_NAME is back, all containers returned" \
"Fallback" "normal"
}
# ==============================================================================================
# ━━━ Main State Machine ━━━
# ==============================================================================================
state_init
set_ddns_arrays
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " $ICON_FALLBACK FALLBACK — $(date '+%Y-%m-%d %H:%M:%S')"
echo " $ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) → monitoring $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo " $ICON_NET Remote IP: $REMOTE_SERVER"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
FALLBACK_RUNNING=true
trap 'FALLBACK_RUNNING=false; warn "Fallback received shutdown signal — stopping cleanly"; exit 0' \
SIGTERM SIGINT
while [[ "$FALLBACK_RUNNING" == true ]]; do
NOW=$(date +%s)
CURRENT_STATE=$(state_get state)
# ── Connectivity checks ──────────────────────────────────────────────────────────────────
REMOTE_UP=false
INTERNET_UP=false
ping_remote && REMOTE_UP=true
ping_internet && INTERNET_UP=true
log "Remote: $REMOTE_UP | Internet: $INTERNET_UP | State: $CURRENT_STATE"
# ════════════════════════════════════════════════════════════════
# NORMAL STATE
# ════════════════════════════════════════════════════════════════
if [[ "$CURRENT_STATE" == "NORMAL" ]]; then
if [[ "$REMOTE_UP" == true && "$INTERNET_UP" == true ]]; then
log "$ICON_SUCCESS NORMAL — all systems up"
elif [[ "$REMOTE_UP" == false && "$INTERNET_UP" == true ]]; then
# Remote is down — enter FALLBACK immediately
echo ""
echo "━━━ $ICON_FALLBACK Entering FALLBACK — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
warn "$REMOTE_SERVER_NAME ($REMOTE_ID) is unreachable — internet is up — starting fallback"
state_set state "FALLBACK"
state_set fallback_start "$NOW"
state_set handback_strikes "0"
state_set tier2_started "false"
state_set tier3_started "false"
state_set tier4_started "false"
echo ""
echo "━━━ $ICON_START Tier 1 — Immediate ━━━"
read -r -a tier1 <<< "$(get_tier_containers 1)"
for container in "${tier1[@]}"; do
[[ -n "$container" ]] && local_start "$container"
done
notify "FALLBACK started on $(hostname)$REMOTE_SERVER_NAME is down — Tier 1 started" \
"Fallback" "warning"
elif [[ "$INTERNET_UP" == false ]]; then
# Lost internet — enter NO_INTERNET
echo ""
echo "━━━ $ICON_NET Entering NO_INTERNET — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
warn "Internet connectivity lost — stopping local DDNS"
state_set state "NO_INTERNET"
local_ddns_stop
# Stop containers configured to stop on internet loss
read -r -a stop_on_no_net <<< "$(get_stop_on_no_net)"
for container in "${stop_on_no_net[@]}"; do
[[ -n "$container" ]] && local_stop "$container"
done
notify "NO_INTERNET on $(hostname) — DDNS stopped, waiting for recovery" \
"Fallback" "warning"
fi
# ════════════════════════════════════════════════════════════════
# FALLBACK STATE
# ════════════════════════════════════════════════════════════════
elif [[ "$CURRENT_STATE" == "FALLBACK" ]]; then
FALLBACK_START_TS=$(state_get fallback_start)
ELAPSED_MIN=$(( (NOW - FALLBACK_START_TS) / 60 ))
if [[ "$REMOTE_UP" == true && "$INTERNET_UP" == true ]]; then
# Remote returned — increment handback strikes
STRIKES=$(state_get handback_strikes)
STRIKES=$(( STRIKES + 1 ))
state_set handback_strikes "$STRIKES"
warn "$ICON_FALLBACK $REMOTE_SERVER_NAME is back — handback strike $STRIKES/$FALLBACK_HANDBACK_STRIKES"
if [[ "$STRIKES" -ge "$FALLBACK_HANDBACK_STRIKES" ]]; then
run_handback
fi
elif [[ "$INTERNET_UP" == false ]]; then
# Lost internet during fallback — enter DARK
echo ""
echo "━━━ $ICON_NET Entering DARK — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
warn "Lost internet during fallback — entering DARK state"
state_set state "DARK"
local_ddns_stop
notify "DARK state on $(hostname) — lost internet during fallback" \
"Fallback" "warning"
else
# Still in failover — check tier escalation
state_set handback_strikes "0"
TIER2_DELAY=$(get_tier2_delay)
if [[ "$(state_get tier2_started)" == "false" && \
"$ELAPSED_MIN" -ge "$TIER2_DELAY" ]]; then
echo ""
echo "━━━ $ICON_START Tier 2 — ${ELAPSED_MIN}min outage ━━━"
read -r -a tier2 <<< "$(get_tier_containers 2)"
for container in "${tier2[@]}"; do
[[ -n "$container" ]] && local_start "$container"
done
state_set tier2_started "true"
notify "Fallback Tier 2 started on $(hostname)${ELAPSED_MIN}min outage" \
"Fallback" "warning"
fi
TIER3_DELAY=$(get_tier3_delay)
if [[ "$(state_get tier3_started)" == "false" && \
"$ELAPSED_MIN" -ge "$TIER3_DELAY" ]]; then
echo ""
echo "━━━ $ICON_START Tier 3 — ${ELAPSED_MIN}min outage ━━━"
read -r -a tier3 <<< "$(get_tier_containers 3)"
for container in "${tier3[@]}"; do
[[ -n "$container" ]] && local_start "$container"
done
state_set tier3_started "true"
notify "Fallback Tier 3 started on $(hostname)${ELAPSED_MIN}min outage" \
"Fallback" "warning"
fi
TIER4_DELAY=$(get_tier4_delay)
if [[ "$(state_get tier4_started)" == "false" && \
"$ELAPSED_MIN" -ge "$TIER4_DELAY" ]]; then
echo ""
echo "━━━ $ICON_START Tier 4 — ${ELAPSED_MIN}min outage — full workflow ━━━"
read -r -a tier4 <<< "$(get_tier_containers 4)"
for container in "${tier4[@]}"; do
[[ -n "$container" ]] && local_start "$container"
done
state_set tier4_started "true"
notify "Fallback Tier 4 started on $(hostname)${ELAPSED_MIN}min outage — full workflow active" \
"Fallback" "warning"
fi
log "$ICON_FALLBACK FALLBACK — ${ELAPSED_MIN}min — T2:$(state_get tier2_started) T3:$(state_get tier3_started) T4:$(state_get tier4_started)"
fi
# ════════════════════════════════════════════════════════════════
# NO_INTERNET STATE
# ════════════════════════════════════════════════════════════════
elif [[ "$CURRENT_STATE" == "NO_INTERNET" ]]; then
if [[ "$INTERNET_UP" == true ]]; then
echo ""
echo "━━━ $ICON_NET Internet Recovered — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
if [[ "$REMOTE_UP" == true ]]; then
warn "Remote is up — returning to NORMAL"
state_set state "NORMAL"
local_ddns_start
notify "Internet recovered on $(hostname) — returning to NORMAL" \
"Fallback" "normal"
else
warn "Internet back but $REMOTE_SERVER_NAME still down — entering FALLBACK"
state_set state "FALLBACK"
state_set fallback_start "$NOW"
state_set handback_strikes "0"
state_set tier2_started "false"
state_set tier3_started "false"
state_set tier4_started "false"
echo ""
echo "━━━ $ICON_START Tier 1 — Immediate ━━━"
read -r -a tier1 <<< "$(get_tier_containers 1)"
for container in "${tier1[@]}"; do
[[ -n "$container" ]] && local_start "$container"
done
notify "Internet recovered on $(hostname) but $REMOTE_SERVER_NAME still down — entering FALLBACK" \
"Fallback" "warning"
fi
else
log "$ICON_NET NO_INTERNET — waiting for connectivity"
fi
# ════════════════════════════════════════════════════════════════
# DARK STATE
# ════════════════════════════════════════════════════════════════
elif [[ "$CURRENT_STATE" == "DARK" ]]; then
if [[ "$INTERNET_UP" == true ]]; then
echo ""
echo "━━━ $ICON_NET Emerging from DARK — $(date '+%Y-%m-%d %H:%M:%S') ━━━"
if [[ "$REMOTE_UP" == true ]]; then
warn "Remote up, internet up — transitioning through FALLBACK for handback"
# Was in failover before DARK — route through FALLBACK state for handback
state_set state "FALLBACK"
state_set handback_strikes "0"
else
warn "Internet back but $REMOTE_SERVER_NAME still down — entering FALLBACK"
state_set state "FALLBACK"
state_set fallback_start "$NOW"
state_set handback_strikes "0"
state_set tier2_started "false"
state_set tier3_started "false"
state_set tier4_started "false"
read -r -a tier1 <<< "$(get_tier_containers 1)"
for container in "${tier1[@]}"; do
[[ -n "$container" ]] && local_start "$container"
done
fi
notify "Emerging from DARK state on $(hostname)" "Fallback" "warning"
else
log "$ICON_FALLBACK DARK — no internet, no remote — waiting"
fi
fi
# ── Sleep until next check ───────────────────────────────────────────────────────────────
log "Next check in ${FALLBACK_CHECK_INTERVAL}s — $(date '+%H:%M:%S')"
sleep "$FALLBACK_CHECK_INTERVAL" &
wait $!
done