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
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { BatchWriteCommandInput } from '@aws-sdk/lib-dynamodb';
14
- import { FakeClock } from '@serverless-ircd/irc-core';
14
+ import { FakeClock, certIdentityFromSubject } from '@serverless-ircd/irc-core';
15
15
  import { describe, expect, it } from 'vitest';
16
16
  import { DynamoServicesStore, loadDynamoServicesStore } from '../src/dynamo-services-store';
17
17
 
@@ -174,6 +174,32 @@ describe('DynamoServicesStore — round-trip via fake DDB', () => {
174
174
  expect(reloaded.verifyNick('alice', 'hunter2').ok).toBe(false);
175
175
  });
176
176
 
177
+ it('persists the identify-failure throttle and round-trips the freeze', async () => {
178
+ const clock = new FakeClock(NOW);
179
+ const { client } = makeFakeClient();
180
+ const first = await loadDynamoServicesStore(asClient(client), 'Services', clock);
181
+ if (first === undefined) throw new Error('store not loaded');
182
+ first.registerNick('alice', 'hunter2', 'a@x');
183
+ clock.advance(10);
184
+ first.recordIdentifyFailure('alice');
185
+ clock.advance(10);
186
+ first.recordIdentifyFailure('alice');
187
+ await first.flush();
188
+
189
+ // Fresh connection / deploy: the reload still sees the freeze.
190
+ const reloaded = await reload(client);
191
+ expect(reloaded.isNickIdentifyFrozen('alice', { maxFailures: 2, windowMs: 300_000 })).toBe(
192
+ true,
193
+ );
194
+ expect(reloaded.clearIdentifyFailures('alice')).toBe(2);
195
+
196
+ // The cleared state round-trips too.
197
+ await reloaded.flush();
198
+ const after = await reload(client);
199
+ expect(after.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
200
+ expect(after.clearIdentifyFailures('alice')).toBe(0);
201
+ });
202
+
177
203
  it('drops a NickServ registration after flush + reload', async () => {
178
204
  const { store, client } = await fresh();
179
205
  store.registerNick('alice', 'pw', 'a@x');
@@ -184,6 +210,54 @@ describe('DynamoServicesStore — round-trip via fake DDB', () => {
184
210
  expect((await reload(client)).isRegisteredNick('alice')).toBe(false);
185
211
  });
186
212
 
213
+ it('round-trips a bound cert fingerprint and verifies EXTERNAL after reload', async () => {
214
+ // A cert binding attached before a deploy must still authenticate a
215
+ // SASL EXTERNAL client after the store rehydrates from DynamoDB —
216
+ // the production path for certificate-backed login.
217
+ const { store, client } = await fresh();
218
+ store.registerNick('alice', 'hunter2', 'a@x');
219
+ store.addCertFingerprint('alice', 'AA112233445566778899AABBCCDDEEFF');
220
+ await store.flush();
221
+
222
+ const reloaded = await reload(client);
223
+ // The fingerprint is normalised (lowercase, colon-free) on the round-trip.
224
+ expect(reloaded.getNick('alice')?.certSubjects).toEqual([
225
+ 'fp:aa112233445566778899aabbccddeeff',
226
+ ]);
227
+ expect(
228
+ reloaded.verifyCertFP(
229
+ certIdentityFromSubject('CN=alice', 'aa112233445566778899aabbccddeeff'),
230
+ ),
231
+ ).toEqual({ ok: true, account: 'alice' });
232
+ });
233
+
234
+ it('round-trips a bound subject DN and verifies EXTERNAL via the canonical DN arm', async () => {
235
+ const { store, client } = await fresh();
236
+ store.registerNick('alice', 'hunter2', 'a@x');
237
+ store.addCertFP('alice', 'CN=alice,O=Example Inc');
238
+ await store.flush();
239
+
240
+ const reloaded = await reload(client);
241
+ // Platform-surfaced DN in a different RDN order still matches (canonical compare).
242
+ expect(reloaded.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice'))).toEqual({
243
+ ok: true,
244
+ account: 'alice',
245
+ });
246
+ });
247
+
248
+ it('keeps EXTERNAL verify failing for an unbound cert after reload', async () => {
249
+ const { store, client } = await fresh();
250
+ store.registerNick('alice', 'hunter2', 'a@x');
251
+ store.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
252
+ await store.flush();
253
+
254
+ const reloaded = await reload(client);
255
+ expect(
256
+ reloaded.verifyCertFP(certIdentityFromSubject('CN=alice', 'ffffffffffffffffffffffffffffffff'))
257
+ .ok,
258
+ ).toBe(false);
259
+ });
260
+
187
261
  it('persists channel + access + levels + topic, then drops the whole subtree', async () => {
188
262
  const { store, client } = await fresh();
189
263
  store.registerChannel('#chan', 'alice');
@@ -324,6 +398,54 @@ describe('DynamoServicesStore — round-trip via fake DDB', () => {
324
398
  });
325
399
 
326
400
  describe('DynamoServicesStore — snapshot coercion tolerance', () => {
401
+ it('defaults the identify-throttle fields on a legacy nick item (attrs absent)', async () => {
402
+ // A nick item written before the identify throttle existed carries no
403
+ // identifyFailures / pendingIdentifyFailureNotice attributes.
404
+ const { client, items } = makeFakeClient();
405
+ items.set('NICK:alice\x00#', {
406
+ pk: 'NICK:alice',
407
+ sk: '#',
408
+ type: 'nick',
409
+ nick: 'alice',
410
+ account: 'alice',
411
+ email: 'a@x',
412
+ createdAt: NOW,
413
+ enforce: 'none',
414
+ // Mixed-types array: exercises the string-filter callback on load.
415
+ certSubjects: ['CN=alice', 7],
416
+ algorithm: 'scrypt',
417
+ salt: 'c2FsdA==',
418
+ hash: 'aGFzaA==',
419
+ });
420
+ const store = await loadDynamoServicesStore(asClient(client), 'Services', new FakeClock(NOW));
421
+ if (store === undefined) throw new Error('store not loaded');
422
+ expect(store.isRegisteredNick('alice')).toBe(true);
423
+ expect(store.getNick('alice')?.certSubjects).toEqual(['CN=alice']);
424
+ expect(store.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
425
+ expect(store.clearIdentifyFailures('alice')).toBe(0);
426
+ // Ill-typed throttle attributes collapse to the same neutral defaults.
427
+ items.set('NICK:bob\x00#', {
428
+ pk: 'NICK:bob',
429
+ sk: '#',
430
+ type: 'nick',
431
+ nick: 'bob',
432
+ account: 'bob',
433
+ email: 'b@x',
434
+ createdAt: NOW,
435
+ enforce: 'none',
436
+ certSubjects: [],
437
+ identifyFailures: 'soon',
438
+ pendingIdentifyFailureNotice: 'many',
439
+ algorithm: 'scrypt',
440
+ salt: 'c2FsdA==',
441
+ hash: 'aGFzaA==',
442
+ });
443
+ const second = await loadDynamoServicesStore(asClient(client), 'Services', new FakeClock(NOW));
444
+ if (second === undefined) throw new Error('store not loaded');
445
+ expect(second.isNickIdentifyFrozen('bob', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
446
+ expect(second.clearIdentifyFailures('bob')).toBe(0);
447
+ });
448
+
327
449
  it('skips items with unknown or ill-typed fields', async () => {
328
450
  // Hand-craft malformed items directly in the fake table.
329
451
  const { client, items } = makeFakeClient();
@@ -62,7 +62,7 @@ async function makeFixture(): Promise<Fixture> {
62
62
  const pkName = props.partitionKey?.name;
63
63
  const skName = props.sortKey?.name;
64
64
  if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
65
- const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
65
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' | 'N' }> = [
66
66
  { AttributeName: pkName, AttributeType: 'S' },
67
67
  ];
68
68
  const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
@@ -72,11 +72,33 @@ async function makeFixture(): Promise<Fixture> {
72
72
  attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
73
73
  keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
74
74
  }
75
+ // Mirror the CDK-declared secondary indexes (the Connections per-IP
76
+ // admission GSI) so the fixture matches the deployed schema.
77
+ const globalSecondaryIndexes = (props.globalSecondaryIndexes ?? []).map((g) => {
78
+ const gpk = g.partitionKey;
79
+ if (gpk === undefined) throw new Error(`index ${g.indexName} missing partitionKey`);
80
+ const gsiKeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
81
+ { AttributeName: gpk.name, KeyType: 'HASH' },
82
+ ];
83
+ attributeDefinitions.push({ AttributeName: gpk.name, AttributeType: 'S' });
84
+ if (g.sortKey !== undefined) {
85
+ gsiKeySchema.push({ AttributeName: g.sortKey.name, KeyType: 'RANGE' });
86
+ attributeDefinitions.push({ AttributeName: g.sortKey.name, AttributeType: 'N' });
87
+ }
88
+ return {
89
+ IndexName: g.indexName,
90
+ KeySchema: gsiKeySchema,
91
+ Projection: { ProjectionType: 'ALL' as const },
92
+ };
93
+ });
75
94
  await client.send(
76
95
  new CreateTableCommand({
77
96
  TableName: `${prefix}${logical}`,
78
97
  AttributeDefinitions: attributeDefinitions,
79
98
  KeySchema: keySchema,
99
+ ...(globalSecondaryIndexes.length > 0
100
+ ? { GlobalSecondaryIndexes: globalSecondaryIndexes }
101
+ : {}),
80
102
  BillingMode: 'PAY_PER_REQUEST',
81
103
  }),
82
104
  );
@@ -257,6 +279,40 @@ describe.skipIf(!available)('handlers', () => {
257
279
  });
258
280
  expect((await probe.getConnectionInfo('b1'))?.id).toBe('b1');
259
281
  });
282
+
283
+ it('rejects the per-IP cap via identity.sourceIp and the sourceIp GSI', async () => {
284
+ // End-to-end per-IP admission: dispatch feeds
285
+ // `event.requestContext.identity.sourceIp` into handleConnect,
286
+ // which counts the IP's live rows on the `sourceIp` GSI and fires
287
+ // the (previously dead) maxConnectionsPerIp branch.
288
+ const capped: HandlerDeps = {
289
+ ...requireFx().deps,
290
+ serverConfig: { ...SERVER_CONFIG, maxClients: 50, maxConnectionsPerIp: 2 },
291
+ };
292
+ const fromIp = (id: string, ip: string) =>
293
+ dispatch(
294
+ {
295
+ requestContext: { routeKey: '$connect', connectionId: id, identity: { sourceIp: ip } },
296
+ },
297
+ capped,
298
+ );
299
+ expect((await fromIp('ip-a1', '203.0.113.30')).statusCode).toBe(200);
300
+ expect((await fromIp('ip-a2', '203.0.113.30')).statusCode).toBe(200);
301
+ const third = await fromIp('ip-a3', '203.0.113.30');
302
+ expect(third.statusCode).toBe(429);
303
+ expect(third.body).toBe('too many connections from this IP');
304
+ // A different IP is unaffected — the budget is keyed per IP.
305
+ expect((await fromIp('ip-b1', '198.51.100.30')).statusCode).toBe(200);
306
+ // The rejected connection must NOT be recorded.
307
+ const probe = new AwsRuntime({
308
+ dynamo: requireFx().client,
309
+ tables: requireFx().tables,
310
+ connId: '__probe__',
311
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
312
+ managementApi: null,
313
+ });
314
+ expect(await probe.getConnectionInfo('ip-a3')).toBeNull();
315
+ });
260
316
  });
261
317
 
262
318
  // -------------------------------------------------------------------------
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Unit tests for the NLB TLS secure-flag propagation (user mode `S`).
3
+ *
4
+ * The stack wires the NLB streaming Lambda exclusively behind a
5
+ * `Protocol.TLS` listener (ACM-cert terminated at the edge), so every
6
+ * connection on that entry is TLS-protected. These tests pin the
7
+ * propagation of that fact end-to-end:
8
+ * - the `nlbStreamHandler` dispatcher (`dispatchNlbStream`) asserts
9
+ * `secure: true`, so registration stamps read-only user mode `S`
10
+ * (visible via `MODE` → `221 … +S`) and the Connections row
11
+ * persists both `secure` and `userModes.tls`;
12
+ * - STS reflects the secure transport (`CAP LS` advertises the
13
+ * duration-only TLS form, never the plaintext `port=` upgrade form);
14
+ * - SASL EXTERNAL negotiation is permitted on the secure path (the
15
+ * mech is accepted when an mTLS provider is bound; the verify-level
16
+ * `904` for a missing account is owned by the EXTERNAL-verify
17
+ * ticket, not a secure-connection refusal);
18
+ * - a plain transport (secure not asserted) stays unflagged.
19
+ *
20
+ * Uses real DynamoDB Local, mirroring `nlb-stream.test.ts`.
21
+ */
22
+
23
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
24
+ import { GetCommand } from '@aws-sdk/lib-dynamodb';
25
+ import {
26
+ InMemoryMtlsIdentityProvider,
27
+ type ParsedServerConfig,
28
+ StaticMotdProvider,
29
+ certIdentityFromSubject,
30
+ } from '@serverless-ircd/irc-core';
31
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
32
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
33
+ import type { PostToConnection } from '../src/aws-runtime.js';
34
+ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
35
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
36
+ import { type HandlerDeps, dispatchNlbStream } from '../src/handlers/index.js';
37
+ import {
38
+ type NlbStreamEvent,
39
+ deriveNlbConnectionId,
40
+ handleNlbStream,
41
+ } from '../src/handlers/nlb-stream.js';
42
+ import { bindMessageStore } from '../src/message-store.js';
43
+ import type { TablesConfig } from '../src/tables.js';
44
+
45
+ const available = process.env.DDB_AVAILABLE === '1';
46
+ const endpoint = process.env.DYNAMO_ENDPOINT;
47
+
48
+ const SERVER_CONFIG: ParsedServerConfig = makeTestServerConfig({
49
+ serverName: 'irc.example.com',
50
+ networkName: 'ExampleNet',
51
+ motdLines: ['Welcome to the test IRC server.'],
52
+ // SASL EXTERNAL is operator opt-in; these tests exercise the enabled
53
+ // deployment (mTLS + TLS listener configured at the edge).
54
+ sasl: { externalEnabled: true },
55
+ });
56
+
57
+ const STS_CONFIG: ParsedServerConfig = makeTestServerConfig({
58
+ serverName: 'irc.example.com',
59
+ networkName: 'ExampleNet',
60
+ motdLines: ['Welcome to the test IRC server.'],
61
+ sts: { duration: 3600, port: 6697 },
62
+ });
63
+
64
+ const MOTD = new StaticMotdProvider(SERVER_CONFIG.motdLines);
65
+
66
+ interface Fixture {
67
+ tables: TablesConfig;
68
+ client: ReturnType<typeof createDynamoDocumentClient>;
69
+ mgmt: LocalPostToConnection;
70
+ deps: HandlerDeps;
71
+ stsDeps: HandlerDeps;
72
+ cleanup: () => Promise<void>;
73
+ }
74
+
75
+ let fx: Fixture | null;
76
+
77
+ function requireFx(): Fixture {
78
+ if (!fx) throw new Error('test fixture not initialized');
79
+ return fx;
80
+ }
81
+
82
+ async function makeFixture(): Promise<Fixture> {
83
+ if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
84
+ const prefix = `s${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
85
+ const tables = Object.fromEntries(
86
+ Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
87
+ ) as TablesConfig;
88
+ const client = createDynamoDocumentClient({ endpoint });
89
+ for (const [logical, props] of Object.entries(TABLE_DEFS)) {
90
+ const pkName = props.partitionKey?.name;
91
+ const skName = props.sortKey?.name;
92
+ if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
93
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' | 'N' }> = [
94
+ { AttributeName: pkName, AttributeType: 'S' },
95
+ ];
96
+ const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
97
+ { AttributeName: pkName, KeyType: 'HASH' },
98
+ ];
99
+ if (skName !== undefined) {
100
+ attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
101
+ keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
102
+ }
103
+ // Secondary indexes declared by the CDK definition (the Connections
104
+ // per-IP admission GSI) are created here too — the NLB new-flow rate
105
+ // gate Queries the index, so a fixture without it diverges from the
106
+ // deployed schema.
107
+ const globalSecondaryIndexes = (props.globalSecondaryIndexes ?? []).map((g) => {
108
+ const gpk = g.partitionKey;
109
+ if (gpk === undefined) throw new Error(`index ${g.indexName} missing partitionKey`);
110
+ const gsiKeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
111
+ { AttributeName: gpk.name, KeyType: 'HASH' },
112
+ ];
113
+ attributeDefinitions.push({ AttributeName: gpk.name, AttributeType: 'S' });
114
+ if (g.sortKey !== undefined) {
115
+ gsiKeySchema.push({ AttributeName: g.sortKey.name, KeyType: 'RANGE' });
116
+ attributeDefinitions.push({ AttributeName: g.sortKey.name, AttributeType: 'N' });
117
+ }
118
+ return {
119
+ IndexName: g.indexName,
120
+ KeySchema: gsiKeySchema,
121
+ Projection: { ProjectionType: 'ALL' as const },
122
+ };
123
+ });
124
+ await client.send(
125
+ new CreateTableCommand({
126
+ TableName: `${prefix}${logical}`,
127
+ AttributeDefinitions: attributeDefinitions,
128
+ KeySchema: keySchema,
129
+ ...(globalSecondaryIndexes.length > 0
130
+ ? { GlobalSecondaryIndexes: globalSecondaryIndexes }
131
+ : {}),
132
+ BillingMode: 'PAY_PER_REQUEST',
133
+ }),
134
+ );
135
+ }
136
+ const mgmt = new LocalPostToConnection();
137
+ const deps: HandlerDeps = {
138
+ dynamo: client,
139
+ tables,
140
+ serverConfig: SERVER_CONFIG,
141
+ motd: MOTD,
142
+ messages: bindMessageStore(SERVER_CONFIG),
143
+ managementApi: mgmt as never,
144
+ };
145
+ const stsDeps: HandlerDeps = {
146
+ ...deps,
147
+ serverConfig: STS_CONFIG,
148
+ messages: bindMessageStore(STS_CONFIG),
149
+ };
150
+ return {
151
+ tables,
152
+ client,
153
+ mgmt,
154
+ deps,
155
+ stsDeps,
156
+ cleanup: async () => {
157
+ for (const logical of Object.keys(TABLE_DEFS)) {
158
+ try {
159
+ await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
160
+ } catch {
161
+ // Already gone.
162
+ }
163
+ }
164
+ },
165
+ };
166
+ }
167
+
168
+ /** Builds an NLB event from raw client bytes (base64-encodes the body). */
169
+ function nlbEvent(sourceIp: string, sourcePort: string, data: string): NlbStreamEvent {
170
+ return {
171
+ requestContext: { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:::targetgroup/test' } },
172
+ version: '2.0',
173
+ body: Buffer.from(data, 'utf8').toString('base64'),
174
+ isBase64Encoded: true,
175
+ headers: {
176
+ 'x-forwarded-for': sourceIp,
177
+ 'x-forwarded-port': sourcePort,
178
+ },
179
+ };
180
+ }
181
+
182
+ /** Decodes an NLB response body back to raw IRC bytes. */
183
+ function decodeBody(res: { body: string }): string {
184
+ return Buffer.from(res.body, 'base64').toString('utf8');
185
+ }
186
+
187
+ /** Reads a raw Connections row. */
188
+ async function readRow(
189
+ tableName: string,
190
+ client: Fixture['client'],
191
+ connId: string,
192
+ ): Promise<Record<string, unknown> | undefined> {
193
+ const row = await client.send(
194
+ new GetCommand({ TableName: tableName, Key: { connectionId: connId } }),
195
+ );
196
+ return row.Item;
197
+ }
198
+
199
+ describe.skipIf(!available)('NLB TLS secure-flag propagation', () => {
200
+ beforeEach(async () => {
201
+ fx = await makeFixture();
202
+ });
203
+ afterEach(async () => {
204
+ if (fx) await fx.cleanup();
205
+ fx = null;
206
+ });
207
+
208
+ it('flags the NLB+TLS connection secure: MODE read-back shows +S and the row persists secure + userModes.tls', async () => {
209
+ const f = requireFx();
210
+ const res = await dispatchNlbStream(
211
+ nlbEvent(
212
+ '10.0.0.70',
213
+ '50070',
214
+ 'NICK tlsclient\r\nUSER tls 0 * :TLS User\r\nMODE tlsclient\r\n',
215
+ ),
216
+ f.deps,
217
+ );
218
+ expect(res.statusCode).toBe(200);
219
+ const body = decodeBody(res);
220
+ // Read-only user mode `S` is visible in the MODE read form (221).
221
+ expect(body).toContain('221 tlsclient +S');
222
+ expect(body).not.toContain('221 tlsclient +\r\n');
223
+
224
+ // The persisted row carries the TLS fact for cross-invocation readers.
225
+ const row = await readRow(
226
+ f.tables.Connections,
227
+ f.client,
228
+ deriveNlbConnectionId('10.0.0.70', '50070'),
229
+ );
230
+ expect(row?.secure).toBe(true);
231
+ expect(row?.userModes).toMatchObject({ tls: true });
232
+ });
233
+
234
+ it('keeps the secure flag across invocations of the same flow (persist + re-stamp)', async () => {
235
+ const f = requireFx();
236
+ await dispatchNlbStream(
237
+ nlbEvent('10.0.0.71', '50071', 'NICK reuser\r\nUSER u 0 * :Re User\r\n'),
238
+ f.deps,
239
+ );
240
+ const res = await dispatchNlbStream(nlbEvent('10.0.0.71', '50071', 'MODE reuser\r\n'), f.deps);
241
+ expect(res.statusCode).toBe(200);
242
+ expect(decodeBody(res)).toContain('221 reuser +S');
243
+
244
+ const row = await readRow(
245
+ f.tables.Connections,
246
+ f.client,
247
+ deriveNlbConnectionId('10.0.0.71', '50071'),
248
+ );
249
+ expect(row?.secure).toBe(true);
250
+ expect(row?.userModes).toMatchObject({ tls: true });
251
+ });
252
+
253
+ it('does not flag a plain transport when secure is not asserted (contrast)', async () => {
254
+ const f = requireFx();
255
+ // handleNlbStream called WITHOUT `secure` models a plain-TCP listener
256
+ // target: no S mode, and the row records the plain transport.
257
+ const res = await handleNlbStream(
258
+ nlbEvent(
259
+ '10.0.0.72',
260
+ '50072',
261
+ 'NICK plainclient\r\nUSER p 0 * :Plain User\r\nMODE plainclient\r\n',
262
+ ),
263
+ {
264
+ dynamo: f.client,
265
+ tables: f.tables,
266
+ serverConfig: SERVER_CONFIG,
267
+ motd: MOTD,
268
+ messages: bindMessageStore(SERVER_CONFIG),
269
+ managementApi: f.mgmt,
270
+ },
271
+ );
272
+ expect(res.statusCode).toBe(200);
273
+ const body = decodeBody(res);
274
+ expect(body).toContain('221 plainclient +\r\n');
275
+ expect(body).not.toContain('+S');
276
+
277
+ const row = await readRow(
278
+ f.tables.Connections,
279
+ f.client,
280
+ deriveNlbConnectionId('10.0.0.72', '50072'),
281
+ );
282
+ expect(row?.secure).toBe(false);
283
+ expect(row?.userModes).toMatchObject({ tls: false });
284
+ });
285
+
286
+ it('advertises the TLS form of sts on the secure NLB path (duration only, no port)', async () => {
287
+ const f = requireFx();
288
+ const res = await dispatchNlbStream(
289
+ nlbEvent('10.0.0.73', '50073', 'CAP LS 302\r\n'),
290
+ f.stsDeps,
291
+ );
292
+ expect(res.statusCode).toBe(200);
293
+ const body = decodeBody(res);
294
+ // Already on the secure listener: duration-only advertisement.
295
+ expect(body).toContain('sts=duration=3600');
296
+ expect(body).not.toContain('port=');
297
+
298
+ // Contrast — the same config over a transport that did not assert
299
+ // secure advertises the plaintext upgrade form with the port.
300
+ const plain = await handleNlbStream(nlbEvent('10.0.0.74', '50074', 'CAP LS 302\r\n'), {
301
+ dynamo: f.client,
302
+ tables: f.tables,
303
+ serverConfig: STS_CONFIG,
304
+ motd: MOTD,
305
+ messages: bindMessageStore(STS_CONFIG),
306
+ managementApi: f.mgmt,
307
+ });
308
+ expect(decodeBody(plain)).toContain('sts=duration=3600,port=6697');
309
+ });
310
+
311
+ it('permits SASL EXTERNAL negotiation on the secure NLB path', async () => {
312
+ const f = requireFx();
313
+ const connId = deriveNlbConnectionId('10.0.0.75', '50075');
314
+ const opts = {
315
+ dynamo: f.client,
316
+ tables: f.tables,
317
+ serverConfig: SERVER_CONFIG,
318
+ motd: MOTD,
319
+ messages: bindMessageStore(SERVER_CONFIG),
320
+ managementApi: f.mgmt,
321
+ secure: true,
322
+ mtlsIdentity: new InMemoryMtlsIdentityProvider([
323
+ { connId, identity: certIdentityFromSubject('CN=extuser') },
324
+ ]),
325
+ };
326
+
327
+ // Mechanism selection: EXTERNAL is advertised and accepted (the
328
+ // server asks for the empty payload with `AUTHENTICATE +`).
329
+ const ls = await handleNlbStream(
330
+ nlbEvent('10.0.0.75', '50075', 'CAP LS 302\r\nAUTHENTICATE EXTERNAL\r\n'),
331
+ opts,
332
+ );
333
+ const lsBody = decodeBody(ls);
334
+ expect(lsBody).toContain('sasl=PLAIN,EXTERNAL');
335
+ expect(lsBody).toContain('AUTHENTICATE +');
336
+
337
+ // Payload delivery: the exchange proceeds to the verify step. With no
338
+ // services store bound the verify fails with 904 (account resolution
339
+ // is the EXTERNAL-verify ticket's concern) — never a 908 mech-level
340
+ // refusal on secure-connection grounds.
341
+ const fin = await handleNlbStream(nlbEvent('10.0.0.75', '50075', 'AUTHENTICATE +\r\n'), opts);
342
+ const finBody = decodeBody(fin);
343
+ expect(finBody).toContain('904');
344
+ expect(finBody).not.toContain('908');
345
+ });
346
+ });
347
+
348
+ /**
349
+ * Test double for `ApiGatewayManagementApi`. Routes `postToConnection`
350
+ * calls to a registered sink for `ConnectionId`.
351
+ */
352
+ class LocalPostToConnection implements PostToConnection {
353
+ private readonly sinks = new Map<string, (data: string) => void>();
354
+ register(connId: string, sink: (data: string) => void): void {
355
+ this.sinks.set(connId, sink);
356
+ }
357
+ async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
358
+ const sink = this.sinks.get(input.ConnectionId);
359
+ if (sink !== undefined) sink(input.Data);
360
+ return {};
361
+ }
362
+ }