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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Static assertions on the container image's runtime hardening.
|
|
6
|
+
*
|
|
7
|
+
* The IRC stack (parser + actor + transport) processes untrusted input,
|
|
8
|
+
* so the runtime process must never be UID 0. Docker is not available in
|
|
9
|
+
* every environment (including the CI runner image), so the Dockerfile is
|
|
10
|
+
* verified statically here; a live `docker run … id -u` smoke runs in CI
|
|
11
|
+
* wherever a docker daemon is available.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DOCKERFILE_URL = new URL('../Dockerfile', import.meta.url);
|
|
15
|
+
|
|
16
|
+
/** Non-comment, non-empty Dockerfile lines of the runtime stage. */
|
|
17
|
+
function runtimeStageLines(content: string): string[] {
|
|
18
|
+
const stageStart = content.indexOf('AS runtime');
|
|
19
|
+
expect(stageStart, 'Dockerfile must define an `AS runtime` stage').toBeGreaterThan(0);
|
|
20
|
+
return content
|
|
21
|
+
.slice(stageStart)
|
|
22
|
+
.split('\n')
|
|
23
|
+
.map((line) => line.trim())
|
|
24
|
+
.filter((line) => line.length > 0 && !line.startsWith('#'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readRuntimeLines(): Promise<string[]> {
|
|
28
|
+
return runtimeStageLines(await readFile(DOCKERFILE_URL, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('Dockerfile — non-root runtime hardening', () => {
|
|
32
|
+
it('runs the runtime stage as USER node before CMD', async () => {
|
|
33
|
+
const lines = await readRuntimeLines();
|
|
34
|
+
const userIndex = lines.findIndex((l) => /^USER\s+node\b/.test(l));
|
|
35
|
+
const cmdIndex = lines.findIndex((l) => /^CMD\b/.test(l));
|
|
36
|
+
expect(userIndex, 'runtime stage must declare USER node').toBeGreaterThan(-1);
|
|
37
|
+
expect(cmdIndex, 'runtime stage must declare CMD').toBeGreaterThan(-1);
|
|
38
|
+
expect(userIndex).toBeLessThan(cmdIndex);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('never escalates back to root after dropping privileges', async () => {
|
|
42
|
+
const lines = await readRuntimeLines();
|
|
43
|
+
const userIndex = lines.findIndex((l) => /^USER\s+node\b/.test(l));
|
|
44
|
+
expect(userIndex).toBeGreaterThan(-1);
|
|
45
|
+
const after = lines.slice(userIndex + 1);
|
|
46
|
+
expect(
|
|
47
|
+
after.filter((l) => /^USER\s+(root|0)\b/.test(l)),
|
|
48
|
+
'no USER root may follow USER node',
|
|
49
|
+
).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('chowns /data to node:node while still root (before USER node)', async () => {
|
|
53
|
+
const lines = await readRuntimeLines();
|
|
54
|
+
const chownIndex = lines.findIndex((l) => /chown\s+(-R\s+)?node:node\s+.*\/data/.test(l));
|
|
55
|
+
const userIndex = lines.findIndex((l) => /^USER\s+node\b/.test(l));
|
|
56
|
+
expect(chownIndex, 'runtime stage must chown node:node /data').toBeGreaterThan(-1);
|
|
57
|
+
expect(userIndex).toBeGreaterThan(-1);
|
|
58
|
+
// chown requires root: it must happen before privileges are dropped.
|
|
59
|
+
expect(chownIndex).toBeLessThan(userIndex);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('creates the /data volume directory it chowns', async () => {
|
|
63
|
+
const content = await readFile(DOCKERFILE_URL, 'utf8');
|
|
64
|
+
expect(content).toMatch(/mkdir\s+-p\s+\/data/);
|
|
65
|
+
expect(content).toMatch(/VOLUME\s+\["\/data"\]/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('documents that cert mounts must stay readable by UID 1000', async () => {
|
|
69
|
+
const content = await readFile(DOCKERFILE_URL, 'utf8');
|
|
70
|
+
// Group consecutive `#` lines into blocks so multi-line comments count.
|
|
71
|
+
const blocks: string[] = [];
|
|
72
|
+
for (const line of content.split('\n')) {
|
|
73
|
+
if (line.trim().startsWith('#')) {
|
|
74
|
+
if (blocks.length === 0) blocks.push(line);
|
|
75
|
+
else blocks[blocks.length - 1] += `\n${line}`;
|
|
76
|
+
} else {
|
|
77
|
+
blocks.push('');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const certBlocks = blocks.filter((b) => /cert/i.test(b));
|
|
81
|
+
expect(certBlocks.length, 'Dockerfile must mention certs').toBeGreaterThan(0);
|
|
82
|
+
expect(
|
|
83
|
+
certBlocks.some((b) => /UID\s*1000/i.test(b)),
|
|
84
|
+
'a cert comment must require UID-1000 readability',
|
|
85
|
+
).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('Dockerfile — TLS-at-origin image defaults', () => {
|
|
90
|
+
it('exposes the TLS port 6697', async () => {
|
|
91
|
+
const content = await readFile(DOCKERFILE_URL, 'utf8');
|
|
92
|
+
expect(content).toMatch(/^EXPOSE\s+6697\b/m);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does not expose the plaintext port 6667 (debug-localhost only, never routed)', async () => {
|
|
96
|
+
const content = await readFile(DOCKERFILE_URL, 'utf8');
|
|
97
|
+
expect(content).not.toMatch(/^EXPOSE\s+6667\b/m);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('defaults TLS_CERT_PATH / TLS_KEY_PATH to the documented /data/certs mount', async () => {
|
|
101
|
+
const content = await readFile(DOCKERFILE_URL, 'utf8');
|
|
102
|
+
expect(content).toMatch(/^ENV\s+TLS_CERT_PATH=\/data\/certs\//m);
|
|
103
|
+
expect(content).toMatch(/^ENV\s+TLS_KEY_PATH=\/data\/certs\//m);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('classifies the image as production so the no-TLS boot guard applies', async () => {
|
|
107
|
+
const content = await readFile(DOCKERFILE_URL, 'utf8');
|
|
108
|
+
expect(content).toMatch(/^ENV\s+NODE_ENV=production\b/m);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { consumeProxyV1Header } from '../src/proxy-protocol.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PROXY protocol v1 header parsing (the line Cloudflare Spectrum prepends
|
|
6
|
+
* to every forwarded TCP flow when `proxy_protocol = "v1"`).
|
|
7
|
+
*
|
|
8
|
+
* The header is a single ASCII line:
|
|
9
|
+
* PROXY TCP4 <src_ip> <dst_ip> <src_port> <dst_port>\r\n
|
|
10
|
+
* and may arrive split across TCP chunks, so `consumeProxyV1Header` is
|
|
11
|
+
* incremental: it reports `incomplete` until the trailing CRLF is seen.
|
|
12
|
+
*/
|
|
13
|
+
describe('proxy-protocol — consumeProxyV1Header', () => {
|
|
14
|
+
it('parses a complete TCP4 header and returns the source IP', () => {
|
|
15
|
+
const result = consumeProxyV1Header(
|
|
16
|
+
Buffer.from('PROXY TCP4 203.0.113.7 198.51.100.2 40001 6697\r\n', 'ascii'),
|
|
17
|
+
);
|
|
18
|
+
expect(result).toEqual({
|
|
19
|
+
status: 'complete',
|
|
20
|
+
sourceIp: '203.0.113.7',
|
|
21
|
+
remainder: Buffer.alloc(0),
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns the bytes after the header as the remainder', () => {
|
|
26
|
+
const result = consumeProxyV1Header(
|
|
27
|
+
Buffer.from('PROXY TCP4 203.0.113.7 198.51.100.2 40001 6697\r\nNICK alice\r\n', 'ascii'),
|
|
28
|
+
);
|
|
29
|
+
expect(result.status).toBe('complete');
|
|
30
|
+
if (result.status === 'complete') {
|
|
31
|
+
expect(result.remainder.toString('ascii')).toBe('NICK alice\r\n');
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('parses a complete TCP6 header', () => {
|
|
36
|
+
const result = consumeProxyV1Header(
|
|
37
|
+
Buffer.from('PROXY TCP6 2001:db8::1 2001:db8::2 40002 6697\r\n', 'ascii'),
|
|
38
|
+
);
|
|
39
|
+
expect(result).toEqual({
|
|
40
|
+
status: 'complete',
|
|
41
|
+
sourceIp: '2001:db8::1',
|
|
42
|
+
remainder: Buffer.alloc(0),
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('accepts boundary ports 0 and 65535', () => {
|
|
47
|
+
const result = consumeProxyV1Header(
|
|
48
|
+
Buffer.from('PROXY TCP4 203.0.113.7 198.51.100.2 0 65535\r\n', 'ascii'),
|
|
49
|
+
);
|
|
50
|
+
expect(result.status).toBe('complete');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('accepts a maximal-length valid TCP6 header (104 bytes incl. CRLF)', () => {
|
|
54
|
+
const v6 = '0000:0000:0000:0000:0000:0000:0000:0000'; // 39 chars
|
|
55
|
+
const line = `PROXY TCP6 ${v6} ${v6} 65535 65535\r\n`;
|
|
56
|
+
expect(line.length).toBe(104);
|
|
57
|
+
const result = consumeProxyV1Header(Buffer.from(line, 'ascii'));
|
|
58
|
+
expect(result.status).toBe('complete');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('reports incomplete for an empty buffer', () => {
|
|
62
|
+
expect(consumeProxyV1Header(Buffer.alloc(0))).toEqual({ status: 'incomplete' });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('reports incomplete for a plausible partial header without CRLF', () => {
|
|
66
|
+
expect(consumeProxyV1Header(Buffer.from('PROXY TCP4 203.0.11', 'ascii'))).toEqual({
|
|
67
|
+
status: 'incomplete',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('reassembles a header split across two buffers (caller-concatenated)', () => {
|
|
72
|
+
const part1 = Buffer.from('PROXY TCP6 2001:db8:', 'ascii');
|
|
73
|
+
const part2 = Buffer.from(':42 2001:db8::2 40003 6697\r\n', 'ascii');
|
|
74
|
+
const result = consumeProxyV1Header(Buffer.concat([part1, part2]));
|
|
75
|
+
expect(result).toEqual({
|
|
76
|
+
status: 'complete',
|
|
77
|
+
sourceIp: '2001:db8::42',
|
|
78
|
+
remainder: Buffer.alloc(0),
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('rejects a buffer that exceeds the max header length with no CRLF', () => {
|
|
83
|
+
const result = consumeProxyV1Header(Buffer.from(`PROXY ${'a'.repeat(200)}`, 'ascii'));
|
|
84
|
+
expect(result).toEqual({ status: 'invalid' });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('rejects a short non-PROXY prefix (direct IRC client, no CRLF yet)', () => {
|
|
88
|
+
expect(consumeProxyV1Header(Buffer.from('NICK', 'ascii'))).toEqual({ status: 'invalid' });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects a long non-PROXY prefix with no CRLF', () => {
|
|
92
|
+
expect(consumeProxyV1Header(Buffer.from('NOTAPROXYLINE lorem ipsum dolor', 'ascii'))).toEqual({
|
|
93
|
+
status: 'invalid',
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('rejects a CRLF-terminated line that is not a PROXY header', () => {
|
|
98
|
+
// The direct-connection case: an IRC client speaks IRC immediately.
|
|
99
|
+
const result = consumeProxyV1Header(Buffer.from('NICK direct\r\nUSER direct 0 * :d\r\n'));
|
|
100
|
+
expect(result).toEqual({ status: 'invalid' });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('rejects a CRLF-terminated header longer than the 107-byte maximum', () => {
|
|
104
|
+
const line = `PROXY UNKNOWN ${'x'.repeat(93)}`; // 107 chars + CRLF = 109 bytes
|
|
105
|
+
const result = consumeProxyV1Header(Buffer.from(`${line}\r\n`, 'ascii'));
|
|
106
|
+
expect(result).toEqual({ status: 'invalid' });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('rejects the UNKNOWN family', () => {
|
|
110
|
+
const result = consumeProxyV1Header(Buffer.from('PROXY UNKNOWN\r\n', 'ascii'));
|
|
111
|
+
expect(result).toEqual({ status: 'invalid' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects a six-token UNKNOWN header (family check, not token count)', () => {
|
|
115
|
+
const result = consumeProxyV1Header(
|
|
116
|
+
Buffer.from('PROXY UNKNOWN 1.2.3.4 5.6.7.8 1234 5678\r\n', 'ascii'),
|
|
117
|
+
);
|
|
118
|
+
expect(result).toEqual({ status: 'invalid' });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('rejects a TCP4 header carrying an IPv6 source address', () => {
|
|
122
|
+
const result = consumeProxyV1Header(
|
|
123
|
+
Buffer.from('PROXY TCP4 2001:db8::1 198.51.100.2 40004 6697\r\n', 'ascii'),
|
|
124
|
+
);
|
|
125
|
+
expect(result).toEqual({ status: 'invalid' });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('rejects a TCP6 header carrying an IPv4 source address', () => {
|
|
129
|
+
const result = consumeProxyV1Header(
|
|
130
|
+
Buffer.from('PROXY TCP6 203.0.113.7 2001:db8::2 40005 6697\r\n', 'ascii'),
|
|
131
|
+
);
|
|
132
|
+
expect(result).toEqual({ status: 'invalid' });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('rejects a non-numeric source port', () => {
|
|
136
|
+
const result = consumeProxyV1Header(
|
|
137
|
+
Buffer.from('PROXY TCP4 203.0.113.7 198.51.100.2 abc 6697\r\n', 'ascii'),
|
|
138
|
+
);
|
|
139
|
+
expect(result).toEqual({ status: 'invalid' });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects a destination port above 65535', () => {
|
|
143
|
+
const result = consumeProxyV1Header(
|
|
144
|
+
Buffer.from('PROXY TCP4 203.0.113.7 198.51.100.2 40006 99999\r\n', 'ascii'),
|
|
145
|
+
);
|
|
146
|
+
expect(result).toEqual({ status: 'invalid' });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('rejects a header with a missing token', () => {
|
|
150
|
+
const result = consumeProxyV1Header(
|
|
151
|
+
Buffer.from('PROXY TCP4 203.0.113.7 198.51.100.2 40007\r\n', 'ascii'),
|
|
152
|
+
);
|
|
153
|
+
expect(result).toEqual({ status: 'invalid' });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('rejects a signature-only line (family slot empty)', () => {
|
|
157
|
+
expect(consumeProxyV1Header(Buffer.from('PROXY\r\n', 'ascii'))).toEqual({
|
|
158
|
+
status: 'invalid',
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('rejects a three-token header (destination address missing)', () => {
|
|
163
|
+
expect(consumeProxyV1Header(Buffer.from('PROXY TCP4 203.0.113.7\r\n', 'ascii'))).toEqual({
|
|
164
|
+
status: 'invalid',
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('rejects a four-token header (source port missing)', () => {
|
|
169
|
+
expect(
|
|
170
|
+
consumeProxyV1Header(Buffer.from('PROXY TCP4 203.0.113.7 198.51.100.2\r\n', 'ascii')),
|
|
171
|
+
).toEqual({ status: 'invalid' });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('rejects a header with an extra token', () => {
|
|
175
|
+
const result = consumeProxyV1Header(
|
|
176
|
+
Buffer.from('PROXY TCP4 203.0.113.7 198.51.100.2 40008 6697 extra\r\n', 'ascii'),
|
|
177
|
+
);
|
|
178
|
+
expect(result).toEqual({ status: 'invalid' });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('rejects a lowercase signature (the protocol is case-sensitive)', () => {
|
|
182
|
+
const result = consumeProxyV1Header(
|
|
183
|
+
Buffer.from('proxy tcp4 203.0.113.7 198.51.100.2 40009 6697\r\n', 'ascii'),
|
|
184
|
+
);
|
|
185
|
+
expect(result).toEqual({ status: 'invalid' });
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { copyFile, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Static→plan assertions on the Spectrum terraform config.
|
|
12
|
+
*
|
|
13
|
+
* `terraform` + network access to the provider registry are not guaranteed
|
|
14
|
+
* on every runner, so the suite skips when the binary is missing. When
|
|
15
|
+
* present, the config is initialized in a scratch dir and the decoded
|
|
16
|
+
* plan is asserted — this is the authoritative seam for the
|
|
17
|
+
* `tls_mode` default (end-to-end TLS, not edge-terminated).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const TERRAFORM_DIR_URL = new URL('../terraform', import.meta.url);
|
|
21
|
+
|
|
22
|
+
/** Wall-clock budget for one terraform invocation (provider download included). */
|
|
23
|
+
const TF_CMD_TIMEOUT_MS = 120_000;
|
|
24
|
+
|
|
25
|
+
const hasTerraform = await (async () => {
|
|
26
|
+
try {
|
|
27
|
+
await execFileAsync('terraform', ['version'], { timeout: 15_000 });
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
|
|
34
|
+
interface PlannedResource {
|
|
35
|
+
address: string;
|
|
36
|
+
values: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function runTerraform(cwd: string, args: string[], envExtra: Record<string, string>) {
|
|
40
|
+
return execFileAsync('terraform', args, {
|
|
41
|
+
cwd,
|
|
42
|
+
timeout: TF_CMD_TIMEOUT_MS,
|
|
43
|
+
env: { ...process.env, ...envExtra },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Decoded planned values of the spectrum application resource. */
|
|
48
|
+
async function planSpectrumApp(): Promise<PlannedResource> {
|
|
49
|
+
const work = await mkdtemp(join(tmpdir(), 'sirc-tf-plan-'));
|
|
50
|
+
try {
|
|
51
|
+
const files = (await readdir(TERRAFORM_DIR_URL)).filter((f) => f.endsWith('.tf'));
|
|
52
|
+
for (const f of files) {
|
|
53
|
+
await copyFile(join(TERRAFORM_DIR_URL.pathname, f), join(work, f));
|
|
54
|
+
}
|
|
55
|
+
// Shared provider cache so the (large) cloudflare provider is only
|
|
56
|
+
// downloaded once per machine, not once per test run.
|
|
57
|
+
const pluginCache = join(tmpdir(), 'sirc-tf-plugin-cache');
|
|
58
|
+
await mkdir(pluginCache, { recursive: true });
|
|
59
|
+
|
|
60
|
+
// The plan is computed from configuration only (`-refresh=false`, no
|
|
61
|
+
// state, dummy token): the provider never contacts the Cloudflare API.
|
|
62
|
+
const env = {
|
|
63
|
+
TF_PLUGIN_CACHE_DIR: pluginCache,
|
|
64
|
+
CLOUDFLARE_API_TOKEN: 'dummy-token-plan-only',
|
|
65
|
+
};
|
|
66
|
+
await runTerraform(work, ['init', '-backend=false', '-input=false', '-no-color'], env);
|
|
67
|
+
await runTerraform(
|
|
68
|
+
work,
|
|
69
|
+
[
|
|
70
|
+
'plan',
|
|
71
|
+
'-input=false',
|
|
72
|
+
'-no-color',
|
|
73
|
+
'-refresh=false',
|
|
74
|
+
'-var',
|
|
75
|
+
'zone_id=0123456789abcdef',
|
|
76
|
+
'-var',
|
|
77
|
+
'origin_address=origin.example.com',
|
|
78
|
+
'-out=plan.tfplan',
|
|
79
|
+
],
|
|
80
|
+
env,
|
|
81
|
+
);
|
|
82
|
+
const { stdout } = await runTerraform(work, ['show', '-json', 'plan.tfplan'], env);
|
|
83
|
+
const plan = JSON.parse(stdout) as {
|
|
84
|
+
planned_values?: { root_module?: { resources?: PlannedResource[] } };
|
|
85
|
+
};
|
|
86
|
+
const resources = plan.planned_values?.root_module?.resources ?? [];
|
|
87
|
+
const app = resources.find((r) => r.address === 'cloudflare_spectrum_application.irc_tls');
|
|
88
|
+
if (app === undefined) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`spectrum application resource missing from plan; got: ${JSON.stringify(resources.map((r) => r.address))}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return app;
|
|
94
|
+
} finally {
|
|
95
|
+
await rm(work, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe.skipIf(!hasTerraform)('spectrum.tf — decoded terraform plan', () => {
|
|
100
|
+
let app: PlannedResource;
|
|
101
|
+
|
|
102
|
+
beforeAll(async () => {
|
|
103
|
+
app = await planSpectrumApp();
|
|
104
|
+
}, 180_000);
|
|
105
|
+
|
|
106
|
+
it('defaults the spectrum application to end-to-end TLS (tls = "full")', () => {
|
|
107
|
+
expect(app.values.tls).toBe('full');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('defaults origin_port to the origin TLS listener (6697)', () => {
|
|
111
|
+
expect(app.values.origin_port).toBe(6697);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('still forwards the real client IP via PROXY protocol v1', () => {
|
|
115
|
+
expect(app.values.proxy_protocol).toBe('v1');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('still exposes the public TLS port 6697', () => {
|
|
119
|
+
expect(app.values.protocol).toBe('tcp/6697');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('pins the accepted TLS versions to 1.2 and 1.3 (no legacy fallback)', () => {
|
|
123
|
+
// Spectrum TCP apps cannot pin per-app TLS versions: the Cloudflare
|
|
124
|
+
// API and this provider expose no `tls_versions` field (an explicit
|
|
125
|
+
// `tls_versions = [...]` fails `terraform plan` with "Unsupported
|
|
126
|
+
// argument"), and the edge floor is fixed at TLS 1.1 (SSLv3 / TLS 1.0
|
|
127
|
+
// are never negotiable). The 1.2/1.3 pin is therefore enforced at the
|
|
128
|
+
// origin listener (`minVersion`/`maxVersion` in container-server.ts,
|
|
129
|
+
// covered by tests/container-server-tls.test.ts). This assertion is
|
|
130
|
+
// the tripwire: the day the provider gains `tls_versions`, the
|
|
131
|
+
// planned values carry the (null) key, this test fails, and the pin
|
|
132
|
+
// must move onto the Spectrum app itself.
|
|
133
|
+
expect(app.values.tls_versions).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -163,6 +163,10 @@ function startTlsProxy(opts: {
|
|
|
163
163
|
const server = tlsCreateServer({ key: opts.key, cert: opts.cert }, (tlsSocket) => {
|
|
164
164
|
const origin = createConnection({ host: opts.originHost, port: opts.originPort });
|
|
165
165
|
originSockets.add(origin);
|
|
166
|
+
// Spectrum with `proxy_protocol = "v1"` prepends a PROXY header to
|
|
167
|
+
// every forwarded flow carrying the real client IP; the analogue
|
|
168
|
+
// does the same when the origin connection is established.
|
|
169
|
+
origin.write('PROXY TCP4 127.0.0.1 192.0.2.1 40000 6697\r\n');
|
|
166
170
|
tlsSocket.pipe(origin);
|
|
167
171
|
origin.pipe(tlsSocket);
|
|
168
172
|
const cleanup = (): void => {
|
|
@@ -222,7 +226,7 @@ describe('container-server — TLS e2e via in-process proxy (Spectrum analogue)'
|
|
|
222
226
|
key: cert.key,
|
|
223
227
|
cert: cert.cert,
|
|
224
228
|
originHost: '127.0.0.1',
|
|
225
|
-
originPort: origin.port,
|
|
229
|
+
originPort: origin.port as number,
|
|
226
230
|
});
|
|
227
231
|
}, 30_000);
|
|
228
232
|
|
|
@@ -3,14 +3,19 @@
|
|
|
3
3
|
## Cloudflare Containers config for the ServerlessIRCd TCP origin.
|
|
4
4
|
##
|
|
5
5
|
## Deploys the IRC TCP container (apps/cf-tcp-container) to the Cloudflare
|
|
6
|
-
## Containers platform. The container
|
|
7
|
-
## Cloudflare Spectrum (configured
|
|
8
|
-
##
|
|
6
|
+
## Containers platform. The container terminates TLS itself on :6697
|
|
7
|
+
## (TLS_CERT_PATH / TLS_KEY_PATH below); Cloudflare Spectrum (configured
|
|
8
|
+
## separately in terraform/spectrum.tf) runs in tls_mode = "full" and
|
|
9
|
+
## re-encrypts edge→origin. Plaintext :6667 is debug-only
|
|
10
|
+
## (DEBUG_PLAINTEXT=localhost binds 127.0.0.1; forbidden in prod).
|
|
9
11
|
##
|
|
10
12
|
## Prerequisites:
|
|
11
13
|
## - Cloudflare account with Containers beta access.
|
|
12
14
|
## - wrangler v4.112+ (`pnpm add -g wrangler`).
|
|
13
15
|
## - The container image is built from apps/cf-tcp-container/Dockerfile.
|
|
16
|
+
## - A PEM cert/key pair mounted at /data/certs/ (see docs/
|
|
17
|
+
## Cloudflare-TCP-Deployment.md for Let's Encrypt / BYO /
|
|
18
|
+
## Cloudflare Origin Cert options).
|
|
14
19
|
##
|
|
15
20
|
## Deploy:
|
|
16
21
|
## wrangler deploy
|
|
@@ -25,7 +30,7 @@ compatibility_flags = ["nodejs_compat"]
|
|
|
25
30
|
enabled = true
|
|
26
31
|
|
|
27
32
|
# --- Container definition ---
|
|
28
|
-
# The IRC TCP origin container.
|
|
33
|
+
# The IRC TCP origin container. Serves TLS on :6697; the Worker below is a
|
|
29
34
|
# thin management/health-check wrapper (Spectrum routes TCP directly to the
|
|
30
35
|
# container origin, not through the Worker).
|
|
31
36
|
[[containers]]
|
|
@@ -50,5 +55,13 @@ new_sqlite_classes = ["IrcTcpOrigin"]
|
|
|
50
55
|
SERVER_NAME = "irc.your-domain.invalid"
|
|
51
56
|
NETWORK_NAME = "ExampleNet"
|
|
52
57
|
MOTD_LINES = "Welcome to the ServerlessIRCd TCP origin."
|
|
58
|
+
# TLS listener: the origin terminates TLS (Spectrum tls_mode = "full").
|
|
59
|
+
# Paths point at the documented /data/certs mount; files must be
|
|
60
|
+
# readable by UID 1000 (node).
|
|
61
|
+
TLS_CERT_PATH = "/data/certs/tls.pem"
|
|
62
|
+
TLS_KEY_PATH = "/data/certs/tls.key"
|
|
63
|
+
# Plaintext debug listener knobs — inert unless DEBUG_PLAINTEXT=localhost
|
|
64
|
+
# is set (localhost-only; rejected when NODE_ENV=production, which the
|
|
65
|
+
# Dockerfile sets by default).
|
|
53
66
|
TCP_PORT = "6667"
|
|
54
67
|
PERSISTENCE_PATH = "/data/state.json"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-worker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Cloudflare Worker deploy glue: WebSocket edge entry point + DO bindings + wrangler pipeline",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"test:watch": "vitest",
|
|
14
14
|
"coverage": "vitest run --coverage",
|
|
15
15
|
"dev": "wrangler dev",
|
|
16
|
-
"deploy": "
|
|
16
|
+
"deploy": "node ../../tools/ci-hardening/dist/cf-deploy-cli.js wrangler.toml",
|
|
17
17
|
"smoke": "node scripts/smoke.mjs",
|
|
18
18
|
"clean": "rimraf dist coverage .tsbuildinfo .turbo"
|
|
19
19
|
},
|
|
@@ -31,17 +31,24 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import {
|
|
34
|
+
COUNTER_INSTANCE_NAME,
|
|
34
35
|
ChannelDO,
|
|
35
36
|
ChannelRegistryDO,
|
|
36
37
|
ConnectionDO,
|
|
38
|
+
CounterDO,
|
|
39
|
+
type CounterRpc,
|
|
37
40
|
type Env,
|
|
41
|
+
RATE_LIMIT_INSTANCE_NAME,
|
|
42
|
+
RateLimitDO,
|
|
43
|
+
type RateLimitRpc,
|
|
38
44
|
RegistryDO,
|
|
45
|
+
loadServerConfigFromCfEnv,
|
|
39
46
|
} from '@serverless-ircd/cf-adapter';
|
|
40
47
|
import { decideOrigin, parseWebOrigins } from './origin-allowlist';
|
|
41
48
|
|
|
42
49
|
// Re-exported for wrangler DO binding resolution. The class names below
|
|
43
50
|
// MUST match `class_name` in `wrangler.toml`.
|
|
44
|
-
export { ChannelDO, ChannelRegistryDO, ConnectionDO, RegistryDO };
|
|
51
|
+
export { ChannelDO, ChannelRegistryDO, ConnectionDO, CounterDO, RateLimitDO, RegistryDO };
|
|
45
52
|
export type { Env };
|
|
46
53
|
|
|
47
54
|
/**
|
|
@@ -99,12 +106,33 @@ const ORIGIN_DENIED_BODY = 'WebSocket upgrade refused: the request Origin is not
|
|
|
99
106
|
const ORIGIN_UNCONFIGURED_BODY =
|
|
100
107
|
'WebSocket upgrades refused: could not determine the request origin and WEB_ORIGINS is not configured.';
|
|
101
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Body of the HTTP 429 admission rejection, delivered once the global
|
|
111
|
+
* live-connection counter (`maxClients` cap) is full. Carries the IRC
|
|
112
|
+
* `ERROR :Closing link` line so non-browser clients (the
|
|
113
|
+
* tcp-ws-forwarder, curl) surface a readable reason: pre-handshake there
|
|
114
|
+
* is no WebSocket, so the body is the only channel available ("delivered
|
|
115
|
+
* where possible"). 429 — not 403 — marks this as back-pressure: the
|
|
116
|
+
* client is welcome to retry once a slot frees.
|
|
117
|
+
*/
|
|
118
|
+
const SERVER_FULL_BODY = 'ERROR :Closing link: server full';
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Body of the HTTP 429 rejection for an IP over its `perIpConnectionRate`
|
|
122
|
+
* budget. Same conventions as {@link SERVER_FULL_BODY}: the IRC ERROR
|
|
123
|
+
* line rides in the body, and 429 marks back-pressure — the client is
|
|
124
|
+
* welcome to retry once the sliding window decays.
|
|
125
|
+
*/
|
|
126
|
+
const RATE_EXCEEDED_BODY = 'ERROR :Closing link: connection rate exceeded';
|
|
127
|
+
|
|
102
128
|
/**
|
|
103
129
|
* Default edge handler.
|
|
104
130
|
*
|
|
105
131
|
* Routing order:
|
|
106
|
-
* 1. WebSocket upgrade → CSWSH `Origin` check
|
|
107
|
-
* `
|
|
132
|
+
* 1. WebSocket upgrade → CSWSH `Origin` check → per-IP upgrade rate
|
|
133
|
+
* check (`perIpConnectionRate` via the RateLimitDO; 429 when over
|
|
134
|
+
* budget) → `maxClients` admission check (global live-connection
|
|
135
|
+
* counter; 429 when full), then per-connection `ConnectionDO`.
|
|
108
136
|
* 2. `GET /health` → plaintext liveness response.
|
|
109
137
|
* 3. everything else → `env.ASSETS` (Kiwi SPA at `/webclient/`, landing at `/`).
|
|
110
138
|
*
|
|
@@ -128,9 +156,53 @@ export default {
|
|
|
128
156
|
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
129
157
|
});
|
|
130
158
|
}
|
|
159
|
+
const config = loadServerConfigFromCfEnv(env);
|
|
160
|
+
// Per-IP upgrade rate check: budget how fast one source IP may
|
|
161
|
+
// drive connection setup (credential brute-force needs exactly
|
|
162
|
+
// that setup rate). Keyed by CF-Connecting-IP, which the
|
|
163
|
+
// Cloudflare edge stamps on every request — without it there is
|
|
164
|
+
// no key to budget on, so the check is skipped (production
|
|
165
|
+
// traffic always enters via the edge). Runs BEFORE the global
|
|
166
|
+
// counter so a flooding IP never consumes `maxClients` slots.
|
|
167
|
+
const clientIp = request.headers.get('CF-Connecting-IP');
|
|
168
|
+
if (clientIp !== null) {
|
|
169
|
+
const limiter = env.RATE_LIMIT_DO.get(
|
|
170
|
+
env.RATE_LIMIT_DO.idFromName(RATE_LIMIT_INSTANCE_NAME),
|
|
171
|
+
);
|
|
172
|
+
const verdict = await (limiter as unknown as RateLimitRpc).check(
|
|
173
|
+
clientIp,
|
|
174
|
+
config.perIpConnectionRate.max,
|
|
175
|
+
config.perIpConnectionRate.windowMs,
|
|
176
|
+
);
|
|
177
|
+
if (!verdict.allowed) {
|
|
178
|
+
return new Response(RATE_EXCEEDED_BODY, {
|
|
179
|
+
status: 429,
|
|
180
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
131
184
|
const connId = crypto.randomUUID();
|
|
132
|
-
const
|
|
133
|
-
|
|
185
|
+
const doId = env.CONNECTION_DO.idFromName(connId);
|
|
186
|
+
// Admission cap: reserve a slot in the global live-connection
|
|
187
|
+
// counter before the upgrade is forwarded. The CounterDO entry is
|
|
188
|
+
// keyed by the ConnectionDO hex id (`doId.toString()` — exactly
|
|
189
|
+
// what the DO itself computes from `ctx.id` on its teardown
|
|
190
|
+
// paths), so the slot is released under the same key when the
|
|
191
|
+
// connection closes without threading the id through the request.
|
|
192
|
+
// Concurrent upgrades are linearised by the counter DO's
|
|
193
|
+
// serialized event loop, so the cap holds under races. A DO that
|
|
194
|
+
// dies without a close event is reconciled by the counter's lazy
|
|
195
|
+
// TTL reap (its PING-alarm heartbeats stop), so slots self-heal.
|
|
196
|
+
const maxClients = config.maxClients;
|
|
197
|
+
const counter = env.COUNTER_DO.get(env.COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME));
|
|
198
|
+
const admission = await (counter as unknown as CounterRpc).admit(doId.toString(), maxClients);
|
|
199
|
+
if (!admission.admitted) {
|
|
200
|
+
return new Response(SERVER_FULL_BODY, {
|
|
201
|
+
status: 429,
|
|
202
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const stub = env.CONNECTION_DO.get(doId);
|
|
134
206
|
return stub.fetch(request);
|
|
135
207
|
}
|
|
136
208
|
const url = new URL(request.url);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient declaration so `?raw` imports (Vite's built-in transform that
|
|
3
|
+
* inlines a file's text at bundle time) typecheck under the strict
|
|
4
|
+
* `tsconfig.test.json`. Mirrors the declaration used by the irc-server and
|
|
5
|
+
* cf-adapter test suites for their source-level regression guards.
|
|
6
|
+
*/
|
|
7
|
+
declare module '*?raw' {
|
|
8
|
+
const content: string;
|
|
9
|
+
// eslint-disable-next-line import/no-default-export
|
|
10
|
+
export default content;
|
|
11
|
+
}
|
|
@@ -27,6 +27,10 @@ declare global {
|
|
|
27
27
|
REGISTRY_DO: DurableObjectNamespace;
|
|
28
28
|
CHANNEL_DO: DurableObjectNamespace;
|
|
29
29
|
CHANNEL_REGISTRY_DO: DurableObjectNamespace;
|
|
30
|
+
// Global live-connection admission counter (maxClients cap).
|
|
31
|
+
COUNTER_DO: DurableObjectNamespace;
|
|
32
|
+
// Per-IP upgrade rate limiter (perIpConnectionRate budget).
|
|
33
|
+
RATE_LIMIT_DO: DurableObjectNamespace;
|
|
30
34
|
SERVER_NAME: string;
|
|
31
35
|
NETWORK_NAME: string;
|
|
32
36
|
MOTD_LINES: string;
|