They were two sections, and being two is what let the second lock you out of a machine the first had failed to put a key on. Step 8 could warn-and-skip — no ssh-keys.zip, or an unrecognised menu choice, since its case had no default arm — and still mark itself done; step 9 then disabled password authentication and root login regardless. No key, no password, no root, on a box that may be in a datacentre. Nothing here turns off password authentication without first confirming a usable key is in place, and the refusal says why rather than skipping quietly. The hardening also did not do anything on a modern Ubuntu, and could not be seen not to: It sed'd /etc/ssh/sshd_config. Ubuntu includes /etc/ssh/sshd_config.d/*.conf from line 12 of that file, and sshd takes the FIRST value it obtains for a keyword rather than the last. Cloud images ship 50-cloud-init.conf containing `PasswordAuthentication yes`, read long before the line the sed edited. The run reported "SSH hardened" and password login stayed on. The settings now go in a drop-in named 01-machine-setup.conf, which is the only placement that wins under first-value-wins. It also sed'd ChallengeResponseAuthentication, renamed to KbdInteractiveAuthentication in OpenSSH 8.7. On 24.04 the old name is nowhere in the file, so that substitution matched nothing at all. State is read with `sshd -T`, which reports what sshd resolves across the main file and every drop-in — reading the config files tells you what is written, not what wins. Keys are counted by asking ssh-keygen to parse authorized_keys rather than by counting lines: comments, blanks and a half-finished paste all look like lines, and "there is a file" is not "there is a key that works". A pasted key is validated before it is stored, and matched on the key body rather than the whole line, so re-running does not authorise the same key four times over four runs. sshd -t validates the new config before anything is reloaded, and the drop-in is restored or removed if it does not parse — a config sshd refuses is a machine with no ssh after the next restart. Reload rather than restart, so the session this is running over is not the experiment, and the run says out loud to test a new connection before closing the current one. Generating a keypair now says the obvious thing the original did not: the private key is on the server, and a private key living on the machine it opens is a spare copy of the lock rather than a second factor. Verified against this host (1 key, already hardened, correctly does nothing) and with sshd_effective stubbed to a fresh-cloud-image state — the guard refuses and harden_sshd is never reached. Also verified key validation, dedup and 0700/0600 permissions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
983 lines
40 KiB
Bash
Executable File
983 lines
40 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"
|
|
# shellcheck source=lib/ssh.sh
|
|
source "$SCRIPT_DIR/lib/ssh.sh"
|
|
|
|
# Trap errors with context. Installed here rather than in lib/base.sh, because
|
|
# that file is definitions only and a trap is a side effect on whoever sources it.
|
|
trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR
|
|
|
|
# =============================================================================
|
|
# 1. Pre-flight
|
|
# =============================================================================
|
|
|
|
echo ""
|
|
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
|
|
echo -e "${BOLD}║ Machine Setup ║${NC}"
|
|
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
|
|
|
|
detect_os
|
|
echo ""
|
|
info "Machine: ${OS_NAME} (${ARCH})"
|
|
info "Packages: ${PM:-none detected}"
|
|
[[ "$IS_WSL" == true ]] && warn "WSL detected — the suspend, logind and boot-hang steps do not apply here"
|
|
|
|
# Everything below this line is written against apt and systemd. Detection above
|
|
# recognises pacman, dnf and brew so the branches have somewhere to hang, but
|
|
# nothing implements them yet — and running the apt path on Arch would half-build
|
|
# a machine and stop somewhere unhelpful. Refuse clearly instead, and relax this
|
|
# list one entry at a time as each package manager grows a real path.
|
|
case "$PM" in
|
|
apt) ;;
|
|
"") fail "Could not find a package manager for '${OS_NAME}'." ;;
|
|
*) fail "${OS_NAME} uses ${PM}, which this script does not implement yet — apt-based systems only, so far." ;;
|
|
esac
|
|
|
|
ask_machine_role
|
|
info "Role: ${MACHINE_ROLE}"
|
|
|
|
if [[ -f "$PROGRESS_FILE" ]]; then
|
|
DONE_COUNT=$(wc -l < "$PROGRESS_FILE")
|
|
echo -e "${YELLOW} Resuming — $DONE_COUNT step(s) already completed${NC}"
|
|
echo -e "${YELLOW} Progress file: $PROGRESS_FILE${NC}"
|
|
echo -e "${YELLOW} To start fresh: rm $PROGRESS_FILE${NC}"
|
|
fi
|
|
|
|
if [[ "$EUID" -ne 0 ]]; then
|
|
fail "Please run as root: sudo ./machine-setup.sh"
|
|
fi
|
|
|
|
ask_username
|
|
if id "$USERNAME" &>/dev/null; then
|
|
info "Account: ${USERNAME} (exists, home ${USER_HOME})"
|
|
else
|
|
info "Account: ${USERNAME} (will be created, home ${USER_HOME})"
|
|
fi
|
|
|
|
ask_officer_root
|
|
if [[ -d "$OFFICER_ROOT" ]]; then
|
|
info "Officer: ${OFFICER_ROOT} (exists already)"
|
|
else
|
|
info "Officer: ${OFFICER_ROOT}"
|
|
fi
|
|
|
|
# Always, and outside any step: everything below reads this index — core utils,
|
|
# the fastfetch PPA, the Docker repo — and `step` skips a step whose name is
|
|
# already in the progress file. With the refresh inside one of those, a resumed
|
|
# run installed against whatever the index happened to say hours or days ago.
|
|
echo ""
|
|
info "Refreshing the package index..."
|
|
pkg_refresh >/dev/null
|
|
|
|
# =============================================================================
|
|
# 2. 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. 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
|
|
|
|
|
|
# =============================================================================
|
|
# 7. 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
|
|
|
|
# =============================================================================
|
|
# 8. 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
|
|
|
|
# =============================================================================
|
|
# 9. 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
|
|
|
|
# =============================================================================
|
|
# 10. 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
|
|
|
|
# =============================================================================
|
|
# 11. 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
|
|
|
|
# =============================================================================
|
|
# 12. 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
|
|
|
|
# =============================================================================
|
|
# 13. 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
|
|
|
|
# =============================================================================
|
|
# 14. 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
|
|
|
|
# =============================================================================
|
|
# 15. 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 ──
|
|
if ((KEY_COUNT == 0)); then
|
|
echo ""
|
|
warn "${USERNAME} has no authorised key. Password login cannot be turned off until it has one."
|
|
echo " [1] paste a public key (the contents of your ~/.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."
|
|
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)"
|
|
else
|
|
fix_ssh_permissions
|
|
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
|
|
|
|
# =============================================================================
|
|
# NOT PORTED YET
|
|
# =============================================================================
|
|
#
|
|
# Sections still to move across from scripts/setup-old/setup-ubuntu.sh, in order:
|
|
#
|
|
# dns · static ip · fail2ban · unattended-upgrades ·
|
|
# git config · docker · zsh + prompt (incl. .tmux.conf) · tailscale · neovim · js runtimes ·
|
|
# dev tools · ufw · zshrc
|
|
#
|
|
# Each arrives as its own commit. Delete this block when the list is empty.
|
|
|
|
|
|
# =============================================================================
|
|
# 15. 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"
|