Infrastructure for weblounge.org, an invite-only IRC
network at irc.weblounge.org. Everything that runs on the servers lives in
this repository and is deployed from it.
You need Nix with flakes enabled, and ideally direnv. Then:
direnv allow # or: nix develop
just # list the available commandsEntering the dev shell installs the git hooks: formatting and lint run on
every commit, and nix flake check (host builds plus VM tests) runs before
every push.
The landing page's sources live in web/: markup in pages/, plain CSS in
styles/, TypeScript in scripts/. Nix builds them with esbuild into
pkgs.weblounge.web; there is no npm.
just web dev # serve web/ on http://localhost:8000The page reloads whenever a stylesheet or script changes; edits to the HTML show up on the next manual reload.
Concierge, the members' site at members.weblounge.org, is a Rust app in
crates/concierge: axum, with askama templates in its templates/. Its
stylesheet, web/styles/members.css, builds on the landing page's tokens
and the components in web/styles/components/, and esbuild gives every
asset a hashed name that concierge looks up in the build's manifest. Both
sites draw the wordmark from web/images/wordmark.svg with <use>, and
concierge's header mark is the favicon's #mark. The network chair,
web/images/chair.svg, is inlined by both: the landing page's build
replaces <!-- include images/chair.svg --> with it, and the Home tab
inlines concierge's built copy, so scripts/chair can animate it on
either site.
just concierge dev # run it on http://localhost:8080
just concierge dev-admin invite create --root # an admin command against itbacon restarts concierge whenever its code, a template or a stylesheet
changes, and the open page reloads once it's back. Next to it runs a local
Ergo on the same base config production builds on, its API on
127.0.0.1:8089 and its log in target/dev/ergo.log. Concierge's database,
Ergo's datastore and the dev secrets all live in target/dev; delete it to
start over. cargo test runs the tests; just check adds clippy and
rustfmt.
Concierge keeps its data in SQLite through sqlx, which checks every query
at compile time against the metadata in .sqlx. Concierge never sends
mail itself: it queues messages in the outbox, a database of its own
owned by crates/outbox, and crates/mailer, a separate binary that
depends on neither concierge nor its database, sends them over SMTP as
the concierge-mailer unit. crates/common holds the little both
share. After changing a query or adding a migration to either database's
migrations, run just concierge prepare and commit what it writes. On
the server, concierge-migrate.service applies new migrations to both
before anything else opens them, so the mailer never migrates; just concierge dev migrates as it starts.
Mail that carries a secret, such as a verification link, goes into the
outbox sealed with age to a key the mailer
generates as it starts, keeps in memory only and withdraws as it stops,
so a copy of the disk never holds the link. Sealed mail still waiting
when the mailer stops is lost, and members ask for another. While no
mailer runs, as in just concierge dev, concierge sends no such mail
and says to try again.
https://irc.weblounge.org serves gamja
from nixpkgs, patched in pkgs/gamja.nix. A member signed in on the
members' site lands in #lounge without typing anything:
- gamja's
config.json, generated inservices/webchat.nix, sends it to concierge's/oauth/authorize. Concierge, a small OAuth 2.0 server, issues a one-minute, single-use code to the signed-in member, or has them sign in first. - gamja trades the code and its PKCE verifier at
/oauth/tokenfor an access token that lasts as long as the member's session; signing out or a password reset ends it. - gamja connects to
wss://irc.weblounge.org/socket, which nginx proxies to Ergo's WebSocket listener on127.0.0.1:8097, and logs in with SASL OAUTHBEARER. Ergo asks concierge's/oauth/introspecton loopback whose token it is, authenticating with the generatedoauth-introspection-secret, and never creates an account for it.
Concierge's INTROSPECTION_CLIENTS lists who may introspect, each with
a secret of its own; all get the same answers. Besides Ergo, the push
relay checks members' session tokens as push-relay, with
push-relay-introspection-secret from sops.
In the VM it's at https://irc.weblounge.localhost:10443.
https://beta.weblounge.org serves the web client that will replace
gamja, weblounge-client,
built by its own flake, the weblounge-client input. It signs members in
the same way, as concierge's second OAuth client, weblounge-client,
reads its settings from the /config.json services/beta-client.nix
generates, and connects to wss://beta.weblounge.org/socket. Members
are still sent to gamja.
https://push.weblounge.org is the push relay, services/push-relay.nix,
also from weblounge-client but through an input of its own, push-relay,
so it and the beta client can move apart. A device registers with it once,
which takes a member's session token that it checks with concierge, and
gets a route for each home, such as our Ergo, to POST its encrypted
pushes to. The relay passes them on to the device's browser push service,
signed with its own VAPID key, push-relay-vapid-key from sops; it never
decrypts them. It keeps devices and routes in
/var/lib/push-relay/push-relay.sqlite3, and reaches the push services
on port 443 alone, and concierge.
nginx refuses bodies over 4 KB and holds homes to a rate per route, and per address but for credenza's own, which Ergo pushes from for every member; it holds devices to a rate per address. Like concierge's limits, these keep addresses in memory alone, for minutes. The web client will need the key's public half, as base64url:
sops decrypt --extract '["push-relay-vapid-key"]' secrets/credenza.yaml \
| openssl ec -pubout -outform DER | tail -c 65 | basenc --base64url | tr -d '=\n'A bot is an Ergo account a member owns. Ergo can't tell it from anyone
else's account, so concierge's bots table is the record of which
accounts are bots and whose they are. Members make, reset and delete
their own on the members' site's Bots tab, three at a time once they've
confirmed their email; admins use concierge-admin bot …, which shares
its code and isn't held to the three. A bot's generated password is
shown once. Deleting a bot erases its Ergo account, disconnecting it.
Its owner may make a bot under its name again at once; anyone else,
member or newcomer, once Ergo's chat history has expired for it, so
nobody passes for it in the scrollback. Concierge is handed that
period, history.restrictions.expire-time, from Ergo's settings. Bots
set mode +B on themselves.
templates/bot is a Go bot to start from (nix flake init -t github:luizribeiro/weblounge#bot); its README covers running it with and
without NixOS. bender, our own bot, doesn't run it, and isn't a
member's bot but a house account: on credenza,
louro holds bender's connection,
in #bots and #ops, and answers nothing. louro may speak in #ops alone,
and only members of the louro group may use its socket. bender
connects from bender!~u@weblounge/bot/bender, like members' bots, so
the bots' ban keeps it out of #lounge too.
Bots connect from their owner's vhost, <bot>!~u@weblounge/bot/<owner>,
where members show <nick>!~u@weblounge; Ergo's API sets it through
pkgs/ergochat-hs-set-api.patch. bot create, ensure and
reset-password set it. Bots from before vhosts need concierge-admin bot apply-vhosts once after the deploy that brought them; its help text
marks it as a migration, and running it again changes nothing. Bots stay
out of #lounge, which everyone auto-joins, through its one ban
+b *!*@weblounge/bot/*, which luiz sets as the channel's founder.
A house account is one of the network's own, neither a member nor a
member's bot: concierge, which the greeter connects as, and bender. The
service that connects as one declares it,
weblounge.ergo.houseAccounts.<name>.vhost = "weblounge/<name>", which
gives it a generated <name>-password in RAM and
<name>-account.service. That oneshot holds Ergo's API token and runs
concierge ensure-house-account <name> <vhost> before the service
starts: it registers the account or puts the password back, and sets its
vhost and always-on. Concierge reads the names from
CONCIERGE_HOUSE_ACCOUNTS: nobody may sign up or make a bot under them,
and member reconcile expects them in Ergo alone.
concierge, weblounge's resident bot, is a house account.
crates/greeter connects as it, in bot mode, from
concierge!~u@weblounge/concierge, outside the bots' ban, so it sits
in #lounge. Every invite a member made records an arrival when
it's redeemed (root invites, which bootstrap the network, don't), and
every few seconds the greeter WHOISes the newcomers it hasn't seen
connected yet. At first sight it welcomes them in #lounge, DMs their
inviter and says hello to them; it nudges the inviter of one who
hasn't connected two days in. welcome_rules.rs holds the timings;
each step is recorded once Ergo echoed it, and the records go two
weeks after joining, along with those of a removed member's invitees.
It parses what the network sends it, so it holds concierge's database
and its account's password alone. concierge-account.service, its
house account's oneshot, holds Ergo's API token and puts the generated
password back before every start. VM tests turn the greeter off, but for welcome.py.
Members paste or attach images in IRC clients, which upload them to the
endpoint Ergo advertises in the soju.im/FILEHOST and draft/FILEHOST
ISUPPORT tokens, https://files.weblounge.org/upload, and post the link
they get back. The limits and the retention live in
crates/concierge/src/upload_rules.rs: 10 MB an image, 500 MB a member,
their bots' counted to them, 30 an hour an account, deleted after 30
days.
Two processes share the work, so the one that parses what members upload holds nothing else:
- Concierge authenticates the upload with the credentials the client
logs in to IRC with: gamja's access token as a Bearer, or a nick and
password as Basic, which Ergo checks behind the sign-in limits. It
checks the limits, and records each upload in its
uploadstable. crates/scrubber, a binary of its own, does everything with the bytes.concierge-scrubber.socketstarts one per connection, with no network, a dynamic user, and nothing of the filesystem but the store and the uploads directory. Concierge streams the body to it, in the framescrates/filehostdefines; it tells PNG, JPEG, GIF and WebP from their bytes, refuses anything else, strips EXIF (but for the orientation), XMP, comments and the like without decoding a pixel, and stores what's left in/var/lib/concierge-filesunder a random 22-character id and the extension of the type it found.
nginx serves the files from files.weblounge.org, logging nothing, as
images that nothing may run or index. Members see and delete their
uploads, and their bots', on the Uploads tab. concierge-expire-uploads
deletes them after 30 days, hourly, with any file no upload stands for;
member delete and bot delete delete them with their owner.
just vm start boots credenza's real configuration in a QEMU VM on your
machine, with ports forwarded from localhost:
| Service | Address |
|---|---|
| SSH | just vm ssh |
| HTTP | http://weblounge.localhost:10080 |
| HTTPS | https://weblounge.localhost:10443 (self-signed) |
| Members | https://members.weblounge.localhost:10443 |
| Chat | https://irc.weblounge.localhost:10443 |
| IRC | irc.weblounge.localhost:16697, TLS (self-signed) |
The VM's names live under weblounge.localhost, which browsers resolve to
your machine without any /etc/hosts edits.
IRC requires SASL, and there's no NickServ registration: accounts come
from Ergo's API on the server's loopback, authenticated with the token
in /run/weblounge-secrets/ergo-api-token.
The VM's console is attached to your terminal (Ctrl-A X quits QEMU). From another terminal:
just vm push # deploy your working tree into the running VM, no reboot
just vm ssh # shell into it
just vm concierge status # run a concierge admin command in it
just vm reset # drop its disk so the next start is freshjust vm push deploys exactly the way just prod push does, so it doubles
as a rehearsal for production deploys.
Logging in requires one of the SSH keys in modules/admins.nix.
just prod build # build credenza's system closure
just prod install root@IP # first install on a fresh server (wipes its disk)
just prod push # deploy to credenza.weblounge.orgjust prod push refuses to deploy while deSEC's zone differs from the one
the hosts generate (just dns push first), and waits until every
nameserver answers the names it orders certificates for.
Every push, to production or the VM, runs the new system on trial. It keeps
it only if a fresh SSH login works and every deploy check passes; otherwise
it switches back to the system that was running. Deploy checks are declared
next to what they check, as weblounge.deployChecks, and run on the host or
from your machine. The host keeps a timer of its own, so it also goes back
by itself, ten minutes on, if the push loses touch with it. #ops hears of
each push once it's built, before the copy to the host, and then of its
trial and whether it was kept, rolled back or given up on before switching.
IRC clients connect to irc.weblounge.org:6697 over TLS.
Concierge's admin commands run on the server, as the service itself:
concierge-admin status over SSH, or just prod concierge status from your
machine. They print plain lines a script can read:
| Command | What it does |
|---|---|
status |
Configuration, secrets, database and whether Ergo answers |
invite create --root [--admin] |
Prints a root invite's link; --admin makes its redeemer admin |
member reset-password <account> |
Sets a new generated password, ends sessions, prints it once |
member delete <account> |
Locks a member out, removes data, uploads and bots; place stays |
member reconcile |
Lists accounts Ergo and concierge disagree on, bots included |
member send-verification <account> |
Mails a member a new link to confirm their email |
member send-verification --unverified |
Mails one to every unconfirmed member never sent one, 60 a run |
bot create <owner> <name> [--note …] |
Registers a member's bot, prints its generated password once |
bot list [<owner>] |
Lists bots, of everyone or one member: name, owner, date, note |
bot reset-password <name> |
Sets a bot's new generated password, prints it once |
bot delete <name> |
Erases a bot and its uploads, disconnecting it; frees its name |
bot ensure <owner> <name> --password-file <f> |
Creates the bot if missing, else sets its password; - is stdin |
After member delete, an oper still runs /NS ERASE <account>. bot delete erases the bot's account itself, through
pkgs/ergochat-ns-erase-api.patch, which disconnects it too.
member send-verification is for the members who joined before emails
were confirmed, and goes once they all have. Members have a week from
their first link to confirm; concierge-remind.timer runs concierge remind hourly, which mails the one reminder, with a fresh link, from the
fifth day.
deSEC hosts the weblounge.org zone, and this repository is its source
of truth. dns/zone.nix generates it from the hosts: an A record for
each host's own name, and a record for every name its nginx serves, so a
new virtual host gets its DNS without anyone writing a record. Mail for
the domain goes through MXroute, whose MX, SPF, DKIM and DMARC records
the zone also holds.
just dns preview # what would change at deSEC
just dns push # make deSEC match the repositoryBoth run dnscontrol with the deSEC API token from secrets/dns.yaml,
which sops decrypts with luiz's GPG key; sops secrets/dns.yaml edits
it. push also deletes records the repository doesn't have.
Most secrets are random, generated on the machine by
weblounge-secrets.service. The ones services only read as they start,
like Ergo's API token, live in RAM, in /run/weblounge-secrets, and
every boot makes new ones, so a copy of the disk holds nothing that
works; the rest, like the session key that keeps members signed in,
stay in /var/lib/weblounge-secrets. Those that come from outside,
like the SMTP password, live in secrets/<host>.yaml, encrypted to
luiz's GPG key and the host's SSH host key; sops-nix decrypts them on
the host. sops secrets/credenza.yaml edits them. VMs decrypt stand-ins
in secrets/vm.yaml with secrets/vm-age.key, committed on purpose.
| Path | What lives there |
|---|---|
hosts/ |
One directory per machine; each becomes a nixosConfiguration |
modules/ |
Configuration shared by every host |
lib/ |
Host discovery and per-host flake outputs |
tests/ |
Flake checks of the Nix code; tests/e2e/ holds the VM tests |
services/ |
Opt-in configuration, one file per service a host can run |
pkgs/ |
Our own packages, exposed to hosts as pkgs.weblounge.* |
crates/ |
Rust code: concierge, its mailer, outbox and scrubber, ergo-api |
web/ |
Sources of everything the browser loads |
templates/ |
nix flake init templates; bot is the template bots start from |
dns/ |
The DNS zone, generated from the hosts, and its dnscontrol config |
just/ |
just vm …, just prod …, just dns …, just web … and more |
scripts/ |
Helpers the recipes call |
Servers are named after furniture: credenza is the first.