serverless-ircd 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -0,0 +1,382 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { EventEmitter } from 'node:events';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { type ConnectionOptions, type TLSSocket, connect as tlsConnect } from 'node:tls';
7
+ import { promisify } from 'node:util';
8
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
9
+ import { type TlsListenerOptions, startContainerServer } from '../src/container-server.js';
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ /**
14
+ * TLS-listener behavior of the container origin. The origin terminates
15
+ * TLS itself (Spectrum `tls_mode = "full"` topology): cert/key paths are
16
+ * supplied, `tls.createServer` binds :6697 (or an ephemeral port in
17
+ * tests), and plaintext :6667 stays closed unless explicitly requested.
18
+ */
19
+
20
+ const OPENSSL_TIMEOUT_MS = 15_000;
21
+
22
+ let certFiles: { certPath: string; keyPath: string; cert: string } | undefined;
23
+ let certDir: string;
24
+
25
+ /** Self-signed cert via the system `openssl` binary (see tls-e2e.test.ts). */
26
+ async function generateSelfSignedCert(
27
+ dir: string,
28
+ ): Promise<{ certPath: string; keyPath: string; cert: string }> {
29
+ const certPath = join(dir, 'tls.pem');
30
+ const keyPath = join(dir, 'tls.key');
31
+ await execFileAsync(
32
+ 'openssl',
33
+ [
34
+ 'req',
35
+ '-x509',
36
+ '-newkey',
37
+ 'rsa:2048',
38
+ '-keyout',
39
+ keyPath,
40
+ '-out',
41
+ certPath,
42
+ '-days',
43
+ '1',
44
+ '-nodes',
45
+ '-subj',
46
+ '/CN=localhost',
47
+ ],
48
+ { timeout: OPENSSL_TIMEOUT_MS },
49
+ );
50
+ const { readFile } = await import('node:fs/promises');
51
+ return { certPath, keyPath, cert: await readFile(certPath, 'utf8') };
52
+ }
53
+
54
+ /** Minimal TLS IRC test client (mirrors the Spectrum client path). */
55
+ class TlsIrcClient extends EventEmitter {
56
+ readonly socket: TLSSocket;
57
+ readonly received: string[] = [];
58
+
59
+ constructor(host: string, port: number, ca: string, overrides: Partial<ConnectionOptions> = {}) {
60
+ super();
61
+ this.socket = tlsConnect({ host, port, rejectUnauthorized: false, ca, ...overrides });
62
+ this.socket.setEncoding('utf8');
63
+ this.socket.on('data', (text: string) => {
64
+ for (const line of text.split(/\r?\n/u)) {
65
+ if (line.length > 0) {
66
+ this.received.push(line);
67
+ this.emit('line', line);
68
+ }
69
+ }
70
+ });
71
+ }
72
+
73
+ secured(): Promise<void> {
74
+ return new Promise((resolve, reject) => {
75
+ if (this.socket.authorized !== undefined) resolve();
76
+ else {
77
+ this.socket.once('secure', () => resolve());
78
+ this.socket.once('error', reject);
79
+ }
80
+ });
81
+ }
82
+
83
+ private proxyHeaderPending = true;
84
+
85
+ send(line: string): Promise<void> {
86
+ // The origin requires a PROXY v1 header on every flow (Spectrum
87
+ // `proxy_protocol = "v1"`); emulate the edge by prefixing the first
88
+ // frame of each connection with the header line.
89
+ const frame = this.proxyHeaderPending
90
+ ? `PROXY TCP4 127.0.0.1 192.0.2.1 40000 6697\r\n${line}\r\n`
91
+ : `${line}\r\n`;
92
+ this.proxyHeaderPending = false;
93
+ return new Promise((resolve, reject) => {
94
+ this.socket.write(frame, (err) => (err ? reject(err) : resolve()));
95
+ });
96
+ }
97
+
98
+ waitFor(predicate: (line: string) => boolean, timeoutMs = 3000): Promise<string> {
99
+ return new Promise((resolve, reject) => {
100
+ const timeout = setTimeout(() => {
101
+ reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`));
102
+ }, timeoutMs);
103
+ const check = (line: string): void => {
104
+ if (predicate(line)) {
105
+ clearTimeout(timeout);
106
+ this.off('line', check);
107
+ resolve(line);
108
+ }
109
+ };
110
+ for (const line of this.received) {
111
+ if (predicate(line)) {
112
+ clearTimeout(timeout);
113
+ resolve(line);
114
+ return;
115
+ }
116
+ }
117
+ this.on('line', check);
118
+ });
119
+ }
120
+
121
+ close(): Promise<void> {
122
+ return new Promise((resolve) => {
123
+ this.socket.once('close', () => resolve());
124
+ this.socket.end();
125
+ });
126
+ }
127
+ }
128
+
129
+ function certTls(overrides: Partial<TlsListenerOptions> = {}): TlsListenerOptions {
130
+ if (certFiles === undefined) throw new Error('cert material not generated');
131
+ return { certPath: certFiles.certPath, keyPath: certFiles.keyPath, port: 0, ...overrides };
132
+ }
133
+
134
+ describe('container-server — origin TLS listener', () => {
135
+ beforeAll(async () => {
136
+ certDir = await mkdtemp(join(tmpdir(), 'sirc-tls-origin-'));
137
+ try {
138
+ certFiles = await generateSelfSignedCert(certDir);
139
+ } catch (err) {
140
+ console.warn(String((err as Error).message));
141
+ }
142
+ }, 30_000);
143
+
144
+ afterAll(async () => {
145
+ await rm(certDir, { recursive: true, force: true });
146
+ });
147
+
148
+ it('binds a TLS listener on an ephemeral port and serves IRC over TLS', async () => {
149
+ if (certFiles === undefined) return; // openssl not available
150
+ const srv = await startContainerServer({
151
+ host: '127.0.0.1',
152
+ serverName: 'irc.tlsorigin.test',
153
+ networkName: 'TlsOriginNet',
154
+ tls: certTls(),
155
+ });
156
+ expect(srv.tlsPort).toBeGreaterThan(0);
157
+
158
+ const client = new TlsIrcClient('127.0.0.1', srv.tlsPort as number, certFiles.cert);
159
+ await client.secured();
160
+ await client.send('NICK tlsUser');
161
+ await client.send('USER tlsUser 0 * :TlsUser');
162
+ await client.waitFor((l) => l.startsWith(':irc.tlsorigin.test 001 '));
163
+ await client.send('PING :tls-listener');
164
+ await client.waitFor((l) => l === 'PONG :tls-listener');
165
+ await client.close();
166
+ await srv.close();
167
+ });
168
+
169
+ it('defaults the TLS listener port to 6697', async () => {
170
+ if (certFiles === undefined) return;
171
+ const srv = await startContainerServer({
172
+ host: '127.0.0.1',
173
+ serverName: 'irc.tlsorigin.test',
174
+ networkName: 'TlsOriginNet',
175
+ tls: certTls({ port: undefined }),
176
+ });
177
+ expect(srv.tlsPort).toBe(6697);
178
+ await srv.close();
179
+ });
180
+
181
+ it('does not open a plaintext listener when only TLS is configured', async () => {
182
+ if (certFiles === undefined) return;
183
+ const srv = await startContainerServer({
184
+ host: '127.0.0.1',
185
+ serverName: 'irc.tlsorigin.test',
186
+ networkName: 'TlsOriginNet',
187
+ tls: certTls({ port: undefined }),
188
+ });
189
+ expect(srv.tlsPort).toBe(6697);
190
+ expect(srv.port).toBeUndefined();
191
+ await srv.close();
192
+ });
193
+
194
+ it('serves both listeners when plaintext debug and TLS are both requested', async () => {
195
+ if (certFiles === undefined) return;
196
+ const srv = await startContainerServer({
197
+ host: '127.0.0.1',
198
+ port: 0,
199
+ serverName: 'irc.tlsorigin.test',
200
+ networkName: 'TlsOriginNet',
201
+ tls: certTls(),
202
+ });
203
+ expect(srv.port).toBeGreaterThan(0);
204
+ expect(srv.tlsPort).toBeGreaterThan(0);
205
+
206
+ const tlsClient = new TlsIrcClient('127.0.0.1', srv.tlsPort as number, certFiles.cert);
207
+ await tlsClient.secured();
208
+ await tlsClient.send('NICK secure');
209
+ await tlsClient.send('USER secure 0 * :Secure');
210
+ await tlsClient.waitFor((l) => l.startsWith(':irc.tlsorigin.test 001 secure'));
211
+ await tlsClient.close();
212
+
213
+ const { createConnection } = await import('node:net');
214
+ const plain = createConnection({ host: '127.0.0.1', port: srv.port as number });
215
+ await new Promise<void>((resolve) => plain.once('connect', () => resolve()));
216
+ plain.destroy();
217
+ await srv.close();
218
+ });
219
+
220
+ it('refuses TLS handshakes after close()', async () => {
221
+ if (certFiles === undefined) return;
222
+ const srv = await startContainerServer({
223
+ host: '127.0.0.1',
224
+ serverName: 'irc.tlsorigin.test',
225
+ networkName: 'TlsOriginNet',
226
+ tls: certTls(),
227
+ });
228
+ const port = srv.tlsPort as number;
229
+ await srv.close();
230
+ const refused = new Promise<void>((resolve, reject) => {
231
+ const probe = tlsConnect({ host: '127.0.0.1', port, rejectUnauthorized: false });
232
+ probe.once('error', () => resolve());
233
+ probe.once('secureConnect', () =>
234
+ reject(new Error('TLS listener still accepting after close')),
235
+ );
236
+ });
237
+ await expect(refused).resolves.toBeUndefined();
238
+ });
239
+
240
+ it('rejects startup with a clear error naming the path when the cert file is missing', async () => {
241
+ if (certFiles === undefined) return;
242
+ const missing = join(certDir, 'no-such-cert.pem');
243
+ await expect(
244
+ startContainerServer({
245
+ host: '127.0.0.1',
246
+ serverName: 'irc.tlsorigin.test',
247
+ networkName: 'TlsOriginNet',
248
+ tls: certTls({ certPath: missing }),
249
+ }),
250
+ ).rejects.toThrow(/no-such-cert\.pem/u);
251
+ });
252
+
253
+ it('rejects startup with a clear error naming the path when the key file is missing', async () => {
254
+ if (certFiles === undefined) return;
255
+ const missing = join(certDir, 'no-such-key.pem');
256
+ await expect(
257
+ startContainerServer({
258
+ host: '127.0.0.1',
259
+ serverName: 'irc.tlsorigin.test',
260
+ networkName: 'TlsOriginNet',
261
+ tls: certTls({ keyPath: missing }),
262
+ }),
263
+ ).rejects.toThrow(/no-such-key\.pem/u);
264
+ });
265
+
266
+ it('rejects startup when no listener is configured at all', async () => {
267
+ await expect(
268
+ startContainerServer({
269
+ host: '127.0.0.1',
270
+ serverName: 'irc.tlsorigin.test',
271
+ networkName: 'TlsOriginNet',
272
+ }),
273
+ ).rejects.toThrow(/no listener/u);
274
+ });
275
+
276
+ it('marks TLS-terminated connections as secure (user mode +S in 221)', async () => {
277
+ if (certFiles === undefined) return;
278
+ const srv = await startContainerServer({
279
+ host: '127.0.0.1',
280
+ serverName: 'irc.tlsorigin.test',
281
+ networkName: 'TlsOriginNet',
282
+ tls: certTls(),
283
+ });
284
+ const client = new TlsIrcClient('127.0.0.1', srv.tlsPort as number, certFiles.cert);
285
+ await client.secured();
286
+ await client.send('NICK modeS');
287
+ await client.send('USER modeS 0 * :ModeS');
288
+ await client.waitFor((l) => l.startsWith(':irc.tlsorigin.test 001 '));
289
+ await client.send('MODE modeS');
290
+ await client.waitFor((l) => l.startsWith(':irc.tlsorigin.test 221 modeS +S'));
291
+ await client.close();
292
+ await srv.close();
293
+ });
294
+
295
+ // Spectrum TCP applications cannot pin TLS versions at the edge (no
296
+ // `tls_versions` in the Cloudflare API/provider; the fixed floor is
297
+ // TLS 1.1), so the origin listener is the enforcement point: it only
298
+ // ever negotiates TLS 1.2 or 1.3 on the re-encrypted Spectrum→origin
299
+ // leg.
300
+ describe('pinned protocol versions (TLS 1.2 / 1.3 only)', () => {
301
+ it('completes an IRC registration over a forced TLS 1.2 handshake (floor)', async () => {
302
+ if (certFiles === undefined) return;
303
+ const srv = await startContainerServer({
304
+ host: '127.0.0.1',
305
+ serverName: 'irc.tlsorigin.test',
306
+ networkName: 'TlsOriginNet',
307
+ tls: certTls(),
308
+ });
309
+ const client = new TlsIrcClient('127.0.0.1', srv.tlsPort as number, certFiles.cert, {
310
+ minVersion: 'TLSv1.2',
311
+ maxVersion: 'TLSv1.2',
312
+ });
313
+ await client.secured();
314
+ await client.send('NICK floor12');
315
+ await client.send('USER floor12 0 * :Floor12');
316
+ await client.waitFor((l) => l.startsWith(':irc.tlsorigin.test 001 floor12'));
317
+ // Asserted after inbound data: the handshake is unambiguously
318
+ // complete (getProtocol() mid-handshake reports the offered, not
319
+ // the negotiated, version).
320
+ expect(client.socket.getProtocol()).toBe('TLSv1.2');
321
+ await client.send('PING :tls12-floor');
322
+ await client.waitFor((l) => l === 'PONG :tls12-floor');
323
+ await client.close();
324
+ await srv.close();
325
+ });
326
+
327
+ it('negotiates TLS 1.3 (ceiling)', async () => {
328
+ if (certFiles === undefined) return;
329
+ const srv = await startContainerServer({
330
+ host: '127.0.0.1',
331
+ serverName: 'irc.tlsorigin.test',
332
+ networkName: 'TlsOriginNet',
333
+ tls: certTls(),
334
+ });
335
+ const client = new TlsIrcClient('127.0.0.1', srv.tlsPort as number, certFiles.cert, {
336
+ minVersion: 'TLSv1.3',
337
+ maxVersion: 'TLSv1.3',
338
+ });
339
+ await client.secured();
340
+ await client.send('PING :tls13-ceiling');
341
+ await client.waitFor((l) => l === 'PONG :tls13-ceiling');
342
+ expect(client.socket.getProtocol()).toBe('TLSv1.3');
343
+ await client.close();
344
+ await srv.close();
345
+ });
346
+
347
+ it('rejects a TLS 1.1 handshake with a protocol-version alert', async () => {
348
+ if (certFiles === undefined) return;
349
+ const srv = await startContainerServer({
350
+ host: '127.0.0.1',
351
+ serverName: 'irc.tlsorigin.test',
352
+ networkName: 'TlsOriginNet',
353
+ tls: certTls(),
354
+ });
355
+ // `@SECLEVEL=0` lets the OpenSSL 3 client offer TLS 1.1 at all —
356
+ // without it the client errors locally before any handshake bytes
357
+ // reach the server.
358
+ const ca = certFiles.cert;
359
+ const rejected = new Promise<void>((resolve, reject) => {
360
+ const probe = tlsConnect({
361
+ host: '127.0.0.1',
362
+ port: srv.tlsPort as number,
363
+ rejectUnauthorized: false,
364
+ ca,
365
+ minVersion: 'TLSv1.1',
366
+ maxVersion: 'TLSv1.1',
367
+ ciphers: 'DEFAULT@SECLEVEL=0',
368
+ });
369
+ probe.once('error', (err: Error) => {
370
+ if (/protocol version/iu.test(err.message)) resolve();
371
+ else reject(err);
372
+ });
373
+ probe.once('secureConnect', () => {
374
+ probe.destroy();
375
+ reject(new Error('TLS 1.1 handshake accepted'));
376
+ });
377
+ });
378
+ await expect(rejected).resolves.toBeUndefined();
379
+ await srv.close();
380
+ });
381
+ });
382
+ });