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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-adapter",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
6
6
  "license": "BSD-3-Clause",
@@ -162,6 +162,30 @@ export class CfRuntime implements IrcRuntime {
162
162
  * round-trip for the enumeration phase.
163
163
  */
164
164
  async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
165
+ await this.fanOutByUserMode(lines, except, 'wallops');
166
+ }
167
+
168
+ /**
169
+ * Oper-only global notice fan-out (e.g. the OPER reducer's per-IP
170
+ * lockout notice). Identical enumeration to {@link broadcastWallops};
171
+ * the recipient gate is user mode `+o` instead of `+w`.
172
+ */
173
+ async broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void> {
174
+ await this.fanOutByUserMode(lines, except, 'oper');
175
+ }
176
+
177
+ /**
178
+ * Shared fan-out behind {@link broadcastWallops} and
179
+ * {@link broadcastOperNotice}: enumerate every connId across all
180
+ * registry shards, fetch each candidate's state, gate on the requested
181
+ * user mode, and deliver via the ConnectionDO `deliver` RPC.
182
+ * `except` (the originator) and the bound connection are skipped.
183
+ */
184
+ private async fanOutByUserMode(
185
+ lines: RawLine[],
186
+ except: ConnId | undefined,
187
+ mode: 'wallops' | 'oper',
188
+ ): Promise<void> {
165
189
  // Phase 1 — enumerate every connId across all registry shards.
166
190
  const shardEntries = await Promise.all(
167
191
  Array.from({ length: this.registryShards }, (_, i) => this.registryShardRpc(i).listEntries()),
@@ -173,18 +197,19 @@ export class CfRuntime implements IrcRuntime {
173
197
  }
174
198
  }
175
199
 
176
- // Phase 2 — fetch each candidate's state, filter +w, deliver.
200
+ // Phase 2 — fetch each candidate's state, filter by mode, deliver.
177
201
  // Bound the per-call fan-out by skipping the excepted/bound connection
178
- // up front (the bound connection's own +w never needs a self-RPC).
202
+ // up front (the bound connection's own mode never needs a self-RPC).
179
203
  await Promise.all(
180
204
  Array.from(seen, async (connId) => {
181
205
  if (connId === except || connId === this.connId) return;
182
206
  const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(connId));
183
207
  const dto = await (stub as unknown as ConnectionRpc).getConnState();
184
- await this.deliverWallopsToCandidate(
208
+ await this.deliverToCandidateByMode(
185
209
  stub as unknown as { deliver(lines: RawLine[]): Promise<unknown> },
186
210
  dto,
187
211
  lines,
212
+ mode,
188
213
  );
189
214
  }),
190
215
  );
@@ -322,11 +347,11 @@ export class CfRuntime implements IrcRuntime {
322
347
  /**
323
348
  * Phase 2 of {@link broadcastWallops}: rehydrates the candidate's
324
349
  * persisted state, gates on user mode `+w`, and dispatches `lines`
325
- * via the target stub's `deliver` RPC. Extracted as a public test
326
- * seam because every real ConnectionDO's `getConnState` always
327
- * returns a non-null DTO (the storage layer is the source of truth
328
- * and always has an entry post-`loadState`), so the `dto === null`
329
- * and `!+w` defensive arms are unreachable through end-to-end RPC.
350
+ * via the target stub's `deliver` RPC. Kept as a public test seam
351
+ * because every real ConnectionDO's `getConnState` always returns a
352
+ * non-null DTO (the storage layer is the source of truth and always
353
+ * has an entry post-`loadState`), so the `dto === null` and `!+w`
354
+ * defensive arms are unreachable through end-to-end RPC.
330
355
  *
331
356
  * Behavioural contract:
332
357
  * - `dto === null` → no-op (candidate reported no state).
@@ -337,10 +362,24 @@ export class CfRuntime implements IrcRuntime {
337
362
  stub: { deliver(lines: RawLine[]): Promise<unknown> },
338
363
  dto: PersistedConnectionState | null,
339
364
  lines: RawLine[],
365
+ ): Promise<void> {
366
+ await this.deliverToCandidateByMode(stub, dto, lines, 'wallops');
367
+ }
368
+
369
+ /**
370
+ * Mode-parameterised gate behind {@link deliverWallopsToCandidate} and
371
+ * {@link fanOutByUserMode}: rehydrate the DTO, check the requested
372
+ * user mode (`+w` wallops / `+o` oper), deliver when it is set.
373
+ */
374
+ private async deliverToCandidateByMode(
375
+ stub: { deliver(lines: RawLine[]): Promise<unknown> },
376
+ dto: PersistedConnectionState | null,
377
+ lines: RawLine[],
378
+ mode: 'wallops' | 'oper',
340
379
  ): Promise<void> {
341
380
  if (dto === null) return;
342
381
  const state = deserialize(dto);
343
- if (!state.userModes.wallops) return;
382
+ if (!state.userModes[mode]) return;
344
383
  await stub.deliver(lines);
345
384
  }
346
385
 
@@ -13,6 +13,15 @@
13
13
 
14
14
  import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
15
15
 
16
+ /**
17
+ * Documented schema defaults for `perIpConnectionRate`. Duplicated here
18
+ * (the schema inlines its `.default(...)`) so an operator setting only
19
+ * one half of the knob via env gets the documented default for the other
20
+ * half rather than a parse error.
21
+ */
22
+ const DEFAULT_PER_IP_RATE_MAX = 5;
23
+ const DEFAULT_PER_IP_RATE_WINDOW_MS = 60_000;
24
+
16
25
  /**
17
26
  * The subset of the Workers `Env` that this loader reads. Mirrors the
18
27
  * production {@link Env} in `env.ts` plus the optional knobs the schema
@@ -38,6 +47,20 @@ export interface CfConfigEnv {
38
47
  CHANNEL_PREFIXES?: string;
39
48
  OPER_USER?: string;
40
49
  OPER_PASSWORD?: string;
50
+ /**
51
+ * Scrypt salt (base64) for the hashed oper credential form. When set
52
+ * alongside `OPER_USER` + `OPER_HASH`, the loader builds a hashed
53
+ * `{user, salt, hash}` cred instead of the legacy plaintext
54
+ * `{user, password}` shape. The hashed form is preferred when both
55
+ * are present so deployments can rotate without removing the legacy
56
+ * vars in the same step. Generate via `tools/hash-oper-cred.ts`.
57
+ */
58
+ OPER_SALT?: string;
59
+ /**
60
+ * Scrypt hash (base64) for the hashed oper credential form. See
61
+ * `OPER_SALT` for the full set of vars.
62
+ */
63
+ OPER_HASH?: string;
41
64
  /**
42
65
  * Server-password gate. Treat as a wrangler secret in production
43
66
  * (never a plaintext `[vars]` entry); the loader threads it through
@@ -59,6 +82,40 @@ export interface CfConfigEnv {
59
82
  * path (vs the connection-do's direct env read) stays consistent.
60
83
  */
61
84
  SASL_ACCOUNTS?: string;
85
+ /**
86
+ * Per-connection inbound frame window ceiling enforced by
87
+ * `ConnectionDO.webSocketMessage` BEFORE the actor / storage write.
88
+ * Parsed into `ServerConfig.adapter.maxFramesPerWindow`.
89
+ */
90
+ MAX_FRAMES_PER_WINDOW?: string;
91
+ /**
92
+ * Sliding-window length (seconds) for the inbound frame limit; frames
93
+ * older than the window stop counting. Parsed into
94
+ * `ServerConfig.adapter.frameWindowSeconds`.
95
+ */
96
+ FRAME_WINDOW_SECONDS?: string;
97
+ /**
98
+ * Per-IP simultaneous-connection cap (`maxConnectionsPerIp`). Enforced
99
+ * by the adapters' admission gates; mirrors the Lambda loader's var of
100
+ * the same name.
101
+ */
102
+ MAX_CONNECTIONS_PER_IP?: string;
103
+ /**
104
+ * Per-IP connection-rate budget (`perIpConnectionRate.max`) — how many
105
+ * new connections one source IP may open per sliding window. The unset
106
+ * half of the knob falls back to the schema default.
107
+ */
108
+ PER_IP_CONNECTION_RATE_MAX?: string;
109
+ /** Sliding window length in ms (`perIpConnectionRate.windowMs`). */
110
+ PER_IP_CONNECTION_RATE_WINDOW_MS?: string;
111
+ /**
112
+ * Operator opt-in for certificate-backed SASL EXTERNAL. 'true'/'1'
113
+ * (case-insensitive) map to `sasl.externalEnabled: true`; any other
114
+ * value (and unset) leaves the mechanism off. EXTERNAL additionally
115
+ * requires edge mTLS (API Shield client certs) and a secure (TLS)
116
+ * connection — the flag alone advertises nothing.
117
+ */
118
+ EXTERNAL_ENABLED?: string;
62
119
  }
63
120
 
64
121
  /**
@@ -91,14 +148,36 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
91
148
  input.channelPrefixes = env.CHANNEL_PREFIXES;
92
149
  }
93
150
  if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
94
- // Build the cred object from whatever was supplied; the schema
95
- // surfaces a `user`/`password` error if only one half was provided.
96
- input.operCreds = [
97
- {
98
- user: env.OPER_USER ?? '',
99
- password: env.OPER_PASSWORD ?? '',
100
- },
101
- ];
151
+ // Hashed form (OPER_USER + OPER_SALT + OPER_HASH) is preferred when
152
+ // both salt + hash are supplied — even if OPER_PASSWORD is also set,
153
+ // so a deployment can rotate to the hashed form without dropping the
154
+ // legacy var in the same release. The legacy plaintext
155
+ // `{user, password}` shape is retained for a single deprecation-
156
+ // cycle window.
157
+ const hasHashed = env.OPER_SALT !== undefined && env.OPER_HASH !== undefined;
158
+ if (hasHashed) {
159
+ input.operCreds = [
160
+ {
161
+ user: env.OPER_USER ?? '',
162
+ salt: env.OPER_SALT ?? '',
163
+ hash: env.OPER_HASH ?? '',
164
+ },
165
+ ];
166
+ } else {
167
+ // Build the legacy cred object from whatever was supplied; the
168
+ // schema surfaces a `user`/`password` error if only one half was
169
+ // provided. If only OPER_SALT/OPER_HASH (partial) are set, the
170
+ // schema will surface the missing hashed-form field via the
171
+ // superRefine error path.
172
+ input.operCreds = [
173
+ {
174
+ user: env.OPER_USER ?? '',
175
+ password: env.OPER_PASSWORD ?? '',
176
+ ...(env.OPER_SALT !== undefined ? { salt: env.OPER_SALT } : {}),
177
+ ...(env.OPER_HASH !== undefined ? { hash: env.OPER_HASH } : {}),
178
+ },
179
+ ];
180
+ }
102
181
  }
103
182
  if (env.SERVER_PASSWORD !== undefined) {
104
183
  input.serverPassword = env.SERVER_PASSWORD;
@@ -127,9 +206,55 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
127
206
  if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
128
207
  input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
129
208
  }
209
+ if (env.MAX_FRAMES_PER_WINDOW !== undefined || env.FRAME_WINDOW_SECONDS !== undefined) {
210
+ // Partial sections are fine: the per-field schema defaults fill the
211
+ // knob(s) the env did not supply.
212
+ input.adapter = {
213
+ ...(env.MAX_FRAMES_PER_WINDOW !== undefined
214
+ ? { maxFramesPerWindow: Number.parseInt(env.MAX_FRAMES_PER_WINDOW, 10) }
215
+ : {}),
216
+ ...(env.FRAME_WINDOW_SECONDS !== undefined
217
+ ? { frameWindowSeconds: Number.parseInt(env.FRAME_WINDOW_SECONDS, 10) }
218
+ : {}),
219
+ };
220
+ }
221
+ if (env.MAX_CONNECTIONS_PER_IP !== undefined) {
222
+ input.maxConnectionsPerIp = Number.parseInt(env.MAX_CONNECTIONS_PER_IP, 10);
223
+ }
224
+ if (
225
+ env.PER_IP_CONNECTION_RATE_MAX !== undefined ||
226
+ env.PER_IP_CONNECTION_RATE_WINDOW_MS !== undefined
227
+ ) {
228
+ // The schema requires both halves; the unset one falls back to the
229
+ // documented schema defaults so an operator can tune just the budget
230
+ // (or just the window) without restating the other.
231
+ input.perIpConnectionRate = {
232
+ max:
233
+ env.PER_IP_CONNECTION_RATE_MAX !== undefined
234
+ ? Number.parseInt(env.PER_IP_CONNECTION_RATE_MAX, 10)
235
+ : DEFAULT_PER_IP_RATE_MAX,
236
+ windowMs:
237
+ env.PER_IP_CONNECTION_RATE_WINDOW_MS !== undefined
238
+ ? Number.parseInt(env.PER_IP_CONNECTION_RATE_WINDOW_MS, 10)
239
+ : DEFAULT_PER_IP_RATE_WINDOW_MS,
240
+ };
241
+ }
242
+ if (env.EXTERNAL_ENABLED !== undefined) {
243
+ input.sasl = { externalEnabled: parseEnabledFlag(env.EXTERNAL_ENABLED) };
244
+ }
130
245
  return input;
131
246
  }
132
247
 
248
+ /**
249
+ * Parses a boolean feature-flag env var: 'true' / '1' (case-insensitive,
250
+ * whitespace-trimmed) are truthy; every other value is false. Flags
251
+ * default off, so a typo must never silently enable the feature.
252
+ */
253
+ function parseEnabledFlag(raw: string): boolean {
254
+ const normalized = raw.trim().toLowerCase();
255
+ return normalized === 'true' || normalized === '1';
256
+ }
257
+
133
258
  /**
134
259
  * Parses the `CREATED_AT` env var into the schema's `createdAt` field.
135
260
  *
@@ -29,12 +29,15 @@
29
29
 
30
30
  import { DurableObject } from 'cloudflare:workers';
31
31
  import {
32
+ type CertIdentity,
32
33
  type ChanName,
33
34
  type ChannelState,
34
35
  type Clock,
35
36
  type ConnId,
36
37
  type ConnectionState,
37
38
  ConsoleLogger,
39
+ FRAME_FLOOD_CLOSE_CODE,
40
+ FRAME_FLOOD_ERROR_LINE,
38
41
  type IdFactory,
39
42
  InMemoryMessageStore,
40
43
  InMemoryMtlsIdentityProvider,
@@ -51,6 +54,8 @@ import {
51
54
  SystemClock,
52
55
  UuidIdFactory,
53
56
  type WsFrameMode,
57
+ admitFrameWindow,
58
+ certIdentityFromSubject,
54
59
  createChannel,
55
60
  createConnection,
56
61
  frameToLines,
@@ -65,8 +70,9 @@ import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
65
70
  import { makeCfRuntime } from './cf-runtime.js';
66
71
  import type { CfConnectionHandlers } from './cf-runtime.js';
67
72
  import { loadServerConfigFromCfEnv } from './config-loader.js';
73
+ import { COUNTER_INSTANCE_NAME } from './counter-do.js';
68
74
  import { type D1ServicesStore, loadD1ServicesStore } from './d1-services-store.js';
69
- import type { Env } from './env.js';
75
+ import type { CounterRpc, Env } from './env.js';
70
76
  import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
71
77
  import type { PersistedConnectionState } from './serialize.js';
72
78
  import { CfStats } from './stats.js';
@@ -155,19 +161,22 @@ export class ConnectionDO extends DurableObject<Env> {
155
161
  /** Guards {@link loadServicesStore} so the D1 scan runs at most once. */
156
162
  private servicesResolved = false;
157
163
  /**
158
- * Verified client-cert subject captured at WebSocket upgrade time when
164
+ * Verified client-cert identity captured at WebSocket upgrade time when
159
165
  * CF API Shield mTLS is configured. Surfaced to the SASL EXTERNAL reducer
160
166
  * via an {@link MtlsIdentityProvider} so `AUTHENTICATE EXTERNAL` can map
161
- * the cert subject to an account.
167
+ * the certificate to an account (DER SHA-256 fingerprint preferred,
168
+ * canonical DN fallback — see `docs/SASL-EXTERNAL.md`).
162
169
  *
163
170
  * CF API Shield mTLS setup:
164
171
  * 1. Upload the client CA to API Shield (dashboard → Security → API Shield).
165
172
  * 2. Create an mTLS policy on the custom hostname that requires client certs.
166
173
  * 3. The verified subject is injected into `request.cf.tlsClientAuthCertSubject`
167
- * on every request, which the Worker reads at upgrade time.
174
+ * (and `request.cf.tlsClientAuth.certFingerprintSHA256` carries the DER
175
+ * SHA-256 fingerprint) on every request, which the Worker reads at
176
+ * upgrade time.
168
177
  * When unset (no mTLS configured), EXTERNAL auth fails with `904`.
169
178
  */
170
- private mtlsCertSubject: string | undefined;
179
+ private mtlsCert: CertIdentity | undefined;
171
180
  /**
172
181
  * Client source IP captured at WebSocket upgrade time, surfaced onto the
173
182
  * {@link ConnectionState.host} field so WHOIS / hostmasks resolve instead
@@ -179,6 +188,23 @@ export class ConnectionDO extends DurableObject<Env> {
179
188
  * this raw IP with a deterministic cloak at registration time.
180
189
  */
181
190
  private sourceHost: string | undefined;
191
+ /**
192
+ * Timestamps of recently admitted inbound frames (the sliding window
193
+ * backing the adapter-boundary rate limit, see `admitFrameWindow`).
194
+ * Kept in memory only: while frames are flowing the DO stays resident
195
+ * (so an active flood is always counted), and after eviction — which
196
+ * requires idleness far longer than the 5 s default window — every
197
+ * retained timestamp would have decayed out anyway. The gate fires
198
+ * BEFORE the actor / storage write so over-limit frames cost neither.
199
+ */
200
+ private inboundFrameTimes: number[] = [];
201
+ /**
202
+ * Test-only override for the inbound frame window (`maxFrames` /
203
+ * `windowMs`), bypassing the env-sourced `adapter` config so the decay
204
+ * behaviour is testable without real multi-second sleeps. `undefined`
205
+ * in production.
206
+ */
207
+ private frameWindowOverride: { maxFrames: number; windowMs: number } | undefined;
182
208
  private readonly clock: Clock = SystemClock;
183
209
  private readonly ids: IdFactory = new UuidIdFactory();
184
210
  private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
@@ -241,6 +267,16 @@ export class ConnectionDO extends DurableObject<Env> {
241
267
  /** Frame → actor → dispatch → persist. */
242
268
  override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
243
269
  const mode = this.wsFrameMode(ws);
270
+ // Adapter-boundary rate limit: consult the sliding window BEFORE any
271
+ // per-frame work (state load, actor run, storage write, D1 flush).
272
+ // An over-limit frame delivers the canonical ERROR line and closes
273
+ // the socket with RFC 6455 close code 1008 (Policy Violation); the
274
+ // normal close handler runs the QUIT teardown.
275
+ if (!this.admitInboundFrame()) {
276
+ this.sendOutbound(ws, [{ text: FRAME_FLOOD_ERROR_LINE }], mode);
277
+ ws.close(FRAME_FLOOD_CLOSE_CODE, 'flooding');
278
+ return;
279
+ }
244
280
  // text.ircv3.net is a text-only subprotocol: a binary frame on such a
245
281
  // connection is a protocol violation. RFC 6455 close code 1003 (unsupported
246
282
  // data) is the spec-mandated response.
@@ -341,6 +377,10 @@ export class ConnectionDO extends DurableObject<Env> {
341
377
  const socket = ws as WebSocket;
342
378
  this.sendOutbound(socket, [{ text: `PING :${token}` }], this.wsFrameMode(socket));
343
379
  }
380
+ // Refresh the admission slot so the counter's lazy TTL reconcile
381
+ // only evicts entries from DOs that stopped heartbeating (crashed or
382
+ // evicted without a close event). No-op for unregistered connections.
383
+ await this.refreshConnectionSlot();
344
384
  await this.ctx.storage.setAlarm(now + this.pingIntervalMs);
345
385
  }
346
386
 
@@ -455,6 +495,37 @@ export class ConnectionDO extends DurableObject<Env> {
455
495
  this.sendOutbound(ws, lines, mode);
456
496
  }
457
497
 
498
+ /**
499
+ * Applies the per-connection inbound frame window to the frame being
500
+ * received: prunes decayed timestamps, records this frame when it
501
+ * fits, and returns whether it was admitted. The window config comes
502
+ * from the schema-validated `adapter` section (default 50 frames /
503
+ * 5 s) unless the test-only override is installed.
504
+ */
505
+ private admitInboundFrame(): boolean {
506
+ const cfg = this.frameWindowOverride ?? {
507
+ maxFrames: this.parsedConfig().adapter.maxFramesPerWindow,
508
+ windowMs: this.parsedConfig().adapter.frameWindowSeconds * 1000,
509
+ };
510
+ const decision = admitFrameWindow(
511
+ this.inboundFrameTimes,
512
+ this.clock.now(),
513
+ cfg.maxFrames,
514
+ cfg.windowMs,
515
+ );
516
+ this.inboundFrameTimes = decision.times;
517
+ return decision.admitted;
518
+ }
519
+
520
+ /**
521
+ * Test-only seam installing a tight inbound frame window (or clearing
522
+ * it with `undefined`) without redeploying the worker env. See
523
+ * {@link frameWindowOverride}.
524
+ */
525
+ public __setFrameWindowForTest(cfg: { maxFrames: number; windowMs: number } | undefined): void {
526
+ this.frameWindowOverride = cfg;
527
+ }
528
+
458
529
  /** Loads the connection state from storage, caching for the event. */
459
530
  private async loadState(): Promise<ConnectionState> {
460
531
  if (this.cached !== undefined) return this.cached;
@@ -516,8 +587,8 @@ export class ConnectionDO extends DurableObject<Env> {
516
587
  const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
517
588
  const services = this.servicesStore();
518
589
  const mtlsIdentity: MtlsIdentityProvider | undefined =
519
- this.mtlsCertSubject !== undefined
520
- ? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.mtlsCertSubject }])
590
+ this.mtlsCert !== undefined
591
+ ? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.mtlsCert }])
521
592
  : undefined;
