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 #