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:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user