The deployment shipped its payload but not the thing that installs it.54a5c09added 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 from947c899-- 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>
320 lines
16 KiB
Bash
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."
|