#!/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" ANSWERS_FILE="$SCRIPT_DIR/.setup-answers" # --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 ;; --reask) RE_ASK=1 shift ;; -l | --list) grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}" exit 0 ;; -h | --help) echo "usage: machine-setup.sh [--only ] [--reask] [--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. # 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" # shellcheck source=lib/dev.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. 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}" # Anything answered on a previous run, unless --reask says to ask again. [[ -n "${RE_ASK:-}" ]] && rm -f "$ANSWERS_FILE" load_answers 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 save_answers # 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. 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" if ! tailscale_is_installed; then echo " not installed on this machine" echo "" if confirm "Install Tailscale?" y tailscale_help; 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 tailscale_help || TS_CONNECT=false fi if [[ "$TS_CONNECT" == false ]]; then SUMMARY+=("Tailscale: connected, unchanged ($(tailscale_ip))") else echo "" tailscale_network_menu TS_LOGIN_SERVER="" TS_PLANE="" while [[ -z "$TS_PLANE" ]]; do if ! read -rp " Which one? (1/2/3/4/?): " TS_PLANE_CHOICE; then echo "" fail "No answer." fi case "$TS_PLANE_CHOICE" in 1 | 2) # Both end in the same place: a coordination server somebody runs. # The difference is only whether they already have one, which changes # what to say, not what to do. if [[ "$TS_PLANE_CHOICE" == "1" ]]; then echo "" echo " Install offscale first — one command, per" echo " https://officer.dev/infrastructure/offscale.html — then give" echo " this machine its address and a key." fi # No default offered. A coordination server URL is somebody's private # infrastructure, and a machine that joins the wrong one has joined a # stranger's network. read -rp " Address of your server (e.g. https://headscale.example.com): " TS_LOGIN_SERVER || fail "No answer." if [[ "$TS_LOGIN_SERVER" =~ ^https?:// ]]; then TS_PLANE="self-hosted" else warn "That needs to be a full URL, starting with https://" fi ;; 3) TS_PLANE="tailscale" ;; 4) echo "" warn "No private network — do this at your own risk." echo "" tailscale_none_warning | page echo "" if confirm "Continue with no private network?" n; then TS_PLANE="none" else echo "" tailscale_network_menu fi ;; *) warn "Pick 1, 2, 3, 4 or ?." ;; esac done if [[ "$TS_PLANE" == "none" ]]; then warn "no private network — Tailscale is installed but not connected" echo " Connect it later with: sudo tailscale up" echo " Remember ALLOW_ANY_ORIGIN=false and an HTTPS proxy in front of Officer." SUMMARY+=("Tailscale: NOT connected by choice — no private network, ALLOW_ANY_ORIGIN must be set false") TS_CONNECT=false fi if [[ "$TS_CONNECT" != false ]]; then echo "" info "How should this machine authenticate?" if [[ "$TS_PLANE" == "tailscale" ]]; then echo " Leave this blank and Tailscale prints a link to open in a" echo " browser. Signing in there creates the account if you do not" echo " have one, or adds this machine to it if you do." echo " An auth key, if you have minted one, skips the browser." else echo " An auth key enrols this machine without a browser. Leaving it" echo " blank prints a link to open against your own server instead." fi 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 # Where this node is being pointed, named explicitly in both cases — see # TS_DEFAULT_CONTROL_URL for why the default is not left implicit. if [[ "$TS_PLANE" == "self-hosted" ]]; then TS_TARGET_URL="$TS_LOGIN_SERVER" else TS_TARGET_URL="$TS_DEFAULT_CONTROL_URL" fi # A node logged in to one coordination server cannot simply be pointed at # another; it has to be logged out first. Said out loud rather than done # quietly, because it drops the tailnet for a moment. TS_URL_NOW="$(tailscale_control_url)" if [[ "$TS_STATE" == "Running" ]] && tailscale_needs_logout "$TS_URL_NOW" "$TS_TARGET_URL"; then echo "" warn "this node is on ${TS_URL_NOW}, and moving it to ${TS_TARGET_URL} means logging out first" echo " The tailnet drops while that happens. If you are connected over" echo " it right now, this session goes with it." if confirm "Log out and move it?" n; then tailscale logout || true else warn "left where it is" SUMMARY+=("Tailscale: left on ${TS_URL_NOW}") TS_CONNECT=false fi fi echo "" TS_ARGS=(up --timeout=60s --login-server "$TS_TARGET_URL") # 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 [[ "$TS_CONNECT" == false ]]; then : elif 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 fi step_ok fi # ============================================================================= # 8. 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 # ============================================================================= # 9. 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 # ============================================================================= # 10. 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 # ============================================================================= # 11. 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 # ============================================================================= # 12. 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 # ============================================================================= # 13. 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 # ============================================================================= # 14. 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 # ============================================================================= # 15. 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 # ============================================================================= # 16. 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 # ============================================================================= # 17. 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 # ============================================================================= # 18. 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 # ============================================================================= # 19. 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 # ============================================================================= # 20. 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 # ============================================================================= # 21. Git # ============================================================================= step "Git" if ! skip; then echo "" info "Git — the identity commits from this machine are made under" GIT_NAME_NOW="$(git_get user.name)" GIT_EMAIL_NOW="$(git_get user.email)" GIT_BRANCH_NOW="$(git_get init.defaultBranch)" GIT_REBASE_NOW="$(git_get pull.rebase)" GIT_DO=true if git_has_identity; then echo " name: ${GIT_NAME_NOW:-not set}" echo " email: ${GIT_EMAIL_NOW:-not set}" echo " default branch: ${GIT_BRANCH_NOW:-not set (git uses master)}" echo " git pull: ${GIT_REBASE_NOW:+rebase}${GIT_REBASE_NOW:-not set — git asks every time branches diverge}" echo "" # Default no. There is already a working identity, and the common case for # re-running this script is everything except this. confirm "Change it?" n || GIT_DO=false else echo " nothing configured yet" echo "" echo " Worth knowing: agents Officer runs commit as this account, so this" echo " is what git log will attribute their commits to as well." fi if [[ "$GIT_DO" == false ]]; then echo " left as it is" SUMMARY+=("Git: unchanged (${GIT_NAME_NOW})") else echo "" ask_required GIT_NAME "Name" "$GIT_NAME_NOW" ask_required GIT_EMAIL "Email" "$GIT_EMAIL_NOW" ask_required GIT_BRANCH "Default branch for new repositories" "${GIT_BRANCH_NOW:-master}" echo "" echo " name: ${GIT_NAME}" echo " email: ${GIT_EMAIL}" echo " default branch: ${GIT_BRANCH}" echo " git pull: rebase" echo "" echo " pull.rebase replays your local commits on top of what was fetched" echo " instead of adding a merge commit that records nothing. Without it," echo " git stops on the first divergence and refuses to pull until told" echo " which to do." echo "" echo " core.editor is deliberately not set here. Git falls back to \$EDITOR," echo " which the shell section sets — so crontab, visudo and git all follow" echo " one preference instead of two that can drift apart." if confirm "Proceed?"; then # Checked, not assumed. These run as another account and can fail for # reasons that have nothing to do with the values — which is exactly what # happened when they were run from a directory that account could not stat. GIT_OK=true git_set user.name "$GIT_NAME" || GIT_OK=false git_set user.email "$GIT_EMAIL" || GIT_OK=false git_set init.defaultBranch "$GIT_BRANCH" || GIT_OK=false git_set pull.rebase true || GIT_OK=false if $GIT_OK && [[ "$(git_get user.name)" == "$GIT_NAME" ]]; then ok "written to ${USER_HOME}/.gitconfig" SUMMARY+=("Git: ${GIT_NAME} <${GIT_EMAIL}>, default branch ${GIT_BRANCH}, pull rebases") else warn "could not write the git config for ${USERNAME}" ERRORS+=("Git: writing ${USER_HOME}/.gitconfig failed") SUMMARY+=("Git: FAILED to write") fi else warn "skipped by request" SUMMARY+=("Git: SKIPPED by request") fi fi step_ok fi # ============================================================================= # 22. Docker # ============================================================================= step "Docker" if ! skip; then echo "" info "Docker — containers, and how ${USERNAME} is allowed to talk to them" echo " engine: $(docker_is_installed && docker --version 2>/dev/null | cut -d, -f1 || echo 'not installed')" echo " daemon: $(docker_daemon_ok && echo 'reachable' || echo 'not reachable from here')" echo " ${USERNAME}: $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')" if ! docker_is_installed; then echo "" echo " to install: docker-ce, the CLI, containerd, buildx and compose," echo " from Docker's own repository" if confirm "Install it?"; then if install_docker_engine; then ok "$(docker --version 2>/dev/null | cut -d, -f1) installed" SUMMARY+=("Docker: engine installed") else warn "the Docker install did not complete" ERRORS+=("Docker: engine install failed") SUMMARY+=("Docker: install FAILED") fi else warn "skipped by request" SUMMARY+=("Docker: SKIPPED by request") fi fi if docker_is_installed; then # ── how this account reaches the daemon ── if user_in_docker_group || docker_rootless_installed; then echo "" echo " ${USERNAME} can already reach a daemon — leaving that as it is" SUMMARY+=("Docker: access unchanged") else echo "" info "How should ${USERNAME} use Docker?" echo "" echo " [1] add ${USERNAME} to the docker group (recommended)" echo " Plain 'docker' commands, and the daemon runs as root." echo " Be clear about what the group is: anyone in it can run" echo " docker run -v /:/host -it alpine chroot /host" echo " which is a root shell. The group is not 'access to Docker'," echo " it is root by a longer route." # Only true if the account really does have sudo. Saying it of one that # does not would be reassuring about the exact case where the group is a # genuine escalation. if id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx sudo; then echo " On this machine that grants nothing new — ${USERNAME} already" echo " has sudo, so it is a shorter route to something they can" echo " already reach." else warn " ${USERNAME} does NOT have sudo, so this genuinely escalates them." echo " That is the case rootless exists for, and why members get it." fi echo "" echo " [2] rootless Docker for ${USERNAME}" echo " Their own daemon, containers in their own user namespace," echo " root inside a container is nobody outside it. The same thing" echo " Officer gives members." warn " Officer's app store cannot provision containers with this." echo " It runs 'docker' with no environment of its own, so it talks" echo " to /var/run/docker.sock — not this account's socket. Making" echo " it work means putting DOCKER_HOST in the officer process's" echo " environment, which this script does not do." echo "" echo " [3] neither — use 'sudo docker'" echo " Nothing is granted. Every command needs sudo, including" echo " anything Officer would run as ${USERNAME}." echo "" DOCKER_ACCESS="" while [[ -z "$DOCKER_ACCESS" ]]; do if ! read -rp " Which one? (1/2/3) [1]: " DOCKER_CHOICE; then echo "" fail "No answer." fi case "${DOCKER_CHOICE:-1}" in 1 | 2 | 3) DOCKER_ACCESS="${DOCKER_CHOICE:-1}" ;; *) warn "Pick 1, 2 or 3." ;; esac done case "$DOCKER_ACCESS" in 1) usermod -aG docker "$USERNAME" ok "${USERNAME} added to the docker group" echo " Takes effect at their next login — group membership is read" echo " when the session starts, so the shell you are in now still" echo " does not have it." SUMMARY+=("Docker: ${USERNAME} in the docker group") ;; 2) if ! pkg_is_installed uidmap || ! pkg_is_installed dbus-user-session; then info " installing uidmap and dbus-user-session, which rootless needs" pkg_install_now uidmap dbus-user-session fi if install_docker_rootless; then ok "rootless Docker running for ${USERNAME}" echo " DOCKER_HOST=unix:///run/user/$(id -u "$USERNAME")/docker.sock" SUMMARY+=("Docker: rootless for ${USERNAME} — app store provisioning will NOT work until DOCKER_HOST is in officer's environment") else warn "the rootless setup did not complete" ERRORS+=("Docker: rootless setup failed") SUMMARY+=("Docker: rootless setup FAILED") fi ;; 3) echo " nothing granted — docker needs sudo" SUMMARY+=("Docker: no access granted, sudo required") ;; esac fi # ── the shared network ── if docker_daemon_ok; then if ensure_docker_network; then echo " network '${DOCKER_NETWORK}' present, so containers from separate" echo " compose files can reach each other by name" fi fi fi step_ok fi # ============================================================================= # NOT PORTED YET # ============================================================================= # # Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order: # # 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: # # default editor — ask for nano, vim or nvim and set EDITOR/VISUAL in the shell # config, plus the Debian `editor` alternative so root and anything reading the # system default agree with it. Has to come after Neovim is installed, or nvim # cannot honestly be offered as a choice. This is the setting core.editor was # deliberately left out in favour of. # # Each arrives as its own commit. Delete this block when the list is empty. # ============================================================================= # 23. 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"