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
@@ -23,6 +23,23 @@ import type { StaticSiteProps } from '../src/static-site.js';
23
23
  /** All five table names that must exist in the stack (PLAN §4). */
24
24
  const TABLE_NAMES = ['ChannelMembers', 'ChannelMeta', 'Connections', 'Nicks', 'Services'] as const;
25
25
 
26
+ /**
27
+ * Raw synthesised CloudFormation node for a DynamoDB table. `DeletionPolicy`
28
+ * and `UpdateReplacePolicy` live at the resource root (siblings of
29
+ * `Properties`), so they are NOT visible via `Template.hasResourceProperties`
30
+ * — tests that assert the removal policy inspect the full node.
31
+ */
32
+ type DynamoTableResource = {
33
+ Type: string;
34
+ DeletionPolicy?: string;
35
+ UpdateReplacePolicy?: string;
36
+ Properties?: {
37
+ TableName?: string;
38
+ PointInTimeRecoverySpecification?: { PointInTimeRecoveryEnabled?: boolean };
39
+ DeletionProtectionEnabled?: boolean;
40
+ };
41
+ };
42
+
26
43
  function makeTemplate(): { app: App; stack: Stack; template: Template } {
27
44
  const app = new App();
28
45
  const stack = new IrcAwsStack(app, 'TestStack');
@@ -99,7 +116,12 @@ describe('IrcAwsStack — DynamoDB tables', () => {
99
116
  const { template } = makeTemplate();
100
117
  template.hasResourceProperties('AWS::DynamoDB::Table', {
101
118
  TableName: 'Connections',
102
- AttributeDefinitions: [{ AttributeName: 'connectionId', AttributeType: 'S' }],
119
+ // connectionId (base key) plus the per-IP admission GSI keys.
120
+ AttributeDefinitions: [
121
+ { AttributeName: 'connectionId', AttributeType: 'S' },
122
+ { AttributeName: 'sourceIp', AttributeType: 'S' },
123
+ { AttributeName: 'connectedSince', AttributeType: 'N' },
124
+ ],
103
125
  KeySchema: [{ AttributeName: 'connectionId', KeyType: 'HASH' }],
104
126
  });
105
127
  });
@@ -153,6 +175,173 @@ describe('IrcAwsStack — DynamoDB tables', () => {
153
175
  });
154
176
  });
155
177
 
