#!/bin/bash # Keep the Hurricane Electric 6in4 tunnel pointed at whichever WAN is live. # # The tunnel is anchored to a source IPv4. When failover moves the default route # from the 10 gig to PPPoE, 6in4 packets keep leaving with the old source, HE # drops them, and IPv6 goes dark while IPv4 keeps working -- a partial outage # that presents as "some sites are broken", which is far worse to diagnose than # a clean one. # # Changes are made at KERNEL level (`ip tunnel change`), not in VyOS config, on # purpose: # - no commit per WAN flip, so a flapping line cannot churn the config; # - no drift against the Pulumi model, so `vyos-verify` stays meaningful; # - a reboot restores config.boot, which pins the 10 gig -- the correct # default -- so the wrong state cannot survive a restart. # # he-tunnel-follow status what is live vs what should be (read-only) # he-tunnel-follow run reconcile, updating HE if the source changed # he-tunnel-follow run --dry say what it would do, change nothing # # Credentials in /config/he-secrets (0600), NOT in git: # HE_USER= # HE_UPDATE_KEY= # HE_TUNNEL_ID= set -uo pipefail TUNNEL="${TUNNEL:-tun0}" SECRETS="${SECRETS:-/config/he-secrets}" STATE="${STATE:-/run/he-tunnel-follow.state}" # 6in4 costs 20 bytes. The 10 gig path is 1500 -> 1480; PPPoE is 1492 -> 1472. # Getting this wrong is the classic "IPv6 works until something large" failure. declare -A WAN_MTU=( ["bond0.53"]=1480 ["pppoe0"]=1472 ) # Require the same answer twice before acting. HE rate-limits updates, and a # flapping WAN would otherwise hammer the API exactly when it is needed most. HYSTERESIS="${HYSTERESIS:-2}" log() { logger -t he-tunnel-follow -- "$*"; printf ' %s\n' "$*"; } die() { logger -t he-tunnel-follow -p user.err -- "$*"; printf ' ERROR: %s\n' "$*" >&2; exit 1; } active_wan() { ip -4 route show default 2>/dev/null | awk '/^default/{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1); exit}'; } addr_of() { ip -4 -br addr show "$1" 2>/dev/null | awk '{print $3}' | cut -d/ -f1; } tunnel_src() { ip tunnel show "$TUNNEL" 2>/dev/null | sed -nE 's/.* local ([0-9.]+).*/\1/p'; } tunnel_mtu() { cat "/sys/class/net/$TUNNEL/mtu" 2>/dev/null; } # HE's dyndns-style endpoint. `myip` is passed EXPLICITLY rather than letting HE # infer it from the request source: mid-failover the request itself may egress # either line, and inferring would happily point the tunnel at the WAN we just # left. he_update() { local ip="$1" [ -r "$SECRETS" ] || die "no $SECRETS -- create it with HE_USER / HE_UPDATE_KEY / HE_TUNNEL_ID (0600)" # shellcheck disable=SC1090 . "$SECRETS" [ -n "${HE_USER:-}" ] && [ -n "${HE_UPDATE_KEY:-}" ] && [ -n "${HE_TUNNEL_ID:-}" ] \ || die "$SECRETS is missing HE_USER, HE_UPDATE_KEY or HE_TUNNEL_ID" local out out="$(curl -sS --max-time 25 \ --data-urlencode "username=$HE_USER" \ --data-urlencode "password=$HE_UPDATE_KEY" \ --data-urlencode "hostname=$HE_TUNNEL_ID" \ --data-urlencode "myip=$ip" \ "https://ipv4.tunnelbroker.net/nic/update" 2>&1)" # dyndns protocol: "good " or "nochg " are both success. case "$out" in good*|nochg*) log "HE endpoint set to $ip ($out)"; return 0 ;; *) die "HE update refused: $out" ;; esac } reconcile() { local dry="${1:-}" local wan src want_mtu cur_src cur_mtu wan="$(active_wan)"; [ -n "$wan" ] || die "no default route; refusing to guess" src="$(addr_of "$wan")"; [ -n "$src" ] || die "no IPv4 address on $wan" want_mtu="${WAN_MTU[$wan]:-}" [ -n "$want_mtu" ] || die "unknown WAN '$wan' -- add it to WAN_MTU rather than guessing an MTU" cur_src="$(tunnel_src)"; cur_mtu="$(tunnel_mtu)" if [ "$cur_src" = "$src" ] && [ "$cur_mtu" = "$want_mtu" ]; then rm -f "$STATE" log "in sync: $TUNNEL via $wan src $src mtu $cur_mtu" return 0 fi # Hysteresis: count consecutive runs agreeing on the same target. local seen=0 last="" [ -r "$STATE" ] && { read -r last seen < "$STATE"; } if [ "$last" = "$src" ]; then seen=$((seen + 1)); else seen=1; fi echo "$src $seen" > "$STATE" if [ "$seen" -lt "$HYSTERESIS" ]; then log "change seen ($cur_src -> $src) but waiting for stability ($seen/$HYSTERESIS)" return 0 fi if [ "$dry" = "--dry" ]; then log "DRY RUN: would set HE endpoint to $src, then $TUNNEL local $src mtu $want_mtu" return 0 fi # HE first, then local. Either order costs a brief drop, but changing locally # first guarantees HE discards our packets for the whole window. he_update "$src" || return 1 sudo ip tunnel change "$TUNNEL" mode sit local "$src" || die "failed to set tunnel local address" sudo ip link set "$TUNNEL" mtu "$want_mtu" || die "failed to set tunnel MTU" rm -f "$STATE" log "moved $TUNNEL to $wan: src $cur_src -> $src, mtu $cur_mtu -> $want_mtu" } case "${1:-status}" in status) wan="$(active_wan)" printf ' active WAN : %s\n' "${wan:-}" printf ' wan addr : %s\n' "$(addr_of "${wan:-lo}")" printf ' tunnel src : %s\n' "$(tunnel_src)" printf ' tunnel mtu : %s (want %s)\n' "$(tunnel_mtu)" "${WAN_MTU[${wan:-}]:-?}" [ -r "$SECRETS" ] && printf ' credentials: present\n' || printf ' credentials: MISSING (%s)\n' "$SECRETS" ;; run) reconcile "${2:-}" ;; *) die "usage: he-tunnel-follow {status|run [--dry]}" ;; esac