- Rust 89.7%
- TypeScript 3.9%
- JavaScript 3.3%
- CSS 1.8%
- PLpgSQL 0.6%
- Other 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
A browser signed in through an OpenID Connect provider no longer sees the security key and authenticator app buttons on the account page, and the endpoints behind them refuse it: the provider owns that sign-in's second step. Existing factors can still be removed. The logo is sized in the markup too, so a cached stylesheet without its rule no longer stretches it across the card, and is smaller. The stylesheet URL carries a hash of its content so browsers pick up changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
| .github/workflows | ||
| admin-ui | ||
| complement | ||
| crates | ||
| deploy | ||
| docs | ||
| fuzz | ||
| nullchat | ||
| scripts | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| compose.yaml | ||
| Containerfile | ||
| deny.toml | ||
| logo.svg | ||
| README.md | ||
| rust-toolchain.toml | ||
| rustfmt.toml | ||
nullChat
A self-hosted chat server for a homelab or a small organisation: rooms, direct messages, and audio and video calls, all spoken in Matrix. That means Element, Element X, FluffyChat and the other Matrix apps work with it; Element Web, self-hosted beside it, is its web client.
It does not federate, deliberately: it talks to its own users and to no other server. People sign in with a local account or through any OpenID Connect provider, nullAuth and zeroAuth included.
Status: in use. It runs one deployment, with Element Web in browsers and Element X on phones: encrypted chat, push notifications and calls all work there. It has had no external security audit; what it defends against, and what it does not, is in
docs/threat-model.md.
What it does
- Sign-in: local accounts with a password, Matrix's OAuth 2.0 API, and any OpenID Connect provider. Two-step verification with an authenticator app or a security key, with recovery codes for a lost one.
- Rooms and sync: classic
/syncand Simplified Sliding Sync (MSC4186), threads, edits, reactions, search, read receipts, typing, presence, public rooms and room addresses, room upgrades, and every room version (1 to 12). - Encryption: the server side of end-to-end encryption (keys, cross-signing, to-device messages, key backup, dehydrated devices), on by default for direct messages and private rooms.
- Media: never served as active content, with thumbnails, asynchronous uploads, and link previews fetched by the server (public addresses only).
- Calls: MatrixRTC on LiveKit; nullChat is Element Call's token service and keeps delayed events (MSC4140) itself. A member who hands over their "leave" (MSC4195) drops out of the call within seconds of losing LiveKit.
- Phones: push notifications that carry event IDs, never message text.
- Bridges: mautrix-whatsapp and mautrix-meta as application services, with end-to-bridge encryption, receipts, typing, backfill and double puppeting.
- Accounts: people manage their sessions, password and second factors at
/account/, download their data, and delete or erase their account. A housekeeping job forgets addresses, old audit entries and, if rooms ask, old messages (docs/gdpr.md). - Administration: an admin API on its own listener (
127.0.0.1:8010) with its console (admin-ui/), for an administrator signed in with two factors; every change and every look at someone's details is audited.
Matrix's own test suite, Complement, runs against it; every test that still
fails is one for something nullChat does not do on purpose, such as
federation (docs/complement.md).
How it is built
| Server | Rust, #![forbid(unsafe_code)], axum, ruma, PostgreSQL 18 |
| Sync | classic /sync, and Simplified Sliding Sync (MSC4186) for Element X |
| Sign-in | Matrix's OAuth 2.0 API, local accounts (argon2id, TOTP, WebAuthn) and upstream OIDC |
| Encryption | end-to-end by default for direct messages and private rooms |
| Calls | MatrixRTC on LiveKit, with its built-in TURN |
| Web client | Element Web, unmodified, self-hosted and configured for this server only |
| Admin console | React and Vite, the same stack as nullAuth's console |
| GDPR | data export, erasure, retention per server and room, no third-party requests beyond the push gateway a phone's app registers (docs/gdpr.md) |
It was built in this order: skeleton → sign-in → rooms and sync → encryption
and media → sliding sync → calls → admin → Element Web → GDPR jobs and
hardening, then presence, push, bridges and what Complement asked for.
Choices that were not obvious are recorded in
docs/decisions.md. The fuzz targets are in
fuzz/.
Running it
cp .env.example .env # then edit
set -a; . ./.env; set +a
cargo run -- migrate # as the owning role
scripts/dev-db.sh init # let nullchat_app log in
cargo run -- user create alice --admin # prints a generated password
(cd admin-ui && npm ci && npm run build) # the admin console, for admin.ui_path
scripts/element-web.sh # Element Web on :8080, for this server
cargo run # serves on 127.0.0.1:8008, probes on :8009,
# the admin API on :8010
The admin API asks for two-step verification. Before signing in to it, turn
that on at /account/, or sign in through a provider that reports
multi-factor sign-ins.
scripts/dev-db.sh bootstrap creates the nullchat role and the nullchat
and nullchat_test databases on a development cluster. For containers, see
compose.yaml, which with a .env is a whole deployment
(a nullContainer stack, for one): it pulls the image, generates its own
secrets, and needs no checkout. The image workflow pushes the image to
src.zerolatitude.dev/gquillery/nullchat on every push to main.
The WhatsApp and Meta (Facebook, Messenger, Instagram) bridges are part of
that stack: add whatsapp and/or meta to COMPOSE_PROFILES and run
docker compose up -d. Each is set up on every start: configured for this
server, encrypted, with read receipts, typing and double puppeting, and
registered with nullChat. The admin console's Bridges page shows each one,
whether events are reaching it and why not, and pings it. Everyone on the server may use them;
NULLCHAT_BRIDGE_ADMINS names who administers them. Then start a chat with
@whatsappbot:<server> and send login qr, or with @metabot:<server> and
send login. A bridge's own settings live in its volume
(/data/config.yaml); the stack changes only what it must. Turning a bridge
off leaves its registration, so remove it and restart nullChat:
docker compose run --rm --no-deps --entrypoint rm whatsapp-setup /bridges/mautrix-whatsapp.yaml
docker compose restart nullchat
Two settings matter before the first start:
server.nameis the part after the colon in@alice:example.org. It is written into the database on first start, and the server refuses to start under any other name afterwards. Every user, room and upload ID contains it, so it cannot be changed.database.urlmust name the unprivilegednullchat_approle. The server refuses to serve as a superuser or as the owner of any table. The owning role goes indatabase.admin_urland is used only for migrations.
Behind a reverse proxy, set http.trusted_proxies. Without it every request
appears to come from the proxy, so a few wrong passwords from anyone throttle
logins for everyone.
Every setting can come from the environment (NULLCHAT_SERVER__NAME), and any
secret from a file (NULLCHAT_DATABASE__URL_FILE). See
deploy/nullchat.toml.example.
Tests
cargo test --workspace # database suites skip without a test database
Against a running server, the real matrix-js-sdk (the library behind Element Web) can hold a conversation between two accounts:
cd scripts/client-smoke && npm install
NULLCHAT_URL=http://localhost:8008 ALICE=alice ALICE_PASSWORD=… BOB=bob BOB_PASSWORD=… node smoke.mjs
NULLCHAT_URL=… ALICE=… ALICE_PASSWORD=… BOB=… BOB_PASSWORD=… node e2ee.mjs
With a mautrix bridge running against it, encryption on, an encrypted DM with its bot (commands sent and answers read, both encrypted):
NULLCHAT_URL=… ALICE=… ALICE_PASSWORD=… BOT=@whatsappbot:example.org PREFIX='!wa' node bridge.mjs
With a LiveKit server beside nullChat (the calls profile in compose.yaml), a call is
joined and left the way Element Call does it, and checked from LiveKit's side:
NULLCHAT_URL=… ALICE=… ALICE_PASSWORD=… BOB=… BOB_PASSWORD=… \
LIVEKIT_KEY=nullchat LIVEKIT_SECRET=… node call.mjs
matrix-js-sdk's own call code (the code inside Element Call) joins a call in an encrypted room, and each side must see both memberships, hold both media keys, and stay connected for 45 seconds — what Element Call needs before it sends any media:
NULLCHAT_URL=… ALICE=… ALICE_PASSWORD=… BOB=… BOB_PASSWORD=… node rtc-keys.mjs
matrix-rust-sdk, the engine inside Element X, does the same over Sliding Sync, with encryption:
cd scripts/sliding-smoke
NULLCHAT_URL=http://localhost:8008 ALICE=alice ALICE_PASSWORD=… BOB=bob BOB_PASSWORD=… cargo run --release
and resets a cross-signing identity the way Element X does, approved on the account page:
NULLCHAT_URL=http://localhost:8008 USER=alice PASSWORD=… cargo run --release --bin reset
The database suites run when NULLCHAT_TEST_DATABASE_URL and
NULLCHAT_TEST_ADMIN_DATABASE_URL are set (see .env.example). CI runs
formatting, clippy, both suites against PostgreSQL 18, and cargo deny.
Licence
AGPL-3.0-or-later.