Officer on a Mac is a dev helper on a laptop somebody sits at. It is never the homelab or VPS case, so the role is not asked for there — it is `dev`, and every section that exists to make a machine a good server is skipped. Seventeen of twenty-six sections skip, listed once in MACOS_SKIP in lib/base.sh with a reason each, rather than an `if macos` threaded through each section. Most would simply fail — no systemd, no ufw, no netplan, no useradd, no /etc/ssh/sshd_config.d — but a few would SUCCEED and be wrong, which is worse: stopping a laptop from sleeping, or freezing the address of a machine that moves between networks daily. Nine run: System update, Core utils, Tailscale, Command-line tools, Git, Docker, Neovim, JavaScript runtimes, Agent CLIs. The blocker was root. Linux needs it for nearly everything; Homebrew REFUSES to run as root and says so, so the whole script under sudo would have failed at the first brew install having already taken a password. It is now required on Linux and refused on macOS, which works precisely because the macOS path skips everything that needed it. Docker is checked, not installed. Docker Desktop is a GUI app that wants opening, permissions and a running window — not a shell script's business — and colima and lima both cost an evening the first time something does not resolve. So the step reports whether the daemon answers and points at the download otherwise. The group-vs-rootless choice below it is Linux only: Desktop runs containers in a VM owned by whoever is logged in, so there is no group to join. Added the Xcode command line tools as a macOS-only step, before anything that builds. node-pty ships no prebuilt binary on any platform and always falls through to node-gyp, so `bun install` cannot finish without a compiler — and it fails deep in a dependency tree naming neither Xcode nor node-pty. `xcode-select --install` opens a dialogue and returns immediately, so the step says to come back rather than pretending to have waited. Tailscale takes the cask, not install.sh — that script is a Linux package-manager wrapper. The cask ships a usable CLI; the Mac App Store build is sandboxed and does not. Not run on a Mac. There isn't one here, so this is read from the code and from what each tool documents, not observed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
674 lines
26 KiB
Bash
674 lines
26 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. Harmless on its own —
|
|
# `set -e` exempts the left side of an && list — but here it is the last
|
|
# thing the case runs, the case is the last thing the loop body runs, and the
|
|
# loop is the last thing THE FUNCTION runs. So load_answers returned
|
|
# non-zero, and calling a function that returns non-zero is a plain command
|
|
# failure, which does end the script.
|
|
#
|
|
# It needed the answers file to exist AND the variables to be set already, so
|
|
# it only appeared when running with env overrides. The trap reported "Step:
|
|
# unknown" at a line inside this library, before pre-flight had run.
|
|
#
|
|
# The general rule this is an instance of: a function whose last statement
|
|
# can return non-zero fails when it is called, however innocuous the
|
|
# statement looks.
|
|
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:-}"
|
|
|
|
# ── Steps that do not exist on macOS ──
|
|
#
|
|
# A Mac running Officer is a DEV MACHINE, never a server. That is not a
|
|
# simplification to revisit: nobody puts a laptop behind a public hostname and
|
|
# hands it a tailnet exit node, and the sections below are all about being a
|
|
# server that is on all the time.
|
|
#
|
|
# Most would fail rather than misbehave — there is no systemd, no ufw, no
|
|
# netplan, no useradd, no /etc/ssh/sshd_config.d. But a few would SUCCEED and be
|
|
# wrong, which is worse: stopping a laptop from sleeping, or freezing its address
|
|
# on a network it moves between every day.
|
|
#
|
|
# Keyed on the step title, so the sections themselves stay Linux code with no
|
|
# `if macos` branches threaded through them. The reason is printed, because a
|
|
# silent skip and a missing step look identical.
|
|
declare -A MACOS_SKIP=(
|
|
["User account"]="accounts are System Settings' business on a Mac, not a script's"
|
|
["Disk space"]="ballast and swap tuning are server concerns"
|
|
["Locale"]="macOS manages locale itself"
|
|
["Timezone"]="macOS manages the timezone itself"
|
|
["Swap"]="macOS sizes its own swap dynamically"
|
|
["Emergency disk ballast"]="a server trick for a machine nobody is sitting at"
|
|
["earlyoom"]="Linux OOM killer tuning; macOS has its own memory pressure handling"
|
|
["inotify watch limit"]="Linux inotify; macOS watches files through FSEvents"
|
|
["Sleep and suspend"]="a laptop SHOULD sleep — this stops a server from doing it"
|
|
["Boot hang"]="a systemd boot ordering fix"
|
|
["SSH access"]="hardening a door a dev machine should not be opening"
|
|
["DNS"]="systemd-resolved"
|
|
["Network address"]="netplan, and a laptop moves between networks by design"
|
|
["fail2ban"]="brute-force protection for an exposed SSH port"
|
|
["Unattended upgrades"]="apt; macOS updates through Software Update"
|
|
["Firewall"]="ufw; macOS has its own application firewall"
|
|
["Shell"]="zsh is already the default, and tmux is a choice you make yourself"
|
|
)
|
|
|
|
step() {
|
|
CURRENT_STEP="$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=<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%/}"
|
|
}
|
|
|
|
# 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" ]]; }
|