serverless-ircd 0.10.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.
Files changed (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -36,22 +36,32 @@ path adds two stateful pieces in front of the shared IRC core:
36
36
  ┌──────────────────────────────────────────────────────────────┐
37
37
  │ Cloudflare Spectrum (Enterprise) │
38
38
  │ • terminates TLS on :6697 │
39
- │ • forwards plaintext TCP to the origin
39
+ │ • re-encrypts to the origin (tls_mode = "full")
40
40
  │ • PROXY protocol v1 carries the real client IP │
41
41
  └──────────────────────────┬───────────────────────────────────┘
42
- plaintext TCP (:6667)
42
+ TLS (:6697, origin cert)
43
43
 
44
44
  ┌──────────────────────────────────────────────────────────────┐
45
45
  │ Cloudflare Container (apps/cf-tcp-container) │
46
- │ • node:24 process, EXPOSE 6667
46
+ │ • node:24 process, EXPOSE 6697 — terminates TLS itself
47
+ │ (TLS_CERT_PATH / TLS_KEY_PATH, mounted at /data/certs) │
47
48
  │ • ConnectionActor + InMemoryRuntime + TcpByteStream- │
48
49
  │ Transport (the shared transport seam, TICKET-053) │
49
50
  │ • persistence snapshot at /data/state.json │
50
51
  │ (channel topology survives restart; live connections │
51
52
  │ do not — clients reconnect) │
53
+ │ • plaintext :6667 = localhost-only debug listener │
54
+ │ (DEBUG_PLAINTEXT=localhost; never routed, never in │
55
+ │ prod — a prod-classified boot refuses to start │
56
+ │ without TLS material) │
52
57
  └──────────────────────────────────────────────────────────────┘
53
58
  ```
54
59
 
60
+ TLS is **end-to-end**: no plaintext IRC bytes ever cross the origin
61
+ network, so an attacker who can reach the origin address directly can
62
+ neither read `PASS`/`AUTHENTICATE`/`PRIVMSG` off the wire nor spoof
63
+ them.
64
+
55
65
  The **parser → reducer → dispatch pipeline is shared verbatim** with the
56
66
  wss path. Only the line-framing transport differs: a `TcpByteStreamTransport`
57
67
  reassembles `\r\n`-terminated IRC lines across arbitrary TCP chunks
@@ -61,8 +71,8 @@ Two artifacts are deployed and configured:
61
71
 
62
72
  | Artifact | Tool | What it does |
63
73
  |---------------------------------------|----------------|------------------------------------------------|
64
- | `sirc-tcp-origin` Cloudflare Container| `wrangler` | Builds + runs the IRC core container image. |
65
- | `cloudflare_spectrum_application` | `terraform` | TLS termination on :6697 + origin forwarding. |
74
+ | `sirc-tcp-origin` Cloudflare Container| `wrangler` | Builds + runs the IRC core container image (TLS origin). |
75
+ | `cloudflare_spectrum_application` | `terraform` | TLS on :6697 + re-encrypted origin forwarding (`tls_mode = "full"`). |
66
76
 
67
77
  ---
68
78
 
@@ -133,17 +143,19 @@ export CLOUDFLARE_ACCOUNT_ID=...
133
143
 
134
144
  ## 4. Local development
135
145
 
136
- Run the container origin locally without Spectrum connect a plaintext
137
- TCP IRC client directly to :6667. This exercises the exact
138
- `ConnectionActor` + `TcpByteStreamTransport` pipeline.
146
+ Run the container origin locally without Spectrum. The plaintext TCP
147
+ listener is **debug-only and localhost-only**: it starts solely when
148
+ `DEBUG_PLAINTEXT=localhost` is set (the config loader then forces the
149
+ bind to `127.0.0.1` — `TCP_HOST` cannot widen it). This exercises the
150
+ exact `ConnectionActor` + `TcpByteStreamTransport` pipeline.
139
151
 
140
152
  ```bash
141
153
  # Build the workspace + the container package.
142
154
  pnpm build
143
155
 
144
- # Run the origin on plaintext :6667 (default TCP_PORT).
145
- pnpm --filter @serverless-ircd/cf-tcp-container start \
146
- -- --env-file <(printf 'SERVER_NAME=irc.local\nTCP_PORT=6667\n')
156
+ # Run the origin with the localhost-only plaintext debug listener.
157
+ DEBUG_PLAINTEXT=localhost SERVER_NAME=irc.local TCP_PORT=6667 \
158
+ pnpm --filter @serverless-ircd/cf-tcp-container start
147
159
  ```
148
160
 
149
161
  Connect a plaintext client (no TLS) to `localhost:6667`:
@@ -156,12 +168,19 @@ JOIN #test
156
168
  PRIVMSG #test :hello over plaintext TCP
157
169
  ```
158
170
 
159
- For a TLS-terminated local loop, put `stunnel` (or `openssl s_server`)
160
- in front of :6667 and connect on :6697 mirroring the Spectrum topology.
171
+ For a TLS local loop that mirrors production (TLS terminating *at the
172
+ origin*), point the origin at a local cert pair instead no debug
173
+ flag needed:
174
+
175
+ ```bash
176
+ TLS_CERT_PATH=./certs/tls.pem TLS_KEY_PATH=./certs/tls.key SERVER_NAME=irc.local \
177
+ pnpm --filter @serverless-ircd/cf-tcp-container start
178
+ # → TLS listener on :6697; plaintext stays closed.
179
+ ```
161
180
 
162
181
  The unit + the TLS e2e suites live in `apps/cf-tcp-container/tests/`
163
- (including `tls-e2e.test.ts`, which runs a real TLS handshake against the
164
- origin). Run them with:
182
+ (including `tls-e2e.test.ts` and `container-server-tls.test.ts`, which
183
+ run real TLS handshakes against the origin). Run them with:
165
184
 
166
185
  ```bash
167
186
  pnpm --filter @serverless-ircd/cf-tcp-container test
@@ -189,7 +208,38 @@ and deploys the `sirc-tcp-origin` Container. The first deploy prints
189
208
  the **origin address** — copy it into `CF_TCP_ORIGIN_ADDRESS` (used by
190
209
  the Spectrum step next).
191
210
 
192
- ### 5.2 Apply the Spectrum TLS config
211
+ ### 5.2 Provision the origin certificate
212
+
213
+ `tls_mode = "full"` means the **origin terminates TLS itself**, so it
214
+ needs a PEM cert/key pair. Mount it at `/data/certs/` (the image's
215
+ documented default: `TLS_CERT_PATH=/data/certs/tls.pem`,
216
+ `TLS_KEY_PATH=/data/certs/tls.key`; override via env if the pair lives
217
+ elsewhere). Cert files must be readable by UID 1000 (`node`) — the
218
+ container drops privileges before reading them. Any of these work:
219
+
220
+ | Option | How | Notes |
221
+ |------------------------|----------------------------------------------------------------|-------|
222
+ | **Cloudflare Origin Cert** | Dashboard → SSL/TLS → Origin Server → Create Certificate. | Free, long-lived, issued for your zone; the easiest option when the origin is only reached via Spectrum. Not publicly trusted (fine — clients validate the *edge* cert). |
223
+ | **Let's Encrypt** | `certbot certonly --dns-plugin … -d origin.example.com`, then copy `fullchain.pem`/`privkey.pem` to `/data/certs/tls.{pem,key}` and renew on a cron/sidecar. | Publicly trusted; needs automation for the 90-day rotation. |
224
+ | **BYO cert** | Any PEM pair from your CA. | Your compliance rules apply. |
225
+
226
+ > A prod-classified boot (`NODE_ENV=production`, set by the Dockerfile)
227
+ > **refuses to start** without `TLS_CERT_PATH`/`TLS_KEY_PATH` — there is
228
+ > no silent plaintext fallback. A missing/unreadable cert file also
229
+ > fails fast at boot with the offending path in the error.
230
+
231
+ ### 5.3 Lock origin ingress down to Cloudflare IPs
232
+
233
+ Spectrum is the only thing that should ever reach the origin's :6697.
234
+ On any platform with a firewall / security-group primitive, restrict
235
+ ingress to Cloudflare's published ranges (https://www.cloudflare.com/ips/)
236
+ — otherwise a direct-reach attacker skips the edge entirely.
237
+ `spectrum.tf` ships a commented `cloudflare_list` reference for keeping
238
+ a managed list of the current ranges; Cloudflare Containers origins
239
+ rely on Spectrum `ip_firewall` + the platform's own network isolation,
240
+ but external-compute origins must apply the firewall on their side.
241
+
242
+ ### 5.4 Apply the Spectrum TLS config
193
243
 
194
244
  ```bash
195
245
  cd apps/cf-tcp-container/terraform
@@ -201,9 +251,10 @@ terraform apply \
201
251
  ```
202
252
 
203
253
  This creates the `cloudflare_spectrum_application.irc_tls` resource:
204
- TLS on :6697 plaintext TCP to the container origin on :6667.
254
+ TLS on :6697 at the edge, **re-encrypted** to the container origin's
255
+ TLS listener on :6697 (`tls_mode = "full"` — the secure default).
205
256
 
206
- ### 5.3 Smoke test over TLS
257
+ ### 5.5 Smoke test over TLS
207
258
 
208
259
  ```bash
209
260
  # Minimal smoke: register + quit over the real TLS endpoint.
@@ -215,7 +266,7 @@ printf 'NICK smoke\r\nUSER smoke 0 * :Smoke\r\nQUIT :smoke\r\n' | \
215
266
  CI does the same — see `.github/workflows/deploy-cf-tcp.yml` step
216
267
  "Smoke e2e (irc+tls)", gated on the `CF_TCP_SMOKE_HOST` repo variable.
217
268
 
218
- ### 5.4 Connect a real IRC client
269
+ ### 5.6 Connect a real IRC client
219
270
 
220
271
  ```
221
272
  /server add ircd irc.example.com/6697
@@ -240,8 +291,13 @@ Top-level `[vars]` map directly to the container config loader
240
291
  | `SERVER_VERSION` | Version string in `002`. | (from package version) |
241
292
  | `CREATED_AT` | Creation text/time in `003`. | build/deploy timestamp |
242
293
  | `MOTD_LINES` | Message-of-the-day, `\n`-delimited. | welcome banner |
243
- | `TCP_PORT` | Plaintext port the container listens on. | `6667` |
244
- | `TCP_HOST` | Bind address (Spectrum forwards from the edge). | `0.0.0.0` |
294
+ | `NODE_ENV` | `production` classifies the boot as prod: the loader then refuses to start without TLS material and rejects `DEBUG_PLAINTEXT`. The Dockerfile sets it. | (unset = dev) |
295
+ | `TLS_CERT_PATH` | PEM certificate the origin TLS listener presents. **Required in prod** (with `TLS_KEY_PATH`). | image: `/data/certs/tls.pem` |
296
+ | `TLS_KEY_PATH` | PEM private key for the origin TLS listener. **Required in prod** (with `TLS_CERT_PATH`). | image: `/data/certs/tls.key` |
297
+ | `TLS_PORT` | Origin TLS listener port. | `6697` |
298
+ | `TCP_PORT` | Plaintext debug-listener port — only used when `DEBUG_PLAINTEXT=localhost` is set. | `6667` |
299
+ | `TCP_HOST` | Bind address. Forced to `127.0.0.1` whenever the plaintext debug listener is enabled. | `0.0.0.0` |
300
+ | `DEBUG_PLAINTEXT` | Exact value `localhost` opts into the localhost-only plaintext debug listener. Any other value is rejected; forbidden in prod. | (unset = TLS-only) |
245
301
  | `PERSISTENCE_PATH` | Snapshot file path; enables channel-state survival. | `/data/state.json` |
246
302
  | `SNAPSHOT_INTERVAL_MS`| Auto-snapshot cadence (ms). | `60000` |
247
303
  | `OPER_USER` / `OPER_PASSWORD` | Oper credentials for `OPER`. | — |
@@ -273,10 +329,10 @@ sleep_after = "2h"
273
329
  |-------------------|---------------------------------------------------|---------------------|
274
330
  | `zone_id` | Cloudflare zone for the hostname. | *(required)* |
275
331
  | `origin_address` | Hostname/IP of the container origin. | *(required)* |
276
- | `origin_port` | Port the origin listens on (plaintext). | `6667` |
332
+ | `origin_port` | Port the origin's **TLS** listener serves. | `6697` |
277
333
  | `hostname` | Public hostname clients connect to. | `irc.example.com` |
278
334
  | `spectrum_port` | Public port Spectrum listens on. | `6697` |
279
- | `tls_mode` | `flexible` (TLS→CF, plain→origin) / `full` / `strict`. | `flexible` |
335
+ | `tls_mode` | `full` (TLS end-to-end — the secure default) / `strict` (edge also validates the origin cert). | `full` |
280
336
 
281
337
  `ip_firewall = true` (Cloudflare's L3/L4 DDoS) and
282
338
  `argo_smart_routing = true` are on by default. `proxy_protocol = "v1"`
@@ -311,24 +367,39 @@ path (Durable Objects) does not have. Two layers of survival:
311
367
 
312
368
  ---
313
369
 
314
- ## 9. mTLS (SASL EXTERNAL)
370
+ ## 9. mTLS (SASL EXTERNAL) — not supported on this transport
371
+
372
+ SASL `EXTERNAL` requires the verified client certificate to reach the
373
+ application layer. On the TCP container path it cannot: Spectrum
374
+ forwards to the origin with **PROXY protocol v1**, which carries no
375
+ client-cert subject, and although `tls_mode = "full"` terminates TLS at
376
+ the origin (`TLS_CERT_PATH`/`TLS_KEY_PATH`), no client-certificate
377
+ trust pool is wired through to the IRC core — there is no
378
+ `MtlsIdentityProvider` to feed, in either TLS mode.
379
+
380
+ The container therefore pins the mechanism off explicitly via the
381
+ `sasl.externalUnsupportedMessage` transport override:
315
382
 
316
- SASL `EXTERNAL` needs the client certificate to reach the application
317
- layer (ADR-006, TICKET-054). On the TCP path this is the TLS handshake:
383
+ - the `sasl` capability advertises `PLAIN` only, and
384
+ - a client sending `AUTHENTICATE EXTERNAL` is rejected up front with
385
+ `904 ERR_SASLFAIL` carrying the stable message
386
+ `SASL EXTERNAL not supported on this transport`.
318
387
 
319
- - **Flexible mode (default):** Spectrum terminates TLS; the origin sees
320
- plaintext. The cert is consumed at the edge and the app only gets a
321
- verified subject via Cloudflare's mTLS config SASL `EXTERNAL` is
322
- **not** available in flexible mode.
323
- - **Full mode:** set `tls_mode = "full"` and configure the origin to
324
- present its own certificate; configure Spectrum/API Shield mTLS with
325
- your CA pool. The verified client-cert subject is then exposed to the
326
- origin, feeding the `MtlsIdentityProvider` port → `AccountStore` →
327
- `AUTHENTICATE EXTERNAL` succeeds (`903`) or fails (`904`).
388
+ The refusal does not count toward the per-connection SASL failure
389
+ lockout. Clients that need certificate-backed SASL should use the wss
390
+ path (CF API Shield mTLS — see `Cloudflare-Deployment-Guide.md`) or the
391
+ AWS NLB+TLS path (API Gateway custom-domain mTLS — see
392
+ `AWS-TCP-Deployment.md`). Forwarding the client-cert subject via
393
+ PROXY-protocol-v2 TLVs (which Spectrum can emit) is the documented
394
+ follow-up if demand materialises; see `SASL-EXTERNAL.md`.
328
395
 
329
- Cross-reference: TICKET-054 (mTLS support), ADR-006 (SASL scope). The wss
330
- path achieves the same via CF API Shield mTLS (see
331
- `Cloudflare-Deployment-Guide.md`).
396
+ - **Flexible mode (explicit downgrade):** Spectrum terminates TLS and
397
+ forwards plaintext TCP to the origin. EXTERNAL is equally unavailable
398
+ there, and plaintext origin traffic reintroduces the direct-reach
399
+ exposure this topology closed. Avoid it.
400
+
401
+ Cross-reference: ADR-006 (SASL scope), `SASL-EXTERNAL.md` (mechanism
402
+ scope + transport availability).
332
403
 
333
404
  ---
334
405
 
@@ -389,7 +460,7 @@ because of the stateful origin:
389
460
  |------------------------|------------------------------------|--------------------------------------------|
390
461
  | **Plan tier** | Workers Paid ($5/mo) suffices. | **Enterprise required** (Spectrum). |
391
462
  | **Idle connections** | Hibernation → ~zero memory cost. | Origin holds every socket; `sleep_after` only helps when *all* connections are idle. |
392
- | **Compute** | Per-request (WebSocket frames). | **GB-second** of running container time. |
463
+ | **Compute** | Per-request (WebSocket frames). | **GB-second** of running container time. `tls_mode = "full"` moves TLS termination into the origin container, so it now pays the per-connection handshake CPU — budget handshakes/sec (each reconnect = one handshake) against the container's compute allocation when sizing `instance_type`. |
393
464
  | **State durability** | DO storage (managed). | Container volume snapshot (self-managed). |
394
465
  | **DDoS** | Cloudflare built-in. | Spectrum `ip_firewall` + Argo. |
395
466
 
@@ -417,10 +488,19 @@ respond. Causes:
417
488
  - `CF_TCP_ORIGIN_ADDRESS` points at the wrong origin. It must match the
418
489
  address printed by `wrangler deploy` (§5.1).
419
490
  - The origin crashed on boot. Check `wrangler tail` for the container
420
- (or container logs in the dashboard). A common cause is an invalid
421
- `MOTD_LINES` or a malformed `SASL_ACCOUNTS` line.
491
+ (or container logs in the dashboard). Common causes: the cert pair is
492
+ missing/unreadable at `TLS_CERT_PATH`/`TLS_KEY_PATH` (prod boots
493
+ refuse to start without it — the error names the offending path), an
494
+ invalid `MOTD_LINES`, or a malformed `SASL_ACCOUNTS` line.
495
+
496
+ ### 13.3 Origin refuses to boot: "refusing to start a prod-classified origin without TLS"
497
+
498
+ `NODE_ENV=production` is set (the Dockerfile sets it by default) but
499
+ `TLS_CERT_PATH`/`TLS_KEY_PATH` are not. Provision a cert pair (§5.2),
500
+ mount it at `/data/certs/`, and restart. There is deliberately no
501
+ plaintext fallback in prod.
422
502
 
423
- ### 13.3 Channel topic/modes lost after a restart
503
+ ### 13.4 Channel topic/modes lost after a restart
424
504
 
425
505
  `PERSISTENCE_PATH` is unset or the volume is not attached. Confirm
426
506
  `PERSISTENCE_PATH=/data/state.json` in `[vars]` and that the `/data`
@@ -428,13 +508,13 @@ volume (`VOLUME ["/data"]` in the `Dockerfile`) is mounted on the
428
508
  container platform. Without it the snapshot is written to ephemeral
429
509
  storage and lost.
430
510
 
431
- ### 13.4 Client IP seen as Spectrum's address
511
+ ### 13.5 Client IP seen as Spectrum's address
432
512
 
433
513
  `proxy_protocol = "v1"` must be set in `spectrum.tf` (it is by default).
434
514
  If you overrode it, the origin's per-IP admission gate sees Spectrum's
435
515
  egress IP for every client and will wrongly throttle/cap.
436
516
 
437
- ### 13.5 Messages split across chunks get lost
517
+ ### 13.6 Messages split across chunks get lost
438
518
 
439
519
  This is a transport-layer bug, not a config issue — the
440
520
  `TcpByteStreamTransport` is contract-tested for chunk boundaries
@@ -442,7 +522,7 @@ This is a transport-layer bug, not a config issue — the
442
522
  `pnpm --filter @serverless-ircd/cf-tcp-container test` and the shared
443
523
  transport suite; a regression there will fail.
444
524
 
445
- ### 13.6 `wrangler deploy` fails: `Containers beta not enabled`
525
+ ### 13.7 `wrangler deploy` fails: `Containers beta not enabled`
446
526
 
447
527
  Cloudflare Containers is in beta. Request access in the dashboard
448
528
  (Workers & Pages → Containers). Until granted, only the local Docker
@@ -457,16 +537,19 @@ path (§4) and the wss transport work.
457
537
  npx wrangler login # or set CLOUDFLARE_API_TOKEN
458
538
  terraform version # >= 1.5
459
539
 
460
- # Local dev (plaintext :6667, no Spectrum)
540
+ # Local dev (TLS origin on :6697 with a local cert pair)
461
541
  pnpm build
462
- pnpm --filter @serverless-ircd/cf-tcp-container start
542
+ TLS_CERT_PATH=./certs/tls.pem TLS_KEY_PATH=./certs/tls.key \
543
+ pnpm --filter @serverless-ircd/cf-tcp-container start
544
+ # …or the localhost-only plaintext debug listener:
545
+ DEBUG_PLAINTEXT=localhost pnpm --filter @serverless-ircd/cf-tcp-container start
463
546
 
464
547
  # Deploy (single target — staging vs prod is which account you point at)
465
548
  pnpm deploy:cf-tcp # container
466
549
  cd apps/cf-tcp-container/terraform && \
467
550
  terraform apply -var zone_id=$CF_ZONE_ID \
468
551
  -var origin_address=$CF_TCP_ORIGIN_ADDRESS \
469
- -var hostname=irc.example.com # Spectrum
552
+ -var hostname=irc.example.com # Spectrum (tls_mode=full)
470
553
 
471
554
  # Tests
472
555
  pnpm --filter @serverless-ircd/cf-tcp-container test
@@ -477,10 +560,10 @@ Key files:
477
560
  | Path | What |
478
561
  |-------------------------------------------------|--------------------------------------------|
479
562
  | `apps/cf-tcp-container/src/main.ts` | Container entry point. |
480
- | `apps/cf-tcp-container/src/container-server.ts` | TCP server + actor wiring + persistence. |
481
- | `apps/cf-tcp-container/src/config-loader.ts` | Env → `ServerConfig` + container knobs. |
563
+ | `apps/cf-tcp-container/src/container-server.ts` | TCP/TLS listeners + actor wiring + persistence. |
564
+ | `apps/cf-tcp-container/src/config-loader.ts` | Env → `ServerConfig` + container knobs (TLS + plaintext-debug gates). |
482
565
  | `apps/cf-tcp-container/src/persistence.ts` | Channel-topology snapshot/restore. |
483
566
  | `apps/cf-tcp-container/wrangler.toml` | Container deploy config. |
484
- | `apps/cf-tcp-container/Dockerfile` | Image (node:24, `/data` volume, :6667). |
485
- | `apps/cf-tcp-container/terraform/spectrum.tf` | Spectrum TLS :6697 → origin :6667. |
567
+ | `apps/cf-tcp-container/Dockerfile` | Image (node:24, `/data` volume, :6697, UID 1000). |
568
+ | `apps/cf-tcp-container/terraform/spectrum.tf` | Spectrum TLS :6697 → origin TLS :6697 (`full`). |
486
569
  | `.github/workflows/deploy-cf-tcp.yml` | Staging deploy + Spectrum + smoke CI. |
@@ -0,0 +1,175 @@
1
+ # SASL EXTERNAL (CertFP) — certificate-to-account binding
2
+
3
+ Operator and contributor reference for how `AUTHENTICATE EXTERNAL`
4
+ (ServerlessIRCd's mTLS-backed SASL mechanism) turns a verified client
5
+ certificate into a logged-in NickServ account.
6
+
7
+ Cross-reference: the `MtlsIdentityProvider` port and
8
+ `ServicesStore.verifyCertFP` in `packages/irc-core/src/ports.ts`, the
9
+ canonicaliser in `packages/irc-core/src/certfp.ts`, the SASL EXTERNAL
10
+ reducer in `packages/irc-core/src/commands/sasl.ts`, and
11
+ `extractMtlsSubject` in `packages/cf-adapter/src/connection-do.ts`.
12
+
13
+ ---
14
+
15
+ ## 1. The chosen identifier
16
+
17
+ Account binding uses, in preference order:
18
+
19
+ 1. **DER SHA-256 fingerprint (primary).** When the platform surfaces a
20
+ SHA-256 fingerprint of the certificate's DER encoding, it is the
21
+ account binding. Stored as `fp:<hex>` (lowercase, colon-free) in the
22
+ nick row's `certSubjects` list. A fingerprint binds **this exact
23
+ certificate** — a CA re-issuing a different cert with the same subject
24
+ does NOT map to the account, and two semantically-identical certs with
25
+ different DER do not collide.
26
+ 2. **Canonical DN (fallback).** On platforms that surface only the
27
+ certificate subject DN, the DN is canonicalised (see §3) and compared
28
+ as the binding. Subject-bound accounts accept any certificate whose
29
+ canonical DN matches — the Atheme-style `CertFP` behaviour.
30
+
31
+ ### Platform availability (verified)
32
+
33
+ | Platform | Fields surfaced | Identifier used |
34
+ | --- | --- | --- |
35
+ | Cloudflare Workers (API Shield mTLS) | `request.cf.tlsClientAuthCertSubject` (legacy documented field) and `request.cf.tlsClientAuth.certSubjectDN`, **plus** `request.cf.tlsClientAuth.certFingerprintSHA256` (lowercase-hex SHA-256 of the DER; `""` on non-mTLS connections) | **Fingerprint** when non-empty, canonical DN otherwise |
36
+ | AWS API Gateway (custom-domain mTLS) | `requestContext.identity.clientCertSubjectDN` only — no fingerprint, no DER | **Canonical DN** |
37
+
38
+ Neither platform hands the raw DER to the WebSocket handler directly
39
+ (Cloudflare does expose the leaf cert RFC 9440-encoded under
40
+ `cf.tlsClientAuth.certRFC9440`, but the platform-computed
41
+ `certFingerprintSHA256` is strictly simpler and equivalent for binding,
42
+ and is preferred). The fingerprint therefore never needs to be computed
43
+ in-process; `certfp.ts` only normalises the platform-supplied hex
44
+ (lowercase, colon-stripping) so `AA:11` and `aa11` are the same binding.
45
+
46
+ ## 2. Flow
47
+
48
+ ```
49
+ TLS handshake (edge terminates, validates client cert against CA store)
50
+ └─ adapter admission: extractMtlsSubject(request) → CertIdentity
51
+ { subject: raw string,
52
+ canonicalDn: canonicalizeCertSubject(subject),
53
+ fingerprint?: platform SHA-256 hex }
54
+ └─ stored per-connection, surfaced via MtlsIdentityProvider.getIdentity(connId)
55
+ client: CAP REQ sasl → AUTHENTICATE EXTERNAL → AUTHENTICATE +
56
+ └─ sasl reducer: identity = provider.getIdentity(connId)
57
+ └─ ServicesStore.verifyCertFP(identity):
58
+ arm 1: fp:<hex> byte-exact match against certSubjects (primary)
59
+ arm 2: canonical-DN match against DN entries (fallback)
60
+ └─ 900 RPL_LOGGEDIN + 903 RPL_SASLSUCCESS, or 904 ERR_SASLFAIL
61
+ ```
62
+
63
+ ## 2a. Operator opt-in — the three-way gate
64
+
65
+ EXTERNAL is only advertised (in the `sasl` cap value and the `908
66
+ ERR_SASLMECHS` list) and only accepted when **all three** of these hold:
67
+
68
+ 1. **An mTLS identity source is bound** for the deployment (CF API Shield
69
+ client certs / AWS APIGW custom-domain mTLS — the
70
+ `MtlsIdentityProvider` port returns an identity for the connection).
71
+ 2. **The operator opted in**: set `EXTERNAL_ENABLED=true` (or `1`) in the
72
+ CF Worker vars / Lambda environment. The adapters map it to
73
+ `sasl.externalEnabled` in the parsed server config; unset or any other
74
+ value keeps the mechanism off (a typo never silently enables it).
75
+ 3. **The connection is secure (TLS)** — the client-cert identity only
76
+ carries meaning over a transport the server trusts (the NLB+TLS
77
+ secure-connection flag on the AWS path, the always-TLS edge on CF).
78
+
79
+ If any one of the three is missing, `AUTHENTICATE EXTERNAL` is refused
80
+ with `908 ERR_SASLMECHS` listing `PLAIN` only, and the refusal does not
81
+ count toward the per-connection SASL failure lockout (it is a
82
+ protocol-level mechanism rejection, not a failed credential attempt).
83
+
84
+ ### Transport availability — the cf-tcp-container exclusion
85
+
86
+ The three-way gate is the *generic* refusal path. One transport
87
+ additionally pins the mechanism off explicitly: the **cf-tcp-container**
88
+ origin (Cloudflare Spectrum → Container, the Cloudflare
89
+ `irc+tls :6697` path). Spectrum forwards with PROXY protocol v1, which
90
+ carries no client-cert subject, and although `tls_mode = "full"`
91
+ terminates TLS at the origin itself, no client-certificate trust pool
92
+ is wired through to the IRC core — there is no `MtlsIdentityProvider`
93
+ to feed, so EXTERNAL can never succeed there regardless of the Spectrum
94
+ TLS mode. Decision (documented, option *explicit non-support*): rather
95
+ than letting `AUTHENTICATE EXTERNAL` fail generically at the gate, the
96
+ container sets the `sasl.externalUnsupportedMessage` transport
97
+ override, so:
98
+
99
+ - the `sasl` cap value and the `908 ERR_SASLMECHS` list advertise
100
+ `PLAIN` only, and
101
+ - `AUTHENTICATE EXTERNAL` is rejected up front with `904 ERR_SASLFAIL`
102
+ carrying the stable message
103
+ `SASL EXTERNAL not supported on this transport`.
104
+
105
+ The refusal does not count toward the per-connection SASL failure
106
+ lockout (a transport capability statement, not a failed credential
107
+ attempt). The override wins even if `EXTERNAL_ENABLED` is set — a
108
+ transport without an mTLS identity source must not half-accept the
109
+ exchange. Extracting the client-cert subject from PROXY-protocol-v2
110
+ TLVs (which Spectrum can emit) is the documented follow-up if demand
111
+ materialises; until then SASL EXTERNAL is available on the **wss**
112
+ (Cloudflare Workers + API Shield mTLS) and **NLB+TLS** (AWS API Gateway
113
+ custom-domain mTLS) paths only.
114
+
115
+ ## 3. DN canonicalisation (RFC 4514-grounded)
116
+
117
+ Subject DNs are compared after canonicalisation
118
+ (`canonicalizeCertSubject` in `packages/irc-core/src/certfp.ts`):
119
+
120
+ 1. Attribute **types** are lowercased (`CN=` → `cn=`); attribute
121
+ **values** keep their case and compare **case-sensitively**.
122
+ 2. Whitespace around types/values is trimmed; runs of unescaped
123
+ whitespace inside a value collapse to a single space. Escaped
124
+ whitespace (e.g. a trailing `\ `) is preserved verbatim.
125
+ 3. RDNs (`a,b`) and the AVAs of a multi-valued RDN (`a+b`) are sorted, so
126
+ two semantically-equal DNs written in different orders or spacing
127
+ collapse to one canonical spelling:
128
+ `CN=alice , O = Example Inc` ≡ `O=Example Inc,CN=alice`.
129
+ 4. RFC 4514 escapes (`\,`, `\+`, `\=` …) pass through intact — the
130
+ canonicaliser never splits on an escaped separator.
131
+
132
+ Two certs whose DNs differ only in spacing/ordering therefore map to the
133
+ same account; DNs that differ in **value case** do not.
134
+
135
+ ## 4. First-use migration of legacy bindings
136
+
137
+ Accounts created before this scheme stored the platform subject string
138
+ verbatim. `verifyCertFP` handles them transparently:
139
+
140
+ - On a canonical-DN arm match, a stored legacy spelling is **rewritten**
141
+ to its canonical form in the nick row.
142
+ - When the platform supplied a fingerprint, the `fp:<hex>` binding is
143
+ **pinned** alongside the DN entry, so subsequent verifies hit the
144
+ fingerprint arm.
145
+ - The `PersistentServicesStore` subclass re-persists the nick row on
146
+ both rewrites (write-behind `upsertNick`), so the migration survives a
147
+ backend reload. No operator action is required; there is no separate
148
+ migration script.
149
+
150
+ ## 5. Attaching bindings
151
+
152
+ - `ServicesStore.addCertFingerprint(nick, fp)` — attach a fingerprint
153
+ binding (normalised to `fp:<hex>`; idempotent).
154
+ - `ServicesStore.addCertFP(nick, dn)` — attach a subject-DN binding
155
+ (canonicalised at verify time; idempotent).
156
+ - **These are the binding seam.** Both are programmatic store APIs
157
+ today — persistence is the `certSubjects` JSON column on the D1
158
+ `nickserv_accounts` row (added by the store's idempotent
159
+ `ALTER TABLE ... ADD COLUMN cert_subjects` runtime migration) and the
160
+ `certSubjects` list attribute on the DynamoDB `Services` item
161
+ (schemaless; no table migration needed). The NickServ `CERT` command
162
+ (Atheme parity) that surfaces them to users is the documented
163
+ follow-up: it will call exactly these two mutators, nothing else.
164
+
165
+ ## 6. Failure modes
166
+
167
+ | Condition | Result |
168
+ | --- | --- |
169
+ | `EXTERNAL_ENABLED` unset/false (default) | EXTERNAL refused at mechanism phase (`908`, `PLAIN` only) — flag off means off |
170
+ | Insecure (non-TLS) connection | EXTERNAL refused at mechanism phase (`908`) even with mTLS + flag on |
171
+ | No mTLS configured (no `MtlsIdentityProvider` bound) | EXTERNAL refused at mechanism phase (`908`) |
172
+ | cf-tcp-container transport (`sasl.externalUnsupportedMessage` override) | up-front `904 ERR_SASLFAIL` — "SASL EXTERNAL not supported on this transport" (not counted toward the lockout) |
173
+ | Cert presented but no account carries a matching fingerprint or canonical DN | `904 ERR_SASLFAIL` ("untrusted certificate") |
174
+ | Fingerprint bound to account A, DN bound to account B | fingerprint arm wins — the verify returns account A |
175
+ | Platform surfaces no fingerprint (API Gateway) | DN arm only; binding is DN-scoped |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serverless-ircd",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
6
6
  "license": "BSD-3-Clause",
@@ -17,7 +17,7 @@
17
17
  "typescript": "^5.9.3",
18
18
  "vite": "^7.3.6",
19
19
  "vitest": "^4.1.10",
20
- "@serverless-ircd/aws-adapter": "0.10.0"
20
+ "@serverless-ircd/aws-adapter": "0.11.0"
21
21
  },
22
22
  "scripts": {
23
23
  "build": "turbo run build",
@@ -32,7 +32,7 @@
32
32
  "mutation:commands": "pnpm --filter @serverless-ircd/irc-core mutation:commands",
33
33
  "mutation": "pnpm run mutation:protocol && pnpm run mutation:commands",
34
34
  "clean": "turbo run clean && rimraf node_modules",
35
- "deploy:cf": "pnpm --filter @serverless-ircd/cf-worker run deploy",
35
+ "deploy:cf": "pnpm build && pnpm --filter @serverless-ircd/cf-worker run deploy",
36
36
  "smoke:cf": "pnpm --filter @serverless-ircd/cf-worker run smoke",
37
37
  "deploy:cf-tcp": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container run deploy",
38
38
  "deploy:aws": "pnpm build && pnpm --filter @serverless-ircd/aws-stack run deploy",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-adapter",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "AWS Lambda + DynamoDB adapter: AwsRuntime implementing IrcRuntime + $connect/$disconnect/$default handlers",
6
6
  "license": "BSD-3-Clause",
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Pure admission policy for the AWS `$connect` handler.
3
3
  *
4
- * The I/O (counting live rows in DynamoDB) lives in `handlers/connect.ts`;
5
- * the *decision* "does this count fit under the caps?" — is a pure
6
- * function extracted here so it is trivial to unit-test every branch
7
- * without standing up DynamoDB Local.
4
+ * The I/O (counting live rows in DynamoDB) lives in `handlers/connect.ts`
5
+ * (`ip-admission.ts` for the per-IP walk, `connection-counter.ts` for the
6
+ * global counter); the *decision* "do these counts fit under the caps?"
7
+ * is a pure function extracted here so it is trivial to unit-test every
8
+ * branch without standing up DynamoDB Local.
8
9
  *
9
10
  * This is deliberately separate from irc-core's `decideAdmission` /
10
11
  * `AdmissionStats`: that helper is an in-memory counter design that
@@ -17,25 +18,30 @@
17
18
  /**
18
19
  * Live connection counts gathered from DynamoDB.
19
20
  *
20
- * - `total` is always supplied (a paginated `Scan COUNT` over
21
+ * - `total` is always supplied (the atomic meta-row counter over all
21
22
  * `Connections`).
22
- * - `perIp` is optional: counting connections per source IP needs a
23
- * GSI on `sourceIp` (see `handlers/connect.ts` for the deferred
24
- * per-IP follow-up). Until that lands, callers omit `perIp` and the
25
- * per-IP branch of {@link decideConnectAdmission} is skipped.
23
+ * - `perIp` / `recentPerIp` are supplied when the connecting client's
24
+ * source IP is known: `perIp` counts the IP's live connections (the
25
+ * `sourceIp` GSI) and `recentPerIp` counts those established inside
26
+ * the `perIpConnectionRate` window. Callers omit both when the
27
+ * event carries no source IP; the per-IP branches are then skipped.
26
28
  */
27
29
  export interface ConnectionCounts {
28
30
  readonly total: number;
29
31
  readonly perIp?: number;
32
+ readonly recentPerIp?: number;
30
33
  }
31
34
 
32
35
  /**
33
- * Effective caps sourced from {@link ParsedServerConfig}. `perIp` is
34
- * optional because per-IP enforcement is not yet wired on the AWS path.
36
+ * Effective caps sourced from {@link ParsedServerConfig}. The per-IP
37
+ * caps are optional because they only bind when the corresponding count
38
+ * is also available (see {@link ConnectionCounts}).
35
39
  */
36
40
  export interface AdmissionLimits {
37
41
  readonly maxClients: number;
38
42
  readonly maxConnectionsPerIp?: number;
43
+ /** Per-IP connection-rate budget (`perIpConnectionRate`). */
44
+ readonly perIpRate?: { readonly max: number; readonly windowMs: number };
39
45
  }
40
46
 
41
47
  /**
@@ -53,8 +59,10 @@ export type AdmissionOutcome =
53
59
  * ambient state.
54
60
  *
55
61
  * Precedence: the global `maxClients` cap is checked first (it is the
56
- * cheap, always-available gate), then the optional per-IP cap when both
57
- * the limit and the `perIp` count are present.
62
+ * cheap, always-available gate), then the optional per-IP simultaneous
63
+ * cap, then the optional per-IP rate window matching the per-cap
64
+ * ordering of irc-core's single-process `decideAdmission` (global first
65
+ * here because its count is O(1)-available, unlike there).
58
66
  */
59
67
  export function decideConnectAdmission(
60
68
  counts: ConnectionCounts,
@@ -70,5 +78,12 @@ export function decideConnectAdmission(
70
78
  ) {
71
79
  return { admitted: false, statusCode: 429, reason: 'too many connections from this IP' };
72
80
  }
81
+ if (
82
+ limits.perIpRate !== undefined &&
83
+ counts.recentPerIp !== undefined &&
84
+ counts.recentPerIp >= limits.perIpRate.max
85
+ ) {
86
+ return { admitted: false, statusCode: 429, reason: 'connection rate exceeded for this IP' };
87
+ }
73
88
  return { admitted: true };
74
89
  }