#!/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 FAILOVER_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. # # FAILOVER — 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 + 1 minute check interval = minimal user impact on failover. # # ── HANDBACK SEQUENCE ───────────────────────────────────────────────────────────────────────── # When remote returns after FAILOVER: # 1. Strike confirmation — FAILOVER_HANDBACK_STRIKES consecutive remote-up checks # prevents handing back during a brief network blip # 2. Pre-flight checks — remote array started, Docker healthy, rootfs not full # 3. Stop remote DDNS first — prevents split brain DNS during rsync # 4. Stop remote containers — clean state, no dirty writes during rsync # 5. Tiered rsync writeback — skip if outage under threshold (short outages = cleaner to skip) # Tier 1: skip if under HOST1_TIER1_WRITEBACK_DELAY (60min default) # Tier 2: skip if under HOST1_TIER2_DELAY # Tier 3: skip if under HOST1_TIER3_DELAY # Tier 4: skip if under HOST*_TIER4_DELAY — opposing daily sync shares + edge cases # 6. Start local containers — confirmed up before DNS cuts over # 7. Start local DDNS — DNS cuts back ONLY after containers confirmed up # 8. Return to NORMAL # # ── TIERED FAILOVER ─────────────────────────────────────────────────────────────────────────── # Tier 1 — Immediate — vital services + Live TV — can't wait # Tier 2 — 2hr default — shared productivity services # Tier 3 — 6hr default — secondary services # Tier 4 — 18hr default — arrs + downloaders — minimal writeback on handback # Delays configurable per host in Master.conf # # ── TO STOP THIS SCRIPT ─────────────────────────────────────────────────────────────────────── # Click Abort beside the script in unRAID's User Scripts plugin page. # Do NOT kill the process directly — state file may be left in inconsistent state. # # All configuration in Master.conf under Failover section. # Supports --dry-run (no container changes) and --status (show current state and exit). # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../Master.conf" source "$SCRIPT_DIR/../common.sh" parse_args "$@" # ============================================================================================== # ━━━ $ICON_GEAR Setup ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_GEAR Setup ━━━" if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi success "Running as root" detect_hosts resolve_remote_ip [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no container or DDNS changes will be made" # ============================================================================================== # STATE FILE HELPERS # State file format — key=value, one per line, persists on /boot/ # Tracks: state, failover_start, handback_strikes, tier2_started, tier3_started, tier4_started # ============================================================================================== state_get() { grep "^${1}=" "$FAILOVER_STATE_FILE" 2>/dev/null | cut -d= -f2 } state_set() { local key="$1" value="$2" if grep -q "^${key}=" "$FAILOVER_STATE_FILE" 2>/dev/null; then sed -i "s|^${key}=.*|${key}=${value}|" "$FAILOVER_STATE_FILE" else echo "${key}=${value}" >> "$FAILOVER_STATE_FILE" fi } state_init() { mkdir -p "$(dirname "$FAILOVER_STATE_FILE")" [[ ! -f "$FAILOVER_STATE_FILE" ]] && touch "$FAILOVER_STATE_FILE" [[ -z "$(state_get state)" ]] && state_set state "NORMAL" [[ -z "$(state_get failover_start)" ]] && state_set failover_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 # ============================================================================================== # Start a container locally local_start() { local container="$1" [[ -z "$container" ]] && return local status status=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null) if [[ "$status" == "true" ]]; then log "$container already running locally" return 0 fi info "$ICON_START Starting $container locally..." if [[ "$DRY_RUN" == false ]]; then docker start "$container" >/dev/null 2>&1 && \ success "$ICON_STARTED $container started" || \ error "Failed to start $container locally" else warn "DRY RUN — would start $container locally" fi } # Stop a container locally local_stop() { local container="$1" [[ -z "$container" ]] && return local status status=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null) if [[ "$status" != "true" ]]; then log "$container already stopped locally" return 0 fi info "$ICON_STOP Stopping $container locally..." if [[ "$DRY_RUN" == false ]]; then docker stop "$container" >/dev/null 2>&1 && \ success "$ICON_STOPPED $container stopped" || \ error "Failed to stop $container locally" else warn "DRY RUN — would stop $container locally" fi } # Start a container on remote via SSH remote_start() { local container="$1" [[ -z "$container" ]] && return local status status=$(ssh -i "$SSH_KEY" -o ConnectTimeout=10 root@"$REMOTE_SERVER" \ "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 info "$ICON_START Starting $container on $REMOTE_SERVER_NAME..." if [[ "$DRY_RUN" == false ]]; then ssh -i "$SSH_KEY" -o ConnectTimeout=10 root@"$REMOTE_SERVER" \ "docker start $container" >/dev/null 2>&1 && \ success "$ICON_STARTED $container started on $REMOTE_SERVER_NAME" || \ error "Failed to start $container on $REMOTE_SERVER_NAME" else warn "DRY RUN — would start $container on $REMOTE_SERVER_NAME" fi } # Stop a container on remote via SSH remote_stop() { local container="$1" [[ -z "$container" ]] && return local status status=$(ssh -i "$SSH_KEY" -o ConnectTimeout=10 root@"$REMOTE_SERVER" \ "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 info "$ICON_STOP Stopping $container on $REMOTE_SERVER_NAME..." if [[ "$DRY_RUN" == false ]]; then ssh -i "$SSH_KEY" -o ConnectTimeout=10 root@"$REMOTE_SERVER" \ "docker stop $container" >/dev/null 2>&1 && \ success "$ICON_STOPPED $container stopped on $REMOTE_SERVER_NAME" || \ error "Failed to stop $container on $REMOTE_SERVER_NAME" else warn "DRY RUN — would stop $container on $REMOTE_SERVER_NAME" fi } # ============================================================================================== # DDNS HELPERS # DDNS is managed exclusively by this script — never by network state # ============================================================================================== # Stop local DDNS — called on internet loss local_ddns_stop() { info "$ICON_NET Stopping local DDNS — internet lost" for container in "${LOCAL_DDNS_CONTAINERS[@]}"; do local_stop "$container" done } # Start local DDNS — called at end of handback ONLY after containers confirmed up local_ddns_start() { info "$ICON_NET Starting local DDNS — containers confirmed up" for container in "${LOCAL_DDNS_CONTAINERS[@]}"; do local_start "$container" done } # Stop remote DDNS — FIRST step of handback sequence, prevents split brain remote_ddns_stop() { info "$ICON_NET Stopping remote DDNS on $REMOTE_SERVER_NAME — handback starting" for container in "${REMOTE_DDNS_CONTAINERS[@]}"; do remote_stop "$container" done } # ============================================================================================== # TIERED FAILOVER HELPERS # Containers selected based on which server is local and which arrays to use # ============================================================================================== # Get the correct tier arrays based on local host # HOST2 runs for HOST1, HOST1 runs for HOST2 get_tier_containers() { local tier="$1" if [[ "$LOCAL_SERVER_NAME" == "$HOST2" ]]; then # HOST2 is running — covers HOST1 case "$tier" in 1) echo "${FAILOVER_HOST2_RUNS_FOR_HOST1_IMMEDIATE[@]}" ;; 2) echo "${FAILOVER_HOST2_RUNS_FOR_HOST1_2HR[@]}" ;; 3) echo "${FAILOVER_HOST2_RUNS_FOR_HOST1_6HR[@]}" ;; 4) echo "${FAILOVER_HOST2_RUNS_FOR_HOST1_18HR[@]}" ;; esac else # HOST1 is running — covers HOST2 case "$tier" in 1) echo "${FAILOVER_HOST1_RUNS_FOR_HOST2_IMMEDIATE[@]}" ;; 2) echo "${FAILOVER_HOST1_RUNS_FOR_HOST2_2HR[@]}" ;; 3) echo "${FAILOVER_HOST1_RUNS_FOR_HOST2_6HR[@]}" ;; 4) echo "${FAILOVER_HOST1_RUNS_FOR_HOST2_18HR[@]}" ;; esac fi } get_tier2_delay() { [[ "$LOCAL_SERVER_NAME" == "$HOST2" ]] && \ echo "$HOST1_TIER2_DELAY" || echo "$HOST2_TIER2_DELAY" } get_tier3_delay() { [[ "$LOCAL_SERVER_NAME" == "$HOST2" ]] && \ echo "$HOST1_TIER3_DELAY" || echo "$HOST2_TIER3_DELAY" } get_tier4_delay() { [[ "$LOCAL_SERVER_NAME" == "$HOST2" ]] && \ echo "$HOST1_TIER4_DELAY" || echo "$HOST2_TIER4_DELAY" } get_tier1_writeback_delay() { [[ "$LOCAL_SERVER_NAME" == "$HOST2" ]] && \ echo "$HOST1_TIER1_WRITEBACK_DELAY" || echo "$HOST2_TIER1_WRITEBACK_DELAY" } get_writeback_jobs_for_tier() { local tier="$1" if [[ "$LOCAL_SERVER_NAME" == "$HOST2" ]]; then # HOST2 is covering HOST1 — write back HOST1's data case "$tier" in 1) echo "${FAILOVER_HOST1_WRITEBACK_TIER1[@]}" ;; 2) echo "${FAILOVER_HOST1_WRITEBACK_TIER2[@]}" ;; 3) echo "${FAILOVER_HOST1_WRITEBACK_TIER3[@]}" ;; 4) # Tier 4 — push HOST1's daily sync shares back (opposing orch list) # These are HOST1's source-of-truth shares that HOST2's arrs managed during outage # Plus any edge case paths defined in FAILOVER_HOST1_WRITEBACK_TIER4 echo "${HOST1_DAILY_SYNC_SHARES[@]}" echo "${FAILOVER_HOST1_WRITEBACK_TIER4[@]}" ;; esac else # HOST1 is covering HOST2 — write back HOST2's data case "$tier" in 1) echo "${FAILOVER_HOST2_WRITEBACK_TIER1[@]}" ;; 2) echo "${FAILOVER_HOST2_WRITEBACK_TIER2[@]}" ;; 3) echo "${FAILOVER_HOST2_WRITEBACK_TIER3[@]}" ;; 4) # Tier 4 — push HOST2's daily sync shares back (opposing orch list) # These are HOST2's source-of-truth shares that HOST1's arrs managed during outage # Plus any edge case paths defined in FAILOVER_HOST2_WRITEBACK_TIER4 echo "${HOST2_DAILY_SYNC_SHARES[@]}" echo "${FAILOVER_HOST2_WRITEBACK_TIER4[@]}" ;; esac fi } # Select correct DDNS arrays based on local host set_ddns_arrays() { if [[ "$LOCAL_SERVER_NAME" == "$HOST1" ]]; then LOCAL_DDNS_CONTAINERS=("${HOST1_DDNS_CONTAINERS[@]}") REMOTE_DDNS_CONTAINERS=("${HOST2_DDNS_CONTAINERS[@]}") else LOCAL_DDNS_CONTAINERS=("${HOST2_DDNS_CONTAINERS[@]}") REMOTE_DDNS_CONTAINERS=("${HOST1_DDNS_CONTAINERS[@]}") fi } # ============================================================================================== # STATUS DISPLAY # ============================================================================================== if [[ "$SHOW_STATUS" == true ]]; then state_init set_ddns_arrays CURRENT_STATE=$(state_get state) FAILOVER_START=$(state_get failover_start) TIER2=$(state_get tier2_started) TIER3=$(state_get tier3_started) TIER4=$(state_get tier4_started) STRIKES=$(state_get handback_strikes) echo "" echo "━━━━━ $ICON_SUMMARY FAILOVER STATUS ━━━━━" echo "$ICON_HOST Local: $LOCAL_SERVER_NAME" echo "$ICON_HOST Remote: $REMOTE_SERVER_NAME ($REMOTE_SERVER)" echo "$ICON_FAILOVER State: $CURRENT_STATE" echo "$ICON_NET Local DDNS: ${LOCAL_DDNS_CONTAINERS[*]}" echo "$ICON_NET Remote DDNS: ${REMOTE_DDNS_CONTAINERS[*]}" echo "$ICON_TIME Interval: ${FAILOVER_CHECK_INTERVAL}s" echo "$ICON_FAILOVER Strikes: $STRIKES / $FAILOVER_HANDBACK_STRIKES" if [[ "$CURRENT_STATE" == "FAILOVER" && "$FAILOVER_START" -gt 0 ]]; then ELAPSED=$(( ($(date +%s) - FAILOVER_START) / 60 )) echo "$ICON_TIME Outage: ${ELAPSED}min" echo "$ICON_FAILOVER Tier 2: $TIER2 (delay: $(get_tier2_delay)min)" echo "$ICON_FAILOVER Tier 3: $TIER3 (delay: $(get_tier3_delay)min)" echo "$ICON_FAILOVER Tier 4: $TIER4 (delay: $(get_tier4_delay)min)" fi echo "$ICON_GEAR Dry Run: $DRY_RUN" echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi # ============================================================================================== # HANDBACK SEQUENCE # Called when remote returns after FAILOVER state # Critical sequencing — do not reorder without understanding the consequences # ============================================================================================== run_handback() { echo "" echo "━━━ $ICON_FAILOVER Handback Sequence — $(date '+%Y-%m-%d %H:%M:%S') ━━━" info "Remote $REMOTE_SERVER_NAME has returned — beginning handback" # ── Step 1: Pre-flight checks ──────────────────────────────────────────────────────────── echo "" echo "━━━ $ICON_SHIELD Pre-flight ━━━" 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; then warn "Remote Docker not ready — aborting handback, will retry next cycle" state_set handback_strikes 0 return 1 fi success "Pre-flight checks passed" # ── Step 2: Stop remote DDNS FIRST ────────────────────────────────────────────────────── # CRITICAL — prevents split brain DNS during rsync window # Remote DDNS must be OFF before any data moves echo "" echo "━━━ $ICON_NET DDNS Handoff ━━━" remote_ddns_stop info "Remote DDNS stopped — DNS will not update during rsync" sleep 5 # brief pause to ensure DDNS stop propagates # ── Step 3: Stop remote containers ────────────────────────────────────────────────────── # Clean state before rsync — no dirty writes during transfer # Containers are only down during the rsync window — minimise this time echo "" echo "━━━ $ICON_STOP Stop Remote Containers ━━━" # Stop all tiers that were started — in reverse order local ALL_FAILOVER_CONTAINERS=() if [[ "$(state_get tier4_started)" == "true" ]]; then read -r -a t4 <<< "$(get_tier_containers 4)" ALL_FAILOVER_CONTAINERS+=("${t4[@]}") fi if [[ "$(state_get tier3_started)" == "true" ]]; then read -r -a t3 <<< "$(get_tier_containers 3)" ALL_FAILOVER_CONTAINERS+=("${t3[@]}") fi if [[ "$(state_get tier2_started)" == "true" ]]; then read -r -a t2 <<< "$(get_tier_containers 2)" ALL_FAILOVER_CONTAINERS+=("${t2[@]}") fi # Tier 1 always started — stop last (DDNS already stopped above) read -r -a t1 <<< "$(get_tier_containers 1)" # Remove DDNS containers from tier 1 list — already handled for container in "${t1[@]}"; do local is_ddns=false for ddns in "${REMOTE_DDNS_CONTAINERS[@]}"; do [[ "$container" == "$ddns" ]] && is_ddns=true && break done [[ "$is_ddns" == false ]] && ALL_FAILOVER_CONTAINERS+=("$container") done for container in "${ALL_FAILOVER_CONTAINERS[@]}"; do [[ -z "$container" ]] && continue local_stop "$container" done success "All failover containers stopped locally" # ── Step 4: Rsync writeback ────────────────────────────────────────────────────────────── # Tiered writeback with skip window — short outages do not benefit from writeback. # Emby syncs every 30min dirty (live container). Clean sync runs nightly at 2:30am. # After a short outage HOST1's clean nightly state is more reliable than HOST2's # dirty sync accumulation — skip writeback entirely for short outages. # # Tier 1 — skip if under HOST1_TIER1_WRITEBACK_DELAY (default 60min) # Tier 2 — skip if under HOST1_TIER2_DELAY (reused — if Tier 2 never started, skip) # Tier 3 — skip if under HOST1_TIER3_DELAY (reused — same logic) # Tier 4 — always writeback — 18hr+ means meaningful delta accumulated echo "" echo "━━━ $ICON_SYNC Rsync Writeback ━━━" local outage_minutes=$(( ($(date +%s) - $(state_get failover_start)) / 60 )) local tier1_wb_delay tier1_wb_delay=$(get_tier1_writeback_delay) info "Outage duration: ${outage_minutes}min" run_writeback_tier() { local tier="$1" local threshold="$2" local label="$3" local jobs read -r -a jobs <<< "$(get_writeback_jobs_for_tier "$tier")" [[ ${#jobs[@]} -eq 0 ]] && return if [[ "$outage_minutes" -ge "$threshold" ]]; then info "Tier $tier writeback ($label) — outage ${outage_minutes}min >= ${threshold}min" for job in "${jobs[@]}"; do [[ -z "$job" ]] && continue info "Syncing: $job" if [[ "$DRY_RUN" == false ]]; then bash "$SCRIPT_DIR/../Rsync/rsync.sh" "$job" else warn "DRY RUN — would rsync: $job" fi done else info "Tier $tier writeback skipped — outage ${outage_minutes}min < ${threshold}min threshold" info "Primary has cleaner state — no writeback needed" fi } run_writeback_tier 1 "$tier1_wb_delay" "Emby + auth stack" run_writeback_tier 2 "$HOST1_TIER2_DELAY" "NextCloud + Immich" run_writeback_tier 3 "$HOST1_TIER3_DELAY" "secondary services" run_writeback_tier 4 "$(get_tier4_delay)" "media shares + edge cases" success "Writeback complete" # ── Step 5: Start remote containers ───────────────────────────────────────────────────── # Start in dependency order — databases before apps echo "" echo "━━━ $ICON_START Start Remote Containers ━━━" # Start tier 1 on remote (excluding DDNS — handled separately) 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 # Brief pause — give databases time to initialise before apps sleep 10 if [[ "$(state_get tier2_started)" == "true" ]]; then for container in "${t2[@]}"; do [[ -n "$container" ]] && remote_start "$container" done fi if [[ "$(state_get tier3_started)" == "true" ]]; then for container in "${t3[@]}"; do [[ -n "$container" ]] && remote_start "$container" done fi if [[ "$(state_get tier4_started)" == "true" ]]; then for container in "${t4[@]}"; do [[ -n "$container" ]] && remote_start "$container" done fi success "Remote containers started" # ── Step 6: Start remote DDNS LAST ────────────────────────────────────────────────────── # DNS cuts over ONLY after containers confirmed up # This is the final step — after this users hit remote echo "" echo "━━━ $ICON_NET DNS Cutover ━━━" sleep 5 # brief pause to ensure containers are accepting connections for container in "${REMOTE_DDNS_CONTAINERS[@]}"; do remote_start "$container" done success "Remote DDNS started — DNS now points at $REMOTE_SERVER_NAME" # ── Step 7: Return to NORMAL ───────────────────────────────────────────────────────────── echo "" state_set state "NORMAL" state_set failover_start "0" state_set handback_strikes "0" state_set tier2_started "false" state_set tier3_started "false" state_set tier4_started "false" success "$ICON_DONE Handback complete — returned to NORMAL" notify "Failover handback complete on $(hostname) — $REMOTE_SERVER_NAME is back, all containers returned" "Failover" "normal" } # ============================================================================================== # MAIN STATE MACHINE # ============================================================================================== state_init set_ddns_arrays echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " $ICON_FAILOVER FAILOVER — $(date '+%Y-%m-%d %H:%M:%S')" echo " $ICON_HOST $LOCAL_SERVER_NAME → monitoring $REMOTE_SERVER_NAME" echo " $ICON_NET Remote: $REMOTE_SERVER" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" while 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" # ── State machine ──────────────────────────────────────────────────────────────────────── # ════════════════════════════════════════════════════════════════ # NORMAL STATE # ════════════════════════════════════════════════════════════════ if [[ "$CURRENT_STATE" == "NORMAL" ]]; then if [[ "$REMOTE_UP" == true && "$INTERNET_UP" == true ]]; then # All good — silent operation log "$ICON_SUCCESS NORMAL — all systems up" elif [[ "$REMOTE_UP" == false && "$INTERNET_UP" == true ]]; then # Remote is down — enter FAILOVER echo "" echo "━━━ $ICON_FAILOVER Entering FAILOVER — $(date '+%Y-%m-%d %H:%M:%S') ━━━" warn "$REMOTE_SERVER_NAME is unreachable — internet is up — starting failover" state_set state "FAILOVER" state_set failover_start "$NOW" state_set handback_strikes "0" state_set tier2_started "false" state_set tier3_started "false" state_set tier4_started "false" # Start Tier 1 immediately — DDNS first, then vital services 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 "FAILOVER started on $(hostname) — $REMOTE_SERVER_NAME is down — Tier 1 containers started" "Failover" "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 any containers configured to stop without internet if [[ "$LOCAL_SERVER_NAME" == "$HOST1" ]]; then for container in "${FAILOVER_HOST1_STOP_ON_NO_NET[@]}"; do [[ -n "$container" ]] && local_stop "$container" done else for container in "${FAILOVER_HOST2_STOP_ON_NO_NET[@]}"; do [[ -n "$container" ]] && local_stop "$container" done fi notify "NO_INTERNET on $(hostname) — DDNS stopped, waiting for recovery" "Failover" "warning" fi # ════════════════════════════════════════════════════════════════ # FAILOVER STATE # ════════════════════════════════════════════════════════════════ elif [[ "$CURRENT_STATE" == "FAILOVER" ]]; then FAILOVER_START=$(state_get failover_start) ELAPSED_MIN=$(( (NOW - FAILOVER_START) / 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" info "$ICON_FAILOVER $REMOTE_SERVER_NAME is back — handback strike $STRIKES/$FAILOVER_HANDBACK_STRIKES" if [[ "$STRIKES" -ge "$FAILOVER_HANDBACK_STRIKES" ]]; then run_handback fi elif [[ "$INTERNET_UP" == false ]]; then # Lost internet during failover — enter DARK echo "" echo "━━━ $ICON_NET Entering DARK — $(date '+%Y-%m-%d %H:%M:%S') ━━━" warn "Lost internet during failover — entering DARK state" state_set state "DARK" local_ddns_stop notify "DARK state on $(hostname) — lost internet during failover" "Failover" "warning" else # Still in failover — check tier escalation state_set handback_strikes "0" # reset strikes — remote still down # Tier 2 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 "Failover Tier 2 started on $(hostname) — ${ELAPSED_MIN}min outage" "Failover" "warning" fi # Tier 3 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 "Failover Tier 3 started on $(hostname) — ${ELAPSED_MIN}min outage" "Failover" "warning" fi # Tier 4 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 "Failover Tier 4 started on $(hostname) — ${ELAPSED_MIN}min outage — full workflow active" "Failover" "warning" fi log "$ICON_FAILOVER FAILOVER active — ${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 # Internet recovered echo "" echo "━━━ $ICON_NET Internet Recovered — $(date '+%Y-%m-%d %H:%M:%S') ━━━" if [[ "$REMOTE_UP" == true ]]; then # Remote up, internet up — return to NORMAL info "Remote is up — returning to NORMAL" state_set state "NORMAL" # DDNS does NOT auto-start here — it only starts via handback sequence # or explicit NORMAL state management # If local DDNS should be running in NORMAL — start it now local_ddns_start notify "Internet recovered on $(hostname) — returning to NORMAL" "Failover" "normal" else # Internet back but remote still down — enter FAILOVER info "Remote still down — entering FAILOVER" state_set state "FAILOVER" state_set failover_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 FAILOVER" "Failover" "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 info "Remote up, internet up — returning to NORMAL" # Was in failover before DARK — need to handback state_set state "FAILOVER" state_set handback_strikes "0" # Will trigger handback on next cycle via FAILOVER + remote up logic else info "Internet back but remote still down — entering FAILOVER" state_set state "FAILOVER" state_set failover_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)" "Failover" "warning" else log "$ICON_FAILOVER DARK — no internet, no remote — waiting" fi fi # ── Sleep until next check ─────────────────────────────────────────────────────────────── log "Next check in ${FAILOVER_CHECK_INTERVAL}s — $(date '+%H:%M:%S')" sleep "$FAILOVER_CHECK_INTERVAL" done