#!/bin/bash set -e # ============================================================================= # machine-setup — provisioning for a fresh machine # # Brings a blank box up to a usable state: users, SSH, networking, firewall, # Docker, shell and editor tooling, language runtimes. # # Run as root: sudo scripts/setup/machine-setup/machine-setup.sh # ============================================================================= SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROGRESS_FILE="$SCRIPT_DIR/.setup-progress" # 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. # shellcheck source=lib/base.sh source "$SCRIPT_DIR/lib/base.sh" # shellcheck source=lib/packages.sh source "$SCRIPT_DIR/lib/packages.sh" # shellcheck source=lib/tools.sh source "$SCRIPT_DIR/lib/tools.sh" # shellcheck source=lib/system.sh source "$SCRIPT_DIR/lib/system.sh" # shellcheck source=lib/disk.sh source "$SCRIPT_DIR/lib/disk.sh" # shellcheck source=lib/files.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. trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR # ============================================================================= # 1. Pre-flight # ============================================================================= echo "" echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}" echo -e "${BOLD}║ Machine Setup ║${NC}" echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}" detect_os echo "" info "Machine: ${OS_NAME} (${ARCH})" info "Packages: ${PM:-none detected}" [[ "$IS_WSL" == true ]] && warn "WSL detected — the suspend, logind and boot-hang steps do not apply here" # Everything below this line is written against apt and systemd. Detection above # recognises pacman, dnf and brew so the branches have somewhere to hang, but # nothing implements them yet — and running the apt path on Arch would half-build # a machine and stop somewhere unhelpful. Refuse clearly instead, and relax this # list one entry at a time as each package manager grows a real path. case "$PM" in apt) ;; "") fail "Could not find a package manager for '${OS_NAME}'." ;; *) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt-based systems only, so far." ;; esac ask_machine_role info "Role: ${MACHINE_ROLE}" if [[ -f "$PROGRESS_FILE" ]]; then DONE_COUNT=$(wc -l < "$PROGRESS_FILE") echo -e "${YELLOW} Resuming — $DONE_COUNT step(s) already completed${NC}" echo -e "${YELLOW} Progress file: $PROGRESS_FILE${NC}" echo -e "${YELLOW} To start fresh: rm $PROGRESS_FILE${NC}" fi if [[ "$EUID" -ne 0 ]]; then fail "Please run as root: sudo ./machine-setup.sh" fi ask_username if id "$USERNAME" &>/dev/null; then info "Account: ${USERNAME} (exists, home ${USER_HOME})" else info "Account: ${USERNAME} (will be created, home ${USER_HOME})" fi ask_officer_root if [[ -d "$OFFICER_ROOT" ]]; then info "Officer: ${OFFICER_ROOT} (exists already)" else info "Officer: ${OFFICER_ROOT}" fi # Always, and outside any step: everything below reads this index — core utils, # the fastfetch PPA, the Docker repo — and `step` skips a step whose name is # already in the progress file. With the refresh inside one of those, a resumed # run installed against whatever the index happened to say hours or days ago. echo "" info "Refreshing the package index..." pkg_refresh >/dev/null # ============================================================================= # 2. User account # ============================================================================= # First of the acting sections, and before anything writes into a home. # # If the account does not exist yet, USER_HOME is a path that is not there — # and the ballast's mkdir -p, running as root, would create it root-owned. adduser # then finds the directory already present and does not populate or chown it. So # the account is made before any step can put a file in its home. step "User account" if ! skip; then echo "" info "User account — the account you will actually log in and work as" if id "$USERNAME" &>/dev/null; then echo " ${USERNAME} already exists" USER_EXISTED=true else echo " ${USERNAME} does not exist yet and will be created" echo " adduser will ask for a password and a few details" USER_EXISTED=false fi IN_SUDO=false id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx sudo && IN_SUDO=true echo " sudo group: $($IN_SUDO && echo 'already a member' || echo 'will be added')" echo " passwordless: $(has_passwordless_sudo "$USERNAME" && echo 'already granted' || echo 'not granted')" if $USER_EXISTED && $IN_SUDO; then SUMMARY+=("User: ${USERNAME} (already set up)") elif confirm "Proceed?"; then if ! $USER_EXISTED; then adduser --gecos "" "$USERNAME" SUMMARY+=("User: ${USERNAME} created") # Re-read it now the account is real. Until this point USER_HOME was the # /home/ guess, since there was nothing to look up; adduser is free # to have used something else, and every step after this writes there. USER_HOME="$(getent passwd "$USERNAME" | cut -d: -f6)" ok "home is ${USER_HOME}" fi $IN_SUDO || usermod -aG sudo "$USERNAME" ok "${USERNAME} is in the sudo group" $USER_EXISTED && SUMMARY+=("User: ${USERNAME} added to sudo") else warn "skipped by request" SUMMARY+=("User: SKIPPED by request") fi # ── passwordless sudo ── # # Asked separately because it is a security posture rather than part of # creating an account, and because Officer has an actual requirement here: # os-user-shell.ts runs `sudo -n` to provision a member's home, and a prompt it # cannot answer is a failure it reports as a permissions error. if has_passwordless_sudo "$USERNAME"; then echo "" echo " passwordless sudo is already granted to ${USERNAME}" SUMMARY+=("Sudo: passwordless (already)") else echo "" info "Passwordless sudo for ${USERNAME}?" echo " Means sudo never asks for a password again. Convenient, and the" echo " cost is real: anything that gets hold of this account, or of a key" echo " that can log into it, is root without another step." if is_role vps; then echo " Worth weighing on a ${MACHINE_ROLE}, which faces the open internet." fi echo "" echo " Officer needs it: it runs 'sudo -n' to provision a member's Linux" echo " account, and a password prompt it cannot answer surfaces as a" echo " permissions error rather than a question." if confirm "Grant it?"; then grant_passwordless_sudo "$USERNAME" ok "passwordless sudo granted — remove /etc/sudoers.d/99-${USERNAME}-nopasswd to undo" SUMMARY+=("Sudo: passwordless") else warn "skipped by request" SUMMARY+=("Sudo: password required (Officer's account provisioning will not work)") fi fi step_ok fi # ============================================================================= # 3. Disk space # ============================================================================= # # First of the sections that change anything, because everything after it sizes # itself from free disk — the swapfile and the ballast both. step "Disk space" if ! skip; then ROOT_DEV="$(root_device)" ROOT_DISK="$(parent_disk "$ROOT_DEV")" ROOT_PART="$(backing_partition "$ROOT_DEV" || true)" DISK_B="$(dev_bytes "$ROOT_DISK")" CONT_B="$(dev_bytes "$ROOT_DEV")" FS_B="$(fs_bytes "$ROOT_DEV")" VG_FREE_B="$(vg_free_bytes)" # A filesystem is always a little smaller than the thing holding it — # metadata, journal, reserved blocks. Only a real gap is worth reporting. SLACK=$((1024 * 1024 * 1024)) echo "" info "Disk space — whether the root filesystem is actually using the whole drive" echo " Ubuntu's installer, left on its defaults, gives the root volume a" echo " fixed size and leaves the rest of the drive unallocated. On a 2TB" echo " disk that is a 100G root and no sign anything is wrong: lsblk shows" echo " the whole drive, df shows 100G, and the two are never seen together" echo " until the day it fills up. Growing a virtual disk at the provider" echo " leaves the same shape." echo "" echo " drive: $(human_bytes "$DISK_B") ${ROOT_DISK}" echo " volume: $(human_bytes "$CONT_B") ${ROOT_DEV}" echo " filesystem: $(human_bytes "$FS_B") $(root_fstype), mounted at /" ((VG_FREE_B > SLACK)) && echo " unused in LVM: $(human_bytes "$VG_FREE_B")" # growpart is the authority on whether a partition can move, but installing a # package just to ask is too eager — the arithmetic decides whether it is even # worth looking. MIGHT_GROW=false ((DISK_B - CONT_B > SLACK)) && MIGHT_GROW=true [[ -n "$ROOT_PART" ]] && $MIGHT_GROW && ensure_growpart if ((VG_FREE_B > SLACK)); then echo "" echo " $(human_bytes "$VG_FREE_B") is sitting unallocated in the volume group" echo " the fix is lvextend, then growing the filesystem into it — both online" if confirm "Use it?"; then grow_lv && grow_fs ok "root filesystem is now $(human_bytes "$(fs_bytes "$ROOT_DEV")")" SUMMARY+=("Disk: reclaimed $(human_bytes "$VG_FREE_B") from the volume group") else warn "skipped by request" SUMMARY+=("Disk: SKIPPED — $(human_bytes "$VG_FREE_B") left unallocated") fi elif [[ -n "$ROOT_PART" ]] && partition_can_grow "$ROOT_PART"; then echo "" echo " the partition stops short of the end of the drive" echo " this is the one step that edits the partition table — it only ever" echo " moves the end of ${ROOT_PART} outwards, and never touches another one" if confirm "Extend it?"; then grow_partition "$ROOT_PART" if root_is_lvm; then grow_pv "$ROOT_PART" grow_lv fi grow_fs ok "root filesystem is now $(human_bytes "$(fs_bytes "$ROOT_DEV")")" SUMMARY+=("Disk: partition extended, filesystem now $(human_bytes "$(fs_bytes "$ROOT_DEV")")") else warn "skipped by request" SUMMARY+=("Disk: SKIPPED — partition left short of the drive") fi elif ((FS_B > 0 && CONT_B - FS_B > SLACK)); then echo "" echo " the filesystem is smaller than the volume holding it" if confirm "Grow it?"; then grow_fs ok "root filesystem is now $(human_bytes "$(fs_bytes "$ROOT_DEV")")" SUMMARY+=("Disk: filesystem grown to $(human_bytes "$(fs_bytes "$ROOT_DEV")")") else warn "skipped by request" SUMMARY+=("Disk: SKIPPED — filesystem left short of its volume") fi else echo "" echo " the whole drive is in use, nothing to reclaim" SUMMARY+=("Disk: already using the whole drive") fi step_ok fi # ============================================================================= # 4. System update # ============================================================================= # # Its own section because it is the only thing in the script that moves versions # of software already on the machine. Everything else only ever adds what is # absent, so this is the one that deserves to be refused on its own. # # The index refresh is NOT here — it runs in pre-flight, unconditionally, because # every later step reads it and this one can be skipped. step "System update" if ! skip; then mapfile -t UPGRADABLE < <(pkg_upgradable) echo "" info "System update — upgrades installed packages to their latest versions" if ((${#UPGRADABLE[@]} == 0)); then echo " to upgrade: nothing, everything is current" SUMMARY+=("System update: already up to date") else echo " to upgrade: ${#UPGRADABLE[@]} package(s)" # Capped, because a box that has not been touched in months lists hundreds # and a wall of names is no more informative than a count. printf ' %s\n' "${UPGRADABLE[@]:0:25}" ((${#UPGRADABLE[@]} > 25)) && echo " … and $((${#UPGRADABLE[@]} - 25)) more" if confirm "Proceed?"; then pkg_upgrade_all ok "System upgraded" SUMMARY+=("System upgraded: ${#UPGRADABLE[@]} package(s)") else warn "skipped by request" SUMMARY+=("System update: SKIPPED by request — ${#UPGRADABLE[@]} package(s) left as they are") fi fi step_ok fi # ============================================================================= # 5. Core utils # ============================================================================= # # What the distribution provides: the six this script would break without, and # the command-line tools that make a machine worth sitting at. step "Core utils" if ! skip; then # shellcheck disable=SC2046 # word splitting is how the list is passed pkg_install "Core utils" $(pkgs_core) summarise_last "Core utils" step_ok fi # ============================================================================= # 6. Command-line tools # ============================================================================= # # A different thing from core utils, and kept apart from them: upstream binaries # fetched from upstream, on their own release cadence, none of which the # distribution ships. Lumping them in made a run look like it was installing # system packages and then start pulling tarballs unannounced. step "Command-line tools" if ! skip; then # shellcheck disable=SC2046 # word splitting is how the list is passed tools_install "Command-line tools" $(tools_default) summarise_last "Command-line tools" step_ok fi # ============================================================================= # 7. Locale # ============================================================================= # # LOCALE in the environment overrides the default. step "Locale" if ! skip; then LOCALE="${LOCALE:-en_US.UTF-8}" CURRENT_LOCALE="$(locale_current)" echo "" info "Locale — the system language and character encoding" echo " current: ${CURRENT_LOCALE:-none configured}" echo " to set: ${LOCALE}" if [[ "$CURRENT_LOCALE" == "$LOCALE" ]] && locale_is_generated "$LOCALE"; then echo " already set and generated, nothing to do" SUMMARY+=("Locale: already ${LOCALE}") else # Say which of the two is actually wrong, since they fail differently: a # missing LANG means the C locale, a missing generation means every login # prints a setlocale warning. [[ "$CURRENT_LOCALE" != "$LOCALE" ]] && echo " LANG is not set to it" locale_is_generated "$LOCALE" || echo " the locale has not been generated on this machine" if confirm "Proceed?"; then locale_set "$LOCALE" ok "Locale set to ${LOCALE}" SUMMARY+=("Locale: ${LOCALE}") else warn "skipped by request" SUMMARY+=("Locale: SKIPPED by request — left at ${CURRENT_LOCALE:-unset}") fi fi step_ok fi # ============================================================================= # 8. Timezone # ============================================================================= # # TIMEZONE in the environment answers the prompt ahead of time. step "Timezone" if ! skip; then CURRENT_TZ="$(timezone_current)" echo "" info "Timezone — what logs, timers and every printed date are relative to" echo " current: ${CURRENT_TZ:-unknown}" if [[ -z "${TIMEZONE:-}" ]]; then echo "" for i in "${!TZ_OPTIONS[@]}"; do printf ' [%d] %s\n' "$((i + 1))" "${TZ_OPTIONS[$i]}" done echo "" while [[ -z "${TIMEZONE:-}" ]]; do if ! read -rp " Pick a number, or type a zone name — Enter keeps ${CURRENT_TZ:-the current one}: " TZ_CHOICE; then echo "" fail "No answer. Set TIMEZONE= to answer this ahead of time." fi if [[ -z "$TZ_CHOICE" ]]; then TIMEZONE="$CURRENT_TZ" elif [[ "$TZ_CHOICE" =~ ^[0-9]+$ ]]; then if ((TZ_CHOICE >= 1 && TZ_CHOICE <= ${#TZ_OPTIONS[@]})); then TIMEZONE="${TZ_OPTIONS[$((TZ_CHOICE - 1))]}" else warn "There is no option ${TZ_CHOICE}." fi else # Validated here rather than left to timedatectl, which fails on an # unknown name and would take the whole run down over a typo. if timezone_is_valid "$TZ_CHOICE"; then TIMEZONE="$TZ_CHOICE" else warn "Not a zone this machine knows: '${TZ_CHOICE}' — try e.g. Europe/Berlin" fi fi done elif ! timezone_is_valid "$TIMEZONE"; then fail "TIMEZONE='${TIMEZONE}' is not a zone this machine knows." fi if [[ "$TIMEZONE" == "$CURRENT_TZ" ]]; then echo " keeping ${CURRENT_TZ}, nothing to do" SUMMARY+=("Timezone: already ${CURRENT_TZ}") else echo " to set: ${TIMEZONE}" if confirm "Proceed?"; then timezone_set "$TIMEZONE" ok "Timezone set to ${TIMEZONE}" SUMMARY+=("Timezone: ${TIMEZONE}") else warn "skipped by request" SUMMARY+=("Timezone: SKIPPED by request — left at ${CURRENT_TZ:-unknown}") fi fi step_ok fi # ============================================================================= # 9. Swap # ============================================================================= # # Disk the kernel can park cold pages on when RAM fills, so a spike costs # latency instead of a process. A `bun install` or a Docker build on a small # machine is exactly the spike this is for. step "Swap" if ! skip; then ACTIVE_SWAP_GB="$(swap_active_gb)" WANT_SWAP_GB="$(swap_recommended_gb)" SWAPPINESS="$(swappiness_for_role)" CURRENT_SWAPPINESS="$(sysctl -n vm.swappiness 2>/dev/null || echo unknown)" echo "" info "Swap — overflow space so a memory spike costs speed rather than a process" echo " RAM: $(ram_gb)G" echo " active swap: ${ACTIVE_SWAP_GB}G" echo " swappiness: ${CURRENT_SWAPPINESS} -> ${SWAPPINESS} (${MACHINE_ROLE})" if [[ "$IS_WSL" == true ]]; then # WSL2 runs its own managed swap inside the VM; a swapfile here is wasted # disk and is not what the kernel would use anyway. echo " WSL manages its own swap — leaving it alone" SUMMARY+=("Swap: left to WSL") elif ((ACTIVE_SWAP_GB > 0)); then echo " already has ${ACTIVE_SWAP_GB}G of swap, leaving it alone" if [[ "$CURRENT_SWAPPINESS" != "$SWAPPINESS" ]] && confirm "Set swappiness to ${SWAPPINESS}?"; then swappiness_set "$SWAPPINESS" ok "swappiness set to ${SWAPPINESS}" SUMMARY+=("Swap: kept ${ACTIVE_SWAP_GB}G, swappiness ${SWAPPINESS}") else SUMMARY+=("Swap: kept ${ACTIVE_SWAP_GB}G") fi elif ((WANT_SWAP_GB == 0)); then # Capped to nothing by the disk check rather than by choice. warn "not enough free disk to add swap safely — $(disk_free_gb)G free" SUMMARY+=("Swap: none added, disk too full") else echo " to create: ${WANT_SWAP_GB}G at ${SWAPFILE} ($(disk_free_gb)G free now)" if confirm "Proceed?"; then swap_create "$WANT_SWAP_GB" swappiness_set "$SWAPPINESS" ok "${WANT_SWAP_GB}G swap active, swappiness ${SWAPPINESS}" SUMMARY+=("Swap: ${WANT_SWAP_GB}G created, swappiness ${SWAPPINESS}") else warn "skipped by request" SUMMARY+=("Swap: SKIPPED by request") fi fi step_ok fi # ============================================================================= # 10. Emergency disk ballast # ============================================================================= # # Always offered, whatever the role — the role only decides which way the # recommendation points. step "Emergency disk ballast" # Servers only. On a machine you sit at, the disk filling up announces itself — # the editor refuses to save, the browser complains — and you are there to deal # with it. The reserve is for the box nobody is watching, where the first sign is # a service that stopped working hours ago. Skipped rather than offered, but said # out loud, so a silent gap in the run is never left unexplained. if ! skip && ! is_server; then echo "" info "Emergency disk ballast — not offered on a ${MACHINE_ROLE} machine" echo " The reserve is for a box nobody is watching. You are sitting at" echo " this one, so a filling disk tells you itself." SUMMARY+=("Disk ballast: not applicable on ${MACHINE_ROLE}") step_ok elif ! skip; then echo "" info "Emergency disk ballast — a reserve you can burn when the disk fills up" echo " A file holding nothing, whose only job is to be deleted. A root cron" echo " checks every 10 minutes and removes it if free space drops below" echo " ${BALLAST_THRESHOLD}%, which buys you room to log in and clean up rather than" echo " meeting a wedged machine — Docker, journald and postgres all" echo " misbehave badly at 100% full, and not all of them recover on their" echo " own." echo "" echo " It is a one-shot valve: once spent, run this again to recreate it." if [[ -f "${USER_HOME}/${BALLAST_NAME}" ]]; then BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}"; fi [[ -f "${OFFICER_ROOT}/${BALLAST_NAME}" ]] && BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}" if ballast_exists; then echo "" echo " already present: $(ballast_size_human) at ${BALLAST_FILE}" SUMMARY+=("Disk ballast: already present ($(ballast_size_human))") elif ! confirm "Create one?"; then warn "skipped by request" SUMMARY+=("Disk ballast: SKIPPED by request") else # ── where ── # # A ballast only protects the filesystem it sits on, because the checker # measures its own directory. So this is also a choice of which mount is # being protected, not just where the file is tidiest. echo "" info "Where should it go?" echo " [1] ${USER_HOME}/${BALLAST_NAME}" echo " your home — easiest to find again months from now" echo " [2] ${OFFICER_ROOT}/${BALLAST_NAME}" echo " beside Officer — same place as everything else it owns" echo " [3] somewhere else, typed in" echo "" BALLAST_FILE="" while [[ -z "$BALLAST_FILE" ]]; do if ! read -rp " Which one? (1/2/3) [1]: " BALLAST_WHERE; then echo "" fail "No answer." fi case "${BALLAST_WHERE:-1}" in 1) BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}" ;; 2) BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}" ;; 3) read -rp " Full path to the file: " BALLAST_TYPED || fail "No answer." BALLAST_TYPED="${BALLAST_TYPED/#\~/$USER_HOME}" if [[ "$BALLAST_TYPED" == /* ]]; then BALLAST_FILE="$BALLAST_TYPED" else warn "That needs to be an absolute path, starting with /" fi ;; *) warn "Pick 1, 2 or 3." ;; esac done # ── how much ── # # Percentages mean nothing without the numbers behind them, and the number # that matters is what is LEFT — the point of the reserve is to be big enough # to matter and small enough not to be the thing that filled the disk. BALLAST_FREE_KB="$(ballast_free_kb "$(dirname "$BALLAST_FILE")")" echo "" info "How much? ${BALLAST_FILE} sits on a filesystem with $(human_bytes $((BALLAST_FREE_KB * 1024))) free." for i in 1 2 3; do case $i in 1) BP=5 ;; 2) BP=10 ;; 3) BP=20 ;; esac BSZ=$((BALLAST_FREE_KB * BP / 100)) printf ' [%d] %3d%% — reserves %-8s leaving %s free\n' \ "$i" "$BP" "$(human_bytes $((BSZ * 1024)))" "$(human_bytes $(((BALLAST_FREE_KB - BSZ) * 1024)))" done echo "" BALLAST_PCT="" while [[ -z "$BALLAST_PCT" ]]; do if ! read -rp " Which one? (1/2/3) [2]: " BALLAST_SIZE_CHOICE; then echo "" fail "No answer." fi case "${BALLAST_SIZE_CHOICE:-2}" in 1) BALLAST_PCT=5 ;; 2) BALLAST_PCT=10 ;; 3) BALLAST_PCT=20 ;; *) warn "Pick 1, 2 or 3." ;; esac done BALLAST_MB=$((BALLAST_FREE_KB * BALLAST_PCT / 100 / 1024)) ballast_create "$BALLAST_MB" ballast_install_checker ok "ballast $(ballast_size_human) at ${BALLAST_FILE}, checked every 10 minutes" ok "status any time: ${BALLAST_CHECKER} --status" SUMMARY+=("Disk ballast: $(ballast_size_human) at ${BALLAST_FILE} (${BALLAST_PCT}%)") fi step_ok fi # ============================================================================= # 11. earlyoom # ============================================================================= step "earlyoom" if ! skip; then echo "" info "earlyoom — keeps the machine reachable when it runs out of memory" echo " The kernel's own OOM killer waits until an allocation actually" echo " fails, and by then the machine has usually spent minutes thrashing:" echo " unresponsive, ssh refusing to connect, nothing to do but reset it." echo " earlyoom watches free memory and kills the biggest consumer while" echo " there is still enough left to stay logged in." echo "" echo " This is what happens after swap runs out, so the two go together." if earlyoom_is_active; then echo " already installed and running" SUMMARY+=("earlyoom: already running") elif confirm "Install it?"; then earlyoom_install if earlyoom_is_active; then ok "earlyoom running" SUMMARY+=("earlyoom: installed and running") else warn "earlyoom installed but not running — check: systemctl status earlyoom" SUMMARY+=("earlyoom: installed, not running") fi else warn "skipped by request" SUMMARY+=("earlyoom: SKIPPED by request") fi step_ok fi # ============================================================================= # 12. inotify watch limit # ============================================================================= step "inotify watch limit" if ! skip; then CURRENT_WATCHES="$(inotify_current_watches)" echo "" info "inotify watch limit — how many files can be watched for changes at once" echo " A single file watcher walking a project with node_modules in it can" echo " exhaust the stock limit on its own, and every watcher on the machine" echo " draws from the same pool. The failure is silent: nothing errors, the" echo " watcher just stops noticing changes. Hot reload goes quiet, a build" echo " stops rebuilding, and the reason is never on screen." echo "" echo " current: ${CURRENT_WATCHES}" echo " to set: ${INOTIFY_WATCHES}" if is_role dev; then echo " recommended on dev — editors, bun --watch and vite are all watchers" else echo " less pressing on ${MACHINE_ROLE}, but anything running bun --watch or" echo " serving a file browser is a watcher too" fi if ((CURRENT_WATCHES >= INOTIFY_WATCHES)); then echo " already at or above that, nothing to do" SUMMARY+=("inotify watches: already ${CURRENT_WATCHES}") elif confirm "Raise it?"; then inotify_raise ok "inotify watches raised to $(inotify_current_watches)" SUMMARY+=("inotify watches: raised to ${INOTIFY_WATCHES}") else warn "skipped by request" SUMMARY+=("inotify watches: SKIPPED by request — left at ${CURRENT_WATCHES}") fi step_ok fi # ============================================================================= # 13. Sleep and suspend # ============================================================================= step "Sleep and suspend" # Homelab only, and the two exclusions are for different reasons. # # dev: a laptop should sleep. Disabling it on the machine somebody carries is how # you get a hot bag and a flat battery. # # vps: not merely unnecessary but harmful. A virtual machine has no lid and no # power button, but the provider's "Shut down" control works by sending an ACPI # power button event — HandlePowerKey=ignore makes the VM ignore it, so a # graceful shutdown request does nothing and the provider hard-kills the instance # instead. systemd defaults that key to poweroff for exactly this reason. if ! skip && is_role dev; then echo "" info "Sleep and suspend — left alone on a ${MACHINE_ROLE} machine" echo " Suspending is what a machine you carry should do. Only a server has" echo " to be stopped from it." SUMMARY+=("Sleep: left alone on ${MACHINE_ROLE}") step_ok elif ! skip && is_role vps; then echo "" info "Sleep and suspend — left alone on a ${MACHINE_ROLE}" echo " A virtual machine has no lid and no power button to disable. What it" echo " does have is the provider's Shut down control, which works by sending" echo " an ACPI power button event — telling this machine to ignore that would" echo " mean graceful shutdowns silently do nothing and the instance gets" echo " hard-killed instead." SUMMARY+=("Sleep: left alone on ${MACHINE_ROLE} — would break provider shutdown") step_ok elif ! skip; then echo "" info "Sleep and suspend — stop this machine putting itself to sleep" echo " A server that suspends is a server that is off: it stops answering," echo " and with no keyboard attached there is nothing to wake it. It looks" echo " like a crash and the fix is a physical visit." echo "" echo " sleep targets: $(sleep_targets_masked && echo 'masked' || echo 'available')" echo " lid closed: $(logind_effective HandleLidSwitch || echo 'default (suspend)')" echo " idle: $(logind_effective IdleAction || echo 'default (ignore)')" echo " power button: $(logind_effective HandlePowerKey || echo 'default (power off)')" if [[ "$IS_WSL" == true ]]; then echo " WSL has no logind and cannot suspend — nothing to do" SUMMARY+=("Sleep: not applicable under WSL") elif sleep_targets_masked && logind_is_configured; then echo " already configured, nothing to do" SUMMARY+=("Sleep: already disabled") else echo "" echo " This masks the four sleep targets and tells logind to ignore a" echo " closed lid, an idle session and the power button. Note the last" echo " one: after this, physically pressing power does nothing, so a" echo " clean shutdown is 'sudo poweroff' rather than the button." if confirm "Proceed?"; then disable_sleep ok "sleep disabled, logind reloaded" SUMMARY+=("Sleep: disabled (targets masked, logind handlers ignored)") else warn "skipped by request" SUMMARY+=("Sleep: SKIPPED by request") fi fi step_ok fi # ============================================================================= # 14. Boot hang # ============================================================================= step "Boot hang" # Not on a VPS. There the network IS systemd-networkd — cloud-init and netplan # put it in charge — so the unit below is load-bearing and completes in # milliseconds. Masking it on that stack is how a box comes up with no network # and no ssh. if ! skip && is_role vps; then echo "" info "Boot hang — not applicable on a ${MACHINE_ROLE}" echo " systemd-networkd is what configures the network here, so the unit" echo " this would mask is doing real work." SUMMARY+=("Boot hang: not applicable on ${MACHINE_ROLE}") step_ok elif ! skip; then WAIT_ONLINE_TIME="$(wait_online_boot_time)" echo "" info "Boot hang — a unit that can add two minutes to every boot" echo " On a machine where NetworkManager owns the network, systemd-networkd" echo " runs nothing — but systemd-networkd-wait-online is still enabled, and" echo " waits for a link that will never come up. It gives up after its full" echo " timeout, every single boot." echo "" echo " network managed by: $(network_manager_name)" echo " that unit took: ${WAIT_ONLINE_TIME:-it did not run} on this boot" if ! wait_online_is_spurious; then echo "" echo " systemd-networkd is live here, so that unit is doing real work." echo " Masking it would be how this machine comes up with no network." SUMMARY+=("Boot hang: nothing to fix — networkd is in charge") else echo "" if is_role dev; then echo " Only worth doing if you actually feel this — if the machine sits" echo " there for a couple of minutes before the login screen. The number" echo " above is the honest answer for this boot; if it is small, there is" echo " nothing here for you." fi echo " This masks systemd-networkd-wait-online.service only. NetworkManager" echo " keeps configuring the network exactly as it does now." if confirm "Mask it?"; then mask_wait_online ok "systemd-networkd-wait-online masked" SUMMARY+=("Boot hang: wait-online masked (NetworkManager stack)") else warn "skipped by request" SUMMARY+=("Boot hang: SKIPPED by request") fi fi step_ok fi # ============================================================================= # 15. SSH access # ============================================================================= # # Keys and hardening in one section, deliberately. They were two in the original, # and being two is what let the second one lock you out of a machine the first # one had failed to put a key on. step "SSH access" if ! skip; then KEY_COUNT="$(authorized_key_count)" echo "" info "SSH access — how you get into this machine" echo " authorised keys for ${USERNAME}: ${KEY_COUNT}" echo " password login: $(sshd_effective passwordauthentication)" echo " root login: $(sshd_effective permitrootlogin)" # ── a key first ── # # The same menu whether or not there is already one, because "add another" is a # real need — a second laptop, a rebuilt machine — and the original had no way # to do it at all. What changes is whether the question can be declined: with no # key, declining means the hardening below will refuse too, and the run says so. ADD_KEY=false if ((KEY_COUNT == 0)); then echo "" warn "${USERNAME} has no authorised key. Password login cannot be turned off until it has one." ADD_KEY=true else fix_ssh_permissions echo "" ADD_KEY=false confirm "Add another authorised key for ${USERNAME}?" n && ADD_KEY=true fi if $ADD_KEY; then echo "" echo " [1] paste a public key (one line, from your own ~/.ssh/id_ed25519.pub)" echo " [2] generate a new keypair on this machine" echo " [3] leave it for now" echo "" SSH_KEY_DONE=false while [[ "$SSH_KEY_DONE" == false ]]; do if ! read -rp " Which one? (1/2/3) [1]: " SSH_KEY_CHOICE; then echo "" fail "No answer." fi case "${SSH_KEY_CHOICE:-1}" in 1) read -rp " Paste the public key: " SSH_PASTED || fail "No answer." # Trimmed: pasting from a terminal or a password manager routinely # brings leading or trailing whitespace, and ssh-keygen will not parse # a key with it attached. SSH_PASTED="${SSH_PASTED#"${SSH_PASTED%%[![:space:]]*}"}" SSH_PASTED="${SSH_PASTED%"${SSH_PASTED##*[![:space:]]}"}" add_authorized_key "$SSH_PASTED" && SSH_KEY_DONE=true ;; 2) generate_user_key "${USERNAME}@$(hostname)" ok "keypair generated and authorised" echo "" echo " The PRIVATE key is on this machine at ${USER_HOME}/.ssh/id_ed25519." echo " Copy it to the machine you connect FROM, then delete it here —" echo " a private key that lives on the server it opens is not a" echo " second factor, it is a spare copy of the lock." SSH_KEY_DONE=true ;; 3) SSH_KEY_DONE=true ;; *) warn "Pick 1, 2 or 3." ;; esac done KEY_COUNT="$(authorized_key_count)" fi # ── then hardening, and only then ── if [[ "$(sshd_effective passwordauthentication)" == "no" && "$(sshd_effective permitrootlogin)" == "no" ]]; then echo "" echo " already hardened, nothing to do" SUMMARY+=("SSH: already hardened, ${KEY_COUNT} key(s) for ${USERNAME}") elif ((KEY_COUNT == 0)); then echo "" warn "not hardening: ${USERNAME} still has no authorised key" echo " Turning off password login now would leave no way in at all. Add a" echo " key and run this again." SUMMARY+=("SSH: NOT hardened — no key for ${USERNAME}, password login left on") else echo "" info "Harden sshd?" echo " Turns off password login, keyboard-interactive login and root" echo " login. ${USERNAME} has ${KEY_COUNT} authorised key(s), so you keep a way in." echo "" echo " Written to ${SSHD_DROPIN} rather than sshd_config, and named 01- so" echo " it is read before the cloud-init drop-in that would otherwise win." echo " Checked with 'sshd -t' before anything is reloaded, and reloaded" echo " rather than restarted so this session is not the experiment." echo "" warn "Test a new ssh session before closing this one." if confirm "Proceed?"; then if harden_sshd; then ok "password login: $(sshd_effective passwordauthentication), root login: $(sshd_effective permitrootlogin)" SUMMARY+=("SSH: hardened (password and root login off, ${KEY_COUNT} key(s))") else SUMMARY+=("SSH: hardening FAILED — sshd rejected the config, nothing changed") fi else warn "skipped by request" SUMMARY+=("SSH: SKIPPED by request — password login left on") fi fi 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 # ============================================================================= # 17. Network address # ============================================================================= # # Homelab only. On a vps the provider's DHCP is authoritative and already stable, # and pinning an address there is how an instance is stranded. On dev the machine # moves between networks and a fixed address is the opposite of what is wanted. step "Network address" if ! skip && ! is_role homelab; then echo "" info "Network address — left alone on a ${MACHINE_ROLE}" if is_role vps; then echo " The provider's DHCP already hands this machine the same address," echo " and pinning one here is how an instance ends up unreachable." else echo " A machine that moves between networks wants whatever each one" echo " gives it." fi SUMMARY+=("Network address: left alone on ${MACHINE_ROLE}") step_ok elif ! skip; then NET_IFACE="$(default_iface)" NET_CIDR="$(iface_ipv4 "$NET_IFACE")" NET_GW="$(iface_gateway)" NET_ID="$(dhcp_client_identifier "$NET_IFACE")" echo "" info "Network address — keeping the same IP across reboots" echo " interface: ${NET_IFACE}" echo " address: ${NET_CIDR:-unknown} $(iface_is_dhcp "$NET_IFACE" && echo 'from DHCP' || echo 'static')" echo " gateway: ${NET_GW:-unknown}" echo " identifies as: ${NET_ID:-not on DHCP}" if [[ "$NET_ID" == "duid" ]]; then echo "" echo " That is why the address changes. Ubuntu identifies to DHCP by a" echo " DUID, while routers key their leases and reservations on the MAC." echo " The router does not recognise this machine as one it has seen, so" echo " it hands out the next free address — and a reservation pinned to" echo " the MAC is never matched." fi echo "" echo " [1] identify by MAC instead — DHCP keeps working, reservations start" echo " being honoured, nothing is pinned here" echo " [2] pin this address as static" echo " [3] leave it alone" echo "" NET_CHOICE="" while [[ -z "$NET_CHOICE" ]]; do if ! read -rp " Which one? (1/2/3) [1]: " NET_ANSWER; then echo "" fail "No answer." fi case "${NET_ANSWER:-1}" in 1 | 2 | 3) NET_CHOICE="${NET_ANSWER:-1}" ;; *) warn "Pick 1, 2 or 3." ;; esac done case "$NET_CHOICE" in 1) if dhcp_identifier_is_mac; then echo " already asked for in /etc/netplan, nothing to do" SUMMARY+=("Network address: already identifying by MAC") else echo "" echo " Writes ${NETPLAN_DHCP_ID}, merged with what is already there." echo " Takes effect at the next reboot — which is the moment the" echo " problem shows up anyway, so there is nothing to apply now and" echo " no risk to this session." echo "" echo " Then reserve ${NET_CIDR%%/*} against ${NET_IFACE}'s MAC on the router," echo " and it will keep being handed back." if confirm "Proceed?"; then set_dhcp_identifier_mac "$NET_IFACE" if netplan_check >/dev/null 2>&1; then ok "written — reboot, then set the reservation on the router" SUMMARY+=("Network address: identifying by MAC from next boot") else warn "netplan rejected the file — removing it, nothing changed" rm -f "$NETPLAN_DHCP_ID" ERRORS+=("Network address: netplan rejected the dhcp-identifier file") SUMMARY+=("Network address: FAILED, netplan rejected it") fi else warn "skipped by request" SUMMARY+=("Network address: SKIPPED by request") fi fi ;; 2) echo "" warn "This freezes the address this machine happens to hold right now." echo " ${NET_CIDR} via ${NET_GW} on ${NET_IFACE}" echo "" echo " If the router hands that address to something else later, both" echo " end up fighting for it. A reservation on the router does the same" echo " job with the router still in charge, which is option 1." echo " 'netplan apply' runs immediately and drops the network for a" echo " moment — over ssh, a wrong value here ends the session." if [[ -z "$NET_CIDR" || -z "$NET_GW" ]]; then warn "could not read the address or gateway — not writing anything" SUMMARY+=("Network address: could not detect, left alone") elif confirm "Pin it?" n; then write_static_netplan "$NET_IFACE" "$NET_CIDR" "$NET_GW" if netplan_check >/dev/null 2>&1; then netplan apply ok "static ${NET_CIDR} on ${NET_IFACE}" SUMMARY+=("Network address: static ${NET_CIDR}") else warn "netplan rejected the file — removing it, nothing changed" rm -f "$NETPLAN_STATIC" ERRORS+=("Network address: netplan rejected the static config") SUMMARY+=("Network address: FAILED, netplan rejected it") fi else warn "skipped by request" SUMMARY+=("Network address: SKIPPED by request") fi ;; 3) echo " left alone" SUMMARY+=("Network address: unchanged") ;; esac step_ok fi # ============================================================================= # 18. fail2ban # ============================================================================= # # Installed as part of core utils rather than here — it is a distro package and # nothing about it needs configuring. This step only reports what it is doing, # because a daemon that silently blocks addresses is worth knowing is running. step "fail2ban" if ! skip; then echo "" info "fail2ban — blocks addresses that keep failing to log in" if ! pkg_is_installed fail2ban; then echo " not installed — it is part of core utils, which was declined or skipped" SUMMARY+=("fail2ban: not installed") elif systemctl is-active --quiet fail2ban 2>/dev/null; then echo " running, and watching:" fail2ban-client status 2>/dev/null | awk -F: '/Jail list/ { print " " $2 }' | xargs -r echo " " echo "" echo " Ubuntu enables the sshd jail by default: five failed logins from" echo " one address within ten minutes blocks it for ten. That includes" echo " you, from wherever you are connecting." echo " Unban with: fail2ban-client set sshd unbanip
" SUMMARY+=("fail2ban: running") else warn "installed but not running" echo " start it with: systemctl enable --now fail2ban" SUMMARY+=("fail2ban: installed but not running") fi step_ok fi # ============================================================================= # 19. Unattended upgrades # ============================================================================= # # The package comes from core utils. This makes sure it is actually switched on, # and reports the one thing about it nobody notices. AUTO_UPGRADES=/etc/apt/apt.conf.d/20auto-upgrades step "Unattended upgrades" if ! skip; then echo "" info "Unattended upgrades — security updates installed on a timer, unattended" if ! pkg_is_installed unattended-upgrades; then echo " not installed — it is part of core utils, which was declined or skipped" SUMMARY+=("Unattended upgrades: not installed") else UU_ENABLED=false grep -qs '^APT::Periodic::Unattended-Upgrade "1"' "$AUTO_UPGRADES" && UU_ENABLED=true echo " package: installed" echo " enabled: $($UU_ENABLED && echo 'yes' || echo 'no')" echo " timers: $(systemctl is-enabled apt-daily.timer 2>/dev/null), $(systemctl is-enabled apt-daily-upgrade.timer 2>/dev/null)" if $UU_ENABLED; then SUMMARY+=("Unattended upgrades: enabled") else echo "" echo " The package is there but ${AUTO_UPGRADES} does not switch it on," echo " so the timers run and do nothing. Writing that file is the whole" echo " of enabling it." if confirm "Enable it?"; then cat >"$AUTO_UPGRADES" <<'EOF' // Written by machine-setup. APT::Periodic::Update-Package-Lists "1"; APT::Periodic::Unattended-Upgrade "1"; APT::Periodic::AutocleanInterval "7"; EOF ok "enabled — updates apply daily, package cache cleaned weekly" SUMMARY+=("Unattended upgrades: enabled") else warn "skipped by request" SUMMARY+=("Unattended upgrades: installed but NOT enabled") fi fi # The failure mode people do not notice for months: a kernel update is # installed but the machine keeps running the old one until it reboots, and # nothing says so except this file. echo "" if [[ -f /var/run/reboot-required ]]; then warn "a reboot is pending — updates are installed but not all of them are in use" [[ -r /var/run/reboot-required.pkgs ]] && sed 's/^/ /' /var/run/reboot-required.pkgs echo " Unattended upgrades never reboots on its own, deliberately. Until" echo " this machine restarts it keeps running the old kernel or library." SUMMARY+=("Reboot: PENDING — updates installed but not active") else echo " no reboot pending" fi fi step_ok fi # ============================================================================= # NOT PORTED YET # ============================================================================= # # Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order: # # git config · docker · zsh + prompt (incl. .tmux.conf) · tailscale · neovim · js runtimes · # dev tools · ufw · zshrc # # Each arrives as its own commit. Delete this block when the list is empty. # ============================================================================= # 15. Summary # ============================================================================= echo "" echo "" if [[ ${#ERRORS[@]} -gt 0 ]]; then echo -e "${YELLOW}╔══════════════════════════════════════════════════╗${NC}" echo -e "${YELLOW}║ Setup Complete (with warnings) ║${NC}" echo -e "${YELLOW}╚══════════════════════════════════════════════════╝${NC}" else echo -e "${GREEN}╔══════════════════════════════════════════════════╗${NC}" echo -e "${GREEN}║ Setup Complete ║${NC}" echo -e "${GREEN}╚══════════════════════════════════════════════════╝${NC}" fi echo "" echo -e "${BOLD} What was done:${NC}" for item in "${SUMMARY[@]}"; do echo -e " ${GREEN}+${NC} $item" done if [[ ${#ERRORS[@]} -gt 0 ]]; then echo "" echo -e "${BOLD} Non-critical issues:${NC}" for err in "${ERRORS[@]}"; do echo -e " ${YELLOW}!${NC} $err" done fi echo "" echo -e "${BOLD} Machine:${NC}" echo " System: $OS_NAME ($ARCH)" echo " Role: $MACHINE_ROLE" echo " User: $USERNAME" echo " Home: $USER_HOME" echo " Officer: $OFFICER_ROOT" [[ -n "${TS_IP:-}" && "$TS_IP" != "unknown" ]] && echo " Tailscale: $TS_IP" echo "" # Clean up progress file on success rm -f "$PROGRESS_FILE"