Files
platform/scripts/setup/machine-setup/lib/network.sh
T
pastilhasandClaude Opus 5 30052e3295 port the firewall, last, and bind the Docker rules to the real interface
Last in the run for the reason the original gave: enabling a firewall is the one
step that can cut the connection it is running over.

The security bug is in the shipped rules. ufw-docker-rules.conf hardcodes eth0 in
all three of its rules. Docker publishes container ports by writing its own
iptables rules underneath ufw — DOCKER-USER is the hook that lets ufw have a say
at all — so on a machine with predictable interface names (ens18, enp1s0, most
VPS images) none of those rules match, the final DROP never fires, and every
published port is open to the internet while `ufw status` reports active. A
firewall that says it is working and is not is worse than no firewall. The rules
are now substituted with the interface the machine actually uses, verified by
applying them against a stubbed ens18.

Order inside the section is the other thing that matters: OpenSSH is allowed
BEFORE anything is enabled, unconditionally, because a firewall enabled without
an ssh rule on a machine reached over ssh needs a console to fix. The prompt says
so, and says to open a second session before closing the current one.

tailscale0 is checked and offered, because the default is deny inbound and the
tailnet is an inbound interface like any other — without that rule Officer is
unreachable over the tailnet while Tailscale reports itself connected.

A correction to something I said while writing this: I reported that this host was
missing its tailscale0 rule. It is not. I had run `ufw status verbose | head -8`,
which cut the output above the rule list. The full status shows it allowed, and
nothing was wrong.

That leaves the NOT PORTED list empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:25:13 +00:00

239 lines
9.6 KiB
Bash

#!/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" <<EOF
# Written by machine-setup. 99- so it sorts last: systemd drop-ins are
# last-value-wins. Only the GLOBAL resolvers are set here — per-link DNS from
# DHCP and from Tailscale is left alone, so internal names keep resolving.
[Resolve]
DNS=${primary}
FallbackDNS=${fallback}
EOF
chmod 644 "$RESOLVED_DROPIN"
# resolv.conf has to point at the stub for any of this to be consulted. A
# machine where something replaced the symlink with a static file bypasses
# resolved entirely, and the drop-in would have no effect at all.
local target
target="$(readlink -f /etc/resolv.conf 2>/dev/null || true)"
if [[ "$target" != /run/systemd/resolve/*resolv.conf ]]; then
cp -a /etc/resolv.conf "/etc/resolv.conf.before-machine-setup" 2>/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" <<EOF
# Written by machine-setup.
#
# Identify to DHCP by MAC rather than by DUID, so the router recognises this
# machine across reboots and any reservation pinned to its MAC is honoured.
# Merged with whatever else is in /etc/netplan; 99- so it is read last.
network:
version: 2
ethernets:
${iface}:
dhcp-identifier: mac
EOF
chmod 600 "$NETPLAN_DHCP_ID"
}
# Freeze the current lease into a static address.
write_static_netplan() {
local iface="$1" cidr="$2" gateway="$3"
cat >"$NETPLAN_STATIC" <<EOF
# Written by machine-setup. Delete this file and run 'netplan apply' to go back
# to DHCP.
network:
version: 2
ethernets:
${iface}:
dhcp4: false
addresses:
- ${cidr}
routes:
- to: default
via: ${gateway}
EOF
chmod 600 "$NETPLAN_STATIC"
}
netplan_check() { netplan generate 2>&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"
}