Files
automations/deployments/ergo/ergolib.sh
T
57_WolveandClaude Opus 5 f916eb549a feat(ergo): ergoctl oper certfp, plus polish from the first live deploy
Adds client-certificate authentication for operators, so an oper can be
identified by the SHA-256 fingerprint of their TLS client certificate instead of
a typed password:

  ergoctl oper certfp <name> <fingerprint|nick> [--auto] [--clear]

The fingerprint can be given directly or read off a connected user over IRC
(276 RPL_WHOISCERTFP). Without --auto Ergo requires BOTH the certificate and the
password -- a second factor; with --auto the password is removed and the
certificate alone grants oper on connect.

It refuses to do this to the 'admin' oper, which matters: ergoctl authenticates
as admin over the loopback PLAINTEXT listener, which presents no client
certificate, and Ergo requires a configured certfp to match. Setting one there
would lock ergoctl -- and the scheduled jobs that use it -- out of the server
permanently.

Polish from the first real deploy on irc-1.srvno.de:

- deploy.sh no longer restarts Caddy on a first deploy. The Caddyfile compare
  treated "destination does not exist" as a change, so Caddy was recreated
  seconds after starting, while the initial ACME order was in flight.
- ergoctl status no longer prints the raw leading parameters of the 265/266
  LUSERS numerics ("1 1 Current local users 1, max 1"), which duplicate the
  counts already in the text. 252/254 keep theirs, where it is the only count.
- ergoctl oper list now shows which opers have a certfp and which are auto.

Verified: seven local suites pass, including new coverage for fingerprint
normalisation (colons and uppercase), insertion into the correct oper block
without touching siblings, replace-not-duplicate, the --auto and --clear paths,
276 parsing, and the admin guard.

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

729 lines
34 KiB
Bash

