Files
platform/scripts/setup/machine-setup/machine-setup.sh
T
pastilhasandClaude Opus 5 fbb90c917d default the username to whoever ran sudo, and look up their real home
Three changes to the question at the top of the run.

USER_HOME is looked up rather than assumed. The original built "/home/$USERNAME",
which is only the usual answer — an account created with a different home, or one
whose home was moved, had every later step writing to a directory that was not
theirs. getent passwd knows; the /home guess remains only as the fallback for an
account that does not exist yet, where there is nothing to look up.

The default is now whoever invoked sudo. On a re-run, or on a machine that is
already somebody's, that is the answer every time, and retyping it is a chance to
typo it into creating a second account. root invoking the script directly offers
no default, since root is never the account being set up — and is refused if
typed.

The name is validated against the portable shape of a Linux account name before
anything else happens. Letting adduser refuse it later means several questions
have already been answered against a name that was never going to work.

It also no longer goes through prompt_value, which obeys any environment variable
matching the name it is filling in. USERNAME is set by some login environments,
and a variable this script silently takes as an answer should not be one that
might already be set for unrelated reasons. SETUP_USERNAME is the explicit
override.

The tmux config write moved out of the user section entirely. It was there only
because the original copied it right after adduser, where $USER_HOME first
exists. It is a dotfile and belongs with .zshrc and the starship config in the
shell section.

Verified: defaults to the sudo invoker, resolves daemon's home to /usr/sbin
rather than /home/daemon, falls back to /home for an account that does not exist,
and rejects a name with a space, a leading digit, one over 32 characters, and
root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:27:46 +00:00

874 lines
35 KiB
Bash
Executable File

#!/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"
# 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. 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
# =============================================================================
# 3. 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
# =============================================================================
# 4. 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
# =============================================================================
# 5. 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
# =============================================================================
# 6. 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
# =============================================================================
# 7. 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=<zone> 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
# =============================================================================
# 8. 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
# =============================================================================
# 9. 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
# =============================================================================
# 10. 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
# =============================================================================
# 11. 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
# =============================================================================
# 12. 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
# =============================================================================
# 13. 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
# =============================================================================
# 14. User account
# =============================================================================
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")
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
# =============================================================================
# NOT PORTED YET
# =============================================================================
#
# Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order:
#
# ssh keys · ssh hardening · dns · static ip · fail2ban · unattended-upgrades ·
# 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.
# =============================================================================
# 12. 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"