Files
automations/automations.sh
T
57_WolveandClaude Opus 5 0c151ce79b feat(knot-dns): add the missing host-level installer
The deployment shipped its payload but not the thing that installs it.
54a5c09 added README, knot.conf, knsctl, zone.tmpl, secrets.conf.example,
the aliases and the MOTD -- but no deploy.sh, no cloud-init.yml, and no
entry in automations.sh's DEPLOYMENTS. `git log --all` confirms deploy.sh
was never committed and it is not gitignored, yet README.md:96 and :100
tell the operator to run it. So the documented install path did not exist.

Alpine only, native, matching the README: Knot binds :53 directly, needs
real client addresses for RRL and cookies, and keeps its DNSSEC key store on
the host filesystem. The RHEL packaging needs EPEL, which nothing here sets
up, so anything that is not Alpine dies with a clear message rather than
half-installing somewhere untested.

Three decisions worth recording:

The include chain is stubbed. knot.conf include:s seven files the `dns` repo
owns; Knot treats a missing include as a config error, so a node the pipeline
has never delivered to would fail conf-check and never start. deploy.sh
writes a placeholder for each one that is ABSENT -- never over a delivered
file -- so the node comes up healthy serving no zones until the pipeline
lands.

TSIG is generated on a primary and required on a secondary. The keys must
match byte for byte, so a secondary that generated its own would
authenticate nothing; it now refuses to deploy without TSIG_AUTHORITIVE and
TSIG_ADMIN. A primary generates both and prints them once. An existing
secrets.conf is never rewritten, so a re-run cannot rotate a key out from
under a running estate.

PRIMARY_ADDR seeds a minimal remotes.conf on a secondary so it can bootstrap
by AXFR before the pipeline runs -- written only when remotes.conf was
absent, verified by re-running against a delivered file and confirming it is
left untouched.

Re-runs apply changes rather than freezing at first deploy, per the pattern
this repo just adopted elsewhere: knot.conf is re-rendered from .env every
run, env-presence is captured before the ":=" defaults, and values passed to
a re-run are written back to .env with the awk-based set_env from 947c899 --
which matters here because a TSIG secret can contain the characters that
broke the sed-based one.

Two bugs caught while testing this, before it shipped:
- the secrets.conf renderer used `++n` as a gsub argument, which awk
  evaluates on every line, not just matching ones -- both keys would have
  received the SAME secret, making the read-only admin key identical to the
  replication key. Increments on a matching line only now.
- the MOTD is a pre-drawn box, so substituting values of a different width
  than their @TOKEN@ shifted the right border on every login. Values are now
  padded to the token's span, measured over an ASCII-only region so it holds
  under busybox awk in the C locale; an over-long value overflows rather than
  being truncated.

Verified: knot.conf renders identity/NSID/listen and leaves the control
socket alone; all seven stubs are created on a fresh node and skipped on a
re-run; the remotes.conf seed fires only for a fresh secondary; .env seeds
every runtime key; the MOTD renders with no leftover tokens and an aligned
border. Not verified: apk, knotc and the service start, which need an actual
Alpine host.

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

320 lines
16 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 knot-dns)
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 ;;
knot-dns)
ask NODE_ID "Node ID for server.identity / NSID (e.g. ANYCAST-DNS-3)"
ask ROLE "Role: primary | secondary"
ask LISTEN "Listen addresses (blank = 0.0.0.0@53, ::@53)" optional
# A secondary's TSIG keys must byte-match the primary's, so they are
# copied from it rather than generated here.
if [[ "${ROLE:-}" == secondary ]]; then
ask PRIMARY_ADDR "Address of the primary this node transfers from"
ask TSIG_AUTHORITIVE "TSIG authortive-tsig secret (from the primary)" password
ask TSIG_ADMIN "TSIG admin-tsig secret (from the primary)" password
fi
ask DATACENTER "Data centre label for the MOTD" optional
ask PEERS "Peer addresses for 'knsctl serials' (space-separated)" 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."