#!/bin/bash # ============================================================================================== # ================================= Fallback Test ============================================== # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Controlled simulation of the fallback lifecycle. Validates the entire sequence # without waiting for a real outage. Contains no fallback logic — exercises the # real fallback.sh via an iptables DROP rule on the remote Tailscale IP. # # Run during a maintenance window. Users will experience a brief service # interruption. Use --dry-run to walk through all phases without real changes. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # Phase 1 — Pre-flight Both servers reachable, Docker daemons healthy, # version parity, fallback.sh exists, state NORMAL # Phase 2 — Block Remote iptables DROP rule added — remote appears unreachable # Phase 3 — Fallback Detection Wait FALLBACK_TEST_BLOCK_WAIT for fallback.sh to # detect the outage and enter FALLBACK state # Phase 4 — Container Start Verify Tier 1 containers started locally # Phase 5 — Restore iptables rule removed — remote reachable again # Phase 6 — Handback Wait FALLBACK_TEST_HANDBACK_WAIT for fallback.sh to # complete full handback and return to NORMAL # Phase 7 — Container Handback Verify Tier 1 containers stopped locally # Report — Full pass/fail per phase with timing # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Test Harness Only # Contains zero fallback logic. All fallback is exercised through fallback.sh. # Any change to fallback.sh is automatically reflected in the test result. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # iptables Safety Trap # The DROP rule is removed via trap on ANY exit — normal completion, crash, error, # ctrl-c. Remote connectivity is always restored regardless of test outcome. # You cannot accidentally leave the remote permanently blocked. # # FALLBACK_ENABLED Gate # Aborts if FALLBACK_ENABLED=false. Testing a disabled fallback system is # misleading and potentially destructive. # # State Must Be NORMAL # Pre-flight fails if state is not NORMAL. Running a test during an actual # fallback event would interfere with the real event. # # Version Parity Check # Pre-flight verifies unRAID version parity before any iptables rules are # added. A mismatch makes the test result unreliable. # # Remote Docker Daemon Check # Pre-flight confirms remote Docker daemon is responsive before Phase 2. # # Lock Acquisition # acquire_lock() prevents concurrent test runs. Running two tests simultaneously # would produce conflicting iptables rules and unreliable results. # # Host Detection # detect_hosts() resolves MY_ID / REMOTE_ID from master.conf at startup. # Exits if the host cannot be identified — prevents testing on an unknown machine. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # master.conf # # FALLBACK_TEST_BLOCK_WAIT # Seconds to wait in Phase 3 for fallback.sh to detect the outage. # Must be > FALLBACK_CHECK_INTERVAL + buffer. At 30s interval: use ≥60s. # (default: 60) # # FALLBACK_TEST_HANDBACK_WAIT # Seconds to wait in Phase 6 for fallback.sh to complete handback. # Must cover: FALLBACK_HANDBACK_STRIKES × FALLBACK_CHECK_INTERVAL + rsync # duration + container start time. At 3 strikes × 30s + ~2min rsync + # ~1min container start: use ≥240s. (default: 300) # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # fallback_test.sh --dry-run # Walk through all 7 phases with output but no iptables changes and no # container starts/stops. ALWAYS run this before a live test. # # fallback_test.sh # Full live test — real iptables DROP rule, real container lifecycle. # Users will experience a brief service interruption. Run during a # maintenance window. # # fallback_test.sh --status # Show current fallback state and test timing configuration. No test run. # # fallback_test.sh --log # Verbose output on every check in every phase. # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" parse_args "$@" FALLBACK_SCRIPT="$SCRIPT_DIR/fallback.sh" DOCKER_TIMEOUT=15 # ============================================================================================== # ── SAFETY TRAP — always remove iptables rule on exit ───────────────────────────────────────── # ============================================================================================== # Fires on normal exit, error exit, ctrl-c, and script crashes. # Remote connectivity is ALWAYS restored regardless of test outcome. IPTABLES_RULE_ACTIVE=false cleanup() { if [[ "$IPTABLES_RULE_ACTIVE" == true ]]; then echo "" warn "$ICON_SHIELD Cleanup — removing iptables block on $REMOTE_SERVER..." if [[ "$DRY_RUN" == false ]]; then iptables -D OUTPUT -d "$REMOTE_SERVER" -j DROP 2>/dev/null IPTABLES_RULE_ACTIVE=false warn "iptables rule removed — remote connectivity restored" else warn "DRY RUN — would remove iptables rule" fi fi } trap cleanup EXIT # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi # FALLBACK_ENABLED gate — no point testing if fallback is disabled if [[ "${FALLBACK_ENABLED:-false}" == false ]]; then warn "FALLBACK_ENABLED=false — fallback test aborted" warn "Enable fallback in master.conf before running this test" exit 0 fi acquire_lock # strict single instance — modifies iptables and containers if ! command -v docker &>/dev/null; then error "Docker command not found" exit 1 fi detect_hosts resolve_remote_ip # Validate commands used by this script platform_require_cmd \ "$(which iptables 2>/dev/null || echo /sbin/iptables)" \ "--version" "iptables" \ "iptables" || { error "iptables not found — required for connectivity simulation"; exit 1; } platform_require_cmd \ "/usr/local/emhttp/plugins/dynamix/scripts/notify" \ "" "" \ "unRAID notify script" || warn "unRAID notify script not found — native notifications disabled" if [[ ! -f "$FALLBACK_SCRIPT" ]]; then error "fallback.sh not found at $FALLBACK_SCRIPT" exit 1 fi log "fallback.sh found at $FALLBACK_SCRIPT" log "$ICON_GEAR Config: remote=${REMOTE_SERVER_NAME} (${REMOTE_SERVER}) fallback-script=${FALLBACK_SCRIPT}" [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no iptables rules or container changes will be made" # ============================================================================================== # ━━━ Status ━━━ # ============================================================================================== if [[ "$SHOW_STATUS" == true ]]; then local_ver=$(grep -oP '(?<=version=")[^"]+' /etc/unraid-version 2>/dev/null || echo "unknown") echo "" echo "━━━━━ $ICON_SUMMARY 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 Block wait: ${FALLBACK_TEST_BLOCK_WAIT}s" echo "$ICON_FALLBACK Handback wait: ${FALLBACK_TEST_HANDBACK_WAIT}s" echo "$ICON_FALLBACK Check interval: ${FALLBACK_CHECK_INTERVAL}s" echo "$ICON_FALLBACK Handback strikes: ${FALLBACK_HANDBACK_STRIKES}" echo "$ICON_GEAR Dry Run: $DRY_RUN" if [[ -f "$FALLBACK_STATE_FILE" ]]; then CURRENT_STATE=$(grep "^state=" "$FALLBACK_STATE_FILE" 2>/dev/null | cut -d= -f2) echo "$ICON_FALLBACK Current state: ${CURRENT_STATE:-unknown}" else echo "$ICON_FALLBACK Current state: no state file" fi # Show Tier 1 containers for this host TIER1_VAR="FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER1" eval "TIER1_CONTAINERS=(\"\${${TIER1_VAR}[@]:-}\")" echo "$ICON_CONTAINERS Tier 1 to test: ${TIER1_CONTAINERS[*]:-none configured}" echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi # ============================================================================================== # ── PHASE TRACKING ──────────────────────────────────────────────────────────────────────────── # ============================================================================================== PHASES_PASS=() PHASES_FAIL=() TOTAL_START=$(date +%s) phase_pass() { PHASES_PASS+=("$1"); warn "$ICON_DONE Phase: $1 — PASSED ✅"; } phase_fail() { PHASES_FAIL+=("$1"); error "Phase: $1 — FAILED ❌"; } # Get Tier 1 containers for this server's fallback responsibility TIER1_VAR="FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER1" eval "TIER1_CONTAINERS=(\"\${${TIER1_VAR}[@]:-}\")" # ============================================================================================== # ━━━ Phase 1 — Pre-flight ━━━ # ============================================================================================== echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " $ICON_SHIELD FALLBACK TEST — $(date '+%Y-%m-%d %H:%M:%S')" echo " $ICON_HOST $MY_ID ($LOCAL_SERVER_NAME) → $REMOTE_ID ($REMOTE_SERVER_NAME)" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "━━━ $ICON_SHIELD Phase 1 — Pre-flight ━━━" # Remote reachable if ping_remote; then log "$REMOTE_SERVER_NAME is reachable" else error "$REMOTE_SERVER_NAME is not reachable — cannot run test" phase_fail "Pre-flight" exit 1 fi # Internet reachable if ping_internet; then log "Internet is reachable" else error "No internet connectivity — cannot run test" phase_fail "Pre-flight" exit 1 fi # Version parity — test may produce misleading results on mismatch if ! check_unraid_version_parity; then error "unRAID version mismatch — test aborted to prevent misleading results" phase_fail "Pre-flight" exit 1 fi # Remote Docker daemon — must be responsive before test manipulates containers if ! check_remote_docker_daemon; then error "Remote Docker daemon not responsive — cannot run test" phase_fail "Pre-flight" exit 1 fi # Fallback state must be NORMAL before test if [[ -f "$FALLBACK_STATE_FILE" ]]; then CURRENT_STATE=$(grep "^state=" "$FALLBACK_STATE_FILE" 2>/dev/null | cut -d= -f2) if [[ "$CURRENT_STATE" != "NORMAL" ]]; then error "Fallback state is $CURRENT_STATE — must be NORMAL before running test" phase_fail "Pre-flight" exit 1 fi log "Fallback state is NORMAL" else warn "No state file found — assuming NORMAL (first run)" fi # Tier 1 containers configured if [[ ${#TIER1_CONTAINERS[@]} -eq 0 ]]; then error "No Tier 1 containers configured for $MY_ID → $REMOTE_ID" error "Check FALLBACK_${MY_ID}_COVERS_${REMOTE_ID}_TIER1 in host*.conf" phase_fail "Pre-flight" exit 1 fi log "Tier 1 containers: ${TIER1_CONTAINERS[*]}" phase_pass "Pre-flight" # ============================================================================================== # ━━━ Phase 2 — Block Remote Connectivity ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_PING Phase 2 — Block Remote Connectivity ━━━" warn "Adding iptables rule — dropping all traffic to $REMOTE_SERVER ($REMOTE_SERVER_NAME)" if [[ "$DRY_RUN" == false ]]; then iptables -I OUTPUT -d "$REMOTE_SERVER" -j DROP IPTABLES_RULE_ACTIVE=true # Verify block is working sleep 2 if ! ping -c1 -W2 "$REMOTE_SERVER" &>/dev/null; then log "Connectivity block confirmed — ping to remote fails as expected" phase_pass "Block Remote" else error "iptables rule did not block connectivity — ping still succeeds" phase_fail "Block Remote" exit 1 fi else warn "DRY RUN — would block $REMOTE_SERVER with iptables DROP rule" phase_pass "Block Remote" fi # ============================================================================================== # ━━━ Phase 3 — Fallback Detection ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_FALLBACK Phase 3 — Fallback Detection ━━━" warn "Waiting ${FALLBACK_TEST_BLOCK_WAIT}s for fallback.sh to detect outage..." log "fallback.sh check interval: ${FALLBACK_CHECK_INTERVAL}s" if [[ "$DRY_RUN" == false ]]; then sleep "$FALLBACK_TEST_BLOCK_WAIT" if [[ -f "$FALLBACK_STATE_FILE" ]]; then NEW_STATE=$(grep "^state=" "$FALLBACK_STATE_FILE" 2>/dev/null | cut -d= -f2) if [[ "$NEW_STATE" == "FALLBACK" ]]; then log "State changed to FALLBACK — outage detected correctly ✅" phase_pass "Fallback Detection" else error "State is $NEW_STATE — expected FALLBACK after ${FALLBACK_TEST_BLOCK_WAIT}s" warn "Is fallback.sh running? Check User Scripts plugin" phase_fail "Fallback Detection" fi else error "No state file found after wait — fallback.sh may not be running" phase_fail "Fallback Detection" fi else warn "DRY RUN — would wait ${FALLBACK_TEST_BLOCK_WAIT}s then check for FALLBACK state" phase_pass "Fallback Detection" fi # ============================================================================================== # ━━━ Phase 4 — Container Start Verification ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_CONTAINERS Phase 4 — Tier 1 Containers Started Locally ━━━" log "Checking Tier 1 containers: ${TIER1_CONTAINERS[*]}" if [[ "$DRY_RUN" == false ]]; then CONTAINERS_OK=true for container in "${TIER1_CONTAINERS[@]}"; do [[ -z "$container" ]] && continue STATUS=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \ "$container" 2>/dev/null) if [[ "$STATUS" == "true" ]]; then log "$ICON_RUNNING $container is running locally ✅" else error "$ICON_NOT_RUNNING $container is NOT running locally" CONTAINERS_OK=false fi done if [[ "$CONTAINERS_OK" == true ]]; then phase_pass "Container Start" else phase_fail "Container Start" fi else warn "DRY RUN — would verify these Tier 1 containers started: ${TIER1_CONTAINERS[*]}" phase_pass "Container Start" fi # ============================================================================================== # ━━━ Phase 5 — Restore Remote Connectivity ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_PING Phase 5 — Restore Remote Connectivity ━━━" warn "Removing iptables block — $REMOTE_SERVER_NAME becomes reachable again" if [[ "$DRY_RUN" == false ]]; then iptables -D OUTPUT -d "$REMOTE_SERVER" -j DROP 2>/dev/null IPTABLES_RULE_ACTIVE=false sleep 3 if ping_remote; then log "$REMOTE_SERVER_NAME is reachable again ✅" phase_pass "Restore Connectivity" else error "$REMOTE_SERVER_NAME still unreachable after removing iptables rule" phase_fail "Restore Connectivity" fi else warn "DRY RUN — would remove iptables rule" phase_pass "Restore Connectivity" fi # ============================================================================================== # ━━━ Phase 6 — Handback ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_FALLBACK Phase 6 — Handback ━━━" warn "Waiting ${FALLBACK_TEST_HANDBACK_WAIT}s for fallback.sh to complete handback..." log "Requires $FALLBACK_HANDBACK_STRIKES consecutive checks at ${FALLBACK_CHECK_INTERVAL}s" log "Minimum handback time: $(( FALLBACK_HANDBACK_STRIKES * FALLBACK_CHECK_INTERVAL ))s" if [[ "$DRY_RUN" == false ]]; then sleep "$FALLBACK_TEST_HANDBACK_WAIT" if [[ -f "$FALLBACK_STATE_FILE" ]]; then FINAL_STATE=$(grep "^state=" "$FALLBACK_STATE_FILE" 2>/dev/null | cut -d= -f2) if [[ "$FINAL_STATE" == "NORMAL" ]]; then log "State returned to NORMAL — handback completed ✅" phase_pass "Handback" else error "State is $FINAL_STATE — expected NORMAL after ${FALLBACK_TEST_HANDBACK_WAIT}s" warn "Handback may still be in progress — check fallback.sh output" phase_fail "Handback" fi else error "No state file found" phase_fail "Handback" fi else warn "DRY RUN — would wait ${FALLBACK_TEST_HANDBACK_WAIT}s then verify NORMAL state" phase_pass "Handback" fi # ============================================================================================== # ━━━ Phase 7 — Container Handback Verification ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_CONTAINERS Phase 7 — Tier 1 Containers Stopped Locally ━━━" log "Verifying Tier 1 containers returned to $REMOTE_SERVER_NAME" if [[ "$DRY_RUN" == false ]]; then HANDBACK_OK=true for container in "${TIER1_CONTAINERS[@]}"; do [[ -z "$container" ]] && continue STATUS=$(timeout "$DOCKER_TIMEOUT" docker inspect -f '{{.State.Running}}' \ "$container" 2>/dev/null) if [[ "$STATUS" != "true" ]]; then log "$ICON_NOT_RUNNING $container stopped locally — handed back ✅" else error "$ICON_RUNNING $container still running locally — handback may have failed" HANDBACK_OK=false fi done if [[ "$HANDBACK_OK" == true ]]; then phase_pass "Container Handback" else phase_fail "Container Handback" fi else warn "DRY RUN — would verify Tier 1 containers stopped locally after handback" phase_pass "Container Handback" fi TOTAL_END=$(date +%s) # ============================================================================================== # ━━━ Test Report ━━━ # ============================================================================================== echo "" echo "━━━━━ $ICON_SUMMARY FALLBACK TEST REPORT ━━━━━" echo "$ICON_HOST My ID: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_HOST Remote: $REMOTE_ID ($REMOTE_SERVER_NAME)" echo "$ICON_TIME Duration: $(format_duration $((TOTAL_END - TOTAL_START)))" echo "" echo " Phase Results:" for phase in "${PHASES_PASS[@]}"; do echo " $ICON_SUCCESS $phase" done for phase in "${PHASES_FAIL[@]}"; do echo " $ICON_ERROR $phase" done echo "" PASS_COUNT=${#PHASES_PASS[@]} FAIL_COUNT=${#PHASES_FAIL[@]} TOTAL_PHASES=$(( PASS_COUNT + FAIL_COUNT )) if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — no changes made" elif [[ "$FAIL_COUNT" -eq 0 ]]; then warn "$ICON_DONE ALL $TOTAL_PHASES PHASES PASSED" notify "Fallback test PASSED on $(hostname) — all $TOTAL_PHASES phases completed" \ "Fallback Test" "normal" else error "$FAIL_COUNT/$TOTAL_PHASES PHASES FAILED" notify "Fallback test FAILED on $(hostname) — $FAIL_COUNT/$TOTAL_PHASES phases failed: ${PHASES_FAIL[*]}" \ "Fallback Test" "warning" fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" [[ "$FAIL_COUNT" -gt 0 ]] && exit 1 exit 0