178
+ describe('IrcAwsStack — DynamoDB table protection (RETAIN + PITR + deletion protection)', () => {
179
+ /**
180
+ * State-bearing tables hold user data (nicks, services credentials,
181
+ * channel state, membership) and MUST survive a mistaken `cdk destroy`,
182
+ * a stack-name collision, or a stray `aws dynamodb delete-table` from a
183
+ * compromised credential. `Connections` is the one exception: its rows
184
+ * are ephemeral per-connection records (TTL-reaped, sweeper-cleaned), so
185
+ * it stays DESTROY with no PITR and no deletion protection.
186
+ */
187
+ const STATE_TABLES = ['ChannelMembers', 'ChannelMeta', 'Nicks', 'Services'] as const;
188
+ const EPHEMERAL_TABLE = 'Connections';
189
+
190
+ /**
191
+ * Returns the synthesised `AWS::DynamoDB::Table` resources keyed by their
192
+ * physical `TableName`. Each value is the raw CloudFormation resource
193
+ * node, so `DeletionPolicy`, `UpdateReplacePolicy`, and the protection
194
+ * properties under `Properties` are all inspectable. `DeletionPolicy` lives
195
+ * at the resource root (sibling of `Properties`), so it is NOT visible to
196
+ * `hasResourceProperties` — the raw node is required.
197
+ */
198
+ function dynamoTablesByName(template: Template): Record<
199
+ string,
200
+ {
201
+ Type: string;
202
+ DeletionPolicy?: string;
203
+ UpdateReplacePolicy?: string;
204
+ Properties?: {
205
+ TableName?: string;
206
+ PointInTimeRecoverySpecification?: { PointInTimeRecoveryEnabled?: boolean };
207
+ DeletionProtectionEnabled?: boolean;
208
+ };
209
+ }
210
+ > {
211
+ const tmpl = template.toJSON() as { Resources?: Record<string, unknown> };
212
+ const out: Record<string, DynamoTableResource> = {};
213
+ for (const raw of Object.values(tmpl.Resources ?? {})) {
214
+ const res = raw as { Type?: string; Properties?: { TableName?: string } };
215
+ if (res.Type !== 'AWS::DynamoDB::Table') {
216
+ continue;
217
+ }
218
+ const name = res.Properties?.TableName;
219
+ if (typeof name === 'string') {
220
+ out[name] = raw as DynamoTableResource;
221
+ }
222
+ }
223
+ return out;
224
+ }
225
+
226
+ describe('default synth (no flags)', () => {
227
+ it.each(STATE_TABLES)('RETAINs the %s table (DeletionPolicy=Retain)', (name) => {
228
+ const { template } = makeTemplate();
229
+ expect(dynamoTablesByName(template)[name]?.DeletionPolicy).toBe('Retain');
230
+ });
231
+
232
+ it.each(STATE_TABLES)('sets UpdateReplacePolicy=Retain on the %s table', (name) => {
233
+ const { template } = makeTemplate();
234
+ expect(dynamoTablesByName(template)[name]?.UpdateReplacePolicy).toBe('Retain');
235
+ });
236
+
237
+ it('keeps the Connections table DESTROY (DeletionPolicy=Delete)', () => {
238
+ const { template } = makeTemplate();
239
+ expect(dynamoTablesByName(template)[EPHEMERAL_TABLE]?.DeletionPolicy).toBe('Delete');
240
+ });
241
+
242
+ it.each(STATE_TABLES)('enables point-in-time recovery on the %s table', (name) => {
243
+ const { template } = makeTemplate();
244
+ expect(
245
+ dynamoTablesByName(template)[name]?.Properties?.PointInTimeRecoverySpecification
246
+ ?.PointInTimeRecoveryEnabled,
247
+ ).toBe(true);
248
+ });
249
+
250
+ it('does NOT enable point-in-time recovery on the Connections table', () => {
251
+ const { template } = makeTemplate();
252
+ expect(
253
+ dynamoTablesByName(template)[EPHEMERAL_TABLE]?.Properties?.PointInTimeRecoverySpecification,
254
+ ).toBeUndefined();
255
+ });
256
+
257
+ it.each(STATE_TABLES)('enables deletion protection on the %s table', (name) => {
258
+ const { template } = makeTemplate();
259
+ expect(dynamoTablesByName(template)[name]?.Properties?.DeletionProtectionEnabled).toBe(true);
260
+ });
261
+
262
+ it('does NOT enable deletion protection on the Connections table', () => {
263
+ const { template } = makeTemplate();
264
+ expect(
265
+ dynamoTablesByName(template)[EPHEMERAL_TABLE]?.Properties?.DeletionProtectionEnabled,
266
+ ).toBeUndefined();
267
+ });
268
+ });
269
+
270
+ describe('allowTableDeletion=true synth (teardown mode)', () => {
271
+ function makeTeardownTemplate(): Template {
272
+ const app = new App();
273
+ const stack = new IrcAwsStack(app, 'TestStack', { allowTableDeletion: true });
274
+ return Template.fromStack(stack);
275
+ }
276
+
277
+ it.each(STATE_TABLES)('flips the %s table to DESTROY (DeletionPolicy=Delete)', (name) => {
278
+ const template = makeTeardownTemplate();
279
+ expect(dynamoTablesByName(template)[name]?.DeletionPolicy).toBe('Delete');
280
+ });
281
+
282
+ it.each(STATE_TABLES)('disables deletion protection on the %s table', (name) => {
283
+ const template = makeTeardownTemplate();
284
+ expect(dynamoTablesByName(template)[name]?.Properties?.DeletionProtectionEnabled).toBe(false);
285
+ });
286
+
287
+ it('keeps the Connections table DESTROY', () => {
288
+ const template = makeTeardownTemplate();
289
+ expect(dynamoTablesByName(template)[EPHEMERAL_TABLE]?.DeletionPolicy).toBe('Delete');
290
+ });
291
+
292
+ it('still keeps point-in-time recovery enabled on state tables in teardown mode', () => {
293
+ // allowTableDeletion and enablePitr are independent flags: a legitimate
294
+ // teardown still keeps PITR on (it is harmless and the data is still
295
+ // recoverable up until the table is actually deleted). Disabling PITR
296
+ // is a separate, deliberate opt-in via enablePitr=false.
297
+ const template = makeTeardownTemplate();
298
+ for (const name of STATE_TABLES) {
299
+ expect(
300
+ dynamoTablesByName(template)[name]?.Properties?.PointInTimeRecoverySpecification
301
+ ?.PointInTimeRecoveryEnabled,
302
+ ).toBe(true);
303
+ }
304
+ });
305
+ });
306
+
307
+ describe('enablePitr=false synth (PITR disabled)', () => {
308
+ function makeNoPitrTemplate(): Template {
309
+ const app = new App();
310
+ const stack = new IrcAwsStack(app, 'TestStack', { enablePitr: false });
311
+ return Template.fromStack(stack);
312
+ }
313
+
314
+ it.each(STATE_TABLES)('disables point-in-time recovery on the %s table', (name) => {
315
+ const template = makeNoPitrTemplate();
316
+ expect(
317
+ dynamoTablesByName(template)[name]?.Properties?.PointInTimeRecoverySpecification
318
+ ?.PointInTimeRecoveryEnabled,
319
+ ).toBe(false);
320
+ });
321
+
322
+ it('still does NOT enable point-in-time recovery on the Connections table', () => {
323
+ const template = makeNoPitrTemplate();
324
+ expect(
325
+ dynamoTablesByName(template)[EPHEMERAL_TABLE]?.Properties?.PointInTimeRecoverySpecification,
326
+ ).toBeUndefined();
327
+ });
328
+
329
+ it('keeps state tables RETAIN with deletion protection when PITR is disabled', () => {
330
+ // enablePitr=false narrows ONLY the PITR knob; the RETAIN +
331
+ // deletion-protection guarantees are independent of PITR and must
332
+ // remain on so a disabled-PITR deploy is still protected from
333
+ // accidental stack deletion.
334
+ const template = makeNoPitrTemplate();
335
+ for (const name of STATE_TABLES) {
336
+ expect(dynamoTablesByName(template)[name]?.DeletionPolicy).toBe('Retain');
337
+ expect(dynamoTablesByName(template)[name]?.Properties?.DeletionProtectionEnabled).toBe(
338
+ true,
339
+ );
340
+ }
341
+ });
342
+ });
343
+ });
344
+
156
345
  describe('IrcAwsStack — API Gateway v2 WebSocket API', () => {
157
346
  it('creates a WebSocket protocol API Gateway v2 API', () => {
158
347
  const { template } = makeTemplate();
@@ -346,15 +535,12 @@ describe('IrcAwsStack — API Gateway logging', () => {
346
535
  ).toBe(true);
347
536
  });
348
537
 
349
- it('grants the logging role CloudWatch Logs write access across ALL log groups', () => {
350
- // APIGW writes execution logs to its OWN AWS/ApiGateway... log groups
351
- // (separate from this stack's access-log group), so the role must be
352
- // able to write broadly — a single-log-group ARN scope is rejected by
353
- // APIGW account-role validation. Defined inline (the
354
- // AmazonAPIGatewayPushToCloudWatchLogs managed policy is not available
355
- // in every partition); CDK renders the statement as a separate
356
- // AWS::IAM::Policy attached to the role.
357
- const { template } = makeTemplate();
538
+ /**
539
+ * Flattens the IAM actions granted by the inline policy CDK attaches to
540
+ * the `ApiGatewayLoggingRole` (`addToPolicy` renders as a separate
541
+ * `AWS::IAM::Policy` resource referencing the role).
542
+ */
543
+ function loggingRoleActions(template: Template): string[] {
358
544
  const roles = template.findResources('AWS::IAM::Role') as Record<string, unknown>;
359
545
  const loggingRoleEntry = Object.entries(roles).find(([id]) =>
360
546
  id.startsWith('ApiGatewayLoggingRole'),
@@ -379,12 +565,54 @@ describe('IrcAwsStack — API Gateway logging', () => {
379
565
  const stmts = attached?.Properties?.PolicyDocument?.Statement ?? [];
380
566
  const flat = (a: string | string[] | undefined): string[] =>
381
567
  Array.isArray(a) ? a : a !== undefined ? [a] : [];
382
- const actions = JSON.stringify(stmts.flatMap((s) => flat(s.Action)));
383
- const resources = JSON.stringify(stmts.flatMap((s) => flat(s.Resource as string[])));
384
- expect(actions).toContain('logs:PutLogEvents');
385
- expect(actions).toContain('logs:CreateLogGroup');
386
- // Broad scope NOT narrowed to a single log-group ARN.
387
- expect(resources).toContain('*');
568
+ return stmts.flatMap((s) => flat(s.Action));
569
+ }
570
+
571
+ it('grants the logging role exactly the three CloudWatch Logs WRITE actions across ALL log groups', () => {
572
+ // APIGW writes execution logs to its OWN AWS/ApiGateway... log groups
573
+ // (separate from this stack's access-log group), so the role must be
574
+ // able to write broadly — a single-log-group ARN scope is rejected by
575
+ // APIGW account-role validation. The grant set is EXACTLY the three
576
+ // write actions APIGW needs; the legacy inline policy additionally
577
+ // carried Describe* actions, which let a compromised role enumerate
578
+ // (and, via the managed policy AWS offers for this purpose, read)
579
+ // arbitrary log groups in the account. Exact-set assertion so any
580
+ // future widening fails this test.
581
+ const { template } = makeTemplate();
582
+ expect([...loggingRoleActions(template)].sort()).toEqual([
583
+ 'logs:CreateLogGroup',
584
+ 'logs:CreateLogStream',
585
+ 'logs:PutLogEvents',
586
+ ]);
587
+ });
588
+
589
+ it('does not grant the logging role any log-enumeration or log-read actions', () => {
590
+ const { template } = makeTemplate();
591
+ const actions = loggingRoleActions(template);
592
+ expect(
593
+ actions.filter((a) => /^logs:(Describe|Get|Filter)/.test(a)),
594
+ 'logging role must not enumerate or read log groups',
595
+ ).toEqual([]);
596
+ });
597
+
598
+ it('attaches no AWS-managed policies to the logging role (documented inline decision)', () => {
599
+ // Decision record: the AmazonAPIGatewayPushToCloudWatchLogs managed
600
+ // policy was considered and REJECTED. Its fixed grant set is broader
601
+ // than the tightened inline policy — it still grants DescribeLogGroups
602
+ // and DescribeLogStreams (the actions this stack deliberately drops)
603
+ // plus GetLogEvents and FilterLogEvents, all on Resource '*' — and AWS
604
+ // can widen its contents without this stack being re-reviewed. The
605
+ // inline policy keeps the grant set exact and auditable at synth time.
606
+ const { template } = makeTemplate();
607
+ const roles = template.findResources('AWS::IAM::Role') as Record<
608
+ string,
609
+ { Properties?: { ManagedPolicyArns?: unknown } }
610
+ >;
611
+ const loggingRole = Object.entries(roles).find(([id]) =>
612
+ id.startsWith('ApiGatewayLoggingRole'),
613
+ );
614
+ expect(loggingRole, 'expected an ApiGatewayLoggingRole').toBeDefined();
615
+ expect(loggingRole?.[1]?.Properties?.ManagedPolicyArns).toBeUndefined();
388
616
  });
389
617
  });
390
618
 
@@ -1012,6 +1240,101 @@ describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
1012
1240
  });
1013
1241
  });
1014
1242
 
1243
+ /**
1244
+ * Collects every IAM policy statement attached to the NLB handler
1245
+ * Lambda's execution role — the role's inline `Policies` plus every
1246
+ * separate `AWS::IAM::Policy` resource referencing it (CDK renders
1247
+ * `grant*` calls as the role's `DefaultPolicy` resource).
1248
+ */
1249
+ function nlbRoleStatements(template: Template): Array<Record<string, unknown>> {
1250
+ const fns = template.findResources('AWS::Lambda::Function') as Record<
1251
+ string,
1252
+ { Properties?: { Role?: { 'Fn::GetAtt'?: string[] } | { Ref?: string } } }
1253
+ >;
1254
+ const nlbFn = Object.entries(fns).find(([id]) => id.startsWith('IrcNlbHandler'));
1255
+ if (nlbFn === undefined) {
1256
+ throw new Error('No Lambda with logical id starting with "IrcNlbHandler"');
1257
+ }
1258
+ const role = nlbFn[1]?.Properties?.Role as
1259
+ | { 'Fn::GetAtt'?: string[]; Ref?: string }
1260
+ | undefined;
1261
+ const getAtt = role?.['Fn::GetAtt'];
1262
+ const roleLogicalId = Array.isArray(getAtt) ? getAtt[0] : role?.Ref;
1263
+ if (typeof roleLogicalId !== 'string') {
1264
+ throw new Error('IrcNlbHandler Lambda has no resolvable execution role');
1265
+ }
1266
+ const statements: Array<Record<string, unknown>> = [];
1267
+ const roles = template.findResources('AWS::IAM::Role') as Record<
1268
+ string,
1269
+ { Properties?: { Policies?: Array<{ PolicyDocument?: { Statement?: unknown[] } }> } }
1270
+ >;
1271
+ for (const inline of roles[roleLogicalId]?.Properties?.Policies ?? []) {
1272
+ statements.push(
1273
+ ...((inline.PolicyDocument?.Statement ?? []) as Array<Record<string, unknown>>),
1274
+ );
1275
+ }
1276
+ const policies = template.findResources('AWS::IAM::Policy') as Record<
1277
+ string,
1278
+ {
1279
+ Properties?: {
1280
+ Roles?: Array<{ Ref?: string }>;
1281
+ PolicyDocument?: { Statement?: unknown[] };
1282
+ };
1283
+ }
1284
+ >;
1285
+ for (const policy of Object.values(policies)) {
1286
+ const attached = (policy.Properties?.Roles ?? []).some((r) => r.Ref === roleLogicalId);
1287
+ if (attached) {
1288
+ statements.push(
1289
+ ...((policy.Properties?.PolicyDocument?.Statement ?? []) as Array<
1290
+ Record<string, unknown>
1291
+ >),
1292
+ );
1293
+ }
1294
+ }
1295
+ return statements;
1296
+ }
1297
+
1298
+ /** True when a statement's Action includes `execute-api:ManageConnections`. */
1299
+ function managesConnections(stmt: Record<string, unknown>): boolean {
1300
+ const action = stmt.Action;
1301
+ return (
1302
+ action === 'execute-api:ManageConnections' ||
1303
+ (Array.isArray(action) && action.includes('execute-api:ManageConnections'))
1304
+ );
1305
+ }
1306
+
1307
+ it('grants the NLB handler Lambda execute-api:ManageConnections on the stage', () => {
1308
+ // The NLB stream handler passes MANAGEMENT_URL into AwsRuntime and
1309
+ // fans channel messages out to *other* connections via
1310
+ // ApiGatewayManagementApi.postToConnection — including wss clients
1311
+ // on the API Gateway transport. Without the stage grant on the NLB
1312
+ // handler's own execution role, every such cross-transport delivery
1313
+ // fails with 403.
1314
+ const { template } = makeTcpTemplate();
1315
+ const manages = nlbRoleStatements(template).filter(managesConnections);
1316
+ expect(
1317
+ manages.length,
1318
+ 'NLB handler role must be granted execute-api:ManageConnections',
1319
+ ).toBeGreaterThan(0);
1320
+ });
1321
+
1322
+ it('scopes the NLB handler ManageConnections grant to this stack API stage connections', () => {
1323
+ // Least privilege: the grant must target THIS stack's WebSocket API
1324
+ // stage connections resource (…/apis/<api>/prod/*/@connections/*),
1325
+ // never a wildcard API or account-wide resource.
1326
+ const { template } = makeTcpTemplate();
1327
+ const apiIds = Object.keys(template.findResources('AWS::ApiGatewayV2::Api')).filter((id) =>
1328
+ id.startsWith('IrcWsApi'),
1329
+ );
1330
+ expect(apiIds).toHaveLength(1);
1331
+ const manages = nlbRoleStatements(template).filter(managesConnections);
1332
+ const resourceJson = JSON.stringify(manages.map((s) => s.Resource));
1333
+ expect(resourceJson).toContain('@connections');
1334
+ expect(resourceJson).toContain('/prod/');
1335
+ expect(resourceJson).toContain(apiIds[0] ?? '');
1336
+ });
1337
+
1015
1338
  it('still creates the wss WebSocket API (both transports coexist)', () => {
1016
1339
  const { template } = makeTcpTemplate();
1017
1340
  template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
@@ -1122,3 +1445,114 @@ describe('IrcAwsStack — web client hosting (StaticSite)', () => {
1122
1445
  });
1123
1446
  });
1124
1447
  });
