#!/bin/bash # ============================================================================= # machine-setup — network configuration # ============================================================================= # # Definitions only, like the other lib/ files. [[ -n "${MACHINE_SETUP_NETWORK_LOADED:-}" ]] && return 0 MACHINE_SETUP_NETWORK_LOADED=1 # ----------------------------------------------------------------------------- # DNS # ----------------------------------------------------------------------------- # # ── What is actually being changed here ── # # On a machine running systemd-resolved there are two layers, and only one of # them is ours to set: # # per-link what DHCP handed each interface, and what Tailscale installs on # its own. These answer for that link's domains — the provider's # internal names, and the tailnet — and are NOT touched here. # Overriding them is how private networking quietly stops resolving. # # global the resolver used when no link claims the query. This is what the # step sets. # # So this changes where public lookups go, and leaves the machine's own networks # resolving exactly as they did. # # ── Drop-in, and note the sort order ── # # systemd reads drop-ins in lexical order and the LAST value wins, so 99- is what # overrides. That is the opposite of sshd, three files away in this same # directory, where the FIRST value wins and the drop-in has to sort early. Worth # stating because getting it backwards fails silently in both directions. # # The original rewrote /etc/systemd/resolved.conf wholesale, which discards # anything else in it — DNSSEC, DNSOverTLS, Domains, Cache — without mentioning # that it had. RESOLVED_DROPIN=/etc/systemd/resolved.conf.d/99-machine-setup.conf resolved_is_active() { systemctl is-active --quiet systemd-resolved 2>/dev/null; } # The global resolvers in force, space separated, or empty if none are set. dns_current_global() { if resolved_is_active; then resolvectl status 2>/dev/null | awk '/^ *DNS Servers:/ { $1 = ""; $2 = ""; print; exit }' | xargs else awk '/^nameserver/ { printf "%s ", $2 }' /etc/resolv.conf 2>/dev/null | xargs fi } # What each interface was handed. Printed, never changed — the point is to show # that this step is not touching them. dns_per_link() { resolved_is_active || return 0 resolvectl status 2>/dev/null | awk '/^Link [0-9]+ \(/ { link = $3; gsub(/[()]/, "", link) } /^ *DNS Servers:/ && link { $1 = ""; $2 = ""; printf "%s:%s\n", link, $0; link = "" }' } dns_set_global() { local primary="$1" fallback="$2" if resolved_is_active; then install -d -m 0755 "$(dirname "$RESOLVED_DROPIN")" cat >"$RESOLVED_DROPIN" </dev/null || true ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf fi systemctl restart systemd-resolved else # No resolved: write resolv.conf directly, and say plainly that anything # managing the interface may put its own back. cp -a /etc/resolv.conf "/etc/resolv.conf.before-machine-setup" 2>/dev/null || true if lsattr /etc/resolv.conf 2>/dev/null | cut -c1-20 | grep -q i; then chattr -i /etc/resolv.conf fi { echo "# Written by machine-setup." local ns for ns in $primary $fallback; do echo "nameserver ${ns}"; done } >/etc/resolv.conf fi } # Does name resolution actually work now? Asked after the change rather than # 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; } # ----------------------------------------------------------------------------- # Firewall # ----------------------------------------------------------------------------- # # Last in the run, for the reason the original gave: enabling a firewall is the # one step that can cut the connection it is being run over. Everything else # should be done and working first. # # ── The bug in the shipped Docker rules ── # # ufw-docker-rules.conf hardcodes eth0. Docker publishes ports by writing its own # iptables rules, which bypass ufw entirely — DOCKER-USER is the hook that lets # ufw have a say. But every rule in that file names eth0, so on a machine with # predictable interface names (ens18, enp1s0, and most VPS images) they match # nothing, the final DROP never fires, and every published container port is open # to the internet while `ufw status` says active. A firewall that reports itself # working and is not is worse than none. UFW_AFTER_RULES=/etc/ufw/after.rules ufw_is_active() { ufw status 2>/dev/null | grep -q "^Status: active"; } ufw_allows_ssh() { ufw status 2>/dev/null | grep -qiE "^(22/tcp|OpenSSH)"; } ufw_has_rule() { ufw status 2>/dev/null | grep -qF "$1"; } ufw_docker_rules_applied() { grep -q "DOCKER-USER" "$UFW_AFTER_RULES" 2>/dev/null; } # The shipped rules, with eth0 replaced by the interface this machine actually # uses. Appended once — the DOCKER-USER marker is the guard. apply_ufw_docker_rules() { local src="$1" iface iface="$(default_iface)" [[ -n "$iface" ]] || return 1 [[ -r "$src" ]] || return 1 { echo "" echo "# Appended by machine-setup. Interface substituted for the one this" echo "# machine actually uses; the shipped file hardcodes eth0." sed "s/-i eth0/-i ${iface}/g" "$src" } >>"$UFW_AFTER_RULES" }