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>
307 lines
15 KiB
Bash
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."
|