serverless-ircd 0.2.0 → 0.4.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 (221) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +258 -18
  8. package/README.md +72 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-stack",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "description": "AWS CDK v2 stack: API Gateway v2 WebSocket API, Lambda, DynamoDB tables, least-privilege IAM",
6
6
  "license": "BSD-3-Clause",
@@ -19,8 +19,8 @@
19
19
  "smoke:staging": "node scripts/smoke.mjs"
20
20
  },
21
21
  "devDependencies": {
22
- "aws-cdk": "^2.160.0",
23
- "aws-cdk-lib": "^2.160.0",
22
+ "aws-cdk": "^2.262.0",
23
+ "aws-cdk-lib": "^2.262.0",
24
24
  "constructs": "^10.4.0",
25
25
  "@types/node": "^26.1.1",
26
26
  "@types/ws": "^8.5.13",
@@ -36,6 +36,6 @@
36
36
  "@serverless-ircd/aws-adapter": "workspace:*"
37
37
  },
38
38
  "engines": {
39
- "node": ">=20"
39
+ "node": ">=24"
40
40
  }
41
41
  }
@@ -3,10 +3,10 @@
3
3
  *
4
4
  * • 5 DynamoDB tables sourced from `@serverless-ircd/aws-adapter/cdk-table-defs`
5
5
  * (Connections, ChannelMeta, ChannelMembers, Nicks, Accounts).
6
- * • ONE Node 20 `NodejsFunction` bundling the AWS Lambda handler
6
+ * • ONE Node 24 `NodejsFunction` bundling the AWS Lambda handler
7
7
  * (`$connect` / `$disconnect` / `$default` dispatched on
8
8
  * `event.requestContext.routeKey`).
9
- * • ONE Node 20 `NodejsFunction` for the gone-connection sweeper
9
+ * • ONE Node 24 `NodejsFunction` for the gone-connection sweeper
10
10
  * (`sweeperHandler`), invoked on a fixed EventBridge schedule.
11
11
  * • API Gateway v2 WebSocket API + auto-deploying `prod` stage.
12
12
  * • Least-privilege IAM: scoped DynamoDB data-plane grants per table +
@@ -19,7 +19,17 @@ import { TABLE_DEFS } from '@serverless-ircd/aws-adapter/cdk-table-defs';
19
19
  import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
20
20
  import { WebSocketApi, WebSocketStage } from 'aws-cdk-lib/aws-apigatewayv2';