1448
+
1449
+ describe('IrcAwsStack — API Gateway stage throttling', () => {
1450
+ it('exposes ApiThrottlingRateLimit as a configurable Number parameter (default 100)', () => {
1451
+ const { template } = makeTemplate();
1452
+ const p = parameters(template).ApiThrottlingRateLimit;
1453
+ expect(p).toBeDefined();
1454
+ expect(p?.Type).toBe('Number');
1455
+ // CfnParameter renders a numeric default as a JSON number.
1456
+ expect(Number(p?.Default)).toBe(100);
1457
+ expect(typeof p?.Description).toBe('string');
1458
+ expect(p?.Description?.length).toBeGreaterThan(0);
1459
+ });
1460
+
1461
+ it('exposes ApiThrottlingBurstLimit as a configurable Number parameter (default 200)', () => {
1462
+ const { template } = makeTemplate();
1463
+ const p = parameters(template).ApiThrottlingBurstLimit;
1464
+ expect(p).toBeDefined();
1465
+ expect(p?.Type).toBe('Number');
1466
+ expect(Number(p?.Default)).toBe(200);
1467
+ expect(typeof p?.Description).toBe('string');
1468
+ });
1469
+
1470
+ it('wires the stage default-route throttling to the parameters (Ref)', () => {
1471
+ // Stage-level throttling is the global backstop behind the per-IP
1472
+ // admission gates: it bounds aggregate request rate into the API
1473
+ // (connect floods included) before a Lambda invocation is billed.
1474
+ const { template } = makeTemplate();
1475
+ template.hasResourceProperties('AWS::ApiGatewayV2::Stage', {
1476
+ DefaultRouteSettings: {
1477
+ ThrottlingRateLimit: { Ref: 'ApiThrottlingRateLimit' },
1478
+ ThrottlingBurstLimit: { Ref: 'ApiThrottlingBurstLimit' },
1479
+ },
1480
+ });
1481
+ });
1482
+ });
1483
+
1484
+ describe('IrcAwsStack — Connections sourceIp GSI', () => {
1485
+ it('adds a sourceIp+connectedSince GSI to the Connections table', () => {
1486
+ // The per-IP admission gates ($connect simultaneous cap + rate
1487
+ // window, NLB new-flow rate) consult this index; without it the
1488
+ // handler would need a full-table Scan per connect.
1489
+ const { template } = makeTemplate();
1490
+ template.hasResourceProperties('AWS::DynamoDB::Table', {
1491
+ TableName: 'Connections',
1492
+ GlobalSecondaryIndexes: [
1493
+ {
1494
+ IndexName: 'sourceIp-connectedSince',
1495
+ KeySchema: [
1496
+ { AttributeName: 'sourceIp', KeyType: 'HASH' },
1497
+ { AttributeName: 'connectedSince', KeyType: 'RANGE' },
1498
+ ],
1499
+ },
1500
+ ],
1501
+ });
1502
+ });
1503
+ });
1504
+
1505
+ describe('IrcAwsStack — WAF rate-based rule on $connect', () => {
1506
+ /** Synths a stack with the optional edge rate limit configured. */
1507
+ function makeWafTemplate(limit: number): Template {
1508
+ const app = new App();
1509
+ const stack = new IrcAwsStack(app, 'TestStack', { wafConnectRateLimit: limit });
1510
+ return Template.fromStack(stack);
1511
+ }
1512
+
1513
+ it('emits no WebACL by default (the knob is opt-in)', () => {
1514
+ const { template } = makeTemplate();
1515
+ template.resourceCountIs('AWS::WAFv2::WebACL', 0);
1516
+ template.resourceCountIs('AWS::WAFv2::WebACLAssociation', 0);
1517
+ });
1518
+
1519
+ it('emits a REGIONAL WebACL with a per-IP rate-based rule when the knob is set', () => {
1520
+ // WAF on a WebSocket API only inspects the initial HTTP upgrade —
1521
+ // the `$connect` route — so a plain rate-based statement is
1522
+ // effectively scoped to connection establishment.
1523
+ const template = makeWafTemplate(2000);
1524
+ template.resourceCountIs('AWS::WAFv2::WebACL', 1);
1525
+ template.hasResourceProperties('AWS::WAFv2::WebACL', {
1526
+ Scope: 'REGIONAL',
1527
+ DefaultAction: { Allow: {} },
1528
+ Rules: [
1529
+ {
1530
+ Name: 'ConnectRateLimit',
1531
+ Priority: 0,
1532
+ Action: { Block: {} },
1533
+ Statement: {
1534
+ RateBasedStatement: { Limit: 2000, AggregateKeyType: 'IP' },
1535
+ },
1536
+ },
1537
+ ],
1538
+ });
1539
+ });
1540
+
1541
+ it('associates the WebACL with the WebSocket stage', () => {
1542
+ const template = makeWafTemplate(2000);
1543
+ template.resourceCountIs('AWS::WAFv2::WebACLAssociation', 1);
1544
+ const assoc = template.findResources('AWS::WAFv2::WebACLAssociation') as Record<
1545
+ string,
1546
+ { Properties?: { ResourceArn?: unknown; WebACLArn?: unknown } }
1547
+ >;
1548
+ const entry = Object.values(assoc)[0];
1549
+ expect(entry?.Properties?.WebACLArn).toBeDefined();
1550
+ // ResourceArn must be the API Gateway stage ARN (Fn::Join over the
1551
+ // partition/region refs + the WebSocket Api Ref + the stage name),
1552
+ // not a dangling literal.
1553
+ const arn = JSON.stringify(entry?.Properties?.ResourceArn);
1554
+ expect(arn).toContain('apigateway');
1555
+ expect(arn).toContain('/stages/prod');
1556
+ expect(arn).toContain('IrcWsApi');
1557
+ });
1558
+ });
@@ -1,14 +1,24 @@
1
1
  # Cloudflare Container / Docker image for the ServerlessIRCd TCP origin.
