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
@@ -9,12 +9,18 @@ import { type ContainerServer, startContainerServer } from '../src/container-ser
9
9
  /**
10
10
  * Minimal IRC-over-TCP test client. Writes lines with CRLF, parses inbound
11
11
  * bytes into IRC lines (tolerant of CRLF and bare LF).
12
+ *
13
+ * By default it emulates the Cloudflare Spectrum edge: the connection
14
+ * opens with a PROXY protocol v1 header carrying `proxySourceIp` (the
15
+ * "real client IP" as the edge would report it). Pass
16
+ * `{ proxySourceIp: null }` for direct-connection scenarios where the
17
+ * client speaks raw IRC with no header.
12
18
  */
13
19
  class TcpClient extends EventEmitter {
14
20
  readonly socket: Socket;
15
21
  readonly received: string[] = [];
16
22
 
17
- constructor(host: string, port: number) {
23
+ constructor(host: string, port: number, opts: { proxySourceIp?: string | null } = {}) {
18
24
  super();
19
25
  this.socket = createConnection({ host, port });
20
26
  this.socket.setEncoding('utf8');
@@ -26,6 +32,15 @@ class TcpClient extends EventEmitter {
26
32
  }
27
33
  }
28
34
  });
35
+ const src = opts.proxySourceIp === undefined ? '127.0.0.1' : opts.proxySourceIp;
36
+ if (src !== null) {
37
+ const family = src.includes(':') ? 'TCP6' : 'TCP4';
38
+ const dst = family === 'TCP6' ? '2001:db8::2' : '192.0.2.1';
39
+ // Writing on a connecting socket is safe: Node queues the bytes
40
+ // until the TCP connection is established, so the header always
41
+ // lands before any subsequent send().
42
+ this.socket.write(`PROXY ${family} ${src} ${dst} 40000 6697\r\n`);
43
+ }
29
44
  }
30
45
 
31
46
  opened(): Promise<void> {
@@ -93,7 +108,8 @@ describe('container-server — lifecycle', () => {
93
108
  serverName: 'irc.test',
94
109
  networkName: 'TestNet',
95
110
  });
96
- expect(srv.port).toBeGreaterThan(0);
111
+ expect(srv.port).toBeDefined();
112
+ expect(srv.port as number).toBeGreaterThan(0);
97
113
  expect(srv.host).toBe('127.0.0.1');
98
114
  await srv.close();
99
115
  });
@@ -141,7 +157,7 @@ describe('container-server — option wiring (coverage matrix)', () => {
141
157
  });
142
158
  expect(srv.host).toBe('0.0.0.0');
143
159
  // Connecting via 127.0.0.1 still reaches the 0.0.0.0 listener.
144
- const c = new TcpClient('127.0.0.1', srv.port);
160
+ const c = new TcpClient('127.0.0.1', srv.port as number);
145
161
  await c.opened();
146
162
  await c.close();
147
163
  await srv.close();
@@ -157,7 +173,7 @@ describe('container-server — option wiring (coverage matrix)', () => {
157
173
  networkName: 'AdmissionNet',
158
174
  maxConnectionsPerUser: 5,
159
175
  });
160
- const c = new TcpClient('127.0.0.1', srv.port);
176
+ const c = new TcpClient('127.0.0.1', srv.port as number);
161
177
  await c.opened();
162
178
  await c.close();
163
179
  await srv.close();
@@ -173,7 +189,7 @@ describe('container-server — option wiring (coverage matrix)', () => {
173
189
  cloaking: { enabled: true, secret: 'cloak-secret', cloakedSuffix: '.hidden' },
174
190
  saslAccounts: [{ username: 'alice', password: 'pw' }],
175
191
  });
176
- const c = new TcpClient('127.0.0.1', srv.port);
192
+ const c = new TcpClient('127.0.0.1', srv.port as number);
177
193
  await c.opened();
178
194
  await c.close();
179
195
  await srv.close();
