diff --git a/scripts/setup/machine-setup/lib/base.sh b/scripts/setup/machine-setup/lib/base.sh index 7812575c..0972b74c 100644 --- a/scripts/setup/machine-setup/lib/base.sh +++ b/scripts/setup/machine-setup/lib/base.sh @@ -85,8 +85,25 @@ fail() { # step_ok # fi +# Set by --only. When it is set, every step whose name does not match is passed +# over in silence, and the one that does matches runs regardless of the progress +# file — the point of asking for a single step is to run that step. +ONLY_STEP="${ONLY_STEP:-}" + step() { CURRENT_STEP="$1" + + if [[ -n "$ONLY_STEP" ]]; then + if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then + SKIP_STEP=false + echo "" + echo -e "${BOLD}── $1 ──${NC}" + else + SKIP_STEP=true + fi + return + fi + if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then echo -e " ${GREEN}SKIP${NC}: $1 (already done)" SKIP_STEP=true @@ -100,6 +117,9 @@ step() { skip() { [[ "$SKIP_STEP" == true ]]; } step_ok() { + # A single step run on its own is not progress through the script, and + # recording it would make the next full run skip it. + [[ -n "$ONLY_STEP" ]] && return 0 echo "$CURRENT_STEP" >>"$PROGRESS_FILE" } diff --git a/scripts/setup/machine-setup/lib/tailscale.sh b/scripts/setup/machine-setup/lib/tailscale.sh new file mode 100644 index 00000000..91ddcec5 --- /dev/null +++ b/scripts/setup/machine-setup/lib/tailscale.sh @@ -0,0 +1,108 @@ +#!/bin/bash +# ============================================================================= +# machine-setup — Tailscale +# ============================================================================= +# +# Definitions only, like the other lib/ files. +# +# ── Why this runs early ── +# +# It is a second way into the machine. The section that can lock you out is SSH +# hardening, and everything after this one can break networking in some smaller +# way; having the tailnet up first means a mistake is recoverable rather than a +# trip to a rescue console. +# +# ── Why it matters to Officer specifically ── +# +# The platform's CLAUDE.md is explicit: the perimeter IS the tailnet. +# ALLOW_ANY_ORIGIN defaults ON, and that is only defensible because the machine is +# not reachable from the open internet in the first place — a valid token plus the +# tailnet is the lock. An Officer install with no tailnet is an Officer install +# with one fewer layer than it was designed around. +# +# ── Why the original hung ── +# +# It passed --authkey unconditionally, and its prompt accepted an empty answer. +# `tailscale up --authkey ""` falls back to interactive login: it prints a URL and +# blocks, with no timeout, forever. Nothing here passes an empty key, every call +# has a timeout, and the state is read before anything is run. + +[[ -n "${MACHINE_SETUP_TAILSCALE_LOADED:-}" ]] && return 0 +MACHINE_SETUP_TAILSCALE_LOADED=1 + +TS_EXIT_SYSCTL=/etc/sysctl.d/99-tailscale-exit.conf +TS_DISPATCHER=/etc/networkd-dispatcher/routable.d/50-tailscale-exit + +tailscale_is_installed() { command -v tailscale &>/dev/null; } + +# NeedsLogin, Running, Stopped, NoState… Read before acting, because the original's +# failure was running `up` blindly against a node that was already up. +tailscale_state() { + tailscale status --json 2>/dev/null | awk -F'"' '/"BackendState"/ { print $4; exit }' +} + +tailscale_ip() { tailscale ip -4 2>/dev/null | head -1; } + +# Which control plane this node is talking to. Empty means Tailscale's own. +tailscale_control_url() { + tailscale debug prefs 2>/dev/null | awk -F'"' '/"ControlURL"/ { print $4; exit }' +} + +tailscale_install() { curl -fsSL https://tailscale.com/install.sh | sh; } + +# Routing has to be on before this machine can forward anyone else's packets, +# whether as an exit node or as a subnet router. Written as a drop-in so it is +# visible as this script's doing. +enable_ip_forwarding() { + cat >"$TS_EXIT_SYSCTL" <<'EOF' +# Written by machine-setup: required to forward traffic for other tailnet nodes, +# as an exit node or as a subnet router. +net.ipv4.ip_forward = 1 +net.ipv6.conf.all.forwarding = 1 +EOF + sysctl --system >/dev/null 2>&1 +} + +# UDP GRO forwarding, which Tailscale documents as roughly doubling throughput on +# a node that forwards for others. Applied on every routable event rather than +# once, because the settings are per-interface and do not survive the link going +# down and back up. +install_exit_node_tuning() { + pkg_is_installed networkd-dispatcher || pkg_install_now networkd-dispatcher + + mkdir -p "$(dirname "$TS_DISPATCHER")" + cat >"$TS_DISPATCHER" <<'EOF' +#!/usr/bin/env bash +# Written by machine-setup. NIC offload settings for a Tailscale exit node or +# subnet router — Tailscale's own recommendation for forwarding throughput. +set -Eeuo pipefail + +IF="${IFACE:-}" +if [[ -z "${IF}" ]]; then + IF="$(ip -o route get 8.8.8.8 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}')" +fi + +[[ -n "${IF}" ]] || exit 0 +command -v ethtool >/dev/null 2>&1 || exit 0 + +ethtool -k "${IF}" 2>/dev/null | grep -q "^generic-receive-offload: " && ethtool -K "${IF}" gro on || true +ethtool -k "${IF}" 2>/dev/null | grep -q "^rx-udp-gro-forwarding: " && ethtool -K "${IF}" rx-udp-gro-forwarding on || true +ethtool -k "${IF}" 2>/dev/null | grep -q "^large-receive-offload: " && ethtool -K "${IF}" lro off || true + +exit 0 +EOF + chmod 755 "$TS_DISPATCHER" + systemctl enable --now networkd-dispatcher >/dev/null 2>&1 || true + + # And once now, for the interface that is already up. + IFACE="$(default_iface)" bash "$TS_DISPATCHER" >/dev/null 2>&1 || true +} + +# The LAN this machine sits on, as a CIDR — the useful default for a subnet +# router, and the number nobody remembers offhand. +lan_cidr() { + local iface + iface="$(default_iface)" + ip -4 route show dev "$iface" 2>/dev/null | + awk '$1 ~ /\// && $1 !~ /^default/ { print $1; exit }' +} diff --git a/scripts/setup/machine-setup/machine-setup.sh b/scripts/setup/machine-setup/machine-setup.sh index e7e7cd9c..8f4daf5a 100755 --- a/scripts/setup/machine-setup/machine-setup.sh +++ b/scripts/setup/machine-setup/machine-setup.sh @@ -13,6 +13,32 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROGRESS_FILE="$SCRIPT_DIR/.setup-progress" +# --only runs one section and nothing else, for working on it. Pre-flight +# still runs, because every section needs what it establishes — the system, the +# role, the account and its home. +ONLY_STEP="" +while [[ $# -gt 0 ]]; do + case "$1" in + --only) + ONLY_STEP="${2:-}" + shift 2 + ;; + --only=*) + ONLY_STEP="${1#*=}" + shift + ;; + -l | --list) + grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}" + exit 0 + ;; + -h | --help) + echo "usage: machine-setup.sh [--only ] [--list]" + exit 0 + ;; + *) echo "unknown option: $1" >&2 && exit 2 ;; + esac +done + # Shared state, output helpers, the step/resume machine and OS detection. Kept in # lib/ so a step can eventually be read — or run — on its own without dragging the # whole script in. Definitions only; nothing in there acts. @@ -36,6 +62,8 @@ source "$SCRIPT_DIR/lib/network.sh" source "$SCRIPT_DIR/lib/dev.sh" # shellcheck source=lib/docker.sh source "$SCRIPT_DIR/lib/docker.sh" +# shellcheck source=lib/tailscale.sh +source "$SCRIPT_DIR/lib/tailscale.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. @@ -357,7 +385,170 @@ fi # ============================================================================= -# 7. Locale +# 7. Tailscale +# ============================================================================= +# +# Placed here, before everything that can go wrong, because it is a second way +# into the machine. It needs curl, so it cannot come before core utils; it wants +# to come before SSH hardening, which is the step that can lock you out. + +step "Tailscale" +if ! skip; then + echo "" + info "Tailscale — a private network between your machines, over WireGuard" + echo " Every device you enrol gets a stable 100.x address and can reach" + echo " every other, wherever they are. Nothing is published to the open" + echo " internet: no port forwarding, no exposed ports, no firewall holes." + echo "" + echo " For Officer this is not a convenience. The platform is built assuming" + echo " the tailnet IS the perimeter — ALLOW_ANY_ORIGIN defaults on, and that" + echo " is only defensible because the machine is not reachable from outside" + echo " in the first place. Installed here, before anything that can lock you" + echo " out, so there is always a second way in." + + if ! tailscale_is_installed; then + echo "" + echo " not installed" + if confirm "Install Tailscale?"; then + tailscale_install + tailscale_is_installed && ok "$(tailscale version 2>/dev/null | head -1) installed" + else + warn "skipped by request" + SUMMARY+=("Tailscale: SKIPPED by request") + fi + fi + + if tailscale_is_installed; then + TS_STATE="$(tailscale_state)" + TS_URL_NOW="$(tailscale_control_url)" + + echo "" + echo " state: ${TS_STATE:-unknown}" + echo " control plane: ${TS_URL_NOW:-tailscale.com (the default service)}" + [[ "$TS_STATE" == "Running" ]] && echo " this machine: $(tailscale_ip) ($(hostname))" + + TS_CONNECT=true + if [[ "$TS_STATE" == "Running" ]]; then + echo "" + echo " Already connected. Reconnecting is only needed to change the" + echo " control plane or what this node advertises." + confirm "Reconfigure it?" n || TS_CONNECT=false + fi + + if [[ "$TS_CONNECT" == false ]]; then + SUMMARY+=("Tailscale: connected, unchanged ($(tailscale_ip))") + else + echo "" + info "Which control plane?" + echo " [1] Tailscale's own service (tailscale.com)" + echo " [2] a self-hosted headscale" + echo "" + TS_LOGIN_SERVER="" + TS_PLANE="" + while [[ -z "$TS_PLANE" ]]; do + if ! read -rp " Which one? (1/2) [1]: " TS_PLANE_CHOICE; then + echo "" + fail "No answer." + fi + case "${TS_PLANE_CHOICE:-1}" in + 1) TS_PLANE="tailscale" ;; + 2) + # No default offered. A control-plane URL is somebody's private + # infrastructure, and a machine that joins the wrong tailnet has + # joined a stranger's network. + read -rp " headscale URL (e.g. https://headscale.example.com): " TS_LOGIN_SERVER || fail "No answer." + if [[ "$TS_LOGIN_SERVER" =~ ^https?:// ]]; then + TS_PLANE="headscale" + else + warn "That needs to be a full URL, starting with https://" + fi + ;; + *) warn "Pick 1 or 2." ;; + esac + done + + echo "" + info "How should this machine authenticate?" + echo " An auth key enrols it without a browser. Leaving this blank is" + echo " fine — Tailscale then prints a URL to open, and waits for you." + echo "" + read -rp " Auth key (blank for the browser flow): " TS_AUTHKEY || fail "No answer." + + echo "" + info "What should this machine offer the tailnet?" + echo "" + echo " Tailscale SSH — ssh to this machine over the tailnet with no keys" + echo " at all; who may connect is decided by your tailnet's ACLs rather" + echo " than by authorized_keys. Independent of the sshd hardening later" + echo " in this run, and a useful way back in if that goes wrong." + TS_SSH="" + confirm "Enable Tailscale SSH?" n && TS_SSH="--ssh" + + TS_ROUTES="" + if is_role homelab; then + echo "" + echo " Subnet router — makes this machine a door onto its LAN, so" + echo " every tailnet device can reach the printers, NAS and switches" + echo " here without each of them running Tailscale." + LAN="$(lan_cidr)" + if [[ -n "$LAN" ]] && confirm "Advertise ${LAN} to the tailnet?" n; then + TS_ROUTES="--advertise-routes=${LAN}" + fi + fi + + echo "" + echo " Exit node — lets other tailnet devices send ALL their internet" + echo " traffic out through this machine, as a VPN would. Useful from a" + echo " phone on hostile wifi; it means this machine's connection carries" + echo " their traffic, and their browsing exits from this IP." + TS_EXIT="" + confirm "Advertise as an exit node?" n && TS_EXIT="--advertise-exit-node" + + if [[ -n "$TS_EXIT" || -n "$TS_ROUTES" ]]; then + echo "" + info " forwarding other machines' packets needs routing enabled — writing ${TS_EXIT_SYSCTL}" + enable_ip_forwarding + info " applying Tailscale's recommended NIC offload settings (roughly doubles forwarding throughput)" + install_exit_node_tuning + fi + + echo "" + TS_ARGS=(up --timeout=60s) + [[ "$TS_PLANE" == "headscale" ]] && TS_ARGS+=(--login-server "$TS_LOGIN_SERVER") + # Never passed empty. `--authkey ""` silently falls back to the interactive + # flow and blocks forever, which is exactly how the original hung. + [[ -n "$TS_AUTHKEY" ]] && TS_ARGS+=(--authkey "$TS_AUTHKEY") + [[ -n "$TS_SSH" ]] && TS_ARGS+=("$TS_SSH") + [[ -n "$TS_ROUTES" ]] && TS_ARGS+=("$TS_ROUTES") + [[ -n "$TS_EXIT" ]] && TS_ARGS+=("$TS_EXIT") + + if [[ -z "$TS_AUTHKEY" ]]; then + warn "no auth key given — a URL will be printed below, and this waits for you to open it" + fi + echo "" + + if tailscale "${TS_ARGS[@]}"; then + TS_IP="$(tailscale_ip)" + ok "connected as ${TS_IP} on $(hostname)" + SUMMARY+=("Tailscale: ${TS_IP}${TS_SSH:+, Tailscale SSH}${TS_ROUTES:+, subnet router}${TS_EXIT:+, exit node}") + if [[ -n "$TS_EXIT" || -n "$TS_ROUTES" ]]; then + warn "an exit node or advertised route must be approved in the admin console before it carries traffic" + fi + else + # Loud rather than silent. The original had no timeout at all, so a + # failure to authenticate looked like the script having frozen. + warn "tailscale up did not complete within 60s" + echo " Run it by hand to see what it is waiting for: tailscale up" + ERRORS+=("Tailscale: up did not complete") + SUMMARY+=("Tailscale: NOT connected") + fi + fi + fi + step_ok +fi + +# ============================================================================= +# 8. Locale # ============================================================================= # # LOCALE in the environment overrides the default. @@ -395,7 +586,7 @@ if ! skip; then fi # ============================================================================= -# 8. Timezone +# 9. Timezone # ============================================================================= # # TIMEZONE in the environment answers the prompt ahead of time. @@ -461,7 +652,7 @@ if ! skip; then fi # ============================================================================= -# 9. Swap +# 10. Swap # ============================================================================= # # Disk the kernel can park cold pages on when RAM fills, so a spike costs @@ -515,7 +706,7 @@ if ! skip; then fi # ============================================================================= -# 10. Emergency disk ballast +# 11. Emergency disk ballast # ============================================================================= # # Always offered, whatever the role — the role only decides which way the @@ -639,7 +830,7 @@ elif ! skip; then fi # ============================================================================= -# 11. earlyoom +# 12. earlyoom # ============================================================================= step "earlyoom" @@ -674,7 +865,7 @@ if ! skip; then fi # ============================================================================= -# 12. inotify watch limit +# 13. inotify watch limit # ============================================================================= step "inotify watch limit" @@ -713,7 +904,7 @@ if ! skip; then fi # ============================================================================= -# 13. Sleep and suspend +# 14. Sleep and suspend # ============================================================================= step "Sleep and suspend" @@ -781,7 +972,7 @@ elif ! skip; then fi # ============================================================================= -# 14. Boot hang +# 15. Boot hang # ============================================================================= step "Boot hang" @@ -837,7 +1028,7 @@ elif ! skip; then fi # ============================================================================= -# 15. SSH access +# 16. SSH access # ============================================================================= # # Keys and hardening in one section, deliberately. They were two in the original, @@ -951,7 +1142,7 @@ if ! skip; then fi # ============================================================================= -# 16. DNS +# 17. DNS # ============================================================================= step "DNS" @@ -1044,7 +1235,7 @@ if ! skip; then fi # ============================================================================= -# 17. Network address +# 18. Network address # ============================================================================= # # Homelab only. On a vps the provider's DHCP is authoritative and already stable, @@ -1175,7 +1366,7 @@ elif ! skip; then fi # ============================================================================= -# 18. fail2ban +# 19. fail2ban # ============================================================================= # # Installed as part of core utils rather than here — it is a distro package and @@ -1208,7 +1399,7 @@ if ! skip; then fi # ============================================================================= -# 19. Unattended upgrades +# 20. Unattended upgrades # ============================================================================= # # The package comes from core utils. This makes sure it is actually switched on, @@ -1272,7 +1463,7 @@ EOF fi # ============================================================================= -# 20. Git +# 21. Git # ============================================================================= step "Git" @@ -1352,7 +1543,7 @@ if ! skip; then fi # ============================================================================= -# 21. Docker +# 22. Docker # ============================================================================= step "Docker" @@ -1485,7 +1676,7 @@ fi # # Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order: # -# zsh + prompt (incl. .tmux.conf) · tailscale · neovim · js runtimes · +# zsh + prompt (incl. .tmux.conf) · neovim · js runtimes · # dev tools · ufw · zshrc # # And one that is new rather than ported, to come last of all: @@ -1500,7 +1691,7 @@ fi # ============================================================================= -# 15. Summary +# 23. Summary # ============================================================================= echo ""