#!/usr/bin/env bash
#
# ergolib.sh -- shared helpers for the Ergo stack. Sourced by deploy.sh (from the
# extracted archive), update.sh and ergoctl (from $STACK_DIR). Not run directly.
#
# Everything that touches the running stack lives here so the three scripts stay
# consistent: .env access, the compose wrapper, health + IRC probes over the
# loopback listener, the throwaway-container config validator, Caddy->Ergo cert
# sync, oper edits in ircd.yaml, and ntfy notifications.
#
# Conventions: functions return non-zero instead of exiting (callers decide),
# and everything is portable to busybox (Alpine) -- POSIX awk, no GNU date -d,
# no sort -V, no grep -P.
# ---------------------------------------------------------------------------
# Logging (reuse the caller's if defined)
# ---------------------------------------------------------------------------
if ! declare -f log >/dev/null 2>&1; then
log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
fi
# ---------------------------------------------------------------------------
# Config file (/etc/ergo-update.conf): KEY=VALUE defaults for the scheduled
# jobs. Environment wins over the file, the file over built-ins.
# ---------------------------------------------------------------------------
: "${ERGO_UPDATE_CONF:=/etc/ergo-update.conf}"
load_conf() {
[[ -r "$ERGO_UPDATE_CONF" ]] || return 0
local _line _k _v
while IFS= read -r _line || [[ -n "$_line" ]]; do
[[ "$_line" =~ ^[[:space:]]*# || -z "${_line//[[:space:]]/}" ]] && continue
_k="${_line%%=*}"; _v="${_line#*=}"; _k="${_k//[[:space:]]/}"
[[ "$_k" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
[[ -n "${!_k:-}" ]] && continue
_v="${_v%\"}"; _v="${_v#\"}"
printf -v "$_k" '%s' "$_v"
done < "$ERGO_UPDATE_CONF"
return 0
}
# Derived paths. Call after STACK_DIR is final.
ergo_set_paths() {
: "${STACK_DIR:=/srv/ergo}"
ENV_FILE="$STACK_DIR/.env"
IRCD_DIR="$STACK_DIR/ircd"
CADDY_DIR="$STACK_DIR/caddy"
SECRETS_DIR="$STACK_DIR/secrets"
BACKUP_DIR="$STACK_DIR/backups"
TEMPLATES_DIR="$STACK_DIR/templates"
STATE_DIR="$STACK_DIR/.state"
ADMIN_PASS_FILE="$SECRETS_DIR/admin.pass"
: "${GH_REPO:=ergochat/ergo}"
: "${NOTIFY:=1}"
: "${SSH_NOTIFY_CONF:=/etc/ssh-notify.conf}"
: "${DRY_RUN:=0}"
}
osfam() {
local id="" like=""
if [[ -r /etc/os-release ]]; then
id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")"
like="$(. /etc/os-release 2>/dev/null && echo "${ID_LIKE:-}")"
fi
case " $id $like " in
*" alpine "*) echo alpine ;;
*" debian "*|*" ubuntu "*) echo debian ;;
*" rhel "*|*" fedora "*|*" centos "*) echo rhel ;;
*) echo "${id:-unknown}" ;;
esac
}
# fetch a URL to stdout (curl or wget); rc 1 on failure.
fetch() {
if command -v curl >/dev/null 2>&1; then curl -fsSL -m 20 "$1" 2>/dev/null
elif command -v wget >/dev/null 2>&1; then wget -qO- -T 20 "$1" 2>/dev/null
else return 1
fi
}
# ---------------------------------------------------------------------------
# Writing into ircd/ safely
#
# ircd/ is owned by the unprivileged container uid, but several things that
# write there run as ROOT (the 15-minute cert sync, every ergoctl config edit).
# A plain `cp`, `install` or `>` follows a symlink at the destination, so code
# execution inside Ergo could point one of those writes at any root-owned file
# and win the host. rename(2) is the one operation that REPLACES a symlink
# instead of following it, so every root write into ircd/ goes: create a temp
# under $STACK_DIR (root-owned 0700, same filesystem) -> set mode/owner -> mv.
# ---------------------------------------------------------------------------
# A temp file that an unprivileged uid cannot reach or pre-empt.
stage_tmp() { mktemp "$STACK_DIR/.stage.XXXXXX"; }
# safe_replace <tmpfile> <target> [mode] [uid] [gid]
safe_replace() {
local tmp="$1" target="$2" mode="${3:-0600}" uid="${4:-}" gid="${5:-}"
chmod "$mode" "$tmp" 2>/dev/null || true
[[ -n "$uid" ]] && chown "${uid}:${gid:-$uid}" "$tmp" 2>/dev/null
mv -f "$tmp" "$target"
}
# Refuse to read a path under ircd/ that the container uid has turned into a
# symlink (a root reader would otherwise copy out whatever it points at).
refuse_symlink() { # <path> [what]
[[ -L "$1" ]] || return 0
warn "${2:-$1} is a symlink; refusing to touch it as root (see README: Security model)."
return 1
}
# ---------------------------------------------------------------------------
# .env access
# ---------------------------------------------------------------------------
env_get() { [[ -f "$ENV_FILE" ]] && grep -E "^$1=" "$ENV_FILE" | head -n1 | cut -d= -f2- || true; }
env_set() { # <KEY> <value>: update KEY in .env, or append if absent
local key="$1" val="$2" esc
esc=${val//\\/\\\\}; esc=${esc//|/\\|}; esc=${esc//&/\\&}
if grep -qE "^${key}=" "$ENV_FILE" 2>/dev/null; then
sed -i -e "s|^${key}=.*|${key}=${esc}|" "$ENV_FILE"
else
printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
fi
}
ergo_domain() { env_get ERGO_DOMAIN | tr 'A-Z' 'a-z' || true; }
# The compose invocation must include the postgres overlay whenever history runs
# on PostgreSQL. deploy.sh writes COMPOSE_FILE into .env, which docker compose
# reads by itself -- these helpers are for code that needs to know.
pg_enabled() { [[ -f "$STACK_DIR/docker-compose.postgres.yml" ]] && grep -q '^COMPOSE_FILE=.*postgres' "$ENV_FILE" 2>/dev/null; }
# Is PostgreSQL up and accepting our credentials? rc 0 ok, 1 not.
PG_CHECK_MSG=""
pg_check() {
PG_CHECK_MSG=""
if ! pg_enabled; then PG_CHECK_MSG="postgres not in use"; return 0; fi
if [[ "$(svc_state postgres)" != running* ]]; then PG_CHECK_MSG="the postgres container is not running"; return 1; fi
local db user
db="$(env_get POSTGRES_DB)"; user="$(env_get POSTGRES_USER)"
if dc exec -T postgres pg_isready -U "${user:-ergo}" -d "${db:-ergo_history}" >/dev/null 2>&1; then
PG_CHECK_MSG="postgres ready (${db:-ergo_history})"; return 0
fi
PG_CHECK_MSG="postgres is running but not accepting connections yet"
return 1
}
# Fill the named variables from .env when they are not already set in the
# environment. Used by update.sh/ergoctl so the knobs .env documents really are
# the ones the scheduled jobs obey (environment > /etc/ergo-update.conf > .env).
env_defaults() { # env_defaults <VAR>...
local k v
for k in "$@"; do
[[ -n "${!k:-}" ]] && continue
v="$(env_get "$k")"
[[ -n "$v" ]] && printf -v "$k" '%s' "$v"
done
return 0
}
ergo_uid() { local v; v="$(env_get ERGO_UID)"; printf '%s' "${v:-1000}"; }
ergo_gid() { local v; v="$(env_get ERGO_GID)"; printf '%s' "${v:-1000}"; }
ergo_tag() { local v; v="$(env_get ERGO_TAG)"; printf '%s' "${v:-stable}"; }
ergo_image() { local i; i="$(env_get ERGO_IMAGE)"; printf '%s:%s' "${i:-ghcr.io/ergochat/ergo}" "$(ergo_tag)"; }
# docker compose, scoped to the stack dir (so ./ircd, ./caddy resolve and .env
# is auto-loaded).
dc() { ( cd "$STACK_DIR" && docker compose "$@" ); }
# ---------------------------------------------------------------------------
# Version helpers (pure bash; busybox sort has no -V)
# ---------------------------------------------------------------------------
# NOTE the trailing `|| true` here and in the cert/state helpers below: these are
# assigned in `set -euo pipefail` scripts, where a no-match grep would abort the
# caller instead of yielding "". Callers test for an empty result.
normver() { printf '%s' "$1" | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n1 || true; }
ver_cmp() { # <a> <b> -> prints -1 / 0 / 1
local a b i x y
IFS=. read -r -a a <<< "$(normver "$1")"
IFS=. read -r -a b <<< "$(normver "$2")"
for i in 0 1 2; do
x="${a[$i]:-0}"; y="${b[$i]:-0}"
if (( x > y )); then echo 1; return; fi
if (( x < y )); then echo -1; return; fi
done
echo 0
}
ver_gt() { [[ "$(ver_cmp "$1" "$2")" == "1" ]]; }
ver_ge() { [[ "$(ver_cmp "$1" "$2")" != "-1" ]]; }
ver_le() { [[ "$(ver_cmp "$1" "$2")" != "1" ]]; }
# Running version as reported by the binary ('ergo-v2.19.1' or 'ergo-2.19.1[-hash]');
# falls back to the pinned tag when the container is down.
ergo_version_running() {
local v=""
if ergo_running; then
v="$(dc exec -T ergo /ircd-bin/ergo --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)"
fi
[[ -n "$v" ]] || v="$(normver "$(ergo_tag)")"
printf '%s' "$v"
}
# Newest non-prerelease tag from GitHub (e.g. v2.19.1). rc 1 if unreachable.
ergo_latest_tag() {
local t
t="$(fetch "https://api.github.com/repos/${GH_REPO}/releases/latest" \
| grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' | head -n1 \
| sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')"
[[ -n "$t" ]] || return 1
printf '%s' "$t"
}
# ---------------------------------------------------------------------------
# Container state
# ---------------------------------------------------------------------------
# "<state> <health>" for one compose service, e.g. "running healthy", "exited ".
# Empty when the container does not exist or the docker daemon is unreachable.
svc_state() {
dc ps -a --format '{{.Service}} {{.State}} {{.Health}}' 2>/dev/null | awk -v s="$1" '$1==s {print $2" "$3; exit}' || true
}
ergo_running() { [[ "$(svc_state ergo)" == running* ]]; }
# Wait for a service to be "running healthy"; rc 1 on timeout. Exited/restarting
# containers never count (docker compose ps hides them without -a).
wait_healthy() { # <service> [timeout-seconds]
local svc="$1" deadline st
deadline=$(( $(date +%s) + ${2:-120} ))
while (( $(date +%s) < deadline )); do
st="$(svc_state "$svc")"
[[ "$st" == "running healthy" ]] && return 0
sleep 3
done
return 1
}
# ---------------------------------------------------------------------------
# Talking IRC to the loopback listener (127.0.0.1:6667) with bash /dev/tcp.
#
# irc_raw [--oper] [--quiet SECS] -- <line>...
#
# Registers a throwaway nick, optionally opers up as 'admin' (password from
# $ADMIN_PASS_FILE or $ERGO_OPER_PASS), sends each line, prints the server's
# replies (control characters stripped) until the server has been quiet for
# --quiet seconds, then QUITs. IRC_LAST holds the raw transcript, but only when
# irc_raw is called directly -- inside $(...) it stays in the subshell, so parse
# the printed output instead.
#
# rc: 0 ok | 3 OPER refused | 4 connect failed | 5 registration failed
# Loopback is exempt from ip-limits/bans, but fakelag applies until opered:
# NICK+USER+OPER is 3 of the 5-line burst, so keep pre-oper payloads short.
# ---------------------------------------------------------------------------
IRC_LAST=""
# Strip CR/LF/NUL before framing: an argument containing a newline (e.g.
# `ergoctl announce "$(cat file)"`) would otherwise terminate this line and make
# the rest a second command, executed with the admin oper's full privileges.
_irc_send() {
local l="${1//$'\r'/}"; l="${l//$'\n'/ }"; l="${l//$'\0'/}"
printf '%s\r\n' "$l" >&3 2>/dev/null
}
_irc_cmd() { # echo the command/numeric token of a server line
local l="$1"
if [[ "$l" == :* ]]; then l="${l#* }"; fi
printf '%s' "${l%% *}"
}
irc_raw() {
local oper=0 quiet=2 line cmd nick tries=0 pass="" registered=0 opered=0 out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--oper) oper=1; shift ;;
--quiet) quiet="$2"; shift 2 ;;
--) shift; break ;;
*) break ;;
esac
done
IRC_LAST=""
if [[ "$oper" == 1 ]]; then
pass="${ERGO_OPER_PASS:-}"
[[ -n "$pass" ]] || pass="$(head -n1 "$ADMIN_PASS_FILE" 2>/dev/null || true)"
[[ -n "$pass" ]] || { warn "No admin oper password (expected $ADMIN_PASS_FILE)."; return 3; }
fi
if ! { exec 3<>/dev/tcp/127.0.0.1/6667; } 2>/dev/null; then
return 4
fi
nick="ergoctl$RANDOM"
_irc_send "NICK $nick"
_irc_send "USER ergoctl 0 * :ergoctl"
while IFS= read -r -t 15 -u 3 line; do
line="${line%$'\r'}"
IRC_LAST+="$line"$'\n'
cmd="$(_irc_cmd "$line")"
case "$cmd" in
PING) _irc_send "PONG ${line#PING }" ;;
376|422) registered=1; break ;;
433) # nick in use -- pick another (up to 3 times)
tries=$((tries + 1)); (( tries > 3 )) && break
nick="ergoctl$RANDOM"; _irc_send "NICK $nick" ;;
432|464|465|ERROR|FAIL) break ;;
esac
done
if [[ "$registered" != 1 ]]; then
exec 3>&- 2>/dev/null; return 5
fi
if [[ "$oper" == 1 ]]; then
_irc_send "OPER admin $pass"
while IFS= read -r -t 15 -u 3 line; do
line="${line%$'\r'}"
IRC_LAST+="$line"$'\n'
cmd="$(_irc_cmd "$line")"
case "$cmd" in
PING) _irc_send "PONG ${line#PING }" ;;
381) opered=1; break ;;
491|464|ERROR) break ;;
esac
done
if [[ "$opered" != 1 ]]; then
_irc_send "QUIT :ergoctl"; exec 3>&- 2>/dev/null; return 3
fi
fi
for line in "$@"; do _irc_send "$line"; done
while IFS= read -r -t "$quiet" -u 3 line; do
line="${line%$'\r'}"
IRC_LAST+="$line"$'\n'
cmd="$(_irc_cmd "$line")"
case "$cmd" in
PING) _irc_send "PONG ${line#PING }"; continue ;;
ERROR) out+="$line"$'\n'; break ;;
esac
out+="$line"$'\n'
done
_irc_send "QUIT :ergoctl"
exec 3>&- 2>/dev/null
# Never let user-controlled IRC text drive the terminal: drop control chars
# (this also strips IRC formatting bytes 0x02/0x03/0x1d-0x1f).
[[ -n "$out" ]] && printf '%s' "$out" | tr -d '\000-\010\013\014\016-\037\177'
return 0
}
# Registration-only probe (no oper): proves the config loaded and the server
# completes a handshake. rc 0/1.
irc_probe() { irc_raw --quiet 1 -- >/dev/null 2>&1; }
# REHASH as the admin oper; sets REHASH_MSG. rc 0 = "Rehash complete",
# 1 = server refused (message in REHASH_MSG), 2 = could not connect/oper.
REHASH_MSG=""
irc_rehash() {
local out rc
REHASH_MSG=""
out="$(irc_raw --oper --quiet 3 -- REHASH)"; rc=$?
if (( rc != 0 )); then
REHASH_MSG="could not reach Ergo as oper (rc=$rc)"; return 2
fi
# $out holds every reply after the REHASH was sent (the $() subshell keeps
# IRC_LAST from us, so parse the printed transcript).
if printf '%s' "$out" | grep -qi 'Rehash complete'; then return 0; fi
REHASH_MSG="$(printf '%s\n' "$out" | awk '$2=="400" {sub(/^[^ ]+ 400 [^ ]+ REHASH :?/,""); print; exit}')"
[[ -n "$REHASH_MSG" ]] || REHASH_MSG="no confirmation from the server: $(printf '%s' "$out" | tail -n 3 | tr '\n' ' ')"
return 1
}
# ---------------------------------------------------------------------------
# Helper containers: never on a network, never privileged, run as the ergo uid.
# ---------------------------------------------------------------------------
ergo_helper() { # ergo_helper [docker-run-opts...] -- <image> <args...>
local opts=()
while [[ $# -gt 0 && "$1" != "--" ]]; do opts+=("$1"); shift; done
[[ "${1:-}" == "--" ]] && shift
docker run --rm --network none --cap-drop ALL --security-opt no-new-privileges \
--user "$(ergo_uid):$(ergo_gid)" "${opts[@]}" "$@"
}
# The image's own default config (already patched by Ergo's Dockerfile: public
# ":6667", no [::1]). Writes to <dest>; rc 1 if nothing came out.
ergo_extract_template() { # <image> <dest>
local image="$1" dest="$2"
ergo_helper --entrypoint cat -- "$image" /ircd-bin/default.yaml > "$dest" 2>/dev/null || true
if ! grep -q '^server:' "$dest" 2>/dev/null; then
ergo_helper --entrypoint /ircd-bin/ergo -- "$image" defaultconfig > "$dest" 2>/dev/null || true
fi
grep -q '^server:' "$dest" 2>/dev/null
}
# bcrypt hash for a password (stdin -> stdout, one line). rc 1 if it doesn't
# look like a bcrypt hash.
ergo_genpasswd() { # <image> <password>
local h
h="$(printf '%s\n' "$2" | ergo_helper -i --entrypoint /ircd-bin/ergo -- "$1" genpasswd 2>/dev/null | tr -d '\r\n')"
[[ "$h" == \$2a\$* || "$h" == \$2b\$* ]] || return 1
printf '%s' "$h"
}
# Random password from [A-Za-z0-9_-]: never trips Ergo's passphrase checks.
ergo_random_password() { head -c 256 /dev/urandom | tr -dc '_A-Za-z0-9-' | head -c "${1:-24}"; }
# ---------------------------------------------------------------------------
# Config validation: `ergo run --smoke` in a throwaway container against a COPY
# of ./ircd. Its own network namespace means the wildcard listeners bind fine,
# the copied ircd.db/lock are disposable, and a pending schema upgrade is
# dry-run on the copy. rc 0 = config (and certs) load; output in $VALIDATE_LOG.
# Ergo has no offline config checker and --smoke on the live dir would fight
# the running server for the flock and the ports.
# ---------------------------------------------------------------------------
VALIDATE_LOG=""
ergo_validate_config() { # [image]
local image="${1:-$(ergo_image)}" tmp rc
tmp="$(mktemp -d "$STACK_DIR/.validate.XXXXXX")" || return 1
VALIDATE_LOG="$(mktemp)"
if ! cp -a "$IRCD_DIR"/. "$tmp"/ 2>>"$VALIDATE_LOG"; then rm -rf "$tmp"; return 1; fi
rm -f "$tmp/ircd.lock"
# The validator has no network (so it cannot fight the live server for ports),
# which means it cannot reach a PostgreSQL history backend either. Neutralise
# that one setting in the COPY so everything else -- listeners, opers, MOTD,
# certs, and any pending DB schema upgrade -- is still really validated.
# PostgreSQL reachability is checked separately (see pg_check in update.sh).
if [[ "$(yaml_get_nested "$tmp/ircd.yaml" datastore postgresql enabled)" == "true" ]]; then
yaml_set_nested "$tmp/ircd.yaml" datastore postgresql enabled false || true
yaml_set_nested "$tmp/ircd.yaml" history persistent enabled false || true
fi
chown -R "$(ergo_uid):$(ergo_gid)" "$tmp"
ergo_helper -v "$tmp:/ircd" -w /ircd --entrypoint /bin/sh -- "$image" \
-c '/ircd-bin/ergo mkcerts >/dev/null 2>&1; exec /ircd-bin/ergo run --smoke' >>"$VALIDATE_LOG" 2>&1
rc=$?
rm -rf "$tmp"
return $rc
}
# ---------------------------------------------------------------------------
# ircd.yaml oper block edits (POSIX awk). The opers block is
# opers:
# <name>:
# class: ...
# password: "..."
# Each helper rewrites in place via a temp file and verifies the result.
# ---------------------------------------------------------------------------
yaml_server_name() {
awk '/^[a-z]/ {top=$1} top=="server:" && /^ name:/ {sub(/^ name:[ \t]*/,""); gsub(/["\047]/,""); print; exit}' "$1"
}
yaml_oper_list() { # <file> -> "name class" per oper
awk '
/^[a-z]/ { top=$1 }
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { if (name!="") print name, cls; name=$1; sub(/:$/,"",name); cls="?" }
top=="opers:" && name!="" && /^ class:/ { cls=$2; gsub(/"/,"",cls) }
END { if (name!="") print name, cls }' "$1"
}
yaml_oper_has() { yaml_oper_list "$1" | awk -v n="$2" '$1==n {f=1} END {exit !f}'; }
yaml_oper_set_password() { # <file> <name> <hash>
local tmp; tmp="$(mktemp)"
NAME="$2" HASH="$3" awk '
/^[a-z]/ { top=$1; cur="" }
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { cur=$1; sub(/:$/,"",cur) }
top=="opers:" && cur==ENVIRON["NAME"] && /^ password:/ { print " password: \"" ENVIRON["HASH"] "\""; done=1; next }
{ print }
END { exit done ? 0 : 1 }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
}
# Read one field from an oper's block ("" when absent). Commented-out template
# lines (#certfp:) do not count -- only a real setting.
yaml_oper_get_field() { # <file> <name> <key>
NAME="$2" KEY="$3" awk '
/^[a-z]/ { top=$1; cur="" }
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { cur=$1; sub(/:$/,"",cur) }
top=="opers:" && cur==ENVIRON["NAME"] && $0 ~ "^ " ENVIRON["KEY"] ":" {
sub(/^ [a-z0-9-]+:[ \t]*/,""); gsub(/"/,""); print; exit }' "$1"
}
# Set a field inside ONE oper's block (certfp, auto, vhost, modes...). Replaces
# the line when it is already there, otherwise inserts it after that oper's
# `class:` line so it lands inside the right block.
yaml_oper_set_field() { # <file> <name> <key> <value>
local tmp; tmp="$(mktemp)"
if NAME="$2" KEY="$3" VAL="$4" awk '
/^[a-z]/ { top=$1; cur="" }
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { cur=$1; sub(/:$/,"",cur) }
top=="opers:" && cur==ENVIRON["NAME"] && $0 ~ "^ " ENVIRON["KEY"] ":" {
print " " ENVIRON["KEY"] ": " ENVIRON["VAL"]; done=1; next }
{ print }
END { exit done ? 0 : 1 }' "$1" > "$tmp"; then
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
return 0
fi
NAME="$2" KEY="$3" VAL="$4" awk '
/^[a-z]/ { top=$1; cur="" }
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { cur=$1; sub(/:$/,"",cur) }
{ print }
top=="opers:" && cur==ENVIRON["NAME"] && /^ class:/ && !done {
print " " ENVIRON["KEY"] ": " ENVIRON["VAL"]; done=1 }
END { exit done ? 0 : 1 }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
}
yaml_oper_rm_field() { # <file> <name> <key>
local tmp; tmp="$(mktemp)"
NAME="$2" KEY="$3" awk '
/^[a-z]/ { top=$1; cur="" }
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { cur=$1; sub(/:$/,"",cur) }
top=="opers:" && cur==ENVIRON["NAME"] && $0 ~ "^ " ENVIRON["KEY"] ":" { done=1; next }
{ print }
END { exit done ? 0 : 1 }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
}
# A SHA-256 certfp as Ergo stores it: 64 lowercase hex digits, no colons.
normalize_certfp() {
local fp; fp="$(printf '%s' "$1" | tr -d ': \t\r\n' | tr 'A-Z' 'a-z')"
[[ "$fp" =~ ^[0-9a-f]{64}$ ]] || return 1
printf '%s' "$fp"
}
yaml_oper_add() { # <file> <name> <class> <hash> [whois-line]
local tmp; tmp="$(mktemp)"
local block
block=" $2:
class: \"$3\"
hidden: true
whois-line: ${5:-is a network operator}
password: \"$4\"
"
BLOCK="$block" awk '
/^[a-z]/ { if (inopers && !done) { printf "%s\n", ENVIRON["BLOCK"]; done=1 } inopers=($1=="opers:") }
{ print }
END { if (inopers && !done) printf "%s", ENVIRON["BLOCK"] }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
yaml_oper_has "$1" "$2"
}
yaml_oper_rm() { # <file> <name>
local tmp; tmp="$(mktemp)"
NAME="$2" awk '
/^[a-z]/ { top=$1; skip=0 }
top=="opers:" && /^ [A-Za-z0-9_.-]+:/ { n=$1; sub(/:$/,"",n); skip=(n==ENVIRON["NAME"]); if (skip) next }
top=="opers:" && skip && (/^ / || /^[ \t]*$/) { next }
top=="opers:" && skip { skip=0 }
{ print }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
! yaml_oper_has "$1" "$2"
}
# Set a key nested as <top>:/<4-space sub>:/<8-space key>. Only the key inside
# THAT block is rewritten -- datastore's mysql/postgresql/sqlite sub-blocks all
# have an `enabled` key, so a plain sed would hit the wrong one. A key that is
# not present is an error (rc 1), never a silent no-op.
yaml_set_nested() { # <file> <top> <sub> <key> <value>
local tmp; tmp="$(mktemp)"
TOP="$2" SUB="$3" KEY="$4" VAL="$5" awk '
/^[a-z][a-z-]*:/ { t=$0; sub(/:.*/,"",t); cur_top=t; cur_sub="" }
/^ [a-z][a-z0-9-]*:/ { s=$0; sub(/^ /,"",s); sub(/:.*/,"",s); cur_sub=s }
cur_top==ENVIRON["TOP"] && cur_sub==ENVIRON["SUB"] && $0 ~ "^ " ENVIRON["KEY"] ":" {
print " " ENVIRON["KEY"] ": " ENVIRON["VAL"]; done=1; next
}
{ print }
END { exit done ? 0 : 1 }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
}
# Read a nested key back (for status/asserts); empty when absent.
yaml_get_nested() { # <file> <top> <sub> <key>
TOP="$2" SUB="$3" KEY="$4" awk '
/^[a-z][a-z-]*:/ { t=$0; sub(/:.*/,"",t); cur_top=t; cur_sub="" }
/^ [a-z][a-z0-9-]*:/ { s=$0; sub(/^ /,"",s); sub(/:.*/,"",s); cur_sub=s }
cur_top==ENVIRON["TOP"] && cur_sub==ENVIRON["SUB"] && $0 ~ "^ " ENVIRON["KEY"] ":" {
sub(/^ [a-z0-9-]+:[ \t]*/,""); gsub(/"/,""); print; exit
}' "$1"
}
# Which persistent-history backend the deployed ircd.yaml actually uses:
# prints sqlite | postgres | off. Reads the config, not .env, so it reports what
# Ergo is really doing after a hand edit.
history_backend() { # [ircd.yaml]
local f="${1:-$IRCD_DIR/ircd.yaml}"
[[ -f "$f" ]] || { printf 'off'; return 0; }
if [[ "$(yaml_get_nested "$f" history persistent enabled)" != "true" ]]; then printf 'off'; return 0; fi
if [[ "$(yaml_get_nested "$f" datastore postgresql enabled)" == "true" ]]; then printf 'postgres'; return 0; fi
if [[ "$(yaml_get_nested "$f" datastore sqlite enabled)" == "true" ]]; then printf 'sqlite'; return 0; fi
printf 'off'
}
# The first logging block's level (info <-> debug), for `ergoctl debug on|off`.
yaml_set_log_level() { # <file> <level>
local tmp; tmp="$(mktemp)"
LEVEL="$2" awk '
/^[a-z]/ { top=$1 }
top=="logging:" && !done && /^ level:/ { print " level: " ENVIRON["LEVEL"]; done=1; next }
{ print }
END { exit done ? 0 : 1 }' "$1" > "$tmp" || { rm -f "$tmp"; return 1; }
safe_replace "$tmp" "$1" 0600 "$(ergo_uid)" "$(ergo_gid)"
}
# ---------------------------------------------------------------------------
# TLS: Caddy's cert -> ./ircd/{fullchain,privkey}.pem -> SIGHUP (rehash)
# ---------------------------------------------------------------------------
# Newest <domain>.crt across issuer dirs (Let's Encrypt, ZeroSSL fallback, ...):
# certmagic writes .key, then .crt, then .json per issuance, each atomically.
ergo_cert_source() {
local d best="" f
d="$(ergo_domain)"
for f in "$CADDY_DIR"/data/caddy/certificates/*/"$d"/"$d".crt; do
[[ -f "$f" ]] || continue
if [[ -z "$best" || "$f" -nt "$best" ]]; then best="$f"; fi
done
[[ -n "$best" ]] || return 1
printf '%s' "$best"
}
cert_fingerprint() { openssl x509 -in "$1" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2 || true; }
cert_enddate() { openssl x509 -in "$1" -noout -enddate 2>/dev/null | cut -d= -f2 || true; }
cert_subject_cn() { openssl x509 -in "$1" -noout -subject 2>/dev/null | sed -E 's/.*CN ?= ?([^,\/]+).*/\1/' || true; }
cert_expires_within() { ! openssl x509 -in "$1" -noout -checkend "$2" >/dev/null 2>&1; } # <file> <seconds>
cert_is_selfsigned() {
[[ "$(openssl x509 -in "$1" -noout -issuer 2>/dev/null | cut -d= -f2-)" == "$(openssl x509 -in "$1" -noout -subject 2>/dev/null | cut -d= -f2-)" ]]
}
served_fingerprint() { # what :6697 currently presents ("" if the handshake fails)
local d; d="$(ergo_domain)"
timeout 10 openssl s_client -connect 127.0.0.1:6697 -servername "$d" </dev/null 2>/dev/null \
| openssl x509 -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2 || true
}
# Sync Caddy's cert into ./ircd if it changed. Pairwise + atomic: both files are
# verified to match (pubkey) and staged on the same filesystem before two mv's,
# so Ergo can never see a half-renewed pair (which is fatal at the next start).
# rc: 0 synced or nothing to do | 2 no cert from Caddy yet | 1 failed (caller notifies)
CERTSYNC_MSG=""
ergo_certsync() {
local src_crt src_key src_json dst_crt dst_key tmpc tmpk want got i uid gid
CERTSYNC_MSG=""
command -v openssl >/dev/null 2>&1 || { CERTSYNC_MSG="openssl missing on the host"; return 1; }
src_crt="$(ergo_cert_source)" || { CERTSYNC_MSG="no certificate from Caddy yet"; return 2; }
src_key="${src_crt%.crt}.key"; src_json="${src_crt%.crt}.json"
[[ -f "$src_key" ]] || { CERTSYNC_MSG="renewal in progress (key missing)"; return 0; }
# .json is written last: if it is older than .crt/.key a renewal is mid-flight.
if [[ -f "$src_json" ]] && { [[ "$src_crt" -nt "$src_json" ]] || [[ "$src_key" -nt "$src_json" ]]; }; then
CERTSYNC_MSG="renewal in progress; retrying later"; return 0
fi
dst_crt="$IRCD_DIR/fullchain.pem"; dst_key="$IRCD_DIR/privkey.pem"
if [[ -f "$dst_crt" && -f "$dst_key" ]] && cmp -s "$src_crt" "$dst_crt" && cmp -s "$src_key" "$dst_key"; then
CERTSYNC_MSG="up to date"; return 0
fi
# cert and key must belong together
want="$(openssl x509 -in "$src_crt" -noout -pubkey 2>/dev/null | sha256sum | cut -d' ' -f1)"
got="$(openssl pkey -in "$src_key" -pubout 2>/dev/null | sha256sum | cut -d' ' -f1)"
if [[ -z "$want" || "$want" != "$got" ]]; then
CERTSYNC_MSG="cert/key mismatch in Caddy storage (renewal in progress?); retrying later"; return 0
fi
uid="$(ergo_uid)"; gid="$(ergo_gid)"
# Stage and keep the rollback copies OUTSIDE ircd/: that directory is
# writable by the container uid, and `install`/`cp` would follow a symlink
# planted there and write as root wherever it points. Only `mv` (rename(2),
# which replaces a symlink rather than following it) touches ircd/.
local prevdir="$STACK_DIR/.certsync"
install -d -m 0700 "$prevdir" || { CERTSYNC_MSG="could not create $prevdir"; return 1; }
tmpc="$prevdir/fullchain.pem.new"; tmpk="$prevdir/privkey.pem.new"
if ! install -m 0600 -o "$uid" -g "$gid" "$src_crt" "$tmpc" || ! install -m 0600 -o "$uid" -g "$gid" "$src_key" "$tmpk"; then
rm -f "$tmpc" "$tmpk"; CERTSYNC_MSG="could not stage the new pair in $prevdir"; return 1
fi
# source must not have moved under us mid-copy
if ! cmp -s "$src_crt" "$tmpc" || ! cmp -s "$src_key" "$tmpk"; then
rm -f "$tmpc" "$tmpk"; CERTSYNC_MSG="source changed during copy; retrying later"; return 0
fi
# Roll-back copies, also outside ircd/. Read the live pair only if it is a
# real file -- a symlink there is the container trying to exfiltrate.
rm -f "$prevdir/fullchain.pem.prev" "$prevdir/privkey.pem.prev"
if ! refuse_symlink "$dst_crt" "ircd/fullchain.pem" || ! refuse_symlink "$dst_key" "ircd/privkey.pem"; then
rm -f "$tmpc" "$tmpk"; CERTSYNC_MSG="ircd/ TLS path is a symlink; refusing to sync"; return 1
fi
[[ -f "$dst_crt" ]] && cp -p "$dst_crt" "$prevdir/fullchain.pem.prev"
[[ -f "$dst_key" ]] && cp -p "$dst_key" "$prevdir/privkey.pem.prev"
mv -f "$tmpc" "$dst_crt"; mv -f "$tmpk" "$dst_key"
if ergo_running; then
dc kill -s HUP ergo >/dev/null 2>&1 || true
want="$(cert_fingerprint "$dst_crt")"; got=""
for i in 1 2 3 4 5 6 7 8 9 10; do
got="$(served_fingerprint)"
[[ -n "$got" && "$got" == "$want" ]] && break
sleep 1
done
if [[ "$got" != "$want" ]]; then
# Ergo kept the previous tls config (a failed rehash leaves the old one
# active). Put the old pair back so the next start is not fatal.
if [[ -f "$prevdir/fullchain.pem.prev" && -f "$prevdir/privkey.pem.prev" ]]; then
mv -f "$prevdir/fullchain.pem.prev" "$dst_crt"; mv -f "$prevdir/privkey.pem.prev" "$dst_key"
dc kill -s HUP ergo >/dev/null 2>&1 || true
fi
CERTSYNC_MSG="Ergo did not present the new cert after SIGHUP (see: docker compose logs ergo); previous pair restored"
return 1
fi
fi
rm -f "$prevdir/fullchain.pem.prev" "$prevdir/privkey.pem.prev"
CERTSYNC_MSG="installed cert for $(cert_subject_cn "$dst_crt") (expires $(cert_enddate "$dst_crt")) from ${src_crt#"$CADDY_DIR"/data/caddy/certificates/}"
return 0
}
# ---------------------------------------------------------------------------
# Small persistent state for de-duplicating notifications (.state/<key>)
# ---------------------------------------------------------------------------
state_get() { cat "$STATE_DIR/$1" 2>/dev/null || true; }
state_set() { install -d -m 0700 "$STATE_DIR" 2>/dev/null; printf '%s\n' "$2" > "$STATE_DIR/$1"; }
state_rm() { rm -f "$STATE_DIR/$1"; }
# ---------------------------------------------------------------------------
# Notify (reuse the login-notifier's ntfy config, like scripts/auto-update.sh)
# ---------------------------------------------------------------------------
send_notice() { # <title> <priority> <body>
[[ "${NOTIFY:-1}" == "1" ]] || return 0
[[ -r "$SSH_NOTIFY_CONF" ]] || return 0
# shellcheck disable=SC1090
. "$SSH_NOTIFY_CONF"
[[ -n "${NTFY_URL:-}" ]] || return 0
command -v curl >/dev/null 2>&1 || return 0
local title="$1" prio="$2" body="$3" host
host="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo unknown)"
set -- -fsS -m 5 -H "X-Title: ${title}" -H "X-Priority: ${prio}"
[[ -n "${NTFY_EMAIL:-}" ]] && set -- "$@" -H "X-Email: ${NTFY_EMAIL}"
local t="ergo"; [[ -n "${NTFY_REGION:-}" ]] && t="${t},${NTFY_REGION}"
set -- "$@" -H "X-Tags: ${t}"
if [[ "${DRY_RUN:-0}" == "1" ]]; then
echo "DRY: curl ntfy ($prio): $body"
elif [[ -n "${NTFY_TOKEN:-}" ]]; then
# The token goes in on stdin, never in argv: /proc/<pid>/cmdline is
# world-readable, and every local process is inside this stack's trust
# boundary already (see README: Security model).
printf 'header = "Authorization: Bearer %s"\n' "$NTFY_TOKEN" \
| curl "$@" --config - -d "${body} [${host}]" "$NTFY_URL" >/dev/null 2>&1 || true
else
curl "$@" -d "${body} [${host}]" "$NTFY_URL" >/dev/null 2>&1 || true
fi
return 0
}