Files
automations/automations.sh
T
57_WolveandClaude Opus 5 85d945ccde feat(ergo): persistent message history, plus deployment-readiness audit fixes
Ergo keeps history in RAM by default, so it is lost on every restart --
including the ones this stack's updater performs. HISTORY now selects a backend
at first deploy: sqlite (default, a file beside ircd.db, no extra container),
postgres (a pinned container via docker-compose.postgres.yml, loopback-only,
POSTGRES_PASSWORD_FILE so no secret lands in .env), or off. Both SQL backends
need Ergo 2.18.0+, which deploy.sh enforces. HISTORY_EXPIRE (default 30d) sets
retention, because upstream's 1w expire-time DELETES from persistent storage --
persistence with the shipped default would buy only a week.

Ergo opens the history backend only at startup and, unlike MySQL, has no
"after launching the server" guard for sqlite/postgresql: a rehash that enables
one reports success and then silently discards every message. So `ergoctl
history` restarts, and `ergoctl edit` detects a backend change and refuses to
apply it by rehash. The config validator runs with no network by design, so it
neutralises postgresql in its copy and reachability is checked separately.

Audit fixes (six lenses over first-deploy, runtime, lifecycle, security, docs):

- SECURITY (blocker): ircd/ is writable by the container uid while the 15-minute
  cert sync and every ergoctl config edit run as root. cp/install/> follow a
  symlink, so code execution in Ergo could redirect a root write onto any host
  file. All such writes now stage under $STACK_DIR (0700 root) and land via mv
  (rename(2) replaces a symlink instead of following it); root reads use cp -P
  or refuse. Regression-tested with real symlinks.
- deploy.sh no longer swaps the image when ERGO_TAG changes on a deployed stack
  (that bypassed pre-flight, snapshot, user warning and rollback); it points at
  `TARGET_VERSION=... ergoctl update update`.
- restore was lossy and could lock you out: it now snapshots the current config
  as well as the database, restores message history, re-points the postgres
  password and re-hashes the local admin password into the restored config, and
  validates before starting.
- `install -d -m` re-modes existing directories: `ergoctl backup /var/backups`
  no longer chmods it 0700 root, and the updater no longer re-modes /var/log
  (0775 root:syslog on Debian, which rsyslog needs).
- The admin oper password is printed only on a first run at a TTY, so it stays
  out of cloud-init serial-console logs.
- A failed update is remembered, so the daily job stops repeating a disruptive
  warn/stop/swap/roll-back cycle every night; postgres readiness gates an update
  that would otherwise stop a healthy server it cannot restart.
- certsync no longer sends "TLS recovered" for outcomes that synced nothing.
- ergoctl history writes .env only after the restart is healthy, and returns 0.
- CR/LF is stripped before IRC framing, so a multi-line argument cannot inject a
  second command; the ntfy token moves out of curl's argv.
- ufw/firewalld are additive, so 6667 is now explicitly revoked when PLAINTEXT=0.
- build.sh refuses to build a deploy.sh whose archive is missing a file the
  script reads -- the failure mode that would have shipped a stack aborting on
  every host.

Docs corrected against the code throughout, including retention, the pre-connect
account-registration default, encrypted-restore (AGE_IDENTITY), what a re-run
really does to .env, and what the update log does and does not contain.

Verified locally: all six suites pass (config render for each backend against the
real 2.19.1 template, yaml/oper/version/env helpers, the IRC client against a
fake server, and the audit fixes including the symlink escalation). Still not
exercised on a Docker host: the containers, ACME issuance, cert sync and
PostgreSQL itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:32:37 -05:00

307 lines
15 KiB
Bash