21
21
  import { WebSocketLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
22
+ import { Certificate } from 'aws-cdk-lib/aws-certificatemanager';
22
23
  import { Table } from 'aws-cdk-lib/aws-dynamodb';
24
+ import { Vpc } from 'aws-cdk-lib/aws-ec2';
25
+ import {
26
+ NetworkListener,
27
+ NetworkLoadBalancer,
28
+ NetworkTargetGroup,
29
+ Protocol,
30
+ TargetType,
31
+ } from 'aws-cdk-lib/aws-elasticloadbalancingv2';
32
+ import { LambdaTarget } from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
23
33
  import { Rule, Schedule } from 'aws-cdk-lib/aws-events';
24
34
  import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
25
35
  import { Runtime } from 'aws-cdk-lib/aws-lambda';
@@ -29,6 +39,9 @@ import type { Construct } from 'constructs';
29
39
  /** Stage name served by the auto-deploying WebSocket stage. */
30
40
  const STAGE_NAME = 'prod';
31
41
 
42
+ /** IRC over TLS port served by the NLB listener (RFC-compliant :6697). */
43
+ const IRC_TLS_PORT = 6697;
44
+
32
45
  /** Fixed rate for the gone-connection sweeper (matches the architecture doc). */
33
46
  const SWEEPER_SCHEDULE_RATE = Duration.minutes(5);
34
47
 
@@ -91,6 +104,21 @@ export interface IrcStackProps extends StackProps {
91
104
  * a `string[]` correctly. Defaults to a short two-line banner.
92
105
  */
93
106
  readonly motdLines?: string[];
107
+
108
+ /**
109
+ * Domain name for the TCP+TLS (IRC `:6697`) endpoint.
110
+ *
111
+ * When provided, the stack provisions:
112
+ * - An ACM certificate for this domain.
113
+ * - A Network Load Balancer with a `TLS` listener on `:6697`.
114
+ * - A Lambda streaming target (the `nlbStreamHandler` export).
115
+ *
116
+ * The NLB terminates TLS at the edge and forwards plaintext TCP
117
+ * invocations to the Lambda. Connection identity is derived from
118
+ * NLB flow metadata (source IP + port). When omitted, only the wss
119
+ * (WebSocket) transport is deployed.
120
+ */
121
+ readonly tcpTlsDomainName?: string;
94
122
  }
95
123
 
96
124
  export class IrcAwsStack extends Stack {
@@ -118,11 +146,11 @@ export class IrcAwsStack extends Stack {
118
146
  );
119
147
 
120
148
  const handler = new NodejsFunction(this, 'IrcHandler', {
121
- runtime: Runtime.NODEJS_20_X,
149
+ runtime: Runtime.NODEJS_24_X,
122
150
  entry: handlerEntry(),
123
151
  handler: 'handler',
124
152
  bundling: {
125
- // AWS SDK v3 is in the Lambda Node 20 runtime; exclude it so the
153
+ // AWS SDK v3 is in the Lambda Node 24 runtime; exclude it so the
126
154
  // bundle stays small. irc-core / irc-server are bundled because
127
155
  // they are first-party.
128
156
  externalModules: ['@aws-sdk/*'],
@@ -134,7 +162,7 @@ export class IrcAwsStack extends Stack {
134
162
  // Catches connections that vanished without APIGW emitting $disconnect;
135
163
  // see packages/aws-adapter/src/handlers/sweeper.ts.
136
164
  const sweeper = new NodejsFunction(this, 'IrcSweeper', {
137
- runtime: Runtime.NODEJS_20_X,
165
+ runtime: Runtime.NODEJS_24_X,
138
166
  entry: handlerEntry(),
139
167
  handler: 'sweeperHandler',
140
168
  bundling: {
@@ -148,7 +176,7 @@ export class IrcAwsStack extends Stack {
148
176
  // packages/aws-adapter/src/handlers/ping-checker.ts. The Cloudflare
149
177
  // adapter implements the equivalent flow inside `ConnectionDO.alarm()`.
150
178
  const pingChecker = new NodejsFunction(this, 'IrcPingChecker', {
151
- runtime: Runtime.NODEJS_20_X,
179
+ runtime: Runtime.NODEJS_24_X,
152
180
  entry: handlerEntry(),
153
181
  handler: 'pingCheckerHandler',
154
182
  bundling: {
@@ -232,6 +260,90 @@ export class IrcAwsStack extends Stack {
232
260
  targets: [new LambdaFunction(pingChecker)],
233
261
  });
234
262
 
263
+ // -------------------------------------------------------------------------
264
+ // TCP+TLS adapter via Network Load Balancer.
265
+ //
266
+ // An NLB with a `TLS` listener terminates TLS at the edge and invokes a
267
+ // Lambda streaming function (`nlbStreamHandler`) for each chunk of client
268
+ // data. The Lambda response body is streamed back over the held-open TCP
269
+ // connection. Only provisioned when `tcpTlsDomainName` is supplied.
270
+ // -------------------------------------------------------------------------
271
+ if (props.tcpTlsDomainName !== undefined) {
272
+ const tcpTlsDomain = props.tcpTlsDomainName;
273
+
274
+ // ACM certificate for TLS termination at the NLB.
275
+ const cert = new Certificate(this, 'TlsCertificate', {
276
+ domainName: tcpTlsDomain,
277
+ });
278
+
279
+ // NLB requires a VPC. A dedicated two-AZ VPC keeps the NLB path
280
+ // isolated from the serverless (APIGW) path which has no VPC.
281
+ // Cast bypasses a CDK `exactOptionalPropertyTypes` friction between
282
+ // the concrete `Vpc` and the `IVpc` interface (optional vs required
283
+ // properties in the CDK jsii layer).
284
+ const vpc = new Vpc(this, 'NlbVpc', {
285
+ maxAzs: 2,
286
+ natGateways: 0,
287
+ }) as never;
288
+
289
+ // The NLB streaming Lambda — same entry, different handler export.
290
+ // Shares the bundled aws-adapter code (tree-shaken to the nlb-stream
291
+ // path by esbuild at synth time). Reuses the same DynamoDB tables,
292
+ // server identity, and MANAGEMENT_URL as the wss handler.
293
+ const nlbHandler = new NodejsFunction(this, 'IrcNlbHandler', {
294
+ runtime: Runtime.NODEJS_24_X,
295
+ entry: handlerEntry(),
296
+ handler: 'nlbStreamHandler',
297
+ bundling: {
298
+ externalModules: ['@aws-sdk/*'],
299
+ },
300
+ });
301
+
302
+ // Grant the NLB handler the same DynamoDB access as the wss handler.
303
+ for (const table of Object.values(tables)) {
304
+ table.grantReadWriteData(nlbHandler);
305
+ }
306
+
307
+ // Inject the same table names, server identity, and management URL.
308
+ for (const logicalId of Object.keys(TABLE_DEFS)) {
309
+ const envName = `${logicalId.toUpperCase()}_TABLE`;
310
+ nlbHandler.addEnvironment(envName, prefixedTableName(environmentName, logicalId));
311
+ }
312
+ nlbHandler.addEnvironment('SERVER_NAME', serverName);
313
+ nlbHandler.addEnvironment('NETWORK_NAME', networkName);
314
+ nlbHandler.addEnvironment('MOTD', motd);
315
+ nlbHandler.addEnvironment('MANAGEMENT_URL', stage.callbackUrl);
316
+
317
+ const nlb = new NetworkLoadBalancer(this, 'IrcNlb', {
318
+ vpc,
319
+ internetFacing: true,
320
+ });
321
+
322
+ const targetGroup = new NetworkTargetGroup(this, 'IrcNlbTargetGroup', {
323
+ port: IRC_TLS_PORT,
324
+ protocol: Protocol.TCP,
325
+ targetType: TargetType.LAMBDA,
326
+ targets: [new LambdaTarget(nlbHandler)],
327
+ });
328
+
329
+ new NetworkListener(this, 'IrcNlbTlsListener', {
330
+ // Cast bypasses a CDK `exactOptionalPropertyTypes` friction between
331
+ // the concrete `NetworkLoadBalancer` and the `INetworkLoadBalancer`
332
+ // interface (optional vs required `securityGroups`).
333
+ loadBalancer: nlb as never,
334
+ port: IRC_TLS_PORT,
335
+ protocol: Protocol.TLS,
336
+ certificates: [{ certificateArn: cert.certificateArn }],
337
+ defaultTargetGroups: [targetGroup],
338
+ });
339
+
340
+ new CfnOutput(this, 'TcpConnectUrl', {
341
+ description:
342
+ 'TCP+TLS connect endpoint (irc+tls :6697). Connect via the NLB DNS name on port 6697.',
343
+ value: `${nlb.loadBalancerDnsName}:6697`,
344
+ });
345
+ }
346
+
235
347
  new CfnOutput(this, 'ConnectUrl', {
236
348
  description: 'WebSocket connect endpoint clients dial (wss).',
237
349
  value: stage.url,
@@ -4,7 +4,7 @@
4
4
  * These are the primary red→green tests for the infrastructure ticket:
5
5
  * they assert the exact CloudFormation shape that `cdk synth` must
6
6
  * produce — five DynamoDB tables with the PLAN §4 schema, a single
7
- * Node 20 Lambda wired to all three WebSocket routes, the API Gateway
7
+ * Node 24 Lambda wired to all three WebSocket routes, the API Gateway
8
8
  * v2 WebSocket API, the two stack outputs, and a least-privilege guard
9
9
  * that no IAM policy grants `Resource: '*'` or `Action: '*'`.
10
10
  *
@@ -157,10 +157,10 @@ describe('IrcAwsStack — Lambda', () => {
157
157
  template.resourceCountIs('AWS::Lambda::Function', 3);
158
158
  });
159
159
 
160
- it('targets the Node 20 runtime for every function', () => {
160
+ it('targets the Node 24 runtime for every function', () => {
161
161
  const { template } = makeTemplate();
162
162
  template.allResourcesProperties('AWS::Lambda::Function', {
163
- Runtime: 'nodejs20.x',
163
+ Runtime: 'nodejs24.x',
164
164
  });
165
165
  });
166
166
  });
@@ -462,3 +462,98 @@ describe('IrcAwsStack — server identity props (configurable deploy identity)',
462
462
  ).toBeUndefined();
463
463
  });
464
464
  });
465
+
466
+ describe('IrcAwsStack — TCP+TLS NLB adapter', () => {
467
+ /** Makes a template WITH the tcpTlsDomainName prop (enables the NLB path). */
468
+ function makeTcpTemplate(domainName = 'irc.example.com'): {
469
+ app: App;
470
+ stack: Stack;
471
+ template: Template;
472
+ } {
473
+ const app = new App();
474
+ const stack = new IrcAwsStack(app, 'TestStack', {
475
+ environmentName: 'staging',
476
+ tcpTlsDomainName: domainName,
477
+ });
478
+ const template = Template.fromStack(stack);
479
+ return { app, stack, template };
480
+ }
481
+
482
+ describe('when tcpTlsDomainName is omitted (default)', () => {
483
+ it('creates no Network Load Balancer', () => {
484
+ const { template } = makeTemplate();
485
+ template.resourceCountIs('AWS::ElasticLoadBalancingV2::LoadBalancer', 0);
486
+ });
487
+
488
+ it('creates no ACM certificate', () => {
489
+ const { template } = makeTemplate();
490
+ template.resourceCountIs('AWS::CertificateManager::Certificate', 0);
491
+ });
492
+
493
+ it('still creates exactly three Lambda functions', () => {
494
+ const { template } = makeTemplate();
495
+ template.resourceCountIs('AWS::Lambda::Function', 3);
496
+ });
497
+ });
498
+
499
+ describe('when tcpTlsDomainName is provided', () => {
500
+ it('creates a Network Load Balancer', () => {
501
+ const { template } = makeTcpTemplate();
502
+ template.resourceCountIs('AWS::ElasticLoadBalancingV2::LoadBalancer', 1);
503
+ });
504
+
505
+ it('creates an ACM certificate for the provided domain', () => {
506
+ const { template } = makeTcpTemplate('irc.my.net');
507
+ template.hasResourceProperties('AWS::CertificateManager::Certificate', {
508
+ DomainName: 'irc.my.net',
509
+ });
510
+ });
511
+
512
+ it('creates a TLS listener on port 6697', () => {
513
+ const { template } = makeTcpTemplate();
514
+ template.hasResourceProperties('AWS::ElasticLoadBalancingV2::Listener', {
515
+ Port: 6697,
516
+ Protocol: 'TLS',
517
+ });
518
+ });
519
+
520
+ it('creates a target group with Lambda as the target type', () => {
521
+ const { template } = makeTcpTemplate();
522
+ template.hasResourceProperties('AWS::ElasticLoadBalancingV2::TargetGroup', {
523
+ TargetType: 'lambda',
524
+ });
525
+ });
526
+
527
+ it('creates a fourth Lambda function for the NLB streaming handler', () => {
528
+ const { template } = makeTcpTemplate();
529
+ template.resourceCountIs('AWS::Lambda::Function', 4);
530
+ });
531
+
532
+ it('targets the Node 24 runtime for the NLB handler Lambda', () => {
533
+ const { template } = makeTcpTemplate();
534
+ template.allResourcesProperties('AWS::Lambda::Function', {
535
+ Runtime: 'nodejs24.x',
536
+ });
537
+ });
538
+
539
+ it('grants the NLB permission to invoke the handler Lambda', () => {
540
+ const { template } = makeTcpTemplate();
541
+ template.hasResourceProperties('AWS::Lambda::Permission', {
542
+ Principal: 'elasticloadbalancing.amazonaws.com',
543
+ });
544
+ });
545
+
546
+ it('still creates the wss WebSocket API (both transports coexist)', () => {
547
+ const { template } = makeTcpTemplate();
548
+ template.hasResourceProperties('AWS::ApiGatewayV2::Api', {
549
+ ProtocolType: 'WEBSOCKET',
550
+ });
551
+ });
552
+
553
+ it('emits a TcpConnectUrl output pointing at the NLB DNS', () => {
554
+ const { template } = makeTcpTemplate();
555
+ const outputs = template.toJSON().Outputs ?? {};
556
+ expect(outputs).toHaveProperty('TcpConnectUrl');
557
+ });
558
+ });
559
+ });
@@ -0,0 +1,69 @@
1
+ # Cloudflare Container / Docker image for the ServerlessIRCd TCP origin.
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.
6
+ #
7
+ # Build context: the repository root (monorepo). The container needs the
8
+ # irc-core, irc-server, and in-memory-runtime packages.
9
+ #
10
+ # 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
12
+ #
13
+ # For Cloudflare Containers deployment, the same image is referenced from
14
+ # wrangler.toml.
15
+
16
+ FROM node:24-slim AS builder
17
+
18
+ WORKDIR /app
19
+
20
+ # Install pnpm.
21
+ RUN corepack enable && corepack prepare pnpm@11.17.0 --activate
22
+
23
+ # Copy workspace config + lockfile first for layer caching.
24
+ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
25
+ COPY tsconfig.base.json ./
26
+
27
+ # Copy all workspace packages needed for the build.
28
+ COPY packages/irc-core/ packages/irc-core/
29
+ COPY packages/irc-server/ packages/irc-server/
30
+ COPY packages/in-memory-runtime/ packages/in-memory-runtime/
31
+ COPY apps/cf-tcp-container/ apps/cf-tcp-container/
32
+
33
+ # Install dependencies and build.
34
+ RUN pnpm install --frozen-lockfile
35
+ RUN pnpm --filter '@serverless-ircd/irc-core' build && \
36
+ pnpm --filter '@serverless-ircd/irc-server' build && \
37
+ pnpm --filter '@serverless-ircd/in-memory-runtime' build && \
38
+ pnpm --filter '@serverless-ircd/cf-tcp-container' build
39
+
40
+ # --- Runtime stage ---
41
+ FROM node:24-slim AS runtime
42
+
43
+ WORKDIR /app
44
+
45
+ # Copy built artifacts + package manifests (for workspace resolution).
46
+ COPY --from=builder /app/package.json /app/pnpm-workspace.yaml ./
47
+ COPY --from=builder /app/packages/irc-core/package.json packages/irc-core/
48
+ COPY --from=builder /app/packages/irc-core/dist/ packages/irc-core/dist/
49
+ COPY --from=builder /app/packages/irc-server/package.json packages/irc-server/
50
+ COPY --from=builder /app/packages/irc-server/dist/ packages/irc-server/dist/
51
+ COPY --from=builder /app/packages/in-memory-runtime/package.json packages/in-memory-runtime/
52
+ COPY --from=builder /app/packages/in-memory-runtime/dist/ packages/in-memory-runtime/dist/
53
+ COPY --from=builder /app/apps/cf-tcp-container/package.json apps/cf-tcp-container/
54
+ COPY --from=builder /app/apps/cf-tcp-container/dist/ apps/cf-tcp-container/dist/
55
+
56
+ # Production install (only the workspace deps, no devDependencies).
57
+ RUN corepack enable && corepack prepare pnpm@11.17.0 --activate && \
58
+ pnpm install --frozen-lockfile --prod
59
+
60
+ # Persistence volume: Spectrum-origin channel state survives restarts.
61
+ VOLUME ["/data"]
62
+
63
+ EXPOSE 6667
64
+
65
+ ENV TCP_PORT=6667
66
+ ENV TCP_HOST=0.0.0.0
67
+ ENV PERSISTENCE_PATH=/data/state.json
68
+
69
+ CMD ["node", "--enable-source-maps", "apps/cf-tcp-container/dist/main.js"]
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@serverless-ircd/cf-tcp-container",
3
+ "version": "0.4.0",
4
+ "private": true,
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
+ "license": "BSD-3-Clause",
7
+ "type": "module",
8
+ "main": "./dist/main.js",
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.build.json",
11
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
12
+ "start": "node --enable-source-maps ./dist/main.js",
13
+ "deploy:staging": "wrangler deploy --env staging",
14
+ "deploy:prod": "wrangler deploy",
15
+ "test": "vitest run",
16
+ "test:watch": "vitest",
17
+ "coverage": "vitest run --coverage",
18
+ "clean": "rimraf dist coverage .tsbuildinfo .turbo"
19
+ },
20
+ "dependencies": {
21
+ "@serverless-ircd/in-memory-runtime": "workspace:*",
22
+ "@serverless-ircd/irc-core": "workspace:*",
23
+ "@serverless-ircd/irc-server": "workspace:*"
24
+ },
25
+ "devDependencies": {
26
+ "@cloudflare/containers": "^0.1.0",
27
+ "@types/node": "^26.1.1",
28
+ "@vitest/coverage-v8": "^4.1.0",
29
+ "rimraf": "^6.0.0",
30
+ "typescript": "^5.6.0",
31
+ "vitest": "^4.1.0",
32
+ "wrangler": "4.112.0"
33
+ }
34
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * CF TCP container config loader.
3
+ *
4
+ * Adapts platform-native env vars (Cloudflare Container runtime env or
5
+ * Docker/Kubernetes env) into the shared `ServerConfigSchema` from
6
+ * `irc-core`, plus a handful of container-specific knobs (TCP port/host,
7
+ * persistence path, snapshot interval).
8
+ *
9
+ * Mirrors `packages/cf-adapter/src/config-loader.ts` in shape — both
10
+ * adapters consume the same `ServerConfigSchema`; only the env-mapping
11
+ * and the extra container knobs differ.
12
+ */
13
+
14
+ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
15
+
16
+ /**
17
+ * The env vars this loader reads. Every field is a string (the platform
18
+ * env shape); numeric fields are coerced inside {@link loadContainerConfig}.
19
+ */
20
+ export interface ContainerConfigEnv {
21
+ SERVER_NAME?: string;
22
+ NETWORK_NAME?: string;
23
+ SERVER_VERSION?: string;
24
+ CREATED_AT?: string;
25
+ MOTD_LINES?: string;
26
+ MAX_CLIENTS?: string;
27
+ CHANNEL_PREFIXES?: string;
28
+ OPER_USER?: string;
29
+ OPER_PASSWORD?: string;
30
+ MAX_CHANNELS_PER_USER?: string;
31
+ MAX_TARGETS_PER_COMMAND?: string;
32
+ NICK_LEN?: string;
33
+ CHANNEL_LEN?: string;
34
+ TOPIC_LEN?: string;
35
+ MAX_LIST_ENTRIES?: string;
36
+ QUIT_MESSAGE?: string;
37
+ SASL_ACCOUNTS?: string;
38
+ /** Container-specific: plaintext TCP port Spectrum forwards to. */
39
+ TCP_PORT?: string;
40
+ /** Container-specific: bind address (default 0.0.0.0 for Spectrum origin). */
41
+ TCP_HOST?: string;
42
+ /** Container-specific: path to the persistence snapshot file. */
43
+ PERSISTENCE_PATH?: string;
44
+ /** Container-specific: auto-snapshot interval in milliseconds. */
45
+ SNAPSHOT_INTERVAL_MS?: string;
46
+ }
47
+
48
+ /** Fully-resolved container config: IRC server config + container knobs. */
49
+ export interface ContainerConfig {
50
+ server: ParsedServerConfig;
51
+ tcpPort: number;
52
+ tcpHost: string;
53
+ persistencePath: string | undefined;
54
+ snapshotIntervalMs: number;
55
+ }
56
+
57
+ const DEFAULT_TCP_PORT = 6667;
58
+ const DEFAULT_TCP_HOST = '0.0.0.0';
59
+ const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000;
60
+
61
+ /** Default server name / network when env vars are missing. */
62
+ const DEFAULT_SERVER_NAME = 'irc.example.com';
63
+ const DEFAULT_NETWORK_NAME = 'ExampleNet';
64
+
65
+ /**
66
+ * Loads and validates the IRC server config plus container-specific knobs
67
+ * from a platform env. Throws on an invalid IRC config (bad port number,
68
+ * missing required fields, etc.) so boot fails fast.
69
+ */
70
+ export function loadContainerConfig(env: ContainerConfigEnv): ContainerConfig {
71
+ const server = parseServerConfig(buildServerInput(env));
72
+ return {
73
+ server,
74
+ tcpPort: parseIntOrDefault(env.TCP_PORT, DEFAULT_TCP_PORT),
75
+ tcpHost: env.TCP_HOST?.trim() || DEFAULT_TCP_HOST,
76
+ persistencePath:
77
+ env.PERSISTENCE_PATH !== undefined && env.PERSISTENCE_PATH.length > 0
78
+ ? env.PERSISTENCE_PATH
79
+ : undefined,
80
+ snapshotIntervalMs: parseIntOrDefault(env.SNAPSHOT_INTERVAL_MS, DEFAULT_SNAPSHOT_INTERVAL_MS),
81
+ };
82
+ }
83
+
84
+ function parseIntOrDefault(raw: string | undefined, def: number): number {
85
+ if (raw === undefined || raw.trim().length === 0) return def;
86
+ const n = Number.parseInt(raw, 10);
87
+ return Number.isFinite(n) ? n : def;
88
+ }
89
+
90
+ function buildServerInput(env: ContainerConfigEnv): Record<string, unknown> {
91
+ const input: Record<string, unknown> = {
92
+ serverName: env.SERVER_NAME ?? DEFAULT_SERVER_NAME,
93
+ networkName: env.NETWORK_NAME ?? DEFAULT_NETWORK_NAME,
94
+ };
95
+ if (env.SERVER_VERSION !== undefined) input.serverVersion = env.SERVER_VERSION;
96
+ if (env.CREATED_AT !== undefined) input.createdAt = parseCreatedAt(env.CREATED_AT);
97
+ if (env.MOTD_LINES !== undefined && env.MOTD_LINES.length > 0) {
98
+ input.motdLines = env.MOTD_LINES.split('\n');
99
+ }
100
+ if (env.MAX_CLIENTS !== undefined) input.maxClients = Number.parseInt(env.MAX_CLIENTS, 10);
101
+ if (env.CHANNEL_PREFIXES !== undefined) input.channelPrefixes = env.CHANNEL_PREFIXES;
102
+ if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
103
+ input.operCreds = [{ user: env.OPER_USER ?? '', password: env.OPER_PASSWORD ?? '' }];
104
+ }
105
+ if (env.MAX_CHANNELS_PER_USER !== undefined) {
106
+ input.maxChannelsPerUser = Number.parseInt(env.MAX_CHANNELS_PER_USER, 10);
107
+ }
108
+ if (env.MAX_TARGETS_PER_COMMAND !== undefined) {
109
+ input.maxTargetsPerCommand = Number.parseInt(env.MAX_TARGETS_PER_COMMAND, 10);
110
+ }
111
+ if (env.NICK_LEN !== undefined) input.nickLen = Number.parseInt(env.NICK_LEN, 10);
112
+ if (env.CHANNEL_LEN !== undefined) input.channelLen = Number.parseInt(env.CHANNEL_LEN, 10);
113
+ if (env.TOPIC_LEN !== undefined) input.topicLen = Number.parseInt(env.TOPIC_LEN, 10);
114
+ if (env.MAX_LIST_ENTRIES !== undefined) {
115
+ input.maxListEntries = Number.parseInt(env.MAX_LIST_ENTRIES, 10);
116
+ }
117
+ if (env.QUIT_MESSAGE !== undefined) input.quitMessage = env.QUIT_MESSAGE;
118
+ if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
119
+ input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
120
+ }
121
+ return input;
122
+ }
123
+
124
+ function parseCreatedAt(raw: string): string | number {
125
+ if (/^\d+$/.test(raw.trim())) {
126
+ const n = Number(raw.trim());
127
+ if (Number.isFinite(n)) return n;
128
+ }
129
+ return raw;
130
+ }
131
+
132
+ function parseSaslAccountsLines(raw: string): Array<{ username: string; password: string }> {
133
+ const out: Array<{ username: string; password: string }> = [];
134
+ for (const line of raw.split('\n')) {
135
+ const trimmed = line.trim();
136
+ if (trimmed.length === 0) continue;
137
+ const sep = trimmed.indexOf(':');
138
+ if (sep <= 0) continue;
139
+ const username = trimmed.slice(0, sep);
140
+ const password = trimmed.slice(sep + 1);
141
+ if (username.length === 0 || password.length === 0) continue;
142
+ out.push({ username, password });
143
+ }
144
+ return out;
145
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Cloudflare Container DO for the IRC TCP origin.
3
+ *
4
+ * The container runs the IRC core as a plaintext TCP server (port 6667).
5
+ * Spectrum terminates TLS on :6697 and forwards plaintext to this
6
+ * container. The DO wrapper provides persistent identity and lifecycle
7
+ * management.
8
+ */
9
+
10
+ import { Container } from '@cloudflare/containers';
11
+
12
+ export class IrcTcpOrigin extends Container {
13
+ // The IRC TCP server port inside the container.
14
+ defaultPort = 6667;
15
+ requiredPorts = [6667];
16
+
17
+ // Keep the container alive for long-lived IRC connections.
18
+ sleepAfter = '2h';
19
+
20
+ enableInternet = true;
21
+
22
+ envVars = {
23
+ TCP_PORT: '6667',
24
+ TCP_HOST: '0.0.0.0',
25
+ };
26
+
27
+ onStart(): void {
28
+ console.log('IrcTcpOrigin container started');
29
+ }
30
+
31
+ onStop(): void {
32
+ console.log('IrcTcpOrigin container stopping');
33
+ }
34
+
35
+ onError(error: Error): void {
36
+ console.error('IrcTcpOrigin container error:', error);
37
+ }
38
+ }