#!/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 step() { CURRENT_STEP="$1" 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() { 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 } # Run a block as the created user (login shell, inherits HOME) as_user() { sudo -u "$USERNAME" -i bash -c "$1" } # ----------------------------------------------------------------------------- # 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}}' } # A default for the question below — a suggestion the user confirms, never a # decision taken on their behalf. # # The signal that separates homelab from vps is whether this machine's own # address is in RFC1918 space. That is a better test than asking whether it is # virtualised, because a homelab is very often a VM on Proxmox and would be # misread as rented; and it is the same fact that decides most of what the role # is consulted for — whether the address is yours to pin, and whether the box is # exposed to the open internet. guess_machine_role() { # Nothing here is a server. It is the machine you are sitting at. if [[ "$OS" == "macos" ]]; then echo dev return fi # A graphical session means someone sits at this one too. if [[ -n "${DISPLAY:-}" ]] || systemctl list-unit-files 2>/dev/null | grep -qE '^(gdm3?|sddm|lightdm)\.service'; then echo dev return fi local ip ip="$(ip -4 addr show "$(default_iface)" 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1)" case "$ip" in 10.* | 192.168.* | 172.1[6-9].* | 172.2[0-9].* | 172.3[01].*) echo homelab ;; "") echo homelab ;; # no address to judge by: assume the safer of the two *) echo vps ;; esac } # 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. ask_machine_role() { 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 local guess choice guess="$(guess_machine_role)" echo "" info "What is this machine?" 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 "" case "$guess" in homelab) choice=1 ;; vps) choice=2 ;; dev) choice=3 ;; esac prompt_value MACHINE_ROLE_CHOICE "Pick a number" "$choice" case "$MACHINE_ROLE_CHOICE" in 1 | homelab) MACHINE_ROLE=homelab ;; 2 | vps) MACHINE_ROLE=vps ;; 3 | dev) MACHINE_ROLE=dev ;; *) fail "Not one of the options: '$MACHINE_ROLE_CHOICE'" ;; esac } # Convenience for the steps that branch on it. is_role() { [[ "$MACHINE_ROLE" == "$1" ]]; } is_server() { [[ "$MACHINE_ROLE" == "homelab" || "$MACHINE_ROLE" == "vps" ]]; }