Claude Code goes in through https://claude.ai/install.sh, matching what the platform already does for members in os-user-claude.ts and chosen there for the auto-update npm does not give. The comment at os-user-claude.ts:28 claiming setup.sh already did this was simply wrong — setup-ubuntu.sh used `npm install -g @anthropic-ai/claude-code`. Two things that installer insists on, both of which a naive port gets wrong and both of which os-user-claude.ts had already found: It REFUSES to run under sudo from a regular user's shell — it checks for uid 0 with SUDO_USER set, because everything it writes goes under $HOME and under sudo that is root's. This script runs as root, so the install has to be done AS the account. It declares #!/bin/bash and uses [[ … =~ … ]], so it must be piped to bash. On Ubuntu /bin/sh is dash and `| sh` fails. Two bugs found by running it rather than reading it: opencode does not install to ~/.local/bin. It goes to ~/.opencode/bin, which is what sidecar/opencode/index.ts:22 hardcodes. The first version looked in the wrong place, reported a working install as missing, and installed it again — the run said "did not complete" while the installer had plainly succeeded. claude on this machine came from npm, so `command -v claude` found it and the section would have left a copy that never updates. It now detects an npm install by resolving the binary into node_modules, says so, and offers to reinstall through the official installer — naming the npm copy and how to remove it rather than deleting something it did not put there. ~/.local/bin and ~/.opencode/bin are both added to the account's PATH. The sidecars do not need it — they check the exact paths — but a user who cannot run `claude` in their own terminal reasonably concludes it was never installed. PI stays optional and says outright that nothing in the platform spawns it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2240 lines
89 KiB
Bash
Executable File
2240 lines
89 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 set to ${SWAPPINESS}"
|
|
SUMMARY+=("Swap: kept ${ACTIVE_SWAP_GB}G, swappiness ${SWAPPINESS}")
|
|
else
|
|
SUMMARY+=("Swap: kept ${ACTIVE_SWAP_GB}G")
|
|
fi
|
|
elif ((WANT_SWAP_GB == 0)); then
|
|
# Capped to nothing by the disk check rather than by choice.
|
|
warn "not enough free disk to add swap safely — $(disk_free_gb)G free"
|
|
SUMMARY+=("Swap: none added, disk too full")
|
|
else
|
|
echo " to create: ${WANT_SWAP_GB}G at ${SWAPFILE} ($(disk_free_gb)G free now)"
|
|
if confirm "Proceed?"; then
|
|
swap_create "$WANT_SWAP_GB"
|
|
swappiness_set "$SWAPPINESS"
|
|
ok "${WANT_SWAP_GB}G swap active, swappiness ${SWAPPINESS}"
|
|
SUMMARY+=("Swap: ${WANT_SWAP_GB}G created, swappiness ${SWAPPINESS}")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Swap: SKIPPED by request")
|
|
fi
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 11. Emergency disk ballast
|
|
# =============================================================================
|
|
#
|
|
# Always offered, whatever the role — the role only decides which way the
|
|
# recommendation points.
|
|
|
|
step "Emergency disk ballast"
|
|
# Servers only. On a machine you sit at, the disk filling up announces itself —
|
|
# the editor refuses to save, the browser complains — and you are there to deal
|
|
# with it. The reserve is for the box nobody is watching, where the first sign is
|
|
# a service that stopped working hours ago. Skipped rather than offered, but said
|
|
# out loud, so a silent gap in the run is never left unexplained.
|
|
if ! skip && ! is_server; then
|
|
echo ""
|
|
info "Emergency disk ballast — not offered on a ${MACHINE_ROLE} machine"
|
|
echo " The reserve is for a box nobody is watching. You are sitting at"
|
|
echo " this one, so a filling disk tells you itself."
|
|
SUMMARY+=("Disk ballast: not applicable on ${MACHINE_ROLE}")
|
|
step_ok
|
|
elif ! skip; then
|
|
echo ""
|
|
info "Emergency disk ballast — a reserve you can burn when the disk fills up"
|
|
echo " A file holding nothing, whose only job is to be deleted. A root cron"
|
|
echo " checks every 10 minutes and removes it if free space drops below"
|
|
echo " ${BALLAST_THRESHOLD}%, which buys you room to log in and clean up rather than"
|
|
echo " meeting a wedged machine — Docker, journald and postgres all"
|
|
echo " misbehave badly at 100% full, and not all of them recover on their"
|
|
echo " own."
|
|
echo ""
|
|
echo " It is a one-shot valve: once spent, run this again to recreate it."
|
|
|
|
if [[ -f "${USER_HOME}/${BALLAST_NAME}" ]]; then BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}"; fi
|
|
[[ -f "${OFFICER_ROOT}/${BALLAST_NAME}" ]] && BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}"
|
|
|
|
if ballast_exists; then
|
|
echo ""
|
|
echo " already present: $(ballast_size_human) at ${BALLAST_FILE}"
|
|
SUMMARY+=("Disk ballast: already present ($(ballast_size_human))")
|
|
elif ! confirm "Create one?"; then
|
|
warn "skipped by request"
|
|
SUMMARY+=("Disk ballast: SKIPPED by request")
|
|
else
|
|
# ── where ──
|
|
#
|
|
# A ballast only protects the filesystem it sits on, because the checker
|
|
# measures its own directory. So this is also a choice of which mount is
|
|
# being protected, not just where the file is tidiest.
|
|
echo ""
|
|
info "Where should it go?"
|
|
echo " [1] ${USER_HOME}/${BALLAST_NAME}"
|
|
echo " your home — easiest to find again months from now"
|
|
echo " [2] ${OFFICER_ROOT}/${BALLAST_NAME}"
|
|
echo " beside Officer — same place as everything else it owns"
|
|
echo " [3] somewhere else, typed in"
|
|
echo ""
|
|
|
|
BALLAST_FILE=""
|
|
while [[ -z "$BALLAST_FILE" ]]; do
|
|
if ! read -rp " Which one? (1/2/3) [1]: " BALLAST_WHERE; then
|
|
echo ""
|
|
fail "No answer."
|
|
fi
|
|
case "${BALLAST_WHERE:-1}" in
|
|
1) BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}" ;;
|
|
2) BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}" ;;
|
|
3)
|
|
read -rp " Full path to the file: " BALLAST_TYPED || fail "No answer."
|
|
BALLAST_TYPED="${BALLAST_TYPED/#\~/$USER_HOME}"
|
|
if [[ "$BALLAST_TYPED" == /* ]]; then
|
|
BALLAST_FILE="$BALLAST_TYPED"
|
|
else
|
|
warn "That needs to be an absolute path, starting with /"
|
|
fi
|
|
;;
|
|
*) warn "Pick 1, 2 or 3." ;;
|
|
esac
|
|
done
|
|
|
|
# ── how much ──
|
|
#
|
|
# Percentages mean nothing without the numbers behind them, and the number
|
|
# that matters is what is LEFT — the point of the reserve is to be big enough
|
|
# to matter and small enough not to be the thing that filled the disk.
|
|
BALLAST_FREE_KB="$(ballast_free_kb "$(dirname "$BALLAST_FILE")")"
|
|
|
|
echo ""
|
|
info "How much? ${BALLAST_FILE} sits on a filesystem with $(human_bytes $((BALLAST_FREE_KB * 1024))) free."
|
|
for i in 1 2 3; do
|
|
case $i in
|
|
1) BP=5 ;;
|
|
2) BP=10 ;;
|
|
3) BP=20 ;;
|
|
esac
|
|
BSZ=$((BALLAST_FREE_KB * BP / 100))
|
|
printf ' [%d] %3d%% — reserves %-8s leaving %s free\n' \
|
|
"$i" "$BP" "$(human_bytes $((BSZ * 1024)))" "$(human_bytes $(((BALLAST_FREE_KB - BSZ) * 1024)))"
|
|
done
|
|
echo ""
|
|
|
|
BALLAST_PCT=""
|
|
while [[ -z "$BALLAST_PCT" ]]; do
|
|
if ! read -rp " Which one? (1/2/3) [2]: " BALLAST_SIZE_CHOICE; then
|
|
echo ""
|
|
fail "No answer."
|
|
fi
|
|
case "${BALLAST_SIZE_CHOICE:-2}" in
|
|
1) BALLAST_PCT=5 ;;
|
|
2) BALLAST_PCT=10 ;;
|
|
3) BALLAST_PCT=20 ;;
|
|
*) warn "Pick 1, 2 or 3." ;;
|
|
esac
|
|
done
|
|
|
|
BALLAST_MB=$((BALLAST_FREE_KB * BALLAST_PCT / 100 / 1024))
|
|
ballast_create "$BALLAST_MB"
|
|
ballast_install_checker
|
|
ok "ballast $(ballast_size_human) at ${BALLAST_FILE}, checked every 10 minutes"
|
|
ok "status any time: ${BALLAST_CHECKER} --status"
|
|
SUMMARY+=("Disk ballast: $(ballast_size_human) at ${BALLAST_FILE} (${BALLAST_PCT}%)")
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 12. earlyoom
|
|
# =============================================================================
|
|
|
|
step "earlyoom"
|
|
if ! skip; then
|
|
echo ""
|
|
info "earlyoom — keeps the machine reachable when it runs out of memory"
|
|
echo " The kernel's own OOM killer waits until an allocation actually"
|
|
echo " fails, and by then the machine has usually spent minutes thrashing:"
|
|
echo " unresponsive, ssh refusing to connect, nothing to do but reset it."
|
|
echo " earlyoom watches free memory and kills the biggest consumer while"
|
|
echo " there is still enough left to stay logged in."
|
|
echo ""
|
|
echo " This is what happens after swap runs out, so the two go together."
|
|
|
|
if earlyoom_is_active; then
|
|
echo " already installed and running"
|
|
SUMMARY+=("earlyoom: already running")
|
|
elif confirm "Install it?"; then
|
|
earlyoom_install
|
|
if earlyoom_is_active; then
|
|
ok "earlyoom running"
|
|
SUMMARY+=("earlyoom: installed and running")
|
|
else
|
|
warn "earlyoom installed but not running — check: systemctl status earlyoom"
|
|
SUMMARY+=("earlyoom: installed, not running")
|
|
fi
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("earlyoom: SKIPPED by request")
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 13. inotify watch limit
|
|
# =============================================================================
|
|
|
|
step "inotify watch limit"
|
|
if ! skip; then
|
|
CURRENT_WATCHES="$(inotify_current_watches)"
|
|
|
|
echo ""
|
|
info "inotify watch limit — how many files can be watched for changes at once"
|
|
echo " A single file watcher walking a project with node_modules in it can"
|
|
echo " exhaust the stock limit on its own, and every watcher on the machine"
|
|
echo " draws from the same pool. The failure is silent: nothing errors, the"
|
|
echo " watcher just stops noticing changes. Hot reload goes quiet, a build"
|
|
echo " stops rebuilding, and the reason is never on screen."
|
|
echo ""
|
|
echo " current: ${CURRENT_WATCHES}"
|
|
echo " to set: ${INOTIFY_WATCHES}"
|
|
if is_role dev; then
|
|
echo " recommended on dev — editors, bun --watch and vite are all watchers"
|
|
else
|
|
echo " less pressing on ${MACHINE_ROLE}, but anything running bun --watch or"
|
|
echo " serving a file browser is a watcher too"
|
|
fi
|
|
|
|
if ((CURRENT_WATCHES >= INOTIFY_WATCHES)); then
|
|
echo " already at or above that, nothing to do"
|
|
SUMMARY+=("inotify watches: already ${CURRENT_WATCHES}")
|
|
elif confirm "Raise it?"; then
|
|
inotify_raise
|
|
ok "inotify watches raised to $(inotify_current_watches)"
|
|
SUMMARY+=("inotify watches: raised to ${INOTIFY_WATCHES}")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("inotify watches: SKIPPED by request — left at ${CURRENT_WATCHES}")
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 14. Sleep and suspend
|
|
# =============================================================================
|
|
|
|
step "Sleep and suspend"
|
|
# Homelab only, and the two exclusions are for different reasons.
|
|
#
|
|
# dev: a laptop should sleep. Disabling it on the machine somebody carries is how
|
|
# you get a hot bag and a flat battery.
|
|
#
|
|
# vps: not merely unnecessary but harmful. A virtual machine has no lid and no
|
|
# power button, but the provider's "Shut down" control works by sending an ACPI
|
|
# power button event — HandlePowerKey=ignore makes the VM ignore it, so a
|
|
# graceful shutdown request does nothing and the provider hard-kills the instance
|
|
# instead. systemd defaults that key to poweroff for exactly this reason.
|
|
if ! skip && is_role dev; then
|
|
echo ""
|
|
info "Sleep and suspend — left alone on a ${MACHINE_ROLE} machine"
|
|
echo " Suspending is what a machine you carry should do. Only a server has"
|
|
echo " to be stopped from it."
|
|
SUMMARY+=("Sleep: left alone on ${MACHINE_ROLE}")
|
|
step_ok
|
|
elif ! skip && is_role vps; then
|
|
echo ""
|
|
info "Sleep and suspend — left alone on a ${MACHINE_ROLE}"
|
|
echo " A virtual machine has no lid and no power button to disable. What it"
|
|
echo " does have is the provider's Shut down control, which works by sending"
|
|
echo " an ACPI power button event — telling this machine to ignore that would"
|
|
echo " mean graceful shutdowns silently do nothing and the instance gets"
|
|
echo " hard-killed instead."
|
|
SUMMARY+=("Sleep: left alone on ${MACHINE_ROLE} — would break provider shutdown")
|
|
step_ok
|
|
elif ! skip; then
|
|
echo ""
|
|
info "Sleep and suspend — stop this machine putting itself to sleep"
|
|
echo " A server that suspends is a server that is off: it stops answering,"
|
|
echo " and with no keyboard attached there is nothing to wake it. It looks"
|
|
echo " like a crash and the fix is a physical visit."
|
|
echo ""
|
|
echo " sleep targets: $(sleep_targets_masked && echo 'masked' || echo 'available')"
|
|
echo " lid closed: $(logind_effective HandleLidSwitch || echo 'default (suspend)')"
|
|
echo " idle: $(logind_effective IdleAction || echo 'default (ignore)')"
|
|
echo " power button: $(logind_effective HandlePowerKey || echo 'default (power off)')"
|
|
|
|
if [[ "$IS_WSL" == true ]]; then
|
|
echo " WSL has no logind and cannot suspend — nothing to do"
|
|
SUMMARY+=("Sleep: not applicable under WSL")
|
|
elif sleep_targets_masked && logind_is_configured; then
|
|
echo " already configured, nothing to do"
|
|
SUMMARY+=("Sleep: already disabled")
|
|
else
|
|
echo ""
|
|
echo " This masks the four sleep targets and tells logind to ignore a"
|
|
echo " closed lid, an idle session and the power button. Note the last"
|
|
echo " one: after this, physically pressing power does nothing, so a"
|
|
echo " clean shutdown is 'sudo poweroff' rather than the button."
|
|
if confirm "Proceed?"; then
|
|
disable_sleep
|
|
ok "sleep disabled, logind reloaded"
|
|
SUMMARY+=("Sleep: disabled (targets masked, logind handlers ignored)")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Sleep: SKIPPED by request")
|
|
fi
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 15. Boot hang
|
|
# =============================================================================
|
|
|
|
step "Boot hang"
|
|
# Not on a VPS. There the network IS systemd-networkd — cloud-init and netplan
|
|
# put it in charge — so the unit below is load-bearing and completes in
|
|
# milliseconds. Masking it on that stack is how a box comes up with no network
|
|
# and no ssh.
|
|
if ! skip && is_role vps; then
|
|
echo ""
|
|
info "Boot hang — not applicable on a ${MACHINE_ROLE}"
|
|
echo " systemd-networkd is what configures the network here, so the unit"
|
|
echo " this would mask is doing real work."
|
|
SUMMARY+=("Boot hang: not applicable on ${MACHINE_ROLE}")
|
|
step_ok
|
|
elif ! skip; then
|
|
WAIT_ONLINE_TIME="$(wait_online_boot_time)"
|
|
|
|
echo ""
|
|
info "Boot hang — a unit that can add two minutes to every boot"
|
|
echo " On a machine where NetworkManager owns the network, systemd-networkd"
|
|
echo " runs nothing — but systemd-networkd-wait-online is still enabled, and"
|
|
echo " waits for a link that will never come up. It gives up after its full"
|
|
echo " timeout, every single boot."
|
|
echo ""
|
|
echo " network managed by: $(network_manager_name)"
|
|
echo " that unit took: ${WAIT_ONLINE_TIME:-it did not run} on this boot"
|
|
|
|
if ! wait_online_is_spurious; then
|
|
echo ""
|
|
echo " systemd-networkd is live here, so that unit is doing real work."
|
|
echo " Masking it would be how this machine comes up with no network."
|
|
SUMMARY+=("Boot hang: nothing to fix — networkd is in charge")
|
|
else
|
|
echo ""
|
|
if is_role dev; then
|
|
echo " Only worth doing if you actually feel this — if the machine sits"
|
|
echo " there for a couple of minutes before the login screen. The number"
|
|
echo " above is the honest answer for this boot; if it is small, there is"
|
|
echo " nothing here for you."
|
|
fi
|
|
echo " This masks systemd-networkd-wait-online.service only. NetworkManager"
|
|
echo " keeps configuring the network exactly as it does now."
|
|
if confirm "Mask it?"; then
|
|
mask_wait_online
|
|
ok "systemd-networkd-wait-online masked"
|
|
SUMMARY+=("Boot hang: wait-online masked (NetworkManager stack)")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Boot hang: SKIPPED by request")
|
|
fi
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 16. SSH access
|
|
# =============================================================================
|
|
#
|
|
# Keys and hardening in one section, deliberately. They were two in the original,
|
|
# and being two is what let the second one lock you out of a machine the first
|
|
# one had failed to put a key on.
|
|
|
|
step "SSH access"
|
|
if ! skip; then
|
|
KEY_COUNT="$(authorized_key_count)"
|
|
|
|
echo ""
|
|
info "SSH access — how you get into this machine"
|
|
echo " authorised keys for ${USERNAME}: ${KEY_COUNT}"
|
|
echo " password login: $(sshd_effective passwordauthentication)"
|
|
echo " root login: $(sshd_effective permitrootlogin)"
|
|
|
|
# ── a key first ──
|
|
#
|
|
# The same menu whether or not there is already one, because "add another" is a
|
|
# real need — a second laptop, a rebuilt machine — and the original had no way
|
|
# to do it at all. What changes is whether the question can be declined: with no
|
|
# key, declining means the hardening below will refuse too, and the run says so.
|
|
ADD_KEY=false
|
|
if ((KEY_COUNT == 0)); then
|
|
echo ""
|
|
warn "${USERNAME} has no authorised key. Password login cannot be turned off until it has one."
|
|
ADD_KEY=true
|
|
else
|
|
fix_ssh_permissions
|
|
echo ""
|
|
ADD_KEY=false
|
|
confirm "Add another authorised key for ${USERNAME}?" n && ADD_KEY=true
|
|
fi
|
|
|
|
if $ADD_KEY; then
|
|
echo ""
|
|
echo " [1] paste a public key (one line, from your own ~/.ssh/id_ed25519.pub)"
|
|
echo " [2] generate a new keypair on this machine"
|
|
echo " [3] leave it for now"
|
|
echo ""
|
|
|
|
SSH_KEY_DONE=false
|
|
while [[ "$SSH_KEY_DONE" == false ]]; do
|
|
if ! read -rp " Which one? (1/2/3) [1]: " SSH_KEY_CHOICE; then
|
|
echo ""
|
|
fail "No answer."
|
|
fi
|
|
case "${SSH_KEY_CHOICE:-1}" in
|
|
1)
|
|
read -rp " Paste the public key: " SSH_PASTED || fail "No answer."
|
|
# Trimmed: pasting from a terminal or a password manager routinely
|
|
# brings leading or trailing whitespace, and ssh-keygen will not parse
|
|
# a key with it attached.
|
|
SSH_PASTED="${SSH_PASTED#"${SSH_PASTED%%[![:space:]]*}"}"
|
|
SSH_PASTED="${SSH_PASTED%"${SSH_PASTED##*[![:space:]]}"}"
|
|
add_authorized_key "$SSH_PASTED" && SSH_KEY_DONE=true
|
|
;;
|
|
2)
|
|
generate_user_key "${USERNAME}@$(hostname)"
|
|
ok "keypair generated and authorised"
|
|
echo ""
|
|
echo " The PRIVATE key is on this machine at ${USER_HOME}/.ssh/id_ed25519."
|
|
echo " Copy it to the machine you connect FROM, then delete it here —"
|
|
echo " a private key that lives on the server it opens is not a"
|
|
echo " second factor, it is a spare copy of the lock."
|
|
SSH_KEY_DONE=true
|
|
;;
|
|
3) SSH_KEY_DONE=true ;;
|
|
*) warn "Pick 1, 2 or 3." ;;
|
|
esac
|
|
done
|
|
KEY_COUNT="$(authorized_key_count)"
|
|
fi
|
|
|
|
# ── then hardening, and only then ──
|
|
if [[ "$(sshd_effective passwordauthentication)" == "no" && "$(sshd_effective permitrootlogin)" == "no" ]]; then
|
|
echo ""
|
|
echo " already hardened, nothing to do"
|
|
SUMMARY+=("SSH: already hardened, ${KEY_COUNT} key(s) for ${USERNAME}")
|
|
elif ((KEY_COUNT == 0)); then
|
|
echo ""
|
|
warn "not hardening: ${USERNAME} still has no authorised key"
|
|
echo " Turning off password login now would leave no way in at all. Add a"
|
|
echo " key and run this again."
|
|
SUMMARY+=("SSH: NOT hardened — no key for ${USERNAME}, password login left on")
|
|
else
|
|
echo ""
|
|
info "Harden sshd?"
|
|
echo " Turns off password login, keyboard-interactive login and root"
|
|
echo " login. ${USERNAME} has ${KEY_COUNT} authorised key(s), so you keep a way in."
|
|
echo ""
|
|
echo " Written to ${SSHD_DROPIN} rather than sshd_config, and named 01- so"
|
|
echo " it is read before the cloud-init drop-in that would otherwise win."
|
|
echo " Checked with 'sshd -t' before anything is reloaded, and reloaded"
|
|
echo " rather than restarted so this session is not the experiment."
|
|
echo ""
|
|
warn "Test a new ssh session before closing this one."
|
|
if confirm "Proceed?"; then
|
|
if harden_sshd; then
|
|
ok "password login: $(sshd_effective passwordauthentication), root login: $(sshd_effective permitrootlogin)"
|
|
SUMMARY+=("SSH: hardened (password and root login off, ${KEY_COUNT} key(s))")
|
|
else
|
|
SUMMARY+=("SSH: hardening FAILED — sshd rejected the config, nothing changed")
|
|
fi
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("SSH: SKIPPED by request — password login left on")
|
|
fi
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 17. DNS
|
|
# =============================================================================
|
|
|
|
step "DNS"
|
|
if ! skip; then
|
|
CURRENT_DNS="$(dns_current_global)"
|
|
|
|
echo ""
|
|
info "DNS — which resolver answers public lookups"
|
|
echo " global resolver: ${CURRENT_DNS:-none set, using whatever DHCP gave}"
|
|
|
|
# Shown so it is clear what this step is NOT doing. Overriding these is how a
|
|
# provider's internal names, or the tailnet, quietly stop resolving.
|
|
if [[ -n "$(dns_per_link)" ]]; then
|
|
echo ""
|
|
echo " per-interface, left untouched:"
|
|
dns_per_link | while IFS=: read -r link servers; do
|
|
printf ' %-14s %s\n' "$link" "$(echo "$servers" | xargs)"
|
|
done
|
|
fi
|
|
|
|
echo ""
|
|
echo " Whoever runs this resolver sees every name this machine looks up."
|
|
echo " [1] keep what is there"
|
|
echo " [2] Cloudflare 1.1.1.1 fast, logs for 24h"
|
|
echo " [3] Quad9 9.9.9.9 blocks known-malicious domains"
|
|
echo " [4] Google 8.8.8.8 fast, ubiquitous"
|
|
echo " [5] type your own"
|
|
echo ""
|
|
|
|
DNS_PRIMARY=""
|
|
DNS_FALLBACK=""
|
|
DNS_CHOSEN=""
|
|
while [[ -z "$DNS_CHOSEN" ]]; do
|
|
if ! read -rp " Which one? (1-5) [1]: " DNS_CHOICE; then
|
|
echo ""
|
|
fail "No answer."
|
|
fi
|
|
case "${DNS_CHOICE:-1}" in
|
|
1) DNS_CHOSEN="keep" ;;
|
|
2)
|
|
DNS_CHOSEN="Cloudflare"
|
|
DNS_PRIMARY="1.1.1.1 2606:4700:4700::1111"
|
|
DNS_FALLBACK="1.0.0.1 2606:4700:4700::1001"
|
|
;;
|
|
3)
|
|
DNS_CHOSEN="Quad9"
|
|
DNS_PRIMARY="9.9.9.9 2620:fe::fe"
|
|
DNS_FALLBACK="149.112.112.112 2620:fe::9"
|
|
;;
|
|
4)
|
|
DNS_CHOSEN="Google"
|
|
DNS_PRIMARY="8.8.8.8 2001:4860:4860::8888"
|
|
DNS_FALLBACK="8.8.4.4 2001:4860:4860::8844"
|
|
;;
|
|
5)
|
|
read -rp " Primary resolver(s), space separated: " DNS_PRIMARY || fail "No answer."
|
|
read -rp " Fallback resolver(s), or blank: " DNS_FALLBACK || fail "No answer."
|
|
[[ -n "$DNS_PRIMARY" ]] && DNS_CHOSEN="custom" || warn "A primary resolver is needed."
|
|
;;
|
|
*) warn "Pick 1 to 5." ;;
|
|
esac
|
|
done
|
|
|
|
if [[ "$DNS_CHOSEN" == "keep" ]]; then
|
|
echo " keeping ${CURRENT_DNS:-the current configuration}"
|
|
SUMMARY+=("DNS: unchanged")
|
|
else
|
|
echo ""
|
|
echo " to set: ${DNS_PRIMARY}"
|
|
[[ -n "$DNS_FALLBACK" ]] && echo " fallback: ${DNS_FALLBACK}"
|
|
if confirm "Proceed?"; then
|
|
dns_set_global "$DNS_PRIMARY" "$DNS_FALLBACK"
|
|
if dns_works; then
|
|
ok "${DNS_CHOSEN} in use, resolution verified"
|
|
SUMMARY+=("DNS: ${DNS_CHOSEN} (${DNS_PRIMARY%% *})")
|
|
else
|
|
# Reported rather than swallowed. Every step after this fetches
|
|
# something, and they would all fail for a reason that has nothing to do
|
|
# with them.
|
|
warn "DNS is set but a test lookup failed — check 'resolvectl status'"
|
|
ERRORS+=("DNS: set to ${DNS_CHOSEN} but a test lookup failed")
|
|
SUMMARY+=("DNS: ${DNS_CHOSEN}, but resolution did not verify")
|
|
fi
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("DNS: SKIPPED by request")
|
|
fi
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 18. Network address
|
|
# =============================================================================
|
|
#
|
|
# Homelab only. On a vps the provider's DHCP is authoritative and already stable,
|
|
# and pinning an address there is how an instance is stranded. On dev the machine
|
|
# moves between networks and a fixed address is the opposite of what is wanted.
|
|
|
|
step "Network address"
|
|
if ! skip && ! is_role homelab; then
|
|
echo ""
|
|
info "Network address — left alone on a ${MACHINE_ROLE}"
|
|
if is_role vps; then
|
|
echo " The provider's DHCP already hands this machine the same address,"
|
|
echo " and pinning one here is how an instance ends up unreachable."
|
|
else
|
|
echo " A machine that moves between networks wants whatever each one"
|
|
echo " gives it."
|
|
fi
|
|
SUMMARY+=("Network address: left alone on ${MACHINE_ROLE}")
|
|
step_ok
|
|
elif ! skip; then
|
|
NET_IFACE="$(default_iface)"
|
|
NET_CIDR="$(iface_ipv4 "$NET_IFACE")"
|
|
NET_GW="$(iface_gateway)"
|
|
NET_ID="$(dhcp_client_identifier "$NET_IFACE")"
|
|
|
|
echo ""
|
|
info "Network address — keeping the same IP across reboots"
|
|
echo " interface: ${NET_IFACE}"
|
|
echo " address: ${NET_CIDR:-unknown} $(iface_is_dhcp "$NET_IFACE" && echo 'from DHCP' || echo 'static')"
|
|
echo " gateway: ${NET_GW:-unknown}"
|
|
echo " identifies as: ${NET_ID:-not on DHCP}"
|
|
|
|
if [[ "$NET_ID" == "duid" ]]; then
|
|
echo ""
|
|
echo " That is why the address changes. Ubuntu identifies to DHCP by a"
|
|
echo " DUID, while routers key their leases and reservations on the MAC."
|
|
echo " The router does not recognise this machine as one it has seen, so"
|
|
echo " it hands out the next free address — and a reservation pinned to"
|
|
echo " the MAC is never matched."
|
|
fi
|
|
|
|
echo ""
|
|
echo " [1] identify by MAC instead — DHCP keeps working, reservations start"
|
|
echo " being honoured, nothing is pinned here"
|
|
echo " [2] pin this address as static"
|
|
echo " [3] leave it alone"
|
|
echo ""
|
|
|
|
NET_CHOICE=""
|
|
while [[ -z "$NET_CHOICE" ]]; do
|
|
if ! read -rp " Which one? (1/2/3) [1]: " NET_ANSWER; then
|
|
echo ""
|
|
fail "No answer."
|
|
fi
|
|
case "${NET_ANSWER:-1}" in
|
|
1 | 2 | 3) NET_CHOICE="${NET_ANSWER:-1}" ;;
|
|
*) warn "Pick 1, 2 or 3." ;;
|
|
esac
|
|
done
|
|
|
|
case "$NET_CHOICE" in
|
|
1)
|
|
if dhcp_identifier_is_mac; then
|
|
echo " already asked for in /etc/netplan, nothing to do"
|
|
SUMMARY+=("Network address: already identifying by MAC")
|
|
else
|
|
echo ""
|
|
echo " Writes ${NETPLAN_DHCP_ID}, merged with what is already there."
|
|
echo " Takes effect at the next reboot — which is the moment the"
|
|
echo " problem shows up anyway, so there is nothing to apply now and"
|
|
echo " no risk to this session."
|
|
echo ""
|
|
echo " Then reserve ${NET_CIDR%%/*} against ${NET_IFACE}'s MAC on the router,"
|
|
echo " and it will keep being handed back."
|
|
if confirm "Proceed?"; then
|
|
set_dhcp_identifier_mac "$NET_IFACE"
|
|
if netplan_check >/dev/null 2>&1; then
|
|
ok "written — reboot, then set the reservation on the router"
|
|
SUMMARY+=("Network address: identifying by MAC from next boot")
|
|
else
|
|
warn "netplan rejected the file — removing it, nothing changed"
|
|
rm -f "$NETPLAN_DHCP_ID"
|
|
ERRORS+=("Network address: netplan rejected the dhcp-identifier file")
|
|
SUMMARY+=("Network address: FAILED, netplan rejected it")
|
|
fi
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Network address: SKIPPED by request")
|
|
fi
|
|
fi
|
|
;;
|
|
2)
|
|
echo ""
|
|
warn "This freezes the address this machine happens to hold right now."
|
|
echo " ${NET_CIDR} via ${NET_GW} on ${NET_IFACE}"
|
|
echo ""
|
|
echo " If the router hands that address to something else later, both"
|
|
echo " end up fighting for it. A reservation on the router does the same"
|
|
echo " job with the router still in charge, which is option 1."
|
|
echo " 'netplan apply' runs immediately and drops the network for a"
|
|
echo " moment — over ssh, a wrong value here ends the session."
|
|
if [[ -z "$NET_CIDR" || -z "$NET_GW" ]]; then
|
|
warn "could not read the address or gateway — not writing anything"
|
|
SUMMARY+=("Network address: could not detect, left alone")
|
|
elif confirm "Pin it?" n; then
|
|
write_static_netplan "$NET_IFACE" "$NET_CIDR" "$NET_GW"
|
|
if netplan_check >/dev/null 2>&1; then
|
|
netplan apply
|
|
ok "static ${NET_CIDR} on ${NET_IFACE}"
|
|
SUMMARY+=("Network address: static ${NET_CIDR}")
|
|
else
|
|
warn "netplan rejected the file — removing it, nothing changed"
|
|
rm -f "$NETPLAN_STATIC"
|
|
ERRORS+=("Network address: netplan rejected the static config")
|
|
SUMMARY+=("Network address: FAILED, netplan rejected it")
|
|
fi
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Network address: SKIPPED by request")
|
|
fi
|
|
;;
|
|
3)
|
|
echo " left alone"
|
|
SUMMARY+=("Network address: unchanged")
|
|
;;
|
|
esac
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 19. fail2ban
|
|
# =============================================================================
|
|
#
|
|
# Installed as part of core utils rather than here — it is a distro package and
|
|
# nothing about it needs configuring. This step only reports what it is doing,
|
|
# because a daemon that silently blocks addresses is worth knowing is running.
|
|
|
|
step "fail2ban"
|
|
if ! skip; then
|
|
echo ""
|
|
info "fail2ban — blocks addresses that keep failing to log in"
|
|
|
|
if ! pkg_is_installed fail2ban; then
|
|
echo " not installed — it is part of core utils, which was declined or skipped"
|
|
SUMMARY+=("fail2ban: not installed")
|
|
elif systemctl is-active --quiet fail2ban 2>/dev/null; then
|
|
echo " running, and watching:"
|
|
fail2ban-client status 2>/dev/null | awk -F: '/Jail list/ { print " " $2 }' | xargs -r echo " "
|
|
echo ""
|
|
echo " Ubuntu enables the sshd jail by default: five failed logins from"
|
|
echo " one address within ten minutes blocks it for ten. That includes"
|
|
echo " you, from wherever you are connecting."
|
|
echo " Unban with: fail2ban-client set sshd unbanip <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. 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
|
|
|
|
# Marker-wrapped, so a second run recognises its own work instead of adding
|
|
# it again. The original appended this unguarded on every pass.
|
|
if command -v starship &>/dev/null; then
|
|
ZSHRC="${USER_HOME}/.zshrc"
|
|
touch "$ZSHRC"
|
|
chown "$USERNAME:$USERNAME" "$ZSHRC"
|
|
if append_once "$ZSHRC" starship <<'EOF'
|
|
eval "$(starship init zsh)"
|
|
EOF
|
|
then
|
|
ok "starship added to .zshrc"
|
|
else
|
|
echo " starship already in .zshrc"
|
|
fi
|
|
fi
|
|
SUMMARY+=("Shell: prompt and dotfiles in place")
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 24. 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
|
|
|
|
# =============================================================================
|
|
# 25. 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
|
|
|
|
# =============================================================================
|
|
# 26. 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
|
|
|
|
# ~/.local/bin has to be on PATH for the account's own shell. The sidecars find
|
|
# claude without it — claude-manager.ts checks that directory explicitly — but
|
|
# a user who cannot run `claude` in their own terminal reasonably concludes it
|
|
# was never installed.
|
|
if id "$USERNAME" &>/dev/null; then
|
|
ZSHRC="${USER_HOME}/.zshrc"
|
|
touch "$ZSHRC"
|
|
chown "$USERNAME:$USERNAME" "$ZSHRC"
|
|
# Both directories, because the two installers disagree about where they put
|
|
# things. The sidecars find their binaries without this — 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 ${USERNAME}'s PATH"
|
|
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
|
|
|
|
# =============================================================================
|
|
# 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.
|
|
|
|
|
|
# =============================================================================
|
|
# 23. Summary
|
|
# =============================================================================
|
|
|
|
echo ""
|
|
echo ""
|
|
if [[ ${#ERRORS[@]} -gt 0 ]]; then
|
|
echo -e "${YELLOW}╔══════════════════════════════════════════════════╗${NC}"
|
|
echo -e "${YELLOW}║ Setup Complete (with warnings) ║${NC}"
|
|
echo -e "${YELLOW}╚══════════════════════════════════════════════════╝${NC}"
|
|
else
|
|
echo -e "${GREEN}╔══════════════════════════════════════════════════╗${NC}"
|
|
echo -e "${GREEN}║ Setup Complete ║${NC}"
|
|
echo -e "${GREEN}╚══════════════════════════════════════════════════╝${NC}"
|
|
fi
|
|
|
|
echo ""
|
|
echo -e "${BOLD} What was done:${NC}"
|
|
for item in "${SUMMARY[@]}"; do
|
|
echo -e " ${GREEN}+${NC} $item"
|
|
done
|
|
|
|
if [[ ${#ERRORS[@]} -gt 0 ]]; then
|
|
echo ""
|
|
echo -e "${BOLD} Non-critical issues:${NC}"
|
|
for err in "${ERRORS[@]}"; do
|
|
echo -e " ${YELLOW}!${NC} $err"
|
|
done
|
|
fi
|
|
|
|
echo ""
|
|
echo -e "${BOLD} Machine:${NC}"
|
|
echo " System: $OS_NAME ($ARCH)"
|
|
echo " Role: $MACHINE_ROLE"
|
|
echo " User: $USERNAME"
|
|
echo " Home: $USER_HOME"
|
|
echo " Officer: $OFFICER_ROOT"
|
|
[[ -n "${TS_IP:-}" && "$TS_IP" != "unknown" ]] && echo " Tailscale: $TS_IP"
|
|
echo ""
|
|
|
|
# Clean up progress file on success
|
|
rm -f "$PROGRESS_FILE"
|