The menu is one function now rather than being written out twice — it is shown
again after ? prints the long answer — and option 1 carries your line: offscale is
just tailscale and headscale, with our own sugar on top.
The bug it surfaced is the more useful half. load_answers used
[[ -z "${MACHINE_ROLE:-}" ]] && MACHINE_ROLE="$value"
as the last statement in a while-read loop body. When the variable is already set
the test is false, the compound returns non-zero, and as the final statement in a
loop body under `set -e` that ends the script. The failure is silent about its
cause: the trap prints "Step: unknown" and a line number inside the library,
before pre-flight has run.
It needed both conditions to appear — an answers file on disk AND the variables
already set in the environment — which is why every earlier test missed it and
running with env overrides hit it immediately. Written as if/then now, and the
other lib files scanned for the same shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
587 lines
22 KiB
Bash
587 lines
22 KiB
Bash
#!/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" <<EOF
|
|
# Written by machine-setup. Delete this to be asked again.
|
|
MACHINE_ROLE=${MACHINE_ROLE}
|
|
SETUP_USERNAME=${USERNAME}
|
|
OFFICER_ROOT=${OFFICER_ROOT}
|
|
EOF
|
|
chmod 600 "$ANSWERS_FILE"
|
|
}
|
|
|
|
# Loaded as assignments, not sourced as a script: this file sits beside the
|
|
# script and is read by a root run, so it should not be able to execute anything.
|
|
load_answers() {
|
|
[[ -n "$ANSWERS_FILE" && -r "$ANSWERS_FILE" ]] || return 0
|
|
local key value
|
|
while IFS='=' read -r key value; do
|
|
[[ "$key" =~ ^[A-Z_]+$ ]] || continue
|
|
[[ -n "$value" ]] || continue
|
|
# The environment wins over what was saved.
|
|
#
|
|
# Written as if/then rather than `[[ … ]] && assign`. That form returns
|
|
# non-zero when the test is false, and as the last statement in a loop body
|
|
# under `set -e` it takes the whole script down — which is exactly what
|
|
# happened the first time this ran with the variables already set in the
|
|
# environment: the file loaded fine, every value was already present, and the
|
|
# run died at "Step: unknown" before pre-flight.
|
|
case "$key" in
|
|
MACHINE_ROLE) if [[ -z "${MACHINE_ROLE:-}" ]]; then MACHINE_ROLE="$value"; fi ;;
|
|
SETUP_USERNAME) if [[ -z "${SETUP_USERNAME:-}" ]]; then SETUP_USERNAME="$value"; fi ;;
|
|
OFFICER_ROOT) if [[ -z "${OFFICER_ROOT:-}" ]]; then OFFICER_ROOT="$value"; fi ;;
|
|
esac
|
|
done <"$ANSWERS_FILE"
|
|
}
|
|
|
|
# Set by --only. When it is set, every step whose name does not match is passed
|
|
# over in silence, and the one that does matches runs regardless of the progress
|
|
# file — the point of asking for a single step is to run that step.
|
|
ONLY_STEP="${ONLY_STEP:-}"
|
|
|
|
step() {
|
|
CURRENT_STEP="$1"
|
|
|
|
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
|
|
}
|
|
|
|
# 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"
|
|
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=<name> 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:
|
|
#
|
|
# <root>/platform/ the app
|
|
# <root>/data/ DATA_PATH
|
|
# <root>/dockers/ services the app store provisioned
|
|
# <root>/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=<path> 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%/}"
|
|
}
|
|
|
|
# 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() {
|
|
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" ]]; }
|