2
2
  #
3
- # Spectrum terminates TLS at the edge and forwards plaintext TCP to this
4
- # container. The container runs the IRC core (ConnectionActor +
5
- # InMemoryRuntime) over the TcpByteStreamTransport seam on port 6667.
3
+ # TLS terminates INSIDE this container on :6697 (Spectrum runs in
4
+ # `tls_mode = "full"`, re-encrypting edge→origin) no plaintext IRC
5
+ # ever crosses the origin network. The container runs the IRC core
6
+ # (ConnectionActor + InMemoryRuntime) over the TcpByteStreamTransport
7
+ # seam behind its own TLS listener.
6
8
  #
7
9
  # Build context: the repository root (monorepo). The container needs the
8
10
  # irc-core, irc-server, and in-memory-runtime packages.
9
11
  #
10
12
  # docker build -f apps/cf-tcp-container/Dockerfile -t sirc-tcp-origin .
11
- # docker run -p 6667:6667 -e SERVER_NAME=irc.example.com sirc-tcp-origin
13
+ # docker run -p 6697:6697 \
14
+ # -v $(pwd)/certs:/data/certs:ro \
15
+ # -e SERVER_NAME=irc.example.com sirc-tcp-origin
16
+ #
17
+ # Certificates: mount a PEM cert/key pair at /data/certs/ (the image
18
+ # defaults TLS_CERT_PATH=/data/certs/tls.pem, TLS_KEY_PATH=/data/certs/
19
+ # tls.key). Let's Encrypt, Bring-Your-Own, and Cloudflare Origin Certs
20
+ # are all supported — see docs/Cloudflare-TCP-Deployment.md. Cert files
21
+ # must be readable by UID 1000 (node).
12
22
  #
