Files
platform/scripts/setup/machine-setup/machine-setup.sh
T
pastilhasandClaude Opus 5 b075f1f882 macOS is a dev machine, and machine-setup now treats it as one
Officer on a Mac is a dev helper on a laptop somebody sits at. It is never the
homelab or VPS case, so the role is not asked for there — it is `dev`, and every
section that exists to make a machine a good server is skipped.

Seventeen of twenty-six sections skip, listed once in MACOS_SKIP in lib/base.sh
with a reason each, rather than an `if macos` threaded through each section. Most
would simply fail — no systemd, no ufw, no netplan, no useradd, no
/etc/ssh/sshd_config.d — but a few would SUCCEED and be wrong, which is worse:
stopping a laptop from sleeping, or freezing the address of a machine that moves
between networks daily.

Nine run: System update, Core utils, Tailscale, Command-line tools, Git, Docker,
Neovim, JavaScript runtimes, Agent CLIs.

The blocker was root. Linux needs it for nearly everything; Homebrew REFUSES to
run as root and says so, so the whole script under sudo would have failed at the
first brew install having already taken a password. It is now required on Linux
and refused on macOS, which works precisely because the macOS path skips
everything that needed it.

Docker is checked, not installed. Docker Desktop is a GUI app that wants opening,
permissions and a running window — not a shell script's business — and colima and
lima both cost an evening the first time something does not resolve. So the step
reports whether the daemon answers and points at the download otherwise. The
group-vs-rootless choice below it is Linux only: Desktop runs containers in a VM
owned by whoever is logged in, so there is no group to join.

Added the Xcode command line tools as a macOS-only step, before anything that
builds. node-pty ships no prebuilt binary on any platform and always falls
through to node-gyp, so `bun install` cannot finish without a compiler — and it
fails deep in a dependency tree naming neither Xcode nor node-pty. `xcode-select
--install` opens a dialogue and returns immediately, so the step says to come
back rather than pretending to have waited.

Tailscale takes the cask, not install.sh — that script is a Linux package-manager
wrapper. The cask ships a usable CLI; the Mac App Store build is sandboxed and
does not.

Not run on a Mac. There isn't one here, so this is read from the code and from
what each tool documents, not observed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:29:56 +00:00

