Your call, and the reasoning holds: it configures nothing of its own, an existing install with its own jails is untouched because pkg_install never names a package that is already present, and it is worth having by default. One thing recorded where it is declared, because it makes fail2ban unlike every other entry in that list: it is a daemon, not a binary. Installing it starts it, and Debian and Ubuntu ship an enabled sshd jail — so from that moment an address that fails to log in five times in ten minutes is blocked for ten. That is the point of it, and it includes you, from wherever you are connecting. (Recalled rather than verified: fail2ban is not installed on this host and the sandbox would not let me unpack the .deb to check the shipped jail.d file.) The section no longer installs anything. It reports whether fail2ban is running, which jails are active, and how to unban an address — because a daemon quietly blocking connections is worth knowing about before it blocks yours, and a run that installs it as one name in a list of twenty gives no hint that anything started. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
249 lines
9.6 KiB
Bash
249 lines
9.6 KiB
Bash
#!/bin/bash
|
|
# =============================================================================
|
|
# machine-setup — distro packages
|
|
# =============================================================================
|
|
#
|
|
# Definitions only, like lib/base.sh. Sourcing this installs nothing.
|
|
#
|
|
# ── The rule: install what is missing, never touch what is there ──
|
|
#
|
|
# `apt-get install <present-package>` is NOT a no-op — it upgrades the package if
|
|
# the repository has a newer one. On a machine somebody already uses, that can
|
|
# move a version they chose deliberately, and the setup script is the last thing
|
|
# that should be doing that behind their back.
|
|
#
|
|
# So every install here goes through pkg_install, which queries the package
|
|
# database first, installs only the subset that is genuinely absent, and prints
|
|
# both lists before doing it. A package already present is never named on a
|
|
# command line at all.
|
|
#
|
|
# ── Why per-package-manager lists rather than a translation table ──
|
|
#
|
|
# The names disagree across distributions (build-essential/base-devel/fd/fd-find)
|
|
# and some packages are not a package elsewhere at all: apt-transport-https,
|
|
# lsb-release and software-properties-common are apt concepts. A canonical-name
|
|
# table with per-manager overrides hides both of those behind indirection. A
|
|
# plain `case $PM` says what each system actually gets, in one place, and matches
|
|
# the shape scripts/setup-old/setup.sh already used.
|
|
|
|
[[ -n "${MACHINE_SETUP_PACKAGES_LOADED:-}" ]] && return 0
|
|
MACHINE_SETUP_PACKAGES_LOADED=1
|
|
|
|
# What the last pkg_install/tools_install actually put on the machine, as opposed
|
|
# to what it was asked for. Read by the caller to write an honest summary line:
|
|
# without it every section reports its whole list as installed, including the
|
|
# packages it deliberately left alone.
|
|
LAST_INSTALLED=()
|
|
LAST_KEPT=()
|
|
LAST_SKIPPED=()
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# The sections
|
|
# -----------------------------------------------------------------------------
|
|
|
|
# Core: what this script itself would break without, plus the command-line tools
|
|
# that make a machine worth sitting at.
|
|
#
|
|
# The first six are load-bearing and each is used by a later step — curl fetches
|
|
# in nine of them, jq parses the lazygit release API, gnupg dearmors the Docker
|
|
# keyring, git clones the Neovim config, unzip opens anything that arrives as an
|
|
# archive, and ca-certificates is what makes any of the fetching work. The rest
|
|
# are the environment: nothing calls them, they are here because a box you use
|
|
# should have them.
|
|
#
|
|
# Two entries reach beyond simply existing on the machine.
|
|
#
|
|
# fail2ban is not a tool, it is a daemon: installing it starts it, and Debian and
|
|
# Ubuntu ship an enabled sshd jail, so from that moment an address failing to log
|
|
# in five times in ten minutes is blocked for ten. That is the point of it, and it
|
|
# is worth having by default — but it is why it appears here rather than being
|
|
# thought of as another binary. An existing install with its own jails is
|
|
# untouched, because pkg_install never names a package that is already there.
|
|
#
|
|
# build-essential is the other: a meta-package (gcc, g++, make, libc6-dev,
|
|
# dpkg-dev), so on a machine where a specific gcc was pinned it pulls the
|
|
# distribution's default alongside it. It stays in core because anything that
|
|
# compiles a native module needs it, but it is the one to move out first if that
|
|
# ever bites.
|
|
pkgs_core() {
|
|
case "$PM" in
|
|
apt)
|
|
# apt-transport-https, lsb-release and software-properties-common are not
|
|
# tools — they are what lets later steps add the Docker repository and the
|
|
# fastfetch PPA. They have no counterpart on the other systems.
|
|
echo curl ca-certificates gnupg git jq unzip \
|
|
apt-transport-https lsb-release software-properties-common \
|
|
wget zip build-essential btop htop tree tmux ripgrep fd-find net-tools \
|
|
fail2ban
|
|
;;
|
|
pacman)
|
|
echo curl ca-certificates gnupg git jq unzip \
|
|
wget zip base-devel btop htop tree tmux ripgrep fd net-tools \
|
|
fail2ban
|
|
;;
|
|
dnf)
|
|
echo curl ca-certificates gnupg2 git jq unzip \
|
|
wget zip btop htop tree tmux ripgrep fd-find net-tools \
|
|
fail2ban
|
|
;;
|
|
brew)
|
|
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
|
# the Xcode command line tools, which is not a formula.
|
|
echo gnupg git jq wget btop htop tree tmux ripgrep fd
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Querying
|
|
# -----------------------------------------------------------------------------
|
|
|
|
# Is this package installed right now?
|
|
#
|
|
# dpkg-query on the status field rather than `dpkg -s`, which also succeeds for a
|
|
# package that was removed but left its config behind — that state would be read
|
|
# as "present" and the package would never be reinstalled.
|
|
pkg_is_installed() {
|
|
case "$PM" in
|
|
apt) [[ "$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null)" == "installed" ]] ;;
|
|
pacman) pacman -Qi "$1" &>/dev/null ;;
|
|
dnf) rpm -q "$1" &>/dev/null ;;
|
|
brew) brew list --formula "$1" &>/dev/null ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Acting
|
|
# -----------------------------------------------------------------------------
|
|
|
|
# Refresh the package index.
|
|
#
|
|
# DEBIAN_FRONTEND stops debconf opening a dialog on a machine with no terminal to
|
|
# draw it on, and NEEDRESTART_MODE=a stops needrestart — on by default since
|
|
# Ubuntu 22.04 — interrupting to ask which services to restart. Both belong here
|
|
# rather than at each call site, because forgetting one turns an unattended run
|
|
# into one that is silently waiting for a keypress.
|
|
pkg_refresh() {
|
|
case "$PM" in
|
|
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -y ;;
|
|
pacman) pacman -Sy --noconfirm ;;
|
|
dnf) dnf makecache ;;
|
|
brew) brew update ;;
|
|
esac
|
|
}
|
|
|
|
# What an upgrade would actually move, one package name per line.
|
|
#
|
|
# Asked before the upgrade runs so the section can name what it is about to
|
|
# change rather than asking to be trusted. Needs a refreshed index to be
|
|
# accurate, which is why pkg_refresh runs first.
|
|
#
|
|
# `apt-get upgrade -s` simulates and prints an "Inst <name> …" line per package,
|
|
# which is the same calculation the real run does — as opposed to
|
|
# `apt list --upgradable`, which also lists packages that are held back and
|
|
# would not actually move.
|
|
pkg_upgradable() {
|
|
case "$PM" in
|
|
apt) apt-get upgrade -s 2>/dev/null | awk '/^Inst /{print $2}' ;;
|
|
pacman) pacman -Qu 2>/dev/null | awk '{print $1}' ;;
|
|
dnf) dnf -q check-update 2>/dev/null | awk 'NF >= 3 && $1 !~ /^(Last|Obsoleting)/ {print $1}' ;;
|
|
brew) brew outdated --quiet 2>/dev/null ;;
|
|
esac
|
|
}
|
|
|
|
# Upgrade everything already installed. Separate from pkg_install on purpose:
|
|
# this one DOES move versions, so it is a deliberate step rather than something
|
|
# that happens as a side effect of installing a tool.
|
|
pkg_upgrade_all() {
|
|
case "$PM" in
|
|
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get upgrade -y ;;
|
|
pacman) pacman -Su --noconfirm ;;
|
|
dnf) dnf upgrade -y ;;
|
|
brew) brew upgrade ;;
|
|
esac
|
|
}
|
|
|
|
# The raw install, with no presence check. Use pkg_install instead.
|
|
pkg_install_now() {
|
|
case "$PM" in
|
|
apt) DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y "$@" ;;
|
|
pacman) pacman -S --noconfirm --needed "$@" ;;
|
|
dnf) dnf install -y "$@" ;;
|
|
brew) brew install "$@" ;;
|
|
esac
|
|
}
|
|
|
|
# Announce a section, then install only what is absent from it.
|
|
#
|
|
# pkg_install "Core packages" $(pkgs_core)
|
|
#
|
|
# Prints both lists before touching anything, so the run says what it is about to
|
|
# do to this machine and what it is deliberately leaving alone. Returns 0 when
|
|
# there was nothing to do.
|
|
pkg_install() {
|
|
local label="$1"
|
|
shift
|
|
|
|
local pkg
|
|
local -a missing=() present=()
|
|
LAST_SKIPPED=()
|
|
for pkg in "$@"; do
|
|
if pkg_is_installed "$pkg"; then present+=("$pkg"); else missing+=("$pkg"); fi
|
|
done
|
|
|
|
LAST_INSTALLED=("${missing[@]}")
|
|
LAST_KEPT=("${present[@]}")
|
|
|
|
announce_plan "$label" present missing || return 0
|
|
pkg_install_now "${missing[@]}"
|
|
}
|
|
|
|
# Print what a section is about to do and ask permission for it.
|
|
#
|
|
# Takes the NAMES of the two arrays rather than their contents, because a list
|
|
# passed by value cannot be told apart from an empty one once it has been through
|
|
# word splitting.
|
|
#
|
|
# Returns non-zero when there is nothing to do, or when the answer was no — in
|
|
# both cases the caller should skip its action. LAST_INSTALLED is cleared on a
|
|
# refusal so the summary does not claim work that never happened.
|
|
announce_plan() {
|
|
local label="$1"
|
|
local -n _present="$2"
|
|
local -n _missing="$3"
|
|
|
|
echo ""
|
|
info "${label} — installs what is missing, keeps what you already have"
|
|
((${#_present[@]})) && echo " already here: ${_present[*]}"
|
|
|
|
if ((${#_missing[@]} == 0)); then
|
|
echo " to install: nothing, all present"
|
|
return 1
|
|
fi
|
|
|
|
echo " to install: ${_missing[*]}"
|
|
if ! confirm "Proceed?"; then
|
|
warn "skipped by request"
|
|
LAST_INSTALLED=()
|
|
LAST_SKIPPED=("${_missing[@]}")
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# One summary line describing what a section actually did, from LAST_INSTALLED
|
|
# and LAST_KEPT. Call straight after pkg_install or tools_install.
|
|
summarise_last() {
|
|
local label="$1"
|
|
if ((${#LAST_SKIPPED[@]})); then
|
|
SUMMARY+=("$label: SKIPPED by request — ${LAST_SKIPPED[*]}")
|
|
elif ((${#LAST_INSTALLED[@]} == 0)); then
|
|
SUMMARY+=("$label: already present, nothing installed")
|
|
elif ((${#LAST_KEPT[@]} == 0)); then
|
|
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]}")
|
|
else
|
|
SUMMARY+=("$label installed: ${LAST_INSTALLED[*]} (${#LAST_KEPT[@]} already present)")
|
|
fi
|
|
}
|