13
23
  # For Cloudflare Containers deployment, the same image is referenced from
14
24
  # wrangler.toml.
@@ -57,13 +67,35 @@ COPY --from=builder /app/apps/cf-tcp-container/dist/ apps/cf-tcp-container/dist/
57
67
  RUN corepack enable && corepack prepare pnpm@11.17.0 --activate && \
58
68
  pnpm install --frozen-lockfile --prod
59
69
 
70
+ # Runtime-writable state: the persistence snapshot lives in /data, and the
71
+ # runtime process runs as the unprivileged `node` user — so the directory
72
+ # must exist and be node-owned before privileges drop (chown needs root).
73
+ RUN mkdir -p /data && chown node:node /data
74
+
60
75
  # Persistence volume: Spectrum-origin channel state survives restarts.
61
76
  VOLUME ["/data"]
62
77
 
63
- EXPOSE 6667
78
+ # Cert files for TLS termination at the origin: mount the PEM pair at
79
+ # /data/certs/ (see ENV defaults below) — Let's Encrypt, BYO, or a
80
+ # Cloudflare Origin Cert all work. Any cert mount must be readable by
81
+ # UID 1000 (node) — mount them owned by 1000:1000.
82
+
83
+ # Drop root: the IRC stack (parser + actor + transport) handles untrusted
84
+ # input, so it must not run as UID 0. node:24-slim ships the non-root
85
+ # `node` user (UID 1000) out of the box.
86
+ USER node
87
+
88
+ EXPOSE 6697
64
89
 
