diff --git a/scripts/setup/machine-setup/lib/network.sh b/scripts/setup/machine-setup/lib/network.sh index a690a835..65454809 100644 --- a/scripts/setup/machine-setup/lib/network.sh +++ b/scripts/setup/machine-setup/lib/network.sh @@ -106,3 +106,92 @@ EOF # assumed, because a resolver that does not answer is the one failure that makes # everything after it look broken for unrelated reasons. dns_works() { getent hosts one.one.one.one >/dev/null 2>&1 || getent hosts example.com >/dev/null 2>&1; } + +# ----------------------------------------------------------------------------- +# The address this machine gets +# ----------------------------------------------------------------------------- +# +# ── Why a fresh Ubuntu box takes a new IP on every reboot ── +# +# Not a router fault, and not something a static IP is the right answer to. +# systemd-networkd's ClientIdentifier defaults to `duid` — an RFC 4361 client ID +# built from an IAID and a DUID — so the machine introduces itself to DHCP by +# that, and `networkctl status` shows it as "DHCP4 Client ID: IAID:0x…/DUID". +# +# Consumer routers key their leases and their reservations on the MAC address. +# The two never match, so the router does not recognise the machine as a client +# it has seen before and hands out the next free address instead. A reservation +# pinned to the MAC never takes effect, which is the part that makes it look like +# the router is broken. +# +# `dhcp-identifier: mac` in netplan sets ClientIdentifier=mac, and the router then +# sees what it expects. DHCP keeps working, the reservation starts being honoured, +# and nothing is pinned on the machine itself — which is why this is offered ahead +# of a static address rather than beside it. + +NETPLAN_DHCP_ID=/etc/netplan/99-machine-setup-dhcp-identifier.yaml +NETPLAN_STATIC=/etc/netplan/99-machine-setup-static.yaml + +# What the machine is sending as its DHCP identity: "mac", "duid", or empty when +# the link is not on DHCP at all. +dhcp_client_identifier() { + local iface="$1" + local id + # Everything after the FIRST colon, not field 2 of a colon split: the value is + # itself "IAID:0x…/DUID", so splitting on colons yields "IAID" and the DUID + # test silently answers backwards. + id="$(networkctl status "$iface" 2>/dev/null | awk '/DHCP4 Client ID/ { sub(/^[^:]*:[[:space:]]*/, ""); print; exit }')" + [[ -z "$id" ]] && return 0 + if [[ "$id" == *DUID* ]]; then echo duid; else echo mac; fi +} + +# Already asked for by some netplan file? +dhcp_identifier_is_mac() { grep -rqs "dhcp-identifier:[[:space:]]*mac" /etc/netplan/ 2>/dev/null; } + +iface_ipv4() { ip -4 addr show "$1" 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}/\d+' | head -1; } +iface_gateway() { ip route | awk '/^default/ { print $3; exit }'; } +iface_is_dhcp() { networkctl status "$1" 2>/dev/null | grep -q "DHCP4"; } + +# Ask for MAC-based identity, as its own netplan file. +# +# Netplan reads /etc/netplan in lexical order and merges, so a 99- file adds this +# one key to whatever the installer or cloud-init already wrote, without this +# script having to parse and rewrite their YAML. +set_dhcp_identifier_mac() { + local iface="$1" + cat >"$NETPLAN_DHCP_ID" <"$NETPLAN_STATIC" <&1; } diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index fe97b7bd..f3e69f0e 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -1039,13 +1039,144 @@ if ! skip; then step_ok fi +# ============================================================================= +# 17. Network address +# ============================================================================= +# +# Homelab only. On a vps the provider's DHCP is authoritative and already stable, +# and pinning an address there is how an instance is stranded. On dev the machine +# moves between networks and a fixed address is the opposite of what is wanted. + +step "Network address" +if ! skip && ! is_role homelab; then + echo "" + info "Network address — left alone on a ${MACHINE_ROLE}" + if is_role vps; then + echo " The provider's DHCP already hands this machine the same address," + echo " and pinning one here is how an instance ends up unreachable." + else + echo " A machine that moves between networks wants whatever each one" + echo " gives it." + fi + SUMMARY+=("Network address: left alone on ${MACHINE_ROLE}") + step_ok +elif ! skip; then + NET_IFACE="$(default_iface)" + NET_CIDR="$(iface_ipv4 "$NET_IFACE")" + NET_GW="$(iface_gateway)" + NET_ID="$(dhcp_client_identifier "$NET_IFACE")" + + echo "" + info "Network address — keeping the same IP across reboots" + echo " interface: ${NET_IFACE}" + echo " address: ${NET_CIDR:-unknown} $(iface_is_dhcp "$NET_IFACE" && echo 'from DHCP' || echo 'static')" + echo " gateway: ${NET_GW:-unknown}" + echo " identifies as: ${NET_ID:-not on DHCP}" + + if [[ "$NET_ID" == "duid" ]]; then + echo "" + echo " That is why the address changes. Ubuntu identifies to DHCP by a" + echo " DUID, while routers key their leases and reservations on the MAC." + echo " The router does not recognise this machine as one it has seen, so" + echo " it hands out the next free address — and a reservation pinned to" + echo " the MAC is never matched." + fi + + echo "" + echo " [1] identify by MAC instead — DHCP keeps working, reservations start" + echo " being honoured, nothing is pinned here" + echo " [2] pin this address as static" + echo " [3] leave it alone" + echo "" + + NET_CHOICE="" + while [[ -z "$NET_CHOICE" ]]; do + if ! read -rp " Which one? (1/2/3) [1]: " NET_ANSWER; then + echo "" + fail "No answer." + fi + case "${NET_ANSWER:-1}" in + 1 | 2 | 3) NET_CHOICE="${NET_ANSWER:-1}" ;; + *) warn "Pick 1, 2 or 3." ;; + esac + done + + case "$NET_CHOICE" in + 1) + if dhcp_identifier_is_mac; then + echo " already asked for in /etc/netplan, nothing to do" + SUMMARY+=("Network address: already identifying by MAC") + else + echo "" + echo " Writes ${NETPLAN_DHCP_ID}, merged with what is already there." + echo " Takes effect at the next reboot — which is the moment the" + echo " problem shows up anyway, so there is nothing to apply now and" + echo " no risk to this session." + echo "" + echo " Then reserve ${NET_CIDR%%/*} against ${NET_IFACE}'s MAC on the router," + echo " and it will keep being handed back." + if confirm "Proceed?"; then + set_dhcp_identifier_mac "$NET_IFACE" + if netplan_check >/dev/null 2>&1; then + ok "written — reboot, then set the reservation on the router" + SUMMARY+=("Network address: identifying by MAC from next boot") + else + warn "netplan rejected the file — removing it, nothing changed" + rm -f "$NETPLAN_DHCP_ID" + ERRORS+=("Network address: netplan rejected the dhcp-identifier file") + SUMMARY+=("Network address: FAILED, netplan rejected it") + fi + else + warn "skipped by request" + SUMMARY+=("Network address: SKIPPED by request") + fi + fi + ;; + 2) + echo "" + warn "This freezes the address this machine happens to hold right now." + echo " ${NET_CIDR} via ${NET_GW} on ${NET_IFACE}" + echo "" + echo " If the router hands that address to something else later, both" + echo " end up fighting for it. A reservation on the router does the same" + echo " job with the router still in charge, which is option 1." + echo " 'netplan apply' runs immediately and drops the network for a" + echo " moment — over ssh, a wrong value here ends the session." + if [[ -z "$NET_CIDR" || -z "$NET_GW" ]]; then + warn "could not read the address or gateway — not writing anything" + SUMMARY+=("Network address: could not detect, left alone") + elif confirm "Pin it?" n; then + write_static_netplan "$NET_IFACE" "$NET_CIDR" "$NET_GW" + if netplan_check >/dev/null 2>&1; then + netplan apply + ok "static ${NET_CIDR} on ${NET_IFACE}" + SUMMARY+=("Network address: static ${NET_CIDR}") + else + warn "netplan rejected the file — removing it, nothing changed" + rm -f "$NETPLAN_STATIC" + ERRORS+=("Network address: netplan rejected the static config") + SUMMARY+=("Network address: FAILED, netplan rejected it") + fi + else + warn "skipped by request" + SUMMARY+=("Network address: SKIPPED by request") + fi + ;; + 3) + echo " left alone" + SUMMARY+=("Network address: unchanged") + ;; + esac + step_ok +fi + # ============================================================================= # NOT PORTED YET # ============================================================================= # # Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order: # -# static ip · fail2ban · unattended-upgrades · +# fail2ban · unattended-upgrades · # git config · docker · zsh + prompt (incl. .tmux.conf) · tailscale · neovim · js runtimes · # dev tools · ufw · zshrc #