#!/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 [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() { # [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() { # : update KEY in .env, or append if absent # The value goes through the ENVIRONMENT, never interpolated into a sed # script. Interpolating it corrupts any value containing & (sed expands it to # the whole match) and aborts the run on one containing the s||| delimiter -- # which is reachable for an OIDC secret, a password or a URL query string. local key="$1" val="$2" tmp if [[ ! -f "$ENV_FILE" ]]; then printf '%s=%s ' "$key" "$val" >> "$ENV_FILE" return 0 fi tmp="$(mktemp)" _SE_KEY="$key" _SE_VAL="$val" awk ' BEGIN { k = ENVIRON["_SE_KEY"]; v = ENVIRON["_SE_VAL"]; seen = 0 } !seen && index($0, k "=") == 1 { print k "=" v; seen = 1; next } { print } END { if (!seen) print k "=" v } ' "$ENV_FILE" > "$tmp" cat "$tmp" > "$ENV_FILE" # rewrite in place: keeps the original mode/owner rm -f "$tmp" } 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 ... 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() { # -> 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 # --------------------------------------------------------------------------- # " " 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() { # [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] -- ... # # 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...] -- 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 ; rc 1 if nothing came out. ergo_extract_template() { # 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() { # 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: # : # 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_network_name() { awk '/^[a-z]/ {top=$1} top=="network:" && /^ name:/ {sub(/^ name:[ \t]*/,""); gsub(/["\047]/,""); print; exit}' "$1" } yaml_oper_list() { # -> "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() { # 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() { # 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() { # 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() { # 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() { # [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() { # 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 :/<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() { # 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() { # 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() { # 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 .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; } # 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 \ | 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/) # --------------------------------------------------------------------------- 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() { # <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 }