Skip to content

Um9i/IRCv

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

51 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

IRCv

A lightweight self-hosted IRC-style chat server — FastAPI backend, React frontend, backed by PostgreSQL and Redis. Single docker compose up to run.

Features

  • 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/-i mode with single-use persistent invites (7-day expiry)
  • Channel modes+i invite-only, +m moderated (only ops/voiced speak), +t topic-locked (only ops set topic), +k channel key/password
  • Voice tier+v between regular user and op; voiced users can speak in +m channels
  • Ops & registration/register to become channel op; /op//deop others; /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 (requires Authorization: Bearer header)
  • 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-virtual with paginated "Load older messages"
  • Multi-worker ready — 4 uvicorn workers, nginx ip_hash sticky sessions, Redis pub/sub broadcast
  • SecurityX-Content-Type-Options, X-Frame-Options, Referrer-Policy, Content-Security-Policy headers; bcrypt cost configurable with transparent re-hash on login; optional at-rest encryption (Fernet) for messages, DMs and audit-log details

Quick Start

git clone <repo>
cd IRCv
docker compose up --build

Open 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_PASSWORD env vars).

Slash Commands

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

User Roles

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.

Stack

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

Architecture

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.

HTTP Endpoints

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.

Environment Variables

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 -d

Key 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.

Security

TLS

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;

Response Headers

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=()

Audit Log

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).

Development

# 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)

Testing

cd backend
pip install -r requirements.txt
pytest tests/ -q

The 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.

Backups

# 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.gz

Backups are stored in backups/ (git-ignored). Schedule make backup via cron for automated backups.

Schema Migrations

# 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.

About

Self-hosted IRC-style chat server — FastAPI + React + PostgreSQL + Redis

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors