Two real defects fixed on the way across.
The sudoers write was in the wrong order. The original echoed the rule straight
into /etc/sudoers.d, validated it afterwards, and chmod'd it later still. A
malformed file there breaks sudo COMPLETELY — and you cannot sudo to repair it,
so on a remote machine that is a rescue console — and so does one with loose
permissions, because sudo refuses to read its own configuration. Both of those
windows were live in the original ordering. grant_passwordless_sudo now writes a
temp file, runs visudo -c against it, and only then places it with install(1),
which applies the content and the 0440 mode in one step. Nothing reaches
/etc/sudoers.d that has not already been validated.
The .tmux.conf copy overwrote whatever was in the home on every run. lib/files.sh
adds the two shapes that stop this whole class of thing:
install_config installs when absent, does nothing when identical, and keeps
what the user wrote when it differs — printing the cp to take
ours, so the choice stays theirs
append_once wraps a block in named markers so a second run recognises its
own work; also lets a human see which lines came from this
script and remove them as a unit
append_once is what the five unguarded `cat >>` into .zshrc need when those
sections are ported — a second pass currently duplicates the starship init, the
nvim PATH, bun, deno and the aliases.
Passwordless sudo is asked separately from creating the account, because it is a
security posture rather than part of making a user, and the cost is stated: a key
that can log into this account is root without a further step. Officer's actual
requirement is stated too — os-user-shell.ts runs `sudo -n`, and a prompt it
cannot answer surfaces as a permissions error rather than a question — and
refusing records that consequence in the summary instead of a bare "skipped".
Verified: all three install_config outcomes, append_once writing exactly once
across two runs, visudo rejecting junk before anything is installed, and the
section reporting correctly against this host's existing account.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
887 lines
35 KiB
Bash
Executable File
887 lines
35 KiB
Bash
Executable File
#!/bin/bash
|
|
set -e
|
|
|
|
# =============================================================================
|
|
# machine-setup — provisioning for a fresh machine
|
|
#
|
|
# Brings a blank box up to a usable state: users, SSH, networking, firewall,
|
|
# Docker, shell and editor tooling, language runtimes.
|
|
#
|
|
# Run as root: sudo scripts/setup/machine-setup/machine-setup.sh
|
|
# =============================================================================
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PROGRESS_FILE="$SCRIPT_DIR/.setup-progress"
|
|
|
|
# Shared state, output helpers, the step/resume machine and OS detection. Kept in
|
|
# lib/ so a step can eventually be read — or run — on its own without dragging the
|
|
# whole script in. Definitions only; nothing in there acts.
|
|
# shellcheck source=lib/base.sh
|
|
source "$SCRIPT_DIR/lib/base.sh"
|
|
# shellcheck source=lib/packages.sh
|
|
source "$SCRIPT_DIR/lib/packages.sh"
|
|
# shellcheck source=lib/tools.sh
|
|
source "$SCRIPT_DIR/lib/tools.sh"
|
|
# shellcheck source=lib/system.sh
|
|
source "$SCRIPT_DIR/lib/system.sh"
|
|
# shellcheck source=lib/disk.sh
|
|
source "$SCRIPT_DIR/lib/disk.sh"
|
|
# shellcheck source=lib/files.sh
|
|
source "$SCRIPT_DIR/lib/files.sh"
|
|
|
|
# Trap errors with context. Installed here rather than in lib/base.sh, because
|
|
# that file is definitions only and a trap is a side effect on whoever sources it.
|
|
trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR
|
|
|
|
# =============================================================================
|
|
# 1. Pre-flight
|
|
# =============================================================================
|
|
|
|
echo ""
|
|
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
|
|
echo -e "${BOLD}║ Machine Setup ║${NC}"
|
|
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
|
|
|
|
detect_os
|
|
echo ""
|
|
info "Machine: ${OS_NAME} (${ARCH})"
|
|
info "Packages: ${PM:-none detected}"
|
|
[[ "$IS_WSL" == true ]] && warn "WSL detected — the suspend, logind and boot-hang steps do not apply here"
|
|
|
|
# Everything below this line is written against apt and systemd. Detection above
|
|
# recognises pacman, dnf and brew so the branches have somewhere to hang, but
|
|
# nothing implements them yet — and running the apt path on Arch would half-build
|
|
# a machine and stop somewhere unhelpful. Refuse clearly instead, and relax this
|
|
# list one entry at a time as each package manager grows a real path.
|
|
case "$PM" in
|
|
apt) ;;
|
|
"") fail "Could not find a package manager for '${OS_NAME}'." ;;
|
|
*) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt-based systems only, so far." ;;
|
|
esac
|
|
|
|
ask_machine_role
|
|
info "Role: ${MACHINE_ROLE}"
|
|
|
|
if [[ -f "$PROGRESS_FILE" ]]; then
|
|
DONE_COUNT=$(wc -l < "$PROGRESS_FILE")
|
|
echo -e "${YELLOW} Resuming — $DONE_COUNT step(s) already completed${NC}"
|
|
echo -e "${YELLOW} Progress file: $PROGRESS_FILE${NC}"
|
|
echo -e "${YELLOW} To start fresh: rm $PROGRESS_FILE${NC}"
|
|
fi
|
|
|
|
if [[ "$EUID" -ne 0 ]]; then
|
|
fail "Please run as root: sudo ./machine-setup.sh"
|
|
fi
|
|
|
|
prompt_value USERNAME "New admin username (or existing)" ""
|
|
if [[ -z "$USERNAME" ]]; then
|
|
fail "Username cannot be empty"
|
|
fi
|
|
|
|
USER_HOME="/home/$USERNAME"
|
|
|
|
ask_officer_root
|
|
if [[ -d "$OFFICER_ROOT" ]]; then
|
|
info "Officer: ${OFFICER_ROOT} (exists already)"
|
|
else
|
|
info "Officer: ${OFFICER_ROOT}"
|
|
fi
|
|
|
|
# Always, and outside any step: everything below reads this index — core utils,
|
|
# the fastfetch PPA, the Docker repo — and `step` skips a step whose name is
|
|
# already in the progress file. With the refresh inside one of those, a resumed
|
|
# run installed against whatever the index happened to say hours or days ago.
|
|
echo ""
|
|
info "Refreshing the package index..."
|
|
pkg_refresh >/dev/null
|
|
|
|
# =============================================================================
|
|
# 2. Disk space
|
|
# =============================================================================
|
|
#
|
|
# First of the sections that change anything, because everything after it sizes
|
|
# itself from free disk — the swapfile and the ballast both.
|
|
|
|
step "Disk space"
|
|
if ! skip; then
|
|
ROOT_DEV="$(root_device)"
|
|
ROOT_DISK="$(parent_disk "$ROOT_DEV")"
|
|
ROOT_PART="$(backing_partition "$ROOT_DEV" || true)"
|
|
|
|
DISK_B="$(dev_bytes "$ROOT_DISK")"
|
|
CONT_B="$(dev_bytes "$ROOT_DEV")"
|
|
FS_B="$(fs_bytes "$ROOT_DEV")"
|
|
VG_FREE_B="$(vg_free_bytes)"
|
|
|
|
# A filesystem is always a little smaller than the thing holding it —
|
|
# metadata, journal, reserved blocks. Only a real gap is worth reporting.
|
|
SLACK=$((1024 * 1024 * 1024))
|
|
|
|
echo ""
|
|
info "Disk space — whether the root filesystem is actually using the whole drive"
|
|
echo " Ubuntu's installer, left on its defaults, gives the root volume a"
|
|
echo " fixed size and leaves the rest of the drive unallocated. On a 2TB"
|
|
echo " disk that is a 100G root and no sign anything is wrong: lsblk shows"
|
|
echo " the whole drive, df shows 100G, and the two are never seen together"
|
|
echo " until the day it fills up. Growing a virtual disk at the provider"
|
|
echo " leaves the same shape."
|
|
echo ""
|
|
echo " drive: $(human_bytes "$DISK_B") ${ROOT_DISK}"
|
|
echo " volume: $(human_bytes "$CONT_B") ${ROOT_DEV}"
|
|
echo " filesystem: $(human_bytes "$FS_B") $(root_fstype), mounted at /"
|
|
((VG_FREE_B > SLACK)) && echo " unused in LVM: $(human_bytes "$VG_FREE_B")"
|
|
|
|
# growpart is the authority on whether a partition can move, but installing a
|
|
# package just to ask is too eager — the arithmetic decides whether it is even
|
|
# worth looking.
|
|
MIGHT_GROW=false
|
|
((DISK_B - CONT_B > SLACK)) && MIGHT_GROW=true
|
|
[[ -n "$ROOT_PART" ]] && $MIGHT_GROW && ensure_growpart
|
|
|
|
if ((VG_FREE_B > SLACK)); then
|
|
echo ""
|
|
echo " $(human_bytes "$VG_FREE_B") is sitting unallocated in the volume group"
|
|
echo " the fix is lvextend, then growing the filesystem into it — both online"
|
|
if confirm "Use it?"; then
|
|
grow_lv && grow_fs
|
|
ok "root filesystem is now $(human_bytes "$(fs_bytes "$ROOT_DEV")")"
|
|
SUMMARY+=("Disk: reclaimed $(human_bytes "$VG_FREE_B") from the volume group")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Disk: SKIPPED — $(human_bytes "$VG_FREE_B") left unallocated")
|
|
fi
|
|
|
|
elif [[ -n "$ROOT_PART" ]] && partition_can_grow "$ROOT_PART"; then
|
|
echo ""
|
|
echo " the partition stops short of the end of the drive"
|
|
echo " this is the one step that edits the partition table — it only ever"
|
|
echo " moves the end of ${ROOT_PART} outwards, and never touches another one"
|
|
if confirm "Extend it?"; then
|
|
grow_partition "$ROOT_PART"
|
|
if root_is_lvm; then
|
|
grow_pv "$ROOT_PART"
|
|
grow_lv
|
|
fi
|
|
grow_fs
|
|
ok "root filesystem is now $(human_bytes "$(fs_bytes "$ROOT_DEV")")"
|
|
SUMMARY+=("Disk: partition extended, filesystem now $(human_bytes "$(fs_bytes "$ROOT_DEV")")")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Disk: SKIPPED — partition left short of the drive")
|
|
fi
|
|
|
|
elif ((FS_B > 0 && CONT_B - FS_B > SLACK)); then
|
|
echo ""
|
|
echo " the filesystem is smaller than the volume holding it"
|
|
if confirm "Grow it?"; then
|
|
grow_fs
|
|
ok "root filesystem is now $(human_bytes "$(fs_bytes "$ROOT_DEV")")"
|
|
SUMMARY+=("Disk: filesystem grown to $(human_bytes "$(fs_bytes "$ROOT_DEV")")")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Disk: SKIPPED — filesystem left short of its volume")
|
|
fi
|
|
|
|
else
|
|
echo ""
|
|
echo " the whole drive is in use, nothing to reclaim"
|
|
SUMMARY+=("Disk: already using the whole drive")
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 3. System update
|
|
# =============================================================================
|
|
#
|
|
# Its own section because it is the only thing in the script that moves versions
|
|
# of software already on the machine. Everything else only ever adds what is
|
|
# absent, so this is the one that deserves to be refused on its own.
|
|
#
|
|
# The index refresh is NOT here — it runs in pre-flight, unconditionally, because
|
|
# every later step reads it and this one can be skipped.
|
|
|
|
step "System update"
|
|
if ! skip; then
|
|
mapfile -t UPGRADABLE < <(pkg_upgradable)
|
|
|
|
echo ""
|
|
info "System update — upgrades installed packages to their latest versions"
|
|
|
|
if ((${#UPGRADABLE[@]} == 0)); then
|
|
echo " to upgrade: nothing, everything is current"
|
|
SUMMARY+=("System update: already up to date")
|
|
else
|
|
echo " to upgrade: ${#UPGRADABLE[@]} package(s)"
|
|
# Capped, because a box that has not been touched in months lists hundreds
|
|
# and a wall of names is no more informative than a count.
|
|
printf ' %s\n' "${UPGRADABLE[@]:0:25}"
|
|
((${#UPGRADABLE[@]} > 25)) && echo " … and $((${#UPGRADABLE[@]} - 25)) more"
|
|
|
|
if confirm "Proceed?"; then
|
|
pkg_upgrade_all
|
|
ok "System upgraded"
|
|
SUMMARY+=("System upgraded: ${#UPGRADABLE[@]} package(s)")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("System update: SKIPPED by request — ${#UPGRADABLE[@]} package(s) left as they are")
|
|
fi
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 4. Core utils
|
|
# =============================================================================
|
|
#
|
|
# What the distribution provides: the six this script would break without, and
|
|
# the command-line tools that make a machine worth sitting at.
|
|
|
|
step "Core utils"
|
|
if ! skip; then
|
|
# shellcheck disable=SC2046 # word splitting is how the list is passed
|
|
pkg_install "Core utils" $(pkgs_core)
|
|
summarise_last "Core utils"
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 5. Command-line tools
|
|
# =============================================================================
|
|
#
|
|
# A different thing from core utils, and kept apart from them: upstream binaries
|
|
# fetched from upstream, on their own release cadence, none of which the
|
|
# distribution ships. Lumping them in made a run look like it was installing
|
|
# system packages and then start pulling tarballs unannounced.
|
|
|
|
step "Command-line tools"
|
|
if ! skip; then
|
|
# shellcheck disable=SC2046 # word splitting is how the list is passed
|
|
tools_install "Command-line tools" $(tools_default)
|
|
summarise_last "Command-line tools"
|
|
step_ok
|
|
fi
|
|
|
|
|
|
# =============================================================================
|
|
# 6. Locale
|
|
# =============================================================================
|
|
#
|
|
# LOCALE in the environment overrides the default.
|
|
|
|
step "Locale"
|
|
if ! skip; then
|
|
LOCALE="${LOCALE:-en_US.UTF-8}"
|
|
CURRENT_LOCALE="$(locale_current)"
|
|
|
|
echo ""
|
|
info "Locale — the system language and character encoding"
|
|
echo " current: ${CURRENT_LOCALE:-none configured}"
|
|
echo " to set: ${LOCALE}"
|
|
|
|
if [[ "$CURRENT_LOCALE" == "$LOCALE" ]] && locale_is_generated "$LOCALE"; then
|
|
echo " already set and generated, nothing to do"
|
|
SUMMARY+=("Locale: already ${LOCALE}")
|
|
else
|
|
# Say which of the two is actually wrong, since they fail differently: a
|
|
# missing LANG means the C locale, a missing generation means every login
|
|
# prints a setlocale warning.
|
|
[[ "$CURRENT_LOCALE" != "$LOCALE" ]] && echo " LANG is not set to it"
|
|
locale_is_generated "$LOCALE" || echo " the locale has not been generated on this machine"
|
|
|
|
if confirm "Proceed?"; then
|
|
locale_set "$LOCALE"
|
|
ok "Locale set to ${LOCALE}"
|
|
SUMMARY+=("Locale: ${LOCALE}")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Locale: SKIPPED by request — left at ${CURRENT_LOCALE:-unset}")
|
|
fi
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 7. Timezone
|
|
# =============================================================================
|
|
#
|
|
# TIMEZONE in the environment answers the prompt ahead of time.
|
|
|
|
step "Timezone"
|
|
if ! skip; then
|
|
CURRENT_TZ="$(timezone_current)"
|
|
|
|
echo ""
|
|
info "Timezone — what logs, timers and every printed date are relative to"
|
|
echo " current: ${CURRENT_TZ:-unknown}"
|
|
|
|
if [[ -z "${TIMEZONE:-}" ]]; then
|
|
echo ""
|
|
for i in "${!TZ_OPTIONS[@]}"; do
|
|
printf ' [%d] %s\n' "$((i + 1))" "${TZ_OPTIONS[$i]}"
|
|
done
|
|
echo ""
|
|
|
|
while [[ -z "${TIMEZONE:-}" ]]; do
|
|
if ! read -rp " Pick a number, or type a zone name — Enter keeps ${CURRENT_TZ:-the current one}: " TZ_CHOICE; then
|
|
echo ""
|
|
fail "No answer. Set TIMEZONE=<zone> to answer this ahead of time."
|
|
fi
|
|
|
|
if [[ -z "$TZ_CHOICE" ]]; then
|
|
TIMEZONE="$CURRENT_TZ"
|
|
elif [[ "$TZ_CHOICE" =~ ^[0-9]+$ ]]; then
|
|
if ((TZ_CHOICE >= 1 && TZ_CHOICE <= ${#TZ_OPTIONS[@]})); then
|
|
TIMEZONE="${TZ_OPTIONS[$((TZ_CHOICE - 1))]}"
|
|
else
|
|
warn "There is no option ${TZ_CHOICE}."
|
|
fi
|
|
else
|
|
# Validated here rather than left to timedatectl, which fails on an
|
|
# unknown name and would take the whole run down over a typo.
|
|
if timezone_is_valid "$TZ_CHOICE"; then
|
|
TIMEZONE="$TZ_CHOICE"
|
|
else
|
|
warn "Not a zone this machine knows: '${TZ_CHOICE}' — try e.g. Europe/Berlin"
|
|
fi
|
|
fi
|
|
done
|
|
elif ! timezone_is_valid "$TIMEZONE"; then
|
|
fail "TIMEZONE='${TIMEZONE}' is not a zone this machine knows."
|
|
fi
|
|
|
|
if [[ "$TIMEZONE" == "$CURRENT_TZ" ]]; then
|
|
echo " keeping ${CURRENT_TZ}, nothing to do"
|
|
SUMMARY+=("Timezone: already ${CURRENT_TZ}")
|
|
else
|
|
echo " to set: ${TIMEZONE}"
|
|
if confirm "Proceed?"; then
|
|
timezone_set "$TIMEZONE"
|
|
ok "Timezone set to ${TIMEZONE}"
|
|
SUMMARY+=("Timezone: ${TIMEZONE}")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Timezone: SKIPPED by request — left at ${CURRENT_TZ:-unknown}")
|
|
fi
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 8. Swap
|
|
# =============================================================================
|
|
#
|
|
# Disk the kernel can park cold pages on when RAM fills, so a spike costs
|
|
# latency instead of a process. A `bun install` or a Docker build on a small
|
|
# machine is exactly the spike this is for.
|
|
|
|
step "Swap"
|
|
if ! skip; then
|
|
ACTIVE_SWAP_GB="$(swap_active_gb)"
|
|
WANT_SWAP_GB="$(swap_recommended_gb)"
|
|
SWAPPINESS="$(swappiness_for_role)"
|
|
CURRENT_SWAPPINESS="$(sysctl -n vm.swappiness 2>/dev/null || echo unknown)"
|
|
|
|
echo ""
|
|
info "Swap — overflow space so a memory spike costs speed rather than a process"
|
|
echo " RAM: $(ram_gb)G"
|
|
echo " active swap: ${ACTIVE_SWAP_GB}G"
|
|
echo " swappiness: ${CURRENT_SWAPPINESS} -> ${SWAPPINESS} (${MACHINE_ROLE})"
|
|
|
|
if [[ "$IS_WSL" == true ]]; then
|
|
# WSL2 runs its own managed swap inside the VM; a swapfile here is wasted
|
|
# disk and is not what the kernel would use anyway.
|
|
echo " WSL manages its own swap — leaving it alone"
|
|
SUMMARY+=("Swap: left to WSL")
|
|
elif ((ACTIVE_SWAP_GB > 0)); then
|
|
echo " already has ${ACTIVE_SWAP_GB}G of swap, leaving it alone"
|
|
if [[ "$CURRENT_SWAPPINESS" != "$SWAPPINESS" ]] && confirm "Set swappiness to ${SWAPPINESS}?"; then
|
|
swappiness_set "$SWAPPINESS"
|
|
ok "swappiness set to ${SWAPPINESS}"
|
|
SUMMARY+=("Swap: kept ${ACTIVE_SWAP_GB}G, swappiness ${SWAPPINESS}")
|
|
else
|
|
SUMMARY+=("Swap: kept ${ACTIVE_SWAP_GB}G")
|
|
fi
|
|
elif ((WANT_SWAP_GB == 0)); then
|
|
# Capped to nothing by the disk check rather than by choice.
|
|
warn "not enough free disk to add swap safely — $(disk_free_gb)G free"
|
|
SUMMARY+=("Swap: none added, disk too full")
|
|
else
|
|
echo " to create: ${WANT_SWAP_GB}G at ${SWAPFILE} ($(disk_free_gb)G free now)"
|
|
if confirm "Proceed?"; then
|
|
swap_create "$WANT_SWAP_GB"
|
|
swappiness_set "$SWAPPINESS"
|
|
ok "${WANT_SWAP_GB}G swap active, swappiness ${SWAPPINESS}"
|
|
SUMMARY+=("Swap: ${WANT_SWAP_GB}G created, swappiness ${SWAPPINESS}")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Swap: SKIPPED by request")
|
|
fi
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 9. Emergency disk ballast
|
|
# =============================================================================
|
|
#
|
|
# Always offered, whatever the role — the role only decides which way the
|
|
# recommendation points.
|
|
|
|
step "Emergency disk ballast"
|
|
# Servers only. On a machine you sit at, the disk filling up announces itself —
|
|
# the editor refuses to save, the browser complains — and you are there to deal
|
|
# with it. The reserve is for the box nobody is watching, where the first sign is
|
|
# a service that stopped working hours ago. Skipped rather than offered, but said
|
|
# out loud, so a silent gap in the run is never left unexplained.
|
|
if ! skip && ! is_server; then
|
|
echo ""
|
|
info "Emergency disk ballast — not offered on a ${MACHINE_ROLE} machine"
|
|
echo " The reserve is for a box nobody is watching. You are sitting at"
|
|
echo " this one, so a filling disk tells you itself."
|
|
SUMMARY+=("Disk ballast: not applicable on ${MACHINE_ROLE}")
|
|
step_ok
|
|
elif ! skip; then
|
|
echo ""
|
|
info "Emergency disk ballast — a reserve you can burn when the disk fills up"
|
|
echo " A file holding nothing, whose only job is to be deleted. A root cron"
|
|
echo " checks every 10 minutes and removes it if free space drops below"
|
|
echo " ${BALLAST_THRESHOLD}%, which buys you room to log in and clean up rather than"
|
|
echo " meeting a wedged machine — Docker, journald and postgres all"
|
|
echo " misbehave badly at 100% full, and not all of them recover on their"
|
|
echo " own."
|
|
echo ""
|
|
echo " It is a one-shot valve: once spent, run this again to recreate it."
|
|
|
|
if [[ -f "${USER_HOME}/${BALLAST_NAME}" ]]; then BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}"; fi
|
|
[[ -f "${OFFICER_ROOT}/${BALLAST_NAME}" ]] && BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}"
|
|
|
|
if ballast_exists; then
|
|
echo ""
|
|
echo " already present: $(ballast_size_human) at ${BALLAST_FILE}"
|
|
SUMMARY+=("Disk ballast: already present ($(ballast_size_human))")
|
|
elif ! confirm "Create one?"; then
|
|
warn "skipped by request"
|
|
SUMMARY+=("Disk ballast: SKIPPED by request")
|
|
else
|
|
# ── where ──
|
|
#
|
|
# A ballast only protects the filesystem it sits on, because the checker
|
|
# measures its own directory. So this is also a choice of which mount is
|
|
# being protected, not just where the file is tidiest.
|
|
echo ""
|
|
info "Where should it go?"
|
|
echo " [1] ${USER_HOME}/${BALLAST_NAME}"
|
|
echo " your home — easiest to find again months from now"
|
|
echo " [2] ${OFFICER_ROOT}/${BALLAST_NAME}"
|
|
echo " beside Officer — same place as everything else it owns"
|
|
echo " [3] somewhere else, typed in"
|
|
echo ""
|
|
|
|
BALLAST_FILE=""
|
|
while [[ -z "$BALLAST_FILE" ]]; do
|
|
if ! read -rp " Which one? (1/2/3) [1]: " BALLAST_WHERE; then
|
|
echo ""
|
|
fail "No answer."
|
|
fi
|
|
case "${BALLAST_WHERE:-1}" in
|
|
1) BALLAST_FILE="${USER_HOME}/${BALLAST_NAME}" ;;
|
|
2) BALLAST_FILE="${OFFICER_ROOT}/${BALLAST_NAME}" ;;
|
|
3)
|
|
read -rp " Full path to the file: " BALLAST_TYPED || fail "No answer."
|
|
BALLAST_TYPED="${BALLAST_TYPED/#\~/$USER_HOME}"
|
|
if [[ "$BALLAST_TYPED" == /* ]]; then
|
|
BALLAST_FILE="$BALLAST_TYPED"
|
|
else
|
|
warn "That needs to be an absolute path, starting with /"
|
|
fi
|
|
;;
|
|
*) warn "Pick 1, 2 or 3." ;;
|
|
esac
|
|
done
|
|
|
|
# ── how much ──
|
|
#
|
|
# Percentages mean nothing without the numbers behind them, and the number
|
|
# that matters is what is LEFT — the point of the reserve is to be big enough
|
|
# to matter and small enough not to be the thing that filled the disk.
|
|
BALLAST_FREE_KB="$(ballast_free_kb "$(dirname "$BALLAST_FILE")")"
|
|
|
|
echo ""
|
|
info "How much? ${BALLAST_FILE} sits on a filesystem with $(human_bytes $((BALLAST_FREE_KB * 1024))) free."
|
|
for i in 1 2 3; do
|
|
case $i in
|
|
1) BP=5 ;;
|
|
2) BP=10 ;;
|
|
3) BP=20 ;;
|
|
esac
|
|
BSZ=$((BALLAST_FREE_KB * BP / 100))
|
|
printf ' [%d] %3d%% — reserves %-8s leaving %s free\n' \
|
|
"$i" "$BP" "$(human_bytes $((BSZ * 1024)))" "$(human_bytes $(((BALLAST_FREE_KB - BSZ) * 1024)))"
|
|
done
|
|
echo ""
|
|
|
|
BALLAST_PCT=""
|
|
while [[ -z "$BALLAST_PCT" ]]; do
|
|
if ! read -rp " Which one? (1/2/3) [2]: " BALLAST_SIZE_CHOICE; then
|
|
echo ""
|
|
fail "No answer."
|
|
fi
|
|
case "${BALLAST_SIZE_CHOICE:-2}" in
|
|
1) BALLAST_PCT=5 ;;
|
|
2) BALLAST_PCT=10 ;;
|
|
3) BALLAST_PCT=20 ;;
|
|
*) warn "Pick 1, 2 or 3." ;;
|
|
esac
|
|
done
|
|
|
|
BALLAST_MB=$((BALLAST_FREE_KB * BALLAST_PCT / 100 / 1024))
|
|
ballast_create "$BALLAST_MB"
|
|
ballast_install_checker
|
|
ok "ballast $(ballast_size_human) at ${BALLAST_FILE}, checked every 10 minutes"
|
|
ok "status any time: ${BALLAST_CHECKER} --status"
|
|
SUMMARY+=("Disk ballast: $(ballast_size_human) at ${BALLAST_FILE} (${BALLAST_PCT}%)")
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 10. earlyoom
|
|
# =============================================================================
|
|
|
|
step "earlyoom"
|
|
if ! skip; then
|
|
echo ""
|
|
info "earlyoom — keeps the machine reachable when it runs out of memory"
|
|
echo " The kernel's own OOM killer waits until an allocation actually"
|
|
echo " fails, and by then the machine has usually spent minutes thrashing:"
|
|
echo " unresponsive, ssh refusing to connect, nothing to do but reset it."
|
|
echo " earlyoom watches free memory and kills the biggest consumer while"
|
|
echo " there is still enough left to stay logged in."
|
|
echo ""
|
|
echo " This is what happens after swap runs out, so the two go together."
|
|
|
|
if earlyoom_is_active; then
|
|
echo " already installed and running"
|
|
SUMMARY+=("earlyoom: already running")
|
|
elif confirm "Install it?"; then
|
|
earlyoom_install
|
|
if earlyoom_is_active; then
|
|
ok "earlyoom running"
|
|
SUMMARY+=("earlyoom: installed and running")
|
|
else
|
|
warn "earlyoom installed but not running — check: systemctl status earlyoom"
|
|
SUMMARY+=("earlyoom: installed, not running")
|
|
fi
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("earlyoom: SKIPPED by request")
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 11. inotify watch limit
|
|
# =============================================================================
|
|
|
|
step "inotify watch limit"
|
|
if ! skip; then
|
|
CURRENT_WATCHES="$(inotify_current_watches)"
|
|
|
|
echo ""
|
|
info "inotify watch limit — how many files can be watched for changes at once"
|
|
echo " A single file watcher walking a project with node_modules in it can"
|
|
echo " exhaust the stock limit on its own, and every watcher on the machine"
|
|
echo " draws from the same pool. The failure is silent: nothing errors, the"
|
|
echo " watcher just stops noticing changes. Hot reload goes quiet, a build"
|
|
echo " stops rebuilding, and the reason is never on screen."
|
|
echo ""
|
|
echo " current: ${CURRENT_WATCHES}"
|
|
echo " to set: ${INOTIFY_WATCHES}"
|
|
if is_role dev; then
|
|
echo " recommended on dev — editors, bun --watch and vite are all watchers"
|
|
else
|
|
echo " less pressing on ${MACHINE_ROLE}, but anything running bun --watch or"
|
|
echo " serving a file browser is a watcher too"
|
|
fi
|
|
|
|
if ((CURRENT_WATCHES >= INOTIFY_WATCHES)); then
|
|
echo " already at or above that, nothing to do"
|
|
SUMMARY+=("inotify watches: already ${CURRENT_WATCHES}")
|
|
elif confirm "Raise it?"; then
|
|
inotify_raise
|
|
ok "inotify watches raised to $(inotify_current_watches)"
|
|
SUMMARY+=("inotify watches: raised to ${INOTIFY_WATCHES}")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("inotify watches: SKIPPED by request — left at ${CURRENT_WATCHES}")
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 12. Sleep and suspend
|
|
# =============================================================================
|
|
|
|
step "Sleep and suspend"
|
|
# Homelab only, and the two exclusions are for different reasons.
|
|
#
|
|
# dev: a laptop should sleep. Disabling it on the machine somebody carries is how
|
|
# you get a hot bag and a flat battery.
|
|
#
|
|
# vps: not merely unnecessary but harmful. A virtual machine has no lid and no
|
|
# power button, but the provider's "Shut down" control works by sending an ACPI
|
|
# power button event — HandlePowerKey=ignore makes the VM ignore it, so a
|
|
# graceful shutdown request does nothing and the provider hard-kills the instance
|
|
# instead. systemd defaults that key to poweroff for exactly this reason.
|
|
if ! skip && is_role dev; then
|
|
echo ""
|
|
info "Sleep and suspend — left alone on a ${MACHINE_ROLE} machine"
|
|
echo " Suspending is what a machine you carry should do. Only a server has"
|
|
echo " to be stopped from it."
|
|
SUMMARY+=("Sleep: left alone on ${MACHINE_ROLE}")
|
|
step_ok
|
|
elif ! skip && is_role vps; then
|
|
echo ""
|
|
info "Sleep and suspend — left alone on a ${MACHINE_ROLE}"
|
|
echo " A virtual machine has no lid and no power button to disable. What it"
|
|
echo " does have is the provider's Shut down control, which works by sending"
|
|
echo " an ACPI power button event — telling this machine to ignore that would"
|
|
echo " mean graceful shutdowns silently do nothing and the instance gets"
|
|
echo " hard-killed instead."
|
|
SUMMARY+=("Sleep: left alone on ${MACHINE_ROLE} — would break provider shutdown")
|
|
step_ok
|
|
elif ! skip; then
|
|
echo ""
|
|
info "Sleep and suspend — stop this machine putting itself to sleep"
|
|
echo " A server that suspends is a server that is off: it stops answering,"
|
|
echo " and with no keyboard attached there is nothing to wake it. It looks"
|
|
echo " like a crash and the fix is a physical visit."
|
|
echo ""
|
|
echo " sleep targets: $(sleep_targets_masked && echo 'masked' || echo 'available')"
|
|
echo " lid closed: $(logind_effective HandleLidSwitch || echo 'default (suspend)')"
|
|
echo " idle: $(logind_effective IdleAction || echo 'default (ignore)')"
|
|
echo " power button: $(logind_effective HandlePowerKey || echo 'default (power off)')"
|
|
|
|
if [[ "$IS_WSL" == true ]]; then
|
|
echo " WSL has no logind and cannot suspend — nothing to do"
|
|
SUMMARY+=("Sleep: not applicable under WSL")
|
|
elif sleep_targets_masked && logind_is_configured; then
|
|
echo " already configured, nothing to do"
|
|
SUMMARY+=("Sleep: already disabled")
|
|
else
|
|
echo ""
|
|
echo " This masks the four sleep targets and tells logind to ignore a"
|
|
echo " closed lid, an idle session and the power button. Note the last"
|
|
echo " one: after this, physically pressing power does nothing, so a"
|
|
echo " clean shutdown is 'sudo poweroff' rather than the button."
|
|
if confirm "Proceed?"; then
|
|
disable_sleep
|
|
ok "sleep disabled, logind reloaded"
|
|
SUMMARY+=("Sleep: disabled (targets masked, logind handlers ignored)")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Sleep: SKIPPED by request")
|
|
fi
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 13. Boot hang
|
|
# =============================================================================
|
|
|
|
step "Boot hang"
|
|
# Not on a VPS. There the network IS systemd-networkd — cloud-init and netplan
|
|
# put it in charge — so the unit below is load-bearing and completes in
|
|
# milliseconds. Masking it on that stack is how a box comes up with no network
|
|
# and no ssh.
|
|
if ! skip && is_role vps; then
|
|
echo ""
|
|
info "Boot hang — not applicable on a ${MACHINE_ROLE}"
|
|
echo " systemd-networkd is what configures the network here, so the unit"
|
|
echo " this would mask is doing real work."
|
|
SUMMARY+=("Boot hang: not applicable on ${MACHINE_ROLE}")
|
|
step_ok
|
|
elif ! skip; then
|
|
WAIT_ONLINE_TIME="$(wait_online_boot_time)"
|
|
|
|
echo ""
|
|
info "Boot hang — a unit that can add two minutes to every boot"
|
|
echo " On a machine where NetworkManager owns the network, systemd-networkd"
|
|
echo " runs nothing — but systemd-networkd-wait-online is still enabled, and"
|
|
echo " waits for a link that will never come up. It gives up after its full"
|
|
echo " timeout, every single boot."
|
|
echo ""
|
|
echo " network managed by: $(network_manager_name)"
|
|
echo " that unit took: ${WAIT_ONLINE_TIME:-it did not run} on this boot"
|
|
|
|
if ! wait_online_is_spurious; then
|
|
echo ""
|
|
echo " systemd-networkd is live here, so that unit is doing real work."
|
|
echo " Masking it would be how this machine comes up with no network."
|
|
SUMMARY+=("Boot hang: nothing to fix — networkd is in charge")
|
|
else
|
|
echo ""
|
|
if is_role dev; then
|
|
echo " Only worth doing if you actually feel this — if the machine sits"
|
|
echo " there for a couple of minutes before the login screen. The number"
|
|
echo " above is the honest answer for this boot; if it is small, there is"
|
|
echo " nothing here for you."
|
|
fi
|
|
echo " This masks systemd-networkd-wait-online.service only. NetworkManager"
|
|
echo " keeps configuring the network exactly as it does now."
|
|
if confirm "Mask it?"; then
|
|
mask_wait_online
|
|
ok "systemd-networkd-wait-online masked"
|
|
SUMMARY+=("Boot hang: wait-online masked (NetworkManager stack)")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Boot hang: SKIPPED by request")
|
|
fi
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# 14. User account
|
|
# =============================================================================
|
|
|
|
step "User account"
|
|
if ! skip; then
|
|
echo ""
|
|
info "User account — the account you will actually log in and work as"
|
|
|
|
if id "$USERNAME" &>/dev/null; then
|
|
echo " ${USERNAME} already exists"
|
|
USER_EXISTED=true
|
|
else
|
|
echo " ${USERNAME} does not exist yet and will be created"
|
|
echo " adduser will ask for a password and a few details"
|
|
USER_EXISTED=false
|
|
fi
|
|
|
|
IN_SUDO=false
|
|
id -nG "$USERNAME" 2>/dev/null | tr ' ' '\n' | grep -qx sudo && IN_SUDO=true
|
|
echo " sudo group: $($IN_SUDO && echo 'already a member' || echo 'will be added')"
|
|
echo " passwordless: $(has_passwordless_sudo "$USERNAME" && echo 'already granted' || echo 'not granted')"
|
|
|
|
if $USER_EXISTED && $IN_SUDO; then
|
|
SUMMARY+=("User: ${USERNAME} (already set up)")
|
|
elif confirm "Proceed?"; then
|
|
if ! $USER_EXISTED; then
|
|
adduser --gecos "" "$USERNAME"
|
|
SUMMARY+=("User: ${USERNAME} created")
|
|
fi
|
|
$IN_SUDO || usermod -aG sudo "$USERNAME"
|
|
ok "${USERNAME} is in the sudo group"
|
|
$USER_EXISTED && SUMMARY+=("User: ${USERNAME} added to sudo")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("User: SKIPPED by request")
|
|
fi
|
|
|
|
# ── passwordless sudo ──
|
|
#
|
|
# Asked separately because it is a security posture rather than part of
|
|
# creating an account, and because Officer has an actual requirement here:
|
|
# os-user-shell.ts runs `sudo -n` to provision a member's home, and a prompt it
|
|
# cannot answer is a failure it reports as a permissions error.
|
|
if has_passwordless_sudo "$USERNAME"; then
|
|
echo ""
|
|
echo " passwordless sudo is already granted to ${USERNAME}"
|
|
SUMMARY+=("Sudo: passwordless (already)")
|
|
else
|
|
echo ""
|
|
info "Passwordless sudo for ${USERNAME}?"
|
|
echo " Means sudo never asks for a password again. Convenient, and the"
|
|
echo " cost is real: anything that gets hold of this account, or of a key"
|
|
echo " that can log into it, is root without another step."
|
|
if is_role vps; then
|
|
echo " Worth weighing on a ${MACHINE_ROLE}, which faces the open internet."
|
|
fi
|
|
echo ""
|
|
echo " Officer needs it: it runs 'sudo -n' to provision a member's Linux"
|
|
echo " account, and a password prompt it cannot answer surfaces as a"
|
|
echo " permissions error rather than a question."
|
|
if confirm "Grant it?"; then
|
|
grant_passwordless_sudo "$USERNAME"
|
|
ok "passwordless sudo granted — remove /etc/sudoers.d/99-${USERNAME}-nopasswd to undo"
|
|
SUMMARY+=("Sudo: passwordless")
|
|
else
|
|
warn "skipped by request"
|
|
SUMMARY+=("Sudo: password required (Officer's account provisioning will not work)")
|
|
fi
|
|
fi
|
|
|
|
# ── tmux config ──
|
|
#
|
|
# install_config rather than cp: the original overwrote whatever was there on
|
|
# every single run.
|
|
if [[ -f "$SCRIPT_DIR/.tmux.conf" ]] && id "$USERNAME" &>/dev/null; then
|
|
install_config "$SCRIPT_DIR/.tmux.conf" "${USER_HOME}/.tmux.conf" "$USERNAME"
|
|
case $? in
|
|
0) ok "tmux config installed" ;;
|
|
1) : ;;
|
|
2) : ;;
|
|
esac
|
|
fi
|
|
step_ok
|
|
fi
|
|
|
|
# =============================================================================
|
|
# NOT PORTED YET
|
|
# =============================================================================
|
|
#
|
|
# Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order:
|
|
#
|
|
# ssh keys · ssh hardening · dns · static ip · fail2ban · unattended-upgrades ·
|
|
# git config · docker · zsh + prompt · tailscale · neovim · js runtimes ·
|
|
# dev tools · ufw · zshrc
|
|
#
|
|
# Each arrives as its own commit. Delete this block when the list is empty.
|
|
|
|
|
|
# =============================================================================
|
|
# 12. Summary
|
|
# =============================================================================
|
|
|
|
echo ""
|
|
echo ""
|
|
if [[ ${#ERRORS[@]} -gt 0 ]]; then
|
|
echo -e "${YELLOW}╔══════════════════════════════════════════════════╗${NC}"
|
|
echo -e "${YELLOW}║ Setup Complete (with warnings) ║${NC}"
|
|
echo -e "${YELLOW}╚══════════════════════════════════════════════════╝${NC}"
|
|
else
|
|
echo -e "${GREEN}╔══════════════════════════════════════════════════╗${NC}"
|
|
echo -e "${GREEN}║ Setup Complete ║${NC}"
|
|
echo -e "${GREEN}╚══════════════════════════════════════════════════╝${NC}"
|
|
fi
|
|
|
|
echo ""
|
|
echo -e "${BOLD} What was done:${NC}"
|
|
for item in "${SUMMARY[@]}"; do
|
|
echo -e " ${GREEN}+${NC} $item"
|
|
done
|
|
|
|
if [[ ${#ERRORS[@]} -gt 0 ]]; then
|
|
echo ""
|
|
echo -e "${BOLD} Non-critical issues:${NC}"
|
|
for err in "${ERRORS[@]}"; do
|
|
echo -e " ${YELLOW}!${NC} $err"
|
|
done
|
|
fi
|
|
|
|
echo ""
|
|
echo -e "${BOLD} Machine:${NC}"
|
|
echo " System: $OS_NAME ($ARCH)"
|
|
echo " Role: $MACHINE_ROLE"
|
|
echo " User: $USERNAME"
|
|
echo " Home: $USER_HOME"
|
|
echo " Officer: $OFFICER_ROOT"
|
|
[[ -n "${TS_IP:-}" && "$TS_IP" != "unknown" ]] && echo " Tailscale: $TS_IP"
|
|
echo ""
|
|
|
|
# Clean up progress file on success
|
|
rm -f "$PROGRESS_FILE"
|