serverless-ircd 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
package/README.md
CHANGED
|
@@ -32,20 +32,60 @@ One TypeScript codebase. Two serverless substrates.
|
|
|
32
32
|
>
|
|
33
33
|
> **Web client.** A vendored **Kiwi IRC** SPA is served at
|
|
34
34
|
> `/webclient/` and a static landing page at `/` directly by the
|
|
35
|
-
> Cloudflare Worker
|
|
36
|
-
>
|
|
37
|
-
>
|
|
38
|
-
>
|
|
39
|
-
> `/docs/`.
|
|
35
|
+
> Cloudflare Worker (via its `[assets]` binding) **or** by AWS via an
|
|
36
|
+
> **S3 + CloudFront + OAC** `StaticSite` construct (opt-in via CDK
|
|
37
|
+
> context). One `apps/web/dist/` artifact ships on both platforms
|
|
38
|
+
> unchanged. The `docs/` wiki is rendered to standalone HTML at
|
|
39
|
+
> `/docs/`. Both platforms wire **Cross-Site WebSocket Hijacking
|
|
40
|
+
> (CSWSH) defense** into the WS upgrade path: the CF Worker uses
|
|
41
|
+
> same-origin auto-derive by default with an optional `WEB_ORIGINS`
|
|
42
|
+
> allowlist for cross-origin deploys; the AWS `$connect` Lambda uses
|
|
43
|
+
> an explicit `WEB_ORIGINS` allowlist only (the SPA and the API
|
|
44
|
+
> Gateway wss endpoint are on different origins, so auto-derive does
|
|
45
|
+
> not apply). AWS deploys are **stack-output-driven** (a two-phase
|
|
46
|
+
> `scripts/deploy-web-aws.mjs` bakes the real `ConnectUrl` into the
|
|
47
|
+
> SPA config, then `s3 sync` + CloudFront invalidation).
|
|
48
|
+
>
|
|
49
|
+
> **Deploy security.** The AWS CI deploy is **OIDC-only** — the
|
|
50
|
+
> GitHub Actions workflow assumes an IAM role via GitHub OIDC web
|
|
51
|
+
> identity (no long-lived access keys, with an in-workflow assertion
|
|
52
|
+
> that the assumed role matches `AWS_DEPLOY_ROLE_ARN` before any
|
|
53
|
+
> `cdk deploy`). API Gateway full-frame body logging
|
|
54
|
+
> (`DataTraceEnabled`) is **off by default and hard-locked**, so IRC
|
|
55
|
+
> frames (`PASS`, `AUTHENTICATE <SASL-PLAIN>`, channel keys,
|
|
56
|
+
> `PRIVMSG`/`NOTICE`) are never written to CloudWatch; the only way
|
|
57
|
+
> back on is an explicit two-flag sandbox escape hatch. The CF deploy
|
|
58
|
+
> workflow refuses placeholder hostnames (`irc.example.com` /
|
|
59
|
+
> `irc.your-domain.invalid`), and a CI **env-var drift guard** keeps
|
|
60
|
+
> the `consumed-env-vars` block of `apps/cf-worker/wrangler.toml`
|
|
61
|
+
> exactly in sync with the config loader.
|
|
62
|
+
>
|
|
63
|
+
> **Abuse controls & hardening.** Layered admission control on both
|
|
64
|
+
> platforms: a global `MAX_CLIENTS` cap (Cloudflare reserves a
|
|
65
|
+
> `CounterDO` slot per upgrade; AWS keeps an atomic connection counter
|
|
66
|
+
> — over-cap connects get `429`), per-IP simultaneous-connection caps
|
|
67
|
+
> and sliding-window connect-rate limits (a `RateLimitDO` at the CF
|
|
68
|
+
> edge; a source-IP GSI count + APIGW stage throttling + an opt-in
|
|
69
|
+
> WAFv2 per-IP rate rule on AWS), and a per-connection inbound
|
|
70
|
+
> frame-rate window at every adapter boundary. Credential handling is
|
|
71
|
+
> hardened end-to-end: scrypt-hashed oper credentials, constant-time
|
|
72
|
+
> server-password comparison, timing-equalized nick verification (no
|
|
73
|
+
> account enumeration), SASL/OPER/IDENTIFY brute-force lockouts, and
|
|
74
|
+
> credential redaction from parse-error logs. SASL `EXTERNAL` is
|
|
75
|
+
> **operator opt-in** (`EXTERNAL_ENABLED`, default off), gated on a
|
|
76
|
+
> bound mTLS identity source **and** a TLS transport, and binds
|
|
77
|
+
> accounts to the client cert's **DER SHA-256 fingerprint**
|
|
78
|
+
> (`fp:<hex>`, with a canonical-DN fallback).
|
|
40
79
|
>
|
|
41
80
|
> **Integrated IRC services.** NickServ, ChanServ, HostServ,
|
|
42
81
|
> OperServ, and MemoServ run inside the daemon (no separate services
|
|
43
82
|
> process, no S2S link) backed by a `ServicesStore` port with
|
|
44
83
|
> **persistent D1 (Cloudflare) and DynamoDB (AWS) backends**
|
|
45
|
-
> (write-behind, surviving redeploys). The
|
|
46
|
-
>
|
|
47
|
-
>
|
|
48
|
-
>
|
|
84
|
+
> (write-behind, surviving redeploys). The `ServicesStore` is the
|
|
85
|
+
> **single credential home**: SASL PLAIN, SASL EXTERNAL (CertFP),
|
|
86
|
+
> NickServ `IDENTIFY`, and `PASS <nick>:<password>` all verify through
|
|
87
|
+
> the same scrypt-hashed `verifyNick` / `verifyCertFP` surface, and
|
|
88
|
+
> `draft/read-marker` / `draft/pre-away` persist through the
|
|
49
89
|
> `ServicesStore`.
|
|
50
90
|
>
|
|
51
91
|
> See `CHANGELOG.md` for the per-release manifests.
|
|
@@ -102,12 +142,18 @@ Hexagonal / ports-and-adapters. The core implements the IRC protocol; adapters h
|
|
|
102
142
|
**Two transports, one core.** Both adapters accept WebSocket text frames
|
|
103
143
|
(the serverless default) **and** a raw `irc+tls :6697` (TLS-over-TCP)
|
|
104
144
|
surface for stock IRC clients. The TCP+TLS edge is platform-specific —
|
|
105
|
-
Cloudflare **Spectrum**
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
145
|
+
Cloudflare **Spectrum** fronts a stateful **Container** origin
|
|
146
|
+
(`apps/cf-tcp-container`) with `proxy_protocol = "v1"` and
|
|
147
|
+
`tls_mode = "full"`: the container itself terminates TLS (pinned to
|
|
148
|
+
1.2/1.3), requires and parses a PROXY v1 header on every flow to recover
|
|
149
|
+
the real client IP, and runs as a non-root user. AWS terminates TLS at a
|
|
150
|
+
**Network Load Balancer** — the streaming handler asserts the flow is
|
|
151
|
+
TLS-secured (surfacing user mode `S`), rejects missing or malformed flow
|
|
152
|
+
headers, and enforces the same per-IP connect-rate budget — and invokes a
|
|
153
|
+
**Lambda streaming** function. Both feed the same `ConnectionActor`
|
|
154
|
+
through a `Transport` seam (`WsTextFrameTransport` vs.
|
|
155
|
+
`TcpByteStreamTransport`); the parser/reducer/dispatch pipeline is
|
|
156
|
+
identical.
|
|
111
157
|
|
|
112
158
|
### Design: pure reducers + location-of-authority
|
|
113
159
|
|
|
@@ -158,9 +204,13 @@ ServerlessIRCd/
|
|
|
158
204
|
├── tools/
|
|
159
205
|
│ ├── tcp-ws-forwarder/ local TCP↔ws/wss bridge for stock IRC clients
|
|
160
206
|
│ ├── load-test/ synthetic WebSocket IRC client pool (10k conns, p50/p95/p99, drop rate)
|
|
161
|
-
│ ├── ci-hardening/ coverage-gate + mutation-config validators
|
|
207
|
+
│ ├── ci-hardening/ coverage-gate + mutation-config + env-var-drift validators
|
|
208
|
+
│ ├── hash-oper-cred.ts scrypt oper-credential generator (OPER_SALT + OPER_HASH)
|
|
162
209
|
│ ├── seed-aws-accounts.ts scrypt-hash SASL PLAIN accounts into DynamoDB
|
|
163
|
-
│
|
|
210
|
+
│ ├── seed-cf-accounts.ts scrypt-hash SASL PLAIN accounts into Cloudflare D1
|
|
211
|
+
│ └── migrate-accounts-to-services.ts one-shot AccountStore→ServicesStore credential backfill
|
|
212
|
+
├── scripts/
|
|
213
|
+
│ └── deploy-web-aws.mjs stack-output-driven AWS web client deploy (describe → bake → s3 sync → invalidate)
|
|
164
214
|
├── pnpm-workspace.yaml turbo.json tsconfig.base.json
|
|
165
215
|
└── README.md CHANGELOG.md
|
|
166
216
|
```
|
|
@@ -220,8 +270,8 @@ pnpm --filter web build # builds the Kiwi SPA into apps/web/dist/webclient/ (/
|
|
|
220
270
|
Coverage reports are written to `packages/*/coverage/`. CI (`.github/workflows/ci.yml`)
|
|
221
271
|
runs lint, typecheck, the coverage gate, the parametrized contract suite, and a
|
|
222
272
|
Stryker mutation spot-check on every push and pull request. Coverage thresholds
|
|
223
|
-
enforce 100% on `irc-core` / `irc-server` / `in-memory-runtime`
|
|
224
|
-
other package.
|
|
273
|
+
enforce 100% on `irc-core` / `irc-server` / `in-memory-runtime` / `ci-hardening`
|
|
274
|
+
and ≥90% on every other package.
|
|
225
275
|
|
|
226
276
|
---
|
|
227
277
|
|
|
@@ -296,14 +346,15 @@ treats `state.account !== undefined` as already authorised, so a
|
|
|
296
346
|
deployment with both `SERVER_PASSWORD` and configured SASL accounts
|
|
297
347
|
does not need to hand the shared password to identified users. The
|
|
298
348
|
server-wide gate is a shared deployment secret (never log it); per-user
|
|
299
|
-
credentials still go through the
|
|
300
|
-
(`
|
|
349
|
+
credentials still go through the unified services credential store
|
|
350
|
+
(`ServicesStore.verifyNick` — D1 `nickserv_accounts` / DynamoDB
|
|
351
|
+
`Services`), which is unaffected.
|
|
301
352
|
|
|
302
353
|
**PASS-based account login:** in addition to SASL, a client may identify
|
|
303
354
|
to its NickServ account by sending `PASS <nick>:<password>` (the same
|
|
304
355
|
`<nick>:<password>` literal the `SASL_ACCOUNTS` seed tooling uses).
|
|
305
|
-
When
|
|
306
|
-
+ `USER …` verifies the credentials
|
|
356
|
+
When a `ServicesStore` is configured, `NICK alice` + `PASS alice:hunter2`
|
|
357
|
+
+ `USER …` verifies the credentials via `services.verifyNick` and, on
|
|
307
358
|
success, logs the connection in as `alice` — emitting `900 RPL_LOGGEDIN`
|
|
308
359
|
+ `903 RPL_SASLSUCCESS` (the same "account is set" numerics SASL uses)
|
|
309
360
|
before `001 RPL_WELCOME`, stamping user mode `+r`, and running the same
|
|
@@ -442,12 +493,22 @@ report).
|
|
|
442
493
|
## Web client (`apps/web`)
|
|
443
494
|
|
|
444
495
|
A vendored **Kiwi IRC** SPA served at `/webclient/` and a static project
|
|
445
|
-
landing page served at `/`, both baked out of `apps/web/dist
|
|
446
|
-
served
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
496
|
+
landing page served at `/`, both baked out of `apps/web/dist/`. The same
|
|
497
|
+
artifact is served by **both** platforms:
|
|
498
|
+
|
|
499
|
+
| Platform | HTTP/SPA origin | WebSocket origin | Same-origin? |
|
|
500
|
+
|-------------|----------------------------------------------------|---------------------------------------------------|--------------|
|
|
501
|
+
| Cloudflare | the Worker (`*.workers.dev` / custom domain) | the same Worker | **Yes** — one domain serves both |
|
|
502
|
+
| AWS | CloudFront (`*.cloudfront.net` / custom domain) | API Gateway (`*.execute-api.*.amazonaws.com`) | **No** — two different endpoints |
|
|
503
|
+
|
|
504
|
+
On Cloudflare the Worker's `[assets]` binding serves the SPA and the WS
|
|
505
|
+
upgrade on one origin — the browser opens a native `wss://` directly to
|
|
506
|
+
the Worker, no proxy or gateway. On AWS the SPA is fronted by an
|
|
507
|
+
**S3 + CloudFront + OAC** `StaticSite` construct
|
|
508
|
+
(`apps/aws-stack/src/static-site.ts`, opt-in via CDK context) and a
|
|
509
|
+
two-phase deploy bakes the real `ConnectUrl` into the SPA config (see
|
|
510
|
+
"Deploying on AWS" below). See `docs/WebClientGuide.md` for the CF
|
|
511
|
+
end-to-end guide and `docs/AWS-Deployment.md` §16 for the AWS path.
|
|
451
512
|
|
|
452
513
|
Build the SPA + landing page (needs the submodule + `yarn`, provided by
|
|
453
514
|
`corepack enable`):
|
|
@@ -455,7 +516,7 @@ Build the SPA + landing page (needs the submodule + `yarn`, provided by
|
|
|
455
516
|
```bash
|
|
456
517
|
git submodule update --init apps/web/upstream # one-time per clone
|
|
457
518
|
git submodule update --init docs # one-time per clone (docs site)
|
|
458
|
-
pnpm --filter web build
|
|
519
|
+
pnpm --filter web build # or build:prod (CF) / build:prod-aws (AWS)
|
|
459
520
|
# → apps/web/dist/index.html (landing page, served at /)
|
|
460
521
|
# → apps/web/dist/webclient/index.html (Kiwi SPA, served at /webclient/)
|
|
461
522
|
# → apps/web/dist/webclient/static/config.json (baked, env-specific)
|
|
@@ -470,8 +531,10 @@ existing `[assets]` binding serves them at `/docs/<slug>.html` (and
|
|
|
470
531
|
command if it is missing or empty (mirroring the Kiwi `upstream/` guard).
|
|
471
532
|
`Home.md` is the docs landing page; `ADR-Index.md` lists every ADR.
|
|
472
533
|
|
|
473
|
-
|
|
474
|
-
|
|
534
|
+
### Deploying on Cloudflare
|
|
535
|
+
|
|
536
|
+
Run everything locally (Worker serves SPA + landing page + WS on one
|
|
537
|
+
origin):
|
|
475
538
|
|
|
476
539
|
```bash
|
|
477
540
|
pnpm build # workspace packages
|
|
@@ -482,18 +545,73 @@ pnpm --filter @serverless-ircd/cf-worker dev # http://localhost:8787
|
|
|
482
545
|
# /health → plaintext liveness
|
|
483
546
|
```
|
|
484
547
|
|
|
485
|
-
Deploy
|
|
548
|
+
Deploy the Worker (Worker + assets in one command):
|
|
549
|
+
|
|
550
|
+
```bash
|
|
551
|
+
pnpm deploy:cf # wrangler deploy
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
#### Configuration vars & secrets (Cloudflare)
|
|
555
|
+
|
|
556
|
+
Every env var the Worker consumes is enumerated — and
|
|
557
|
+
**drift-guarded in CI** — in the `consumed-env-vars` block of
|
|
558
|
+
`apps/cf-worker/wrangler.toml`, classified as a plaintext `[vars]`
|
|
559
|
+
knob or a `[secret]`. Credential material must be set as Workers
|
|
560
|
+
secrets, never `[vars]`:
|
|
561
|
+
|
|
562
|
+
| Secret | Purpose |
|
|
563
|
+
|--------|---------|
|
|
564
|
+
| `SERVER_PASSWORD` | server-wide PASS gate (see [above](#server-password-server_password---server-password)) |
|
|
565
|
+
| `OPER_PASSWORD` | legacy plaintext oper credential |
|
|
566
|
+
| `OPER_SALT` + `OPER_HASH` | hashed oper credential — generate with `node --import tsx tools/hash-oper-cred.ts --user admin --stdin` |
|
|
567
|
+
| `SASL_ACCOUNTS` | newline-delimited `user:password` SASL seed list (see `tools/seed-cf-accounts.ts`) |
|
|
568
|
+
|
|
569
|
+
Set each with `wrangler secret put <NAME>`. Everything else
|
|
570
|
+
(`SERVER_NAME`, `MAX_CLIENTS`, `MAX_CONNECTIONS_PER_IP`,
|
|
571
|
+
`PER_IP_CONNECTION_RATE_*`, `MAX_FRAMES_PER_WINDOW`,
|
|
572
|
+
`FRAME_WINDOW_SECONDS`, `EXTERNAL_ENABLED`, …) is a non-sensitive
|
|
573
|
+
`[vars]` knob.
|
|
574
|
+
|
|
575
|
+
### Deploying on AWS
|
|
576
|
+
|
|
577
|
+
The web client is **opt-in**: provision the `StaticSite` construct by
|
|
578
|
+
passing `webSite*` CDK context on the stack deploy, and (once the SPA
|
|
579
|
+
ships) set `webOrigins` for the CSWSH defence:
|
|
486
580
|
|
|
487
581
|
```bash
|
|
488
|
-
pnpm deploy:
|
|
582
|
+
pnpm deploy:aws -- \
|
|
583
|
+
-c webSiteCustomDomain=app.example.com \
|
|
584
|
+
-c webSiteCertificateArn=arn:aws:acm:us-east-1:... \
|
|
585
|
+
-c webSiteHostedZoneName=example.com. -c webSiteHostedZoneId=... \
|
|
586
|
+
-c webOrigins=https://app.example.com
|
|
489
587
|
```
|
|
490
588
|
|
|
589
|
+
Then bake + ship the SPA (stack-output-driven — reads `ConnectUrl`,
|
|
590
|
+
`WebsiteBucketName`, `WebsiteDistributionId` from the stack outputs,
|
|
591
|
+
bakes the real wss URL into `config.json`, `s3 sync`s the build, and
|
|
592
|
+
invalidates the CloudFront edge cache):
|
|
593
|
+
|
|
594
|
+
```bash
|
|
595
|
+
node scripts/deploy-web-aws.mjs
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
The ACM certificate **must** be in `us-east-1` (CloudFront requirement).
|
|
599
|
+
See `docs/AWS-Deployment.md` §16 for the full two-phase flow, the
|
|
600
|
+
`server` / `direct_path` config split (irc-framework prepends `wss://`
|
|
601
|
+
itself, so the host is baked scheme-less), and the custom-domain setup.
|
|
602
|
+
|
|
491
603
|
### WebSocket Origin policy (CSWSH defense)
|
|
492
604
|
|
|
493
|
-
WebSocket upgrades do **not** follow the same-origin policy, so
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
605
|
+
WebSocket upgrades do **not** follow the same-origin policy, so a
|
|
606
|
+
malicious page can open a WebSocket to the IRC server from a victim's
|
|
607
|
+
browser and drive the session with their credentials (Cross-Site
|
|
608
|
+
WebSocket Hijacking, CSWSH). Both adapters enforce an `Origin` policy
|
|
609
|
+
on the upgrade — but the **modes differ**, because the CF Worker serves
|
|
610
|
+
the SPA and the wss endpoint on the same origin while AWS serves them
|
|
611
|
+
on different origins:
|
|
612
|
+
|
|
613
|
+
**Cloudflare** — see `apps/cf-worker/src/origin-allowlist.ts` and the
|
|
614
|
+
SPA guide §5. Two modes, evaluated in order:
|
|
497
615
|
|
|
498
616
|
1. **Explicit allowlist** — set `WEB_ORIGINS` (comma-separated) for
|
|
499
617
|
cross-origin deploys (SPA on a different domain than the Worker,
|
|
@@ -502,11 +620,27 @@ modes, evaluated in order:
|
|
|
502
620
|
unset/empty, the Worker compares the browser's `Origin` against the
|
|
503
621
|
request's own origin. Match → proceed; mismatch → `403 Forbidden`.
|
|
504
622
|
|
|
505
|
-
`WEB_ORIGINS` is **optional** — same-origin auto-derive needs
|
|
506
|
-
per-env config and works for `*.workers.dev`, custom domains, and
|
|
507
|
-
preview URLs alike.
|
|
508
|
-
|
|
509
|
-
|
|
623
|
+
`WEB_ORIGINS` is **optional** on CF — same-origin auto-derive needs
|
|
624
|
+
zero per-env config and works for `*.workers.dev`, custom domains, and
|
|
625
|
+
preview URLs alike.
|
|
626
|
+
|
|
627
|
+
**AWS** — see `packages/aws-adapter/src/origin-allowlist.ts` and
|
|
628
|
+
`docs/AWS-Deployment.md` §8.2. **Explicit allowlist only** (no
|
|
629
|
+
auto-derive): the SPA is on a CloudFront origin and the wss endpoint is
|
|
630
|
+
on an API Gateway origin, so the request's own host is never the SPA's
|
|
631
|
+
origin. The defence is **opt-in** — unset `WEB_ORIGINS` skips the check
|
|
632
|
+
entirely (existing bare-IRC deployments without a web frontend are
|
|
633
|
+
unchanged on upgrade); set it to the SPA's origin(s) once the web client
|
|
634
|
+
ships:
|
|
635
|
+
|
|
636
|
+
```bash
|
|
637
|
+
pnpm deploy:aws -- -c webOrigins=https://app.example.com
|
|
638
|
+
# or as a stack prop: webOrigins: 'https://app.example.com,https://staging.app.example.com'
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
Non-browser clients (curl, WeeChat, the `tcp-ws-forwarder`, scripted
|
|
642
|
+
harnesses) never send `Origin` and pass through unchanged on both
|
|
643
|
+
platforms.
|
|
510
644
|
|
|
511
645
|
---
|
|
512
646
|
|
|
@@ -546,6 +680,73 @@ deployed stack exercises the spec path end-to-end.
|
|
|
546
680
|
|
|
547
681
|
---
|
|
548
682
|
|
|
683
|
+
## Abuse controls & credential hardening
|
|
684
|
+
|
|
685
|
+
Every transport edge enforces layered admission and rate limits, and
|
|
686
|
+
every credential path verifies through hardened, timing-equalized
|
|
687
|
+
comparisons. The knobs below are `[vars]` on the Cloudflare Worker and
|
|
688
|
+
mirrored as Lambda env vars on AWS.
|
|
689
|
+
|
|
690
|
+
### Connection admission & rate limiting
|
|
691
|
+
|
|
692
|
+
| Layer | Knobs | Enforcement |
|
|
693
|
+
|------------------------|---------------------------------------------------|-------------|
|
|
694
|
+
| 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. |
|
|
695
|
+
| Per-IP simultaneous | `MAX_CONNECTIONS_PER_IP` | Admission gates on both platforms reject over-budget source IPs with `429`. |
|
|
696
|
+
| 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. |
|
|
697
|
+
| Per-connection frames | `MAX_FRAMES_PER_WINDOW` / `FRAME_WINDOW_SECONDS` | Inbound frame budget enforced at the adapter boundary before the actor / storage write. |
|
|
698
|
+
| Line-buffer memory | (fixed) | TCP input buffers capped at 8 KiB at every transport edge (local CLI, container origin, NLB handler, forwarder). |
|
|
699
|
+
|
|
700
|
+
### Protocol budgets
|
|
701
|
+
|
|
702
|
+
All length limits are enforced in **UTF-8 bytes**, not UTF-16 code
|
|
703
|
+
units: the 510-byte WS frame / 512-byte TCP line budgets, `TOPICLEN`
|
|
704
|
+
enforced on character boundaries, `draft/multiline` batch byte budgets
|
|
705
|
+
enforced incrementally as lines arrive, at most 15 tags and an
|
|
706
|
+
8192-byte tag section per message, long NAMES rosters split across
|
|
707
|
+
multiple `353` replies within the 510-byte budget, `CHATHISTORY`
|
|
708
|
+
limits capped at a configurable ceiling (default 100), and
|
|
709
|
+
`MAX_TARGETS_PER_COMMAND` capping comma-split targets. The parser
|
|
710
|
+
rejects bare-`CR` smuggling and NUL/control characters in channel
|
|
711
|
+
names, builds tag maps with a null prototype (blocking prototype
|
|
712
|
+
pollution), and `OPER` requires a registered connection (`451
|
|
713
|
+
ERR_NOTREGISTERED`).
|
|
714
|
+
|
|
715
|
+
### Credentials & anti-abuse
|
|
716
|
+
|
|
717
|
+
Oper credentials verify against scrypt hashes (`OPER_USER` +
|
|
718
|
+
`OPER_SALT`/`OPER_HASH`, generated with `tools/hash-oper-cred.ts`);
|
|
719
|
+
the server password compares in constant time; `verifyNick` runs a
|
|
720
|
+
dummy scrypt verify on unknown nicks so response timings cannot
|
|
721
|
+
enumerate accounts. Brute force is throttled at every auth surface: a
|
|
722
|
+
per-connection SASL failure lockout, a per-IP `OPER` failure lockout,
|
|
723
|
+
and a per-account NickServ `IDENTIFY` freeze. HostServ auto-approve
|
|
724
|
+
honours a vhost denylist/allowlist, and ChanServ `DEOP`/`KICK` protect
|
|
725
|
+
founders and enforce caller rank. Parse-error logs carry only
|
|
726
|
+
token/length/reason — `PASS` / `AUTHENTICATE` payloads are redacted —
|
|
727
|
+
and the Worker's log sampling defaults to 10%.
|
|
728
|
+
|
|
729
|
+
### SASL EXTERNAL (mTLS)
|
|
730
|
+
|
|
731
|
+
`EXTERNAL` is advertised and accepted only when **all** of the
|
|
732
|
+
following hold: an mTLS identity source is bound for the connection
|
|
733
|
+
(CF API Shield / AWS API Gateway client certs), the operator opt-in
|
|
734
|
+
`EXTERNAL_ENABLED` is set (default **off**), and the transport is
|
|
735
|
+
TLS-secured. Otherwise the `sasl` cap and `908 ERR_SASLMECHS` list
|
|
736
|
+
`PLAIN` only — refusals do not count toward the SASL lockout.
|
|
737
|
+
Accounts bind to the client cert's **DER SHA-256 fingerprint**
|
|
738
|
+
(`fp:<hex>` entries — what Cloudflare surfaces from
|
|
739
|
+
`request.cf.tlsClientAuth`; the preferred binding) or a **canonical
|
|
740
|
+
subject DN** (the only identifier API Gateway exposes; DNs are
|
|
741
|
+
canonicalised — types lower-cased, whitespace collapsed, RDN/AVA order
|
|
742
|
+
sorted — so re-issued or differently-ordered certificates still match).
|
|
743
|
+
Transports without any client-cert surface — the Spectrum container
|
|
744
|
+
origin, and the NLB stream handler unless an mTLS provider is injected
|
|
745
|
+
— pin the mechanism off and reject `AUTHENTICATE EXTERNAL` with a
|
|
746
|
+
transport-specific `904`.
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
549
750
|
## Testing strategy
|
|
550
751
|
|
|
551
752
|
This project follows strict TDD (Red → Green → Refactor) — every reducer is
|
|
@@ -582,12 +783,17 @@ Active follow-ups:
|
|
|
582
783
|
surfaces.
|
|
583
784
|
- **Web client e2e** — Playwright headless-browser e2e exercising the
|
|
584
785
|
vendored Kiwi IRC SPA against a deployed stack.
|
|
585
|
-
- **Coverage hardening** — `
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
`
|
|
589
|
-
|
|
590
|
-
|
|
786
|
+
- **Coverage hardening** — `irc-core`, `irc-server`,
|
|
787
|
+
`in-memory-runtime`, `local-cli`, `cf-worker`, `aws-stack`,
|
|
788
|
+
`load-test`, `cf-tcp-container`, `tcp-ws-forwarder`,
|
|
789
|
+
`irc-test-support`, and `ci-hardening` sit at 100% line coverage;
|
|
790
|
+
`cf-adapter` (~99.7%) and `aws-adapter` (~98%) clear the 90% gate
|
|
791
|
+
with follow-ups driving each to full coverage.
|
|
792
|
+
- **Protocol follow-ups** — `WHOX` (`WHO <mask> %<fields>` /
|
|
793
|
+
`354 RPL_WHOSPCRPL`), `LIST` search masks + `ELIST=MNTU` filters,
|
|
794
|
+
`cap-notify` capability-change push, services data lifecycle
|
|
795
|
+
(last-used tracking + expiry sweep), and a persisted oper audit
|
|
796
|
+
trail.
|
|
591
797
|
- **Persistent ChanServ ban list** — ban masks currently live on
|
|
592
798
|
`ChannelState.banMasks` and do not survive an empty-recreate of a
|
|
593
799
|
channel. Extending `ServicesStore` with a persistent ban list is the
|
|
@@ -637,8 +843,9 @@ for clients with a dedicated slash-command UX. Reached via `PRIVMSG
|
|
|
637
843
|
|
|
638
844
|
- **NickServ** — `REGISTER` / `IDENTIFY` (alias: `ID`) / `DROP` / `INFO`
|
|
639
845
|
(owner + oper only for the `Email:` line) / `SET ENFORCE` + nick
|
|
640
|
-
enforcement on the `NICK` path
|
|
641
|
-
|
|
846
|
+
enforcement on the `NICK` path / `SET PASSWORD` (self-service password
|
|
847
|
+
change; requires the current password re-supplied). `INFO <nick>` with
|
|
848
|
+
no target resolves to the caller's own account.
|
|
642
849
|
- **ChanServ** — `REGISTER` / `DROP` / `SET` (`FOUNDER` / `MLOCK` /
|
|
643
850
|
`RESTRICTED` / `KEEPTOPIC`) / `INFO` / `ACCESS` (`SOP` / `AOP` / `HOP`
|
|
644
851
|
/ `VOP`, each `ADD` / `DEL` / `LIST`) / `LEVELS`, plus the prefix /
|
|
@@ -658,19 +865,32 @@ for clients with a dedicated slash-command UX. Reached via `PRIVMSG
|
|
|
658
865
|
- **MemoServ** — `SEND` / `LIST` / `READ` / `DEL` with queue delivery
|
|
659
866
|
at identify.
|
|
660
867
|
|
|
661
|
-
The
|
|
662
|
-
|
|
663
|
-
|
|
868
|
+
The `ServicesStore` is the **single credential home**: SASL PLAIN, SASL
|
|
869
|
+
EXTERNAL (CertFP), NickServ `IDENTIFY`, and `PASS <nick>:<password>` all
|
|
870
|
+
verify through the same scrypt-hashed `verifyNick` / `verifyCertFP`
|
|
871
|
+
surface, so a registered nick is also a SASL login and vice versa.
|
|
872
|
+
The credential env vars that seed and unlock these paths
|
|
873
|
+
(`SASL_ACCOUNTS`, `OPER_*`, `SERVER_PASSWORD`) are Workers secrets on
|
|
874
|
+
Cloudflare — the full consumed-var list lives in the drift-guarded
|
|
875
|
+
`consumed-env-vars` block of `apps/cf-worker/wrangler.toml` (see
|
|
876
|
+
[Configuration vars & secrets](#configuration-vars-secrets-cloudflare)).
|
|
877
|
+
Backends: D1 on Cloudflare, DynamoDB on AWS, in-memory for the
|
|
664
878
|
local CLI / tests (all write-behind; registrations survive redeploys).
|
|
665
879
|
When no store is bound, services commands reply `501` and the rest of
|
|
666
880
|
the daemon is unaffected. See `docs/Services.md` for the full
|
|
667
881
|
reference.
|
|
668
|
-
|
|
669
882
|
**IRCv3 extensions (negotiated via `CAP`):** `message-tags` (incl. the
|
|
670
883
|
`TAGMSG` command), `server-time`, `account-tag`, `account-notify`
|
|
671
884
|
(pushes `ACCOUNT` on SASL login/logout), `echo-message`, `batch`,
|
|
672
|
-
`sasl` (`PLAIN` always; `EXTERNAL`
|
|
673
|
-
|
|
885
|
+
`sasl` (`PLAIN` always; `EXTERNAL` only under the triple gate of
|
|
886
|
+
operator opt-in (`EXTERNAL_ENABLED`, default off) + a bound edge-mTLS
|
|
887
|
+
identity source + a TLS connection — accounts bind to the client
|
|
888
|
+
cert's DER SHA-256 fingerprint or a canonical subject DN, refusals
|
|
889
|
+
answer `908 ERR_SASLMECHS` listing `PLAIN` only, and the
|
|
890
|
+
`cf-tcp-container` Spectrum origin rejects `AUTHENTICATE EXTERNAL`
|
|
891
|
+
with a transport-specific `904`; see
|
|
892
|
+
[Abuse controls & credential hardening](#abuse-controls--credential-hardening)),
|
|
893
|
+
`multi-prefix`, `away-notify`, `chghost`, `invite-notify`,
|
|
674
894
|
`extended-join`, `msgid` (`@+msgid=<id>` on every PRIVMSG/NOTICE/TAGMSG,
|
|
675
895
|
shared between live and `draft/chathistory` replay), `standard-replies`
|
|
676
896
|
(`FAIL`/`WARN`/`NOTE` replacements for a curated numeric subset),
|
|
@@ -712,9 +932,15 @@ bridges a stock TCP client to a deployed WebSocket endpoint.
|
|
|
712
932
|
account store, service shortcut verbs), adapter backends, and an
|
|
713
933
|
end-to-end registration walkthrough.
|
|
714
934
|
- `docs/WebClientGuide.md` — end-to-end contributor/operator doc for
|
|
715
|
-
the web client: build pipeline, per-env config matrix,
|
|
716
|
-
rationale and the optional `WEB_ORIGINS` var, local dev,
|
|
717
|
-
Cloudflare Pages alternative, and troubleshooting.
|
|
935
|
+
the **Cloudflare** web client: build pipeline, per-env config matrix,
|
|
936
|
+
CSWSH rationale and the optional `WEB_ORIGINS` var, local dev,
|
|
937
|
+
optional Cloudflare Pages alternative, and troubleshooting.
|
|
938
|
+
- `docs/AWS-Deployment.md` — end-to-end AWS deploy guide: first-time
|
|
939
|
+
setup, CDK reference, DynamoDB capacity planning, region strategy,
|
|
940
|
+
cost notes, **OIDC-only CI** (§17), **APIGW `DataTraceEnabled` hard-
|
|
941
|
+
lock** (§7.6), and the **S3 + CloudFront + OAC web client** (§16,
|
|
942
|
+
including the two-phase stack-output-driven deploy and the
|
|
943
|
+
`webOrigins` CSWSH knob).
|
|
718
944
|
- `docs/Cloudflare-TCP-Deployment.md` and `docs/AWS-TCP-Deployment.md`
|
|
719
945
|
— end-to-end guides for the `:6697` TCP+TLS variants
|
|
720
946
|
(Spectrum/Container on CF, NLB + Lambda streaming on AWS), including
|
package/apps/aws-stack/README.md
CHANGED
|
@@ -21,8 +21,7 @@ The Lambda handler is currently a stub (`{ statusCode: 200 }`); the real
|
|
|
21
21
|
|
|
22
22
|
```sh
|
|
23
23
|
pnpm cdk:synth # synthesize the CloudFormation template (cdk.out/)
|
|
24
|
-
pnpm deploy
|
|
25
|
-
pnpm deploy:prod # cdk deploy --all --require-approval never
|
|
24
|
+
pnpm deploy # cdk deploy --all --require-approval never
|
|
26
25
|
pnpm test # synth-time assertions (no AWS, no Docker)
|
|
27
26
|
pnpm typecheck
|
|
28
27
|
```
|
|
@@ -30,9 +29,8 @@ pnpm typecheck
|
|
|
30
29
|
Repo-level shortcuts (from the monorepo root):
|
|
31
30
|
|
|
32
31
|
```sh
|
|
33
|
-
pnpm deploy:aws
|
|
34
|
-
pnpm
|
|
35
|
-
pnpm smoke:aws:staging # deferred (smoke harness not yet wired)
|
|
32
|
+
pnpm deploy:aws
|
|
33
|
+
pnpm smoke:aws # deferred (smoke harness not yet wired)
|
|
36
34
|
```
|
|
37
35
|
|
|
38
36
|
## Optional localstack validation
|
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* CDK CLI's default account/region when those env vars are set; otherwise
|
|
6
6
|
* the stack is environment-agnostic (suitable for `cdk synth` and localstack).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* There is a single deploy target per platform (the staging/prod split was
|
|
9
|
+
* collapsed). This app synthesizes exactly one stack named `IrcAwsStack`;
|
|
10
|
+
* staging vs production isolation is driven by which AWS account and region
|
|
11
|
+
* the deploy credentials target, not by stack-name templating. Server
|
|
12
|
+
* identity (`serverName`, `networkName`, `motd`) and the optional TCP+TLS
|
|
13
|
+
* domain are read from CDK context so deploys can override them without
|
|
14
|
+
* editing source.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import { App } from 'aws-cdk-lib';
|
|
@@ -20,8 +22,6 @@ const app = new App();
|
|
|
20
22
|
|
|
21
23
|
const account = process.env.CDK_DEFAULT_ACCOUNT;
|
|
22
24
|
const region = process.env.CDK_DEFAULT_REGION;
|
|
23
|
-
const environmentName =
|
|
24
|
-
(app.node.tryGetContext('environmentName') as string | undefined) ?? 'staging';
|
|
25
25
|
|
|
26
26
|
// Server identity is read from CDK context so deploys can override it
|
|
27
27
|
// without editing source (`-c serverName=…`, `-c networkName=…`,
|
|
@@ -44,13 +44,122 @@ const motdLines = Array.isArray(rawMotd)
|
|
|
44
44
|
// docs/AWS-TCP-Deployment.md.
|
|
45
45
|
const tcpTlsDomainName = app.node.tryGetContext('tcpTlsDomainName') as string | undefined;
|
|
46
46
|
|
|
47
|
+
// `$connect` Origin allowlist (CSWSH defence). Opt-in: only provisioned
|
|
48
|
+
// when this context var is supplied as a non-empty string. Comma-separated
|
|
49
|
+
// origin list (e.g. `'https://app.example.com, https://staging.app.example.com'`).
|
|
50
|
+
// When omitted, the `$connect` handler skips the Origin check entirely
|
|
51
|
+
// (existing AWS deployments without a web frontend are unchanged).
|
|
52
|
+
const webOrigins = app.node.tryGetContext('webOrigins') as string | undefined;
|
|
53
|
+
|
|
54
|
+
// API Gateway full-frame body tracing (`DataTraceEnabled`). Safe-by-default:
|
|
55
|
+
// omitted → `false` (no IRC frame bodies logged). Re-enabling requires BOTH
|
|
56
|
+
// `-c allowDataTrace=true` AND `-c iUnderstandThisLeaksCredentials=true`; the
|
|
57
|
+
// stack REFUSES synth with a credential-leak error if only the first is set.
|
|
58
|
+
// Since the staging/prod split was collapsed every synth is effectively a
|
|
59
|
+
// prod synth, so there is no quiet "non-prod" path — use ONLY in a throwaway
|
|
60
|
+
// sandbox for live debugging. See docs/AWS-Deployment.md (§7.6).
|
|
61
|
+
const allowDataTrace = app.node.tryGetContext('allowDataTrace') as boolean | string | undefined;
|
|
62
|
+
const iUnderstandThisLeaksCredentials = app.node.tryGetContext('iUnderstandThisLeaksCredentials') as
|
|
63
|
+
| boolean
|
|
64
|
+
| string
|
|
65
|
+
| undefined;
|
|
66
|
+
|
|
67
|
+
// CDK context vars arrive as strings; normalise the booleans so the stack's
|
|
68
|
+
// guard sees real booleans (and `--c allowDataTrace=false` stays false-y).
|
|
69
|
+
const allowDataTraceBool = parseContextBool(allowDataTrace);
|
|
70
|
+
const acknowledgeLeakBool = parseContextBool(iUnderstandThisLeaksCredentials);
|
|
71
|
+
|
|
72
|
+
// DynamoDB table-deletion escape hatch (state-table protection). Safe-by-
|
|
73
|
+
// default: omitted → the stack keeps state tables RETAIN + deletion-protected
|
|
74
|
+
// + PITR-on. `-c allowTableDeletion=true` flips state tables to DESTROY +
|
|
75
|
+
// deletion-protection OFF so a deliberate `cdk destroy` teardown can delete
|
|
76
|
+
// them (the data is irreversibly lost — DynamoDB tables do not snapshot on
|
|
77
|
+
// delete). `Connections` is always DESTROY regardless. See §9.4 / §11.
|
|
78
|
+
const allowTableDeletion = app.node.tryGetContext('allowTableDeletion') as
|
|
79
|
+
| boolean
|
|
80
|
+
| string
|
|
81
|
+
| undefined;
|
|
82
|
+
const allowTableDeletionBool = parseContextBool(allowTableDeletion);
|
|
83
|
+
|
|
84
|
+
// DynamoDB point-in-time recovery toggle. Defaults to ON (the stack's own
|
|
85
|
+
// default when omitted); `-c enablePitr=false` disables PITR on every state
|
|
86
|
+
// table (PITR is billed continuously at ~$0.20/GB-month per table; RETAIN
|
|
87
|
+
// and deletionProtection are free). `Connections` never carries PITR. The
|
|
88
|
+
// flag is independent of `allowTableDeletion` — a teardown keeps PITR on.
|
|
89
|
+
const enablePitr = app.node.tryGetContext('enablePitr') as boolean | string | undefined;
|
|
90
|
+
const enablePitrBool = parseContextBool(enablePitr);
|
|
91
|
+
|
|
92
|
+
// Web client hosting (S3 + CloudFront + OAC) is opt-in. Supplying
|
|
93
|
+
// `webSiteCustomDomain` provisions the static-site construct; prod passes
|
|
94
|
+
// the custom domain + cert + hosted zone, staging leaves them unset to use
|
|
95
|
+
// the default `*.cloudfront.net` URL. See docs/AWS-Deployment.md (web section).
|
|
96
|
+
const webSiteCustomDomain = app.node.tryGetContext('webSiteCustomDomain') as string | undefined;
|
|
97
|
+
const webSiteCertificateArn = app.node.tryGetContext('webSiteCertificateArn') as string | undefined;
|
|
98
|
+
const webSiteHostedZoneName = app.node.tryGetContext('webSiteHostedZoneName') as string | undefined;
|
|
99
|
+
const webSiteHostedZoneId = app.node.tryGetContext('webSiteHostedZoneId') as string | undefined;
|
|
100
|
+
const webSite =
|
|
101
|
+
webSiteCustomDomain !== undefined
|
|
102
|
+
? {
|
|
103
|
+
customDomain: webSiteCustomDomain,
|
|
104
|
+
...(webSiteCertificateArn !== undefined ? { certificateArn: webSiteCertificateArn } : {}),
|
|
105
|
+
...(webSiteHostedZoneName !== undefined ? { hostedZoneName: webSiteHostedZoneName } : {}),
|
|
106
|
+
...(webSiteHostedZoneId !== undefined ? { hostedZoneId: webSiteHostedZoneId } : {}),
|
|
107
|
+
}
|
|
108
|
+
: undefined;
|
|
109
|
+
|
|
110
|
+
// Optional WAFv2 per-IP edge rate limit on the WebSocket API's `$connect`
|
|
111
|
+
// (requests per 5-minute window per client IP). Omitted → no WAF
|
|
112
|
+
// resources; `-c wafConnectRateLimit=2000` provisions the WebACL with a
|
|
113
|
+
// rate-based rule + the stage association. CDK context values arrive as
|
|
114
|
+
// strings, so parse to an integer and drop non-numeric values.
|
|
115
|
+
const rawWafConnectRateLimit = app.node.tryGetContext('wafConnectRateLimit');
|
|
116
|
+
const wafConnectRateLimit =
|
|
117
|
+
typeof rawWafConnectRateLimit === 'number'
|
|
118
|
+
? rawWafConnectRateLimit
|
|
119
|
+
: typeof rawWafConnectRateLimit === 'string' && /^\d+$/u.test(rawWafConnectRateLimit.trim())
|
|
120
|
+
? Number.parseInt(rawWafConnectRateLimit.trim(), 10)
|
|
121
|
+
: undefined;
|
|
122
|
+
|
|
47
123
|
const stackProps: IrcStackProps = {
|
|
48
|
-
environmentName,
|
|
49
124
|
...(serverName !== undefined ? { serverName } : {}),
|
|
50
125
|
...(networkName !== undefined ? { networkName } : {}),
|
|
51
126
|
...(motdLines !== undefined ? { motdLines } : {}),
|
|
52
127
|
...(tcpTlsDomainName !== undefined ? { tcpTlsDomainName } : {}),
|
|
128
|
+
...(webOrigins !== undefined && webOrigins.length > 0 ? { webOrigins } : {}),
|
|
129
|
+
...(webSite !== undefined ? { webSite } : {}),
|
|
130
|
+
...(allowDataTraceBool !== undefined ? { allowDataTrace: allowDataTraceBool } : {}),
|
|
131
|
+
...(acknowledgeLeakBool !== undefined
|
|
132
|
+
? { iUnderstandThisLeaksCredentials: acknowledgeLeakBool }
|
|
133
|
+
: {}),
|
|
134
|
+
...(allowTableDeletionBool !== undefined ? { allowTableDeletion: allowTableDeletionBool } : {}),
|
|
135
|
+
...(enablePitrBool !== undefined ? { enablePitr: enablePitrBool } : {}),
|
|
136
|
+
...(wafConnectRateLimit !== undefined ? { wafConnectRateLimit } : {}),
|
|
53
137
|
...(account && region ? { env: { account, region } } : {}),
|
|
54
138
|
};
|
|
55
139
|
|
|
56
|
-
new IrcAwsStack(app,
|
|
140
|
+
new IrcAwsStack(app, 'IrcAwsStack', stackProps);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Normalises a CDK context value (`-c key=value`) into a boolean.
|
|
144
|
+
* CDK passes context values as strings, so `'true'` / `'false'` are
|
|
145
|
+
* parsed; anything else (including `undefined`) returns `undefined` so
|
|
146
|
+
* the downstream optional prop is simply omitted. The data-trace guard
|
|
147
|
+
* in `IrcAwsStack` is strict-equality-checked against `true`, so a stray
|
|
148
|
+
* string here would otherwise defeat the opt-in.
|
|
149
|
+
*/
|
|
150
|
+
function parseContextBool(value: boolean | string | undefined): boolean | undefined {
|
|
151
|
+
if (value === undefined) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
if (typeof value === 'boolean') {
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
const normalised = value.trim().toLowerCase();
|
|
158
|
+
if (normalised === 'true') {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (normalised === 'false') {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|