From 7a5cf898195a3f61d5304f7a0ad8ab3e47eced3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 12 Aug 2026 18:45:24 +0000 Subject: [PATCH] port the DNS section, as a choice rather than a decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original hardcoded Cloudflare plus Google with no way to say otherwise, and rewrote /etc/systemd/resolved.conf wholesale — discarding DNSSEC, DNSOverTLS, Domains and Cache if anything had set them, without mentioning it had. The settings are a drop-in now, and the resolver is picked from a list with a "keep what is there" that is the default. The part worth having explicit is which layer is being changed. With systemd-resolved there are two: 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, the tailnet — and are printed by this step precisely to show they are NOT being touched. Overriding them is how private networking quietly stops resolving. global the resolver used when no link claims the query. This is the one the step sets. On this host that distinction is live: eth0 has Hetzner's resolvers and tailscale0 has 100.100.100.100, which is what answers ts.pastilhas.dev. Both are left alone. The drop-in is named 99- because systemd reads drop-ins in lexical order and the LAST value wins. That is the opposite of sshd, whose drop-in three files away in this same directory has to sort FIRST. Both are stated where they are written, because getting it backwards fails silently in either direction. resolv.conf is checked for actually pointing at resolved's stub before the drop-in is trusted to do anything — a machine where something replaced the symlink with a static file bypasses resolved entirely. Resolution is tested afterwards rather than assumed. A resolver that does not answer makes every later step fail for a reason that has nothing to do with it, so that failure is reported and recorded rather than swallowed. The choice names what each provider actually is, including that a resolver sees every name the machine looks up. Verified both paths against this host: keep reports unchanged, Quad9 renders the right addresses, and the per-link display shows Hetzner and Tailscale correctly. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/setup/machine-setup/lib/network.sh | 108 +++++++++++++++++++ scripts/setup/machine-setup/machine-setup.sh | 97 ++++++++++++++++- 2 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 scripts/setup/machine-setup/lib/network.sh diff --git a/scripts/setup/machine-setup/lib/network.sh b/scripts/setup/machine-setup/lib/network.sh new file mode 100644 index 00000000..a690a835 --- /dev/null +++ b/scripts/setup/machine-setup/lib/network.sh @@ -0,0 +1,108 @@ +#!/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; } diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index d48fc35c..fe97b7bd 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -30,6 +30,8 @@ source "$SCRIPT_DIR/lib/disk.sh" source "$SCRIPT_DIR/lib/files.sh" # shellcheck source=lib/ssh.sh source "$SCRIPT_DIR/lib/ssh.sh" +# shellcheck source=lib/network.sh +source "$SCRIPT_DIR/lib/network.sh" # Trap errors with context. Installed here rather than in lib/base.sh, because # that file is definitions only and a trap is a side effect on whoever sources it. @@ -944,13 +946,106 @@ if ! skip; then step_ok fi +# ============================================================================= +# 16. DNS +# ============================================================================= + +step "DNS" +if ! skip; then + CURRENT_DNS="$(dns_current_global)" + + echo "" + info "DNS — which resolver answers public lookups" + echo " global resolver: ${CURRENT_DNS:-none set, using whatever DHCP gave}" + + # Shown so it is clear what this step is NOT doing. Overriding these is how a + # provider's internal names, or the tailnet, quietly stop resolving. + if [[ -n "$(dns_per_link)" ]]; then + echo "" + echo " per-interface, left untouched:" + dns_per_link | while IFS=: read -r link servers; do + printf ' %-14s %s\n' "$link" "$(echo "$servers" | xargs)" + done + fi + + echo "" + echo " Whoever runs this resolver sees every name this machine looks up." + echo " [1] keep what is there" + echo " [2] Cloudflare 1.1.1.1 fast, logs for 24h" + echo " [3] Quad9 9.9.9.9 blocks known-malicious domains" + echo " [4] Google 8.8.8.8 fast, ubiquitous" + echo " [5] type your own" + echo "" + + DNS_PRIMARY="" + DNS_FALLBACK="" + DNS_CHOSEN="" + while [[ -z "$DNS_CHOSEN" ]]; do + if ! read -rp " Which one? (1-5) [1]: " DNS_CHOICE; then + echo "" + fail "No answer." + fi + case "${DNS_CHOICE:-1}" in + 1) DNS_CHOSEN="keep" ;; + 2) + DNS_CHOSEN="Cloudflare" + DNS_PRIMARY="1.1.1.1 2606:4700:4700::1111" + DNS_FALLBACK="1.0.0.1 2606:4700:4700::1001" + ;; + 3) + DNS_CHOSEN="Quad9" + DNS_PRIMARY="9.9.9.9 2620:fe::fe" + DNS_FALLBACK="149.112.112.112 2620:fe::9" + ;; + 4) + DNS_CHOSEN="Google" + DNS_PRIMARY="8.8.8.8 2001:4860:4860::8888" + DNS_FALLBACK="8.8.4.4 2001:4860:4860::8844" + ;; + 5) + read -rp " Primary resolver(s), space separated: " DNS_PRIMARY || fail "No answer." + read -rp " Fallback resolver(s), or blank: " DNS_FALLBACK || fail "No answer." + [[ -n "$DNS_PRIMARY" ]] && DNS_CHOSEN="custom" || warn "A primary resolver is needed." + ;; + *) warn "Pick 1 to 5." ;; + esac + done + + if [[ "$DNS_CHOSEN" == "keep" ]]; then + echo " keeping ${CURRENT_DNS:-the current configuration}" + SUMMARY+=("DNS: unchanged") + else + echo "" + echo " to set: ${DNS_PRIMARY}" + [[ -n "$DNS_FALLBACK" ]] && echo " fallback: ${DNS_FALLBACK}" + if confirm "Proceed?"; then + dns_set_global "$DNS_PRIMARY" "$DNS_FALLBACK" + if dns_works; then + ok "${DNS_CHOSEN} in use, resolution verified" + SUMMARY+=("DNS: ${DNS_CHOSEN} (${DNS_PRIMARY%% *})") + else + # Reported rather than swallowed. Every step after this fetches + # something, and they would all fail for a reason that has nothing to do + # with them. + warn "DNS is set but a test lookup failed — check 'resolvectl status'" + ERRORS+=("DNS: set to ${DNS_CHOSEN} but a test lookup failed") + SUMMARY+=("DNS: ${DNS_CHOSEN}, but resolution did not verify") + fi + else + warn "skipped by request" + SUMMARY+=("DNS: SKIPPED by request") + fi + fi + step_ok +fi + # ============================================================================= # NOT PORTED YET # ============================================================================= # # Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order: # -# dns · static ip · fail2ban · unattended-upgrades · +# static ip · fail2ban · unattended-upgrades · # git config · docker · zsh + prompt (incl. .tmux.conf) · tailscale · neovim · js runtimes · # dev tools · ufw · zshrc #