Skip to content

Instantly share code, notes, and snippets.

@klutchell
Last active February 3, 2026 21:51
Show Gist options
  • Select an option

  • Save klutchell/3fa614d2479734865a6d4f9a411d4a03 to your computer and use it in GitHub Desktop.

Select an option

Save klutchell/3fa614d2479734865a6d4f9a411d4a03 to your computer and use it in GitHub Desktop.
Safely migrate balenaOS from DHCP to static IP with automatic rollback
#!/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