serverless-ircd
v0.11.0
Published
Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters
Downloads
1,364
Readme
ServerlessIRCd
A serverless IRC daemon where the IRC protocol logic lives in a pure, platform-agnostic core, and two thin adapters run it on Cloudflare Workers (Durable Objects) or AWS (API Gateway WebSockets + Lambda + DynamoDB).
One TypeScript codebase. Two serverless substrates.
Status: preview. The pure protocol core, the
IrcRuntimeport + in-memory runtime, a runnable local CLI server, the Cloudflare Workers adapter, and the AWS (API Gateway WebSocket + Lambda + DynamoDB + CDK) adapter are all functional and deployed to staging.Dual transport. Both adapters speak a real
irc+tls :6697(TLS-over-TCP) surface for stock IRC clients — Cloudflare via Spectrum + a Container origin, AWS via a Network Load Balancer + Lambda streaming — alongside the WebSocket path, plus SASLEXTERNALvia mTLS and a Cloudflare D1-backed SASL account store.Complete protocol surface. The deferred IRC verbs (
KILL,REHASH,LUSERS+STATS,TRACE,WALLOPS,SETNAME) are implemented, and the S2S (CONNECT/SQUIT/LINKS) and obsolete RFC 2812 (SERVICE/SUMMON/USERS) verbs are formally dropped.IRCv3 extensions. Negotiated caps include
account-notify,msgid,standard-replies,MONITOR,labeled-response,sts,draft/typing,draft/multiline,draft/read-marker, anddraft/pre-away, plus the ISUPPORT tokens for each and the read-only user modeS(TLS connected).Web client. A vendored Kiwi IRC SPA is served at
/webclient/and a static landing page at/directly by the Cloudflare Worker (via its[assets]binding) or by AWS via an S3 + CloudFront + OACStaticSiteconstruct (opt-in via CDK context). Oneapps/web/dist/artifact ships on both platforms unchanged. Thedocs/wiki is rendered to standalone HTML at/docs/. Both platforms wire Cross-Site WebSocket Hijacking (CSWSH) defense into the WS upgrade path: the CF Worker uses same-origin auto-derive by default with an optionalWEB_ORIGINSallowlist for cross-origin deploys; the AWS$connectLambda uses an explicitWEB_ORIGINSallowlist only (the SPA and the API Gateway wss endpoint are on different origins, so auto-derive does not apply). AWS deploys are stack-output-driven (a two-phasescripts/deploy-web-aws.mjsbakes the realConnectUrlinto the SPA config, thens3 sync+ CloudFront invalidation).Deploy security. The AWS CI deploy is OIDC-only — the GitHub Actions workflow assumes an IAM role via GitHub OIDC web identity (no long-lived access keys, with an in-workflow assertion that the assumed role matches
AWS_DEPLOY_ROLE_ARNbefore anycdk deploy). API Gateway full-frame body logging (DataTraceEnabled) is off by default and hard-locked, so IRC frames (PASS,AUTHENTICATE <SASL-PLAIN>, channel keys,PRIVMSG/NOTICE) are never written to CloudWatch; the only way back on is an explicit two-flag sandbox escape hatch. The CF deploy workflow refuses placeholder hostnames (irc.example.com/irc.your-domain.invalid), and a CI env-var drift guard keeps theconsumed-env-varsblock ofapps/cf-worker/wrangler.tomlexactly in sync with the config loader.Abuse controls & hardening. Layered admission control on both platforms: a global
MAX_CLIENTScap (Cloudflare reserves aCounterDOslot per upgrade; AWS keeps an atomic connection counter — over-cap connects get429), per-IP simultaneous-connection caps and sliding-window connect-rate limits (aRateLimitDOat the CF edge; a source-IP GSI count + APIGW stage throttling + an opt-in WAFv2 per-IP rate rule on AWS), and a per-connection inbound frame-rate window at every adapter boundary. Credential handling is hardened end-to-end: scrypt-hashed oper credentials, constant-time server-password comparison, timing-equalized nick verification (no account enumeration), SASL/OPER/IDENTIFY brute-force lockouts, and credential redaction from parse-error logs. SASLEXTERNALis operator opt-in (EXTERNAL_ENABLED, default off), gated on a bound mTLS identity source and a TLS transport, and binds accounts to the client cert's DER SHA-256 fingerprint (fp:<hex>, with a canonical-DN fallback).Integrated IRC services. NickServ, ChanServ, HostServ, OperServ, and MemoServ run inside the daemon (no separate services process, no S2S link) backed by a
ServicesStoreport with persistent D1 (Cloudflare) and DynamoDB (AWS) backends (write-behind, surviving redeploys). TheServicesStoreis the single credential home: SASL PLAIN, SASL EXTERNAL (CertFP), NickServIDENTIFY, andPASS <nick>:<password>all verify through the same scrypt-hashedverifyNick/verifyCertFPsurface, anddraft/read-marker/draft/pre-awaypersist through theServicesStore.See
CHANGELOG.mdfor the per-release manifests.
Why
IRC servers have historically been long-running stateful processes. This project moves the protocol logic into a set of pure reducers so that the only platform-specific code is the side-effect layer (transports, registries, fanout). The consequences:
- Every command handler is a trivial unit test: arrange state, apply a message, assert the new state and emitted effects.
- The same core runs unchanged on Cloudflare and AWS.
- Authoritative state lives in exactly one place per piece of data, so races are structurally impossible rather than defended against.
Architecture
Hexagonal / ports-and-adapters. The core implements the IRC protocol; adapters handle I/O.
┌─────────────────────────────────────────────────────────────────┐
│ packages/irc-core (pure TS, no cloud deps) │
│ protocol/ parse · serialize · numerics · messages │
│ commands/ pure reducers: (state, msg) → { state, effects } │
│ state/ channel · connection · mode · registry shapes │
└─────────────────────────────────────────────────────────────────┘
▲ implements IrcRuntime (port)
│
┌─────────────────────────────────────────────────────────────────┐
│ packages/irc-server (orchestration, defines the port) │
│ IrcRuntime side-effect interface (transport + state IO) │
│ dispatch interprets Effect[] against a bound runtime │
└─────────────────────────────────────────────────────────────────┘
▲ ▲
┌────────────────────────────┐ ┌──────────────────────────────┐
│ packages/cf-adapter │ │ packages/aws-adapter │
│ ConnectionDO │ │ $connect/$disconnect/ │
│ ChannelDO │ │ $default Lambda handlers │
│ ChannelRegistryDO │ │ DynamoDB tables │
│ RegistryDO │ │ AwsRuntime │
│ CfRuntime │ │ │
└────────────────────────────┘ └──────────────────────────────┘packages/in-memory-runtime is the reference implementation of
IrcRuntime, used by integration tests and the local CLI server.
Two transports, one core. Both adapters accept WebSocket text frames
(the serverless default) and a raw irc+tls :6697 (TLS-over-TCP)
surface for stock IRC clients. The TCP+TLS edge is platform-specific —
Cloudflare Spectrum fronts a stateful Container origin
(apps/cf-tcp-container) with proxy_protocol = "v1" and
tls_mode = "full": the container itself terminates TLS (pinned to
1.2/1.3), requires and parses a PROXY v1 header on every flow to recover
the real client IP, and runs as a non-root user. AWS terminates TLS at a
Network Load Balancer — the streaming handler asserts the flow is
TLS-secured (surfacing user mode S), rejects missing or malformed flow
headers, and enforces the same per-IP connect-rate budget — and invokes a
Lambda streaming function. Both feed the same ConnectionActor
through a Transport seam (WsTextFrameTransport vs.
TcpByteStreamTransport); the parser/reducer/dispatch pipeline is
identical.
Design: pure reducers + location-of-authority
Each command handler is a pure function:
type Reducer<S> = (state: S, msg: IrcMessage, ctx: Ctx) => {
state: S;
effects: Effect[];
};Side effects are values (Effect[]), not performed in the handler. The
actor layer's dispatch(effects, runtime) interprets each effect against the
bound runtime.
Each reducer runs in whichever authority owns the state it mutates:
| Command family | Runs in authority | Primary state mutated |
|-----------------------------|-------------------------|------------------------------|
| Registration (NICK/USER) | Connection entity | Connection state |
| PING/PONG, QUIT, AWAY | Connection entity | Connection state |
| JOIN / PART / channel MODE | Channel entity | Roster, channel modes |
| PRIVMSG/NOTICE to channel | Channel entity | Fanout (reads roster) |
| Nick collision check | Registry entity | Nick → Connection map |
| NAMES / WHO / WHOIS | Reads snapshots | (no mutation) |
| Services (PRIVMSG NickServ/ChanServ/…) | Connection entity | ServicesStore (account/channel/vhost/memo state) |
Repository layout
ServerlessIRCd/
├── packages/
│ ├── irc-core/ pure protocol + reducers
│ ├── irc-server/ orchestration, IrcRuntime port, dispatch
│ ├── in-memory-runtime/ reference runtime (used by tests + local CLI)
│ ├── irc-test-support/ parametrized IRC scenario suite + harness seam
│ ├── cf-adapter/ Durable Objects + CfRuntime
│ └── aws-adapter/ Lambda + DynamoDB + AwsRuntime
├── apps/
│ ├── cf-worker/ worker entry, DO migrations, bindings, [assets] + CSWSH gate
│ ├── cf-tcp-container/ Spectrum + Container origin for irc+tls :6697
│ ├── aws-stack/ CDK stack (APIGW WS + NLB + Lambda streaming + DynamoDB)
│ ├── web/ vendored Kiwi IRC SPA (→ /webclient/) + static landing page (→ /)
│ └── local-cli/ runnable WS + TCP server using in-memory-runtime
├── tools/
│ ├── tcp-ws-forwarder/ local TCP↔ws/wss bridge for stock IRC clients
│ ├── load-test/ synthetic WebSocket IRC client pool (10k conns, p50/p95/p99, drop rate)
│ ├── ci-hardening/ coverage-gate + mutation-config + env-var-drift validators
│ ├── hash-oper-cred.ts scrypt oper-credential generator (OPER_SALT + OPER_HASH)
│ ├── seed-aws-accounts.ts scrypt-hash SASL PLAIN accounts into DynamoDB
│ ├── seed-cf-accounts.ts scrypt-hash SASL PLAIN accounts into Cloudflare D1
│ └── migrate-accounts-to-services.ts one-shot AccountStore→ServicesStore credential backfill
├── scripts/
│ └── deploy-web-aws.mjs stack-output-driven AWS web client deploy (describe → bake → s3 sync → invalidate)
├── pnpm-workspace.yaml turbo.json tsconfig.base.json
└── README.md CHANGELOG.mdToolchain
| Concern | Choice |
|------------------|---------------------------------------------------|
| Runtime | Node ≥ 24, ES2022+ |
| Package mgr | pnpm workspaces |
| Build cache | turbo |
| Language | TypeScript (strict, exactOptionalPropertyTypes) |
| Test runner | vitest + @vitest/coverage-v8 |
| Property tests | fast-check |
| Mutation tests | Stryker (≥80% score gate on irc-core) |
| AWS IaC | AWS CDK v2 |
| Lint / format | Biome |
Getting started
Requires Node ≥ 24 and pnpm 11. Enable corepack so the packageManager
pin is resolved automatically:
corepack enable # lets the pinned pnpm@11 run on any Node ≥ 24
git submodule update --init # docs + apps/web Kiwi upstream sources
pnpm install # install workspace deps
pnpm build # build all packages (turbo)
pnpm typecheck # tsc --noEmit across the workspace
pnpm test # run all tests once
pnpm test:watch # vitest watch mode
pnpm coverage # tests + v8 coverage report (enforces thresholds)
pnpm lint # biome check .
pnpm lint:fix # biome check --write .
pnpm format # biome format --write .
pnpm mutation # Stryker spot-check on irc-core (protocol + commands)
pnpm clean # remove dist/coverage/.turbo + node_modulesThe web client (apps/web) vendors Kiwi IRC as a git submodule. Building it
needs the upstream sources (git submodule update --init apps/web/upstream)
and yarn (Kiwi ships a yarn.lock; corepack enable provides it):
pnpm --filter web build # builds the Kiwi SPA into apps/web/dist/webclient/ (/webclient/)Coverage reports are written to packages/*/coverage/. CI (.github/workflows/ci.yml)
runs lint, typecheck, the coverage gate, the parametrized contract suite, and a
Stryker mutation spot-check on every push and pull request. Coverage thresholds
enforce 100% on irc-core / irc-server / in-memory-runtime / ci-hardening
and ≥90% on every other package.
Running the local server
apps/local-cli is a runnable IRC server built on the in-memory runtime.
It's the manual-test harness and the e2e fixture target — the same
irc-core reducers and ConnectionActor the cloud adapters use, just
wired to plain localhost listeners. By default it binds two listeners
that share one runtime, so a TCP client and a WS client can see each other
(cross-transport channel broadcast):
- a WebSocket listener (the serverless transport) on
--port, and - an RFC-style TCP listener on
--tcp-port(default<port> + 1) so real IRC clients (WeeChat, HexChat, irssi, …) can connect directly.
First build the workspace (the CLI runs from compiled dist/):
pnpm buildThen start the server (from the repo root):
pnpm --filter local-cli start
# or with explicit args:
pnpm --filter local-cli start --port 6667 --host 127.0.0.1On startup it logs a JSON line like:
{"ts":"...","level":"info","msg":"local-cli listening","wsUrl":"ws://127.0.0.1:6667/","port":6667,"tcpPort":6668,"hostname":"127.0.0.1"}CLI flags
| Flag | Default | Description |
|--------------------|---------------|--------------------------------------------------------|
| --port <n> | 6667 | WebSocket port to bind. Use 0 for an ephemeral. |
| --host <h> | 127.0.0.1 | Hostname / interface to bind. |
| --tcp-port <n> | <port> + 1 | TCP port for RFC-style IRC clients. |
| --no-tcp | | Disable the TCP listener (WebSocket only). |
| --motd-file <p> | built-in | Read MOTD lines from this file (one per line). |
| --server-name <h>| irc.localhost | Server hostname advertised in 001/005. Production MUST override. |
| --network-name <n>| LocalNet | Network name advertised in 005 NETWORK=…. |
| --server-password <p>| | Server-password gate. When set, every connection must supply PASS <p> (or SASL-identify) before 001. Treat as a secret. |
| -h, --help | | Show help and exit. |
Pass --host 0.0.0.0 to expose the server on all interfaces. SIGINT /
SIGTERM perform a graceful shutdown (closes active sockets, then exits).
Server password (SERVER_PASSWORD / --server-password)
All three adapters (Cloudflare Worker, AWS Lambda, local CLI) support an
optional server-password gate. When the knob is set, every connection
must supply the matching PASS <value> before 001 RPL_WELCOME is
emitted; when unset or empty the gate is disabled (the default).
| Adapter | Knob |
|----------------|-------------------------------------------------------------------------------|
| Cloudflare | wrangler secret put SERVER_PASSWORD (Workers secret; never a [vars] entry). |
| AWS | SERVER_PASSWORD Lambda env var (sourced from Secrets Manager / SSM). |
| local CLI | --server-password <p> flag, or serverPassword on StartServerOptions. |
On a mismatched/missing PASS the server emits 464 ERR_PASSWDMISMATCH
and disconnects with reason Bad Password. SASL short-circuit: a
connection that has authenticated an account via SASL
(AUTHENTICATE PLAIN / EXTERNAL) is exempt — the reducer's gate
treats state.account !== undefined as already authorised, so a
deployment with both SERVER_PASSWORD and configured SASL accounts
does not need to hand the shared password to identified users. The
server-wide gate is a shared deployment secret (never log it); per-user
credentials still go through the unified services credential store
(ServicesStore.verifyNick — D1 nickserv_accounts / DynamoDB
Services), which is unaffected.
PASS-based account login: in addition to SASL, a client may identify
to its NickServ account by sending PASS <nick>:<password> (the same
<nick>:<password> literal the SASL_ACCOUNTS seed tooling uses).
When a ServicesStore is configured, NICK alice + PASS alice:hunter2
USER …verifies the credentials viaservices.verifyNickand, on success, logs the connection in asalice— emitting900 RPL_LOGGEDIN903 RPL_SASLSUCCESS(the same "account is set" numerics SASL uses) before001 RPL_WELCOME, stamping user mode+r, and running the same read-marker / away / memo replay as a SASL login. The login is also honoured whenPASS <nick>:<password>arrives after registration has completed (e.g. a client that sentNICK+USERbeforePASS, or anyPASSarriving afterCAP ENDresolved): the same verify +applyAccountSuccesspipeline runs, emitting900/903and stamping+rin-band. The nick left of the:must match the connection's current nick; a barePASS <value>with no colon is always treated as a server-password candidate (silent no-op once registration has completed), never as account credentials. A wrong password or unknown nick is indistinguishable (no904/464from the auth path) and the connection simply proceeds un-identified when no server password is set. This composes with the gate above by precedence: an already-identified connection (SASL or PASS-auth) satisfies the gate; a<nick>:<password>that fails verify never matches a bare shared secret, so a server with both configured still rejects it with464.
Connecting
The WebSocket listener speaks WebSocket text frames (one IRC message per
frame) — point any WebSocket-capable IRC client or script at
ws://127.0.0.1:6667/. The TCP listener speaks plain RFC IRC over the
wire, so stock clients connect directly, e.g. in WeeChat:
/connect 127.0.0.1/6668No tcp-ws-forwarder is needed for local development — both transports
are built in. The MOTD is the built-in default unless --motd-file is
given.
Connecting a TCP IRC client to a deployed stack (TCP→WS forwarder)
The local CLI ships a built-in TCP listener, so no bridge is needed for local
development. Deployed stacks (Cloudflare Workers and AWS API Gateway) are
WebSocket-only, so a stock TCP IRC client (WeeChat, HexChat, irssi, …) cannot
connect to them directly. tools/tcp-ws-forwarder is a local bridge for that case:
it listens on a TCP port and, for each connection, opens one WebSocket to a
ws:// / wss:// target and forwards IRC lines both directions — reassembling the TCP
byte stream into one message per WS frame outbound, and normalizing inbound
frames back to canonical CRLF.
Build the workspace, then start the forwarder (from the repo root):
pnpm build
# Bridge localhost:16667 → the local WS server started above:
pnpm --filter tcp-ws-forwarder start \
--target ws://127.0.0.1:6667/ --listen-port 16667
# Or bridge straight to a deployed stack:
pnpm --filter tcp-ws-forwarder start \
--target wss://irc.example.com/ --listen-port 16667On startup it logs a JSON line like:
{"ts":"...","level":"info","msg":"tcp-ws-forwarder listening","listen":"127.0.0.1:16667","target":"ws://127.0.0.1:6667/"}CLI flags
| Flag | Default | Description |
|---------------------|-------------|--------------------------------------------------|
| --target <url> | (required) | Upstream ws:// or wss:// URL to bridge to. |
| --listen-port <n> | 6667 | TCP port to listen on. Use 0 for an ephemeral. |
| --listen-host <h> | 127.0.0.1 | Interface to bind. |
| -h, --help | | Show help and exit. |
Connecting
Point any TCP IRC client at 127.0.0.1:<listen-port> and it will appear as a
direct connection to the WebSocket endpoint. SIGINT / SIGTERM perform a
graceful shutdown (closes the listener and every live bridge). The forwarder
is transport-agnostic — it ships no @serverless-ircd/* dependency and works
against any line-oriented WebSocket endpoint.
Load testing (tools/load-test)
A synthetic WebSocket IRC client pool. It opens N
connections to a ws:// / wss:// target, registers each (NICK/USER),
joins a channel, and optionally chats. Per-stage p50 / p95 / p99
latency (connect / register / join / message) and the drop rate
are captured, then printed as a markdown report to stdout (and written
to --report <path> when given).
Build the workspace, then start a run (from the repo root):
pnpm build
pnpm --filter load-test start -- \
--target wss://irc.staging.example.com/ \
--connections 10000 --concurrency 200 --ramp-ms 60000 \
--channel '#loadtest' --messages 3 --platform cf-staging \
--report reports/cf-staging-2026-08-03.mdThe harness drives the WebSocket transport only (the serverless default);
a TCP IRC client is not what a serverless load test exercises. Each
connection is independent — the package ships no @serverless-ircd/*
runtime dependency (only the IRC line framing shared with the
forwarder), so it can be pointed at any WebSocket IRC endpoint.
CLI flags
| Flag | Default | Description |
|-------------------------|-------------|----------------------------------------------------------|
| --target <url> | (required) | Upstream ws:// or wss:// URL to load test. |
| --connections <n> | 1000 | Total connections to open. |
| --concurrency <n> | 50 | Concurrent in-flight connection attempts during ramp-up. |
| --ramp-ms <ms> | 30000 | Ramp-up duration over which connections are opened. |
| --channel <c> | #loadtest | Channel every client JOINs. |
| --messages <n> | 0 | PRIVMSGs each client sends after JOIN (0 = connect only). |
| --echo-message | | Negotiate echo-message so sent PRIVMSGs round-trip. |
| --platform <name> | unknown | Label baked into the report header. |
| --report <path> | | Write the markdown report to this path. |
| -h, --help | | Show help and exit. |
SIGINT / SIGTERM cancel an in-flight run gracefully (drains live
sockets, prints the partial report). The package ships its own vitest
suite at ≥90% coverage (config parsing, framing, harness, metrics,
report).
Web client (apps/web)
A vendored Kiwi IRC SPA served at /webclient/ and a static project
landing page served at /, both baked out of apps/web/dist/. The same
artifact is served by both platforms:
| Platform | HTTP/SPA origin | WebSocket origin | Same-origin? |
|-------------|----------------------------------------------------|---------------------------------------------------|--------------|
| Cloudflare | the Worker (*.workers.dev / custom domain) | the same Worker | Yes — one domain serves both |
| AWS | CloudFront (*.cloudfront.net / custom domain) | API Gateway (*.execute-api.*.amazonaws.com) | No — two different endpoints |
On Cloudflare the Worker's [assets] binding serves the SPA and the WS
upgrade on one origin — the browser opens a native wss:// directly to
the Worker, no proxy or gateway. On AWS the SPA is fronted by an
S3 + CloudFront + OAC StaticSite construct
(apps/aws-stack/src/static-site.ts, opt-in via CDK context) and a
two-phase deploy bakes the real ConnectUrl into the SPA config (see
"Deploying on AWS" below). See docs/WebClientGuide.md for the CF
end-to-end guide and docs/AWS-Deployment.md §16 for the AWS path.
Build the SPA + landing page (needs the submodule + yarn, provided by
corepack enable):
git submodule update --init apps/web/upstream # one-time per clone
git submodule update --init docs # one-time per clone (docs site)
pnpm --filter web build # or build:prod (CF) / build:prod-aws (AWS)
# → apps/web/dist/index.html (landing page, served at /)
# → apps/web/dist/webclient/index.html (Kiwi SPA, served at /webclient/)
# → apps/web/dist/webclient/static/config.json (baked, env-specific)
# → apps/web/dist/docs/<slug>.html (rendered docs/, served at /docs/)The build also renders the docs/ submodule (the Gitea wiki, 23 GFM
markdown files) to standalone HTML under apps/web/dist/docs/ so the
existing [assets] binding serves them at /docs/<slug>.html (and
/docs/ for the index — Home.md becomes index.html). Requires the
docs/ submodule checkout; the build fails loudly with the recovery
command if it is missing or empty (mirroring the Kiwi upstream/ guard).
Home.md is the docs landing page; ADR-Index.md lists every ADR.
Deploying on Cloudflare
Run everything locally (Worker serves SPA + landing page + WS on one origin):
pnpm build # workspace packages
pnpm --filter @serverless-ircd/cf-worker dev # http://localhost:8787
# / → landing page
# /webclient/ → Kiwi SPA (opens ws://localhost:8787/)
# /docs/ → rendered docs/ (Home → index, per-page HTML)
# /health → plaintext livenessDeploy the Worker (Worker + assets in one command):
pnpm deploy:cf # wrangler deployConfiguration vars & secrets (Cloudflare)
Every env var the Worker consumes is enumerated — and
drift-guarded in CI — in the consumed-env-vars block of
apps/cf-worker/wrangler.toml, classified as a plaintext [vars]
knob or a [secret]. Credential material must be set as Workers
secrets, never [vars]:
| Secret | Purpose |
|--------|---------|
| SERVER_PASSWORD | server-wide PASS gate (see above) |
| OPER_PASSWORD | legacy plaintext oper credential |
| OPER_SALT + OPER_HASH | hashed oper credential — generate with node --import tsx tools/hash-oper-cred.ts --user admin --stdin |
| SASL_ACCOUNTS | newline-delimited user:password SASL seed list (see tools/seed-cf-accounts.ts) |
Set each with wrangler secret put <NAME>. Everything else
(SERVER_NAME, MAX_CLIENTS, MAX_CONNECTIONS_PER_IP,
PER_IP_CONNECTION_RATE_*, MAX_FRAMES_PER_WINDOW,
FRAME_WINDOW_SECONDS, EXTERNAL_ENABLED, …) is a non-sensitive
[vars] knob.
Deploying on AWS
The web client is opt-in: provision the StaticSite construct by
passing webSite* CDK context on the stack deploy, and (once the SPA
ships) set webOrigins for the CSWSH defence:
pnpm deploy:aws -- \
-c webSiteCustomDomain=app.example.com \
-c webSiteCertificateArn=arn:aws:acm:us-east-1:... \
-c webSiteHostedZoneName=example.com. -c webSiteHostedZoneId=... \
-c webOrigins=https://app.example.comThen bake + ship the SPA (stack-output-driven — reads ConnectUrl,
WebsiteBucketName, WebsiteDistributionId from the stack outputs,
bakes the real wss URL into config.json, s3 syncs the build, and
invalidates the CloudFront edge cache):
node scripts/deploy-web-aws.mjsThe ACM certificate must be in us-east-1 (CloudFront requirement).
See docs/AWS-Deployment.md §16 for the full two-phase flow, the
server / direct_path config split (irc-framework prepends wss://
itself, so the host is baked scheme-less), and the custom-domain setup.
WebSocket Origin policy (CSWSH defense)
WebSocket upgrades do not follow the same-origin policy, so a
malicious page can open a WebSocket to the IRC server from a victim's
browser and drive the session with their credentials (Cross-Site
WebSocket Hijacking, CSWSH). Both adapters enforce an Origin policy
on the upgrade — but the modes differ, because the CF Worker serves
the SPA and the wss endpoint on the same origin while AWS serves them
on different origins:
Cloudflare — see apps/cf-worker/src/origin-allowlist.ts and the
SPA guide §5. Two modes, evaluated in order:
- Explicit allowlist — set
WEB_ORIGINS(comma-separated) for cross-origin deploys (SPA on a different domain than the Worker, e.g. Cloudflare Pages). Only listed origins proceed. - Same-origin auto-derive (the default) — when
WEB_ORIGINSis unset/empty, the Worker compares the browser'sOriginagainst the request's own origin. Match → proceed; mismatch →403 Forbidden.
WEB_ORIGINS is optional on CF — same-origin auto-derive needs
zero per-env config and works for *.workers.dev, custom domains, and
preview URLs alike.
AWS — see packages/aws-adapter/src/origin-allowlist.ts and
docs/AWS-Deployment.md §8.2. Explicit allowlist only (no
auto-derive): the SPA is on a CloudFront origin and the wss endpoint is
on an API Gateway origin, so the request's own host is never the SPA's
origin. The defence is opt-in — unset WEB_ORIGINS skips the check
entirely (existing bare-IRC deployments without a web frontend are
unchanged on upgrade); set it to the SPA's origin(s) once the web client
ships:
pnpm deploy:aws -- -c webOrigins=https://app.example.com
# or as a stack prop: webOrigins: 'https://app.example.com,https://staging.app.example.com'Non-browser clients (curl, WeeChat, the tcp-ws-forwarder, scripted
harnesses) never send Origin and pass through unchanged on both
platforms.
WebSocket transport (IRCv3 subprotocols)
The WebSocket entry points (the local CLI, the Cloudflare Worker, and the
AWS API Gateway $connect route) implement the
IRCv3 WebSocket support subprotocol
negotiation. A client advertises support by offering one of two registered
subprotocols in its opening handshake's Sec-WebSocket-Protocol header:
| Subprotocol | Frames | Notes |
|--------------------|----------|--------------------------------------------------|
| text.ircv3.net | UTF-8 text | Lone surrogates → U+FFFD; binary frames rejected (close 1003). |
| binary.ircv3.net | binary | UTF-8 encoded bytes; same framing as text. |
The server echoes back the first supported entry in client-preference order
(in the 101 response's Sec-WebSocket-Protocol header). Once negotiated,
the connection uses spec framing:
- One IRC message per WebSocket message — a frame is never split on an
embedded line break. A single optional trailing
CR-LF(or bareLF) is stripped; the remainder is exactly one IRC line. - No trailing
CR-LFon the wire, outbound or inbound. - 510-byte message budget — the 512-byte IRC line limit minus the omitted
CR-LF. A message exceeding 510 bytes is a protocol violation and the connection is closed with RFC 6455 code1009(Message Too Big).
Legacy fallback. A client that offers no recognized subprotocol (or none
at all) is still accepted, in legacy mode: frames are split on \r\n so
older clients that concatenate several messages into one frame keep working,
and the parser's existing 512-byte line cap applies. Nothing breaks until a
client opts into a subprotocol. The in-tree
tcp-ws-forwarder
offers binary.ircv3.net upstream, so a stock TCP IRC client bridged to a
deployed stack exercises the spec path end-to-end.
Abuse controls & credential hardening
Every transport edge enforces layered admission and rate limits, and
every credential path verifies through hardened, timing-equalized
comparisons. The knobs below are [vars] on the Cloudflare Worker and
mirrored as Lambda env vars on AWS.
Connection admission & rate limiting
| Layer | Knobs | Enforcement |
|------------------------|---------------------------------------------------|-------------|
| Global cap | MAX_CLIENTS | CF reserves a CounterDO slot before each upgrade and answers 429 (ERROR :Closing link: server full) at the cap (slots released on close, TTL-reaped if a DO dies); AWS keeps an atomic connection counter in DynamoDB. |
| Per-IP simultaneous | MAX_CONNECTIONS_PER_IP | Admission gates on both platforms reject over-budget source IPs with 429. |
| Per-IP connect rate | PER_IP_CONNECTION_RATE_MAX / PER_IP_CONNECTION_RATE_WINDOW_MS | CF: a RateLimitDO sliding window keyed on CF-Connecting-IP, checked at the worker edge (rejections never consume budget, so a blocked IP recovers after window decay). AWS: $connect counts in-window establishments via a sourceIp+connectedSince GSI, the NLB path applies the same budget to new flows, APIGW stage throttling backstops globally, and an opt-in WAFv2 per-IP rate rule (-c wafConnectRateLimit=<n>) sits at the edge. |
| Per-connection frames | MAX_FRAMES_PER_WINDOW / FRAME_WINDOW_SECONDS | Inbound frame budget enforced at the adapter boundary before the actor / storage write. |
| Line-buffer memory | (fixed) | TCP input buffers capped at 8 KiB at every transport edge (local CLI, container origin, NLB handler, forwarder). |
Protocol budgets
All length limits are enforced in UTF-8 bytes, not UTF-16 code
units: the 510-byte WS frame / 512-byte TCP line budgets, TOPICLEN
enforced on character boundaries, draft/multiline batch byte budgets
enforced incrementally as lines arrive, at most 15 tags and an
8192-byte tag section per message, long NAMES rosters split across
multiple 353 replies within the 510-byte budget, CHATHISTORY
limits capped at a configurable ceiling (default 100), and
MAX_TARGETS_PER_COMMAND capping comma-split targets. The parser
rejects bare-CR smuggling and NUL/control characters in channel
names, builds tag maps with a null prototype (blocking prototype
pollution), and OPER requires a registered connection (451
ERR_NOTREGISTERED).
Credentials & anti-abuse
Oper credentials verify against scrypt hashes (OPER_USER +
OPER_SALT/OPER_HASH, generated with tools/hash-oper-cred.ts);
the server password compares in constant time; verifyNick runs a
dummy scrypt verify on unknown nicks so response timings cannot
enumerate accounts. Brute force is throttled at every auth surface: a
per-connection SASL failure lockout, a per-IP OPER failure lockout,
and a per-account NickServ IDENTIFY freeze. HostServ auto-approve
honours a vhost denylist/allowlist, and ChanServ DEOP/KICK protect
founders and enforce caller rank. Parse-error logs carry only
token/length/reason — PASS / AUTHENTICATE payloads are redacted —
and the Worker's log sampling defaults to 10%.
SASL EXTERNAL (mTLS)
EXTERNAL is advertised and accepted only when all of the
following hold: an mTLS identity source is bound for the connection
(CF API Shield / AWS API Gateway client certs), the operator opt-in
EXTERNAL_ENABLED is set (default off), and the transport is
TLS-secured. Otherwise the sasl cap and 908 ERR_SASLMECHS list
PLAIN only — refusals do not count toward the SASL lockout.
Accounts bind to the client cert's DER SHA-256 fingerprint
(fp:<hex> entries — what Cloudflare surfaces from
request.cf.tlsClientAuth; the preferred binding) or a canonical
subject DN (the only identifier API Gateway exposes; DNs are
canonicalised — types lower-cased, whitespace collapsed, RDN/AVA order
sorted — so re-issued or differently-ordered certificates still match).
Transports without any client-cert surface — the Spectrum container
origin, and the NLB stream handler unless an mTLS provider is injected
— pin the mechanism off and reject AUTHENTICATE EXTERNAL with a
transport-specific 904.
Testing strategy
This project follows strict TDD (Red → Green → Refactor) — every reducer is
landed test-first. See the project-level CLAUDE.md / AGENTS.md for the
full rules.
| Layer | Tooling | What it asserts |
|--------------------|---------------------------------------------|---------------------------------------|
| Parser/serializer | vitest + fast-check | Grammar correctness, round-trip |
| Reducers (core) | vitest pure unit tests | (state,msg) → {state,effects} exact |
| Mutation (core) | Stryker spot-check (≥80% score gate) | Reducer test quality, surviving mutants |
| Runtime contract | vitest parametrized via irc-test-support | Same scenarios pass in-memory+CF+AWS |
| CF adapter | vitest-pool-workers (real workerd) | DO behavior, alarms, stub fanout |
| AWS adapter | vitest + testcontainers (DynamoDB Local) | DynamoDB schema, transactions, fanout |
| E2E | scripts/smoke.mjs against deployed stack | CONNECT→REGISTER→JOIN→PRIVMSG→QUIT |
Determinism: reducers never touch real timers or randomness. A Clock
port and an IdFactory port are injected so tests are fully deterministic.
Roadmap
A v1 ships when the parametrized contract suite passes against every
runtime, the client compatibility matrix is signed off, and every
package clears its coverage gate. Per-release manifests live in
CHANGELOG.md.
Active follow-ups:
- Client compatibility sweep — formal sign-off on ≥3 reference
clients (WeeChat / HexChat / IRCCloud / TheLounge) against both
deployed adapters, on both the WebSocket and
irc+tls :6697surfaces. - Web client e2e — Playwright headless-browser e2e exercising the vendored Kiwi IRC SPA against a deployed stack.
- Coverage hardening —
irc-core,irc-server,in-memory-runtime,local-cli,cf-worker,aws-stack,load-test,cf-tcp-container,tcp-ws-forwarder,irc-test-support, andci-hardeningsit at 100% line coverage;cf-adapter(~99.7%) andaws-adapter(~98%) clear the 90% gate with follow-ups driving each to full coverage. - Protocol follow-ups —
WHOX(WHO <mask> %<fields>/354 RPL_WHOSPCRPL),LISTsearch masks +ELIST=MNTUfilters,cap-notifycapability-change push, services data lifecycle (last-used tracking + expiry sweep), and a persisted oper audit trail. - Persistent ChanServ ban list — ban masks currently live on
ChannelState.banMasksand do not survive an empty-recreate of a channel. ExtendingServicesStorewith a persistent ban list is the documented next step.
Protocol scope
Core (RFC 1459/2812 subset): registration (NICK/USER/CAP/PASS),
PING/PONG, QUIT, JOIN, PART, PRIVMSG, NOTICE, MODE (user +
channel), TOPIC, KICK, INVITE, NAMES, LIST, WHO, WHOIS,
WHOWAS, MOTD, AWAY, OPER (credential auth → o user mode), the
query verbs VERSION, TIME, ADMIN, INFO, USERHOST, ISON, the
modern-online-presence verb MONITOR (+/-/C/L/S with the
730/731/732/734 numerics and async online/offline push), the
network-statistics verbs LUSERS and STATS (u uptime + l link-info
letters; unknown letters get the charybdis-style empty body), the
routing-diagnostic verb TRACE (oper-gated per-connection detail), the
realname-change verb SETNAME, and the oper verbs KILL (force-
disconnect), REHASH (config reload via IrcRuntime.reloadConfig()),
WALLOPS (global broadcast to every +w user).
(Out of scope: the S2S verbs CONNECT/SQUIT/LINKS and the obsolete
RFC 2812 verbs SERVICE/SUMMON/USERS are formally dropped —
they return 421 ERR_UNKNOWNCOMMAND, and their reserved numerics have
been removed. S2S linking is a stated non-goal.)
Channel modes: o v b i k l t n m s p, plus the
services-derived modes r (registered), R (block unidentified
join/message), M (moderated-identified) — settable only via ChanServ,
not via MODE.
User modes: i, o (local only), w, s, and the read-only S
(TLS connected — set by the transport at registration, surfaced in WHOIS
via 276 RPL_WHOISSECURE, not settable via MODE) and the read-only
r (registered — set by NickServ IDENTIFY / SASL login, surfaced as
+r, not settable via MODE).
Integrated IRC services: the service nicks NickServ, ChanServ,
HostServ, OperServ, and MemoServ are reserved and routed to
dedicated pure reducers when a ServicesStore is bound — there is no
separate services process and no S2S link. Each service also accepts a
shortcut verb (/NICKSERV, /NS, /CS, /HS, /MS, /OS, …)
that the daemon rewrites to the equivalent PRIVMSG <Service> :<args>
for clients with a dedicated slash-command UX. Reached via PRIVMSG
<Service> :<subcommand>:
- NickServ —
REGISTER/IDENTIFY(alias:ID) /DROP/INFO(owner + oper only for theEmail:line) /SET ENFORCE+ nick enforcement on theNICKpath /SET PASSWORD(self-service password change; requires the current password re-supplied).INFO <nick>with no target resolves to the caller's own account. - ChanServ —
REGISTER/DROP/SET(FOUNDER/MLOCK/RESTRICTED/KEEPTOPIC) /INFO/ACCESS(SOP/AOP/HOP/VOP, eachADD/DEL/LIST) /LEVELS, plus the prefix / roster mutation verbsOP,DEOP,VOICE,DEVOICE,KICK, andBAN/UNBAN(each emits the corresponding:ChanServ MODE/KICKbroadcast andApplyChannelDeltaagainst the channel authority). Auto-op / auto-voice onJOINfollows the access list; the first joiner of a registered channel no longer gets auto-opped merely for being first. - HostServ —
ON/OFF/REQUEST+ operSET/APPROVE/ACTIVATE/REJECT/LIST, withCHGHOSTfanout. An assigned vhost is auto-applied on identify (SASL, NickServIDENTIFY, or PASS-auth):state.hostis set,state.vhostActiveis stamped, and any currently-joinedchghost-capable peers see the broadcast. Oper-only subcommands are hidden from non-opers in the help NOTICE. - OperServ —
AKILL/JUPE/UNJUPE/RAW(oper-only). - MemoServ —
SEND/LIST/READ/DELwith queue delivery at identify.
The ServicesStore is the single credential home: SASL PLAIN, SASL
EXTERNAL (CertFP), NickServ IDENTIFY, and PASS <nick>:<password> all
verify through the same scrypt-hashed verifyNick / verifyCertFP
surface, so a registered nick is also a SASL login and vice versa.
The credential env vars that seed and unlock these paths
(SASL_ACCOUNTS, OPER_*, SERVER_PASSWORD) are Workers secrets on
Cloudflare — the full consumed-var list lives in the drift-guarded
consumed-env-vars block of apps/cf-worker/wrangler.toml (see
Configuration vars & secrets).
Backends: D1 on Cloudflare, DynamoDB on AWS, in-memory for the
local CLI / tests (all write-behind; registrations survive redeploys).
When no store is bound, services commands reply 501 and the rest of
the daemon is unaffected. See docs/Services.md for the full
reference.
IRCv3 extensions (negotiated via CAP): message-tags (incl. the
TAGMSG command), server-time, account-tag, account-notify
(pushes ACCOUNT on SASL login/logout), echo-message, batch,
sasl (PLAIN always; EXTERNAL only under the triple gate of
operator opt-in (EXTERNAL_ENABLED, default off) + a bound edge-mTLS
identity source + a TLS connection — accounts bind to the client
cert's DER SHA-256 fingerprint or a canonical subject DN, refusals
answer 908 ERR_SASLMECHS listing PLAIN only, and the
cf-tcp-container Spectrum origin rejects AUTHENTICATE EXTERNAL
with a transport-specific 904; see
Abuse controls & credential hardening),
multi-prefix, away-notify, chghost, invite-notify,
extended-join, msgid (@+msgid=<id> on every PRIVMSG/NOTICE/TAGMSG,
shared between live and draft/chathistory replay), standard-replies
(FAIL/WARN/NOTE replacements for a curated numeric subset),
MONITOR=<n> (config-driven ceiling, default 30), labeled-response
(+label=<id> → BATCH +id labeled-response … BATCH -id wrapping),
sts (Strict Transport Security — conditionally advertised with
duration/port/optional preload from ServerConfig when configured,
so compliant clients upgrade from plaintext to TLS and pin the secure
listener), draft/chathistory, safelist, draft/typing (typing-indicator
broadcast), draft/multiline=<n> (multi-line BATCH, default 4096-byte
budget), draft/read-marker (per-account persisted last-read, with the
timestamp-based MARKREAD <target> [timestamp] verb plus the
+draft/read-marker tag fanout), and
draft/pre-away (per-account persisted away reason, replayed at
identify). Case-insensitive nick/channel comparison uses RFC 1459
case-mapping (advertised via 005 CASEMAPPING=rfc1459). The new
MONITOR / MULTILINE / TYPING / STATUSMSG / EXTBAN /
ACCOUNTEXTBAN ISUPPORT tokens are advertised from ServerConfig.
Transport: deployed stacks (Cloudflare Workers, AWS API Gateway) speak
WebSocket text frames by default (one IRC message per frame, with tolerance
for \r\n-joined frames), and optionally expose a raw irc+tls :6697
(TLS-over-TCP) surface — Cloudflare via Spectrum + Container, AWS via a
Network Load Balancer + Lambda streaming function. The local CLI
additionally binds a plain RFC TCP listener for direct IRC-client access,
and the tcp-ws-forwarder
bridges a stock TCP client to a deployed WebSocket endpoint.
Further reading
CHANGELOG.md— per-release manifests in Keep a Changelog format.docs/Services.md— operator and contributor reference for the integrated IRC services: architecture (integrated vs. pseudo-client), the per-service command tables (including ChanServOP/DEOP/VOICE/DEVOICE/KICK/BAN/UNBAN, HostServ oper approval flow, the+r/+R/+Mmodes, nick enforcement, the unified scrypt account store, service shortcut verbs), adapter backends, and an end-to-end registration walkthrough.docs/WebClientGuide.md— end-to-end contributor/operator doc for the Cloudflare web client: build pipeline, per-env config matrix, CSWSH rationale and the optionalWEB_ORIGINSvar, local dev, optional Cloudflare Pages alternative, and troubleshooting.docs/AWS-Deployment.md— end-to-end AWS deploy guide: first-time setup, CDK reference, DynamoDB capacity planning, region strategy, cost notes, OIDC-only CI (§17), APIGWDataTraceEnabledhard- lock (§7.6), and the S3 + CloudFront + OAC web client (§16, including the two-phase stack-output-driven deploy and thewebOriginsCSWSH knob).docs/Cloudflare-TCP-Deployment.mdanddocs/AWS-TCP-Deployment.md— end-to-end guides for the:6697TCP+TLS variants (Spectrum/Container on CF, NLB + Lambda streaming on AWS), including mTLS trust-store setup.README.mdin eachpackages/*andapps/*— per-package notes (e.g.packages/aws-adapter/README.mdfor the DynamoDB-Local test setup,apps/aws-stack/README.mdfor CDK commands and localstack validation).
