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>
415 lines
23 KiB
Markdown
415 lines
23 KiB
Markdown
# ergo
|
|
|
|
[Ergo](https://ergo.chat) — a modern IRC server with built-in services
|
|
(NickServ/ChanServ/HostServ), message history, always-on "bouncer" clients and
|
|
IRCv3 — behind Caddy for automatic Let's Encrypt TLS. Ships a host CLI
|
|
(**`ergoctl`**) for day-to-day administration and a health-checked updater
|
|
(**`update.sh`**) that also keeps Ergo's TLS certificate in sync with Caddy.
|
|
|
|
## Docker, not an OpenRC/systemd service
|
|
|
|
Ergo is a single static Go binary, so a native install is perfectly possible.
|
|
This stack still uses Docker Compose because it matches the repo's goals better:
|
|
|
|
| | Docker (this stack) | Native OpenRC / systemd |
|
|
|---|---|---|
|
|
| Alpine, Debian, Alma | one compose file, one image | Alpine packages `ergo` in community, but a stable branch keeps the version it shipped with (3.24 → 2.18.0, 3.23/3.22 → 2.16.0) while upstream is at 2.19.1, a *security* release; only edge tracks upstream. Debian/Alma have no package at all → hand-rolled tarball install + a unit per init system |
|
|
| Upgrade / rollback | `ERGO_TAG` in `.env`, health-checked, DB snapshot, rollback | replace a binary + hand-restore the DB |
|
|
| ACME TLS | stock Caddy (already used by every other stack) | certbot/acme.sh per distro + deploy hook |
|
|
| Real client IPs | yes — **host networking** (see below) | yes |
|
|
|
|
The usual objection to containerised IRC — bans, throttling and IP cloaking
|
|
need the client's real address, and Docker's userland proxy hides IPv6 clients
|
|
behind the bridge gateway — is solved by running **both** containers with
|
|
`network_mode: host`. That also means the host's deny-by-default firewall
|
|
genuinely governs the ports (the "Docker bypasses INPUT" caveat from the root
|
|
README does not apply here).
|
|
|
|
## Topology
|
|
|
|
| Port | Who listens | Purpose |
|
|
|---|---|---|
|
|
| `6697/tcp` | Ergo | IRC over TLS (Let's Encrypt cert, copied from Caddy) |
|
|
| `443/tcp` | Caddy | `wss://DOMAIN/webirc` → Ergo's websocket listener on `127.0.0.1:8097`; text landing page at `/` |
|
|
| `80/tcp` | Caddy | ACME HTTP-01 + redirect to HTTPS |
|
|
| `127.0.0.1:6667` | Ergo | loopback plaintext: compose healthcheck + `ergoctl` (exempt from ip-limits/bans, treated as secure) |
|
|
| `6667/tcp` public | Ergo | only with `PLAINTEXT=1` (STS is then advertised) — **not recommended** |
|
|
|
|
No HTTP/3 (would bind udp/443), Caddy's admin API is **off** (it would sit on
|
|
the host's loopback), and there is no `ports:` section — host mode.
|
|
|
|
## Required `.env` values
|
|
|
|
| Variable | Notes |
|
|
|---|---|
|
|
| `ERGO_DOMAIN` | Public hostname (lower-case). Server name, cert subject, Caddy site and websocket origin. **Immutable** after the first deploy. |
|
|
| `ACME_EMAIL` | Let's Encrypt registration email. |
|
|
| `NETWORK_NAME` | IRC network name (letters, digits, `. _ -`; no spaces). Defaults to `ERGO_DOMAIN`. |
|
|
| `ERGO_TAG` | Pinned image tag **with the `v`** (`v2.19.1`). Set by deploy.sh to the newest release; bumped by update.sh. Never `stable`/`latest` — if a first deploy could not reach GitHub it falls back to `stable`, and the next update run pins it. |
|
|
| `PLAINTEXT` | `0` (default) or `1` — public plaintext 6667. **Fixed at the first deploy** (it selects a listener in `ircd.yaml`, which deploy.sh never rewrites); see below to change it. |
|
|
| `HISTORY` | `sqlite` (default) / `postgres` / `off` — see [Message history](#message-history). |
|
|
| `UPDATE_POLICY` | `latest` (default) / `security` / `off` — see [Updates](#updates). |
|
|
|
|
See [`.env.example`](.env.example) for the rest (`UPDATE_GRACE`,
|
|
`FORCE_UPDATE`, `CADDY_AUTOUPDATE`, `CADDY_TAG`). `.env` must not contain `$`
|
|
(Compose interpolates it) — secrets never live there. The four update knobs are
|
|
read from `.env` by the scheduled jobs; an environment variable, or a value
|
|
pinned in `/etc/ergo-update.conf`, overrides it for that run.
|
|
|
|
Deploy-time-only options (not stored in `.env`): `ERGO_AUTOUPDATE=0` installs
|
|
`update.sh` without scheduling the daily update (cert sync is still scheduled),
|
|
and `CERT_WAIT` (default 180s) bounds the wait for the first Let's Encrypt cert.
|
|
|
|
To switch `PLAINTEXT` after the first deploy: change the plaintext listener with
|
|
`ergoctl edit` (`"127.0.0.1:6667":` ↔ `":6667":`, and `server.sts.enabled` with
|
|
it), set `PLAINTEXT` in `.env`, run `ergoctl restart`, then re-run `deploy.sh`
|
|
so the host firewall matches. Passing a conflicting `PLAINTEXT=` to a re-run
|
|
stops with those instructions rather than half-applying the change.
|
|
|
|
## Deploy
|
|
|
|
```bash
|
|
./automations.sh # Deploy on this host → deploy: ergo
|
|
```
|
|
|
|
Or build + run the self-contained artifact:
|
|
|
|
```bash
|
|
./build.sh
|
|
scp deploy.sh root@host:
|
|
ssh root@host 'bash deploy.sh'
|
|
# non-interactive:
|
|
# ERGO_DOMAIN=irc.example.com ACME_EMAIL=me@example.com NETWORK_NAME=MyNet SKIP_PROMPTS=1 bash deploy.sh
|
|
```
|
|
|
|
Unattended provisioning: [`cloud-init.yml`](cloud-init.yml). On first run
|
|
deploy.sh:
|
|
|
|
1. installs Docker (+ `openssl`, `jq`, `curl`) and creates the `ergo` system
|
|
user the container runs as;
|
|
2. registers `80,443,6697/tcp` with the host firewall
|
|
(`/etc/firewall/ports.d/ergo.rule`, or ufw/firewalld);
|
|
3. pins `ERGO_TAG` to the newest release and seeds `.env`;
|
|
4. generates **`ircd/ircd.yaml` once**, from the pulled image's *own*
|
|
`default.yaml` (so it always matches the running version): server/network
|
|
name, the listeners above, the websocket origin, the cloak suffix, and an
|
|
`admin` oper whose random password is saved to `secrets/admin.pass` (0600;
|
|
only the bcrypt hash goes into the yaml). Hard post-checks refuse to
|
|
continue if the upstream template layout ever changes under the edits;
|
|
5. validates the config with `ergo run --smoke` in a throwaway container
|
|
**before** anything starts, validates the Caddyfile, then starts the stack
|
|
and waits for both healthchecks (Ergo's is IRC-level: a `QUIT` must be
|
|
answered with `ERROR`);
|
|
6. waits for Caddy's Let's Encrypt cert, copies it into `ircd/` and rehashes
|
|
Ergo (until then Ergo serves a self-signed cert from its own `mkcerts`);
|
|
7. schedules `update.sh certsync` every 15 min and `update.sh run` daily
|
|
(`ERGO_AUTOUPDATE=0` schedules only the cert sync).
|
|
|
|
Re-runs are idempotent: they never touch `ircd/ircd.yaml`, `secrets/` or
|
|
`caddy/etc/conf.d/`, and they adopt the deployed settings from `.env` rather
|
|
than this shell's defaults, so a plain `bash deploy.sh` cannot silently close a
|
|
`PLAINTEXT=1` firewall port or reset your update policy. A setting you do pass
|
|
explicitly is written back to `.env`. A changed `Caddyfile` triggers a Caddy
|
|
restart, since Caddy has no admin API here.
|
|
|
|
## Administer: `ergoctl`
|
|
|
|
Installed at `/usr/local/bin/ergoctl`. It talks to Ergo over the loopback
|
|
listener as the `admin` oper.
|
|
|
|
| Command | What it does |
|
|
|---|---|
|
|
| `ergoctl status` / `users` / `logs [-f]` / `version` | health, versions, TLS state, user count; server log with health-probe noise filtered |
|
|
| `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\|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 |
|
|
| `ergoctl cmd <raw IRC line>` | anything else as the admin oper, e.g. `ergoctl cmd NS SAREGISTER alice hunter2`, `ergoctl cmd CS PURGE ADD #spam` (`CS PURGE DEL` to undo); replies are printed with control characters stripped |
|
|
| `ergoctl cert [show\|sync]` | cert on disk vs the one served on 6697; force a sync |
|
|
| `ergoctl backup [--live] [dir]` / `restore <file>` | see [Backups](#backups) |
|
|
| `ergoctl update …` | passthrough to `update.sh` |
|
|
| `ergoctl history [backend]` | show or switch the persistent-history backend |
|
|
| `ergoctl debug on\|off` · `caddy-restart` · `shell` | log level; apply Caddyfile/conf.d changes; a shell in the container |
|
|
|
|
Editing by hand works too: change `ircd/ircd.yaml`, then `ergoctl rehash` — a
|
|
rejected config leaves the old one running (Ergo's rehash is transactional), but
|
|
the *file* on disk would then be unbootable, which is why `ergoctl edit`
|
|
validates first. Ergo's [manual](https://github.com/ergochat/ergo/blob/master/docs/MANUAL.md)
|
|
covers every option; the pristine template for your version is in `templates/`.
|
|
|
|
## TLS
|
|
|
|
Caddy owns the ACME account and renews the cert on its own schedule (ARI-driven).
|
|
Ergo terminates TLS itself on 6697, so the cert has to reach `ircd/fullchain.pem`
|
|
+ `privkey.pem`. `update.sh certsync` (every 15 minutes, via busybox crond or a
|
|
systemd timer) does that the way upstream's certbot deploy-hook recipe does,
|
|
plus safety checks:
|
|
|
|
- picks the newest `<domain>.crt` under `caddy/data/caddy/certificates/*/` (Let's
|
|
Encrypt or the ZeroSSL fallback), skipping a renewal that is still mid-write;
|
|
- verifies the key matches the cert (certmagic writes them as separate files and
|
|
generates a new key on every renewal), stages both in `ircd/` and installs
|
|
them with two atomic `mv`s — Ergo never sees a half pair, which would be
|
|
fatal at its next start;
|
|
- `SIGHUP`s Ergo (rehash swaps the TLS config for new connections only) and
|
|
confirms the fingerprint served on 6697 changed; if not, the previous pair is
|
|
restored and you get an ntfy alert.
|
|
|
|
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
|
|
and always-on (bouncer) clients. **Upstream keeps it in RAM**, so it is lost on
|
|
every restart — including the ones this stack's updater performs. So persistence
|
|
is on by default here:
|
|
|
|
| `HISTORY` | What it does |
|
|
|---|---|
|
|
| `sqlite` *(default)* | A file at `ircd/ergo_history.db`, next to the account database. No extra container, backed up with everything else. Right for a single-node server. |
|
|
| `postgres` | A pinned PostgreSQL container (`docker-compose.postgres.yml`), reachable only on `127.0.0.1`. Choose it if you want a real database to query, or already run Postgres. |
|
|
| `off` | Upstream behaviour: RAM only. |
|
|
|
|
Both SQL backends need **Ergo 2.18.0+** (deploy.sh refuses older pins). How long
|
|
messages are kept is `history.restrictions.expire-time` in `ircd.yaml` — **one
|
|
week by default** — not a size limit: enabling persistence removes the in-memory
|
|
`channel-length`/`client-length` caps. Raise or lower it with `ergoctl edit`, and
|
|
note the privacy point upstream flags: persisted messages are personal data, so
|
|
check what your jurisdiction expects of you before extending retention.
|
|
|
|
```bash
|
|
ergoctl history # backend, database size / connectivity, retention
|
|
ergoctl history postgres # switch (restarts Ergo; does NOT migrate messages)
|
|
```
|
|
|
|
Switching backends leaves the old store on disk and starts the new one empty —
|
|
there is no migration path, so pick one at deploy time if you can.
|
|
|
|
### PostgreSQL
|
|
|
|
`HISTORY=postgres` adds `docker-compose.postgres.yml` to `COMPOSE_FILE` in `.env`,
|
|
which `docker compose` reads by itself — so every command in this stack sees the
|
|
same services with no extra flags. The container publishes **only to
|
|
`127.0.0.1:5432`**, Ergo reaches it there (the host-networked containers cannot
|
|
use compose service DNS), and `depends_on: service_healthy` keeps Ergo from
|
|
starting before the database accepts connections. The password is generated into
|
|
`secrets/postgres.pass` (0600) and passed via `POSTGRES_PASSWORD_FILE`, so it
|
|
never lands in `.env`.
|
|
|
|
**The major version is pinned and the updater never touches it.** PostgreSQL
|
|
refuses to start on a data directory written by a different major version, so
|
|
moving from `17-alpine` to `18-alpine` is a deliberate dump-and-restore:
|
|
|
|
```bash
|
|
ergoctl backup # contains a pg_dump of the history database
|
|
ergoctl history off && docker compose down -v postgres # drops the old data volume
|
|
# set POSTGRES_TAG=18-alpine in .env, then:
|
|
ergoctl history postgres && ergoctl restore backups/ergo-backup-<ts>.tar.gz
|
|
```
|
|
|
|
Note that Postgres 18 also changed the image's default data directory layout; the
|
|
compose file pins `PGDATA` explicitly so a future image default cannot move it.
|
|
|
|
## Updates
|
|
|
|
```bash
|
|
ergoctl update check # running vs latest, published security advisories, cert state
|
|
# reports only, with one exception: a floating ERGO_TAG
|
|
# ('stable') is pinned to the running version in .env
|
|
ergoctl update update # update now (TARGET_VERSION=2.19.1 to pin); honours the safety rails
|
|
ergoctl update install # (re)schedule / uninstall to stop
|
|
```
|
|
|
|
`UPDATE_POLICY` (in `.env`) drives the daily `run`:
|
|
|
|
| Policy | Behaviour |
|
|
|---|---|
|
|
| `latest` *(default)* | Update to the newest release whenever one exists. |
|
|
| `security` | Update **only** when a published GitHub security advisory covers the running version, or a release since it has a `### Security` section in its notes (upstream only opened GHSAs from 2.19.1). |
|
|
| `off` | Never change the running version (check/notify only). |
|
|
|
|
Every update, scheduled or manual:
|
|
|
|
1. pulls the pinned `ghcr.io/ergochat/ergo:vX.Y.Z` and **pre-flights** it against
|
|
a copy of `ircd/` (`ergo run --smoke` — also dry-runs a DB schema upgrade);
|
|
2. `NOTICE`s connected users and waits `UPDATE_GRACE` seconds (default 60);
|
|
3. stops Ergo and snapshots `ircd.db` to `backups/` (consistent copy; buntdb is
|
|
append-only, so a live copy could miss the last second), keeps the last 5;
|
|
4. flips `ERGO_TAG`, starts, waits for the healthcheck **and** a registration
|
|
handshake;
|
|
5. on failure: previous tag back; if the old version refuses the upgraded
|
|
database ("Database requires update") the snapshot is restored too. Cause
|
|
and snapshot path go into the ntfy message;
|
|
6. on success: saves the new image's `default.yaml` under `templates/` and, if it
|
|
differs from the previous version's, writes `templates/diff-vA-vB.txt` so
|
|
new/renamed options are visible.
|
|
|
|
Releases whose notes announce **compatibility breaks** are held (one ntfy, not
|
|
daily) until you run `ergoctl update update` or set `FORCE_UPDATE=1`. Restarting
|
|
Ergo disconnects every user (there is no hot restart), so pick `security` if
|
|
that matters more than being current. Caddy is only updated by
|
|
`ergoctl update caddy` (or daily with `CADDY_AUTOUPDATE=1`); recreating it drops
|
|
web-client websockets. Update results reuse the ntfy config at
|
|
`/etc/ssh-notify.conf` when present.
|
|
|
|
## Backups
|
|
|
|
`ergoctl backup` writes `backups/ergo-backup-<ts>.tar.gz` (0600) with
|
|
`ircd.yaml`, `ircd.db`, `ergo.motd`, the message history (the SQLite file, or a
|
|
`pg_dump` — a file copy of a live PostgreSQL data directory would not be a valid
|
|
backup) and a `meta` file (version, history backend, date). By default
|
|
it stops Ergo for a few seconds for a consistent copy (`--live` skips that).
|
|
Put an age public key in `$STACK_DIR/age-recipients.txt` (e.g. from
|
|
`globals/age-pubkey.txt`) **and install `age`** (`apk add age`, `apt install age`,
|
|
`dnf install age` — deploy.sh does not) and backups are encrypted with it; with
|
|
the recipients file present but `age` missing, `ergoctl backup` refuses rather
|
|
than writing the account and oper hashes out in the clear. TLS files are not
|
|
included — certsync regenerates them. `ergoctl restore <file>` stops Ergo, keeps the current config and database as
|
|
`backups/{ircd.yaml,ircd.db}.pre-restore.<ts>`, warns if the backup came from a
|
|
newer version (older Ergo cannot read a newer schema), restores, validates the
|
|
result before starting, and waits for health. Because the archive carries the
|
|
*original* host's credentials, restore re-points `datastore.postgresql.password`
|
|
at this host's `secrets/postgres.pass` and re-hashes this host's
|
|
`secrets/admin.pass` into the config — otherwise a rebuild would come up healthy
|
|
but leave you unable to `/OPER`. An encrypted backup needs the private key:
|
|
|
|
```bash
|
|
AGE_IDENTITY=/root/age.key ergoctl restore backups/ergo-backup-<ts>.tar.gz.age
|
|
``` Ergo's own `datastore.autoupgrade`
|
|
leaves `ircd/ircd.db.v<N>.<ts>.bak` files behind on schema upgrades; prune them
|
|
once you are happy with a release.
|
|
|
|
## Web client (optional)
|
|
|
|
The `wss://DOMAIN/webirc` endpoint is ready; Ergo only accepts browser
|
|
connections whose `Origin` is `https://DOMAIN` (`server.websockets.allowed-origins`).
|
|
To host [Gamja](https://codeberg.org/emersion/gamja) on the same origin, unpack a
|
|
release tarball into `caddy/www/gamja`, add
|
|
`{"server": {"url": "/webirc", "autojoin": "#lobby"}}` as `config.json`, mount
|
|
`./caddy/www:/www:ro` in the caddy service and drop a `handle { root * /www/gamja
|
|
file_server }` block into `caddy/etc/conf.d/` (see `00-readme.caddy`), then
|
|
`ergoctl caddy-restart`.
|
|
|
|
## Security model
|
|
|
|
- **Host loopback is Ergo's trust boundary.** Loopback peers are exempt from
|
|
bans and ip-limits, are treated as secure, and (because
|
|
`proxy-allowed-from: [localhost]` is what lets Caddy's `X-Forwarded-For`
|
|
through) may assert a client IP. Every local process and every other
|
|
host-network container can reach `127.0.0.1:6667`/`:8097`. This stack is for
|
|
a single-purpose container host without untrusted local users; deploy.sh
|
|
warns about other host-network containers.
|
|
- **Ergo runs unprivileged**: as the host's `ergo` system user, read-only root
|
|
filesystem, all capabilities dropped, `no-new-privileges`. `ircd/` (database,
|
|
config with the oper hash, TLS key) is `0700 ergo:ergo`; `secrets/`,
|
|
`backups/`, `caddy/data` are `0700 root`.
|
|
- **Root never follows a symlink into `ircd/`.** That directory is writable by
|
|
the container uid while the cert sync and every `ergoctl` config edit run as
|
|
root, so a plain `cp`/`install`/`>` there would let code execution inside Ergo
|
|
redirect a root write onto any file on the host. Every such write stages under
|
|
`$STACK_DIR` (0700 root) and lands with `mv` — `rename(2)` replaces a symlink
|
|
instead of following it — and root reads use `cp -P` or refuse outright.
|
|
- **Caddy runs as root** (it needs `NET_BIND_SERVICE` for 80/443) with every
|
|
other capability dropped, admin API off, HTTP/3 off.
|
|
- **Loopback plaintext is the control plane by design**: OPER over loopback is
|
|
not a downgrade (Ergo marks it secure), and it lets `ergoctl` work even when a
|
|
ban or throttle would lock out a remote client. `PLAINTEXT=1` puts real users'
|
|
credentials on the wire in clear — leave it at `0`.
|
|
- The admin password is sent to Ergo only over loopback and stored only in
|
|
`secrets/admin.pass`. `ergo genpasswd` hashes at bcrypt cost 4, so oper
|
|
passwords are always generated (24 random chars), never typed.
|
|
|
|
## Files
|
|
|
|
| File | Purpose |
|
|
|---|---|
|
|
| `docker-compose.yml` | caddy + ergo, both `network_mode: host`, hardened; IRC-level healthcheck. |
|
|
| `docker-compose.postgres.yml` | Optional PostgreSQL overlay for `HISTORY=postgres` (loopback-only, pinned major). |
|
|
| `Caddyfile` | ACME, `/webirc` websocket proxy, text landing page, `admin off`, no h3; imports `conf.d/*.caddy`. |
|
|
| `conf.d-readme.caddy` | Installed once as `caddy/etc/conf.d/00-readme.caddy` (operator drop-ins; Gamja example). |
|
|
| `ergo.motd` | MOTD template (`__NETWORK_NAME__`, `__DOMAIN__`). |
|
|
| `ergolib.sh` | Shared helpers: `.env`, compose, health/IRC probes, `--smoke` validator, oper edits, certsync, ntfy. |
|
|
| `update.sh` | Updater + certsync + Caddy update + scheduling. |
|
|
| `ergoctl` | Host admin CLI. |
|
|
| `.env.example` | Stack tunables. |
|
|
| `deploy.sh` / `build.sh` | Self-contained installer + archive embedder. |
|
|
| `cloud-init.yml` | Fresh-VM bootstrap (harden SSH, then deploy). |
|
|
|
|
On the host: `/srv/ergo/{ircd,caddy/{etc,data,config},secrets,backups,templates}`
|
|
plus `.state/` (notification de-duplication) and `.env.bak.<ts>` copies from each
|
|
update (last 5 kept); `/etc/ergo-update.conf` (where the jobs find the stack);
|
|
`/usr/local/bin/ergoctl`; `/var/log/ergo-update.log` (the **daily update run**
|
|
logs there, since busybox crond has nowhere else to put its output — cert-sync
|
|
failures instead surface through ntfy and as `certsync: LAST FAILURE` in
|
|
`ergoctl status`).
|
|
|
|
## Notes
|
|
|
|
- Only one host-networked Caddy fits on a box: don't co-locate this stack with
|
|
another Caddy/80/443 stack (deploy.sh checks the ports).
|
|
- DNS for `ERGO_DOMAIN` must resolve to the host and 80/443 be reachable before
|
|
deploy for the cert to issue; Ergo stays up on a self-signed cert meanwhile.
|
|
- Ergo's built-in registration is open by default, and **`allow-before-connect`
|
|
is also on**, so accounts can be created by a client that has not finished
|
|
connecting, throttled only globally (30 attempts per 10 minutes ≈ 4,300/day).
|
|
Each account is a durable row in `ircd.db`. For a private network set
|
|
`accounts.registration.enabled: false`, or keep registration but set
|
|
`allow-before-connect: false`, with `ergoctl edit`.
|
|
- The admin oper password is printed by `deploy.sh` only on the first run and
|
|
only to a terminal. Unattended runs (cloud-init) print the path instead, so the
|
|
credential does not end up in the provider's serial-console log; read it with
|
|
`ergoctl passwd show`.
|