From 4cbd808eb158445dda944e2ed540b7c5989324f6 Mon Sep 17 00:00:00 2001 From: William Gill Date: Wed, 2 Sep 2026 13:19:31 -0500 Subject: [PATCH] feat(ergo): add Ergo IRC server deployment with ergoctl admin CLI New deployments/ergo/: the Ergo IRC server behind Caddy for Let's Encrypt TLS and the IRC-over-WebSocket endpoint. Docker rather than a native OpenRC/systemd service, because Alpine's apk ergo trails upstream (3.24 ships 2.18.0 against a 2.19.1 security release) and Debian/Alma package it at all -- so native would mean three install paths plus a per-distro ACME client. Both containers run with network_mode: host. IRC bans, throttling and cloaking key on the client's address, and Docker's userland proxy would hide every IPv6 client behind the bridge gateway; host mode also makes the repo's INPUT firewall genuinely govern 80/443/6697. Caddy reaches Ergo over loopback, which is what lets Ergo honour X-Forwarded-For (proxy-allowed-from defaults to localhost) and mark web sessions secure. - deploy.sh generates ircd.yaml ONCE from the pulled image's own default.yaml (version-matched), rewriting the listeners/websockets blocks wholesale rather than patching lines, then asserts hard post-conditions and validates with `ergo run --smoke` in a throwaway container before anything starts. - update.sh: pinned vX.Y.Z tags, GHSA + "### Security" release-note policies, pre-flight against the new image, user NOTICE + grace, stop-consistent DB snapshot, health check (IRC-level, not a bare TCP connect) and rollback that restores the DB only when the schema actually moved. Compatibility-break releases are held for review. certsync copies Caddy's cert pairwise-atomically and verifies the fingerprint served on 6697 after the rehash. - ergoctl: status/users/logs, validated edit+rehash, oper add/passwd/rm, moderation, backup/restore, cert and update passthrough. Talks IRC to the loopback listener over bash /dev/tcp and strips control characters from replies. - Ergo runs as a non-root system user, read-only rootfs, all caps dropped; Caddy keeps only NET_BIND_SERVICE, with admin API and HTTP/3 off. Reviewed adversarially across six lenses; 20 confirmed findings fixed, notably a dead SIGHUP fallback (`rc=$?` after an `if` is always 0), several `set -e` aborts from non-total pipelines, a release-list cache that only ever populated in a subshell, and re-runs that used shell defaults instead of the deployed .env. Verified locally: bash -n, LF endings, the ircd.yaml render against the real 2.19.1 template in both PLAINTEXT modes, the yaml/oper/version/env helpers, and the IRC client against a fake server (registration, oper, rehash success and 400-failure, control-character stripping, server-down paths). Not yet exercised on a Docker host: the containers themselves, ACME issuance and cert sync. Co-Authored-By: Claude Opus 5 --- .gitattributes | 5 + .gitignore | 9 + README.md | 11 +- automations.sh | 12 +- deployments/ergo/.env.example | 63 ++ deployments/ergo/Caddyfile | 76 ++ deployments/ergo/README.md | 281 +++++++ deployments/ergo/build.sh | 55 ++ deployments/ergo/cloud-init.yml | 42 + deployments/ergo/conf.d-readme.caddy | 20 + deployments/ergo/deploy.sh | 1084 ++++++++++++++++++++++++++ deployments/ergo/docker-compose.yml | 80 ++ deployments/ergo/ergo.motd | 11 + deployments/ergo/ergoctl | 402 ++++++++++ deployments/ergo/ergolib.sh | 554 +++++++++++++ deployments/ergo/update.sh | 570 ++++++++++++++ 16 files changed, 3269 insertions(+), 6 deletions(-) create mode 100644 deployments/ergo/.env.example create mode 100644 deployments/ergo/Caddyfile create mode 100644 deployments/ergo/README.md create mode 100644 deployments/ergo/build.sh create mode 100644 deployments/ergo/cloud-init.yml create mode 100644 deployments/ergo/conf.d-readme.caddy create mode 100644 deployments/ergo/deploy.sh create mode 100644 deployments/ergo/docker-compose.yml create mode 100644 deployments/ergo/ergo.motd create mode 100644 deployments/ergo/ergoctl create mode 100644 deployments/ergo/ergolib.sh create mode 100644 deployments/ergo/update.sh diff --git a/.gitattributes b/.gitattributes index 9833e4a..00d0fd7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,8 @@ Caddyfile text eol=lf # Treat the embedded-archive deploy scripts as text too (they're base64+shell). deployments/*/deploy.sh text eol=lf + +# Extension-less / unusual-extension scripts and configs that must stay LF. +deployments/ergo/ergoctl text eol=lf +*.caddy text eol=lf +*.motd text eol=lf diff --git a/.gitignore b/.gitignore index d7c2b27..60a2e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,15 @@ deployments/squid/ssl/ # copyparty.conf.example template stays tracked. deployments/copyparty/cfg/ +# Ergo -- generated on the host at deploy time: ircd/ (config with the oper +# hash, database, TLS key), caddy/ (ACME account + certs), secrets/ (admin oper +# password), backups/, templates/. Only the loose sources stay tracked. +deployments/ergo/ircd/ +deployments/ergo/caddy/ +deployments/ergo/secrets/ +deployments/ergo/backups/ +deployments/ergo/templates/ + # ── Backups ───────────────────────────────────────────────────────────────── *.tar.gz.age *-backup-*.tar.gz* diff --git a/README.md b/README.md index f6e5853..a0ca0dd 100644 --- a/README.md +++ b/README.md @@ -120,12 +120,15 @@ deployments// # one folder per stack | [`copyparty`](deployments/copyparty/) | Portable file server — web UI/WebDAV behind Caddy, plus direct **SFTP** + **FTPS**. Ships a security-notices-aware updater. | — | | [`simplex`](deployments/simplex/) | SimpleX SMP + XFTP relay with Tor hidden services + encrypted backups. | globals/age-pubkey.txt | | [`openbao`](deployments/openbao/) | Hardened tape-encryption key store for **Kanrisha** (the LTO tape-archive system — separate repo/host). **Exception: native TLS on the LAN, no Caddy/LE.** raft storage, mlock, self-signed or Smallstep-ACME cert, manual/HSM unseal. | globals/age-pubkey.txt (backups) | +| [`ergo`](deployments/ergo/) | IRC server ([Ergo](https://ergo.chat)) with Caddy for Let's Encrypt + the websocket endpoint. **Both containers use host networking** (real client IPs for bans/cloaking; the host firewall applies). Ships `ergoctl` (admin CLI) and a health-checked updater that also syncs Caddy's cert into Ergo. | — | ## Conventions - **Alpine + Docker Compose + Caddy/Let's Encrypt** across every stack, with two exceptions: `squid` (a forward proxy with a local TLS-interception CA) and `openbao` (a same-LAN secrets store with native TLS) — neither uses Caddy/LE. + `ergo` keeps Caddy/LE but runs both containers in the **host network + namespace** (IRC needs real client IPs), so it has no published ports. - **`build.sh` → `deploy.sh`**: each stack's `build.sh` embeds its `docker-compose.yml` / `Caddyfile` / `.env.example` into a single self-contained `deploy.sh` (base64 tar.gz). That one file can be `scp`'d to a @@ -154,8 +157,8 @@ instance user-data, and the host configures itself on first boot. ## Multi-OS notes The host-provisioning scripts (`setup-host`, `harden-ssh`, `harden-jumphost`, -`sshuser`) and the eight Docker stacks (pocket-id, beszel, headscale, webfinger, -squid, copyparty, simplex, openbao) run on Alpine, Debian, and Alma. Distro differences live in +`sshuser`) and the nine Docker stacks (pocket-id, beszel, headscale, webfinger, +squid, copyparty, simplex, openbao, ergo) run on Alpine, Debian, and Alma. Distro differences live in [`scripts/oslib.sh`](scripts/oslib.sh) — package manager (`apk`/`apt`/`dnf`), init system (OpenRC/systemd), sshd service name, the per-distro `sftp-server` path, hostname, boot hooks, and the sshguard log source/backend. @@ -199,7 +202,9 @@ networking is unaffected. The harden scripts and `cloud-init/base.yml` / - **Docker caveat** — containers published with `-p` (e.g. Caddy's 80/443) reach the host through nat/`FORWARD` and **bypass `INPUT`**, so the firewall neither blocks nor needs to open them; the per-stack rule files are - belt-and-braces for any host-bound bind and self-documentation. + belt-and-braces for any host-bound bind and self-documentation. The one + stack that runs with `network_mode: host` (`ergo`) binds on the host + directly, so there the registered ports are the real gate. - **Recovery** — `harden-firewall.sh disable` un-locks you: on iptables it flushes the rules and sets `INPUT` back to `ACCEPT` (persisted); on firewalld it re-opens SSH (the `ssh` service + the configured port) and leaves firewalld diff --git a/automations.sh b/automations.sh index aa61a0a..b86bd4c 100644 --- a/automations.sh +++ b/automations.sh @@ -14,8 +14,8 @@ # It opens a Gum wizard (auto-installed) that lets you: # • Mode: deploy on THIS host, or build deploy.sh artifacts locally. # • Pick any deployment (pocket-id, beszel, headscale, webfinger, squid, -# copyparty, simplex, openbao) or any generic script (setup-host, harden-ssh, -# harden-jumphost, sshuser, auto-update). +# copyparty, simplex, openbao, ergo) or any generic script (setup-host, +# harden-ssh, harden-jumphost, sshuser, auto-update). # Shared defaults come from globals/ (see globals/README.md). # # Non-interactive: set SKIP_PROMPTS=1 plus the needed vars and pipe the menu @@ -118,7 +118,7 @@ set -euo pipefail . "$ROOT/scripts/lib.sh" load_globals -DEPLOYMENTS=(pocket-id beszel headscale webfinger squid copyparty simplex openbao) +DEPLOYMENTS=(pocket-id beszel headscale webfinger squid copyparty simplex openbao ergo) SCRIPTS=(setup-host harden-ssh harden-jumphost sshuser auto-update) # ---------------------------------------------------------------------------- @@ -184,6 +184,12 @@ ask_deployment_vars() { openbao) ask OPENBAO_ADDR "LAN address the Kanrisha tape host reaches the vault at (IP or DNS)" ask OPENBAO_BIND "Host IP to bind the API on (blank = 0.0.0.0)" optional ;; + ergo) + ask ERGO_DOMAIN "IRC server hostname (e.g. irc.example.com)" + ask ACME_EMAIL "Let's Encrypt email" + ask NETWORK_NAME "IRC network name, no spaces (blank = the hostname)" optional + ask PLAINTEXT "Also serve PUBLIC plaintext IRC on 6667? (1 = yes, blank = no)" optional + ask UPDATE_POLICY "Auto-update policy: latest | security | off (blank = latest)" optional ;; esac } diff --git a/deployments/ergo/.env.example b/deployments/ergo/.env.example new file mode 100644 index 0000000..2550aef --- /dev/null +++ b/deployments/ergo/.env.example @@ -0,0 +1,63 @@ +# Copy to .env and fill in. docker compose picks .env up automatically. +# deploy.sh seeds this file on first run and never rewrites it afterwards. +# +# NOTE: values are NOT quoted and must not contain a dollar sign (Compose +# interpolates .env). Secrets never live here: the admin oper password is in +# secrets/admin.pass (0600) and only its bcrypt hash is in ircd/ircd.yaml. + +# --- Identity ----------------------------------------------------------------- +# Public hostname (lower-case). Becomes Ergo's server.name, the Let's Encrypt +# cert subject, the Caddy site and the allowed websocket origin. Immutable after +# the first deploy (Ergo refuses to rehash a changed server name). +ERGO_DOMAIN=irc.example.com + +# Email for Let's Encrypt registration / expiry notices. +ACME_EMAIL=admin@example.com + +# IRC network name (ISUPPORT NETWORK=, welcome line). Letters, digits, . _ - +# only -- no spaces. deploy.sh defaults it to ERGO_DOMAIN. +NETWORK_NAME=irc.example.com + +# --- Image -------------------------------------------------------------------- +# ghcr.io tags carry the leading 'v' (v2.19.1). deploy.sh pins the newest +# release on first deploy; update.sh bumps this when it updates. Never pin +# 'stable'/'latest' here -- those are separate builds and defeat rollback. +ERGO_IMAGE=ghcr.io/ergochat/ergo +ERGO_TAG=v2.19.1 + +# uid:gid Ergo runs as inside the container = the host's `ergo` system user +# (created by deploy.sh; owns ./ircd). Filled in by deploy.sh. +ERGO_UID= +ERGO_GID= + +# --- Listeners ---------------------------------------------------------------- +# 0 (default): plaintext 6667 on 127.0.0.1 only (healthcheck + ergoctl). +# 1: ALSO serve plaintext IRC on :6667 publicly, with STS advertised so capable +# clients pin TLS. Credentials sent over 6667 cross the internet in clear. +# Chosen at the FIRST deploy: it selects a listener in ircd/ircd.yaml, which +# deploy.sh never rewrites. To change it later, edit that listener with +# 'ergoctl edit', set the value here, 'ergoctl restart', then re-run deploy.sh +# so the host firewall matches. +PLAINTEXT=0 + +# --- Updates (update.sh) ------------------------------------------------------ +# This file is where the scheduled jobs read these four knobs from; edit them +# here. (An explicit environment variable, or a value pinned in +# /etc/ergo-update.conf, overrides what is set here for that run.) +# latest - update to the newest release whenever one exists (default) +# security - update ONLY when a published GitHub security advisory covers the +# running version, or a release in range has a "### Security" +# section in its notes +# off - never change the running version (check/notify only) +UPDATE_POLICY=latest +# Releases whose notes announce "Compatibility breaks" are held for review and +# only notified, unless FORCE_UPDATE=1. +FORCE_UPDATE=0 +# Seconds of warning (NOTICE to all users) before an update restarts Ergo. +UPDATE_GRACE=60 +# 1 = the daily run also pulls a newer Caddy image and recreates it (drops every +# web-client websocket at that moment). 0 = Caddy updates via `ergoctl update caddy`. +CADDY_AUTOUPDATE=0 + +# --- Image tags --------------------------------------------------------------- +CADDY_TAG=2-alpine diff --git a/deployments/ergo/Caddyfile b/deployments/ergo/Caddyfile new file mode 100644 index 0000000..57d9e70 --- /dev/null +++ b/deployments/ergo/Caddyfile @@ -0,0 +1,76 @@ +# Caddyfile for the Ergo stack. Caddy runs in the HOST network namespace. +# +# Roles: +# 1. Obtain and renew the Let's Encrypt certificate for {$ERGO_DOMAIN}. Ergo's +# :6697 listener reuses that cert -- `update.sh certsync` copies it into +# ./ircd and rehashes Ergo (runs every 15 minutes, silent when unchanged). +# 2. Terminate HTTPS for IRC-over-WebSocket: wss://{$ERGO_DOMAIN}/webirc -> +# Ergo's plaintext websocket listener on 127.0.0.1:8097. Caddy adds +# X-Forwarded-For / X-Forwarded-Proto and Ergo trusts them from loopback +# (proxy-allowed-from: localhost), so web users keep their real IP and are +# marked secure (+Z). +# 3. A plain-text landing page with connection details at /. +# +# Add your own site config (e.g. a Gamja web client, see README) as +# conf.d/*.caddy -- deploy.sh installs this file but never touches conf.d/. +# Apply changes with: docker compose restart caddy + +{ + email {$ACME_EMAIL} + + # Host networking would put the admin API on the HOST's 127.0.0.1:2019, + # reachable by every local process and container. The config is static, so + # turn it off; changes are applied by restarting the container. + admin off + + # No HTTP/3: it would bind udp/443 on the host (not in the firewall's port + # list) and browsers do not run WebSockets over h3 anyway. + servers { + protocols h1 h2 + } +} + +# Loopback-only health endpoint for the compose healthcheck (no admin API to ask). +http://127.0.0.1 { + respond "ok" 200 +} + +{$ERGO_DOMAIN} { + encode zstd gzip + + # IRC over WebSocket. Ergo ignores the request path; Caddy proxies the + # Upgrade transparently and supplies X-Forwarded-For/-Proto itself. + handle_path /webirc* { + reverse_proxy 127.0.0.1:8097 + } + + # Operator additions (web client, redirects, ...). See conf.d/00-readme.caddy. + import conf.d/*.caddy + + handle { + header Content-Type "text/plain; charset=utf-8" + respond < + Powered by Ergo (https://ergo.chat). + TXT 200 + } + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } + + log { + output stdout + format console + } +} diff --git a/deployments/ergo/README.md b/deployments/ergo/README.md new file mode 100644 index 0000000..15963cc --- /dev/null +++ b/deployments/ergo/README.md @@ -0,0 +1,281 @@ +# 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. | +| `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\|rm` | manage operators in `ircd.yaml`; passwords are generated (24 chars) and printed once | +| `ergoctl passwd [show\|rotate]` | the `admin` oper password | +| `ergoctl announce ` | NOTICE to everyone | +| `ergoctl kill [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 ` first to get their IP | +| `ergoctl cmd ` | anything else as the admin oper, e.g. `ergoctl cmd NS SAREGISTER alice hunter2`, `ergoctl cmd CS PURGE #spam`; 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 ` | see [Backups](#backups) | +| `ergoctl update …` | passthrough to `update.sh` | +| `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 `.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. + +## Updates + +```bash +ergoctl update check # running vs latest, published security advisories, cert state; changes nothing +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-.tar.gz` (0600) with +`ircd.yaml`, `ircd.db`, `ergo.motd` and a `meta` file (version, 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 ` stops Ergo, +warns if the backup came from a newer version (older Ergo cannot read a newer +schema), restores, and waits for health. Ergo's own `datastore.autoupgrade` +leaves `ircd/ircd.db.v..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`. +- **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. | +| `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.` 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` (scheduled runs log there, +since busybox crond has nowhere else to put their output). + +## 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 (`accounts.registration.enabled`); + edit `ircd.yaml` if you want an invite-only network. diff --git a/deployments/ergo/build.sh b/deployments/ergo/build.sh new file mode 100644 index 0000000..0066140 --- /dev/null +++ b/deployments/ergo/build.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# build.sh -- (re)embed the loose deployment files into deploy.sh as a base64 +# tar.gz payload after __ARCHIVE_BELOW__. Idempotent: strips any existing +# payload first. +# +# Run this after editing ANY embedded file below, then re-stage deploy.sh -- +# the deployed stack uses the EMBEDDED copies, not the loose files. + +set -euo pipefail + +DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +SCRIPT="$DIR/deploy.sh" +MARKER="__ARCHIVE_BELOW__" + +# README, cloud-init and the build/deploy scripts are NOT embedded. ircd.yaml is +# generated on the host from the pulled image's own default config. +FILES=( + docker-compose.yml + Caddyfile + conf.d-readme.caddy + ergo.motd + .env.example + ergolib.sh + update.sh + ergoctl +) + +[[ -f "$SCRIPT" ]] || { echo "deploy.sh not found at $SCRIPT" >&2; exit 1; } +for f in "${FILES[@]}"; do + [[ -f "$DIR/$f" ]] || { echo "Missing $DIR/$f" >&2; exit 1; } +done + +# Syntax-check the bash payload before embedding it. +for f in ergolib.sh update.sh ergoctl deploy.sh; do + bash -n "$DIR/$f" || { echo "bash -n failed: $f" >&2; exit 1; } +done + +PAYLOAD=$(tar -czf - -C "$DIR" "${FILES[@]}" | base64) + +TMP=$(mktemp) +trap 'rm -f "$TMP"' EXIT + +sed "/^${MARKER}\$/,\$d" "$SCRIPT" > "$TMP" +{ + echo "$MARKER" + echo "$PAYLOAD" +} >> "$TMP" + +mv "$TMP" "$SCRIPT" +chmod +x "$SCRIPT" +trap - EXIT + +size=$(wc -c < "$SCRIPT") +echo "Built $SCRIPT (${size} bytes)" diff --git a/deployments/ergo/cloud-init.yml b/deployments/ergo/cloud-init.yml new file mode 100644 index 0000000..b7cef80 --- /dev/null +++ b/deployments/ergo/cloud-init.yml @@ -0,0 +1,42 @@ +#cloud-config +# +# Ergo (IRC server) -- harden SSH, then deploy, on a fresh host. +# +# Fill in REPO_URL and the values in the runcmd block, then paste this as the +# instance user-data. DNS for ERGO_DOMAIN must point at this host and ports +# 80/443 must be reachable before boot, or the Let's Encrypt cert request fails +# (Ergo then keeps serving a self-signed cert until certsync catches up). +# 6697/tcp (IRC over TLS) is opened on the host firewall as well. + +packages: + - git + +runcmd: + - hostnamectl set-hostname irc || true + - | + set -e + REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git + REPO_BRANCH=main + HARDEN_SSH=1 # harden SSH on this fresh VM (set 0 to skip) + SSH_PORT=22 + ALLOWED_IP= # optional: whitelist your client IP in sshguard + git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" /opt/automations + cd /opt/automations + + # Harden SSH first (PQ KEX, key-only auth, sshguard + deny-by-default + # firewall). Because the Ergo stack uses host networking, that firewall + # genuinely governs 80/443/6697 -- deploy.sh registers them with it. + if [ "$HARDEN_SSH" = 1 ]; then + SSH_PORT="$SSH_PORT" ALLOWED_IP="$ALLOWED_IP" SKIP_PROMPTS=1 FORCE=1 \ + bash scripts/harden-ssh.sh + fi + + # Deploy Ergo. The admin oper password is generated and stored in + # /srv/ergo/secrets/admin.pass (also printed to this log once). + ERGO_DOMAIN=irc.example.com \ + ACME_EMAIL=admin@example.com \ + NETWORK_NAME=ExampleNet \ + PLAINTEXT=0 \ + UPDATE_POLICY=latest \ + SKIP_PROMPTS=1 \ + bash deployments/ergo/deploy.sh diff --git a/deployments/ergo/conf.d-readme.caddy b/deployments/ergo/conf.d-readme.caddy new file mode 100644 index 0000000..5d51cb2 --- /dev/null +++ b/deployments/ergo/conf.d-readme.caddy @@ -0,0 +1,20 @@ +# Operator drop-ins for the Ergo stack's Caddy site block. +# +# Every conf.d/*.caddy file is imported INSIDE the {$ERGO_DOMAIN} site block, after +# the /webirc websocket route and before the text landing page. deploy.sh installs +# this readme once and never touches conf.d/ again. Apply with: +# +# docker compose restart caddy (or: ergoctl caddy-restart) +# +# Example -- serve the Gamja web client (https://codeberg.org/emersion/gamja) from +# ./caddy/www/gamja (unpack a release tarball there and write a config.json with +# {"server": {"url": "/webirc", "autojoin": "#lobby"}}): +# +# handle { +# root * /www/gamja +# file_server +# } +# +# ...and add `- ./caddy/www:/www:ro` to the caddy service volumes in +# docker-compose.yml. Gamja's Origin (https://{$ERGO_DOMAIN}) already matches +# server.websockets.allowed-origins in ircd.yaml. diff --git a/deployments/ergo/deploy.sh b/deployments/ergo/deploy.sh new file mode 100644 index 0000000..240de71 --- /dev/null +++ b/deployments/ergo/deploy.sh @@ -0,0 +1,1084 @@ +#!/usr/bin/env bash +# +# deploy.sh -- deploy Ergo (IRC server) behind Caddy (Let's Encrypt) on Alpine, +# Debian or Alma. Single-node; runs as root. +# +# What this does: +# 1. Installs docker + compose (and openssl/jq/curl) if missing; creates the +# `ergo` system user the container runs as. +# 2. Lays down the stack in $STACK_DIR: docker-compose.yml, caddy/etc/Caddyfile, +# update.sh, ergoctl (+ /usr/local/bin/ergoctl wrapper), ergolib.sh. +# 3. Seeds .env on first run, pinning ERGO_TAG to the newest release. +# 4. Generates ircd/ircd.yaml ONCE from the pulled image's own default config +# (server name, network name, listeners, websocket origin, admin oper with a +# random password saved to secrets/admin.pass) and validates it with a +# throwaway `ergo run --smoke` before anything starts. Never rewritten. +# 5. Opens 80/443/6697 (+6667 with PLAINTEXT=1) on the host firewall. Both +# containers use host networking, so this firewall really applies. +# 6. Pulls images, validates the Caddyfile, brings the stack up, waits for +# health, copies Caddy's Let's Encrypt cert into ircd/ and rehashes Ergo. +# 7. Schedules update.sh: certsync every 15 min, update check daily. +# +# Idempotent: re-run to apply compose/Caddyfile changes or pull images. It never +# touches ircd/ircd.yaml, secrets/, caddy/etc/conf.d/ or .env after the first run. +# +# Self-contained: docker-compose.yml, Caddyfile, conf.d-readme.caddy, ergo.motd, +# .env.example, ergolib.sh, update.sh and ergoctl are embedded as a base64 +# tar.gz at the bottom of this file. Rebuild with build.sh after editing them. +# +# Usage: +# bash deploy.sh # interactive prompts +# ERGO_DOMAIN=irc.example.com ACME_EMAIL=me@x.com NETWORK_NAME=MyNet \ +# SKIP_PROMPTS=1 bash deploy.sh # non-interactive +# PLAINTEXT=1 bash deploy.sh # FIRST deploy only: also serve plaintext +# # :6667 publicly (+STS). Fixed thereafter. +# ERGO_TAG=v2.19.1 bash deploy.sh # pin a specific release +# UPDATE_POLICY=security bash deploy.sh # also latest | off +# ERGO_AUTOUPDATE=0 bash deploy.sh # install update.sh but don't schedule the +# # daily update (certsync is still scheduled) +# CERT_WAIT=0 bash deploy.sh # don't wait for the first LE cert +# SKIP_DOCKER_INSTALL=1 bash deploy.sh + +set -euo pipefail + +: "${STACK_DIR:=/srv/ergo}" +: "${SKIP_DOCKER_INSTALL:=0}" +: "${FORCE:=0}" +: "${SKIP_PROMPTS:=0}" +[[ "$SKIP_PROMPTS" == "1" ]] && FORCE=1 +: "${ERGO_DOMAIN:=}" +: "${ACME_EMAIL:=}" +: "${NETWORK_NAME:=}" +: "${ERGO_IMAGE:=ghcr.io/ergochat/ergo}" +: "${ERGO_TAG:=}" # blank = pin the newest release +# Remember which settings the caller actually passed, BEFORE the defaults below +# hide that. On a re-run the rest come from the existing .env instead, so a +# plain `bash deploy.sh` never silently reverts a deployed stack's settings. +# (`|| true` on the trailing `&&`s below: a false test makes the whole list +# return 1, which aborts the script under `set -e` if it ever ends a block.) +EXPLICIT="" +for _k in ERGO_TAG PLAINTEXT UPDATE_POLICY FORCE_UPDATE UPDATE_GRACE CADDY_AUTOUPDATE CADDY_TAG; do + [[ -n "${!_k:-}" ]] && EXPLICIT="${EXPLICIT} ${_k}" || true +done +: "${PLAINTEXT:=0}" +: "${UPDATE_POLICY:=latest}" +: "${FORCE_UPDATE:=0}" +: "${UPDATE_GRACE:=60}" +: "${CADDY_AUTOUPDATE:=0}" +: "${CADDY_TAG:=2-alpine}" +: "${ERGO_AUTOUPDATE:=1}" # 0 = install update.sh but don't schedule the daily update +: "${CERT_WAIT:=180}" # seconds to wait for Caddy's first Let's Encrypt cert + +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; } + +[[ $EUID -eq 0 ]] || die "Run as root." + +# --------------------------------------------------------------------------- +# Extract the embedded archive; ergolib.sh (shared helpers) comes from it. +# --------------------------------------------------------------------------- +SCRIPT_DIR=$(mktemp -d -t ergo-deploy.XXXXXX) +trap 'rm -rf "$SCRIPT_DIR"' EXIT + +extract_archive() { + grep -a -A 9999999 '^__ARCHIVE_BELOW__$' "$0" \ + | tail -n +2 \ + | base64 -d \ + | tar -xz -C "$SCRIPT_DIR" +} + +if grep -q -a '^__ARCHIVE_BELOW__$' "$0"; then + log "Extracting embedded deployment files..." + extract_archive +else + die "No embedded archive found. Run build.sh to embed deployment files." +fi + +EMBEDDED=(docker-compose.yml Caddyfile conf.d-readme.caddy ergo.motd .env.example ergolib.sh update.sh ergoctl) +for f in "${EMBEDDED[@]}"; do + [[ -f "$SCRIPT_DIR/$f" ]] || die "Embedded archive missing $f" +done + +# shellcheck source=ergolib.sh +. "$SCRIPT_DIR/ergolib.sh" +ergo_set_paths + +# --------------------------------------------------------------------------- +# Host packages + Docker (deploy-only helpers; ergolib has the rest) +# --------------------------------------------------------------------------- +pkg_install() { # best-effort install across the three families + case "$(osfam)" in + alpine) apk add -q "$@" || true ;; + debian) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" || true ;; + rhel) dnf install -y -q "$@" || true ;; + esac +} + +install_docker() { + if command -v docker >/dev/null 2>&1; then + log "Docker already installed: $(docker --version)" + else + log "Installing Docker (OS: $(osfam))..." + case "$(osfam)" in + alpine) apk add -q docker docker-cli-compose openrc ;; + debian|rhel) command -v curl >/dev/null 2>&1 || pkg_install curl + curl -fsSL https://get.docker.com | sh ;; + *) die "Unsupported OS for auto Docker install. Set SKIP_DOCKER_INSTALL=1 and install Docker yourself." ;; + esac + fi + if command -v rc-update >/dev/null 2>&1; then + rc-update add docker default >/dev/null 2>&1 || true + rc-service docker status >/dev/null 2>&1 || rc-service docker start + elif command -v systemctl >/dev/null 2>&1; then + systemctl enable --now docker >/dev/null 2>&1 || systemctl start docker || true + fi + # dockerd may return before its socket listens (esp. OpenRC); don't race it. + local i + for i in $(seq 1 30); do + docker info >/dev/null 2>&1 && return + sleep 1 + done + warn "Docker daemon not ready after 30s; continuing (compose may fail -- check 'docker info')." +} + +# The container runs as this host user (no login, no home). It owns ./ircd so +# the DB, config and the copied TLS key are never readable by other local users. +ensure_ergo_user() { + if ! getent passwd ergo >/dev/null 2>&1; then + log "Creating system user 'ergo' (owns $IRCD_DIR; the container runs as it)..." + case "$(osfam)" in + alpine) getent group ergo >/dev/null 2>&1 || addgroup -S ergo + adduser -S -D -H -G ergo -s /sbin/nologin -g "Ergo IRC server" ergo ;; + *) useradd -r -M -s /usr/sbin/nologin -d /nonexistent -c "Ergo IRC server" ergo 2>/dev/null \ + || useradd -r -M -s /sbin/nologin -c "Ergo IRC server" ergo ;; + esac + fi + ERGO_UID="$(id -u ergo)"; ERGO_GID="$(id -g ergo)" +} + +open_ports() { + # Host networking: these ports are bound on the host itself, so the INPUT + # firewall genuinely governs them (unlike Docker-published ports). + local ports=(80/tcp 443/tcp 6697/tcp) + [[ "$PLAINTEXT" == "1" ]] && ports+=(6667/tcp) + if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then + log "Registering ${ports[*]} with the host firewall..." + printf '%s\n' "${ports[@]}" > /etc/firewall/ports.d/ergo.rule + /usr/local/sbin/firewall-apply + elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then + log "ufw active -- allowing ${ports[*]}..." + local p; for p in "${ports[@]}"; do ufw allow "$p" >/dev/null; done + elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then + log "firewalld active -- allowing ${ports[*]}..." + local p; for p in "${ports[@]}"; do firewall-cmd -q --add-port="$p" --permanent; done + firewall-cmd -q --reload + else + warn "No managed host firewall found; make sure ${ports[*]} are reachable (and nothing else is)." + fi +} + +# Anything already listening on our host ports (only checked while our own +# containers are not running -- on re-runs they hold the ports themselves). +check_ports_free() { + local listing p busy="" + listing="$(netstat -ltn 2>/dev/null || ss -ltn 2>/dev/null || true)" + [[ -n "$listing" ]] || return 0 + for p in 80 443 6697 8097 6667; do + if printf '%s\n' "$listing" | grep -qE "[:.]${p}[[:space:]]"; then busy="${busy} ${p}"; fi + done + [[ -z "$busy" ]] || die "Ports already in use on this host:${busy}. Both containers use host networking, so this stack needs 80/443 (Caddy), 6697/6667/8097 (Ergo) to itself. Another Caddy stack on this box? Move it or this." +} + +# --------------------------------------------------------------------------- +# Prompt for required vars, then normalise + validate them +# --------------------------------------------------------------------------- +prompt() { # [default] + local varname="$1" message="$2" def="${3:-}" + local -n ref="$varname" + if [[ -z "${ref:-}" ]]; then + if [[ "$SKIP_PROMPTS" == "1" ]]; then + [[ -n "$def" ]] && { ref="$def"; return 0; } + die "$varname required (set it in the environment; running with SKIP_PROMPTS=1)." + fi + if [[ -n "$def" ]]; then read -r -p "$message [$def]: " ref; ref="${ref:-$def}" + else read -r -p "$message: " ref; fi + [[ -n "$ref" ]] || die "$varname required." + fi +} + +prompt ERGO_DOMAIN "Public hostname of the IRC server (e.g. irc.example.com)" +ERGO_DOMAIN="$(printf '%s' "$ERGO_DOMAIN" | tr 'A-Z' 'a-z' | sed -E 's#^https?://##; s#[/.]+$##')" +[[ "$ERGO_DOMAIN" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$ ]] \ + || die "ERGO_DOMAIN '$ERGO_DOMAIN' is not a valid hostname." +prompt ACME_EMAIL "Let's Encrypt email" +prompt NETWORK_NAME "IRC network name (no spaces)" "$ERGO_DOMAIN" +[[ "$NETWORK_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]] \ + || die "NETWORK_NAME '$NETWORK_NAME' must be 1-64 chars of letters, digits, . _ - (no spaces: it is sent as an IRC parameter)." +[[ "$PLAINTEXT" == "0" || "$PLAINTEXT" == "1" ]] || die "PLAINTEXT must be 0 or 1." +case "$UPDATE_POLICY" in latest|security|off) ;; *) die "UPDATE_POLICY must be latest, security or off." ;; esac + +# Re-run guard: server.name is immutable once Ergo has run with it, and the +# cert/websocket origin/Caddy site all derive from ERGO_DOMAIN. +if [[ -f "$IRCD_DIR/ircd.yaml" ]]; then + existing="$(yaml_server_name "$IRCD_DIR/ircd.yaml" || true)" + if [[ -n "$existing" && "$existing" != "$ERGO_DOMAIN" ]]; then + die "This stack is already deployed as '$existing' (ircd/ircd.yaml server.name) but ERGO_DOMAIN=$ERGO_DOMAIN. Ergo cannot rename a running server; re-run with ERGO_DOMAIN=$existing, or stop the stack, move $STACK_DIR away and deploy fresh." + fi +fi + +# --------------------------------------------------------------------------- +# Re-run: adopt the deployed settings from .env +# +# Everything below (the firewall rule, the scheduled jobs, the summary) must +# describe the stack as deployed, not this shell's defaults. A setting the +# caller passed explicitly wins and is written back; PLAINTEXT is the exception, +# because the listener it selects lives in ircd.yaml, which is never rewritten. +# --------------------------------------------------------------------------- +if [[ -f "$ENV_FILE" ]]; then + for _k in PLAINTEXT UPDATE_POLICY FORCE_UPDATE UPDATE_GRACE CADDY_AUTOUPDATE CADDY_TAG; do + _v="$(env_get "$_k")" + [[ -n "$_v" ]] || continue + if [[ " $EXPLICIT " == *" $_k "* ]]; then + [[ "${!_k}" == "$_v" ]] && continue + if [[ "$_k" == "PLAINTEXT" ]]; then + die "This stack was deployed with PLAINTEXT=${_v} and you passed PLAINTEXT=${!_k}. The listener is fixed in ircd/ircd.yaml, which deploy.sh never rewrites, so re-running cannot change it. To switch: edit the plaintext listener with 'ergoctl edit' ($( [[ "$_v" == 1 ]] && echo '":6667": -> "127.0.0.1:6667":' || echo '"127.0.0.1:6667": -> ":6667":' ) and server.sts.enabled), set PLAINTEXT=${!_k} in $ENV_FILE, run 'ergoctl restart', then re-run this script to fix the firewall." + fi + log "${_k}: ${_v} -> ${!_k} (updating $ENV_FILE)." + else + printf -v "$_k" '%s' "$_v" + fi + done + [[ "$PLAINTEXT" == "0" || "$PLAINTEXT" == "1" ]] || die "PLAINTEXT in $ENV_FILE must be 0 or 1 (found '$PLAINTEXT')." + case "$UPDATE_POLICY" in latest|security|off) ;; *) die "UPDATE_POLICY in $ENV_FILE must be latest, security or off (found '$UPDATE_POLICY')." ;; esac +fi + +# --------------------------------------------------------------------------- +# Host prerequisites +# --------------------------------------------------------------------------- +[[ "$SKIP_DOCKER_INSTALL" == "1" ]] || install_docker +for tool in openssl jq curl; do + command -v "$tool" >/dev/null 2>&1 || { log "Installing $tool..."; pkg_install "$tool"; } + command -v "$tool" >/dev/null 2>&1 || die "$tool is required (certsync/updater); install it and re-run." +done +command -v docker >/dev/null 2>&1 || die "docker not found." +docker compose version >/dev/null 2>&1 || die "docker compose (v2 plugin) not found." +ensure_ergo_user + +# Other host-network containers share Ergo's trust boundary (its loopback). +docker info >/dev/null 2>&1 || die "The Docker daemon is not running or not reachable (check 'docker info'). Start it and re-run." +others="$(docker ps -q 2>/dev/null | xargs -r docker inspect -f '{{.Name}} {{.HostConfig.NetworkMode}}' 2>/dev/null \ + | awk '$2=="host" && $1!="/ergo" && $1!="/ergo-caddy" {print $1}' | tr '\n' ' ' || true)" +[[ -z "$others" ]] || warn "Other containers use host networking: ${others}-- they can reach Ergo's loopback listeners (see README: Security model)." + +if [[ -z "$(dc ps -q 2>/dev/null || true)" ]]; then check_ports_free; fi +open_ports + +# --------------------------------------------------------------------------- +# Stack directory + files +# --------------------------------------------------------------------------- +log "Setting up $STACK_DIR..." +install -d -m 0700 "$STACK_DIR" "$SECRETS_DIR" "$BACKUP_DIR" "$TEMPLATES_DIR" "$STATE_DIR" +install -d -m 0700 "$CADDY_DIR" "$CADDY_DIR/data" "$CADDY_DIR/config" +install -d -m 0755 "$CADDY_DIR/etc" "$CADDY_DIR/etc/conf.d" +install -m 0640 "$SCRIPT_DIR/docker-compose.yml" "$STACK_DIR/docker-compose.yml" +# Caddy has no admin API here and bind-mounted file contents are not part of the +# compose config hash, so `up -d` alone would leave a changed Caddyfile unloaded. +CADDY_CHANGED=0 +cmp -s "$SCRIPT_DIR/Caddyfile" "$CADDY_DIR/etc/Caddyfile" 2>/dev/null || CADDY_CHANGED=1 +install -m 0644 "$SCRIPT_DIR/Caddyfile" "$CADDY_DIR/etc/Caddyfile" +[[ -e "$CADDY_DIR/etc/conf.d/00-readme.caddy" ]] || install -m 0644 "$SCRIPT_DIR/conf.d-readme.caddy" "$CADDY_DIR/etc/conf.d/00-readme.caddy" +install -m 0644 "$SCRIPT_DIR/ergolib.sh" "$STACK_DIR/ergolib.sh" +install -m 0750 "$SCRIPT_DIR/update.sh" "$STACK_DIR/update.sh" +install -m 0750 "$SCRIPT_DIR/ergoctl" "$STACK_DIR/ergoctl" +install -d -m 0755 /usr/local/bin +cat > /usr/local/bin/ergoctl </dev/null || missing+=("$var") +done +(( ${#missing[@]} == 0 )) || die "Missing values in $ENV_FILE: ${missing[*]}" + +# --------------------------------------------------------------------------- +# Pull images, validate the Caddyfile (static config -- no admin API needed) +# --------------------------------------------------------------------------- +cd "$STACK_DIR" +log "Pulling images ($IMAGE, caddy:$(env_get CADDY_TAG))..." +docker compose pull +log "Validating the Caddyfile..." +# A one-off `compose run` container gets a generated name (compose drops the +# service's container_name for these), so this is safe while ergo-caddy runs. +docker compose run --rm --no-deps -T caddy \ + caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null \ + || die "Caddyfile validation failed. Check $CADDY_DIR/etc/Caddyfile and conf.d/*.caddy." + +# --------------------------------------------------------------------------- +# First run: generate ircd.yaml from the image's own default config +# --------------------------------------------------------------------------- +render_ircd_yaml() { #