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.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- 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 +17 -4
- package/apps/cf-worker/package.json +2 -2
- 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 +86 -9
- 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 +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- 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 +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- 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-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- 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 +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -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/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- 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/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 +40 -1
- 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 +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- 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 +393 -20
- 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 +8 -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 +395 -36
- 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 +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- 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/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- 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/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 +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- 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 +544 -7
- 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/scripts/package.json +1 -1
- 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 +17 -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-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -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/tcp-ws-forwarder/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-adapter",
|
|
3
|
-
"version": "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
|
|
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
|
|
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.
|
|
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.
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
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
|
|
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
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
520
|
-
? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.
|
|
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
|
|
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
|
|
846
|
-
if (
|
|
847
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
|
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`
|
|
938
|
-
* workerd test runtime does not surface
|
|
939
|
-
* does, and `Request.cf` is read-only so
|
|
940
|
-
*
|
|
941
|
-
*
|
|
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):
|
|
1052
|
+
export function extractMtlsSubject(request: Request): CertIdentity | undefined {
|
|
944
1053
|
const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
|
|
945
|
-
if (cf
|
|
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
|