If a client registered under a different nick than the upstream's, it kept its own nick and didn't treat the synthesized ':<upnick> JOIN #chan' as a self-join — so channels never opened. Send an explicit NICK change to the upstream nick after the welcome burst when they differ. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| cmd | ||
| deploy | ||
| dev | ||
| internal | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| docker-compose.dev.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| Dockerfile.dev | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| README.md | ||
evrd
The IRCForever IRC bouncer — stay connected, forever.
evrd keeps your IRC connection alive while you're away, buffers what you miss,
and lets clients (native or web) attach and detach at will.
Layout
This is a monorepo with three parts:
| Part | Path | What it is |
|---|---|---|
| core | cmd/evrd, internal/ |
The bouncer daemon and its engine (IRC, sessions, storage). |
| cli | cmd/evrctl |
Command-line admin for a running daemon. |
| spa | web/ |
The Svelte web client (Vite + Svelte 5 + Tailwind). |
cmd/evrd and cmd/evrctl are thin entrypoints; the real logic lives in
internal/:
store— persistence (users, networks, buffered history) via GORM behind aStoreinterface. SQLite by default (pure-Go, no CGO); switch to Postgres by changing the DSN —db postgres://user:pass@host/evrd— no code change.irc— protocol message parse/encodeupstream— one live IRC connection per network: dial, register, autojoin, reconnect, parse + persist + fan out messages, sendbouncer— owns the store and the set of running upstream connectionsapi— HTTP + WebSocket admin/client API consumed by the web appclient— downstream IRC listener: native clients (Hexchat, irssi, …) attach with a server password, get backlog replay, and a full-fidelity live feed bridged to/from the upstream
Develop
The whole stack — an upstream IRC server, evrd, and the web client — runs with
Docker Compose, with hot reload on both services (air rebuilds the daemon on
.go changes; vite HMR reloads the web client on source changes):
make up # dev stack with hot reload
make down # stop it
- web client → http://localhost:5173
- evrd admin/client API → http://localhost:8098
- evrd IRC (native client attach) → localhost:6697 (plaintext in dev)
Create an account, then sign in on the web client and add a network:
docker compose -f docker-compose.dev.yml exec evrd \
go run ./cmd/evrctl -db sqlite:/var/lib/evrd/evrd.db user add <name> <password>
In the dev stack, point a network at the internal upstream: host ircd, port
6667, TLS off.
Attaching a native IRC client
Point any IRC client at the bouncer and authenticate with a server password
of <user>:<password> (or <user>/<network>:<password> to pick a network):
server: localhost
port: 6697 (TLS, self-signed in dev — enable "accept invalid cert")
6667 (plaintext)
password: bncuser:bncpass
On connect you get the welcome burst, your channels (with names/topic), backlog replay, and live traffic. Anything you send shows up in the web console and on your other attached clients too — all sessions share one upstream connection.
For a real TLS cert, set tls_cert / tls_key in the config. Upstream networks
authenticate with SASL PLAIN when the network has a password and the server
supports it (falling back to a server PASS).
The upstream ircd (ergo, a stand-in for the
production ngircd-27) is internal-only — evrd reaches it as ircd:6667 over
the compose network, so it never collides with a host port.
To run the built images instead (what CI publishes; web served by nginx on :8080):
make up-prod
End-to-end tests
Playwright specs in web/e2e drive the UI against the real daemon (login, add
- connect a network, send a message in the console):
make e2e-setup # one-time: install web deps + the Playwright browser
make e2e # brings up the dev stack and runs the specs
Without Docker
make build # builds bin/evrd and bin/evrctl
make web # builds web/dist
./bin/evrd -version
cd web && pnpm dev
Status
Working bouncer. Create an account, add a network, and it really connects: the daemon dials the upstream, registers, autojoins, stays connected (reconnecting with backoff), and buffers history. Two ways to use it, sharing one upstream:
- Web client — login, manage networks, live console.
- Native IRC client — attach on 6697 (TLS) or 6667 with a server password; get backlog + a full-fidelity live feed (names, joins, topics, everyone's messages). Multiple clients + the web stay in sync over one upstream.
Upstreams authenticate via SASL PLAIN (or server PASS). The IRC listener does TLS (self-signed in dev, or your own cert).