@@ -197,8 +213,8 @@ describe('container-server — IRC flows over TCP', () => {
197
213
  });
198
214
 
199
215
  it('completes register → join → privmsg → quit', async () => {
200
- const alice = new TcpClient('127.0.0.1', server.port);
201
- const bob = new TcpClient('127.0.0.1', server.port);
216
+ const alice = new TcpClient('127.0.0.1', server.port as number);
217
+ const bob = new TcpClient('127.0.0.1', server.port as number);
202
218
  await alice.opened();
203
219
  await bob.opened();
204
220
 
@@ -223,7 +239,7 @@ describe('container-server — IRC flows over TCP', () => {
223
239
  });
224
240
 
225
241
  it('routes PING → PONG', async () => {
226
- const client = new TcpClient('127.0.0.1', server.port);
242
+ const client = new TcpClient('127.0.0.1', server.port as number);
227
243
  await client.opened();
228
244
  await client.send('NICK pinger');
229
245
  await client.send('USER pinger 0 * :Pinger');
@@ -235,7 +251,7 @@ describe('container-server — IRC flows over TCP', () => {
235
251
  });
236
252
 
237
253
  it('reassembles a single IRC message split across two TCP chunks', async () => {
238
- const client = new TcpClient('127.0.0.1', server.port);
254
+ const client = new TcpClient('127.0.0.1', server.port as number);
239
255
  await client.opened();
240
256
  await client.send('NICK splitter');
241
257
  await client.send('USER splitter 0 * :Splitter');
@@ -248,7 +264,7 @@ describe('container-server — IRC flows over TCP', () => {
248
264
  });
249
265
 
250
266
  it('processes multiple IRC lines delivered in a single TCP chunk', async () => {
251
- const client = new TcpClient('127.0.0.1', server.port);
267
+ const client = new TcpClient('127.0.0.1', server.port as number);
252
268
  await client.opened();
253
269
  await client.writeRaw('NICK bundler\r\nUSER bundler 0 * :Bundler\r\n');
254
270
  await client.waitFor((l) => l.startsWith(':irc.example.com 001 bundler'));
@@ -256,7 +272,7 @@ describe('container-server — IRC flows over TCP', () => {
256
272
  });
257
273
 
258
274
  it('cleans up the runtime connection on abrupt socket termination', async () => {
259
- const client = new TcpClient('127.0.0.1', server.port);
275
+ const client = new TcpClient('127.0.0.1', server.port as number);
260
276
  await client.opened();
261
277
  await client.send('NICK termUser');
262
278
  await client.send('USER termUser 0 * :Term');
@@ -272,7 +288,7 @@ describe('container-server — IRC flows over TCP', () => {
272
288
  });
273
289
 
274
290
  it('delivers an ERROR notice when a reducer supplies a disconnect reason', async () => {
275
- const client = new TcpClient('127.0.0.1', server.port);
291
+ const client = new TcpClient('127.0.0.1', server.port as number);
276
292
  await client.opened();
277
293
  await client.send('NICK floodUser');
278
294
  await client.send('USER floodUser 0 * :Flood');
@@ -285,6 +301,35 @@ describe('container-server — IRC flows over TCP', () => {
285
301
  await expect(wait).resolves.toContain('Excess Flood');
286
302
  await client.close();
287
303
  });
304
+
305
+ it('closes the connection with ERROR when the input buffer cap is exceeded', async () => {
306
+ // A peer streaming > 8 KiB with no line terminator would grow the
307
+ // transport buffer unboundedly; the origin must answer with the
308
+ // RFC-style ERROR notice, destroy the socket, and unregister the
309
+ // connection from the runtime.
310
+ const client = new TcpClient('127.0.0.1', server.port as number);
311
+ await client.opened();
312
+ await client.send('NICK overflower');
313
+ await client.send('USER overflower 0 * :Over');
314
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
315
+
316
+ const connId = await server.runtime.lookupNick('overflower');
317
+ expect(connId).not.toBeNull();
318
+ expect(server.runtime.hasConnection(connId as string)).toBe(true);
319
+
320
+ const closed = new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
321
+ await client.writeRaw('x'.repeat(9 * 1024)); // no terminator anywhere
322
+ const errorLine = await client.waitFor(
323
+ (l) => l === 'ERROR :Closing link: input buffer overflow',
324
+ 2_000,
325
+ );
326
+ expect(errorLine).toBe('ERROR :Closing link: input buffer overflow');
327
+ await closed;
328
+ expect(client.socket.destroyed).toBe(true);
329
+ // Cleanup ran: the runtime no longer tracks the connection.
330
+ await new Promise((r) => setTimeout(r, 100));
331
+ expect(server.runtime.hasConnection(connId as string)).toBe(false);
332
+ });
288
333
  });
289
334
 
290
335
  describe('container-server — persistence across restart', () => {
@@ -309,7 +354,7 @@ describe('container-server — persistence across restart', () => {
309
354
  networkName: 'PersistNet',
310
355
  persistencePath: statePath,
311
356
  });
312
- const c1 = new TcpClient('127.0.0.1', srv1.port);
357
+ const c1 = new TcpClient('127.0.0.1', srv1.port as number);
313
358
  await c1.opened();
314
359
  await c1.send('NICK op');
315
360
  await c1.send('USER op 0 * :Op');
@@ -331,7 +376,7 @@ describe('container-server — persistence across restart', () => {
331
376
  networkName: 'PersistNet',
332
377
  persistencePath: statePath,
333
378
  });
334
- const c2 = new TcpClient('127.0.0.1', srv2.port);
379
+ const c2 = new TcpClient('127.0.0.1', srv2.port as number);
335
380
  await c2.opened();
336
381
  await c2.send('NICK late');
337
382
  await c2.send('USER late 0 * :Late');
@@ -359,10 +404,15 @@ describe('container-server — admission gate', () => {
359
404
  networkName: 'GateNet',
360
405
  maxConnectionsPerIp: 1,
361
406
  });
362
- const c1 = new TcpClient('127.0.0.1', srv.port);
407
+ const c1 = new TcpClient('127.0.0.1', srv.port as number);
363
408
  await c1.opened();
364
- // Second connection from the same IP should be rejected.
365
- const c2 = new TcpClient('127.0.0.1', srv.port);
409
+ // Admission is committed once the PROXY header has been parsed, so
410
+ // let c1 fully register before a second flow from the same IP.
411
+ await c1.send('NICK gateOne');
412
+ await c1.send('USER gateOne 0 * :Gate');
413
+ await c1.waitFor((l) => l.startsWith(':irc.gate 001 '), 2000);
414
+ // Second connection from the same source IP should be rejected.
415
+ const c2 = new TcpClient('127.0.0.1', srv.port as number);
366
416
  const error = await new Promise<string | null>((resolve) => {
367
417
  c2.socket.setEncoding('utf8');
368
418
  c2.socket.once('data', (d: string) => resolve(d));
@@ -389,7 +439,7 @@ describe('container-server — snapshot error resilience', () => {
389
439
  persistencePath: path,
390
440
  });
391
441
  // Server should still work — starts fresh.
392
- const c = new TcpClient('127.0.0.1', srv.port);
442
+ const c = new TcpClient('127.0.0.1', srv.port as number);
393
443
  await c.opened();
394
444
  await c.send('NICK test');
395
445
  await c.send('USER test 0 * :Test');
@@ -410,7 +460,7 @@ describe('container-server — snapshot error resilience', () => {
410
460
  persistencePath: path,
411
461
  snapshotIntervalMs: 999_999,
412
462
  });
413
- const c = new TcpClient('127.0.0.1', srv.port);
463
+ const c = new TcpClient('127.0.0.1', srv.port as number);
414
464
  await c.opened();
415
465
  // Wait for the server-side socket to be registered in the sockets set.
416
466
  for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
@@ -435,7 +485,7 @@ describe('container-server — snapshot error resilience', () => {
435
485
  serverName: 'irc.skip',
436
486
  networkName: 'SkipNet',
437
487
  });
438
- const c = new TcpClient('127.0.0.1', srv.port);
488
+ const c = new TcpClient('127.0.0.1', srv.port as number);
439
489
  await c.opened();
440
490
  for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
441
491
  await new Promise((r) => setTimeout(r, 5));
@@ -466,7 +516,7 @@ describe('container-server — snapshot error resilience', () => {
466
516
  persistencePath: dirPath,
467
517
  snapshotIntervalMs: 999_999,
468
518
  });
469
- const c = new TcpClient('127.0.0.1', srv.port);
519
+ const c = new TcpClient('127.0.0.1', srv.port as number);
470
520
  await c.opened();
471
521
  await c.send('NICK finalFail');
472
522
  await c.send('USER finalFail 0 * :FinalFail');
@@ -493,8 +543,11 @@ describe('container-server — error paths', () => {
493
543
  srv.runtime.send = async () => {
494
544
  throw new Error('synthetic send failure');
495
545
  };
496
- const client = new TcpClient('127.0.0.1', srv.port);
546
+ const client = new TcpClient('127.0.0.1', srv.port as number);
497
547
  await client.opened();
548
+ // Settle so the PROXY header is consumed as its own chunk; every
549
+ // subsequent IRC line then flows through the post-attach data path.
550
+ await new Promise((r) => setTimeout(r, 50));
498
551
  await client.send('NICK errUser');
499
552
  await client.send('USER errUser 0 * :Err');
500
553
  // PING triggers a Send → synthetic throw → actor catch → logError.
@@ -503,8 +556,9 @@ describe('container-server — error paths', () => {
503
556
 
504
557
  srv.runtime.send = original;
505
558
  // Server should still serve new connections.
506
- const next = new TcpClient('127.0.0.1', srv.port);
559
+ const next = new TcpClient('127.0.0.1', srv.port as number);
507
560
  await next.opened();
561
+ await new Promise((r) => setTimeout(r, 50));
508
562
  await next.send('NICK recover');
509
563
  await next.send('USER recover 0 * :Recover');
510
564
  await next.waitFor((l) => l.startsWith(':irc.err 001 '), 3000);
@@ -527,16 +581,18 @@ describe('container-server — error paths', () => {
527
581
  // eslint-disable-next-line no-throw-literal
528
582
  throw { code: 'SYNTHETIC', detail: 'not an Error' };
529
583
  };
530
- const client = new TcpClient('127.0.0.1', srv.port);
584
+ const client = new TcpClient('127.0.0.1', srv.port as number);
531
585
  await client.opened();
586
+ await new Promise((r) => setTimeout(r, 50));
532
587
  await client.send('NICK nonErrUser');
533
588
  await client.send('USER nonErrUser 0 * :NonErr');
534
589
  await client.send('PING :will-throw-non-error');
535
590
  await new Promise((r) => setTimeout(r, 100));
536
591
 
537
592
  srv.runtime.send = original;
538
- const next = new TcpClient('127.0.0.1', srv.port);
593
+ const next = new TcpClient('127.0.0.1', srv.port as number);
539
594
  await next.opened();
595
+ await new Promise((r) => setTimeout(r, 50));
540
596
  await next.send('NICK recover2');
541
597
  await next.send('USER recover2 0 * :Recover2');
542
598
  await next.waitFor((l) => l.startsWith(':irc.nonerr 001 '), 3000);
@@ -554,7 +610,7 @@ describe('container-server — error paths', () => {
554
610
  serverName: 'irc.noreason',
555
611
  networkName: 'NoReasonNet',
556
612
  });
557
- const client = new TcpClient('127.0.0.1', srv.port);
613
+ const client = new TcpClient('127.0.0.1', srv.port as number);
558
614
  await client.opened();
559
615
  await client.send('NICK noReason');
560
616
  await client.send('USER noReason 0 * :NoReason');
@@ -580,7 +636,7 @@ describe('container-server — error paths', () => {
580
636
  serverName: 'irc.unwritable',
581
637
  networkName: 'UnwritableNet',
582
638
  });
583
- const client = new TcpClient('127.0.0.1', srv.port);
639
+ const client = new TcpClient('127.0.0.1', srv.port as number);
584
640
  await client.opened();
585
641
  await client.send('NICK unwritable');
586
642
  await client.send('USER unwritable 0 * :Unwritable');
@@ -609,7 +665,7 @@ describe('container-server — error paths', () => {
609
665
  serverName: 'irc.sockerr',
610
666
  networkName: 'SockErrNet',
611
667
  });
612
- const client = new TcpClient('127.0.0.1', srv.port);
668
+ const client = new TcpClient('127.0.0.1', srv.port as number);
613
669
  await client.opened();
614
670
  for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
615
671
  await new Promise((r) => setTimeout(r, 5));
@@ -619,7 +675,7 @@ describe('container-server — error paths', () => {
619
675
  serverSideSocket.emit('error', new Error('synthetic socket failure'));
620
676
  await new Promise((r) => setImmediate(r));
621
677
  // Server should still serve new connections.
622
- const next = new TcpClient('127.0.0.1', srv.port);
678
+ const next = new TcpClient('127.0.0.1', srv.port as number);
623
679
  await next.opened();
624
680
  await next.close();
625
681
  await client.close();
@@ -638,7 +694,7 @@ describe('container-server — error paths', () => {
638
694
  snapshotIntervalMs: 100,
639
695
  });
640
696
  // Create a channel so the snapshot has content.
641
- const c = new TcpClient('127.0.0.1', srv.port);
697
+ const c = new TcpClient('127.0.0.1', srv.port as number);
642
698
  await c.opened();
643
699
  await c.send('NICK autoUser');
644
700
  await c.send('USER autoUser 0 * :Auto');
@@ -666,7 +722,7 @@ describe('container-server — draft/multiline BATCH to a nick target', () => {
666
722
 
667
723
  /** Negotiates `batch` + `draft/multiline`, then registers the nick. */
668
724
  async function registerMultilineCapable(srv: ContainerServer, nick: string): Promise<TcpClient> {
669
- const c = new TcpClient('127.0.0.1', srv.port);
725
+ const c = new TcpClient('127.0.0.1', srv.port as number);
670
726
  await c.opened();
671
727
  await c.send('CAP LS 302');
672
728
  await c.waitFor((l) => l.startsWith(':irc.ml CAP * LS :'), 2000);
@@ -711,3 +767,274 @@ describe('container-server — draft/multiline BATCH to a nick target', () => {
711
767
  await srv.close();
712
768
  });
713
769
  });
770
+
771
+ describe('container-server — PROXY protocol v1', () => {
772
+ // Spectrum is configured with `proxy_protocol = "v1"`: every forwarded
773
+ // flow starts with `PROXY TCP4|TCP6 <src> <dst> <sport> <dport>\r\n`.
774
+ // The origin must peel that header off before the bytes reach the IRC
775
+ // actor and must key per-IP admission on the recovered source IP.
776
+
777
+ it('admits a headered connection with no 421 on the first frame', async () => {
778
+ const srv = await startContainerServer({
779
+ port: 0,
780
+ host: '127.0.0.1',
781
+ serverName: 'irc.proxy',
782
+ networkName: 'ProxyNet',
783
+ });
784
+ const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: '203.0.113.5' });
785
+ await c.opened();
786
+ await c.send('NICK viaProxy');
787
+ await c.send('USER viaProxy 0 * :Via');
788
+ await c.waitFor((l) => l.startsWith(':irc.proxy 001 '), 2000);
789
+ // The PROXY line must never surface as an unknown IRC command.
790
+ expect(c.received.some((l) => l.includes(' 421 '))).toBe(false);
791
+ await c.close();
792
+ await srv.close();
793
+ });
794
+
795
+ it('surfaces the PROXY source IP as the connection host', async () => {
796
+ const srv = await startContainerServer({
797
+ port: 0,
798
+ host: '127.0.0.1',
799
+ serverName: 'irc.hostip',
800
+ networkName: 'HostIpNet',
801
+ });
802
+ const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: '203.0.113.77' });
803
+ await c.opened();
804
+ await c.send('NICK hostcheck');
805
+ await c.send('USER hostcheck 0 * :Host');
806
+ await c.waitFor((l) => l.startsWith(':irc.hostip 001 '), 2000);
807
+ const connId = await srv.runtime.lookupNick('hostcheck');
808
+ expect(connId).not.toBeNull();
809
+ const state = await srv.runtime.getConnection(connId as string);
810
+ // Not the socket peer address (127.0.0.1) — the PROXY-header IP.
811
+ expect(state?.host).toBe('203.0.113.77');
812
+ await c.close();
813
+ await srv.close();
814
+ });
815
+
816
+ it('parses TCP6-family headers', async () => {
817
+ const srv = await startContainerServer({
818
+ port: 0,
819
+ host: '127.0.0.1',
820
+ serverName: 'irc.v6',
821
+ networkName: 'V6Net',
822
+ });
823
+ const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: '2001:db8::1' });
824
+ await c.opened();
825
+ await c.send('NICK v6user');
826
+ await c.send('USER v6user 0 * :V6');
827
+ await c.waitFor((l) => l.startsWith(':irc.v6 001 '), 2000);
828
+ const connId = await srv.runtime.lookupNick('v6user');
829
+ expect(connId).not.toBeNull();
830
+ const state = await srv.runtime.getConnection(connId as string);
831
+ expect(state?.host).toBe('2001:db8::1');
832
+ await c.close();
833
+ await srv.close();
834
+ });
835
+
836
+ it('reassembles a header split across two TCP chunks', async () => {
837
+ const srv = await startContainerServer({
838
+ port: 0,
839
+ host: '127.0.0.1',
840
+ serverName: 'irc.split',
841
+ networkName: 'SplitNet',
842
+ });
843
+ const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: null });
844
+ await c.opened();
845
+ await c.writeRaw('PROXY TCP4 192.0.2.5');
846
+ // Let the partial header be delivered as its own chunk (the server
847
+ // must buffer it), then complete the header in a second chunk that
848
+ // also carries IRC bytes behind it.
849
+ await new Promise((r) => setTimeout(r, 50));
850
+ await c.writeRaw(' 198.51.100.7 40001 6697\r\nNICK hdrsplit\r\n');
851
+ await c.send('USER hdrsplit 0 * :Split');
852
+ await c.waitFor((l) => l.startsWith(':irc.split 001 '), 2000);
853
+ expect(c.received.some((l) => l.includes(' 421 '))).toBe(false);
854
+ const connId = await srv.runtime.lookupNick('hdrsplit');
855
+ expect(connId).not.toBeNull();
856
+ const state = await srv.runtime.getConnection(connId as string);
857
+ expect(state?.host).toBe('192.0.2.5');
858
+ await c.close();
859
+ await srv.close();
860
+ });
861
+
862
+ it('rejects a direct connection that speaks IRC without a PROXY header', async () => {
863
+ const srv = await startContainerServer({
864
+ port: 0,
865
+ host: '127.0.0.1',
866
+ serverName: 'irc.direct',
867
+ networkName: 'DirectNet',
868
+ });
869
+ const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: null });
870
+ await c.opened();
871
+ const closed = new Promise<void>((resolve) => c.socket.once('close', () => resolve()));
872
+ await c.send('NICK direct');
873
+ const errorLine = await c.waitFor((l) => l.startsWith('ERROR :Closing link:'), 2000);
874
+ expect(errorLine).toContain('PROXY');
875
+ await closed;
876
+ expect(c.socket.destroyed).toBe(true);
877
+ // No IRC identity was ever created for the rejected flow.
878
+ expect(await srv.runtime.lookupNick('direct')).toBeNull();
879
+ await srv.close();
880
+ });
881
+
882
+ it('rejects a malformed PROXY header', async () => {
883
+ const srv = await startContainerServer({
884
+ port: 0,
885
+ host: '127.0.0.1',
886
+ serverName: 'irc.badhdr',
887
+ networkName: 'BadHdrNet',
888
+ });
889
+ const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: null });
890
+ await c.opened();
891
+ const closed = new Promise<void>((resolve) => c.socket.once('close', () => resolve()));
892
+ await c.writeRaw('PROXY UNKNOWN\r\n');
893
+ const errorLine = await c.waitFor((l) => l.startsWith('ERROR :Closing link:'), 2000);
894
+ expect(errorLine).toContain('PROXY');
895
+ await closed;
896
+ expect(c.socket.destroyed).toBe(true);
897
+ await srv.close();
898
+ });
899
+
900
+ it('logs an actor failure for IRC bytes coalesced with the header', async () => {
901
+ const srv = await startContainerServer({
902
+ port: 0,
903
+ host: '127.0.0.1',
904
+ serverName: 'irc.hdrerr',
905
+ networkName: 'HdrErrNet',
906
+ });
907
+ const original = srv.runtime.send.bind(srv.runtime);
908
+ srv.runtime.send = async () => {
909
+ throw new Error('synthetic send failure');
910
+ };
911
+ const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: null });
912
+ await c.opened();
913
+ // One write: the header and the full registration share a single TCP
914
+ // chunk, so the welcome send fails inside the remainder receive.
915
+ await c.writeRaw(
916
+ 'PROXY TCP4 203.0.113.99 192.0.2.1 40000 6697\r\nNICK hdrErr\r\nUSER hdrErr 0 * :E\r\n',
917
+ );
918
+ await new Promise((r) => setTimeout(r, 100));
919
+ srv.runtime.send = original;
920
+
921
+ // The server must survive the failure and serve a fresh connection.
922
+ const next = new TcpClient('127.0.0.1', srv.port as number);
923
+ await next.opened();
924
+ await new Promise((r) => setTimeout(r, 50));
925
+ await next.send('NICK hdrRecover');
926
+ await next.send('USER hdrRecover 0 * :R');
927
+ await next.waitFor((l) => l.startsWith(':irc.hdrerr 001 '), 2000);
928
+ await c.close();
929
+ await next.close();
930
+ await srv.close();
931
+ });
932
+
933
+ it('tracks admission counters per real source IP, not the edge address', async () => {
934
+ // With the pre-PROXY bug, every flow was admitted under the single
935
+ // Spectrum edge IP, so `maxConnectionsPerIp: 1` capped the aggregate.
936
+ // With per-real-IP counters, clients from distinct PROXY IPs are
937
+ // independent and only same-IP flows share a counter.
938
+ const srv = await startContainerServer({
939
+ port: 0,
940
+ host: '127.0.0.1',
941
+ serverName: 'irc.perip',
942
+ networkName: 'PerIpNet',
943
+ maxConnectionsPerIp: 1,
944
+ });
945
+ const a1 = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: '203.0.113.10' });
946
+ await a1.opened();
947
+ await a1.send('NICK ipA');
948
+ await a1.send('USER ipA 0 * :A');
949
+ await a1.waitFor((l) => l.startsWith(':irc.perip 001 '), 2000);
950
+
951
+ // A client from a distinct PROXY IP: independently admitted.
952
+ const b = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: '198.51.100.20' });
953
+ await b.opened();
954
+ await b.send('NICK ipB');
955
+ await b.send('USER ipB 0 * :B');
956
+ await b.waitFor((l) => l.startsWith(':irc.perip 001 '), 2000);
957
+
958
+ // A second flow from ipA's address: rejected by the per-IP cap.
959
+ const a2 = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: '203.0.113.10' });
960
+ const error = await new Promise<string | null>((resolve) => {
961
+ a2.socket.once('data', (d: string) => resolve(d));
962
+ a2.socket.once('close', () => resolve(null));
963
+ setTimeout(() => resolve(null), 2000);
964
+ });
965
+ expect(error).toContain('Closing link');
966
+
967
+ await a1.close();
968
+ await b.close();
969
+ a2.destroy();
970
+ await srv.close();
971
+ });
972
+ });
973
+
974
+ describe('container-server — SASL EXTERNAL non-support', () => {
975
+ // The TCP container origin has no mTLS identity source (Spectrum
976
+ // speaks PROXY v1, which carries no client-cert subject), so SASL
977
+ // EXTERNAL is explicitly unsupported on this transport: the mechanism
978
+ // is rejected up front with a stable, transport-specific 904. The
979
+ // literal is asserted verbatim so the message cannot drift silently.
980
+ const EXTERNAL_904 =
981
+ ':irc.example.com 904 extuser :SASL EXTERNAL not supported on this transport';
982
+
983
+ let server: ContainerServer;
984
+
985
+ beforeAll(async () => {
986
+ server = await startContainerServer({
987
+ port: 0,
988
+ host: '127.0.0.1',
989
+ serverName: 'irc.example.com',
990
+ networkName: 'ExampleNet',
991
+ });
992
+ });
993
+
994
+ afterAll(async () => {
995
+ await server.close();
996
+ });
997
+
998
+ it('advertises sasl=PLAIN only (EXTERNAL excluded on this transport)', async () => {
999
+ const client = new TcpClient('127.0.0.1', server.port as number);
1000
+ await client.opened();
1001
+ await client.send('CAP LS 302');
1002
+ // Wait for the final LS line (a `LS *` continuation is not last).
1003
+ await client.waitFor((l) => /^:irc\.example\.com CAP \S+ LS :/u.test(l));
1004
+ const lsBody = client.received.filter((l) => l.includes(' LS ')).join(' ');
1005
+ expect(lsBody).toContain('sasl=PLAIN');
1006
+ expect(lsBody).not.toContain('EXTERNAL');
1007
+ await client.close();
1008
+ });
1009
+
1010
+ it('rejects AUTHENTICATE EXTERNAL with the transport-specific 904', async () => {
1011
+ const client = new TcpClient('127.0.0.1', server.port as number);
1012
+ await client.opened();
1013
+ await client.send('CAP LS 302');
1014
+ await client.waitFor((l) => /^:irc\.example\.com CAP \S+ LS :/u.test(l));
1015
+ await client.send('NICK extuser');
1016
+ await client.send('AUTHENTICATE EXTERNAL');
1017
+ await client.waitFor((l) => l === EXTERNAL_904);
1018
+ await client.close();
1019
+ });
1020
+
1021
+ it('does not trip the SASL failure lockout on repeated EXTERNAL attempts', async () => {
1022
+ // The transport-level rejection is not a credential attempt: with
1023
+ // the default lockout (3 failures) a counting implementation would
1024
+ // disconnect by the third attempt. Four attempts must all be
1025
+ // answered and the connection must stay alive.
1026
+ const client = new TcpClient('127.0.0.1', server.port as number);
1027
+ await client.opened();
1028
+ await client.send('CAP LS 302');
1029
+ await client.waitFor((l) => /^:irc\.example\.com CAP \S+ LS :/u.test(l));
1030
+ await client.send('NICK extuser');
1031
+ for (let i = 0; i < 4; i++) {
1032
+ await client.send('AUTHENTICATE EXTERNAL');
1033
+ }
1034
+ await client.send('PING :lockout-probe');
1035
+ await client.waitFor((l) => l === 'PONG :lockout-probe');
1036
+ expect(client.received.filter((l) => l === EXTERNAL_904)).toHaveLength(4);
1037
+ expect(client.received.some((l) => l.startsWith('ERROR :Closing link'))).toBe(false);
1038
+ await client.close();
1039
+ });
1040
+ });