port the DNS section, as a choice rather than a decision

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 18:45:24 +00:00
co-authored by Claude Opus 5
parent 192293cdca
commit 7a5cf89819
2 changed files with 204 additions and 1 deletions
+108
View File
@@ -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" <<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; }
+96 -1
View File
@@ -30,6 +30,8 @@ source "$SCRIPT_DIR/lib/disk.sh"
source "$SCRIPT_DIR/lib/files.sh" source "$SCRIPT_DIR/lib/files.sh"
# shellcheck source=lib/ssh.sh # shellcheck source=lib/ssh.sh
source "$SCRIPT_DIR/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 # 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. # 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 step_ok
fi 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 # NOT PORTED YET
# ============================================================================= # =============================================================================
# #
# Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order: # 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 · # git config · docker · zsh + prompt (incl. .tmux.conf) · tailscale · neovim · js runtimes ·
# dev tools · ufw · zshrc # dev tools · ufw · zshrc
# #