Files
platform/scripts/setup/machine-setup/machine-setup.sh
T
pastilhasandClaude Opus 5 e120dfa36e full read: fix the set -e footguns a full run would have hit
Read the whole thing — 2392 lines of entry point and 2700 of libraries — looking
for what shellcheck cannot see. shellcheck itself is clean at error level; its
warnings are cross-file false positives and one deliberate tilde in a display
string. Everything below is a real defect.

── The Git section aborted on any machine where git was not already configured ──

`git config --global --get <key>` exits NON-ZERO when the key is simply unset,
and `VAR="$(git_get …)"` propagates that under `set -e`. So on a fresh machine —
the case this script exists for — the section died at its first assignment,
before printing anything, and took the remaining nine sections with it.

It passed every earlier test because those harnesses sourced the section under a
`bash -c` with no `set -e`. Verified now against a genuinely fresh account with
the real script: the section completes and writes a correct .gitconfig.

── An optional step failing aborted the whole run ──

Twelve functions ended on a command that can fail — `systemctl enable --now
earlyoom`, `systemctl restart systemd-logind`, `chsh`, `sysctl -w`, `chown -R`,
the oh-my-zsh installer, and others. Called as plain commands under `set -e`, any
one of them failing ends the script, so a masked unit or a container without
systemd would abort a 28-section run over an optional improvement.

They now return 0 explicitly and the callers verify the outcome instead — which
also fixed a lie: the sleep section printed "sleep disabled, logind reloaded"
whether or not the restart had worked. It now checks the targets and the logind
values and reports honestly.

── chown user:user assumed the primary group is named after the user ──

True on Debian and Ubuntu, which create a group per user. Not true for an account
from LDAP, or made with `useradd -g users`, or on an image with a shared group —
there `install -g <user>` fails with "invalid group" and the step aborts. Proved
it against an account whose primary group is `oddgroup`: the old form fails, the
new one gets ownership right. Eight call sites now ask `id -gn`.

── Also hardened ──

agent_path and current_editor gained `|| true` for the same reason git_get needed
it: "nothing is set" is an answer, not a failure.

Verified afterwards: shellcheck clean at error level, every section runs
standalone without aborting, and the two apparent failures in that sweep are
correct behaviour — Timezone and Git refusing an empty answer from /dev/null.

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