2490 lines
99 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"
ANSWERS_FILE="$SCRIPT_DIR/.setup-answers"
# --only <step> 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 <step>] [--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 ""
info "Resuming — ${DONE_COUNT} step(s) already done, and they will be skipped"
echo " To start over instead: sudo rm ${PROGRESS_FILE}"
else
echo ""
echo " This can be stopped at any point and run again later. Completed"
echo " steps are remembered and skipped, and the answers below are kept,"
echo " so coming back costs nothing."
fi
# ── root on Linux, NOT root on macOS ──
#
# The two are opposites and it is not a preference. On Linux nearly every section
# needs root — apt, systemd units, useradd, netplan, ufw. On macOS Homebrew
# REFUSES to run as root and says so; running the whole script under sudo there
# would fail at the first `brew install` having already asked for a password.
#
# It works out because the macOS path skips everything that needed root in the
# first place (see MACOS_SKIP in lib/base.sh). What is left — brew, the Xcode
# command line tools, the agent CLIs, bun — is all per-user by design.
if [[ "$OS" == "macos" ]]; then
if [[ "$EUID" -eq 0 ]]; then
fail "Do not run this with sudo on macOS — Homebrew refuses to run as root. Run it as yourself."
fi
else
if [[ "$EUID" -ne 0 ]]; then
fail "Please run as root: sudo ./machine-setup.sh"
fi
fi
# On macOS the account running the script IS the account, and there is nothing to
# create — the User account step is skipped entirely.
if [[ "$OS" == "macos" ]]; then
USERNAME="$(id -un)"
USER_HOME="$HOME"
info "Account: ${USERNAME} (you — macOS creates no accounts here)"
else
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
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/<name> 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.
# macOS's compilers, before anything that might need to build a native module.
if [[ "$OS" == "macos" ]]; then
step "Xcode command line tools"
if ! skip; then
echo ""
if xcode_clt_installed; then
ok "already installed ($(xcode-select -p))"
SUMMARY+=("Xcode CLT: already installed")
else
info "Xcode command line tools — macOS's compilers"
echo " Needed because node-pty ships no prebuilt binary and compiles"
echo " from source on every machine, so 'bun install' cannot finish"
echo " without a compiler."
echo ""
if confirm "Start the install?"; then
xcode_clt_install
warn "a macOS dialogue has opened — finish it there, then re-run this step"
echo " ./machine-setup.sh --only 'Xcode command line tools'"
SUMMARY+=("Xcode CLT: install started in a GUI dialogue — finish it, then re-run")
else
warn "skipped — 'bun install' will fail on node-pty without it"
SUMMARY+=("Xcode CLT: SKIPPED by request")
fi
fi
step_ok
fi
fi
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. 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 " offscale — just like headscale, and just like Tailscale's own"
echo " service — needs to run on a publicly reachable server of its"
echo " own. Not this machine, and not behind a home router. Work"
echo " through the whole setup there first:"
echo ""
echo " https://officer.dev/infrastructure/offscale.html#install"
echo ""
echo " Then come back here with its address and a key. Nothing below"
echo " will work until that server is up and answering."
echo ""
echo " Take as long as you need. Stop this script whenever you like"
echo " — Ctrl-C is fine — and run it again when you are ready. Every"
echo " step already done is remembered and skipped, and the answers"
echo " you have given are kept, so you will come back to this same"
echo " question and nothing before it."
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 an HTTPS proxy in front of Officer, and restrict who can reach it."
SUMMARY+=("Tailscale: NOT connected by choice — no private network, so the token is the only lock")
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
# =============================================================================
# 7. 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
# =============================================================================
# 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=<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
# =============================================================================
# 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 is now $(sysctl -n vm.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 "$(swap_active_gb)G swap active, swappiness $(sysctl -n vm.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
# Verified rather than asserted: disable_sleep returns 0 whatever happens,
# so that a failed logind restart cannot abort the remaining sections. The
# check is what turns that into an honest report.
if sleep_targets_masked && logind_is_configured; then
ok "sleep disabled, logind reloaded"
SUMMARY+=("Sleep: disabled (targets masked, logind handlers ignored)")
else
warn "sleep settings were written but are not all in force — check: systemctl status systemd-logind"
ERRORS+=("Sleep: settings written but not in force")
SUMMARY+=("Sleep: written, NOT fully in force")
fi
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 <address>"
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')"
if [[ "$OS" != "macos" ]]; then
echo " ${USERNAME}: $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')"
fi
# ── macOS: we do not install Docker, we check for it ──
#
# Docker Desktop is the only thing that works here without a fight. Lima and
# colima both technically run containers on a Mac and both cost an evening the
# first time something does not resolve, so this asks for Desktop by name
# rather than installing an alternative that will disappoint later.
#
# Not installed by the script either: it is a GUI app that wants to be opened,
# granted permissions and left running, none of which a shell script should be
# doing on somebody's laptop.
if [[ "$OS" == "macos" ]]; then
if docker_daemon_ok; then
ok "Docker Desktop is running"
SUMMARY+=("Docker: Docker Desktop running")
elif docker_is_installed; then
warn "the docker CLI is here but the daemon is not answering"
echo " Open Docker Desktop from Applications and let it finish starting."
SUMMARY+=("Docker: installed but not running — open Docker Desktop")
else
warn "Docker is not installed"
echo ""
echo " Officer needs it for Postgres and for anything the app store"
echo " installs. Get Docker Desktop:"
echo ""
echo " https://www.docker.com/products/docker-desktop/"
echo ""
echo " Open it once after installing, then run this step again:"
echo " ./machine-setup.sh --only Docker"
SUMMARY+=("Docker: NOT installed — install Docker Desktop, then re-run this step")
fi
step_ok
elif ! 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
# The group-vs-rootless choice below is Linux only: Docker Desktop runs
# containers in a VM owned by whoever is logged in, so there is no group to
# join and no rootless variant to pick.
if [[ "$OS" != "macos" ]] && 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
# =============================================================================
# 23. Neovim
# =============================================================================
step "Neovim"
if ! skip; then
NVIM_NOW="$(nvim_installed_version)"
NVIM_LATEST="$(nvim_latest_version)"
echo ""
info "Neovim — from upstream, not from the distribution"
echo " installed: ${NVIM_NOW:-not installed}"
echo " latest: ${NVIM_LATEST:-could not reach github}"
echo " Ubuntu ships a Neovim years behind upstream, and LazyVim wants a"
echo " recent one, so this takes the release tarball."
if [[ -z "$NVIM_LATEST" ]]; then
warn "cannot reach the release API — leaving Neovim alone"
SUMMARY+=("Neovim: skipped, could not reach github")
elif [[ "$NVIM_NOW" == "$NVIM_LATEST" ]]; then
echo " already on the latest release, nothing to do"
SUMMARY+=("Neovim: already ${NVIM_NOW}")
else
echo ""
if confirm "${NVIM_NOW:+Upgrade}${NVIM_NOW:-Install} Neovim ${NVIM_LATEST}?"; then
if nvim_install; then
ok "neovim $(nvim_installed_version) at /usr/local/bin/nvim"
SUMMARY+=("Neovim: $(nvim_installed_version)")
else
warn "the Neovim install did not complete"
ERRORS+=("Neovim: install failed")
SUMMARY+=("Neovim: install FAILED")
fi
else
warn "skipped by request"
SUMMARY+=("Neovim: SKIPPED by request")
fi
fi
# ── configuration ──
if command -v nvim &>/dev/null && id "$USERNAME" &>/dev/null; then
NVIM_CONFIG="${USER_HOME}/.config/nvim"
if [[ -d "$NVIM_CONFIG" ]]; then
echo ""
echo " ${USERNAME} already has a Neovim config at ${NVIM_CONFIG}"
SUMMARY+=("Neovim: existing config left alone")
else
echo ""
info "Neovim configuration for ${USERNAME}"
echo " [1] LazyVim starter — a maintained set of defaults, sensible to"
echo " build on and easy to remove"
echo " [2] a git repository of your own"
echo " [3] nothing — plain Neovim"
echo ""
NVIM_REPO=""
NVIM_PICK=""
while [[ -z "$NVIM_PICK" ]]; do
if ! read -rp " Which one? (1/2/3) [1]: " NVIM_CHOICE; then
echo ""
fail "No answer."
fi
case "${NVIM_CHOICE:-1}" in
1)
NVIM_REPO="https://github.com/LazyVim/starter"
NVIM_PICK=config
;;
2)
# No default. The original suggested its author's own private repo,
# which nobody else can clone.
read -rp " Repository URL: " NVIM_REPO || fail "No answer."
[[ -n "$NVIM_REPO" ]] && NVIM_PICK=config || warn "A repository URL is needed."
;;
3) NVIM_PICK=none ;;
*) warn "Pick 1, 2 or 3." ;;
esac
done
if [[ "$NVIM_PICK" == config ]]; then
if nvim_clone_config "$NVIM_REPO"; then
ok "config cloned to ${NVIM_CONFIG}"
SUMMARY+=("Neovim: config from ${NVIM_REPO}")
else
warn "could not clone ${NVIM_REPO}"
ERRORS+=("Neovim: cloning ${NVIM_REPO} failed")
SUMMARY+=("Neovim: config clone FAILED")
fi
else
SUMMARY+=("Neovim: no config, plain Neovim")
fi
fi
fi
step_ok
fi
# =============================================================================
# 24. JavaScript runtimes
# =============================================================================
#
# Not offered as a choice. Officer does not run without these, so asking would be
# asking whether to install Officer — which was settled by running this script.
# Deno is the exception and is asked, because nothing uses it.
step "JavaScript runtimes"
if ! skip; then
NODE_LTS="$(node_lts_major)"
NODE_NOW="$(node_installed_major)"
echo ""
info "JavaScript runtimes — required, so these are installed rather than offered"
echo " node ${NODE_NOW:+v$NODE_NOW }${NODE_NOW:+-> }LTS $(node_lts_label)"
echo " pm2 is a Node application, and officer-pty compiles node-pty"
echo " against whatever Node is installed — there is no Linux prebuild."
echo " bun $(bun_installed && bun_version || echo 'not installed')"
echo " the platform itself, and nineteen of the twenty pm2 apps."
echo " pm2 $(pm2_installed && echo installed || echo 'not installed')"
echo " supervises all of them, and the ecosystem files are written for it."
echo ""
# ── node ──
if [[ -z "$NODE_LTS" ]]; then
warn "could not reach nodejs.org to find the current LTS — leaving Node alone"
ERRORS+=("Node: could not determine the current LTS")
elif [[ "$NODE_NOW" == "$NODE_LTS" ]]; then
ok "node v${NODE_NOW} is the current LTS"
else
info " installing Node ${NODE_LTS}${NODE_NOW:+, replacing v$NODE_NOW}..."
# Checked afterwards rather than trusting the installer's exit status: a
# NodeSource run can succeed while apt keeps an older nodejs held back, and
# reporting the version we asked for instead of the one that is there is how
# a machine ends up disagreeing with its own setup log.
if install_node "$NODE_LTS" && [[ "$(node_installed_major)" == "$NODE_LTS" ]]; then
ok "node $(node -v)"
SUMMARY+=("Node: $(node -v) (current LTS)")
else
warn "the Node install did not complete"
ERRORS+=("Node: install failed")
fi
fi
# ── bun ──
if bun_installed; then
ok "bun $(bun_version) already installed"
else
info " installing bun..."
if install_bun && bun_installed; then
ok "bun $(bun_version)"
SUMMARY+=("Bun: $(bun_version)")
else
warn "the bun install did not complete"
ERRORS+=("Bun: install failed")
fi
fi
# Ensured every run, not only after an install. A machine that already had bun
# would otherwise never get the link, and the failure only shows up at the next
# reboot.
if ensure_bun_symlink; then
ok "bun symlinked to /usr/local/bin/bun — pm2 at boot has no ~/.bun on PATH"
SUMMARY+=("Bun: symlinked system-wide")
elif [[ -x /usr/local/bin/bun ]]; then
echo " /usr/local/bin/bun already points at it"
else
warn "bun is not at ${USER_HOME}/.bun/bin/bun — cannot link it system-wide"
ERRORS+=("Bun: no system-wide symlink; pm2 apps will fail at boot")
fi
# ── pm2 ──
if pm2_installed; then
ok "pm2 $(pm2 -v 2>/dev/null | tail -1) already installed"
elif command -v npm &>/dev/null; then
info " installing pm2..."
if install_pm2 && pm2_installed; then
ok "pm2 $(pm2 -v | tail -1)"
SUMMARY+=("pm2: $(pm2 -v | tail -1)")
else
warn "the pm2 install did not complete"
ERRORS+=("pm2: install failed")
fi
else
warn "no npm, so pm2 cannot be installed — Node did not install correctly"
ERRORS+=("pm2: not installed, npm missing")
fi
# ── deno, which is the one genuine choice here ──
echo ""
if deno_installed; then
echo " deno is already installed — left alone"
else
info "Deno?"
echo " Nothing in Officer uses Deno. It was in the original setup script"
echo " for one thing that no longer exists, and there is no reference to"
echo " it anywhere in the platform today."
echo " Offered because you may want it for your own work."
if confirm "Install Deno?" n; then
if install_deno && deno_installed; then
ok "deno installed to ${USER_HOME}/.deno"
SUMMARY+=("Deno: installed (not used by Officer)")
else
warn "the Deno install did not complete"
fi
else
echo " skipped"
fi
fi
step_ok
fi
# =============================================================================
# 25. Agent CLIs
# =============================================================================
#
# claude and opencode are what the chat sidecars spawn, so they are installed
# rather than offered — for the same reason node and bun are. PI is nobody's
# dependency and is asked for.
step "Agent CLIs"
if ! skip; then
echo ""
info "Agent CLIs — the programs Officer's chat actually runs"
echo " claude $(agent_version claude || echo 'not installed')"
echo " spawned by officer-agent; chat does not work without it."
echo " opencode $(agent_version opencode || echo 'not installed')"
echo " the alternative agent, run by officer-opencode."
echo ""
echo " Installed as ${USERNAME} rather than as root: Anthropic's installer"
echo " refuses to run under sudo, because everything it writes goes under"
echo " \$HOME and under sudo that is root's. They land in different places —"
echo " claude in ~/.local/bin, opencode in ~/.opencode/bin — and the"
echo " sidecars look in exactly those two."
if agent_installed claude && agent_is_npm_install claude; then
# The npm package does not auto-update; Anthropic's installer does, which is
# why the platform uses it for members. Worth offering to switch rather than
# leaving a copy that quietly goes stale.
echo ""
warn "claude here came from npm ($(agent_path claude)) and does not auto-update"
echo " Anthropic's installer puts it in ${USER_HOME}/.local/bin and keeps it"
echo " current. That is what the platform installs for members."
if confirm "Reinstall it with the official installer?"; then
if install_claude_code; then
ok "claude $(agent_version claude) from $(agent_path claude)"
echo " the npm copy is still at /usr/local/bin/claude — remove it with:"
echo " sudo npm uninstall -g @anthropic-ai/claude-code"
SUMMARY+=("Claude Code: reinstalled via the official installer")
else
warn "the Claude Code install did not complete"
ERRORS+=("Claude Code: install failed")
fi
else
echo " left as the npm install"
SUMMARY+=("Claude Code: npm install kept, does not auto-update")
fi
elif agent_installed claude; then
ok "claude $(agent_version claude) already installed"
else
info " installing Claude Code..."
if install_claude_code; then
ok "claude $(agent_version claude)"
SUMMARY+=("Claude Code: $(agent_version claude)")
else
warn "the Claude Code install did not complete"
ERRORS+=("Claude Code: install failed")
fi
fi
if agent_installed opencode; then
ok "opencode $(agent_version opencode) already installed"
else
info " installing opencode..."
if install_opencode; then
ok "opencode $(agent_version opencode)"
SUMMARY+=("opencode: $(agent_version opencode)")
else
warn "the opencode install did not complete"
ERRORS+=("opencode: install failed")
fi
fi
# ── PI, which nothing here uses ──
echo ""
if command -v pi &>/dev/null; then
echo " pi is already installed — left alone"
else
info "PI?"
echo " Another coding agent. Officer does not use it — nothing in the"
echo " platform spawns it — and it is offered because it was in the"
echo " original script and you may want it for your own work."
if confirm "Install PI?" n; then
if install_pi && command -v pi &>/dev/null; then
ok "pi installed"
SUMMARY+=("PI: installed (not used by Officer)")
else
warn "the PI install did not complete"
fi
else
echo " skipped"
fi
fi
step_ok
fi
# =============================================================================
# 26. Shell
# =============================================================================
#
# zsh, oh-my-zsh, the starship prompt, and the dotfiles that go with them. The
# .tmux.conf lives here rather than in user creation, where the original put it
# only because that is where $USER_HOME first exists.
step "Shell"
if ! skip; then
SHELL_NOW="$(user_login_shell)"
echo ""
info "Shell — what ${USERNAME} gets at every login"
echo " login shell: ${SHELL_NOW}"
echo " zsh: $(command -v zsh &>/dev/null && echo 'installed' || echo 'not installed')"
echo " oh-my-zsh: $(oh_my_zsh_installed && echo 'installed' || echo 'not installed')"
echo " starship: $(command -v starship &>/dev/null && echo 'installed' || echo 'not installed')"
# ── zsh and oh-my-zsh ──
if ! command -v zsh &>/dev/null || ! oh_my_zsh_installed; then
echo ""
echo " oh-my-zsh is a configuration framework for zsh: completions, a"
echo " plugin system, and sensible history behaviour out of the box."
if confirm "Install zsh and oh-my-zsh?"; then
command -v zsh &>/dev/null || pkg_install_now zsh
if ! oh_my_zsh_installed; then
install_oh_my_zsh
oh_my_zsh_installed && ok "oh-my-zsh installed" || warn "oh-my-zsh did not install"
fi
SUMMARY+=("Shell: zsh and oh-my-zsh installed")
else
warn "skipped by request"
SUMMARY+=("Shell: SKIPPED by request")
fi
fi
# ── the login shell, asked separately ──
#
# Having zsh on the machine and being handed it at every login are different
# decisions, and the original made the second one silently.
if command -v zsh &>/dev/null && [[ "$SHELL_NOW" != *zsh ]]; then
echo ""
echo " ${USERNAME}'s login shell is ${SHELL_NOW}. Changing it to zsh takes"
echo " effect at the next login, and does not affect this session."
if confirm "Make zsh the login shell?"; then
set_login_shell "$(command -v zsh)"
ok "login shell is now $(user_login_shell)"
SUMMARY+=("Shell: login shell set to zsh")
else
warn "left as ${SHELL_NOW}"
SUMMARY+=("Shell: login shell left as ${SHELL_NOW}")
fi
fi
# ── dotfiles, none of which overwrite ──
if id "$USERNAME" &>/dev/null; then
echo ""
info " shell configuration"
# The prompt config the platform also deploys to every member. install_config
# keeps whatever is already there if it differs.
if [[ -r "$STARSHIP_SRC" ]]; then
# && / || rather than a bare call: 2 means "kept yours", which is an
# outcome and not a failure, but is still non-zero and would end the run.
install_config "$STARSHIP_SRC" "${USER_HOME}/.config/starship.toml" "$USERNAME" && RC=0 || RC=$?
case $RC in
0) ok "starship config installed — the same one members get" ;;
1) echo " starship config already matches" ;;
esac
fi
if [[ -r "$SCRIPT_DIR/.tmux.conf" ]]; then
install_config "$SCRIPT_DIR/.tmux.conf" "${USER_HOME}/.tmux.conf" "$USERNAME" && RC=0 || RC=$?
case $RC in
0) ok "tmux config installed" ;;
1) echo " tmux config already matches" ;;
esac
fi
# Everything below writes .zshrc, and every block is marker-wrapped so a
# second run recognises its own work. The original appended all of it
# unguarded, so a re-run duplicated the lot.
ZSHRC="${USER_HOME}/.zshrc"
touch "$ZSHRC"
chown "${USERNAME}:$(user_group)" "$ZSHRC"
if command -v starship &>/dev/null; then
if append_once "$ZSHRC" starship <<'EOF'
eval "$(starship init zsh)"
EOF
then
ok "starship added to .zshrc"
fi
fi
# The agent CLIs install to two different directories, neither of which is on
# PATH by default. The sidecars find them regardless — they check the exact
# paths — but a user who cannot run `claude` in their own terminal reasonably
# concludes it was never installed.
if append_once "$ZSHRC" agent-clis <<'EOF'
export PATH="$HOME/.local/bin:$HOME/.opencode/bin:$PATH"
EOF
then
ok "~/.local/bin and ~/.opencode/bin added to PATH"
fi
if append_once "$ZSHRC" aliases <<'EOF'
alias sz="source ~/.zshrc"
EOF
then
ok "shell aliases added"
fi
SUMMARY+=("Shell: prompt and dotfiles in place")
fi
# ── the default editor ──
echo ""
info "Default editor — what opens when anything needs you to type something"
echo " git commit, crontab -e, systemctl edit, sudoedit. One preference,"
echo " which is why git's own core.editor is deliberately not set: git"
echo " falls back to \$EDITOR, so setting it here covers everything."
echo ""
mapfile -t EDITORS < <(editor_candidates)
if ((${#EDITORS[@]} == 0)); then
warn "no editor found to offer — skipping"
else
EDITOR_NOW="$(current_editor)"
echo " currently: ${EDITOR_NOW:-not set}"
for i in "${!EDITORS[@]}"; do
printf ' [%d] %s\n' "$((i + 1))" "${EDITORS[$i]}"
done
echo ""
EDITOR_PICK=""
while [[ -z "$EDITOR_PICK" ]]; do
if ! read -rp " Which one? (1-${#EDITORS[@]}) [1]: " EDITOR_CHOICE; then
echo ""
fail "No answer."
fi
EDITOR_CHOICE="${EDITOR_CHOICE:-1}"
if [[ "$EDITOR_CHOICE" =~ ^[0-9]+$ ]] && ((EDITOR_CHOICE >= 1 && EDITOR_CHOICE <= ${#EDITORS[@]})); then
EDITOR_PICK="${EDITORS[$((EDITOR_CHOICE - 1))]}"
else
warn "Pick a number from the list."
fi
done
# Written to the account's shell, and set as the system `editor` alternative
# so root and sudoedit agree with it.
if append_once "$ZSHRC" editor <<EOF
export EDITOR="${EDITOR_PICK}"
export VISUAL="${EDITOR_PICK}"
export SUDO_EDITOR="${EDITOR_PICK}"
EOF
then
ok "EDITOR, VISUAL and SUDO_EDITOR set to ${EDITOR_PICK}"
else
echo " .zshrc already has an editor block — edit it by hand to change it"
fi
set_system_editor "$EDITOR_PICK" && ok "system 'editor' alternative set to ${EDITOR_PICK}"
SUMMARY+=("Editor: ${EDITOR_PICK}")
fi
step_ok
fi
# =============================================================================
# 27. Firewall
# =============================================================================
#
# Last, deliberately. Enabling a firewall is the one step in this script that can
# cut the connection it is running over, so everything else is done and working
# before it happens.
step "Firewall"
if ! skip; then
echo ""
info "Firewall — what this machine will accept connections on"
echo " ufw: $(ufw_is_active && echo active || echo inactive)"
echo " ssh allowed: $(ufw_allows_ssh && echo yes || echo 'NO')"
echo " tailscale0: $(ufw_has_rule tailscale0 && echo allowed || echo 'not allowed')"
echo " docker rules: $(ufw_docker_rules_applied && echo applied || echo 'not applied')"
# ── ssh first, always, before anything is enabled ──
#
# The order matters more than anything else here. A firewall enabled without an
# ssh rule on a machine reached over ssh is unrecoverable without a console.
if ! ufw_allows_ssh; then
info " allowing OpenSSH before anything else"
ufw allow OpenSSH >/dev/null 2>&1
fi
# ── the tailnet ──
#
# Checked rather than assumed. Without this rule Officer is unreachable over
# the tailnet even though Tailscale is connected — the default is deny
# inbound, and the tailnet is an inbound interface like any other.
if ip link show tailscale0 &>/dev/null; then
if ufw_has_rule tailscale0; then
echo " tailnet traffic already allowed"
else
echo ""
echo " tailscale0 exists but is not allowed through. Everything Officer"
echo " serves is reached over the tailnet, so without this rule the"
echo " platform is unreachable even though Tailscale is connected."
if confirm "Allow all traffic on tailscale0?"; then
ufw allow in on tailscale0 >/dev/null 2>&1
ok "tailnet traffic allowed"
SUMMARY+=("Firewall: tailscale0 allowed")
fi
fi
fi
# ── docker ──
#
# Docker publishes ports by writing its own iptables rules, which bypass ufw
# entirely. DOCKER-USER is the hook that lets ufw have a say at all.
if command -v docker &>/dev/null && [[ -r "$SCRIPT_DIR/ufw-docker-rules.conf" ]]; then
if ufw_docker_rules_applied; then
echo " docker rules already in ${UFW_AFTER_RULES}"
else
echo ""
echo " Docker publishes container ports by writing iptables rules of its"
echo " own, underneath ufw — a published port is reachable from the"
echo " internet whatever ufw says. These rules close that, allowing only"
echo " 80 and 443 in from outside."
if confirm "Apply the Docker firewall rules?"; then
if apply_ufw_docker_rules "$SCRIPT_DIR/ufw-docker-rules.conf"; then
ok "docker rules appended, bound to $(default_iface)"
SUMMARY+=("Firewall: Docker rules applied on $(default_iface)")
else
warn "could not apply the Docker rules"
fi
fi
fi
fi
# ── enable ──
if ufw_is_active; then
ufw reload >/dev/null 2>&1
ok "firewall active and reloaded"
SUMMARY+=("Firewall: active")
else
echo ""
info "Enable the firewall?"
echo " Default: deny everything inbound, allow everything outbound."
echo " Allowed in: $(ufw_allows_ssh && echo 'OpenSSH') $(ufw_has_rule tailscale0 && echo '· all traffic on tailscale0')"
echo ""
warn "If you are connected over ssh, this is the moment it could go wrong."
echo " OpenSSH is allowed above before anything is enabled, which is what"
echo " makes this safe — but check you can open a second session before"
echo " closing this one."
if confirm "Enable it?"; then
ufw --force enable >/dev/null 2>&1
if ufw_is_active; then
ok "firewall enabled"
SUMMARY+=("Firewall: enabled")
else
warn "ufw did not come up — check: ufw status verbose"
ERRORS+=("Firewall: enable failed")
fi
else
warn "skipped by request — this machine has no firewall"
SUMMARY+=("Firewall: SKIPPED by request, not enabled")
fi
fi
echo ""
ufw status 2>/dev/null | sed 's/^/ /'
step_ok
fi
# =============================================================================
# NOT PORTED YET
# =============================================================================
#
# Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order:
#
# 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.
# =============================================================================
# 28. 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"