serverless-ircd 0.3.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 (159) 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 +174 -18
  8. package/README.md +63 -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/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -0,0 +1,363 @@
1
+ /**
2
+ * CF TCP container origin — the stateful IRC server behind Cloudflare
3
+ * Spectrum.
4
+ *
5
+ * Spectrum terminates TLS at the edge and forwards plaintext TCP to this
6
+ * origin. The container runs the shared IRC core (`ConnectionActor` +
7
+ * `InMemoryRuntime`) over the `TcpByteStreamTransport` seam,
8
+ * which reassembles `\r\n`-terminated IRC lines across arbitrary TCP
9
+ * chunks. The actor/reducer/dispatch pipeline is identical to the wss
10
+ * path — only the line-framing transport differs.
11
+ *
12
+ * When `persistencePath` is supplied, channel state (topics, modes, bans)
13
+ * is restored from a snapshot file on boot and auto-saved on an interval
14
+ * plus at graceful shutdown, so a restart doesn't lose channel topology.
15
+ */
16
+
17
+ import { randomUUID } from 'node:crypto';
18
+ import { type Server, type Socket, createServer } from 'node:net';
19
+ import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
20
+ import {
21
+ type AccountStore,
22
+ type AdmissionConfig,
23
+ type ChanName,
24
+ type Clock,
25
+ type ConnectionState,
26
+ ConsoleLogger,
27
+ InMemoryAccountStore,
28
+ InMemoryMessageStore,
29
+ InMemoryNickHistoryStore,
30
+ LogLevel,
31
+ type Logger,
32
+ type MessageStore,
33
+ type NickHistoryStore,
34
+ type RawLine,
35
+ createConnection,
36
+ } from '@serverless-ircd/irc-core';
37
+ import { ConnectionActor, TcpByteStreamTransport } from '@serverless-ircd/irc-server';
38
+ import { loadSnapshot, restoreRuntime, saveSnapshot, snapshotRuntime } from './persistence.js';
39
+
40
+ /** Options accepted by {@link startContainerServer}. */
41
+ export interface StartContainerOptions {
42
+ /** Plaintext TCP port. Use 0 for an ephemeral port. */
43
+ readonly port: number;
44
+ /** Bind address. Defaults to `0.0.0.0` (Spectrum forwards from the edge). */
45
+ readonly host?: string;
46
+ readonly serverName: string;
47
+ readonly networkName: string;
48
+ readonly motdLines?: string[];
49
+ readonly maxChannelsPerUser?: number;
50
+ readonly maxTargetsPerCommand?: number;
51
+ readonly maxListEntries?: number;
52
+ readonly nickLen?: number;
53
+ readonly channelLen?: number;
54
+ readonly topicLen?: number;
55
+ readonly quitMessage?: string;
56
+ readonly serverPassword?: string;
57
+ readonly cloaking?: { enabled: boolean; secret: string; cloakedSuffix?: string };
58
+ readonly maxConnectionsPerIp?: number;
59
+ readonly maxConnectionsPerUser?: number;
60
+ readonly perIpConnectionRate?: { max: number; windowMs: number };
61
+ readonly historyMaxPerChannel?: number;
62
+ readonly saslAccounts?: Array<{ username: string; password: string }>;
63
+ /**
64
+ * Path to the persistence snapshot file. When set, channel state is
65
+ * restored on boot and auto-saved every {@link snapshotIntervalMs} plus
66
+ * at shutdown. Omit to disable persistence.
67
+ */
68
+ readonly persistencePath?: string;
69
+ /** Auto-snapshot interval in milliseconds. Defaults to 60 000. */
70
+ readonly snapshotIntervalMs?: number;
71
+ /** Injected clock (testing). Defaults to wall-clock `Date.now()`. */
72
+ readonly clock?: Clock;
73
+ }
74
+
75
+ /** A running container origin. Call {@link close} to shut down. */
76
+ export interface ContainerServer {
77
+ readonly port: number;
78
+ readonly host: string;
79
+ readonly runtime: InMemoryRuntime;
80
+ readonly messages: MessageStore;
81
+ readonly history: NickHistoryStore;
82
+ /**
83
+ * Test-only seam: live server-side TCP sockets. Production code MUST
84
+ * NOT use.
85
+ */
86
+ readonly testSockets: ReadonlySet<Socket>;
87
+ close(): Promise<void>;
88
+ }
89
+
90
+ interface ConnectionBindings {
91
+ state: ConnectionState;
92
+ actor: ConnectionActor;
93
+ }
94
+
95
+ const DEFAULT_ID_FACTORY = {
96
+ batchId: () => randomUUID(),
97
+ nonce: () => randomUUID(),
98
+ sessionId: () => randomUUID(),
99
+ traceId: () => randomUUID(),
100
+ };
101
+
102
+ const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
103
+ const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
104
+ const DEFAULT_PER_IP_CONNECTION_RATE = { max: 5, windowMs: 60_000 };
105
+ const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000;
106
+
107
+ /**
108
+ * Starts the TCP container origin. Resolves once the TCP listener is
109
+ * ready. When `persistencePath` is set and a snapshot file exists, channel
110
+ * state is restored before any connection is accepted.
111
+ */
112
+ export async function startContainerServer(opts: StartContainerOptions): Promise<ContainerServer> {
113
+ const host = opts.host ?? '0.0.0.0';
114
+ const clock: Clock = opts.clock ?? { now: () => Date.now() };
115
+
116
+ const admission: AdmissionConfig | undefined =
117
+ opts.maxConnectionsPerIp !== undefined ||
118
+ opts.maxConnectionsPerUser !== undefined ||
119
+ opts.perIpConnectionRate !== undefined
120
+ ? {
121
+ maxConnectionsPerIp: opts.maxConnectionsPerIp ?? DEFAULT_MAX_CONNECTIONS_PER_IP,
122
+ maxConnectionsPerUser: opts.maxConnectionsPerUser ?? DEFAULT_MAX_CONNECTIONS_PER_USER,
123
+ perIpConnectionRate: opts.perIpConnectionRate ?? DEFAULT_PER_IP_CONNECTION_RATE,
124
+ }
125
+ : undefined;
126
+
127
+ const runtime = new InMemoryRuntime({
128
+ clock,
129
+ ...(admission !== undefined ? { admission } : {}),
130
+ });
131
+
132
+ const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
133
+ const history = new InMemoryNickHistoryStore(clock);
134
+ const accounts: AccountStore | undefined =
135
+ opts.saslAccounts !== undefined && opts.saslAccounts.length > 0
136
+ ? new InMemoryAccountStore(opts.saslAccounts)
137
+ : undefined;
138
+
139
+ // Restore persisted channel state before accepting connections.
140
+ if (opts.persistencePath !== undefined) {
141
+ try {
142
+ const snap = await loadSnapshot(opts.persistencePath);
143
+ if (snap !== null) {
144
+ restoreRuntime(runtime, snap);
145
+ }
146
+ } catch (err) {
147
+ logError('failed to load persistence snapshot; starting fresh', err);
148
+ }
149
+ }
150
+
151
+ const connections = new Map<Socket, ConnectionBindings>();
152
+ const sockets = new Set<Socket>();
153
+
154
+ const tcpServer = createServer((socket) => {
155
+ sockets.add(socket);
156
+
157
+ const ip = socket.remoteAddress ?? 'unknown';
158
+ const decision = runtime.admitConnection(ip, undefined);
159
+ if (!decision.ok) {
160
+ if (socket.writable) {
161
+ socket.write(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
162
+ }
163
+ socket.destroy();
164
+ sockets.delete(socket);
165
+ return;
166
+ }
167
+ const admissionRecordId = decision.recordId;
168
+ runtime.commitAdmission(ip, undefined, admissionRecordId);
169
+
170
+ const { state, actor } = attachConnection(runtime, opts, messages, accounts, history, clock, {
171
+ sendText: (text) => {
172
+ if (socket.writable) socket.write(text);
173
+ },
174
+ closeTransport: () => socket.destroy(),
175
+ admissionRecordId,
176
+ sourceHost: ip,
177
+ });
178
+ connections.set(socket, { state, actor });
179
+
180
+ socket.on('data', (chunk: Buffer) => {
181
+ actor.receive(chunk.toString('utf8')).catch((err) => {
182
+ logError('actor failure', err);
183
+ });
184
+ });
185
+
186
+ const cleanup = (): void => {
187
+ connections.delete(socket);
188
+ sockets.delete(socket);
189
+ runtime.unregisterConnection(state.id);
190
+ };
191
+ socket.on('close', cleanup);
192
+ socket.on('error', (err) => {
193
+ logError('tcp socket error', err);
194
+ cleanup();
195
+ });
196
+ });
197
+
198
+ // Auto-snapshot timer.
199
+ const snapshotIntervalMs = opts.snapshotIntervalMs ?? DEFAULT_SNAPSHOT_INTERVAL_MS;
200
+ const timer: ReturnType<typeof setInterval> | undefined =
201
+ opts.persistencePath !== undefined
202
+ ? setInterval(() => {
203
+ void snapshotNow(runtime, opts.persistencePath as string);
204
+ }, snapshotIntervalMs)
205
+ : undefined;
206
+ if (timer !== undefined) timer.unref?.();
207
+
208
+ return new Promise<ContainerServer>((resolve, reject) => {
209
+ const onError = (err: Error): void => {
210
+ tcpServer.removeListener('listening', onListening);
211
+ reject(err);
212
+ };
213
+ const onListening = (): void => {
214
+ tcpServer.removeListener('error', onError);
215
+ const addr = tcpServer.address();
216
+ // c8 ignore next 1
217
+ const port = typeof addr === 'object' && addr !== null ? addr.port : opts.port;
218
+ resolve({
219
+ port,
220
+ host,
221
+ runtime,
222
+ messages,
223
+ history,
224
+ testSockets: sockets,
225
+ close: () =>
226
+ closeServer({
227
+ tcpServer,
228
+ connections,
229
+ sockets,
230
+ timer,
231
+ runtime,
232
+ persistencePath: opts.persistencePath,
233
+ }),
234
+ });
235
+ };
236
+ tcpServer.once('listening', onListening);
237
+ tcpServer.once('error', onError);
238
+ tcpServer.listen(opts.port, host);
239
+ });
240
+ }
241
+
242
+ function attachConnection(
243
+ runtime: InMemoryRuntime,
244
+ opts: StartContainerOptions,
245
+ messages: MessageStore,
246
+ accounts: AccountStore | undefined,
247
+ history: NickHistoryStore,
248
+ clock: Clock,
249
+ transport: {
250
+ sendText: (text: string) => void;
251
+ closeTransport: () => void;
252
+ admissionRecordId?: string;
253
+ sourceHost?: string;
254
+ },
255
+ ): ConnectionBindings {
256
+ const id = randomUUID();
257
+ const state = createConnection({ id, connectedSince: clock.now() });
258
+ if (transport.sourceHost !== undefined) {
259
+ state.host = transport.sourceHost;
260
+ }
261
+
262
+ const logger: Logger = new ConsoleLogger({ connectionId: id }, undefined, LogLevel.Info);
263
+
264
+ const actor = new ConnectionActor({
265
+ state,
266
+ runtime,
267
+ channels: runtime,
268
+ serverConfig: {
269
+ serverName: opts.serverName,
270
+ networkName: opts.networkName,
271
+ maxChannelsPerUser: opts.maxChannelsPerUser ?? 30,
272
+ maxTargetsPerCommand: opts.maxTargetsPerCommand ?? 10,
273
+ maxListEntries: opts.maxListEntries ?? 50,
274
+ nickLen: opts.nickLen ?? 30,
275
+ channelLen: opts.channelLen ?? 50,
276
+ topicLen: opts.topicLen ?? 390,
277
+ quitMessage: opts.quitMessage ?? 'Client Quit',
278
+ ...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
279
+ ...(opts.cloaking !== undefined ? { cloaking: opts.cloaking } : {}),
280
+ },
281
+ clock,
282
+ ids: DEFAULT_ID_FACTORY,
283
+ motd: { lines: () => opts.motdLines ?? [] },
284
+ messages,
285
+ ...(accounts !== undefined ? { accounts } : {}),
286
+ history,
287
+ logger,
288
+ transport: new TcpByteStreamTransport(),
289
+ });
290
+
291
+ runtime.registerConnection(
292
+ state,
293
+ {
294
+ send: (lines: RawLine[]) => {
295
+ const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
296
+ transport.sendText(text);
297
+ },
298
+ disconnect: (reason?: string) => {
299
+ if (reason !== undefined) {
300
+ transport.sendText(`ERROR :Closing link: (${reason})\r\n`);
301
+ }
302
+ transport.closeTransport();
303
+ },
304
+ },
305
+ transport.admissionRecordId,
306
+ );
307
+
308
+ return { state, actor };
309
+ }
310
+
311
+ async function closeServer(args: {
312
+ tcpServer: Server;
313
+ connections: Map<Socket, ConnectionBindings>;
314
+ sockets: Set<Socket>;
315
+ timer: ReturnType<typeof setInterval> | undefined;
316
+ runtime: InMemoryRuntime;
317
+ persistencePath: string | undefined;
318
+ }): Promise<void> {
319
+ const { tcpServer, connections, sockets, timer, runtime, persistencePath } = args;
320
+
321
+ if (timer !== undefined) clearInterval(timer);
322
+
323
+ // Destroy all live sockets so clients see the disconnect.
324
+ for (const socket of sockets) {
325
+ if (!socket.destroyed) socket.destroy();
326
+ }
327
+ sockets.clear();
328
+ connections.clear();
329
+
330
+ if (persistencePath !== undefined) {
331
+ try {
332
+ await snapshotNow(runtime, persistencePath);
333
+ } catch (err) {
334
+ logError('failed to save final snapshot', err);
335
+ }
336
+ }
337
+
338
+ // Stop accepting new connections. We do NOT await the callback because
339
+ // `server.close()` waits for every connection's 'close' event, which can
340
+ // hang if a destroyed socket's TCP cleanup is delayed by the OS. All
341
+ // sockets are already destroyed above; the server is safe to abandon.
342
+ tcpServer.close();
343
+ (tcpServer as Server & { closeAllConnections?: () => void }).closeAllConnections?.();
344
+ tcpServer.unref();
345
+ }
346
+
347
+ async function snapshotNow(runtime: InMemoryRuntime, path: string): Promise<void> {
348
+ const state = await snapshotRuntime(runtime);
349
+ await saveSnapshot(path, state);
350
+ }
351
+
352
+ function logError(msg: string, err: unknown): void {
353
+ const payload = {
354
+ ts: new Date().toISOString(),
355
+ level: 'error',
356
+ msg,
357
+ err: err instanceof Error ? { name: err.name, message: err.message } : err,
358
+ };
359
+ // eslint-disable-next-line no-console
360
+ console.error(JSON.stringify(payload));
361
+ }
362
+
363
+ export type { ChanName };
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CF TCP container origin entry point.
4
+ *
5
+ * Boots the IRC core as a plaintext TCP server behind Cloudflare Spectrum.
6
+ * Spectrum terminates TLS at the edge and forwards plaintext TCP to this
7
+ * process. Configuration comes from environment variables (set by the
8
+ * Cloudflare Container runtime, Docker, or Kubernetes).
9
+ *
10
+ * Usage:
11
+ * TCP_PORT=6667 SERVER_NAME=irc.example.com node dist/main.js
12
+ *
13
+ * The process runs until SIGINT/SIGTERM. On shutdown it writes a final
14
+ * persistence snapshot (when PERSISTENCE_PATH is set) so channel state
15
+ * survives the next boot.
16
+ */
17
+
18
+ import { loadContainerConfig } from './config-loader.js';
19
+ import { startContainerServer } from './container-server.js';
20
+
21
+ async function main(): Promise<void> {
22
+ const cfg = loadContainerConfig(process.env as Record<string, string | undefined>);
23
+
24
+ const server = await startContainerServer({
25
+ port: cfg.tcpPort,
26
+ host: cfg.tcpHost,
27
+ serverName: cfg.server.serverName,
28
+ networkName: cfg.server.networkName,
29
+ motdLines: cfg.server.motdLines,
30
+ maxChannelsPerUser: cfg.server.maxChannelsPerUser,
31
+ maxTargetsPerCommand: cfg.server.maxTargetsPerCommand,
32
+ maxListEntries: cfg.server.maxListEntries,
33
+ nickLen: cfg.server.nickLen,
34
+ channelLen: cfg.server.channelLen,
35
+ topicLen: cfg.server.topicLen,
36
+ quitMessage: cfg.server.quitMessage,
37
+ snapshotIntervalMs: cfg.snapshotIntervalMs,
38
+ ...(cfg.persistencePath !== undefined ? { persistencePath: cfg.persistencePath } : {}),
39
+ ...(cfg.server.saslAccounts !== undefined && cfg.server.saslAccounts.length > 0
40
+ ? { saslAccounts: cfg.server.saslAccounts }
41
+ : {}),
42
+ });
43
+
44
+ process.stdout.write(
45
+ `${JSON.stringify({
46
+ ts: new Date().toISOString(),
47
+ level: 'info',
48
+ msg: 'cf-tcp-container listening',
49
+ host: server.host,
50
+ port: server.port,
51
+ persistence: cfg.persistencePath ?? null,
52
+ serverName: cfg.server.serverName,
53
+ })}\n`,
54
+ );
55
+
56
+ const shutdown = (signal: string): void => {
57
+ process.stdout.write(
58
+ `${JSON.stringify({ ts: new Date().toISOString(), level: 'info', msg: 'shutdown', signal })}\n`,
59
+ );
60
+ server
61
+ .close()
62
+ .then(() => process.exit(0))
63
+ .catch((err) => {
64
+ process.stderr.write(`shutdown error: ${String(err)}\n`);
65
+ process.exit(1);
66
+ });
67
+ };
68
+ process.on('SIGINT', () => shutdown('SIGINT'));
69
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
70
+ }
71
+
72
+ main().catch((err) => {
73
+ process.stderr.write(
74
+ `fatal: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`,
75
+ );
76
+ process.exit(1);
77
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Runtime persistence for the CF TCP container origin.
3
+ *
4
+ * The container is a stateful process fronted by Cloudflare Spectrum (TLS
5
+ * termination). When the container restarts (crash, deploy, scale event)
6
+ * every live TCP connection drops — clients must reconnect. But the
7
+ * channel topology they built (topics, modes, ban masks) must survive so
8
+ * a reconnecting client doesn't find a bare server.
9
+ *
10
+ * This module snapshots the non-connection channel state (everything
11
+ * EXCEPT the member roster — those ConnIds are stale after a restart) to
12
+ * a JSON file on the container's persistent volume and restores it on
13
+ * boot. The shape is deliberately minimal and forward-compatible
14
+ * (`version` field) so future schema changes can migrate.
15
+ *
16
+ * Connection state survives origin reconnect via the runtime's
17
+ * persistence layer.
18
+ */
19
+
20
+ import { readFile, writeFile } from 'node:fs/promises';
21
+
22
+ import type { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
23
+ import type { ChanSnapshot, ChannelModes, ChannelTopic } from '@serverless-ircd/irc-core';
24
+
25
+ /** Schema version for the persisted snapshot file. */
26
+ const SCHEMA_VERSION = 1;
27
+
28
+ /**
29
+ * One channel's persistent state. Mirrors {@link ChannelState} minus the
30
+ * member roster and pending invites — both are per-connection and cannot
31
+ * outlive the connections that created them.
32
+ */
33
+ export interface PersistedChannel {
34
+ name: string;
35
+ nameLower: string;
36
+ modes: ChannelModes;
37
+ banMasks: string[];
38
+ topic?: ChannelTopic;
39
+ createdAt: number;
40
+ }
41
+
42
+ /** Top-level persisted state envelope. */
43
+ export interface PersistedState {
44
+ version: typeof SCHEMA_VERSION;
45
+ savedAt: number;
46
+ channels: PersistedChannel[];
47
+ }
48
+
49
+ /**
50
+ * Extracts every channel's durable state from `runtime`. Members and
51
+ * pending invites are omitted — they reference ConnIds that will be stale
52
+ * after a restart.
53
+ */
54
+ export async function snapshotRuntime(runtime: InMemoryRuntime): Promise<PersistedState> {
55
+ return snapshotFromList(await runtime.listChannels());
56
+ }
57
+
58
+ /** Pure extraction from pre-fetched channel snapshots. */
59
+ export function snapshotFromList(channels: ReadonlyArray<ChanSnapshot>): PersistedState {
60
+ return {
61
+ version: SCHEMA_VERSION,
62
+ savedAt: Date.now(),
63
+ channels: channels.map((c) => {
64
+ const entry: PersistedChannel = {
65
+ name: c.name,
66
+ nameLower: c.nameLower,
67
+ modes: { ...c.modes },
68
+ banMasks: [...c.banMasks],
69
+ createdAt: c.createdAt,
70
+ };
71
+ if (c.topic !== undefined) entry.topic = c.topic;
72
+ return entry;
73
+ }),
74
+ };
75
+ }
76
+
77
+ /** Serializes a {@link PersistedState} to a JSON string. */
78
+ export function serializeState(state: PersistedState): string {
79
+ return JSON.stringify(state);
80
+ }
81
+
82
+ /**
83
+ * Parses and validates a JSON string into a {@link PersistedState}. Throws
84
+ * on malformed JSON or an incompatible schema version.
85
+ */
86
+ export function deserializeState(json: string): PersistedState {
87
+ const parsed = JSON.parse(json) as unknown;
88
+ if (typeof parsed !== 'object' || parsed === null) {
89
+ throw new Error('invalid persisted state: expected an object');
90
+ }
91
+ const obj = parsed as Record<string, unknown>;
92
+ if (obj.version !== SCHEMA_VERSION) {
93
+ throw new Error(
94
+ `incompatible persisted state version: got ${String(obj.version)}, expected ${SCHEMA_VERSION}`,
95
+ );
96
+ }
97
+ if (!Array.isArray(obj.channels)) {
98
+ throw new Error('invalid persisted state: channels is not an array');
99
+ }
100
+ return {
101
+ version: SCHEMA_VERSION,
102
+ savedAt: typeof obj.savedAt === 'number' ? obj.savedAt : 0,
103
+ channels: obj.channels as PersistedChannel[],
104
+ };
105
+ }
106
+
107
+ /** Writes a snapshot to `filePath`. */
108
+ export async function saveSnapshot(filePath: string, state: PersistedState): Promise<void> {
109
+ await writeFile(filePath, serializeState(state), 'utf8');
110
+ }
111
+
112
+ /**
113
+ * Reads and parses a snapshot from `filePath`. Returns `null` when the
114
+ * file does not exist (first boot). Throws on corrupt content so the
115
+ * caller can log and continue with a fresh state.
116
+ */
117
+ export async function loadSnapshot(filePath: string): Promise<PersistedState | null> {
118
+ let raw: string;
119
+ try {
120
+ raw = await readFile(filePath, 'utf8');
121
+ } catch (err) {
122
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
123
+ throw err;
124
+ }
125
+ return deserializeState(raw);
126
+ }
127
+
128
+ /**
129
+ * Restores channels from a persisted snapshot into `runtime`. Each channel
130
+ * is created via `getOrCreateChannel` (so the runtime owns it) and then its
131
+ * modes, ban masks, topic, and original `createdAt` are overwritten. The
132
+ * member roster stays empty — clients reconnect and rejoin after a restart.
133
+ */
134
+ export function restoreRuntime(runtime: InMemoryRuntime, state: PersistedState): void {
135
+ for (const pc of state.channels) {
136
+ const chan = runtime.getOrCreateChannel(pc.name);
137
+ chan.modes = { ...pc.modes };
138
+ chan.banMasks = new Set(pc.banMasks);
139
+ if (pc.topic !== undefined) {
140
+ chan.topic = pc.topic;
141
+ }
142
+ chan.createdAt = pc.createdAt;
143
+ }
144
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Worker entry for the CF TCP container origin.
3
+ *
4
+ * This Worker is a thin management wrapper around the Cloudflare Container.
5
+ * It is NOT in the data path for IRC traffic — Spectrum terminates TLS on
6
+ * :6697 and forwards plaintext TCP directly to the container's port 6667.
7
+ * The Worker exists for:
8
+ * - Health checks (`GET /health` → container status).
9
+ * - Container lifecycle management (start/stop via the DO binding).
10
+ */
11
+
12
+ export { IrcTcpOrigin } from './container-do.js';
13
+
14
+ export default {
15
+ async fetch(request: Request, env: Env): Promise<Response> {
16
+ const url = new URL(request.url);
17
+
18
+ if (url.pathname === '/health') {
19
+ const container = env.IRC_TCP_ORIGIN.getByName('singleton');
20
+ try {
21
+ await container.startAndWaitForPorts();
22
+ return new Response(JSON.stringify({ ok: true, status: 'running' }), {
23
+ headers: { 'content-type': 'application/json' },
24
+ });
25
+ } catch {
26
+ return new Response(JSON.stringify({ ok: false, status: 'error' }), {
27
+ status: 503,
28
+ headers: { 'content-type': 'application/json' },
29
+ });
30
+ }
31
+ }
32
+
33
+ return new Response('ServerlessIRCd TCP origin management endpoint\n', {
34
+ headers: { 'content-type': 'text/plain' },
35
+ });
36
+ },
37
+ };
38
+
39
+ interface Env {
40
+ IRC_TCP_ORIGIN: DurableObjectNamespace;
41
+ }
@@ -0,0 +1,24 @@
1
+ ###############################################################################
2
+ # Terraform provider config for Cloudflare Spectrum.
3
+ #
4
+ # Configure via environment variables:
5
+ # export CLOUDFLARE_API_TOKEN=cf-secret-token
6
+ # export CLOUDFLARE_ACCOUNT_ID=your-account-id
7
+ #
8
+ # Or via a *.tfvars file passed with -var-file.
9
+ ###############################################################################
10
+
11
+ terraform {
12
+ required_version = ">= 1.5"
13
+ required_providers {
14
+ cloudflare = {
15
+ source = "cloudflare/cloudflare"
16
+ version = "~> 4.0"
17
+ }
18
+ }
19
+ }
20
+
21
+ provider "cloudflare" {
22
+ # api_token and account_id are read from CLOUDFLARE_API_TOKEN and
23
+ # CLOUDFLARE_ACCOUNT_ID environment variables.
24
+ }