#!/bin/bash # ============================================================================= # machine-setup — shared foundation # ============================================================================= # # Sourced by machine-setup.sh before anything runs. DEFINITIONS ONLY: this file # declares state and functions and must never install, write or restart # anything. Sourcing it has to be safe at any point, including from a step that # is only being read for its variables. # # The one thing it expects from its caller, because they are facts about the # entry point rather than about this library: # # SCRIPT_DIR directory of the script being run # PROGRESS_FILE where completed step names are recorded # # Everything else below is owned here. # Guard against being sourced twice — steps will eventually source this # directly so they can be run on their own, and re-running it would reset # SUMMARY and lose everything recorded so far. [[ -n "${MACHINE_SETUP_BASE_LOADED:-}" ]] && return 0 MACHINE_SETUP_BASE_LOADED=1 # ----------------------------------------------------------------------------- # Shared state # ----------------------------------------------------------------------------- SUMMARY=() # what was done, printed at the end ERRORS=() # non-fatal failures, printed at the end CURRENT_STEP="" SKIP_STEP=false # What machine this is. Filled in by detect_os() before any step runs; every step # after that branches on these rather than assuming apt on x86_64. OS="" # os-release ID: ubuntu | debian | arch | fedora | macos | … OS_NAME="" # pretty name, for the banner OS_VERSION="" # version id; empty on rolling releases PM="" # apt | pacman | dnf | brew ARCH="" # amd64 | arm64, normalised — upstream tarballs disagree on spelling IS_WSL=false # What this box is FOR. Asked once in pre-flight and consulted by the steps # afterwards, because several of them have a different right answer per role and # no way to work it out on their own: # # homelab a machine you physically control on a network you own # vps rented, public IP, someone else's DHCP and console # dev a laptop or desktop you sit at # # Set MACHINE_ROLE in the environment to answer it ahead of time — hence the # :- default rather than a plain assignment, which would wipe what the caller # passed in before ask_machine_role ever looked at it. MACHINE_ROLE="${MACHINE_ROLE:-}" # ----------------------------------------------------------------------------- # Output # ----------------------------------------------------------------------------- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' info() { echo -e "${CYAN}::${NC} $*"; } ok() { echo -e " ${GREEN}OK${NC}: $*"; } warn() { echo -e " ${YELLOW}WARN${NC}: $*"; } fail() { echo -e " ${RED}FAIL${NC}: $*" exit 1 } # ----------------------------------------------------------------------------- # Steps and resume # ----------------------------------------------------------------------------- # # A step announces itself, and is skipped when its name is already in the # progress file. step_ok records it. The pattern at each call site is: # # step "Name" # if ! skip; then # … # step_ok # fi # ----------------------------------------------------------------------------- # Remembering the answers # ----------------------------------------------------------------------------- # # Pre-flight asks four things — role, account, where Officer goes — and every # section needs them. Asking again on every run made a resumed run re-answer # questions it had already been told, and made --only unusable: four questions to # reach one section. # # Saved beside the progress file, and loaded before anything is asked. The # environment still wins, so SETUP_USERNAME=x on the command line overrides what # was saved. ANSWERS_FILE="${ANSWERS_FILE:-}" save_answers() { [[ -n "$ANSWERS_FILE" ]] || return 0 cat >"$ANSWERS_FILE" </dev/null && report_section "$1" if [[ "${OS:-}" == "macos" && -n "${MACOS_SKIP[$1]:-}" ]]; then echo "" echo -e "${BOLD}── $1 ──${NC}" echo -e " ${GREEN}SKIP${NC}: not on macOS — ${MACOS_SKIP[$1]}" SKIP_STEP=true return fi if [[ -n "$ONLY_STEP" ]]; then if [[ "${1,,}" == "${ONLY_STEP,,}" ]]; then SKIP_STEP=false echo "" echo -e "${BOLD}── $1 ──${NC}" else SKIP_STEP=true fi return fi if grep -qxF "$1" "$PROGRESS_FILE" 2>/dev/null; then echo -e " ${GREEN}SKIP${NC}: $1 (already done)" SKIP_STEP=true return fi SKIP_STEP=false echo "" echo -e "${BOLD}── $1 ──${NC}" } skip() { [[ "$SKIP_STEP" == true ]]; } step_ok() { # A single step run on its own is not progress through the script, and # recording it would make the next full run skip it. [[ -n "$ONLY_STEP" ]] && return 0 echo "$CURRENT_STEP" >>"$PROGRESS_FILE" } # Try a command, log error but don't exit try() { local label="$1" shift if "$@" 2>&1; then ok "$label" else warn "$label — failed (non-critical, continuing)" ERRORS+=("$label") fi } # ----------------------------------------------------------------------------- # Input # ----------------------------------------------------------------------------- prompt_value() { local varname="$1" message="$2" default="$3" # If env var already set, use it silently if [[ -n "${!varname:-}" ]]; then return fi local input if [[ -n "$default" ]]; then read -rp "$message [$default]: " input eval "$varname=\"\${input:-$default}\"" else read -rp "$message: " input eval "$varname=\"\$input\"" fi } # Show long output a screen at a time. # # Only when there is a terminal to page on: with output redirected or piped — # a transcript, a log, the test harness — it has to come through whole, and a # pager would either block or mangle it. `more` rather than `less` because it # exits at the end of the file instead of sitting there waiting to be quit, # which is what you want for something you asked to read once. page() { if [[ -t 1 ]] && command -v more &>/dev/null; then more else cat fi } # Ask before acting. Every section that changes the machine goes through this, so # a run is a sequence of things you agreed to rather than a wall of output you # read afterwards to find out what happened. # # Enter means yes — unlike the machine-role question, which has no default. These # are "do the thing you already asked for", and making twenty of them require a # deliberate keystroke would train people to hold the y key down. # # ASSUME_YES=1 answers all of them, for an unattended run. confirm() { local message="${1:-Proceed?}" # Second argument flips the default. Most questions here are "do the thing you # already asked for" and Enter should mean yes; a few are genuine extras, where # defaulting to yes would have people agreeing to them by reflex. local default="${2:-y}" # Third is the name of a function that explains the question. Where one is # given, `?` becomes an answer — so the explanation is available to whoever # wants it without being in the way of whoever does not. local help_fn="${3:-}" local answer prompt [[ "${ASSUME_YES:-}" == "1" ]] && { [[ "$default" == "y" ]] && return 0 || return 1; } if [[ "$default" == "y" ]]; then prompt="[Y/n]"; else prompt="[y/N]"; fi [[ -n "$help_fn" ]] && prompt="${prompt%]}/?]" while true; do # EOF is not a yes. Without this an unattended run without ASSUME_YES would # spin here forever. if ! read -rp " ${message} ${prompt}: " answer; then echo "" fail "No answer. Set ASSUME_YES=1 to run without prompts." fi [[ -z "$answer" ]] && answer="$default" case "$answer" in y | Y | yes | Yes) return 0 ;; n | N | no | No) return 1 ;; "?") if [[ -n "$help_fn" ]]; then echo "" "$help_fn" | page echo "" else warn "Answer y or n." fi ;; *) warn "Answer y or n${help_fn:+, or ? for what this is}." ;; esac done } # Which account this machine is being set up for. # # Asked at the top because two later questions default off it — where Officer is # installed, and where the disk ballast goes — so it has to be settled before # either is put to the user. # # Defaults to whoever invoked sudo. On a re-run, or on a machine that is already # somebody's, that is the answer every time, and typing it again is a chance to # typo it into creating a second account. # # SETUP_USERNAME in the environment answers it ahead of time. Deliberately not # USERNAME: that name is set by some login environments, and a variable this # script silently obeys should not be one that might already be in the # environment for unrelated reasons. ask_username() { local default="${SUDO_USER:-}" answer # root invoked the script directly rather than through sudo. It is never the # account being set up, so there is nothing to suggest. [[ "$default" == "root" ]] && default="" if [[ -n "${SETUP_USERNAME:-}" ]]; then answer="$SETUP_USERNAME" else echo "" info "Which account is this machine for?" echo " The account you log in and work as, day to day. It will be created" echo " if it does not exist." echo "" warn "Strongly advised: use a normal account, not root." echo " Working as root means everything runs with no safety net. A typo in" echo " a path deletes instead of refusing, anything you run has the whole" echo " machine, and nothing distinguishes you from a process that got out" echo " of hand. sudo gives you the same power when you ask for it, and" echo " only then — which is why root is not accepted as an answer here." # Whether this was started FROM a root session, which usually means root is # how they log in. That is exactly the situation the advice above is for, and # the one where general advice is easiest to assume is aimed at somebody else. # # Two ways to be in it, and the second is the one that hides: no SUDO_USER at # all, or a SUDO_USER that is itself uid 0. Some providers ship an image whose # default account is uid 0 under an ordinary-looking name, so `sudo` from it # sets SUDO_USER to something that looks like a normal user and is not. local invoker_uid="" [[ -n "${SUDO_USER:-}" ]] && invoker_uid="$(id -u "$SUDO_USER" 2>/dev/null || true)" if [[ -z "${SUDO_USER:-}" || "$invoker_uid" == "0" ]]; then echo "" if [[ -n "${SUDO_USER:-}" ]]; then warn "You are running this from '${SUDO_USER}', which is uid 0 — the root account." else warn "You are running this as root directly, not through sudo." fi echo " If that is how you normally log into this machine, now is the" echo " moment to make an account and stop doing that." fi echo "" while [[ -z "${answer:-}" ]]; do if ! read -rp " Username${default:+ [$default]}: " answer; then echo "" fail "No answer. Set SETUP_USERNAME= to answer this ahead of time." fi answer="${answer:-$default}" [[ -z "$answer" ]] && warn "There is no default here — type a username." done fi # The portable shape of a Linux account name. Worth checking rather than # letting adduser refuse it later, because by then several questions have been # answered against a name that was never going to work. [[ "$answer" =~ ^[a-z_][a-z0-9_-]*\$?$ && ${#answer} -le 32 ]] || fail "'${answer}' is not a usable Linux username — lower case, starting with a letter or underscore." # By uid, not by name. "root" is a label — what makes an account root is uid 0, # and some providers ship an image whose default login is uid 0 under a # friendlier name. Refusing only the string would let exactly that case through, # which is the one worth catching. local answer_uid answer_uid="$(id -u "$answer" 2>/dev/null || true)" if [[ "$answer_uid" == "0" ]]; then if [[ "$answer" == "root" ]]; then fail "root is not the account to set up here — see the warning above." fi fail "'${answer}' is uid 0 — the root account under another name, and not what to set up here." fi USERNAME="$answer" # Looked up, not assumed. The original built "/home/$USERNAME", which is merely # the usual answer — an account created with a different home, or one whose home # was moved, would have every later step writing to a directory that is not # theirs. USER_HOME="$(getent passwd "$USERNAME" 2>/dev/null | cut -d: -f6)" [[ -n "$USER_HOME" ]] || USER_HOME="/home/${USERNAME}" } # Where Officer will live. # # Asked in pre-flight with the rest of the questions rather than at the point it # is first needed, because it decides the shape of several later steps — the # directory the repository is cloned into, where DATA_PATH sits beside it, and # which filesystem the app store's containers bind-mount out of. Answering it # once at the start also means the run can be described before it begins. # # One directory holding four, per docs/sidecar-app-store.md: # # /platform/ the app # /data/ DATA_PATH # /dockers/ services the app store provisioned # /capabilities/ the file-based item store # # OFFICER_ROOT in the environment answers it ahead of time. ask_officer_root() { local default="${USER_HOME}/officerdev" answer if [[ -n "${OFFICER_ROOT:-}" ]]; then answer="$OFFICER_ROOT" else echo "" info "Where should Officer be installed?" echo " One directory holding the app, its data, the item store and any" echo " containers the app store provisions — so it can be moved, backed" echo " up or deleted as a unit." echo "" if ! read -rp " Path [${default}]: " answer; then echo "" fail "No answer. Set OFFICER_ROOT= to answer this ahead of time." fi answer="${answer:-$default}" fi # A leading ~ arrives as a literal when it comes from a read or an environment # variable — nothing expands it there — and would create a directory named "~". answer="${answer/#\~/$USER_HOME}" [[ "$answer" == /* ]] || fail "That needs to be an absolute path, starting with / — got '${answer}'" OFFICER_ROOT="${answer%/}" } # The account's PRIMARY GROUP, asked of the system rather than assumed to be # named after the user. # # Debian and Ubuntu create a group per user, so "pastilhas:pastilhas" is right on # most machines — but not on one where the account came from LDAP, or was made # with `useradd -g users`, or is a cloud image with a shared group. There # `chown user:user` fails with "invalid group" and `install -g user` refuses, # both of which abort the step. user_group() { id -gn "${1:-$USERNAME}" 2>/dev/null || echo "${1:-$USERNAME}"; } # Run a block as the created user (login shell, inherits HOME) as_user() { sudo -u "$USERNAME" -i bash -c "$1" } # ----------------------------------------------------------------------------- # sudoers # ----------------------------------------------------------------------------- # Grant an account passwordless sudo, safely. # # A malformed file in /etc/sudoers.d breaks sudo COMPLETELY — and you cannot sudo # to repair it, so on a remote machine that is unrecoverable short of a rescue # console. The same is true of one with loose permissions: sudo refuses to read # its own configuration and every sudo on the box fails. # # The original wrote the file into /etc/sudoers.d first and validated it after, # with a chmod later still. Both of those leave a window where a broken or # world-readable sudoers file is live. This validates a temp file first and then # places it with its mode in a single install(1) — so what lands in /etc is # already known good and already 0440. grant_passwordless_sudo() { # Declared separately, deliberately. In `local a="$1" b="${a}"` bash expands # $a before it has been assigned, so b comes out with the name missing — which # here meant every account's rule landing in the same /etc/sudoers.d/99--nopasswd, # each one silently overwriting the last, and has_passwordless_sudo never # finding the file it was looking for. local user="$1" local dest="/etc/sudoers.d/99-${user}-nopasswd" local tmp tmp="$(mktemp)" [[ -n "$user" ]] || fail "grant_passwordless_sudo needs a username" echo "${user} ALL=(ALL) NOPASSWD: ALL" >"$tmp" if ! visudo -c -f "$tmp" >/dev/null 2>&1; then rm -f "$tmp" fail "visudo rejected the sudoers entry for '${user}' — not installing it" fi install -m 0440 -o root -g root "$tmp" "$dest" rm -f "$tmp" } has_passwordless_sudo() { local user="$1" [[ -f "/etc/sudoers.d/99-${user}-nopasswd" ]] || grep -rqsE "^${user}[[:space:]]+ALL=\(ALL\)[[:space:]]+NOPASSWD" /etc/sudoers /etc/sudoers.d 2>/dev/null } # ----------------------------------------------------------------------------- # Operating system detection # ----------------------------------------------------------------------------- # # Read one key out of /etc/os-release without leaking the rest of it into this # script. That file defines NAME, VERSION and ID — all generic enough to collide # with something here — so it is sourced in a subshell and only the one value # asked for comes back. os_release() { [[ -r /etc/os-release ]] || return 1 # shellcheck disable=SC1091 ( . /etc/os-release 2>/dev/null printf '%s' "${!1:-}" ) } # Identify the machine, or refuse to guess. # # /etc/os-release rather than probing for a binary: a box can have more than one # package manager on PATH (a Homebrew install on Linux, a leftover apt on a # converted box), and only os-release can say which distribution the machine # actually IS, or give a version worth reporting. # # ID_LIKE is the fallback so derivatives resolve without being listed by name — # Pop!_OS, Mint and EndeavourOS all answer correctly without appearing below. detect_os() { local kernel like kernel="$(uname -s)" case "$kernel" in Darwin) OS="macos" OS_VERSION="$(sw_vers -productVersion 2>/dev/null || true)" OS_NAME="macOS ${OS_VERSION}" PM="brew" ;; Linux) OS="$(os_release ID || true)" OS_NAME="$(os_release PRETTY_NAME || true)" OS_VERSION="$(os_release VERSION_ID || true)" like="$(os_release ID_LIKE || true)" case "$OS" in ubuntu | debian | linuxmint | pop | raspbian | elementary) PM="apt" ;; arch | manjaro | endeavouros | cachyos | garuda) PM="pacman" ;; fedora | rhel | centos | rocky | almalinux) PM="dnf" ;; *) case " $like " in *" debian "* | *" ubuntu "*) PM="apt" ;; *" arch "*) PM="pacman" ;; *" fedora "* | *" rhel "*) PM="dnf" ;; esac ;; esac # WSL reports itself as Linux, but has no real systemd session: masking # sleep targets, restarting logind and anything touching the boot path # either fail or silently do nothing. Worth knowing before those steps run. if grep -qi microsoft /proc/version 2>/dev/null; then IS_WSL=true; fi ;; MINGW* | MSYS* | CYGWIN*) fail "Windows is not supported. Run this inside WSL2 with an Ubuntu image instead." ;; *) fail "Unrecognised kernel '$kernel' — cannot tell what this machine is." ;; esac # Normalised once here because upstream projects spell it differently: # Neovim ships aarch64, Go and Docker ship arm64, and lazygit ships x86_64. case "$(uname -m)" in x86_64 | amd64) ARCH="amd64" ;; aarch64 | arm64) ARCH="arm64" ;; *) fail "Unsupported CPU architecture '$(uname -m)' — this script installs amd64/arm64 binaries only." ;; esac [[ -n "$OS" ]] || fail "Could not identify this distribution (no readable /etc/os-release)." [[ -n "$OS_NAME" ]] || OS_NAME="$OS${OS_VERSION:+ $OS_VERSION}" } # ----------------------------------------------------------------------------- # Machine role # ----------------------------------------------------------------------------- # The interface packets actually leave by, which is not always the first one up. default_iface() { ip route get 8.8.8.8 2>/dev/null | awk '{for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}' } # Ask what this machine is, unless the environment already said. # # Asked in pre-flight rather than at the point of use so that the run knows its # own shape before it starts: the steps that care are spread from swap through to # the firewall, and being asked "is this a VPS?" for the fourth time halfway down # a provisioning run is how people start answering without reading. # # NO DEFAULT, deliberately, and it is the only question in the script like that. # A guessed default is right often enough to be trusted and wrong in exactly the # case that costs the most: pinning a static IP on a rented box, or leaving the # firewall open on one. Every branch downstream is about what this machine is # exposed to, so it is worth one deliberate keystroke rather than an Enter. ask_machine_role() { # Not a question on a Mac. Officer on macOS is a dev helper on a machine # somebody sits at — there is no homelab or VPS answer that would make sense, # and every section that branches on the role branches toward "server". if [[ "${OS:-}" == "macos" && -z "$MACHINE_ROLE" ]]; then MACHINE_ROLE="dev" info "macOS — treated as a dev machine. The server-only sections are skipped." return fi if [[ -n "$MACHINE_ROLE" ]]; then case "$MACHINE_ROLE" in homelab | vps | dev) return ;; *) fail "MACHINE_ROLE must be homelab, vps or dev — got '$MACHINE_ROLE'" ;; esac fi echo "" info "What is this machine? Several later steps depend on the answer." echo " [1] homelab — yours, on a network you control" echo " [2] vps — rented, public IP, provider's DHCP and console" echo " [3] dev — a laptop or desktop you sit at" echo "" local choice while [[ -z "$MACHINE_ROLE" ]]; do # A failed read means EOF, not a wrong answer — without this the loop would # spin forever when stdin is closed, which is how an unattended run hangs. if ! read -rp " Which one? (1/2/3): " choice; then fail "No answer, and this question has no default. Set MACHINE_ROLE=homelab|vps|dev to answer it ahead of time." fi case "$choice" in 1 | homelab) MACHINE_ROLE=homelab ;; 2 | vps) MACHINE_ROLE=vps ;; 3 | dev) MACHINE_ROLE=dev ;; "") warn "There is no default here — pick 1, 2 or 3." ;; *) warn "Not one of the options: '$choice'" ;; esac done } # Convenience for the steps that branch on it. is_role() { [[ "$MACHINE_ROLE" == "$1" ]]; } is_server() { [[ "$MACHINE_ROLE" == "homelab" || "$MACHINE_ROLE" == "vps" ]]; }