serverless-ircd 0.8.0 → 0.9.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.
- package/.github/workflows/ci.yml +4 -0
- package/CHANGELOG.md +245 -0
- package/README.md +160 -200
- package/apps/aws-stack/package.json +1 -1
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/container-server.ts +21 -1
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-worker/package.json +1 -1
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +94 -31
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/index.html +226 -3
- package/apps/web/package.json +2 -1
- package/apps/web/scripts/build.mjs +25 -2
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +31 -2
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/Services.md +33 -1
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/connection-do.ts +18 -6
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/commands/account-auth.ts +46 -18
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/nickserv.ts +16 -4
- package/packages/irc-core/src/commands/registration.ts +27 -16
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
- package/packages/irc-core/tests/commands/registration.test.ts +227 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -30
- package/packages/irc-server/tests/actor.test.ts +365 -3
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
|
@@ -128,6 +128,58 @@ describe('container-server — lifecycle', () => {
|
|
|
128
128
|
});
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
describe('container-server — option wiring (coverage matrix)', () => {
|
|
132
|
+
// Exercises the `opts.X ?? default` / `opts.X !== undefined ? ... : ...`
|
|
133
|
+
// branches in startContainerServer by starting the server with each optional
|
|
134
|
+
// knob set, so every "option provided" branch is taken at least once.
|
|
135
|
+
|
|
136
|
+
it('binds the default host when `host` is omitted', async () => {
|
|
137
|
+
const srv = await startContainerServer({
|
|
138
|
+
port: 0,
|
|
139
|
+
serverName: 'irc.defaulthost',
|
|
140
|
+
networkName: 'DefaultHostNet',
|
|
141
|
+
});
|
|
142
|
+
expect(srv.host).toBe('0.0.0.0');
|
|
143
|
+
// Connecting via 127.0.0.1 still reaches the 0.0.0.0 listener.
|
|
144
|
+
const c = new TcpClient('127.0.0.1', srv.port);
|
|
145
|
+
await c.opened();
|
|
146
|
+
await c.close();
|
|
147
|
+
await srv.close();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('applies the default admission knobs when only maxConnectionsPerUser is set', async () => {
|
|
151
|
+
// Setting only maxConnectionsPerUser builds the admission config, so
|
|
152
|
+
// maxConnectionsPerIp and perIpConnectionRate fall back to their defaults.
|
|
153
|
+
const srv = await startContainerServer({
|
|
154
|
+
port: 0,
|
|
155
|
+
host: '127.0.0.1',
|
|
156
|
+
serverName: 'irc.admission',
|
|
157
|
+
networkName: 'AdmissionNet',
|
|
158
|
+
maxConnectionsPerUser: 5,
|
|
159
|
+
});
|
|
160
|
+
const c = new TcpClient('127.0.0.1', srv.port);
|
|
161
|
+
await c.opened();
|
|
162
|
+
await c.close();
|
|
163
|
+
await srv.close();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('wires saslAccounts, serverPassword, and cloaking when provided', async () => {
|
|
167
|
+
const srv = await startContainerServer({
|
|
168
|
+
port: 0,
|
|
169
|
+
host: '127.0.0.1',
|
|
170
|
+
serverName: 'irc.options',
|
|
171
|
+
networkName: 'OptionsNet',
|
|
172
|
+
serverPassword: 'secret',
|
|
173
|
+
cloaking: { enabled: true, secret: 'cloak-secret', cloakedSuffix: '.hidden' },
|
|
174
|
+
saslAccounts: [{ username: 'alice', password: 'pw' }],
|
|
175
|
+
});
|
|
176
|
+
const c = new TcpClient('127.0.0.1', srv.port);
|
|
177
|
+
await c.opened();
|
|
178
|
+
await c.close();
|
|
179
|
+
await srv.close();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
131
183
|
describe('container-server — IRC flows over TCP', () => {
|
|
132
184
|
let server: ContainerServer;
|
|
133
185
|
|
|
@@ -329,7 +381,6 @@ describe('container-server — snapshot error resilience', () => {
|
|
|
329
381
|
const path = join(dir, 'corrupt.json');
|
|
330
382
|
const { writeFile } = await import('node:fs/promises');
|
|
331
383
|
await writeFile(path, '{ this is not valid json', 'utf8');
|
|
332
|
-
|
|
333
384
|
const srv = await startContainerServer({
|
|
334
385
|
port: 0,
|
|
335
386
|
host: '127.0.0.1',
|
|
@@ -373,6 +424,61 @@ describe('container-server — snapshot error resilience', () => {
|
|
|
373
424
|
expect(c.socket.destroyed).toBe(true);
|
|
374
425
|
await rm(dir, { recursive: true, force: true });
|
|
375
426
|
});
|
|
427
|
+
|
|
428
|
+
it('close() skips sockets that are already destroyed', async () => {
|
|
429
|
+
// Covers closeServer's `!socket.destroyed` false branch: a server-side
|
|
430
|
+
// socket destroyed in the same tick as close() is still in the sockets
|
|
431
|
+
// set (its 'close' cleanup is async) but already destroyed.
|
|
432
|
+
const srv = await startContainerServer({
|
|
433
|
+
port: 0,
|
|
434
|
+
host: '127.0.0.1',
|
|
435
|
+
serverName: 'irc.skip',
|
|
436
|
+
networkName: 'SkipNet',
|
|
437
|
+
});
|
|
438
|
+
const c = new TcpClient('127.0.0.1', srv.port);
|
|
439
|
+
await c.opened();
|
|
440
|
+
for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
|
|
441
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
442
|
+
}
|
|
443
|
+
const serverSideSocket = [...srv.testSockets][0];
|
|
444
|
+
if (serverSideSocket === undefined) throw new Error('no server-side socket');
|
|
445
|
+
// Destroy the server-side socket synchronously; its 'close' handler
|
|
446
|
+
// (which would remove it from the set) fires on a later tick.
|
|
447
|
+
serverSideSocket.destroy();
|
|
448
|
+
await expect(srv.close()).resolves.toBeUndefined();
|
|
449
|
+
await new Promise<void>((resolve) => c.socket.once('close', () => resolve()));
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('logs and resolves when the final snapshot save fails on close', async () => {
|
|
453
|
+
// Pointing persistencePath at an existing directory makes the boot-time
|
|
454
|
+
// load fail (EISDIR → caught → start fresh) AND the close-time save fail
|
|
455
|
+
// (writeFile on a directory → EISDIR → caught at the closeServer catch).
|
|
456
|
+
const dir = await mkdtemp(join(tmpdir(), 'sirc-finalfail-'));
|
|
457
|
+
const { mkdir } = await import('node:fs/promises');
|
|
458
|
+
const dirPath = join(dir, 'snap-target-dir');
|
|
459
|
+
await mkdir(dirPath);
|
|
460
|
+
|
|
461
|
+
const srv = await startContainerServer({
|
|
462
|
+
port: 0,
|
|
463
|
+
host: '127.0.0.1',
|
|
464
|
+
serverName: 'irc.finalfail',
|
|
465
|
+
networkName: 'FinalFailNet',
|
|
466
|
+
persistencePath: dirPath,
|
|
467
|
+
snapshotIntervalMs: 999_999,
|
|
468
|
+
});
|
|
469
|
+
const c = new TcpClient('127.0.0.1', srv.port);
|
|
470
|
+
await c.opened();
|
|
471
|
+
await c.send('NICK finalFail');
|
|
472
|
+
await c.send('USER finalFail 0 * :FinalFail');
|
|
473
|
+
await c.waitFor((l) => l.startsWith(':irc.finalfail 001 '), 2000);
|
|
474
|
+
// The server destroys live sockets on close; capture the close event first.
|
|
475
|
+
const closed = new Promise<void>((resolve) => c.socket.once('close', () => resolve()));
|
|
476
|
+
// close() must resolve (not reject) even though the final snapshot write
|
|
477
|
+
// throws — the error is logged and swallowed.
|
|
478
|
+
await expect(srv.close()).resolves.toBeUndefined();
|
|
479
|
+
await closed;
|
|
480
|
+
await rm(dir, { recursive: true, force: true });
|
|
481
|
+
});
|
|
376
482
|
});
|
|
377
483
|
|
|
378
484
|
describe('container-server — error paths', () => {
|
|
@@ -407,6 +513,95 @@ describe('container-server — error paths', () => {
|
|
|
407
513
|
await srv.close();
|
|
408
514
|
});
|
|
409
515
|
|
|
516
|
+
it('logs a non-Error rejection from the actor without crashing', async () => {
|
|
517
|
+
// logError's `err instanceof Error ? {...} : err` false branch is only
|
|
518
|
+
// reached when the caught value is NOT an Error instance.
|
|
519
|
+
const srv = await startContainerServer({
|
|
520
|
+
port: 0,
|
|
521
|
+
host: '127.0.0.1',
|
|
522
|
+
serverName: 'irc.nonerr',
|
|
523
|
+
networkName: 'NonErrNet',
|
|
524
|
+
});
|
|
525
|
+
const original = srv.runtime.send.bind(srv.runtime);
|
|
526
|
+
srv.runtime.send = async () => {
|
|
527
|
+
// eslint-disable-next-line no-throw-literal
|
|
528
|
+
throw { code: 'SYNTHETIC', detail: 'not an Error' };
|
|
529
|
+
};
|
|
530
|
+
const client = new TcpClient('127.0.0.1', srv.port);
|
|
531
|
+
await client.opened();
|
|
532
|
+
await client.send('NICK nonErrUser');
|
|
533
|
+
await client.send('USER nonErrUser 0 * :NonErr');
|
|
534
|
+
await client.send('PING :will-throw-non-error');
|
|
535
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
536
|
+
|
|
537
|
+
srv.runtime.send = original;
|
|
538
|
+
const next = new TcpClient('127.0.0.1', srv.port);
|
|
539
|
+
await next.opened();
|
|
540
|
+
await next.send('NICK recover2');
|
|
541
|
+
await next.send('USER recover2 0 * :Recover2');
|
|
542
|
+
await next.waitFor((l) => l.startsWith(':irc.nonerr 001 '), 3000);
|
|
543
|
+
await next.close();
|
|
544
|
+
await client.close();
|
|
545
|
+
await srv.close();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('disconnects a connection without an ERROR notice when no reason is supplied', async () => {
|
|
549
|
+
// Covers the disconnect callback's `reason === undefined` branch: no
|
|
550
|
+
// `ERROR :Closing link` line is sent, the transport is just closed.
|
|
551
|
+
const srv = await startContainerServer({
|
|
552
|
+
port: 0,
|
|
553
|
+
host: '127.0.0.1',
|
|
554
|
+
serverName: 'irc.noreason',
|
|
555
|
+
networkName: 'NoReasonNet',
|
|
556
|
+
});
|
|
557
|
+
const client = new TcpClient('127.0.0.1', srv.port);
|
|
558
|
+
await client.opened();
|
|
559
|
+
await client.send('NICK noReason');
|
|
560
|
+
await client.send('USER noReason 0 * :NoReason');
|
|
561
|
+
await client.waitFor((l) => l.startsWith(':irc.noreason 001 '), 2000);
|
|
562
|
+
|
|
563
|
+
const connId = await srv.runtime.lookupNick('noReason');
|
|
564
|
+
expect(connId).not.toBeNull();
|
|
565
|
+
const closed = new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
|
|
566
|
+
await srv.runtime.disconnect(connId as string);
|
|
567
|
+
await closed;
|
|
568
|
+
// The client must NOT have received an `ERROR :Closing link` line.
|
|
569
|
+
expect(client.received.some((l) => l.startsWith('ERROR :Closing link:'))).toBe(false);
|
|
570
|
+
await srv.close();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('skips writing when the server-side socket is no longer writable', async () => {
|
|
574
|
+
// Covers the sendText callback's `socket.writable` else branch: after the
|
|
575
|
+
// server-side socket is destroyed (writable=false) but before its 'close'
|
|
576
|
+
// handler removes the connection, a runtime send must skip the write.
|
|
577
|
+
const srv = await startContainerServer({
|
|
578
|
+
port: 0,
|
|
579
|
+
host: '127.0.0.1',
|
|
580
|
+
serverName: 'irc.unwritable',
|
|
581
|
+
networkName: 'UnwritableNet',
|
|
582
|
+
});
|
|
583
|
+
const client = new TcpClient('127.0.0.1', srv.port);
|
|
584
|
+
await client.opened();
|
|
585
|
+
await client.send('NICK unwritable');
|
|
586
|
+
await client.send('USER unwritable 0 * :Unwritable');
|
|
587
|
+
await client.waitFor((l) => l.startsWith(':irc.unwritable 001 '), 2000);
|
|
588
|
+
for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
|
|
589
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
590
|
+
}
|
|
591
|
+
const serverSideSocket = [...srv.testSockets][0];
|
|
592
|
+
if (serverSideSocket === undefined) throw new Error('no server-side socket');
|
|
593
|
+
const connId = (await srv.runtime.lookupNick('unwritable')) as string;
|
|
594
|
+
// Destroy the server-side socket: writable flips false synchronously,
|
|
595
|
+
// while the 'close' cleanup (which unregisters the connection) is queued.
|
|
596
|
+
serverSideSocket.destroy();
|
|
597
|
+
// The send resolves without writing because socket.writable is false.
|
|
598
|
+
await expect(
|
|
599
|
+
srv.runtime.send(connId, [{ text: ':irc.unwritable NOTICE unwritable :dropped' }]),
|
|
600
|
+
).resolves.toBeUndefined();
|
|
601
|
+
await srv.close();
|
|
602
|
+
await new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
|
|
603
|
+
});
|
|
604
|
+
|
|
410
605
|
it('logs and recovers from a per-socket error', async () => {
|
|
411
606
|
const srv = await startContainerServer({
|
|
412
607
|
port: 0,
|
|
@@ -463,3 +658,56 @@ describe('container-server — error paths', () => {
|
|
|
463
658
|
await rm(dir, { recursive: true, force: true });
|
|
464
659
|
});
|
|
465
660
|
});
|
|
661
|
+
|
|
662
|
+
describe('container-server — draft/multiline BATCH to a nick target', () => {
|
|
663
|
+
// Driving a nick-target multi-line batch exercises the `batchId` and
|
|
664
|
+
// `nonce` id-factory seams in DEFAULT_ID_FACTORY (container-server lines
|
|
665
|
+
// 96/98), which are only invoked by closeMultilineBatchToNick.
|
|
666
|
+
|
|
667
|
+
/** Negotiates `batch` + `draft/multiline`, then registers the nick. */
|
|
668
|
+
async function registerMultilineCapable(srv: ContainerServer, nick: string): Promise<TcpClient> {
|
|
669
|
+
const c = new TcpClient('127.0.0.1', srv.port);
|
|
670
|
+
await c.opened();
|
|
671
|
+
await c.send('CAP LS 302');
|
|
672
|
+
await c.waitFor((l) => l.startsWith(':irc.ml CAP * LS :'), 2000);
|
|
673
|
+
await c.send('CAP REQ :batch draft/multiline');
|
|
674
|
+
await c.waitFor((l) => l.includes('CAP') && l.includes('ACK'), 2000);
|
|
675
|
+
await c.send('CAP END');
|
|
676
|
+
await c.send(`NICK ${nick}`);
|
|
677
|
+
await c.send(`USER ${nick} 0 * :${nick}`);
|
|
678
|
+
await c.waitFor((l) => l.startsWith(':irc.ml 001 '), 2000);
|
|
679
|
+
return c;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
it('delivers a finalized nick-target batch and exercises batchId/nonce', async () => {
|
|
683
|
+
const srv = await startContainerServer({
|
|
684
|
+
port: 0,
|
|
685
|
+
host: '127.0.0.1',
|
|
686
|
+
serverName: 'irc.ml',
|
|
687
|
+
networkName: 'MLNet',
|
|
688
|
+
});
|
|
689
|
+
const alice = await registerMultilineCapable(srv, 'alice');
|
|
690
|
+
const bob = await registerMultilineCapable(srv, 'bob');
|
|
691
|
+
|
|
692
|
+
await alice.send('BATCH +m1 draft/multiline :bob');
|
|
693
|
+
await alice.send('PRIVMSG bob :line one');
|
|
694
|
+
await alice.send('PRIVMSG bob :line two');
|
|
695
|
+
await alice.send('BATCH -m1');
|
|
696
|
+
|
|
697
|
+
// Bob (also batch+draft/multiline capable) receives the replayed BATCH.
|
|
698
|
+
// The server allocates a fresh UUID batch id via the batchId() seam.
|
|
699
|
+
const open = await bob.waitFor(
|
|
700
|
+
(l) => /^BATCH \+[0-9a-f-]{36} draft\/multiline bob$/u.test(l),
|
|
701
|
+
3000,
|
|
702
|
+
);
|
|
703
|
+
const batchId = open?.match(/^BATCH \+([0-9a-f-]{36})/u)?.[1];
|
|
704
|
+
expect(batchId).toBeDefined();
|
|
705
|
+
await bob.waitFor((l) => l.includes(':alice!') && l.includes('PRIVMSG bob :line one'), 2000);
|
|
706
|
+
await bob.waitFor((l) => l.includes(':alice!') && l.includes('PRIVMSG bob :line two'), 2000);
|
|
707
|
+
await bob.waitFor((l) => l === `BATCH -${batchId}`, 2000);
|
|
708
|
+
|
|
709
|
+
await alice.close();
|
|
710
|
+
await bob.close();
|
|
711
|
+
await srv.close();
|
|
712
|
+
});
|
|
713
|
+
});
|
|
@@ -143,6 +143,15 @@ describe('persistence — saveSnapshot / loadSnapshot file I/O', () => {
|
|
|
143
143
|
expect(loaded).toBeNull();
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
it('re-throws non-ENOENT read errors (e.g. EISDIR when the path is a directory)', async () => {
|
|
147
|
+
// Reading a directory produces EISDIR, which is NOT ENOENT, so
|
|
148
|
+
// loadSnapshot must propagate it instead of treating it as first-boot.
|
|
149
|
+
const { mkdir } = await import('node:fs/promises');
|
|
150
|
+
const subDir = join(dir, 'not-a-file-dir');
|
|
151
|
+
await mkdir(subDir);
|
|
152
|
+
await expect(loadSnapshot(subDir)).rejects.toThrow();
|
|
153
|
+
});
|
|
154
|
+
|
|
146
155
|
it('overwrites a previous snapshot on re-save', async () => {
|
|
147
156
|
const path = join(dir, 'overwrite.json');
|
|
148
157
|
const rt1 = new InMemoryRuntime({ clock: { now: () => 0 } });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
4
4
|
import { type Socket, createConnection } from 'node:net';
|
|
@@ -10,9 +10,21 @@ import {
|
|
|
10
10
|
connect as tlsConnect,
|
|
11
11
|
createServer as tlsCreateServer,
|
|
12
12
|
} from 'node:tls';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
13
14
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
14
15
|
import { type ContainerServer, startContainerServer } from '../src/container-server.js';
|
|
15
16
|
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Maximum wall-clock budget for a single `openssl` invocation. RSA-2048
|
|
21
|
+
* keygen finishes in well under a second on any modern CPU; if it hasn't
|
|
22
|
+
* returned by this point the binary is wedged (or the runner is so starved
|
|
23
|
+
* that the TLS e2e would be flaky anyway), so we skip the suite instead of
|
|
24
|
+
* stalling the test process.
|
|
25
|
+
*/
|
|
26
|
+
const OPENSSL_TIMEOUT_MS = 15_000;
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* Generates a self-signed TLS certificate for the test proxy using the
|
|
18
30
|
* system `openssl` binary. This mirrors what Cloudflare Spectrum does in
|
|
@@ -26,7 +38,12 @@ async function generateSelfSignedCert(dir: string): Promise<{ key: string; cert:
|
|
|
26
38
|
const keyPath = join(dir, 'key.pem');
|
|
27
39
|
const certPath = join(dir, 'cert.pem');
|
|
28
40
|
try {
|
|
29
|
-
execFileSync
|
|
41
|
+
// Bound openssl: the previous `execFileSync` blocked the event loop,
|
|
42
|
+
// which defeated vitest's hook timer and let a stuck/missing binary
|
|
43
|
+
// hang the whole suite. The async call + `timeout` fails fast and
|
|
44
|
+
// yields control back to the runner; beforeAll swallows the throw and
|
|
45
|
+
// skips the suite (each test also guards on `cert === undefined`).
|
|
46
|
+
await execFileAsync(
|
|
30
47
|
'openssl',
|
|
31
48
|
[
|
|
32
49
|
'req',
|
|
@@ -43,10 +60,12 @@ async function generateSelfSignedCert(dir: string): Promise<{ key: string; cert:
|
|
|
43
60
|
'-subj',
|
|
44
61
|
'/CN=localhost',
|
|
45
62
|
],
|
|
46
|
-
{
|
|
63
|
+
{ timeout: OPENSSL_TIMEOUT_MS },
|
|
47
64
|
);
|
|
48
65
|
} catch {
|
|
49
|
-
throw new Error(
|
|
66
|
+
throw new Error(
|
|
67
|
+
`openssl unavailable or did not finish within ${OPENSSL_TIMEOUT_MS}ms — required to generate test TLS cert`,
|
|
68
|
+
);
|
|
50
69
|
}
|
|
51
70
|
const key = await readFile(keyPath, 'utf8');
|
|
52
71
|
const cert = await readFile(certPath, 'utf8');
|
|
@@ -205,7 +224,7 @@ describe('container-server — TLS e2e via in-process proxy (Spectrum analogue)'
|
|
|
205
224
|
originHost: '127.0.0.1',
|
|
206
225
|
originPort: origin.port,
|
|
207
226
|
});
|
|
208
|
-
});
|
|
227
|
+
}, 30_000);
|
|
209
228
|
|
|
210
229
|
afterAll(async () => {
|
|
211
230
|
if (proxy !== undefined) await proxy.close();
|
|
@@ -77,6 +77,7 @@ type ResolvedServerConfig = Omit<
|
|
|
77
77
|
| 'perIpConnectionRate'
|
|
78
78
|
| 'historyMaxPerChannel'
|
|
79
79
|
| 'saslAccounts'
|
|
80
|
+
| 'operCreds'
|
|
80
81
|
> &
|
|
81
82
|
Pick<
|
|
82
83
|
LocalServerConfig,
|
|
@@ -86,6 +87,7 @@ type ResolvedServerConfig = Omit<
|
|
|
86
87
|
| 'maxConnectionsPerUser'
|
|
87
88
|
| 'perIpConnectionRate'
|
|
88
89
|
| 'saslAccounts'
|
|
90
|
+
| 'operCreds'
|
|
89
91
|
>;
|
|
90
92
|
|
|
91
93
|
/**
|
|
@@ -107,6 +109,7 @@ function serverConfigFromResolved(cfg: ResolvedServerConfig): ServerConfig {
|
|
|
107
109
|
quitMessage: cfg.quitMessage,
|
|
108
110
|
...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
|
|
109
111
|
...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
|
|
112
|
+
...(cfg.operCreds !== undefined ? { operCreds: cfg.operCreds } : {}),
|
|
110
113
|
};
|
|
111
114
|
}
|
|
112
115
|
|
|
@@ -171,6 +174,14 @@ export interface LocalServerConfig {
|
|
|
171
174
|
* disabled (`ctx.accounts` undefined → `904 ERR_SASLFAIL`).
|
|
172
175
|
*/
|
|
173
176
|
saslAccounts?: Array<{ username: string; password: string }>;
|
|
177
|
+
/**
|
|
178
|
+
* IRC operator credentials consulted by the `OPER` reducer. When omitted
|
|
179
|
+
* (or empty) `OPER` receives `491 ERR_NOOPERHOST` and oper-only commands
|
|
180
|
+
* like `REHASH` are unreachable; supply a non-empty list to let a client
|
|
181
|
+
* oper up. Threaded straight through to the reducer-facing
|
|
182
|
+
* {@link ServerConfig} via the shared schema.
|
|
183
|
+
*/
|
|
184
|
+
readonly operCreds?: Array<{ user: string; password: string }>;
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
export interface StartServerOptions extends LocalServerConfig {
|
|
@@ -235,6 +246,7 @@ const DEFAULT_CONFIG: Omit<
|
|
|
235
246
|
| 'perIpConnectionRate'
|
|
236
247
|
| 'historyMaxPerChannel'
|
|
237
248
|
| 'saslAccounts'
|
|
249
|
+
| 'operCreds'
|
|
238
250
|
| 'serverName'
|
|
239
251
|
| 'networkName'
|
|
240
252
|
> = {
|
|
@@ -303,6 +315,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
303
315
|
...(opts.quitMessage !== undefined ? { quitMessage: opts.quitMessage } : {}),
|
|
304
316
|
...(opts.saslAccounts !== undefined ? { saslAccounts: opts.saslAccounts } : {}),
|
|
305
317
|
...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
|
|
318
|
+
...(opts.operCreds !== undefined ? { operCreds: opts.operCreds } : {}),
|
|
306
319
|
});
|
|
307
320
|
} catch (err) {
|
|
308
321
|
return Promise.reject(err);
|
|
@@ -332,6 +345,9 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
332
345
|
...(opts.saslAccounts !== undefined && opts.saslAccounts.length > 0
|
|
333
346
|
? { saslAccounts: opts.saslAccounts }
|
|
334
347
|
: {}),
|
|
348
|
+
...(opts.operCreds !== undefined && opts.operCreds.length > 0
|
|
349
|
+
? { operCreds: opts.operCreds }
|
|
350
|
+
: {}),
|
|
335
351
|
};
|
|
336
352
|
const hostname = opts.hostname ?? '127.0.0.1';
|
|
337
353
|
|
|
@@ -399,7 +415,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
399
415
|
// Admission gate: refuse the connection BEFORE we attach an actor / emit
|
|
400
416
|
// any IRC state. Keeps per-IP / per-user counters authoritative and
|
|
401
417
|
// avoids burning a nick or runtime slot on a denied client.
|
|
402
|
-
const ip = req.socket.remoteAddress
|
|
418
|
+
const ip = peerHost(req.socket.remoteAddress);
|
|
403
419
|
const decision = runtime.admitConnection(ip, undefined);
|
|
404
420
|
if (!decision.ok) {
|
|
405
421
|
ws.send(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
|
|
@@ -485,12 +501,15 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
485
501
|
const onWsListening = (): void => {
|
|
486
502
|
wss.removeListener('error', onWsError);
|
|
487
503
|
const addr = wss.address();
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
504
|
+
const wsPort = listenerPort(addr, opts.port);
|
|
505
|
+
|
|
506
|
+
// Capture the requested TCP port as a `const` so TS narrows it to
|
|
507
|
+
// `number` past the `undefined` early-return below (a property access
|
|
508
|
+
// like `opts.tcpPort` is not narrowed across the closure).
|
|
509
|
+
const requestedTcpPort = opts.tcpPort;
|
|
491
510
|
|
|
492
511
|
// If no TCP listener was requested, resolve immediately with just WS.
|
|
493
|
-
if (
|
|
512
|
+
if (requestedTcpPort === undefined) {
|
|
494
513
|
finishResolve({ wsPort, tcpPort: undefined, tcpServer: undefined });
|
|
495
514
|
return;
|
|
496
515
|
}
|
|
@@ -506,12 +525,13 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
506
525
|
|
|
507
526
|
// Admission gate — same policy as the WS path: refuse before the
|
|
508
527
|
// actor is attached so per-IP / per-user counters stay authoritative.
|
|
509
|
-
const ip = socket.remoteAddress
|
|
528
|
+
const ip = peerHost(socket.remoteAddress);
|
|
510
529
|
const decision = runtime.admitConnection(ip, undefined);
|
|
511
530
|
if (!decision.ok) {
|
|
512
|
-
|
|
513
|
-
socket
|
|
514
|
-
|
|
531
|
+
safeSocketWrite(
|
|
532
|
+
socket,
|
|
533
|
+
`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`,
|
|
534
|
+
);
|
|
515
535
|
socket.destroy();
|
|
516
536
|
tcpSockets.delete(socket);
|
|
517
537
|
return;
|
|
@@ -521,7 +541,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
521
541
|
|
|
522
542
|
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
|
|
523
543
|
sendText: (text) => {
|
|
524
|
-
|
|
544
|
+
safeSocketWrite(socket, text);
|
|
525
545
|
},
|
|
526
546
|
closeTransport: () => {
|
|
527
547
|
// `destroy()` over `end()` so a half-closed peer can't hold the
|
|
@@ -562,12 +582,10 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
562
582
|
});
|
|
563
583
|
tcpServer.once('listening', () => {
|
|
564
584
|
const tcpAddr = tcpServer.address();
|
|
565
|
-
|
|
566
|
-
const tcpPort =
|
|
567
|
-
typeof tcpAddr === 'object' && tcpAddr !== null ? tcpAddr.port : opts.tcpPort;
|
|
585
|
+
const tcpPort = listenerPort(tcpAddr, requestedTcpPort);
|
|
568
586
|
finishResolve({ wsPort, tcpPort, tcpServer, tcpConnections, tcpSockets });
|
|
569
587
|
});
|
|
570
|
-
tcpServer.listen(
|
|
588
|
+
tcpServer.listen(requestedTcpPort, hostname);
|
|
571
589
|
};
|
|
572
590
|
|
|
573
591
|
const finishResolve = (args: {
|
|
@@ -618,7 +636,12 @@ function attachConnection(
|
|
|
618
636
|
sendText: (text: string) => void;
|
|
619
637
|
closeTransport: () => void;
|
|
620
638
|
admissionRecordId?: string;
|
|
621
|
-
|
|
639
|
+
/**
|
|
640
|
+
* Transport-visible source host recorded on the connection state (cloak
|
|
641
|
+
* input / visible host). Both transports always supply the peer IP, so
|
|
642
|
+
* this is required rather than optional.
|
|
643
|
+
*/
|
|
644
|
+
sourceHost: string;
|
|
622
645
|
/**
|
|
623
646
|
* Negotiated IRCv3 WebSocket frame mode for this connection. Spec modes
|
|
624
647
|
* (`spec-text` / `spec-binary`) switch outbound delivery to one WS
|
|
@@ -639,9 +662,7 @@ function attachConnection(
|
|
|
639
662
|
const state = createConnection({ id, connectedSince: Date.now() });
|
|
640
663
|
// Record the transport-visible source host up front. Used as the cloak
|
|
641
664
|
// input when cloaking is enabled, and as the visible host otherwise.
|
|
642
|
-
|
|
643
|
-
state.host = transport.sourceHost;
|
|
644
|
-
}
|
|
665
|
+
state.host = transport.sourceHost;
|
|
645
666
|
|
|
646
667
|
// Per-connection logger: every record the actor emits
|
|
647
668
|
// carries this connection's id so the local-cli log stream can be
|
|
@@ -731,29 +752,71 @@ function closeEverything(args: {
|
|
|
731
752
|
// Tear down WS connections + server first.
|
|
732
753
|
const wsClosed = teardownWss(wss, wsConnections);
|
|
733
754
|
|
|
734
|
-
// Tear down TCP connections + server in parallel (if enabled).
|
|
755
|
+
// Tear down TCP connections + server in parallel (if enabled). Both are
|
|
756
|
+
// captured together (a TCP-disabled server has neither), so the guard is a
|
|
757
|
+
// single expression covering both the disabled and enabled cases.
|
|
735
758
|
const tcpClosed =
|
|
736
|
-
tcpServer === undefined
|
|
759
|
+
tcpServer === undefined || tcpConnections === undefined
|
|
737
760
|
? Promise.resolve()
|
|
738
761
|
: new Promise<void>((res) => {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
// but hasn't yet been removed from `tcpConnections`. The cleanup
|
|
744
|
-
// handler always removes on 'close'/'error', so this branch is a
|
|
745
|
-
// no-op in steady state.
|
|
746
|
-
// c8 ignore next 1
|
|
747
|
-
if (!socket.destroyed) socket.destroy();
|
|
748
|
-
}
|
|
749
|
-
tcpConnections.clear();
|
|
762
|
+
for (const socket of tcpConnections.keys()) {
|
|
763
|
+
// Idempotent: a socket already torn down by a reducer-driven
|
|
764
|
+
// disconnect racing the close sweep is skipped.
|
|
765
|
+
safeDestroy(socket);
|
|
750
766
|
}
|
|
767
|
+
tcpConnections.clear();
|
|
751
768
|
tcpServer.close(() => res());
|
|
752
769
|
});
|
|
753
770
|
|
|
754
771
|
return Promise.all([wsClosed, tcpClosed]).then(() => undefined);
|
|
755
772
|
}
|
|
756
773
|
|
|
774
|
+
/**
|
|
775
|
+
* Resolves the visible peer IP for a freshly-accepted socket, falling back to
|
|
776
|
+
* `'unknown'` when the transport exposes no remote address (e.g. a socket
|
|
777
|
+
* already torn down at upgrade time). Extracted as a pure helper so the
|
|
778
|
+
* fallback arm is unit-testable in isolation — the live WS/TCP paths always
|
|
779
|
+
* observe a concrete `remoteAddress`, so the `'unknown'` arm is unreachable
|
|
780
|
+
* through the integration suite.
|
|
781
|
+
*/
|
|
782
|
+
export function peerHost(remoteAddress: string | undefined): string {
|
|
783
|
+
return remoteAddress ?? 'unknown';
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Reads the bound port from a listener's `address()` result, falling back to
|
|
788
|
+
* the requested port for non-IP transports (named-pipe / unix-socket) which
|
|
789
|
+
* local-cli never binds but the `node:net` typing permits. Pure helper so the
|
|
790
|
+
* fallback arm is unit-testable; the live listeners always bind IP.
|
|
791
|
+
*/
|
|
792
|
+
export function listenerPort(addr: { port: number } | string | null, fallback: number): number {
|
|
793
|
+
return typeof addr === 'object' && addr !== null ? addr.port : fallback;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Writes `data` to `socket` only while it is still writable, silently
|
|
798
|
+
* skipping a destroyed / non-writable socket. Extracted so the non-writable
|
|
799
|
+
* arm is unit-testable with a fake — the live WS/TCP send paths always
|
|
800
|
+
* observe a writable transport, and the TCP admission-deny write races a
|
|
801
|
+
* socket that a peer RST may already have torn down.
|
|
802
|
+
*/
|
|
803
|
+
export function safeSocketWrite(
|
|
804
|
+
socket: { writable: boolean; write: (data: string) => void },
|
|
805
|
+
data: string,
|
|
806
|
+
): void {
|
|
807
|
+
if (socket.writable) socket.write(data);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Destroys `socket` unless it is already destroyed (idempotent teardown).
|
|
812
|
+
* Extracted so the already-destroyed arm is unit-testable with a fake — the
|
|
813
|
+
* race that produces an already-destroyed socket still present in the
|
|
814
|
+
* connection map is not deterministically reproducible in the suite.
|
|
815
|
+
*/
|
|
816
|
+
export function safeDestroy(socket: { destroyed: boolean; destroy(): void }): void {
|
|
817
|
+
if (!socket.destroyed) socket.destroy();
|
|
818
|
+
}
|
|
819
|
+
|
|
757
820
|
function logError(msg: string, err: unknown): void {
|
|
758
821
|
// Structured JSON per acceptance criteria.
|
|
759
822
|
const payload = { ts: new Date().toISOString(), level: 'error', msg, err: stringifyErr(err) };
|