Last active
February 3, 2026 21:51
-
-
Save klutchell/3fa614d2479734865a6d4f9a411d4a03 to your computer and use it in GitHub Desktop.
Safely migrate balenaOS from DHCP to static IP with automatic rollback
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # | |
| # dhcp-to-static.sh - Safely migrate balenaOS between DHCP and static IP | |
| # | |
| # Uses a "dead man's switch" rollback pattern: a background timer will | |
| # automatically revert to the previous configuration unless explicitly | |
| # cancelled after successful connectivity verification. | |
| # | |
| # Usage: | |
| # ./dhcp-to-static.sh [--dry-run] [--force] # DHCP -> static | |
| # ./dhcp-to-static.sh --dhcp [--dry-run] [--force] # static -> DHCP | |
| # ROLLBACK_TIMEOUT=120 ./dhcp-to-static.sh | |
| # | |
| # Environment variables: | |
| # ROLLBACK_TIMEOUT - Seconds before auto-rollback (default: 60) | |
| set -euo pipefail | |
| # Configuration | |
| ROLLBACK_TIMEOUT="${ROLLBACK_TIMEOUT:-60}" | |
| DRY_RUN=false | |
| FORCE=false | |
| MODE="static" # "static" = DHCP->static (default), "dhcp" = static->DHCP | |
| ROLLBACK_SCRIPT="/tmp/dhcp-rollback-$$.sh" | |
| ROLLBACK_PID_FILE="/tmp/dhcp-rollback-$$.pid" | |
| ROLLBACK_LOG="/tmp/dhcp-rollback.log" | |
| # Colors for output | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| NC='\033[0m' # No Color | |
| log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } | |
| log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } | |
| log_error() { echo -e "${RED}[ERROR]${NC} $*"; } | |
| cleanup() { | |
| # Kill rollback timer if it's still running | |
| if [[ -f "$ROLLBACK_PID_FILE" ]]; then | |
| local pid | |
| pid=$(cat "$ROLLBACK_PID_FILE" 2>/dev/null || true) | |
| if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then | |
| kill "$pid" 2>/dev/null || true | |
| fi | |
| rm -f "$ROLLBACK_PID_FILE" | |
| fi | |
| rm -f "$ROLLBACK_SCRIPT" | |
| } | |
| die() { | |
| log_error "$*" | |
| exit 1 | |
| } | |
| # Parse arguments | |
| for arg in "$@"; do | |
| case "$arg" in | |
| --dry-run) | |
| DRY_RUN=true | |
| ;; | |
| --force|-f) | |
| FORCE=true | |
| ;; | |
| --dhcp) | |
| MODE="dhcp" | |
| ;; | |
| --help|-h) | |
| echo "Usage: $0 [--dry-run] [--force] [--dhcp]" | |
| echo "" | |
| echo "Migrate between DHCP and static IP with automatic rollback safety." | |
| echo "" | |
| echo "Options:" | |
| echo " --dry-run Show what would be changed without applying" | |
| echo " --force, -f Run even if current config doesn't match expected state" | |
| echo " --dhcp Switch from static IP back to DHCP (reverse operation)" | |
| echo " --help Show this help message" | |
| echo "" | |
| echo "Environment variables:" | |
| echo " ROLLBACK_TIMEOUT Seconds before auto-rollback (default: 60)" | |
| exit 0 | |
| ;; | |
| *) | |
| die "Unknown argument: $arg" | |
| ;; | |
| esac | |
| done | |
| ############################################################################# | |
| # Phase 1: Discovery | |
| ############################################################################# | |
| log_info "Phase 1: Discovering current network configuration..." | |
| # Detect primary interface (the one with default route) | |
| IFACE=$(ip route show default | awk '/default/ {print $5; exit}') | |
| [[ -z "$IFACE" ]] && die "Could not detect primary network interface" | |
| # Get connection name for that interface | |
| CONN=$(nmcli -t -f NAME,DEVICE con show --active | grep ":${IFACE}$" | cut -d: -f1) | |
| [[ -z "$CONN" ]] && die "Could not find NetworkManager connection for interface $IFACE" | |
| # Verify current method matches expected state for the operation (unless --force) | |
| CURRENT_METHOD=$(nmcli -g ipv4.method con show "$CONN") | |
| if [[ "$MODE" == "static" ]]; then | |
| # Switching to static: expect current method to be auto (DHCP) | |
| if [[ "$CURRENT_METHOD" != "auto" ]]; then | |
| if $FORCE; then | |
| log_warn "Interface $IFACE is already using static IP (--force specified)" | |
| else | |
| die "Interface $IFACE is not using DHCP (current method: $CURRENT_METHOD). Use --force to re-apply." | |
| fi | |
| fi | |
| else | |
| # Switching to DHCP: expect current method to be manual (static) | |
| if [[ "$CURRENT_METHOD" != "manual" ]]; then | |
| if $FORCE; then | |
| log_warn "Interface $IFACE is already using DHCP (--force specified)" | |
| else | |
| die "Interface $IFACE is not using static IP (current method: $CURRENT_METHOD). Use --force to re-apply." | |
| fi | |
| fi | |
| fi | |
| # Get current IP configuration | |
| # Note: || true suppresses pipefail exit when nmcli fails | |
| IP_PREFIX=$(nmcli -g IP4.ADDRESS con show "$CONN" 2>/dev/null | head -1 || true) | |
| [[ -z "$IP_PREFIX" ]] && die "Could not determine current IP address" | |
| # Extract IP and prefix separately | |
| IP=$(echo "$IP_PREFIX" | cut -d/ -f1) | |
| PREFIX=$(echo "$IP_PREFIX" | cut -d/ -f2) | |
| # Get gateway | |
| GW=$(nmcli -g IP4.GATEWAY con show "$CONN" 2>/dev/null || true) | |
| [[ -z "$GW" ]] && die "Could not determine gateway" | |
| # Get DNS servers (may be newline or ' | ' separated, convert to space-separated for nmcli) | |
| DNS=$(nmcli -g IP4.DNS con show "$CONN" 2>/dev/null | sed 's/ | /\n/g' | tr '\n' ' ' | xargs || true) | |
| [[ -z "$DNS" ]] && log_warn "No DNS servers found, will use gateway as DNS" | |
| DNS="${DNS:-$GW}" | |
| # Get IPv6 configuration (global scope only, not link-local) | |
| # nmcli may return multiple addresses joined with " | ", so split first, then filter link-local | |
| # Note: nmcli escapes colons with backslashes, so we strip them with tr -d '\\' | |
| # Note: grep -v can also fail (exit 1) if no lines match, so || true is needed | |
| IPV6_PREFIX=$(nmcli -g IP6.ADDRESS con show "$CONN" 2>/dev/null | tr -d '\\' | sed 's/ | /\n/g' | grep -v '^fe80' | head -1 || true) | |
| IPV6_GW=$(nmcli -g IP6.GATEWAY con show "$CONN" 2>/dev/null | tr -d '\\' || true) | |
| IPV6_DNS=$(nmcli -g IP6.DNS con show "$CONN" 2>/dev/null | tr -d '\\' | sed 's/ | /\n/g' | tr '\n' ' ' | xargs || true) | |
| # Extract IPv6 and prefix if present | |
| if [[ -n "$IPV6_PREFIX" ]]; then | |
| IPV6=$(echo "$IPV6_PREFIX" | cut -d/ -f1) | |
| IPV6_PREFIX_LEN=$(echo "$IPV6_PREFIX" | cut -d/ -f2) | |
| fi | |
| # Display discovered configuration | |
| echo "" | |
| echo "Current Configuration:" | |
| echo " Interface: $IFACE" | |
| echo " Connection: $CONN" | |
| echo " IPv4 Address: $IP/$PREFIX" | |
| echo " IPv4 Gateway: $GW" | |
| echo " IPv4 DNS: $DNS" | |
| if [[ -n "$IPV6_PREFIX" ]]; then | |
| echo " IPv6 Address: $IPV6/$IPV6_PREFIX_LEN" | |
| echo " IPv6 Gateway: ${IPV6_GW:-<none>}" | |
| echo " IPv6 DNS: ${IPV6_DNS:-<auto>}" | |
| fi | |
| echo "" | |
| if $DRY_RUN; then | |
| if [[ "$MODE" == "static" ]]; then | |
| log_info "[DRY-RUN] Would convert the above configuration to static" | |
| if [[ -n "$IPV6_PREFIX" ]]; then | |
| log_info "[DRY-RUN] IPv6 detected - will configure both IPv4 and IPv6 as static" | |
| fi | |
| else | |
| log_info "[DRY-RUN] Would switch to DHCP (current static config saved for rollback)" | |
| fi | |
| log_info "[DRY-RUN] Rollback timeout would be: ${ROLLBACK_TIMEOUT}s" | |
| log_info "[DRY-RUN] No changes made." | |
| exit 0 | |
| fi | |
| ############################################################################# | |
| # Phase 2: Rollback Timer Setup | |
| ############################################################################# | |
| log_info "Phase 2: Setting up rollback timer (${ROLLBACK_TIMEOUT}s)..." | |
| # Create the rollback script (restores previous config) | |
| cat > "$ROLLBACK_SCRIPT" << ROLLBACK_EOF | |
| #!/bin/bash | |
| # Auto-generated rollback script - DO NOT EDIT | |
| sleep $ROLLBACK_TIMEOUT | |
| ROLLBACK_EOF | |
| if [[ "$MODE" == "static" ]]; then | |
| # Switching to static: rollback restores DHCP | |
| cat >> "$ROLLBACK_SCRIPT" << ROLLBACK_EOF | |
| echo "[\$(date)] Rollback timer expired, reverting to DHCP..." >> "$ROLLBACK_LOG" | |
| nmcli con mod "$CONN" \\ | |
| ipv4.method auto \\ | |
| ipv4.addresses "" \\ | |
| ipv4.gateway "" \\ | |
| ipv4.dns "" \\ | |
| ipv6.method auto \\ | |
| ipv6.addresses "" \\ | |
| ipv6.gateway "" \\ | |
| ipv6.dns "" | |
| nmcli con up "$CONN" | |
| echo "[\$(date)] Rolled back to DHCP successfully" >> "$ROLLBACK_LOG" | |
| ROLLBACK_EOF | |
| else | |
| # Switching to DHCP: rollback restores static config | |
| cat >> "$ROLLBACK_SCRIPT" << ROLLBACK_EOF | |
| echo "[\$(date)] Rollback timer expired, restoring static IP..." >> "$ROLLBACK_LOG" | |
| nmcli con mod "$CONN" \\ | |
| ipv4.method manual \\ | |
| ipv4.addresses "$IP_PREFIX" \\ | |
| ipv4.gateway "$GW" \\ | |
| ipv4.dns "$DNS" | |
| ROLLBACK_EOF | |
| # Add IPv6 restore if it was configured | |
| if [[ -n "$IPV6_PREFIX" ]]; then | |
| cat >> "$ROLLBACK_SCRIPT" << ROLLBACK_EOF | |
| nmcli con mod "$CONN" \\ | |
| ipv6.method manual \\ | |
| ipv6.addresses "$IPV6_PREFIX" ${IPV6_GW:+\\ | |
| ipv6.gateway "$IPV6_GW"} ${IPV6_DNS:+\\ | |
| ipv6.dns "$IPV6_DNS"} | |
| ROLLBACK_EOF | |
| fi | |
| cat >> "$ROLLBACK_SCRIPT" << ROLLBACK_EOF | |
| nmcli con up "$CONN" | |
| echo "[\$(date)] Rolled back to static IP successfully" >> "$ROLLBACK_LOG" | |
| ROLLBACK_EOF | |
| fi | |
| cat >> "$ROLLBACK_SCRIPT" << ROLLBACK_EOF | |
| rm -f "$ROLLBACK_PID_FILE" | |
| ROLLBACK_EOF | |
| chmod +x "$ROLLBACK_SCRIPT" | |
| # Launch rollback timer with nohup (survives SSH disconnect) | |
| nohup "$ROLLBACK_SCRIPT" > /dev/null 2>&1 & | |
| ROLLBACK_PID=$! | |
| echo "$ROLLBACK_PID" > "$ROLLBACK_PID_FILE" | |
| log_info "Rollback timer started (PID: $ROLLBACK_PID)" | |
| if [[ "$MODE" == "static" ]]; then | |
| log_warn "If anything goes wrong, DHCP will be restored in ${ROLLBACK_TIMEOUT}s" | |
| else | |
| log_warn "If anything goes wrong, static IP will be restored in ${ROLLBACK_TIMEOUT}s" | |
| fi | |
| echo "" | |
| ############################################################################# | |
| # Phase 3: Apply Configuration | |
| ############################################################################# | |
| if [[ "$MODE" == "static" ]]; then | |
| log_info "Phase 3: Applying static IP configuration..." | |
| # Apply IPv4 static configuration | |
| # (ipv4.method manual requires an address to be set simultaneously) | |
| nmcli con mod "$CONN" \ | |
| ipv4.method manual \ | |
| ipv4.addresses "$IP_PREFIX" \ | |
| ipv4.gateway "$GW" \ | |
| ipv4.dns "$DNS" | |
| # Apply IPv6 static configuration if detected | |
| if [[ -n "$IPV6_PREFIX" ]]; then | |
| log_info "Applying IPv6 static configuration..." | |
| nmcli con mod "$CONN" \ | |
| ipv6.method manual \ | |
| ipv6.addresses "$IPV6_PREFIX" \ | |
| ${IPV6_GW:+ipv6.gateway "$IPV6_GW"} \ | |
| ${IPV6_DNS:+ipv6.dns "$IPV6_DNS"} | |
| fi | |
| else | |
| log_info "Phase 3: Switching to DHCP..." | |
| # Switch to DHCP (clear static settings) | |
| nmcli con mod "$CONN" \ | |
| ipv4.method auto \ | |
| ipv4.addresses "" \ | |
| ipv4.gateway "" \ | |
| ipv4.dns "" \ | |
| ipv6.method auto \ | |
| ipv6.addresses "" \ | |
| ipv6.gateway "" \ | |
| ipv6.dns "" | |
| fi | |
| log_info "Bringing connection back up..." | |
| nmcli con up "$CONN" | |
| # Brief pause to let network settle | |
| sleep 2 | |
| ############################################################################# | |
| # Phase 4: Connectivity Verification | |
| ############################################################################# | |
| log_info "Phase 4: Verifying connectivity..." | |
| # Run a connectivity test; returns 0 on success, 1 on failure | |
| run_test() { | |
| local label="$1" | |
| local cmd="$2" | |
| echo -n " Testing $label... " | |
| if eval "$cmd" > /dev/null 2>&1; then | |
| echo -e "${GREEN}OK${NC}" | |
| return 0 | |
| else | |
| echo -e "${RED}FAILED${NC}" | |
| return 1 | |
| fi | |
| } | |
| TESTS_PASSED=true | |
| run_test "gateway ($GW)" "ping -c 2 -W 3 '$GW'" || TESTS_PASSED=false | |
| run_test "internet (8.8.8.8)" "ping -c 2 -W 3 8.8.8.8" || TESTS_PASSED=false | |
| run_test "DNS resolution" "host google.com || nslookup google.com" || TESTS_PASSED=false | |
| # Test IPv6 connectivity (informational, doesn't block success) | |
| if [[ -n "$IPV6_PREFIX" ]]; then | |
| run_ipv6_test() { | |
| local label="$1" | |
| local cmd="$2" | |
| echo -n " Testing $label... " | |
| if eval "$cmd" > /dev/null 2>&1; then | |
| echo -e "${GREEN}OK${NC}" | |
| else | |
| echo -e "${YELLOW}SKIPPED${NC} (IPv6 not required for success)" | |
| fi | |
| } | |
| echo "" | |
| echo " IPv6 Tests (informational only):" | |
| [[ -n "$IPV6_GW" ]] && run_ipv6_test "IPv6 gateway ($IPV6_GW)" "ping -6 -c 2 -W 3 '$IPV6_GW'" | |
| run_ipv6_test "IPv6 internet (2001:4860:4860::8888)" "ping -6 -c 2 -W 3 2001:4860:4860::8888" | |
| fi | |
| echo "" | |
| ############################################################################# | |
| # Phase 5/6: Success or Failure Path | |
| ############################################################################# | |
| if $TESTS_PASSED; then | |
| log_info "Phase 5: All tests passed! Persisting configuration..." | |
| # Cancel rollback timer | |
| cleanup | |
| log_info "Rollback timer cancelled" | |
| # Persist to boot partition for balenaOS | |
| BOOT_CONN_DIR="/mnt/boot/system-connections" | |
| CONN_FILE="" | |
| # Find the connection file (may or may not have .nmconnection extension) | |
| for candidate in "/etc/NetworkManager/system-connections/${CONN}.nmconnection" \ | |
| "/etc/NetworkManager/system-connections/${CONN}"; do | |
| [[ -f "$candidate" ]] && CONN_FILE="$candidate" && break | |
| done | |
| if [[ ! -d "$BOOT_CONN_DIR" ]]; then | |
| log_warn "balenaOS boot partition not found at $BOOT_CONN_DIR" | |
| log_warn "Configuration applied but may not survive reboot" | |
| elif [[ -z "$CONN_FILE" ]]; then | |
| log_warn "Could not find connection file to persist" | |
| log_warn "Configuration applied but may not survive reboot" | |
| else | |
| cp "$CONN_FILE" "$BOOT_CONN_DIR/" | |
| log_info "Configuration persisted to $BOOT_CONN_DIR/" | |
| fi | |
| echo "" | |
| echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}" | |
| if [[ "$MODE" == "static" ]]; then | |
| echo -e "${GREEN} SUCCESS: Static IP configuration applied and persisted${NC}" | |
| else | |
| echo -e "${GREEN} SUCCESS: DHCP configuration applied and persisted${NC}" | |
| fi | |
| echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}" | |
| echo "" | |
| if [[ "$MODE" == "static" ]]; then | |
| echo "Final Configuration:" | |
| echo " Interface: $IFACE" | |
| echo " IPv4 Address: $IP/$PREFIX" | |
| echo " IPv4 Gateway: $GW" | |
| echo " IPv4 DNS: $DNS" | |
| if [[ -n "$IPV6_PREFIX" ]]; then | |
| echo " IPv6 Address: $IPV6/$IPV6_PREFIX_LEN" | |
| echo " IPv6 Gateway: ${IPV6_GW:-<none>}" | |
| echo " IPv6 DNS: ${IPV6_DNS:-<auto>}" | |
| fi | |
| else | |
| echo "Configuration: DHCP (addresses assigned dynamically)" | |
| echo " Interface: $IFACE" | |
| fi | |
| echo "" | |
| echo "Verification commands:" | |
| echo " nmcli con show \"$CONN\" | grep -E 'ipv[46]'" | |
| echo " ip addr show $IFACE" | |
| echo " ip route" | |
| echo "" | |
| else | |
| log_error "Phase 6: Connectivity tests FAILED" | |
| echo "" | |
| echo -e "${RED}═══════════════════════════════════════════════════════════════${NC}" | |
| echo -e "${RED} FAILURE: Waiting for automatic rollback...${NC}" | |
| echo -e "${RED}═══════════════════════════════════════════════════════════════${NC}" | |
| echo "" | |
| if [[ "$MODE" == "static" ]]; then | |
| echo "The rollback timer is still running and will restore DHCP in" | |
| else | |
| echo "The rollback timer is still running and will restore static IP in" | |
| fi | |
| echo "approximately ${ROLLBACK_TIMEOUT} seconds." | |
| echo "" | |
| echo "If you regain connectivity after rollback, check:" | |
| if [[ "$MODE" == "static" ]]; then | |
| echo " - Is the IP address already in use on the network?" | |
| echo " - Is the gateway correct?" | |
| echo " - Are there any firewall rules blocking traffic?" | |
| else | |
| echo " - Is the DHCP server running and reachable?" | |
| echo " - Is the network cable connected?" | |
| echo " - Are there any VLAN or network segmentation issues?" | |
| fi | |
| echo "" | |
| echo "Rollback log: $ROLLBACK_LOG" | |
| echo "" | |
| # Exit with error but don't cleanup - let the timer do its job | |
| exit 1 | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment