diff --git a/migration/he-tunnel-follow b/migration/he-tunnel-follow new file mode 100755 index 0000000..1df675d --- /dev/null +++ b/migration/he-tunnel-follow @@ -0,0 +1,121 @@ +#!/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