90
+ # The image is a production origin by default: the config loader then
91
+ # refuses to boot without TLS material (no silent plaintext fallback).
92
+ ENV NODE_ENV=production
65
93
  ENV TCP_PORT=6667
66
94
  ENV TCP_HOST=0.0.0.0
67
95
  ENV PERSISTENCE_PATH=/data/state.json
96
+ # Documented cert mount path (override per-deploy via TLS_CERT_PATH /
97
+ # TLS_KEY_PATH if the pair lives elsewhere).
98
+ ENV TLS_CERT_PATH=/data/certs/tls.pem
99
+ ENV TLS_KEY_PATH=/data/certs/tls.key
68
100
 
69
101
  CMD ["node", "--enable-source-maps", "apps/cf-tcp-container/dist/main.js"]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-tcp-container",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Container TCP origin for the irc+tls :6697 transport — Spectrum terminates TLS at the edge, this container runs the IRC core over plaintext TCP",
6
6
  "license": "BSD-3-Clause",
@@ -10,7 +10,7 @@
10
10
  "build": "tsc -p tsconfig.build.json",
11
11
  "typecheck": "tsc -p tsconfig.test.json --noEmit",
12
12
  "start": "node --enable-source-maps ./dist/main.js",
13
- "deploy": "wrangler deploy",
13
+ "deploy": "node ../../tools/ci-hardening/dist/cf-deploy-cli.js wrangler.toml",
14
14
  "test": "vitest run",
15
15
  "test:watch": "vitest",
16
16
  "coverage": "vitest run --coverage",
@@ -21,6 +21,11 @@
21
21
  "@serverless-ircd/irc-core": "workspace:*",
22
22
  "@serverless-ircd/irc-server": "workspace:*"
23
23
  },
24
+ "proxyProtocol": {
25
+ "version": 1,
26
+ "parser": "hand-rolled (src/proxy-protocol.ts)",
27
+ "rationale": "PROXY protocol v1 is a single fixed-format ASCII line, fully validated in ~40 lines; no npm proxy-protocol dependency is pulled onto the connection hot path (supply-chain surface > value)."
28
+ },
24
29
  "devDependencies": {
25
30
  "@cloudflare/containers": "^0.1.0",
26
31
  "@types/node": "^26.1.1",