2402 lines
95 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
if [[ "$EUID" -ne 0 ]]; then
fail "Please run as root: sudo ./machine-setup.sh"
fi
ask_username
if id "$USERNAME" &>/dev/null; then
info "Account: ${USERNAME} (exists, home ${USER_HOME})"
else
info "Account: ${USERNAME} (will be created, home ${USER_HOME})"
fi
ask_officer_root
if [[ -d "$OFFICER_ROOT" ]]; then
info "Officer: ${OFFICER_ROOT} (exists already)"
else
info "Officer: ${OFFICER_ROOT}"
fi
save_answers
# Always, and outside any step: everything below reads this index — core utils,
# the fastfetch PPA, the Docker repo — and `step` skips a step whose name is
# already in the progress file. With the refresh inside one of those, a resumed
# run installed against whatever the index happened to say hours or days ago.
echo ""
info "Refreshing the package index..."
pkg_refresh >/dev/null
# =============================================================================
# 2. User account
# =============================================================================
# First of the acting sections, and before anything writes into a home.
#
# If the account does not exist yet, USER_HOME is a path that is not there —
# and the ballast's mkdir -p, running as root, would create it root-owned. adduser
# then finds the directory already present and does not populate or chown it. So
# the account is made before any step can put a file in its home.
step "User account"
if ! skip; then
echo ""
info "User account — the account you will actually log in and work as"
if id "$USERNAME" &>/dev/null; then
echo " ${USERNAME} already exists"
USER_EXISTED=true
else
echo " ${USERNAME} does not exist yet and will be created"
echo " adduser will ask for a password and a few details"
USER_EXISTED=false
fi
IN_SUDO=false
id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx sudo && IN_SUDO=true
echo " sudo group: $($IN_SUDO && echo 'already a member' || echo 'will be added')"
echo " passwordless: $(has_passwordless_sudo "$USERNAME" && echo 'already granted' || echo 'not granted')"
if $USER_EXISTED && $IN_SUDO; then
SUMMARY+=("User: ${USERNAME} (already set up)")
elif confirm "Proceed?"; then
if ! $USER_EXISTED; then
adduser --gecos "" "$USERNAME"
SUMMARY+=("User: ${USERNAME} created")
# Re-read it now the account is real. Until this point USER_HOME was the
# /home/<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.
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 ALLOW_ANY_ORIGIN=false and an HTTPS proxy in front of Officer."
SUMMARY+=("Tailscale: NOT connected by choice — no private network, ALLOW_ANY_ORIGIN must be set false")
TS_CONNECT=false
fi
if [[ "$TS_CONNECT" != false ]]; then
echo ""
info "How should this machine authenticate?"
if [[ "$TS_PLANE" == "tailscale" ]]; then
echo " Leave this blank and Tailscale prints a link to open in a"
echo " browser. Signing in there creates the account if you do not"
echo " have one, or adds this machine to it if you do."
echo " An auth key, if you have minted one, skips the browser."
else
echo " An auth key enrols this machine without a browser. Leaving it"
echo " blank prints a link to open against your own server instead."
fi
echo ""
read -rp " Auth key (blank for the browser flow): " TS_AUTHKEY || fail "No answer."
echo ""
info "What should this machine offer the tailnet?"
echo ""
echo " Tailscale SSH — ssh to this machine over the tailnet with no keys"
echo " at all; who may connect is decided by your tailnet's ACLs rather"
echo " than by authorized_keys. Independent of the sshd hardening later"
echo " in this run, and a useful way back in if that goes wrong."
TS_SSH=""
confirm "Enable Tailscale SSH?" n && TS_SSH="--ssh"
TS_ROUTES=""
if is_role homelab; then
echo ""
echo " Subnet router — makes this machine a door onto its LAN, so"
echo " every tailnet device can reach the printers, NAS and switches"
echo " here without each of them running Tailscale."
LAN="$(lan_cidr)"
if [[ -n "$LAN" ]] && confirm "Advertise ${LAN} to the tailnet?" n; then
TS_ROUTES="--advertise-routes=${LAN}"
fi
fi
echo ""
echo " Exit node — lets other tailnet devices send ALL their internet"
echo " traffic out through this machine, as a VPN would. Useful from a"
echo " phone on hostile wifi; it means this machine's connection carries"
echo " their traffic, and their browsing exits from this IP."
TS_EXIT=""
confirm "Advertise as an exit node?" n && TS_EXIT="--advertise-exit-node"
if [[ -n "$TS_EXIT" || -n "$TS_ROUTES" ]]; then
echo ""
info " forwarding other machines' packets needs routing enabled — writing ${TS_EXIT_SYSCTL}"
enable_ip_forwarding
info " applying Tailscale's recommended NIC offload settings (roughly doubles forwarding throughput)"
install_exit_node_tuning
fi
# Where this node is being pointed, named explicitly in both cases — see
# TS_DEFAULT_CONTROL_URL for why the default is not left implicit.
if [[ "$TS_PLANE" == "self-hosted" ]]; then
TS_TARGET_URL="$TS_LOGIN_SERVER"
else
TS_TARGET_URL="$TS_DEFAULT_CONTROL_URL"
fi
# A node logged in to one coordination server cannot simply be pointed at
# another; it has to be logged out first. Said out loud rather than done
# quietly, because it drops the tailnet for a moment.
TS_URL_NOW="$(tailscale_control_url)"
if [[ "$TS_STATE" == "Running" ]] && tailscale_needs_logout "$TS_URL_NOW" "$TS_TARGET_URL"; then
echo ""
warn "this node is on ${TS_URL_NOW}, and moving it to ${TS_TARGET_URL} means logging out first"
echo " The tailnet drops while that happens. If you are connected over"
echo " it right now, this session goes with it."
if confirm "Log out and move it?" n; then
tailscale logout || true
else
warn "left where it is"
SUMMARY+=("Tailscale: left on ${TS_URL_NOW}")
TS_CONNECT=false
fi
fi
echo ""
TS_ARGS=(up --timeout=60s --login-server "$TS_TARGET_URL")
# Never passed empty. `--authkey ""` silently falls back to the interactive
# flow and blocks forever, which is exactly how the original hung.
[[ -n "$TS_AUTHKEY" ]] && TS_ARGS+=(--authkey "$TS_AUTHKEY")
[[ -n "$TS_SSH" ]] && TS_ARGS+=("$TS_SSH")
[[ -n "$TS_ROUTES" ]] && TS_ARGS+=("$TS_ROUTES")
[[ -n "$TS_EXIT" ]] && TS_ARGS+=("$TS_EXIT")
if [[ -z "$TS_AUTHKEY" ]]; then
warn "no auth key given — a URL will be printed below, and this waits for you to open it"
fi
echo ""
if [[ "$TS_CONNECT" == false ]]; then
:
elif tailscale "${TS_ARGS[@]}"; then
TS_IP="$(tailscale_ip)"
ok "connected as ${TS_IP} on $(hostname)"
SUMMARY+=("Tailscale: ${TS_IP}${TS_SSH:+, Tailscale SSH}${TS_ROUTES:+, subnet router}${TS_EXIT:+, exit node}")
if [[ -n "$TS_EXIT" || -n "$TS_ROUTES" ]]; then
warn "an exit node or advertised route must be approved in the admin console before it carries traffic"
fi
else
# Loud rather than silent. The original had no timeout at all, so a
# failure to authenticate looked like the script having frozen.
warn "tailscale up did not complete within 60s"
echo " Run it by hand to see what it is waiting for: tailscale up"
ERRORS+=("Tailscale: up did not complete")
SUMMARY+=("Tailscale: NOT connected")
fi
fi
fi
fi
step_ok
fi
# =============================================================================
# 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')"
echo " ${USERNAME}: $(user_in_docker_group && echo 'in the docker group' || echo 'not in the docker group')"
if ! docker_is_installed; then
echo ""
echo " to install: docker-ce, the CLI, containerd, buildx and compose,"
echo " from Docker's own repository"
if confirm "Install it?"; then
if install_docker_engine; then
ok "$(docker --version 2>/dev/null | cut -d, -f1) installed"
SUMMARY+=("Docker: engine installed")
else
warn "the Docker install did not complete"
ERRORS+=("Docker: engine install failed")
SUMMARY+=("Docker: install FAILED")
fi
else
warn "skipped by request"
SUMMARY+=("Docker: SKIPPED by request")
fi
fi
if docker_is_installed; then
# ── how this account reaches the daemon ──
if user_in_docker_group || docker_rootless_installed; then
echo ""
echo " ${USERNAME} can already reach a daemon — leaving that as it is"
SUMMARY+=("Docker: access unchanged")
else
echo ""
info "How should ${USERNAME} use Docker?"
echo ""
echo " [1] add ${USERNAME} to the docker group (recommended)"
echo " Plain 'docker' commands, and the daemon runs as root."
echo " Be clear about what the group is: anyone in it can run"
echo " docker run -v /:/host -it alpine chroot /host"
echo " which is a root shell. The group is not 'access to Docker',"
echo " it is root by a longer route."
# Only true if the account really does have sudo. Saying it of one that
# does not would be reassuring about the exact case where the group is a
# genuine escalation.
if id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx sudo; then
echo " On this machine that grants nothing new — ${USERNAME} already"
echo " has sudo, so it is a shorter route to something they can"
echo " already reach."
else
warn " ${USERNAME} does NOT have sudo, so this genuinely escalates them."
echo " That is the case rootless exists for, and why members get it."
fi
echo ""
echo " [2] rootless Docker for ${USERNAME}"
echo " Their own daemon, containers in their own user namespace,"
echo " root inside a container is nobody outside it. The same thing"
echo " Officer gives members."
warn " Officer's app store cannot provision containers with this."
echo " It runs 'docker' with no environment of its own, so it talks"
echo " to /var/run/docker.sock — not this account's socket. Making"
echo " it work means putting DOCKER_HOST in the officer process's"
echo " environment, which this script does not do."
echo ""
echo " [3] neither — use 'sudo docker'"
echo " Nothing is granted. Every command needs sudo, including"
echo " anything Officer would run as ${USERNAME}."
echo ""
DOCKER_ACCESS=""
while [[ -z "$DOCKER_ACCESS" ]]; do
if ! read -rp " Which one? (1/2/3) [1]: " DOCKER_CHOICE; then
echo ""
fail "No answer."
fi
case "${DOCKER_CHOICE:-1}" in
1 | 2 | 3) DOCKER_ACCESS="${DOCKER_CHOICE:-1}" ;;
*) warn "Pick 1, 2 or 3." ;;
esac
done
case "$DOCKER_ACCESS" in
1)
usermod -aG docker "$USERNAME"
ok "${USERNAME} added to the docker group"
echo " Takes effect at their next login — group membership is read"
echo " when the session starts, so the shell you are in now still"
echo " does not have it."
SUMMARY+=("Docker: ${USERNAME} in the docker group")
;;
2)
if ! pkg_is_installed uidmap || ! pkg_is_installed dbus-user-session; then
info " installing uidmap and dbus-user-session, which rootless needs"
pkg_install_now uidmap dbus-user-session
fi
if install_docker_rootless; then
ok "rootless Docker running for ${USERNAME}"
echo " DOCKER_HOST=unix:///run/user/$(id -u "$USERNAME")/docker.sock"
SUMMARY+=("Docker: rootless for ${USERNAME} — app store provisioning will NOT work until DOCKER_HOST is in officer's environment")
else
warn "the rootless setup did not complete"
ERRORS+=("Docker: rootless setup failed")
SUMMARY+=("Docker: rootless setup FAILED")
fi
;;
3)
echo " nothing granted — docker needs sudo"
SUMMARY+=("Docker: no access granted, sudo required")
;;
esac
fi
# ── the shared network ──
if docker_daemon_ok; then
if ensure_docker_network; then
echo " network '${DOCKER_NETWORK}' present, so containers from separate"
echo " compose files can reach each other by name"
fi
fi
fi
step_ok
fi
# =============================================================================
# 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"