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
@@ -0,0 +1,275 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
+ import { type Socket, createConnection } from 'node:net';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import {
8
+ type Server,
9
+ type TLSSocket,
10
+ connect as tlsConnect,
11
+ createServer as tlsCreateServer,
12
+ } from 'node:tls';
13
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
14
+ import { type ContainerServer, startContainerServer } from '../src/container-server.js';
15
+
16
+ /**
17
+ * Generates a self-signed TLS certificate for the test proxy using the
18
+ * system `openssl` binary. This mirrors what Cloudflare Spectrum does in
19
+ * production: terminate TLS at the edge with a certificate, then forward
20
+ * plaintext TCP to the origin.
21
+ *
22
+ * If `openssl` is not available, the calling test is skipped — the TLS
23
+ * e2e requires it to synthesise a trust anchor.
24
+ */
25
+ async function generateSelfSignedCert(dir: string): Promise<{ key: string; cert: string }> {
26
+ const keyPath = join(dir, 'key.pem');
27
+ const certPath = join(dir, 'cert.pem');
28
+ try {
29
+ execFileSync(
30
+ 'openssl',
31
+ [
32
+ 'req',
33
+ '-x509',
34
+ '-newkey',
35
+ 'rsa:2048',
36
+ '-keyout',
37
+ keyPath,
38
+ '-out',
39
+ certPath,
40
+ '-days',
41
+ '1',
42
+ '-nodes',
43
+ '-subj',
44
+ '/CN=localhost',
45
+ ],
46
+ { stdio: 'pipe' },
47
+ );
48
+ } catch {
49
+ throw new Error('openssl not available — required to generate test TLS cert');
50
+ }
51
+ const key = await readFile(keyPath, 'utf8');
52
+ const cert = await readFile(certPath, 'utf8');
53
+ return { key, cert };
54
+ }
55
+
56
+ /**
57
+ * IRC test client that speaks TLS (mirrors the Spectrum client path).
58
+ * Connects via `tls.connect`, sends lines with CRLF, parses inbound bytes.
59
+ */
60
+ class TlsIrcClient extends EventEmitter {
61
+ readonly socket: TLSSocket;
62
+ readonly received: string[] = [];
63
+
64
+ constructor(host: string, port: number, opts: { key: string; cert: string }) {
65
+ super();
66
+ this.socket = tlsConnect({
67
+ host,
68
+ port,
69
+ rejectUnauthorized: false,
70
+ ca: opts.cert,
71
+ });
72
+ this.socket.setEncoding('utf8');
73
+ this.socket.on('data', (text: string) => {
74
+ for (const line of text.split(/\r?\n/u)) {
75
+ if (line.length > 0) {
76
+ this.received.push(line);
77
+ this.emit('line', line);
78
+ }
79
+ }
80
+ });
81
+ }
82
+
83
+ secured(): Promise<void> {
84
+ return new Promise((resolve, reject) => {
85
+ if (this.socket.authorized !== undefined) resolve();
86
+ else {
87
+ this.socket.once('secure', () => resolve());
88
+ this.socket.once('error', reject);
89
+ }
90
+ });
91
+ }
92
+
93
+ send(line: string): Promise<void> {
94
+ return new Promise((resolve, reject) => {
95
+ this.socket.write(`${line}\r\n`, (err) => (err ? reject(err) : resolve()));
96
+ });
97
+ }
98
+
99
+ waitFor(predicate: (line: string) => boolean, timeoutMs = 3000): Promise<string> {
100
+ return new Promise((resolve, reject) => {
101
+ const timeout = setTimeout(() => {
102
+ reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`));
103
+ }, timeoutMs);
104
+ const check = (line: string): void => {
105
+ if (predicate(line)) {
106
+ clearTimeout(timeout);
107
+ this.off('line', check);
108
+ resolve(line);
109
+ }
110
+ };
111
+ for (const line of this.received) {
112
+ if (predicate(line)) {
113
+ clearTimeout(timeout);
114
+ resolve(line);
115
+ return;
116
+ }
117
+ }
118
+ this.on('line', check);
119
+ });
120
+ }
121
+
122
+ close(): Promise<void> {
123
+ return new Promise((resolve) => {
124
+ this.socket.once('close', () => resolve());
125
+ this.socket.end();
126
+ });
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Starts an in-process TLS-terminating proxy (the "stunnel" replacement).
132
+ * Each TLS connection from a client gets a plaintext TCP connection piped
133
+ * to the container origin. This is the local analogue of Cloudflare
134
+ * Spectrum: TLS at the edge, plaintext to the origin.
135
+ */
136
+ function startTlsProxy(opts: {
137
+ key: string;
138
+ cert: string;
139
+ originHost: string;
140
+ originPort: number;
141
+ }): Promise<{ server: Server; port: number; close: () => Promise<void> }> {
142
+ return new Promise((resolve, reject) => {
143
+ const originSockets = new Set<Socket>();
144
+ const server = tlsCreateServer({ key: opts.key, cert: opts.cert }, (tlsSocket) => {
145
+ const origin = createConnection({ host: opts.originHost, port: opts.originPort });
146
+ originSockets.add(origin);
147
+ tlsSocket.pipe(origin);
148
+ origin.pipe(tlsSocket);
149
+ const cleanup = (): void => {
150
+ origin.destroy();
151
+ tlsSocket.destroy();
152
+ };
153
+ tlsSocket.on('close', cleanup);
154
+ origin.on('close', () => {
155
+ originSockets.delete(origin);
156
+ tlsSocket.destroy();
157
+ });
158
+ tlsSocket.on('error', cleanup);
159
+ origin.on('error', cleanup);
160
+ });
161
+ server.once('error', reject);
162
+ server.listen(0, '127.0.0.1', () => {
163
+ server.removeListener('error', reject);
164
+ const addr = server.address();
165
+ // c8 ignore next 1
166
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
167
+ resolve({
168
+ server,
169
+ port,
170
+ close: () =>
171
+ new Promise<void>((res) => {
172
+ for (const s of originSockets) s.destroy();
173
+ originSockets.clear();
174
+ server.close(() => res());
175
+ }),
176
+ });
177
+ });
178
+ });
179
+ }
180
+
181
+ describe('container-server — TLS e2e via in-process proxy (Spectrum analogue)', () => {
182
+ let origin: ContainerServer;
183
+ let proxy: { server: Server; port: number; close: () => Promise<void> };
184
+ let dir: string;
185
+ let cert: { key: string; cert: string };
186
+
187
+ beforeAll(async () => {
188
+ dir = await mkdtemp(join(tmpdir(), 'sirc-tls-'));
189
+ try {
190
+ cert = await generateSelfSignedCert(dir);
191
+ } catch (err) {
192
+ // Skip the entire suite if openssl isn't available.
193
+ console.warn(String((err as Error).message));
194
+ return;
195
+ }
196
+ origin = await startContainerServer({
197
+ port: 0,
198
+ host: '127.0.0.1',
199
+ serverName: 'irc.tls.test',
200
+ networkName: 'TlsTestNet',
201
+ });
202
+ proxy = await startTlsProxy({
203
+ key: cert.key,
204
+ cert: cert.cert,
205
+ originHost: '127.0.0.1',
206
+ originPort: origin.port,
207
+ });
208
+ });
209
+
210
+ afterAll(async () => {
211
+ if (proxy !== undefined) await proxy.close();
212
+ if (origin !== undefined) await origin.close();
213
+ await rm(dir, { recursive: true, force: true });
214
+ });
215
+
216
+ it('a TLS client completes register → join → privmsg → quit through the proxy', async () => {
217
+ if (cert === undefined) return; // openssl not available
218
+
219
+ const alice = new TlsIrcClient('127.0.0.1', proxy.port, cert);
220
+ await alice.secured();
221
+ const bob = new TlsIrcClient('127.0.0.1', proxy.port, cert);
222
+ await bob.secured();
223
+
224
+ await alice.send('NICK alice');
225
+ await alice.send('USER alice 0 * :Alice');
226
+ await alice.waitFor((l) => l.startsWith(':irc.tls.test 001 '));
227
+
228
+ await bob.send('NICK bob');
229
+ await bob.send('USER bob 0 * :Bob');
230
+ await bob.waitFor((l) => l.startsWith(':irc.tls.test 001 '));
231
+
232
+ await alice.send('JOIN #tls-e2e');
233
+ await alice.waitFor((l) => l.includes('JOIN #tls-e2e'));
234
+ await bob.send('JOIN #tls-e2e');
235
+ await bob.waitFor((l) => l.includes('JOIN #tls-e2e'));
236
+
237
+ await alice.send('PRIVMSG #tls-e2e :over tls');
238
+ await bob.waitFor((l) => l.includes('PRIVMSG #tls-e2e :over tls'));
239
+
240
+ await alice.close();
241
+ await bob.close();
242
+ });
243
+
244
+ it('routes PING → PONG over TLS', async () => {
245
+ if (cert === undefined) return;
246
+
247
+ const client = new TlsIrcClient('127.0.0.1', proxy.port, cert);
248
+ await client.secured();
249
+ await client.send('NICK tlsPing');
250
+ await client.send('USER tlsPing 0 * :TlsPing');
251
+ await client.waitFor((l) => l.startsWith(':irc.tls.test 001 '));
252
+
253
+ await client.send('PING :tls-tok');
254
+ await client.waitFor((l) => l === 'PONG :tls-tok');
255
+ await client.close();
256
+ });
257
+
258
+ it('the origin only sees plaintext (no TLS handshake bytes reach the actor)', async () => {
259
+ if (cert === undefined) return;
260
+
261
+ // If the origin received TLS handshake bytes, the TcpByteStreamTransport
262
+ // would try to parse them as IRC lines and produce garbage 421 replies.
263
+ // A clean registration proves only plaintext IRC lines arrived.
264
+ const client = new TlsIrcClient('127.0.0.1', proxy.port, cert);
265
+ await client.secured();
266
+ await client.send('NICK plaintext');
267
+ await client.send('USER plaintext 0 * :Plaintext');
268
+ const welcome = await client.waitFor((l) => l.startsWith(':irc.tls.test 001 plaintext'));
269
+ expect(welcome).toBeDefined();
270
+ // No 421 (ERR_UNKNOWNCOMMAND) should appear — that would indicate
271
+ // non-IRC bytes (TLS handshake) leaking through to the parser.
272
+ expect(client.received.some((l) => l.includes(' 421 '))).toBe(false);
273
+ await client.close();
274
+ });
275
+ });
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "rootDir": "./src",
6
+ "outDir": "./dist",
7
+ "tsBuildInfoFile": "./.tsbuildinfo",
8
+ "types": ["node"]
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["dist", "tests", "**/*.test.ts", "src/worker.ts", "src/container-do.ts"],
12
+ "references": [
13
+ { "path": "../../packages/irc-core/tsconfig.build.json" },
14
+ { "path": "../../packages/irc-server/tsconfig.build.json" },
15
+ { "path": "../../packages/in-memory-runtime/tsconfig.build.json" }
16
+ ]
17
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": ["node"],
6
+ "composite": false
7
+ },
8
+ "include": ["src/**/*.ts", "tests/**/*.ts"],
9
+ "exclude": ["dist", "src/worker.ts", "src/container-do.ts"],
10
+ "references": [
11
+ { "path": "../../packages/irc-core/tsconfig.build.json" },
12
+ { "path": "../../packages/irc-server/tsconfig.build.json" },
13
+ { "path": "../../packages/in-memory-runtime/tsconfig.build.json" }
14
+ ]
15
+ }
@@ -0,0 +1,26 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ testTimeout: 10_000,
7
+ include: ['tests/**/*.test.ts'],
8
+ coverage: {
9
+ provider: 'v8',
10
+ all: false,
11
+ include: ['src/**/*.ts'],
12
+ // `main.ts` is CLI glue (argv parsing, signal handlers); exercised
13
+ // manually by `pnpm start`. `worker.ts` + `container-do.ts` are
14
+ // wrangler-only deploy artifacts (depend on @cloudflare/containers,
15
+ // compiled by wrangler at deploy time). The rest targets ≥90%.
16
+ exclude: ['src/main.ts', 'src/worker.ts', 'src/container-do.ts'],
17
+ reporter: ['text', 'html', 'json-summary'],
18
+ thresholds: {
19
+ lines: 90,
20
+ functions: 90,
21
+ branches: 80,
22
+ statements: 90,
23
+ },
24
+ },
25
+ },
26
+ });
@@ -0,0 +1,63 @@
1
+ #:vim:set ft=toml:
2
+ ##
3
+ ## Cloudflare Containers config for the ServerlessIRCd TCP origin.
4
+ ##
5
+ ## Deploys the IRC TCP container (apps/cf-tcp-container) to the Cloudflare
6
+ ## Containers platform. The container listens on port 6667 (plaintext TCP);
7
+ ## Cloudflare Spectrum (configured separately in terraform/spectrum.tf)
8
+ ## terminates TLS on :6697 and forwards plaintext to this container.
9
+ ##
10
+ ## Prerequisites:
11
+ ## - Cloudflare account with Containers beta access.
12
+ ## - wrangler v4.112+ (`pnpm add -g wrangler`).
13
+ ## - The container image is built from apps/cf-tcp-container/Dockerfile.
14
+ ##
15
+ ## Deploy:
16
+ ## wrangler deploy --env staging
17
+ ##
18
+
19
+ name = "sirc-tcp-origin"
20
+ main = "src/worker.ts"
21
+ compatibility_date = "2024-11-01"
22
+ compatibility_flags = ["nodejs_compat"]
23
+
24
+ [observability]
25
+ enabled = true
26
+
27
+ # --- Container definition ---
28
+ # The IRC TCP origin container. Listens on port 6667; the Worker below is a
29
+ # thin management/health-check wrapper (Spectrum routes TCP directly to the
30
+ # container origin, not through the Worker).
31
+ [[containers]]
32
+ class_name = "IrcTcpOrigin"
33
+ image = "./Dockerfile"
34
+ instance_type = "standard-1"
35
+ max_instances = 3
36
+ sleep_after = "2h"
37
+
38
+ # --- Durable Object binding for the container ---
39
+ [[durable_objects.bindings]]
40
+ name = "IRC_TCP_ORIGIN"
41
+ class_name = "IrcTcpOrigin"
42
+
43
+ # --- Migration ---
44
+ [[migrations]]
45
+ tag = "v1"
46
+ new_sqlite_classes = ["IrcTcpOrigin"]
47
+
48
+ # --- Environment ---
49
+ [vars]
50
+ SERVER_NAME = "irc.example.com"
51
+ NETWORK_NAME = "ExampleNet"
52
+ MOTD_LINES = "Welcome to the ServerlessIRCd TCP origin."
53
+ TCP_PORT = "6667"
54
+ PERSISTENCE_PATH = "/data/state.json"
55
+
56
+ # --- Staging ---
57
+ [env.staging]
58
+ name = "sirc-tcp-origin-staging"
59
+
60
+ [env.staging.vars]
61
+ SERVER_NAME = "irc-staging.example.com"
62
+ NETWORK_NAME = "StagingNet"
63
+ MOTD_LINES = "Welcome to the staging ServerlessIRCd TCP origin."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-worker",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Worker deploy glue: WebSocket edge entry point + DO bindings + wrangler pipeline",
6
6
  "license": "BSD-3-Clause",
File without changes
@@ -22,11 +22,17 @@
22
22
  * See `wrangler.toml` and `docs/deployment-cf.md`.
23
23
  */
24
24
 
25
- import { ChannelDO, ConnectionDO, type Env, RegistryDO } from '@serverless-ircd/cf-adapter';
25
+ import {
26
+ ChannelDO,
27
+ ChannelRegistryDO,
28
+ ConnectionDO,
29
+ type Env,
30
+ RegistryDO,
31
+ } from '@serverless-ircd/cf-adapter';
26
32
 
27
33
  // Re-exported for wrangler DO binding resolution. The class names below
28
34
  // MUST match `class_name` in `wrangler.toml`.
29
- export { ChannelDO, ConnectionDO, RegistryDO };
35
+ export { ChannelDO, ChannelRegistryDO, ConnectionDO, RegistryDO };
30
36
  export type { Env };
31
37
 
32
38
  /**
@@ -26,6 +26,7 @@ declare global {
26
26
  CONNECTION_DO: DurableObjectNamespace;
27
27
  REGISTRY_DO: DurableObjectNamespace;
28
28
  CHANNEL_DO: DurableObjectNamespace;
29
+ CHANNEL_REGISTRY_DO: DurableObjectNamespace;
29
30
  SERVER_NAME: string;
30
31
  NETWORK_NAME: string;
31
32
  MOTD_LINES: string;
@@ -28,6 +28,16 @@ class_name = "RegistryDO"
28
28
  name = "CHANNEL_DO"
29
29
  class_name = "ChannelDO"
30
30
 
31
+ [[durable_objects.bindings]]
32
+ name = "CHANNEL_REGISTRY_DO"
33
+ class_name = "ChannelRegistryDO"
34
+
35
+ # D1 backing for persistent SASL accounts
36
+ [[d1_databases]]
37
+ binding = "ACCOUNTS_DB"
38
+ database_name = "cf-worker-test-accounts"
39
+ database_id = "test-d1-worker-accounts"
40
+
31
41
  [[migrations]]
32
42
  tag = "v1"
33
- new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO"]
43
+ new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
@@ -50,7 +50,10 @@ invocation_logs = true
50
50
  [vars]
51
51
  SERVER_NAME = "irc.example.com"
52
52
  NETWORK_NAME = "ServerlessIRCd"
53
- MOTD_LINES = "Welcome to ServerlessIRCd.\nThis is a deployed Cloudflare Worker."
53
+ MOTD_LINES = """
54
+ Welcome to ServerlessIRCd.
55
+ This is a deployed Cloudflare Worker.
56
+ """
54
57
 
55
58
  ## ---------------------------------------------------------------------------
56
59
  ## Staging environment.
@@ -68,7 +71,30 @@ head_sampling_rate = 1
68
71
  [env.staging.vars]
69
72
  SERVER_NAME = "irc-staging.example.com"
70
73
  NETWORK_NAME = "ServerlessIRCd (staging)"
71
- MOTD_LINES = "Welcome to ServerlessIRCd staging.\nThis Worker was deployed by CI from the main branch."
74
+ MOTD_LINES = """
75
+ Welcome to ServerlessIRCd staging.
76
+ This is a deployed Cloudflare Worker.
77
+ This Worker was deployed by CI from the main branch.
78
+ """
79
+
80
+ ## ---------------------------------------------------------------------------
81
+ ## D1 database — SASL account persistence.
82
+ ## ---------------------------------------------------------------------------
83
+ ## The `accounts` table stores scrypt-hashed SASL PLAIN credentials (never
84
+ ## plaintext). Loaded at ConnectionDO construction; when empty or unreachable
85
+ ## the resolver falls back to the `SASL_ACCOUNTS` env-var seed. Provision with:
86
+ ## wrangler d1 create serverless-ircd-accounts
87
+ ## wrangler d1 execute serverless-ircd-accounts --command \
88
+ ## "CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)"
89
+ ## Seed accounts via `tools/seed-cf-accounts.ts`.
90
+ [[d1_databases]]
91
+ binding = "ACCOUNTS_DB"
92
+ database_name = "serverless-ircd-accounts"
93
+ # database_id is filled in by `wrangler d1 create` and is env-specific.
94
+
95
+ [[env.staging.d1_databases]]
96
+ binding = "ACCOUNTS_DB"
97
+ database_name = "serverless-ircd-accounts-staging"
72
98
 
73
99
  ## ---------------------------------------------------------------------------
74
100
  ## Durable Object bindings.
@@ -91,6 +117,10 @@ class_name = "RegistryDO"
91
117
  name = "CHANNEL_DO"
92
118
  class_name = "ChannelDO"
93
119
 
120
+ [[durable_objects.bindings]]
121
+ name = "CHANNEL_REGISTRY_DO"
122
+ class_name = "ChannelRegistryDO"
123
+
94
124
  # Staging environment bindings (wrangler requires per-env blocks).
95
125
  [[env.staging.durable_objects.bindings]]
96
126
  name = "CONNECTION_DO"
@@ -104,20 +134,40 @@ class_name = "RegistryDO"
104
134
  name = "CHANNEL_DO"
105
135
  class_name = "ChannelDO"
106
136
 
107
- ## ---------------------------------------------------------------------------
108
- ## Durable Object migrations.
109
- ## ---------------------------------------------------------------------------
110
- ## `tag` is the user-defined migration id; wrangler refuses to deploy if
111
- ## the on-disk migrations don't match the live Workers state. `new_classes`
112
- ## declares the three DO classes on first deploy; subsequent schema
113
- ## changes (renames, deletions, splits) extend this list with their own
114
- ## `tag` so the runtime can migrate persisted state.
115
- ##
116
- ## See https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/
117
- [[migrations]]
118
- tag = "v1"
119
- new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO"]
120
-
121
- [[env.staging.migrations]]
122
- tag = "v1"
123
- new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO"]
137
+ [[env.staging.durable_objects.bindings]]
138
+ name = "CHANNEL_REGISTRY_DO"
139
+ class_name = "ChannelRegistryDO"
140
+
141
+ # ## ---------------------------------------------------------------------------
142
+ # ## Durable Object migrations.
143
+ # ## ---------------------------------------------------------------------------
144
+ # ## `tag` is the user-defined migration id; wrangler refuses to deploy if
145
+ # ## the on-disk migrations don't match the live Workers state. `new_classes`
146
+ # ## declares the three DO classes on first deploy; subsequent schema
147
+ # ## changes (renames, deletions, splits) extend this list with their own
148
+ # ## `tag` so the runtime can migrate persisted state.
149
+ # ##
150
+ # ## See https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/
151
+ # [[migrations]]
152
+ # tag = "v1"
153
+ # new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
154
+
155
+ # [[env.staging.migrations]]
156
+ # tag = "v1"
157
+ # new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
158
+
159
+ [exports.ConnectionDO]
160
+ type = "durable-object"
161
+ storage = "sqlite"
162
+
163
+ [exports.RegistryDO]
164
+ type = "durable-object"
165
+ storage = "sqlite"
166
+
167
+ [exports.ChannelDO]
168
+ type = "durable-object"
169
+ storage = "sqlite"
170
+
171
+ [exports.ChannelRegistryDO]
172
+ type = "durable-object"
173
+ storage = "sqlite"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "description": "Runnable WebSocket IRC server using the in-memory runtime; manual-test harness and e2e fixture target",
6
6
  "license": "BSD-3-Clause",
@@ -21,6 +21,16 @@ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc
21
21
  export interface CliConfigInput {
22
22
  serverName: string;
23
23
  networkName: string;
24
+ /**
25
+ * Server version surfaced in `002`/`004`/`351`/`371`. When omitted the
26
+ * schema default ({@link DEFAULT_SERVER_VERSION}) applies.
27
+ */
28
+ serverVersion?: string;
29
+ /**
30
+ * "Created" text for `003 RPL_CREATED`. A string is used verbatim; a
31
+ * number is treated as epoch-ms and formatted as a UTC timestamp.
32
+ */
33
+ createdAt?: string | number;
24
34
  motdLines?: string[];
25
35
  channelPrefixes?: string;
26
36
  maxClients?: number;
@@ -32,6 +42,7 @@ export interface CliConfigInput {
32
42
  channelLen?: number;
33
43
  topicLen?: number;
34
44
  quitMessage?: string;
45
+ saslAccounts?: Array<{ username: string; password: string }>;
35
46
  }
36
47
 
37
48
  /**
@@ -14,7 +14,7 @@
14
14
  * Both listeners share one runtime, so a TCP client and a WS client can
15
15
  * see each other (cross-transport channel broadcast). Pass `--no-tcp` to
16
16
  * run WebSocket-only; the WebSocket port / hostname / MOTD are tunable via
17
- * flags (a fuller config-loader lands in a later ticket).
17
+ * flags.
18
18
  *
19
19
  * Usage:
20
20
  * pnpm --filter local-cli start -- --port 6667 --tcp-port 6668 --host 127.0.0.1