A lightweight self-hosted IRC-style chat server — FastAPI backend, React frontend, backed by PostgreSQL and Redis. Single docker compose up to run.
- Channels & DMs — join named
#channels, persistent history, direct messages - Auth — nickname + email + password, bcrypt-hashed, JWT session tokens with version invalidation on password change
- irssi-style slash commands — full set including moderation, superuser, and account self-service
- Tab completion — cycles through nick matches;
@-prefix preserved - Invite-only channels —
+i/-imode with single-use persistent invites (7-day expiry) - Channel modes —
+iinvite-only,+mmoderated (only ops/voiced speak),+ttopic-locked (only ops set topic),+kchannel key/password - Voice tier —
+vbetween regular user and op; voiced users can speak in+mchannels - Ops & registration —
/registerto become channel op;/op//deopothers;/voice//devoice - Moderation —
/kick,/ban,/kickban,/unban,/bans; superuser global bans - Rate limiting — Redis-backed sliding-window: 5 auth/30 s per IP, 20 messages/5 s per nick, 30 DMs/60 s per nick, 5 joins/30 s per nick
- Audit log — all moderation and auth-failure events logged with actor/target/channel/reason; queryable via
/admin/audit-log(requiresAuthorization: Bearerheader) - Autojoin — mark channels to rejoin automatically on login
- Per-user ignore list —
/ignore <nick>mutes their channel and DM messages - Light / dark mode — toggle in sidebar, persisted in localStorage
- Collapsible sidebars — left (channels/DMs) and right (users); mobile off-canvas drawer ≤640 px
- Unread badges — live messages only; history never badges
- Auto-reconnect — exponential backoff (1 s → 30 s); session restored via short-lived access token + rotating refresh token
- Virtualised message list —
@tanstack/react-virtualwith paginated "Load older messages" - Multi-worker ready — 4 uvicorn workers, nginx
ip_hashsticky sessions, Redis pub/sub broadcast - Security —
X-Content-Type-Options,X-Frame-Options,Referrer-Policy,Content-Security-Policyheaders; bcrypt cost configurable with transparent re-hash on login; optional at-rest encryption (Fernet) for messages, DMs and audit-log details
git clone <repo>
cd IRCv
docker compose up --buildOpen http://localhost, register an account, and start chatting.
On first start a superuser account is created — credentials are printed to the backend container logs (or set via
SUPERUSER_NICK/SUPERUSER_PASSWORDenv vars).
| Command | Description |
|---|---|
/join #channel [key] |
Join a channel (provide key if +k is set) |
/part [#channel] |
Leave current or named channel / close DM |
/msg <nick> <text> |
Send a direct message |
/me <text> |
Send an action message (* nick text) |
/nick <new> |
Change your nickname |
/topic [text] |
Get or set the channel topic |
/names |
List users in the current channel |
/clear |
Clear the message pane locally |
/list |
Open the channel browser |
/register |
Register current channel — first user becomes op |
/deregister |
Deregister channel (owner only) |
/op <nick> |
Give op status (ops only) |
/deop <nick> |
Remove op status (ops only) |
/voice <nick> |
Give voice status (ops only) |
/devoice <nick> |
Remove voice status (ops only) |
/kick <nick> [reason] |
Kick user from channel (ops only) |
/ban <nick> |
Ban user from channel (ops only) |
/kickban <nick> [reason] |
Kick and ban (ops only) |
/unban <nick> |
Remove channel ban (ops only) |
/bans |
List current channel bans |
/mode [+i|-i|+m|-m|+t|-t|+k <key>|-k] |
View or set channel modes (ops only to change) |
/invite <nick> [#channel] |
Invite a user to an invite-only channel (ops only) |
/autojoin #channel |
Toggle autojoin for a channel on login |
/whois <nick> |
Show online status, role, channels, join date, last seen |
/search <term> |
Search your channels and DMs for a term (scoped to the active view when one is open) |
/sessions [list|revoke <id>] |
List your active login sessions (devices) or revoke one |
/passwd <old> <new> |
Change your password |
/ignore <nick> |
Toggle ignore for a user |
/deleteaccount <password> |
Permanently delete your account |
/quit [message] |
Disconnect with an optional goodbye message |
/gban <nick> [reason] |
Globally ban user from server (superuser) |
/gunban <nick> |
Remove global ban (superuser) |
/gbans |
List all global bans (superuser) |
/addsuper <nick> |
Grant superuser status (superuser) |
/removesuper <nick> |
Revoke superuser status (superuser) |
/help |
Show all commands |
| Role | Colour | Capabilities |
|---|---|---|
| Regular user | neutral | Chat, DMs, join/part channels |
Voiced (+v) |
neutral† | Same as regular; can speak in +m (moderated) channels |
| Op | blue | Kick, ban, kickban, unban, op/deop/voice/devoice others |
| Superuser | violet | All op actions + global bans, manage superusers, auto-op in all channels |
The first user to /register a channel becomes its op. Superusers are auto-op in all channels.
| Layer | Technology |
|---|---|
| Backend | Python 3.12, FastAPI, asyncpg, WebSockets |
| Frontend | Vite, React 18, TypeScript |
| Database | PostgreSQL 16 |
| Cache / PubSub | Redis 7 |
| Reverse proxy | nginx (ip_hash sticky sessions, security headers) |
| Containers | Docker Compose |
Browser ──WS──► nginx (ip_hash) ──► Worker 1 ──► PostgreSQL
├──► Worker 2 │
├──► Worker 3 Redis pub/sub
└──► Worker 4 (ircv:events)
│ │
└────────────┘
(all workers subscribed)
Broadcasting: each handler pushes events onto an asyncio.Queue; a queue consumer publishes to Redis ircv:events. Every worker subscribes and delivers to locally-connected clients.
Service layer: services/ orchestrators own DB transaction boundaries. channel_service handles channel register/deregister; identity handles nick renames with rollback. Global bans and superuser grants are inlined in handlers/moderation.py. Handlers stay declarative — no async with db.acquire() outside services.
Frontend event emitter: useChat emits 28 typed events via chatEmitter.ts (ChatEmitter). useAppCallbacks delegates to 4 domain-specific hooks (useAuthEventBindings, useChannelEventBindings, useMessageEventBindings, useServerEventBindings). No callback bag — the emitter is the API boundary between transport and state.
Sessions / refresh tokens: login/register mints a short-lived (15 min) access JWT carrying a sid claim plus a long-lived opaque refresh token, each bound to a server-side sessions row (only the SHA-256 hash of the refresh token is stored). On reconnect the client sends the access token; if expired, the refresh token is rotated and a new access token issued. /sessions list / /sessions revoke <id> give per-device visibility and a device-level kill-switch; password change revokes every session (and bumps token_version as a global kill-switch), account deletion deletes them. See db/sessions.py, services/auth_service._authenticate_token, and migration 011.
Persistence: users, channels, messages, DM history, topics, bans, autojoin prefs, ignore lists, invites, sessions, and audit log live in PostgreSQL.
Schema migrations: managed with Alembic (backend/migrations/versions/). make migrate applies pending migrations.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Liveness probe — {"status":"ok"} |
GET |
/ready |
Readiness probe — pings PostgreSQL + Redis; 503 with error details on failure |
WS |
/ws |
Main chat WebSocket (first message must be auth; rejected after 10 s timeout) |
GET |
/admin/audit-log |
Superuser-only audit log. Requires Authorization: Bearer <jwt> header. Params: limit (1–1000, default 200), before_id (cursor), action, actor, target, channel. Returns newest-first. |
| Variable | Default | Description |
|---|---|---|
SECRET_KEY |
change-me-in-production |
JWT signing key-ring — comma-separated, newest first (NEW,OLD) for zero-downtime rotation. The first key signs new tokens; all keys are accepted when verifying. Logs a warning if left at default. |
DATABASE_URL |
postgresql://ircv:ircv@db:5432/ircv |
PostgreSQL connection string |
REDIS_URL |
redis://redis:6379 |
Redis connection string (use rediss:// for TLS) |
REDIS_PASSWORD |
(unset) | Redis AUTH password. Also supports REDIS_PASSWORD_FILE for Docker secret mounts. Leave empty when Redis is on a trusted private network. |
POSTGRES_SSL_MODE |
(unset) | Postgres SSL mode (verify-full for production when DB is on a separate host). Leave empty for Docker-local. |
POSTGRES_SSL_CERT |
(unset) | Path to a PEM CA bundle for Postgres TLS verification (used when POSTGRES_SSL_MODE is set). |
ALLOWED_ORIGINS |
* |
Comma-separated CORS / WS origins |
HISTORY_LIMIT |
100 |
Channel/DM history rows replayed on join |
MAX_MESSAGE_LEN |
2000 |
Maximum channel/DM message length in characters |
SEARCH_RATE_LIMIT |
10 |
Per-nick /search requests per window |
SEARCH_RATE_WINDOW |
30 |
/search rate-limit window in seconds |
SEARCH_SCAN_LIMIT |
1000 |
Max rows decrypted and scanned per source (channels, DMs) for a single /search |
SEARCH_RESULT_LIMIT |
50 |
Max matches returned to the client per /search |
AUTH_RATE_LIMIT |
5 |
Per-IP auth attempts per window |
AUTH_RATE_WINDOW |
30 |
Auth rate-limit sliding window in seconds |
MESSAGE_RATE_LIMIT |
20 |
Per-nick channel-message limit per window |
MESSAGE_RATE_WINDOW |
5 |
Channel-message rate-limit window in seconds |
DM_RATE_LIMIT |
30 |
Per-nick DM limit per window |
DM_RATE_WINDOW |
60 |
DM rate-limit sliding window in seconds |
JOIN_RATE_LIMIT |
5 |
Per-nick channel-join limit per window |
TOPIC_RATE_LIMIT |
5 |
Per-nick topic-change limit (per TOPIC_RATE_WINDOW seconds) |
TOPIC_RATE_WINDOW |
60 |
Topic rate-limit window in seconds |
AUTH_TIMEOUT_SECONDS |
10.0 |
Seconds to wait for the first auth frame on a new WS connection |
JOIN_RATE_WINDOW |
30 |
Channel-join rate-limit window in seconds |
TOKEN_EXPIRE_HOURS |
24 |
Legacy JWT lifetime in hours (back-compat path) |
ACCESS_TOKEN_EXPIRE_MINUTES |
15 |
Short-lived session-bound access-token lifetime |
REFRESH_TOKEN_EXPIRE_DAYS |
30 |
Refresh-token / session lifetime |
MAX_WS_PER_IP |
10 |
Per-IP WebSocket connection ceiling (globally consistent across workers via Redis) |
WS_MAX_SIZE |
65536 |
Maximum WebSocket frame size in bytes |
WS_PING_INTERVAL |
30 |
WebSocket keepalive ping interval in seconds |
BCRYPT_COST |
12 |
bcrypt work factor; increase over time — re-hash is transparent on login |
REQUIRE_TLS |
0 |
Set to 1 to reject non-TLS WebSocket connections (X-Forwarded-Proto != https) |
ENVIRONMENT |
development |
Set to production to turn missing AT_REST_KEY / default SECRET_KEY warnings into hard startup failures |
HIBP_CHECK |
0 |
Set to 1 to check passwords against the Have I Been Pwned API on registration and /passwd (k-anonymity — only a 5-char hash prefix is sent) |
INVITE_EXPIRY_DAYS |
7 |
Single-use invite expiry in days |
AUDIT_PRUNE_DAYS |
90 |
Routine audit log retention in days |
AUDIT_KEEP_SENSITIVE_DAYS |
365 |
Sensitive audit log retention (gban, addsuper, auth_failed, account_delete) in days |
SUPERUSER_NICK |
(unset) | Bootstrap superuser nick — created on first start if absent |
SUPERUSER_PASSWORD |
(unset) | Bootstrap superuser password (random + logged if unset) |
LOG_LEVEL |
INFO |
Python log level (DEBUG, INFO, WARNING, ERROR) |
LOG_FORMAT |
text |
text (human-readable) or json (newline-delimited JSON) |
AT_REST_KEY |
(unset) | Comma-separated Fernet key-ring for at-rest encryption (NEW,OLD for zero-downtime rotation) — logs a warning if unset |
AT_REST_KEY_FILE |
(unset) | Path to a file containing AT_REST_KEY value (Docker / Kubernetes secret mount alternative) |
AT_REST_STRICT |
0 |
Set to 1 to return a sentinel (⚠ [undecryptable content]) on decrypt failure instead of passing through raw ciphertext. Enable only after all legacy plaintext rows are migrated. |
BLIND_INDEX_KEY |
(unset) | HMAC key for deterministic blind indexes over encrypted columns (currently users.email). Preserves the unique constraint + equality lookup once the column is encrypted. If unset, an unkeyed hash is used (still unique, but dictionary-attackable from a DB dump) and a warning is logged. Generate with openssl rand -hex 32. |
BLIND_INDEX_KEY_FILE |
(unset) | Path to a file containing BLIND_INDEX_KEY value (Docker / Kubernetes secret mount alternative). |
SECRET_KEY_FILE |
(unset) | Path to a file containing SECRET_KEY value (Docker / Kubernetes secret mount alternative) |
WS_ABUSE_THRESHOLD |
20 |
Number of malformed / invalid WebSocket frames before the connection is closed with code 1008 (policy violation). |
Set SECRET_KEY and AT_REST_KEY to strong random values in production:
SECRET_KEY=$(openssl rand -hex 32) \
AT_REST_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") \
docker compose up -dKey rotation (zero downtime): generate a new Fernet key, prepend it to AT_REST_KEY as a comma-separated list, then restart. The old key stays as the secondary until all rows are re-encrypted:
NEW_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
# Phase 1: set AT_REST_KEY="$NEW_KEY,$OLD_KEY" in your env / .env file, then restart workers.
# New writes use $NEW_KEY; old rows still decrypt under $OLD_KEY.
# Phase 2: re-encrypt existing rows onto the new primary key (resumable, batched 5k/tx):
make rotate-at-rest-key # DATABASE_URL + AT_REST_KEY from env
make rotate-at-rest-key ARGS="--dry-run" # report row counts without writing
make rotate-at-rest-key ARGS="--restart" # clear saved progress and start over
# Phase 3: once complete, set AT_REST_KEY="$NEW_KEY" (drop the old key) and restart.The re-encryption job (backend/scripts/reencrypt.py) walks messages.content,
dm_messages.content, audit_log.detail, and users.email, decrypting each value with
whichever ring key still works and re-encrypting under the new primary key. It commits a
keyset cursor per column to the reencrypt_progress table, so an interrupted run resumes
where it left off; it is safe to re-run (content-idempotent). The one-shot
scripts/rotate_key.py (single serialisable transaction, explicit --old-key/--new-key)
remains for small datasets / maintenance-window rotations.
JWT key rotation (zero downtime): SECRET_KEY is a comma-separated key-ring (newest first). New tokens are signed with the first key and carry a kid header; verification accepts any key in the ring, so existing sessions survive a rotation. Prepend a fresh key, restart, and retire the old key one TOKEN_EXPIRE_HOURS window later:
NEW_JWT_KEY=$(openssl rand -hex 32)
# Phase 1: set SECRET_KEY="$NEW_JWT_KEY,$OLD_JWT_KEY", restart. All sessions stay valid.
# Phase 2: after TOKEN_EXPIRE_HOURS, set SECRET_KEY="$NEW_JWT_KEY" to retire the old key.Copy .env.example to .env and fill in values before your first run.
Passwords and tokens are transmitted in WebSocket frames. Run this behind a TLS-terminating reverse proxy in production (nginx, Caddy, Cloudflare Tunnel, etc.) so the connection is wss://, not ws://.
The nginx config in frontend/nginx.conf includes a commented-out Strict-Transport-Security header — uncomment it once TLS is confirmed:
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;The nginx config adds these headers on every response:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Referrer-Policy |
no-referrer |
Content-Security-Policy |
default-src 'self'; script-src 'self'; … |
Cross-Origin-Opener-Policy |
same-origin |
Cross-Origin-Resource-Policy |
same-origin |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), payment=() |
All moderation actions and failed login attempts are logged to the audit_log table. Query via:
curl -H "Authorization: Bearer <superuser-jwt>" \
"http://localhost/admin/audit-log?limit=50&action=auth_failed"Routine events are retained for AUDIT_PRUNE_DAYS (90 days); sensitive events (gban, addsuper, auth_failed, account_delete) are retained for AUDIT_KEEP_SENSITIVE_DAYS (365 days).
# Start only the dependencies
docker compose up -d db redis
# Backend (hot reload — single worker)
cd backend
pip install -r requirements.txt
DATABASE_URL=postgresql://ircv:ircv@localhost:5432/ircv \
REDIS_URL=redis://localhost:6379 \
uvicorn main:app --reload
# Frontend (Vite dev server, separate terminal)
cd frontend
npm install
npm run dev
# → http://localhost:5173 (proxies /ws to localhost:8000)cd backend
pip install -r requirements.txt
pytest tests/ -qThe backend suite covers auth, validators, rate limiting, config, error messages, permissions, handler guards, at-rest crypto, search, login sessions, and protocol contract schemas (165 unit tests + 56 integration tests).
Protocol contract tests (backend/tests/test_protocol_contract.py and
frontend/src/test/protocolContract.test.ts) validate representative server→client frames
(init_state, history, dm_history, mode_change, invited, message, error,
users, whois, search_results, sessions_list) against shared JSON Schema fixtures in shared/schemas/.
Both suites load the same schema files, so any drift between protocol.py builders and
types.ts wire types fails one side.
# Create a timestamped gzip dump in ./backups/
make backup
# Restore from a dump file (.sql or .sql.gz)
make restore BACKUP=backups/ircv-20260101-120000.sql.gzBackups are stored in backups/ (git-ignored). Schedule make backup via cron for automated backups.
# Apply all pending migrations
make migrate
# Generate a new migration after a schema change
docker compose run --rm backend alembic revision -m "describe your change"
# Then edit the file in backend/migrations/versions/The initial migration uses IF NOT EXISTS throughout — safe to run against an existing deployment.