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>
729 lines
34 KiB
Bash
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
|
|
}
|