From bdf13331ae0a6c1d99690545803c215e387dfab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 12 Aug 2026 18:49:50 +0000 Subject: [PATCH] port the static IP section, and offer the actual fix for the reboot-changes-IP problem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Homelab only, as it should always have been. 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. Both say so rather than skipping quietly. The more useful change is that a static address is no longer the only answer offered, because it is not the right one for the problem it was added to solve. A fresh Ubuntu box taking a new IP on every reboot is not the router misbehaving. systemd-networkd's ClientIdentifier defaults to `duid` — man systemd.network is explicit — so the machine introduces itself to DHCP with an RFC 4361 client ID built from an IAID and a DUID. This host shows it: DHCP4 Client ID: IAID:0x56504d98/DUID Consumer routers key leases and reservations on the MAC. The two never match, so the router does not recognise the machine as one it has seen and hands out the next free address — and a reservation pinned to the MAC is never honoured, which is the part that makes the router look broken. `dhcp-identifier: mac` in netplan sets ClientIdentifier=mac and the router sees what it expects. DHCP keeps working, reservations start being honoured, and nothing is pinned on the machine. That is now the first option, with the static address second and still carrying the original's warnings. It is written as its own 99- netplan file and merged with whatever the installer or cloud-init already wrote, rather than this script parsing and rewriting their YAML. Deliberately NOT applied: it takes effect at the next reboot, which is the moment the problem shows up anyway, so there is nothing to gain by dropping the network now. `netplan generate` validates before either file is kept, and the file is removed again if it does not. Found and fixed while testing: dhcp_client_identifier parsed the value with awk -F': *' and took field 2 — but the value is itself "IAID:0x…/DUID", so the split yielded "IAID", the DUID test failed, and the helper reported "mac" on a machine that was plainly sending a DUID. It would have told the user the opposite of the truth about their own problem. Reads everything after the first colon now. Verified on this host: correctly reports duid, explains why, and renders both the homelab and vps paths. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/setup/machine-setup/lib/network.sh | 89 +++++++++++++ scripts/setup/machine-setup/machine-setup.sh | 133 ++++++++++++++++++- 2 files changed, 221 insertions(+), 1 deletion(-) 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 #