#!/bin/sh
#
# automations.sh -- one command to run or deploy anything in this repo.
#
# Run it two ways:
#
# 1. One-liner on a fresh target host (clones the repo, then launches):
# curl -fsSL https://git.anomalous.dev/57_Wolve/automations/raw/branch/main/automations.sh \
# | REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git sh
#
# 2. From a clone:
# ./automations.sh
#
# It opens a Gum wizard (auto-installed) that lets you:
# • Mode: deploy on THIS host, or build deploy.sh artifacts locally.
# • Pick any deployment (pocket-id, beszel, headscale, webfinger, squid,
# copyparty, simplex, openbao, ergo) or any generic script (setup-host,
# harden-ssh, harden-jumphost, sshuser, auto-update).
# Shared defaults come from globals/ (see globals/README.md).
#
# Non-interactive: set SKIP_PROMPTS=1 plus the needed vars and pipe the menu
# choices in, or just call the underlying deployments/<name>/deploy.sh
# directly -- they all honor SKIP_PROMPTS=1.
# ============================================================================
# PROLOGUE -- POSIX sh only. Everything below the "exec bash" handoff is bash.
#
# The shebang is /bin/sh, not bash, on purpose: a stock Alpine box has busybox
# ash and NO bash at all, so a `#!/usr/bin/env bash` launcher dies before it
# can install anything ("env: 'bash': No such file or directory"). This part
# therefore has to parse and run under ash: no [[ ]], no arrays, no
# BASH_SOURCE, no printf -v. It locates (or clones) the repo, makes sure bash
# exists, and re-execs this same file under bash -- which then skips the
# prologue via BASH_VERSION and runs the real launcher.
# ============================================================================
set -eu
_boot_log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
_boot_die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
# Install packages with whichever manager this distro has -- apk (Alpine),
# apt-get (Debian/Ubuntu), dnf/yum (Alma/RHEL). oslib.sh's pkg_install can't
# help here: it's bash, and on the piped path it isn't even on disk yet.
_boot_install() {
if command -v apk >/dev/null 2>&1; then
apk add -q "$@" 2>/dev/null && return 0
apk update -q >/dev/null 2>&1 || true # stale/absent index on a fresh box
apk add -q "$@"
elif command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@"
elif command -v dnf >/dev/null 2>&1; then
dnf install -y -q "$@"
elif command -v yum >/dev/null 2>&1; then
yum install -y -q "$@"
else
return 1
fi
}
_boot_need() { # _boot_need <command> [package] -> 0 if it's available afterwards
if command -v "$1" >/dev/null 2>&1; then return 0; fi
_boot_log "$1 not found; installing it..."
_boot_install "${2:-$1}" || true
command -v "$1" >/dev/null 2>&1
}
# ----------------------------------------------------------------------------
# Self-locate, or bootstrap by cloning the repo (one-liner / piped form).
# ----------------------------------------------------------------------------
ROOT=""
# Strip the last path component ourselves rather than calling dirname: busybox
# dirname takes its first argument literally, so `dirname -- "$0"` would answer
# "." on Alpine. `$0` is "sh"/"bash" (no slash) when we're piped from curl.
case "$0" in
*/*) _dir="${0%/*}" ;;
*) _dir="." ;;
esac
_dir="$(CDPATH= cd "$_dir" 2>/dev/null && pwd)" || _dir=""
if [ -n "$_dir" ] && [ -f "$_dir/scripts/lib.sh" ]; then
ROOT="$_dir"
else
# Piped via curl: we don't have the repo on disk. Clone it, then hand off.
: "${REPO_URL:=}"
: "${REPO_BRANCH:=main}"
[ -n "$REPO_URL" ] || _boot_die "Running standalone (piped). Set REPO_URL=... so I can clone the repo."
_boot_need git || _boot_die "git is required to clone the repo, but it isn't installed and I couldn't install it automatically (need root + a supported package manager). Install git, then re-run."
_tmp="$(mktemp -d -t automations.XXXXXX)"
_boot_log "Cloning $REPO_URL ($REPO_BRANCH)..."
git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" "$_tmp"
ROOT="$_tmp"
fi
# ----------------------------------------------------------------------------
# Hand off to bash. Needed when we're running under ash/dash, and when the body
# we want is the freshly cloned copy rather than the piped stdin we came from.
# ----------------------------------------------------------------------------
if [ -z "${BASH_VERSION:-}" ] || [ ! -f "$0" ] || [ "$ROOT" != "$_dir" ]; then
if [ "${_AUTOMATIONS_REEXEC:-0}" = 1 ]; then
# Already handed off once. If we're in bash the handoff worked and only
# the path comparison differs (symlinked checkout) -- just continue.
[ -n "${BASH_VERSION:-}" ] || _boot_die "Re-exec under bash did not take effect. Run it explicitly: bash $ROOT/automations.sh"
else
# The launcher, everything it sources (scripts/lib.sh, scripts/oslib.sh),
# and every deploy.sh it invokes are bash. Alpine images routinely ship
# without it, so install it before going any further.
_boot_need bash || _boot_die "bash is required, but it isn't installed and I couldn't install it automatically (need root + a supported package manager). Install bash, then re-run."
_AUTOMATIONS_REEXEC=1; export _AUTOMATIONS_REEXEC
exec bash "$ROOT/automations.sh" "$@"
fi
fi
# ============================================================================
# Running under bash from here down.
# ============================================================================
set -euo pipefail
# shellcheck source=scripts/lib.sh
. "$ROOT/scripts/lib.sh"
load_globals
DEPLOYMENTS=(pocket-id beszel headscale webfinger squid copyparty simplex openbao ergo)
SCRIPTS=(setup-host harden-ssh harden-jumphost sshuser auto-update)
# ----------------------------------------------------------------------------
# Prompt helpers (gum). `ask` records each answer in ENVS for passing onward.
# ----------------------------------------------------------------------------
ENVS=()
ask() { # <VAR> <label> [password|optional]
local var="$1" label="$2" mode="${3:-}"
local cur="${!var:-}" val # indirect ref needs var already declared
if [[ "$mode" == "password" ]]; then
val="$(gum input --password --header "$label")"
else
val="$(gum input --header "$label" --value "$cur" --placeholder "$cur")"
fi
[[ "$mode" == "optional" && -z "$val" ]] && return 0
printf -v "$var" '%s' "$val"
ENVS+=("$var=$val")
}
# Which values to ask for, per deployment. Generated secrets are produced by
# the deploy scripts themselves and are intentionally not listed here.
ask_deployment_vars() {
case "$1" in
pocket-id)
ask POCKETID_DOMAIN "Public hostname (e.g. id.example.com)"
ask ACME_EMAIL "Let's Encrypt email"
ask BASE_DOMAIN "WebFinger base domain (blank = none / use webfinger deployment)" optional
if [[ -n "${BASE_DOMAIN:-}" ]]; then ask REDIRECT_URL "Redirect target for the base domain"; fi ;;
beszel)
ask BESZEL_DOMAIN "Public hostname (e.g. monitoring.example.com)"
ask ACME_EMAIL "Let's Encrypt email" ;;
headscale)
ask HEADSCALE_DOMAIN "headscale hostname (e.g. hs.example.com)"
ask ACME_EMAIL "Let's Encrypt email"
ask TAILNET_DOMAIN "Tailnet MagicDNS base (e.g. tail.example.com)"
ask POCKETID_DOMAIN "OIDC issuer hostname (your pocket-id)"
ask OIDC_CLIENT_ID "OIDC client_id (from pocket-id)"
ask OIDC_CLIENT_SECRET "OIDC client_secret (from pocket-id)" password
ask HEADPLANE_OIDC_CLIENT_ID "headplane UI OIDC client_id (blank = API-key login)" optional
if [[ -n "${HEADPLANE_OIDC_CLIENT_ID:-}" ]]; then ask HEADPLANE_OIDC_CLIENT_SECRET "headplane UI OIDC client_secret" password; fi ;;
webfinger)
ask BASE_DOMAIN "Apex domain to serve (e.g. example.com)"
ask ISSUER_URL "OIDC issuer URL (e.g. https://auth.example.com)"
ask REDIRECT_URL "Redirect target for other traffic (e.g. https://example.org)"
ask ACME_EMAIL "Let's Encrypt email" ;;
squid)
ask TRUSTED_CIDR "Trusted client CIDR(s) allowed to use the proxy (e.g. 100.64.0.0/10)"
ask BIND_ADDR "Host IP to bind the proxy on (blank = 0.0.0.0)" optional
ask CACHE_SIZE_MB "On-disk cache size in MB (blank = 5000)" optional
ask CACHE_ONLY_LISTED "Cache ONLY listed domains? (1=yes, blank=boost mode)" optional ;;
copyparty)
ask COPYPARTY_DOMAIN "Public hostname for the web UI (e.g. files.example.com)"
ask ACME_EMAIL "Let's Encrypt email"
ask DATA_DIR "Host data folder shared as the root (blank = /srv/copyparty/data)" optional
ask FTP_NAT "Public IP for passive FTPS via NAT (blank = none)" optional
ask UPDATE_POLICY "Auto-update policy: latest | security | off (blank = latest)" optional ;;
simplex)
ask DOMAIN "Apex domain (creates smp.DOMAIN, xftp.DOMAIN)"
ask ACME_EMAIL "Let's Encrypt email"
ask XFTP_QUOTA "XFTP disk quota" optional
ask SSH_PORT "SSH port" optional
ask ALLOWED_IP "Your IP to whitelist in sshguard" optional ;;
openbao)
ask OPENBAO_ADDR "LAN address the Kanrisha tape host reaches the vault at (IP or DNS)"
ask OPENBAO_BIND "Host IP to bind the API on (blank = 0.0.0.0)" optional ;;
ergo)
ask ERGO_DOMAIN "IRC server hostname (e.g. irc.example.com)"
ask ACME_EMAIL "Let's Encrypt email"
ask NETWORK_NAME "IRC network name, no spaces (blank = the hostname)" optional
ask HISTORY "Persistent message history: sqlite | postgres | off (blank = sqlite)" optional
ask PLAINTEXT "Also serve PUBLIC plaintext IRC on 6667? (1 = yes, blank = no)" optional
ask UPDATE_POLICY "Auto-update policy: latest | security | off (blank = latest)" optional ;;
esac
}
ask_script_vars() {
case "$1" in
setup-host)
ask HOST "Hostname <svc>-<n> or FQDN (e.g. sto-1)"
ask BASE_DOMAIN "Base domain" optional
ask DATACENTER "Data center label" optional ;;
harden-ssh)
ask SSH_PORT "SSH port to listen on" optional
ask ALLOWED_IP "Your IP to whitelist in sshguard" optional
ask NTFY_URL "ntfy login-notify URL (blank to skip)" optional
if [[ -n "${NTFY_URL:-}" ]]; then ask NTFY_TOKEN "ntfy bearer token (blank if unauth publish)" password; fi ;;
harden-jumphost)
ask SSH_PORT "SSH port to listen on" optional
ask ALLOWED_IP "Your IP to whitelist in sshguard" optional
ask JUMP_TARGETS "Allowed ProxyJump targets (host:port, space-separated)" optional
ask NTFY_URL "ntfy login-notify URL (blank to skip)" optional
if [[ -n "${NTFY_URL:-}" ]]; then ask NTFY_TOKEN "ntfy bearer token (blank if unauth publish)" password; fi ;;
auto-update)
ask AUTO_REBOOT "Auto-reboot when needed? (0=never, 1=always, idle=when no SSH active)" optional
ask ALLOW_RELEASE_UPGRADE "Also upgrade to a new Alpine stable release? (1/0)" optional ;;
sshuser)
: ;; # sshuser.sh has its own interactive interface
esac
}
require_root() {
[[ $EUID -eq 0 ]] || _die "Deploying on this host must run as root."
os_detect # validates the distro is supported (Alpine/Debian/Alma)
}
# ----------------------------------------------------------------------------
# Dispatch
# ----------------------------------------------------------------------------
bootstrap_deployment() {
local name="$1"
require_root
ask_deployment_vars "$name"
if [[ "$name" == "simplex" ]]; then
# install-simplex.sh re-clones REPO_URL and runs harden + deploy + backup.
[[ -n "${REPO_URL:-}" ]] || _die "REPO_URL is required for simplex (set it in globals.env)."
_log "Launching simplex installer..."
env "${ENVS[@]}" REPO_URL="$REPO_URL" REPO_BRANCH="${REPO_BRANCH:-main}" SKIP_PROMPTS=1 \
bash "$ROOT/deployments/simplex/install-simplex.sh"
else
_log "Deploying $name on this host..."
env "${ENVS[@]}" SKIP_PROMPTS=1 bash "$ROOT/deployments/$name/deploy.sh"
fi
}
bootstrap_script() {
local name="$1"
require_root
ask_script_vars "$name"
# auto-update from the menu means "schedule the daily job".
local subcmd=""
[[ "$name" == "auto-update" ]] && subcmd="install"
_log "Running $name on this host..."
env "${ENVS[@]}" FORCE=1 bash "$ROOT/scripts/$name.sh" $subcmd
}
build_deployment() {
local name="$1"
if [[ "$name" == "simplex" ]]; then
_warn "simplex has no embedded-archive build step; it deploys via install-simplex.sh."
return 0
fi
local dir="$ROOT/deployments/$name"
[[ -f "$dir/build.sh" ]] || _die "$name has no build.sh."
_log "Building $name/deploy.sh..."
bash "$dir/build.sh"
if gum confirm "scp $name/deploy.sh to a host now?"; then
local target port
target="$(gum input --header "scp target (user@host)" --placeholder "root@host")"
port="$(gum input --header "SSH port" --value "${SSH_PORT:-22}")"
[[ -n "$target" ]] || { _warn "No target given; skipping scp."; return 0; }
scp -P "${port:-22}" "$dir/deploy.sh" "$target:"
_log "Copied. On the host, run: bash deploy.sh"
fi
}
# ----------------------------------------------------------------------------
# Wizard
# ----------------------------------------------------------------------------
ensure_gum
MODE="$(gum choose --header "What do you want to do?" \
"Deploy on this host" \
"Build deploy.sh artifacts locally")"
case "$MODE" in
"Deploy on this host")
CHOICE="$(printf '%s\n' \
"${DEPLOYMENTS[@]/#/deploy: }" \
"${SCRIPTS[@]/#/script: }" \
| gum choose --header "Pick a deployment or script")"
kind="${CHOICE%%: *}"; name="${CHOICE#*: }"
case "$kind" in
deploy) bootstrap_deployment "$name" ;;
script) bootstrap_script "$name" ;;
*) _die "Nothing selected." ;;
esac ;;
"Build deploy.sh artifacts locally")
name="$(printf '%s\n' "${DEPLOYMENTS[@]}" | gum choose --header "Pick a deployment to build")"
[[ -n "$name" ]] || _die "Nothing selected."
build_deployment "$name" ;;
*)
_die "Nothing selected." ;;
esac
_log "Done."