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
|
@@ -17,6 +17,18 @@ interface CfEnvLike {
|
|
|
17
17
|
CHANNEL_PREFIXES?: string;
|
|
18
18
|
OPER_USER?: string;
|
|
19
19
|
OPER_PASSWORD?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Scrypt salt (base64) for the hashed oper credential form. When set
|
|
22
|
+
* alongside `OPER_USER` + `OPER_HASH`, the loader builds a hashed
|
|
23
|
+
* `{user, salt, hash}` cred instead of the legacy plaintext
|
|
24
|
+
* `{user, password}` shape.
|
|
25
|
+
*/
|
|
26
|
+
OPER_SALT?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Scrypt hash (base64) for the hashed oper credential form. See
|
|
29
|
+
* `OPER_SALT` for the full set of vars.
|
|
30
|
+
*/
|
|
31
|
+
OPER_HASH?: string;
|
|
20
32
|
/**
|
|
21
33
|
* Server-password gate. Treat as a wrangler secret in production
|
|
22
34
|
* (never a plaintext `[vars]` entry); the loader threads it through
|
|
@@ -30,6 +42,8 @@ interface CfEnvLike {
|
|
|
30
42
|
TOPIC_LEN?: string;
|
|
31
43
|
MAX_LIST_ENTRIES?: string;
|
|
32
44
|
QUIT_MESSAGE?: string;
|
|
45
|
+
/** SASL EXTERNAL operator opt-in ('true'/'1' enable; default off). */
|
|
46
|
+
EXTERNAL_ENABLED?: string;
|
|
33
47
|
}
|
|
34
48
|
|
|
35
49
|
function baseEnv(): CfEnvLike {
|
|
@@ -82,6 +96,49 @@ describe('loadServerConfigFromCfEnv — valid env', () => {
|
|
|
82
96
|
expect(cfg.operCreds).toEqual([{ user: 'admin', password: 'hunter2' }]);
|
|
83
97
|
});
|
|
84
98
|
|
|
99
|
+
it('builds a hashed operCred from OPER_USER + OPER_SALT + OPER_HASH', () => {
|
|
100
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
101
|
+
...baseEnv(),
|
|
102
|
+
OPER_USER: 'admin',
|
|
103
|
+
OPER_SALT: 'c2FsdA==',
|
|
104
|
+
OPER_HASH: 'aGFzaA==',
|
|
105
|
+
});
|
|
106
|
+
expect(cfg.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('prefers the hashed form when both OPER_PASSWORD and OPER_HASH/OPER_SALT are set', () => {
|
|
110
|
+
// Hashed form takes precedence so operators can rotate to it without
|
|
111
|
+
// removing the legacy OPER_PASSWORD var in the same deploy.
|
|
112
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
113
|
+
...baseEnv(),
|
|
114
|
+
OPER_USER: 'admin',
|
|
115
|
+
OPER_PASSWORD: 'legacy',
|
|
116
|
+
OPER_SALT: 'c2FsdA==',
|
|
117
|
+
OPER_HASH: 'aGFzaA==',
|
|
118
|
+
});
|
|
119
|
+
expect(cfg.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('throws when OPER_USER + OPER_SALT are set but OPER_HASH is missing', () => {
|
|
123
|
+
expect(() =>
|
|
124
|
+
loadServerConfigFromCfEnv({
|
|
125
|
+
...baseEnv(),
|
|
126
|
+
OPER_USER: 'admin',
|
|
127
|
+
OPER_SALT: 'c2FsdA==',
|
|
128
|
+
}),
|
|
129
|
+
).toThrowError(/hash/u);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('throws when OPER_USER + OPER_HASH are set but OPER_SALT is missing', () => {
|
|
133
|
+
expect(() =>
|
|
134
|
+
loadServerConfigFromCfEnv({
|
|
135
|
+
...baseEnv(),
|
|
136
|
+
OPER_USER: 'admin',
|
|
137
|
+
OPER_HASH: 'aGFzaA==',
|
|
138
|
+
}),
|
|
139
|
+
).toThrowError(/salt/u);
|
|
140
|
+
});
|
|
141
|
+
|
|
85
142
|
it('threads SERVER_PASSWORD through to serverPassword', () => {
|
|
86
143
|
const cfg = loadServerConfigFromCfEnv({
|
|
87
144
|
...baseEnv(),
|
|
@@ -237,3 +294,105 @@ describe('loadServerConfigFromCfEnv — SASL_ACCOUNTS parsing', () => {
|
|
|
237
294
|
expect(cfg.createdAt).toBe(huge);
|
|
238
295
|
});
|
|
239
296
|
});
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Adapter frame-rate limit (per-connection inbound frame window).
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
describe('loadServerConfigFromCfEnv — adapter frame-rate limit', () => {
|
|
303
|
+
it('defaults the adapter frame window when the vars are unset', () => {
|
|
304
|
+
const cfg = loadServerConfigFromCfEnv(baseEnv());
|
|
305
|
+
expect(cfg.adapter).toEqual({ maxFramesPerWindow: 50, frameWindowSeconds: 5 });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('maps MAX_FRAMES_PER_WINDOW + FRAME_WINDOW_SECONDS to the adapter section', () => {
|
|
309
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
310
|
+
...baseEnv(),
|
|
311
|
+
MAX_FRAMES_PER_WINDOW: '10',
|
|
312
|
+
FRAME_WINDOW_SECONDS: '2',
|
|
313
|
+
});
|
|
314
|
+
expect(cfg.adapter).toEqual({ maxFramesPerWindow: 10, frameWindowSeconds: 2 });
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('fills the unset knob with its schema default when only one var is set', () => {
|
|
318
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), MAX_FRAMES_PER_WINDOW: '25' });
|
|
319
|
+
expect(cfg.adapter).toEqual({ maxFramesPerWindow: 25, frameWindowSeconds: 5 });
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('throws when MAX_FRAMES_PER_WINDOW is non-numeric', () => {
|
|
323
|
+
expect(() =>
|
|
324
|
+
loadServerConfigFromCfEnv({ ...baseEnv(), MAX_FRAMES_PER_WINDOW: 'lots' }),
|
|
325
|
+
).toThrowError(/maxFramesPerWindow/u);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('throws when FRAME_WINDOW_SECONDS is non-positive', () => {
|
|
329
|
+
expect(() =>
|
|
330
|
+
loadServerConfigFromCfEnv({ ...baseEnv(), FRAME_WINDOW_SECONDS: '0' }),
|
|
331
|
+
).toThrowError(/frameWindowSeconds/u);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// EXTERNAL_ENABLED — SASL EXTERNAL operator opt-in (default off).
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
describe('loadServerConfigFromCfEnv — EXTERNAL_ENABLED (SASL EXTERNAL opt-in)', () => {
|
|
340
|
+
it('leaves SASL EXTERNAL off when EXTERNAL_ENABLED is unset', () => {
|
|
341
|
+
const cfg = loadServerConfigFromCfEnv(baseEnv());
|
|
342
|
+
expect(cfg.sasl?.externalEnabled ?? false).toBe(false);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('maps EXTERNAL_ENABLED=true to sasl.externalEnabled', () => {
|
|
346
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: 'true' });
|
|
347
|
+
expect(cfg.sasl?.externalEnabled).toBe(true);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('maps EXTERNAL_ENABLED=1 to sasl.externalEnabled', () => {
|
|
351
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: '1' });
|
|
352
|
+
expect(cfg.sasl?.externalEnabled).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('maps EXTERNAL_ENABLED=false (and any other value) to false', () => {
|
|
356
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: 'false' });
|
|
357
|
+
expect(cfg.sasl?.externalEnabled).toBe(false);
|
|
358
|
+
const junk = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: 'yes please' });
|
|
359
|
+
expect(junk.sasl?.externalEnabled ?? false).toBe(false);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Per-IP admission knobs — edge throttling is operator-tunable via env vars.
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
describe('loadServerConfigFromCfEnv — per-IP admission knobs', () => {
|
|
368
|
+
it('defaults maxConnectionsPerIp / perIpConnectionRate from the schema', () => {
|
|
369
|
+
const cfg = loadServerConfigFromCfEnv(baseEnv());
|
|
370
|
+
expect(cfg.maxConnectionsPerIp).toBe(10);
|
|
371
|
+
expect(cfg.perIpConnectionRate).toEqual({ max: 5, windowMs: 60_000 });
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('parses MAX_CONNECTIONS_PER_IP as an integer', () => {
|
|
375
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), MAX_CONNECTIONS_PER_IP: '3' });
|
|
376
|
+
expect(cfg.maxConnectionsPerIp).toBe(3);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('parses PER_IP_CONNECTION_RATE_MAX / _WINDOW_MS into the rate knob', () => {
|
|
380
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
381
|
+
...baseEnv(),
|
|
382
|
+
PER_IP_CONNECTION_RATE_MAX: '10',
|
|
383
|
+
PER_IP_CONNECTION_RATE_WINDOW_MS: '30000',
|
|
384
|
+
});
|
|
385
|
+
expect(cfg.perIpConnectionRate).toEqual({ max: 10, windowMs: 30000 });
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('fills the unset half of the rate knob with the schema default', () => {
|
|
389
|
+
const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), PER_IP_CONNECTION_RATE_MAX: '9' });
|
|
390
|
+
expect(cfg.perIpConnectionRate).toEqual({ max: 9, windowMs: 60_000 });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('rejects a non-positive rate budget', () => {
|
|
394
|
+
expect(() =>
|
|
395
|
+
loadServerConfigFromCfEnv({ ...baseEnv(), PER_IP_CONNECTION_RATE_MAX: '0' }),
|
|
396
|
+
).toThrowError(/perIpConnectionRate/u);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ConnectionDO` × `CounterDO` — admission-slot teardown wiring.
|
|
3
|
+
*
|
|
4
|
+
* TDD outline:
|
|
5
|
+
* "Red: the counter is not decremented on close; assert it is. Green:
|
|
6
|
+
* decrement on close + error. Red: a crashed DO leaves the counter
|
|
7
|
+
* sticky; assert the reconcile sweep reduces it (covered in
|
|
8
|
+
* counter-do.test.ts — here we assert the live-side of that
|
|
9
|
+
* contract: the PING alarm heartbeats the entry so the sweep spares
|
|
10
|
+
* healthy connections)."
|
|
11
|
+
*
|
|
12
|
+
* The worker edge registers each upgrade in the global counter keyed by
|
|
13
|
+
* the ConnectionDO's hex id. These tests pre-register that key directly
|
|
14
|
+
* (simulating the worker), drive every ConnectionDO teardown path, and
|
|
15
|
+
* assert the slot is released.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { env, reset, runDurableObjectAlarm, runInDurableObject } from 'cloudflare:test';
|
|
19
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
20
|
+
import { COUNTER_INSTANCE_NAME, DEFAULT_CONNECTION_TTL_MS } from '../src/counter-do';
|
|
21
|
+
import type { CounterRpc } from '../src/env';
|
|
22
|
+
|
|
23
|
+
declare global {
|
|
24
|
+
namespace Cloudflare {
|
|
25
|
+
interface Env {
|
|
26
|
+
CONNECTION_DO: DurableObjectNamespace;
|
|
27
|
+
COUNTER_DO: DurableObjectNamespace;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
await reset();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** RPC handle on the single global counter instance. */
|
|
37
|
+
function counter(): CounterRpc {
|
|
38
|
+
const stub = env.COUNTER_DO.get(env.COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME));
|
|
39
|
+
return stub as unknown as CounterRpc;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Stub for the ConnectionDO instance named `name`. */
|
|
43
|
+
function connStub(name: string): DurableObjectStub {
|
|
44
|
+
return env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(name));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The canonical conn id used by the admission counter: the ConnectionDO's
|
|
49
|
+
* hex id. This is exactly what the worker registers
|
|
50
|
+
* (`idFromName(uuid).toString()`) and what the DO computes from `ctx.id`,
|
|
51
|
+
* so both sides agree on the key without passing it explicitly.
|
|
52
|
+
*/
|
|
53
|
+
function hexId(name: string): string {
|
|
54
|
+
return env.CONNECTION_DO.idFromName(name).toString();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Opens a real WebSocket to the ConnectionDO named `name`. */
|
|
58
|
+
async function openSocket(name: string): Promise<WebSocket> {
|
|
59
|
+
const response = await connStub(name).fetch('https://do/upgrade', {
|
|
60
|
+
headers: { Upgrade: 'websocket' },
|
|
61
|
+
});
|
|
62
|
+
const ws = response.webSocket;
|
|
63
|
+
if (ws === undefined || ws === null) {
|
|
64
|
+
throw new Error('ConnectionDO.fetch did not return a WebSocket');
|
|
65
|
+
}
|
|
66
|
+
ws.accept();
|
|
67
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
68
|
+
return ws as WebSocket;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Simulates the worker edge: registers the connection's slot. */
|
|
72
|
+
async function registerSlot(name: string, now?: number): Promise<void> {
|
|
73
|
+
const result = await counter().admit(hexId(name), 10_000, now);
|
|
74
|
+
if (!result.admitted) throw new Error('test setup admit unexpectedly rejected');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('ConnectionDO — admission-slot release on teardown', () => {
|
|
78
|
+
it('a clean close releases the slot (webSocketClose → tearDown)', async () => {
|
|
79
|
+
await registerSlot('slot-close-1');
|
|
80
|
+
const ws = await openSocket('slot-close-1');
|
|
81
|
+
expect(await counter().liveCount()).toBe(1);
|
|
82
|
+
|
|
83
|
+
// Deterministic close-teardown (the platform's close event lags in
|
|
84
|
+
// the test runtime; __triggerClose runs the same code path).
|
|
85
|
+
await runInDurableObject(connStub('slot-close-1'), async (instance: unknown) => {
|
|
86
|
+
const doi = instance as { __triggerClose?: () => Promise<void> };
|
|
87
|
+
await doi.__triggerClose?.();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(await counter().liveCount()).toBe(0);
|
|
91
|
+
ws.close();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('a transport error releases the slot (webSocketError → tearDown)', async () => {
|
|
95
|
+
await registerSlot('slot-error-1');
|
|
96
|
+
const ws = await openSocket('slot-error-1');
|
|
97
|
+
expect(await counter().liveCount()).toBe(1);
|
|
98
|
+
|
|
99
|
+
await runInDurableObject(connStub('slot-error-1'), async (instance: unknown) => {
|
|
100
|
+
const doi = instance as { __triggerWsError?: (error: unknown) => Promise<void> };
|
|
101
|
+
await doi.__triggerWsError?.(new Error('boom'));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(await counter().liveCount()).toBe(0);
|
|
105
|
+
ws.close();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('a socketless teardown releases the slot (disconnect RPC → tearDownNoSocket)', async () => {
|
|
109
|
+
await registerSlot('slot-nosock-1');
|
|
110
|
+
expect(await counter().liveCount()).toBe(1);
|
|
111
|
+
|
|
112
|
+
await (
|
|
113
|
+
connStub('slot-nosock-1') as unknown as { disconnect: () => Promise<void> }
|
|
114
|
+
).disconnect();
|
|
115
|
+
|
|
116
|
+
expect(await counter().liveCount()).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('the PING alarm heartbeats the slot so the TTL reconcile spares live connections', async () => {
|
|
120
|
+
// Register with a timestamp that is ALREADY stale — only the alarm's
|
|
121
|
+
// heartbeat can rescue the entry from the next sweep. This is the
|
|
122
|
+
// live-side complement of the crashed-DO reap test: a ConnectionDO
|
|
123
|
+
// that keeps running must keep its slot alive.
|
|
124
|
+
await registerSlot('slot-beat-1', Date.now() - DEFAULT_CONNECTION_TTL_MS - 60_000);
|
|
125
|
+
const ws = await openSocket('slot-beat-1');
|
|
126
|
+
|
|
127
|
+
expect(await runDurableObjectAlarm(connStub('slot-beat-1'))).toBe(true);
|
|
128
|
+
|
|
129
|
+
const swept = await counter().sweep();
|
|
130
|
+
expect(swept.reaped).toBe(0);
|
|
131
|
+
expect(swept.live).toBe(1);
|
|
132
|
+
ws.close();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('an idle-timeout alarm teardown releases the slot (alarm → tearDown)', async () => {
|
|
136
|
+
await registerSlot('slot-idle-1');
|
|
137
|
+
const ws = await openSocket('slot-idle-1');
|
|
138
|
+
expect(await counter().liveCount()).toBe(1);
|
|
139
|
+
|
|
140
|
+
// Backdate lastSeen past the PONG timeout so the alarm takes the
|
|
141
|
+
// idle-teardown branch.
|
|
142
|
+
await runInDurableObject(connStub('slot-idle-1'), async (instance: unknown) => {
|
|
143
|
+
const doi = instance as { __backdateLastSeen?: (ms: number) => Promise<void> };
|
|
144
|
+
await doi.__backdateLastSeen?.(48 * 60 * 60 * 1000);
|
|
145
|
+
});
|
|
146
|
+
expect(await runDurableObjectAlarm(connStub('slot-idle-1'))).toBe(true);
|
|
147
|
+
|
|
148
|
+
expect(await counter().liveCount()).toBe(0);
|
|
149
|
+
ws.close();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('teardown of a never-registered connection is a harmless no-op', async () => {
|
|
153
|
+
// Direct-to-DO paths (integration harness, RPC callers) never
|
|
154
|
+
// registered a slot; teardown must not throw and the counter must
|
|
155
|
+
// stay untouched.
|
|
156
|
+
const ws = await openSocket('slot-unreg-1');
|
|
157
|
+
await runInDurableObject(connStub('slot-unreg-1'), async (instance: unknown) => {
|
|
158
|
+
const doi = instance as { __triggerClose?: () => Promise<void> };
|
|
159
|
+
await doi.__triggerClose?.();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(await counter().liveCount()).toBe(0);
|
|
163
|
+
ws.close();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CF adapter — per-connection inbound frame-rate window at the adapter
|
|
3
|
+
* boundary (`ConnectionDO.webSocketMessage`).
|
|
4
|
+
*
|
|
5
|
+
* The gate fires BEFORE the actor / storage write: a connection that
|
|
6
|
+
* delivers more than `adapter.maxFramesPerWindow` frames inside the
|
|
7
|
+
* `adapter.frameWindowSeconds` sliding window is closed with RFC 6455
|
|
8
|
+
* close code 1008 (Policy Violation) after receiving the canonical
|
|
9
|
+
* `ERROR :Closing link (flooding)` line.
|
|
10
|
+
*
|
|
11
|
+
* The default window (50 frames / 5 s) exercises the under/at/over
|
|
12
|
+
* thresholds; the sliding-window decay uses the `__setFrameWindowForTest`
|
|
13
|
+
* seam to install a tight window so the test does not sleep for real
|
|
14
|
+
* seconds.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { env, runInDurableObject } from 'cloudflare:test';
|
|
18
|
+
import { describe, expect, it } from 'vitest';
|
|
19
|
+
|
|
20
|
+
declare global {
|
|
21
|
+
namespace Cloudflare {
|
|
22
|
+
interface Env {
|
|
23
|
+
CONNECTION_DO: DurableObjectNamespace;
|
|
24
|
+
REGISTRY_DO: DurableObjectNamespace;
|
|
25
|
+
CHANNEL_DO: DurableObjectNamespace;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Poll interval (ms) for the waitFor* helpers. */
|
|
31
|
+
const POLL_MS = 10;
|
|
32
|
+
/** Default timeout (ms) for the waitFor* helpers. */
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 4000;
|
|
34
|
+
|
|
35
|
+
class FrameWsClient {
|
|
36
|
+
readonly lines: string[] = [];
|
|
37
|
+
readonly ws: WebSocket;
|
|
38
|
+
closedCode: number | undefined;
|
|
39
|
+
|
|
40
|
+
constructor(ws: WebSocket) {
|
|
41
|
+
this.ws = ws;
|
|
42
|
+
ws.addEventListener('message', (ev: MessageEvent) => {
|
|
43
|
+
const data = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data);
|
|
44
|
+
for (const line of data.split('\r\n')) {
|
|
45
|
+
if (line.length > 0) this.lines.push(line);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
ws.addEventListener('close', (ev: CloseEvent) => {
|
|
49
|
+
this.closedCode = ev.code;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
send(line: string): void {
|
|
54
|
+
this.ws.send(`${line}\r\n`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
countPongs(): number {
|
|
58
|
+
return this.lines.filter((l) => l.startsWith('PONG')).length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async waitForPongs(n: number, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> {
|
|
62
|
+
const deadline = Date.now() + timeoutMs;
|
|
63
|
+
while (Date.now() < deadline) {
|
|
64
|
+
if (this.countPongs() >= n) return;
|
|
65
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`waitForPongs(${n}) timed out; lines: ${JSON.stringify(this.lines)}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async waitForClose(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<number> {
|
|
71
|
+
const deadline = Date.now() + timeoutMs;
|
|
72
|
+
while (Date.now() < deadline) {
|
|
73
|
+
if (this.closedCode !== undefined) return this.closedCode;
|
|
74
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`waitForClose timed out; lines: ${JSON.stringify(this.lines)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function openConnection(
|
|
81
|
+
connId: string,
|
|
82
|
+
): Promise<{ client: FrameWsClient; stub: DurableObjectStub }> {
|
|
83
|
+
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
|
|
84
|
+
const response = await stub.fetch('https://do/upgrade', { headers: { Upgrade: 'websocket' } });
|
|
85
|
+
const ws = response.webSocket;
|
|
86
|
+
if (ws === undefined || ws === null) {
|
|
87
|
+
throw new Error('ConnectionDO.fetch did not return a WebSocket');
|
|
88
|
+
}
|
|
89
|
+
ws.accept();
|
|
90
|
+
const client = await new Promise<FrameWsClient>((resolve) => {
|
|
91
|
+
setTimeout(() => resolve(new FrameWsClient(ws as WebSocket)), 0);
|
|
92
|
+
});
|
|
93
|
+
return { client, stub };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** RPC shape of the `ConnectionDO` test seam used for the decay test. */
|
|
97
|
+
interface FrameWindowSeam {
|
|
98
|
+
__setFrameWindowForTest(cfg: { maxFrames: number; windowMs: number } | undefined): void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('ConnectionDO — inbound frame-rate window (default 50 / 5 s)', () => {
|
|
102
|
+
it('admits frames while the window is under the ceiling and at the ceiling (no close)', async () => {
|
|
103
|
+
const { client } = await openConnection('conn-frame-under');
|
|
104
|
+
// 50 frames = exactly the default ceiling; every one is admitted and
|
|
105
|
+
// answered (each PING is one WebSocket frame through the actor).
|
|
106
|
+
for (let i = 1; i <= 50; i++) {
|
|
107
|
+
client.send(`PING :u${i}`);
|
|
108
|
+
}
|
|
109
|
+
await client.waitForPongs(50);
|
|
110
|
+
// Grace window: a close would have landed by now if the 50th frame
|
|
111
|
+
// had tripped the gate.
|
|
112
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
113
|
+
expect(client.closedCode).toBeUndefined();
|
|
114
|
+
client.ws.close();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('closes with RFC 6455 code 1008 after ERROR :Closing link (flooding) on frame 51', async () => {
|
|
118
|
+
const { client } = await openConnection('conn-frame-over');
|
|
119
|
+
for (let i = 1; i <= 50; i++) {
|
|
120
|
+
client.send(`PING :o${i}`);
|
|
121
|
+
}
|
|
122
|
+
await client.waitForPongs(50);
|
|
123
|
+
|
|
124
|
+
// Frame 51 exceeds the window: no actor run (no PONG), the ERROR
|
|
125
|
+
// line is delivered, then the socket is closed with 1008.
|
|
126
|
+
client.send('PING :o51');
|
|
127
|
+
const code = await client.waitForClose();
|
|
128
|
+
expect(code).toBe(1008);
|
|
129
|
+
expect(client.lines).toContain('ERROR :Closing link (flooding)');
|
|
130
|
+
expect(client.lines.some((l) => l.startsWith('PONG :o51'))).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('admits a fresh burst after the window decays (sliding window)', async () => {
|
|
134
|
+
const { client, stub } = await openConnection('conn-frame-decay');
|
|
135
|
+
// Tight window (5 frames / 400 ms) via the test seam so the decay
|
|
136
|
+
// wait is fast instead of a real 5-second sleep.
|
|
137
|
+
await runInDurableObject(stub, (instance: unknown) => {
|
|
138
|
+
(instance as FrameWindowSeam).__setFrameWindowForTest({
|
|
139
|
+
maxFrames: 5,
|
|
140
|
+
windowMs: 400,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
for (let i = 1; i <= 5; i++) {
|
|
145
|
+
client.send(`PING :d1-${i}`);
|
|
146
|
+
}
|
|
147
|
+
await client.waitForPongs(5);
|
|
148
|
+
|
|
149
|
+
// Wait past the window: the first burst decays out entirely.
|
|
150
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
151
|
+
for (let i = 1; i <= 5; i++) {
|
|
152
|
+
client.send(`PING :d2-${i}`);
|
|
153
|
+
}
|
|
154
|
+
await client.waitForPongs(10);
|
|
155
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
156
|
+
expect(client.closedCode).toBeUndefined();
|
|
157
|
+
expect(client.countPongs()).toBe(10);
|
|
158
|
+
client.ws.close();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('still floods out mid-burst once the tight window is full (seam sanity)', async () => {
|
|
162
|
+
const { client, stub } = await openConnection('conn-frame-tight');
|
|
163
|
+
await runInDurableObject(stub, (instance: unknown) => {
|
|
164
|
+
(instance as FrameWindowSeam).__setFrameWindowForTest({
|
|
165
|
+
maxFrames: 2,
|
|
166
|
+
windowMs: 60_000,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
client.send('PING :t1');
|
|
170
|
+
client.send('PING :t2');
|
|
171
|
+
client.send('PING :t3');
|
|
172
|
+
const code = await client.waitForClose();
|
|
173
|
+
expect(code).toBe(1008);
|
|
174
|
+
expect(client.lines).toContain('ERROR :Closing link (flooding)');
|
|
175
|
+
expect(client.countPongs()).toBe(2);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -233,16 +233,78 @@ describe('NO_OP_SEND / NO_OP_DISCONNECT', () => {
|
|
|
233
233
|
});
|
|
234
234
|
|
|
235
235
|
// ============================================================================
|
|
236
|
-
// extractMtlsSubject — request.cf
|
|
236
|
+
// extractMtlsSubject — request.cf mTLS identity capture
|
|
237
|
+
// (cf.tlsClientAuthCertSubject + cf.tlsClientAuth.certFingerprintSHA256)
|
|
237
238
|
// ============================================================================
|
|
238
239
|
|
|
239
240
|
describe('extractMtlsSubject', () => {
|
|
240
|
-
it('returns the subject when cf.tlsClientAuthCertSubject is a string', () => {
|
|
241
|
+
it('returns the subject identity when cf.tlsClientAuthCertSubject is a string', () => {
|
|
241
242
|
// The CF edge injects request.cf on every proxied request. We can't
|
|
242
243
|
// mutate Request.cf in workerd tests (it's read-only), so the
|
|
243
244
|
// upgrade-time capture is exercised via this pure seam.
|
|
244
245
|
const fake = { cf: { tlsClientAuthCertSubject: 'CN=alice' } } as unknown as Request;
|
|
245
|
-
expect(extractMtlsSubject(fake)).
|
|
246
|
+
expect(extractMtlsSubject(fake)).toEqual({
|
|
247
|
+
subject: 'CN=alice',
|
|
248
|
+
canonicalDn: 'cn=alice',
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('includes the fingerprint from cf.tlsClientAuth.certFingerprintSHA256 when present', () => {
|
|
253
|
+
const fake = {
|
|
254
|
+
cf: {
|
|
255
|
+
tlsClientAuthCertSubject: 'CN=alice',
|
|
256
|
+
tlsClientAuth: { certFingerprintSHA256: 'af363dc85bc942a8' },
|
|
257
|
+
},
|
|
258
|
+
} as unknown as Request;
|
|
259
|
+
expect(extractMtlsSubject(fake)).toEqual({
|
|
260
|
+
subject: 'CN=alice',
|
|
261
|
+
canonicalDn: 'cn=alice',
|
|
262
|
+
fingerprint: 'af363dc85bc942a8',
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('omits the fingerprint when certFingerprintSHA256 is the documented empty default', () => {
|
|
267
|
+
// Cloudflare documents certFingerprintSHA256 as `""` when the
|
|
268
|
+
// connection is not mTLS — treat that as "no fingerprint".
|
|
269
|
+
const fake = {
|
|
270
|
+
cf: {
|
|
271
|
+
tlsClientAuthCertSubject: 'CN=alice',
|
|
272
|
+
tlsClientAuth: { certFingerprintSHA256: '' },
|
|
273
|
+
},
|
|
274
|
+
} as unknown as Request;
|
|
275
|
+
expect(extractMtlsSubject(fake)?.fingerprint).toBeUndefined();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('ignores a non-string certFingerprintSHA256', () => {
|
|
279
|
+
const fake = {
|
|
280
|
+
cf: {
|
|
281
|
+
tlsClientAuthCertSubject: 'CN=alice',
|
|
282
|
+
tlsClientAuth: { certFingerprintSHA256: 42 },
|
|
283
|
+
},
|
|
284
|
+
} as unknown as Request;
|
|
285
|
+
expect(extractMtlsSubject(fake)?.fingerprint).toBeUndefined();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('falls back to cf.tlsClientAuth.certSubjectDN when the legacy subject field is absent', () => {
|
|
289
|
+
const fake = {
|
|
290
|
+
cf: {
|
|
291
|
+
tlsClientAuth: { certSubjectDN: 'CN=alice,O=Example', certFingerprintSHA256: 'aa11' },
|
|
292
|
+
},
|
|
293
|
+
} as unknown as Request;
|
|
294
|
+
const identity = extractMtlsSubject(fake);
|
|
295
|
+
expect(identity?.subject).toBe('CN=alice,O=Example');
|
|
296
|
+
expect(identity?.canonicalDn).toBe('cn=alice,o=Example');
|
|
297
|
+
expect(identity?.fingerprint).toBe('aa11');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('prefers the legacy subject field over tlsClientAuth.certSubjectDN', () => {
|
|
301
|
+
const fake = {
|
|
302
|
+
cf: {
|
|
303
|
+
tlsClientAuthCertSubject: 'CN=legacy',
|
|
304
|
+
tlsClientAuth: { certSubjectDN: 'CN=newer' },
|
|
305
|
+
},
|
|
306
|
+
} as unknown as Request;
|
|
307
|
+
expect(extractMtlsSubject(fake)?.subject).toBe('CN=legacy');
|
|
246
308
|
});
|
|
247
309
|
|
|
248
310
|
it('returns undefined when cf is absent', () => {
|
|
@@ -250,8 +312,10 @@ describe('extractMtlsSubject', () => {
|
|
|
250
312
|
expect(extractMtlsSubject(fake)).toBeUndefined();
|
|
251
313
|
});
|
|
252
314
|
|
|
253
|
-
it('returns undefined when cf is present but
|
|
254
|
-
const fake = {
|
|
315
|
+
it('returns undefined when cf is present but carries no cert subject', () => {
|
|
316
|
+
const fake = {
|
|
317
|
+
cf: { country: 'US', tlsClientAuth: { certVerified: 'NONE' } },
|
|
318
|
+
} as unknown as Request;
|
|
255
319
|
expect(extractMtlsSubject(fake)).toBeUndefined();
|
|
256
320
|
});
|
|
257
321
|
|
|
@@ -259,4 +323,9 @@ describe('extractMtlsSubject', () => {
|
|
|
259
323
|
const fake = { cf: { tlsClientAuthCertSubject: 42 } } as unknown as Request;
|
|
260
324
|
expect(extractMtlsSubject(fake)).toBeUndefined();
|
|
261
325
|
});
|
|
326
|
+
|
|
327
|
+
it('returns undefined when tlsClientAuth is not an object', () => {
|
|
328
|
+
const fake = { cf: { tlsClientAuth: 'nope' } } as unknown as Request;
|
|
329
|
+
expect(extractMtlsSubject(fake)).toBeUndefined();
|
|
330
|
+
});
|
|
262
331
|
});
|
|
@@ -223,12 +223,15 @@ describe('ConnectionDO — IRCv3 WebSocket spec contract (end-to-end)', () => {
|
|
|
223
223
|
client.messages.length = 0;
|
|
224
224
|
|
|
225
225
|
// One WS message that LOOKS like two PING lines. In spec mode the frame
|
|
226
|
-
// is a single IRC message, so
|
|
226
|
+
// is a single IRC message, so it is never split into two commands: the
|
|
227
|
+
// embedded CR makes the line unparseable (bare-CR rejection) and the
|
|
228
|
+
// actor answers with exactly ONE 421 — never a PONG, let alone two.
|
|
227
229
|
client.sendRaw('PING one\r\nPING two');
|
|
228
|
-
await client.
|
|
229
|
-
// Give a grace window for a (incorrect) second
|
|
230
|
+
await client.waitForLine((l) => l.includes(' 421 '));
|
|
231
|
+
// Give a grace window for a (incorrect) PONG or second reply to arrive.
|
|
230
232
|
await new Promise((r) => setTimeout(r, 100));
|
|
231
|
-
expect(countCommand(client.messages, 'PONG')).toBe(
|
|
233
|
+
expect(countCommand(client.messages, 'PONG')).toBe(0);
|
|
234
|
+
expect(client.messages.filter((m) => m.includes(' 421 '))).toHaveLength(1);
|
|
232
235
|
client.ws.close();
|
|
233
236
|
});
|
|
234
237
|
|