serverless-ircd 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -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 listens on port 6667 (plaintext TCP);
7
- ## Cloudflare Spectrum (configured separately in terraform/spectrum.tf)
8
- ## terminates TLS on :6697 and forwards plaintext to this container.
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. Listens on port 6667; the Worker below is a
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.10.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": "wrangler 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, then per-connection
107
- * `ConnectionDO`.
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 id = env.CONNECTION_DO.idFromName(connId);
133
- const stub = env.CONNECTION_DO.get(id);
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;