522
593
  return new ConnectionActor({
523
594
  state,
@@ -588,6 +659,7 @@ export class ConnectionDO extends DurableObject<Env> {
588
659
  await actor.receiveTextFrame('QUIT\r\n');
589
660
  await this.releaseAndPersist(state);
590
661
  await this.flushServices();
662
+ await this.releaseConnectionSlot();
591
663
  this.closeIfStillOpen(ws);
592
664
  try {
593
665
  await this.ctx.storage.deleteAlarm();
@@ -623,6 +695,32 @@ export class ConnectionDO extends DurableObject<Env> {
623
695
  await this.persistState(state);
624
696
  }
625
697
 
698
+ /**
699
+ * Releases this connection's admission slot in the global counter
700
+ * (`maxClients` cap). The key is this DO's hex id — exactly what the
701
+ * worker edge registered at upgrade time (`idFromName(uuid).toString()`),
702
+ * so no id needs to be passed through the upgrade request. Idempotent
703
+ * (deleting an absent key is a no-op): connections opened directly
704
+ * against the DO (integration harness, cross-DO RPC) never registered
705
+ * a slot and tear down harmlessly.
706
+ */
707
+ private async releaseConnectionSlot(): Promise<void> {
708
+ const stub = this.env.COUNTER_DO.get(this.env.COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME));
709
+ await (stub as unknown as CounterRpc).release(this.ctx.id.toString());
710
+ }
711
+
712
+ /**
713
+ * Refreshes the admission slot's `lastSeen` so the counter's lazy TTL
714
+ * reconcile only evicts entries whose ConnectionDO stopped
715
+ * heartbeating (crashed / evicted without delivering a close event).
716
+ * Called from the PING alarm; no-op for connections that never
717
+ * registered a slot.
718
+ */
719
+ private async refreshConnectionSlot(): Promise<void> {
720
+ const stub = this.env.COUNTER_DO.get(this.env.COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME));
721
+ await (stub as unknown as CounterRpc).heartbeat(this.ctx.id.toString());
722
+ }
723
+
626
724
  private serverConfig(): ServerConfig {
627
725
  // Single source of truth: delegate to the schema-validated loader so
628
726
  // every reducer-facing field (operCreds, serverPassword, motdLines,
@@ -789,6 +887,7 @@ export class ConnectionDO extends DurableObject<Env> {
789
887
  await actor.receiveTextFrame('QUIT\r\n');
790
888
  await this.releaseAndPersist(state);
791
889
  await this.flushServices();
890
+ await this.releaseConnectionSlot();
792
891
  try {
793
892
  await this.ctx.storage.deleteAlarm();
794
893
  } catch {
@@ -835,26 +934,26 @@ export class ConnectionDO extends DurableObject<Env> {
835
934
  * test runtime).
836
935
  */
837
936
  /**
838
- * Captures the verified mTLS cert subject off an inbound request and
937
+ * Captures the verified mTLS cert identity off an inbound request and
839
938
  * stores it on the instance. Extracted so the assignment branch is
840
939
  * testable via {@link __captureMtlsSubjectForTest} (the workerd test
841
940
  * runtime strips `request.cf` across the DO RPC boundary, so the
842
941
  * inline fetch path cannot be exercised end-to-end).
843
942
  */
844
943
  private captureMtlsSubject(request: Request): void {
845
- const cf = extractMtlsSubject(request);
846
- if (cf !== undefined) {
847
- this.mtlsCertSubject = cf;
944
+ const identity = extractMtlsSubject(request);
945
+ if (identity !== undefined) {
946
+ this.mtlsCert = identity;
848
947
  }
849
948
  }
850
949
 
851
950
  public __setMtlsCertSubject(subject: string | undefined): void {
852
- this.mtlsCertSubject = subject;
951
+ this.mtlsCert = subject === undefined ? undefined : certIdentityFromSubject(subject);
853
952
  }
854
953
 
855
954
  /** Test-only: returns the captured mTLS cert subject (or undefined). */
856
955
  public async __peekMtlsCertSubject(): Promise<string | undefined> {
857
- return this.mtlsCertSubject;
956
+ return this.mtlsCert?.subject;
858
957
  }
859
958
 
860
959
  /**
@@ -932,22 +1031,56 @@ export class ConnectionDO extends DurableObject<Env> {
932
1031
  const DEFAULT_MOTD: string[] = ['Welcome to the ServerlessIRCd Cloudflare adapter.'];
933
1032
 
934
1033
  /**
935
- * Reads the verified mTLS client-cert subject off an inbound Request, when
1034
+ * Reads the verified mTLS client-cert identity off an inbound Request, when
936
1035
  * CF API Shield mTLS is configured. Extracted as a pure helper so the
937
- * `cf.tlsClientAuthCertSubject` branch is unit-testable directly (the
938
- * workerd test runtime does not surface `request.cf` the way the CF edge
939
- * does, and `Request.cf` is read-only so tests cannot inject it via the
940
- * standard constructor). Returns the subject string when present, or
941
- * `undefined` when mTLS is not configured.
1036
+ * `cf.tlsClientAuthCertSubject` / `cf.tlsClientAuth` branches are
1037
+ * unit-testable directly (the workerd test runtime does not surface
1038
+ * `request.cf` the way the CF edge does, and `Request.cf` is read-only so
1039
+ * tests cannot inject it via the standard constructor).
1040
+ *
1041
+ * Identifier resolution (strongest first):
1042
+ * 1. Subject from `cf.tlsClientAuthCertSubject` (legacy documented field),
1043
+ * falling back to `cf.tlsClientAuth.certSubjectDN` when absent.
1044
+ * 2. DER SHA-256 fingerprint from `cf.tlsClientAuth.certFingerprintSHA256`
1045
+ * when it is a non-empty string (Cloudflare documents it as `""` for
1046
+ * non-mTLS connections).
1047
+ *
1048
+ * Returns the {@link CertIdentity} (raw subject + canonical DN + optional
1049
+ * fingerprint) when a subject was captured, or `undefined` when mTLS is
1050
+ * not configured.
942
1051
  */
943
- export function extractMtlsSubject(request: Request): string | undefined {
1052
+ export function extractMtlsSubject(request: Request): CertIdentity | undefined {
944
1053
  const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
945
- if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
1054
+ if (cf === undefined) return undefined;
1055
+ const subject = readCertSubject(cf);
1056
+ if (subject === undefined) return undefined;
1057
+ return certIdentityFromSubject(subject, readCertFingerprint(cf));
1058
+ }
1059
+
1060
+ /** Reads the cert subject: legacy `tlsClientAuthCertSubject`, then `tlsClientAuth.certSubjectDN`. */
1061
+ function readCertSubject(cf: Record<string, unknown>): string | undefined {
1062
+ if (typeof cf.tlsClientAuthCertSubject === 'string') {
946
1063
  return cf.tlsClientAuthCertSubject;
947
1064
  }
1065
+ const tlsClientAuth = cf.tlsClientAuth;
1066
+ if (isRecord(tlsClientAuth) && typeof tlsClientAuth.certSubjectDN === 'string') {
1067
+ return tlsClientAuth.certSubjectDN;
1068
+ }
948
1069
  return undefined;
949
1070
  }
950
1071
 
1072
+ /** Reads the DER SHA-256 fingerprint off `tlsClientAuth` (empty string = absent). */
1073
+ function readCertFingerprint(cf: Record<string, unknown>): string | undefined {
1074
+ const tlsClientAuth = cf.tlsClientAuth;
1075
+ if (!isRecord(tlsClientAuth)) return undefined;
1076
+ const fp = tlsClientAuth.certFingerprintSHA256;
1077
+ return typeof fp === 'string' && fp.length > 0 ? fp : undefined;
1078
+ }
1079
+
1080
+ function isRecord(v: unknown): v is Record<string, unknown> {
1081
+ return typeof v === 'object' && v !== null;
1082
+ }
1083
+
951
1084
  /**
952
1085
  * No-op transport callbacks used by {@link ConnectionDO.tearDownNoSocket}
953
1086
  * when the WebSocket has already been dropped. Exported so the function