Read the whole thing — 2392 lines of entry point and 2700 of libraries — looking for what shellcheck cannot see. shellcheck itself is clean at error level; its warnings are cross-file false positives and one deliberate tilde in a display string. Everything below is a real defect. ── The Git section aborted on any machine where git was not already configured ── `git config --global --get <key>` exits NON-ZERO when the key is simply unset, and `VAR="$(git_get …)"` propagates that under `set -e`. So on a fresh machine — the case this script exists for — the section died at its first assignment, before printing anything, and took the remaining nine sections with it. It passed every earlier test because those harnesses sourced the section under a `bash -c` with no `set -e`. Verified now against a genuinely fresh account with the real script: the section completes and writes a correct .gitconfig. ── An optional step failing aborted the whole run ── Twelve functions ended on a command that can fail — `systemctl enable --now earlyoom`, `systemctl restart systemd-logind`, `chsh`, `sysctl -w`, `chown -R`, the oh-my-zsh installer, and others. Called as plain commands under `set -e`, any one of them failing ends the script, so a masked unit or a container without systemd would abort a 28-section run over an optional improvement. They now return 0 explicitly and the callers verify the outcome instead — which also fixed a lie: the sleep section printed "sleep disabled, logind reloaded" whether or not the restart had worked. It now checks the targets and the logind values and reports honestly. ── chown user:user assumed the primary group is named after the user ── True on Debian and Ubuntu, which create a group per user. Not true for an account from LDAP, or made with `useradd -g users`, or on an image with a shared group — there `install -g <user>` fails with "invalid group" and the step aborts. Proved it against an account whose primary group is `oddgroup`: the old form fails, the new one gets ownership right. Eight call sites now ask `id -gn`. ── Also hardened ── agent_path and current_editor gained `|| true` for the same reason git_get needed it: "nothing is set" is an answer, not a failure. Verified afterwards: shellcheck clean at error level, every section runs standalone without aborting, and the two apparent failures in that sweep are correct behaviour — Timezone and Git refusing an empty answer from /dev/null. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
249 lines
10 KiB
Bash
249 lines
10 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"
|
|
# Returns 0 whatever happens. This is an optional improvement, and a
|
|
# function that ends on a failing command is fatal under `set -e` when it
|
|
# is called as a plain command — which would abort the remaining sections
|
|
# over something the run could simply report. The caller checks the outcome.
|
|
return 0
|
|
}
|
|
|
|
# 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"
|
|
# Returns 0 whatever happens. This is an optional improvement, and a
|
|
# function that ends on a failing command is fatal under `set -e` when it
|
|
# is called as a plain command — which would abort the remaining sections
|
|
# over something the run could simply report. The caller checks the outcome.
|
|
return 0
|
|
}
|
|
|
|
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"
|
|
}
|