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>
This commit is contained in:
2026-09-02 15:34:32 -05:00
co-authored by Claude Opus 5
parent 85d945ccde
commit f916eb549a
4 changed files with 851 additions and 617 deletions
+49 -1
View File
@@ -123,7 +123,7 @@ listener as the `admin` oper.
| `ergoctl edit` / `motd` | `$EDITOR` on `ircd.yaml` / `ergo.motd`, then **validate** (throwaway container) and **REHASH**; the previous file is restored if either step fails |
| `ergoctl rehash` | reload config, MOTD and TLS certs without disconnecting anyone |
| `ergoctl restart` / `stop` / `start` | restart the container (drops every user; needed for settings Ergo fixes at startup, such as `server.name`, `datastore.path`, `casemapping`, `enforce-utf8`, `max-line-len`, `idle-timeouts`). Changes to `docker-compose.yml` or `.env` need `docker compose up -d` instead |
| `ergoctl oper list\|add\|passwd\|rm` | manage operators in `ircd.yaml`; passwords are generated (24 chars) and printed once |
| `ergoctl oper list\|add\|passwd\|certfp\|rm` | manage operators in `ircd.yaml`; passwords are generated (24 chars) and printed once. See [Operators](#operators) |
| `ergoctl passwd [show\|rotate]` | the `admin` oper password |
| `ergoctl announce <text>` | NOTICE to everyone |
| `ergoctl kill <nick> [reason]` · `ban add\|del\|list\|info` · `defcon [1-5]` | moderation (`UBAN` under the hood). `ban add` takes an **IP, CIDR, `nick!user@host` mask, or account name** — a bare name is an *account* to suspend, not a connected nick, so run `ban info <nick>` first to get their IP |
@@ -162,6 +162,54 @@ Silent when nothing changed. The daily run also warns when the served cert is
still self-signed (DNS/80/443 not right yet) or expires within 14 days
(certsync not running). `ergoctl cert` shows both sides.
## Operators
An oper name is a **credential, not a nick** — you authenticate as one and keep
your own nickname. Deploy creates `admin` (class `server-admin`, `hidden: true`),
whose password is in `secrets/admin.pass` (`ergoctl passwd show`):
```
/OPER admin <password>
```
Add your own rather than sharing that one. Ergo casefolds oper names, so use
lowercase letters, digits, `_` and `-`, starting with a letter:
```bash
ergoctl oper add alice server-admin
```
The class is `chat-moderator` by default; `server-admin` adds rehash, account and
channel administration, defcon and massmessage.
### Client-certificate auth
Instead of typing a password every session, an oper can be identified by the
SHA-256 fingerprint of their TLS client certificate. Find it on the `276` line of
your own `/WHOIS`, or from the certificate:
```bash
openssl x509 -noout -fingerprint -sha256 -in client.pem
```
Then pass that fingerprint, or let ergoctl read it off a connected user:
```bash
ergoctl oper certfp alice 57_Wolve --auto
```
Without `--auto`, Ergo requires **both** the certificate and the password, which
makes the certificate a second factor. With `--auto` the password is removed and
the certificate alone grants operator status the moment they connect — nothing
typed, nothing replayable, but anyone holding that key is an operator on sight.
`ergoctl oper certfp alice --clear` revokes it.
**This is refused for `admin`, deliberately.** `ergoctl` opers up over the
loopback plaintext listener, which presents no client certificate, so a certfp
there would make Ergo reject it — and every ergoctl command needing oper,
including the scheduled jobs, would stop working. Keep `admin` on its password
and put certificates on personal opers.
## Message history
Ergo keeps channel and DM history for `CHATHISTORY`, `/HISTORY`, autoreplay-on-join
+657 -613
View File
File diff suppressed because it is too large Load Diff
+92 -3
View File
@@ -53,6 +53,14 @@ Operators
oper list
oper add <name> [class] class: chat-moderator (default) | server-admin; prints the password once
oper passwd <name> new random password for an oper
oper certfp <name> <fingerprint|nick> [--auto]
authenticate that oper by TLS client certificate.
Pass a SHA-256 fingerprint, or the nick of a connected
user to read it from the server. Without --auto the
cert AND the password are both required; with --auto
the password is dropped and they are opered on connect.
--clear removes it. Not allowed for 'admin' (ergoctl
opers over loopback with no certificate).
oper rm <name>
passwd [show|rotate] the 'admin' oper password (secrets/admin.pass)
@@ -209,8 +217,15 @@ cmd_users() {
local out
out="$(irc_raw --quiet 1 -- LUSERS)" || die "Could not talk to Ergo on 127.0.0.1:6667 (is it running?)."
# ":server 251 nick :There are N users..." -> "There are N users..."
# 265/266 repeat their counts in the trailing text ("1 1 :Current local users
# 1, max 1"), so drop their leading parameters; 252/254 carry the only count
# there ("0 :IRC Operators online"), so keep it.
printf '%s\n' "$out" | awk '$2=="251"||$2=="252"||$2=="254"||$2=="265"||$2=="266" {
sub(/^:[^ ]+ [0-9]+ [^ ]+ /, ""); sub(/^:/, ""); sub(/ :/, " "); print " " $0 }'
n=$2
sub(/^:[^ ]+ [0-9]+ [^ ]+ /, "")
if (n=="265" || n=="266") sub(/^[^:]*:/, "")
sub(/^:/, ""); sub(/ :/, " ")
print " " $0 }'
}
cmd_logs() {
@@ -248,7 +263,73 @@ cmd_oper() {
require_root; require_stack
local sub="${1:-list}" name="${2:-}" class="${3:-chat-moderator}" pw hash
case "$sub" in
list) yaml_oper_list "$CONF" | awk '{printf " %-20s %s\n", $1, $2}' ;;
list) yaml_oper_list "$CONF" | awk '{printf " %-20s %s\n", $1, $2}' \
| while read -r line; do
n="${line%% *}"; n="${n# }"
fp="$(yaml_oper_get_field "$CONF" "$n" certfp)"
au="$(yaml_oper_get_field "$CONF" "$n" auto)"
printf '%s%s%s\n' "$line" \
"$( [[ -n "$fp" ]] && printf ' certfp:%s…' "${fp:0:12}" )" \
"$( [[ "$au" == "true" ]] && printf ' auto' )"
done ;;
certfp)
# Authenticate an oper by TLS client certificate instead of (or as
# well as) a typed password.
[[ -n "$name" ]] || die "Usage: ergoctl oper certfp <name> <fingerprint|nick> [--auto] | ergoctl oper certfp <name> --clear"
yaml_oper_has "$CONF" "$name" || die "No oper '$name' in ircd.yaml (see: ergoctl oper list)."
# ergoctl itself opers as 'admin' over the loopback PLAINTEXT listener,
# which by definition presents no client certificate. Ergo requires a
# configured certfp to match, so this would lock the tooling -- and the
# scheduled jobs that use it -- out of the server for good.
[[ "$name" != "admin" ]] || die "Refusing to set a certfp on 'admin': ergoctl opers up over the loopback plaintext listener with no client certificate, so Ergo would reject it and every ergoctl command that needs oper would stop working. Make a personal oper instead: ergoctl oper add <you> server-admin && ergoctl oper certfp <you> <nick> --auto"
local arg="" auto=0 clear=0 a
for a in "${@:3}"; do
case "$a" in
--auto) auto=1 ;;
--clear) clear=1 ;;
--*) die "Unknown option '$a'." ;;
*) arg="$a" ;;
esac
done
snapshot_file "$CONF"
if (( clear )); then
yaml_oper_rm_field "$CONF" "$name" certfp || true
yaml_oper_rm_field "$CONF" "$name" auto || true
[[ -n "$(yaml_oper_get_field "$CONF" "$name" password)" ]] \
|| warn "'$name' now has neither a certfp nor a password; Ergo refuses to load an oper with no way to authenticate. Set one with: ergoctl oper passwd $name"
apply_change "certfp removal for '$name'" "$CONF"
return 0
fi
[[ -n "$arg" ]] || { rm -f "$CONF.last-good"; die "Give a SHA-256 fingerprint, or the nick of a connected user to read it from. Your own is on the 276 line of /WHOIS <yournick>, or: openssl x509 -noout -fingerprint -sha256 -in client.pem"; }
local fp=""
if fp="$(normalize_certfp "$arg")"; then
: # a fingerprint was given directly
else
log "Looking up the certificate fingerprint of '$arg' over IRC..."
local found; found="$(certfp_of_nick "$arg" || true)"
[[ -n "$found" ]] || { rm -f "$CONF.last-good"; die "No certificate fingerprint for '$arg'. They must be connected AND using a TLS client certificate. Have them check the 276 line of their own /WHOIS, then pass the fingerprint here directly."; }
fp="$(normalize_certfp "$found")" || { rm -f "$CONF.last-good"; die "The server returned '$found', which is not a SHA-256 fingerprint."; }
log "Found: $fp"
fi
yaml_oper_set_field "$CONF" "$name" certfp "\"$fp\"" \
|| { cat "$CONF.last-good" > "$CONF"; die "Could not write certfp into the '$name' oper block."; }
if (( auto )); then
# certfp + password means Ergo requires BOTH, so the password has
# to go for a no-typing login.
yaml_oper_rm_field "$CONF" "$name" password || true
yaml_oper_set_field "$CONF" "$name" auto true \
|| { cat "$CONF.last-good" > "$CONF"; die "Could not set auto for '$name'."; }
fi
apply_change "certfp for oper '$name'" "$CONF"
echo
if (( auto )); then
echo " '$name' is now granted operator status automatically on connect with that"
echo " certificate -- no /OPER, no password. The password has been removed."
warn "Anyone holding that client certificate is now an operator on sight. Keep the key safe, and 'ergoctl oper certfp $name --clear' revokes it."
else
echo " '$name' now requires BOTH that certificate and the password: /OPER $name <password>"
echo " For passwordless automatic oper instead: ergoctl oper certfp $name --auto"
fi ;;
add)
[[ -n "$name" ]] || die "Usage: ergoctl oper add <name> [chat-moderator|server-admin]"
oper_name_ok "$name" || die "Oper names: lowercase letters, digits, _ - (2-32 chars)."
@@ -280,10 +361,18 @@ cmd_oper() {
snapshot_file "$CONF"
yaml_oper_rm "$CONF" "$name" || { cat "$CONF.last-good" > "$CONF"; die "Could not remove the oper block."; }
apply_change "removal of oper '$name'" "$CONF" ;;
*) die "Usage: ergoctl oper list|add|passwd|rm" ;;
*) die "Usage: ergoctl oper list|add|passwd|certfp|rm" ;;
esac
}
# A connected user's TLS certificate fingerprint, via 276 RPL_WHOISCERTFP.
# Empty when they are not connected or not using a client certificate.
certfp_of_nick() {
local out
out="$(irc_raw --oper --quiet 2 -- "WHOIS $1")" || return 1
printf '%s\n' "$out" | awk '$2=="276" { for (i=1;i<=NF;i++) if ($i ~ /^[0-9a-fA-F]{64}$/) { print $i; exit } }'
}
cmd_passwd() {
require_root; require_stack
case "${1:-show}" in
+53
View File
@@ -455,6 +455,59 @@ yaml_oper_set_password() { # <file> <name> <hash>
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