serverless-ircd 0.7.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 +7 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +518 -29
- package/README.md +221 -161
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- 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/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +95 -31
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- 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 +237 -16
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +46 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +43 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- 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/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +578 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +20 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -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/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +130 -8
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +200 -0
- package/packages/irc-core/src/commands/chanserv.ts +1166 -0
- package/packages/irc-core/src/commands/hostserv.ts +522 -0
- package/packages/irc-core/src/commands/index.ts +13 -0
- package/packages/irc-core/src/commands/join.ts +171 -9
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +402 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +78 -12
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +60 -10
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +38 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
- package/packages/irc-core/tests/commands/join.test.ts +572 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +121 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +988 -15
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -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 +53 -2
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -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
|
> = {
|
|
@@ -302,6 +314,8 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
302
314
|
...(opts.topicLen !== undefined ? { topicLen: opts.topicLen } : {}),
|
|
303
315
|
...(opts.quitMessage !== undefined ? { quitMessage: opts.quitMessage } : {}),
|
|
304
316
|
...(opts.saslAccounts !== undefined ? { saslAccounts: opts.saslAccounts } : {}),
|
|
317
|
+
...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
|
|
318
|
+
...(opts.operCreds !== undefined ? { operCreds: opts.operCreds } : {}),
|
|
305
319
|
});
|
|
306
320
|
} catch (err) {
|
|
307
321
|
return Promise.reject(err);
|
|
@@ -331,6 +345,9 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
331
345
|
...(opts.saslAccounts !== undefined && opts.saslAccounts.length > 0
|
|
332
346
|
? { saslAccounts: opts.saslAccounts }
|
|
333
347
|
: {}),
|
|
348
|
+
...(opts.operCreds !== undefined && opts.operCreds.length > 0
|
|
349
|
+
? { operCreds: opts.operCreds }
|
|
350
|
+
: {}),
|
|
334
351
|
};
|
|
335
352
|
const hostname = opts.hostname ?? '127.0.0.1';
|
|
336
353
|
|
|
@@ -398,7 +415,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
398
415
|
// Admission gate: refuse the connection BEFORE we attach an actor / emit
|
|
399
416
|
// any IRC state. Keeps per-IP / per-user counters authoritative and
|
|
400
417
|
// avoids burning a nick or runtime slot on a denied client.
|
|
401
|
-
const ip = req.socket.remoteAddress
|
|
418
|
+
const ip = peerHost(req.socket.remoteAddress);
|
|
402
419
|
const decision = runtime.admitConnection(ip, undefined);
|
|
403
420
|
if (!decision.ok) {
|
|
404
421
|
ws.send(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
|
|
@@ -484,12 +501,15 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
484
501
|
const onWsListening = (): void => {
|
|
485
502
|
wss.removeListener('error', onWsError);
|
|
486
503
|
const addr = wss.address();
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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;
|
|
490
510
|
|
|
491
511
|
// If no TCP listener was requested, resolve immediately with just WS.
|
|
492
|
-
if (
|
|
512
|
+
if (requestedTcpPort === undefined) {
|
|
493
513
|
finishResolve({ wsPort, tcpPort: undefined, tcpServer: undefined });
|
|
494
514
|
return;
|
|
495
515
|
}
|
|
@@ -505,12 +525,13 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
505
525
|
|
|
506
526
|
// Admission gate — same policy as the WS path: refuse before the
|
|
507
527
|
// actor is attached so per-IP / per-user counters stay authoritative.
|
|
508
|
-
const ip = socket.remoteAddress
|
|
528
|
+
const ip = peerHost(socket.remoteAddress);
|
|
509
529
|
const decision = runtime.admitConnection(ip, undefined);
|
|
510
530
|
if (!decision.ok) {
|
|
511
|
-
|
|
512
|
-
socket
|
|
513
|
-
|
|
531
|
+
safeSocketWrite(
|
|
532
|
+
socket,
|
|
533
|
+
`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`,
|
|
534
|
+
);
|
|
514
535
|
socket.destroy();
|
|
515
536
|
tcpSockets.delete(socket);
|
|
516
537
|
return;
|
|
@@ -520,7 +541,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
520
541
|
|
|
521
542
|
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
|
|
522
543
|
sendText: (text) => {
|
|
523
|
-
|
|
544
|
+
safeSocketWrite(socket, text);
|
|
524
545
|
},
|
|
525
546
|
closeTransport: () => {
|
|
526
547
|
// `destroy()` over `end()` so a half-closed peer can't hold the
|
|
@@ -561,12 +582,10 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
561
582
|
});
|
|
562
583
|
tcpServer.once('listening', () => {
|
|
563
584
|
const tcpAddr = tcpServer.address();
|
|
564
|
-
|
|
565
|
-
const tcpPort =
|
|
566
|
-
typeof tcpAddr === 'object' && tcpAddr !== null ? tcpAddr.port : opts.tcpPort;
|
|
585
|
+
const tcpPort = listenerPort(tcpAddr, requestedTcpPort);
|
|
567
586
|
finishResolve({ wsPort, tcpPort, tcpServer, tcpConnections, tcpSockets });
|
|
568
587
|
});
|
|
569
|
-
tcpServer.listen(
|
|
588
|
+
tcpServer.listen(requestedTcpPort, hostname);
|
|
570
589
|
};
|
|
571
590
|
|
|
572
591
|
const finishResolve = (args: {
|
|
@@ -617,7 +636,12 @@ function attachConnection(
|
|
|
617
636
|
sendText: (text: string) => void;
|
|
618
637
|
closeTransport: () => void;
|
|
619
638
|
admissionRecordId?: string;
|
|
620
|
-
|
|
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;
|
|
621
645
|
/**
|
|
622
646
|
* Negotiated IRCv3 WebSocket frame mode for this connection. Spec modes
|
|
623
647
|
* (`spec-text` / `spec-binary`) switch outbound delivery to one WS
|
|
@@ -638,9 +662,7 @@ function attachConnection(
|
|
|
638
662
|
const state = createConnection({ id, connectedSince: Date.now() });
|
|
639
663
|
// Record the transport-visible source host up front. Used as the cloak
|
|
640
664
|
// input when cloaking is enabled, and as the visible host otherwise.
|
|
641
|
-
|
|
642
|
-
state.host = transport.sourceHost;
|
|
643
|
-
}
|
|
665
|
+
state.host = transport.sourceHost;
|
|
644
666
|
|
|
645
667
|
// Per-connection logger: every record the actor emits
|
|
646
668
|
// carries this connection's id so the local-cli log stream can be
|
|
@@ -730,29 +752,71 @@ function closeEverything(args: {
|
|
|
730
752
|
// Tear down WS connections + server first.
|
|
731
753
|
const wsClosed = teardownWss(wss, wsConnections);
|
|
732
754
|
|
|
733
|
-
// 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.
|
|
734
758
|
const tcpClosed =
|
|
735
|
-
tcpServer === undefined
|
|
759
|
+
tcpServer === undefined || tcpConnections === undefined
|
|
736
760
|
? Promise.resolve()
|
|
737
761
|
: new Promise<void>((res) => {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
// but hasn't yet been removed from `tcpConnections`. The cleanup
|
|
743
|
-
// handler always removes on 'close'/'error', so this branch is a
|
|
744
|
-
// no-op in steady state.
|
|
745
|
-
// c8 ignore next 1
|
|
746
|
-
if (!socket.destroyed) socket.destroy();
|
|
747
|
-
}
|
|
748
|
-
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);
|
|
749
766
|
}
|
|
767
|
+
tcpConnections.clear();
|
|
750
768
|
tcpServer.close(() => res());
|
|
751
769
|
});
|
|
752
770
|
|
|
753
771
|
return Promise.all([wsClosed, tcpClosed]).then(() => undefined);
|
|
754
772
|
}
|
|
755
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
|
+
|
|
756
820
|
function logError(msg: string, err: unknown): void {
|
|
757
821
|
// Structured JSON per acceptance criteria.
|
|
758
822
|
const payload = { ts: new Date().toISOString(), level: 'error', msg, err: stringifyErr(err) };
|
|
@@ -55,6 +55,20 @@ describe('loadServerConfigFromCliArgs — minimal input', () => {
|
|
|
55
55
|
expect(cfg.maxClients).toBe(50);
|
|
56
56
|
expect(cfg.operCreds).toEqual([{ user: 'op', password: 'p' }]);
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
it('threads serverPassword through to the parsed config', () => {
|
|
60
|
+
const cfg = loadServerConfigFromCliArgs({
|
|
61
|
+
serverName: 's',
|
|
62
|
+
networkName: 'n',
|
|
63
|
+
serverPassword: 'hunter2',
|
|
64
|
+
});
|
|
65
|
+
expect(cfg.serverPassword).toBe('hunter2');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('leaves serverPassword undefined when not supplied', () => {
|
|
69
|
+
const cfg = loadServerConfigFromCliArgs({ serverName: 's', networkName: 'n' });
|
|
70
|
+
expect(cfg.serverPassword).toBeUndefined();
|
|
71
|
+
});
|
|
58
72
|
});
|
|
59
73
|
|
|
60
74
|
describe('loadServerConfigFromCliArgs — failure modes', () => {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage for `startLocalServer`'s config-resolution branches:
|
|
3
|
+
* - the conditional spreads that pass optional CLI knobs through to the
|
|
4
|
+
* shared schema (each knob's "defined" arm — exercised only when the
|
|
5
|
+
* caller explicitly supplies the value);
|
|
6
|
+
* - the admission-config `?? DEFAULT_*` fallbacks (exercised only when
|
|
7
|
+
* admission is enabled without the corresponding cap/rate).
|
|
8
|
+
*
|
|
9
|
+
* Starting a server (and closing it) is enough to drive the resolution — no
|
|
10
|
+
* client connection is required.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import { startLocalServer } from '../src/server';
|
|
15
|
+
|
|
16
|
+
describe('startLocalServer — threads explicit config knobs to the schema', () => {
|
|
17
|
+
it('accepts every optional limit/knob override and starts cleanly', async () => {
|
|
18
|
+
const srv = await startLocalServer({
|
|
19
|
+
port: 0,
|
|
20
|
+
hostname: '127.0.0.1',
|
|
21
|
+
serverName: 'cfg.example.com',
|
|
22
|
+
networkName: 'CfgNet',
|
|
23
|
+
motdLines: ['explicit motd line'],
|
|
24
|
+
maxChannelsPerUser: 7,
|
|
25
|
+
maxTargetsPerCommand: 3,
|
|
26
|
+
maxListEntries: 11,
|
|
27
|
+
nickLen: 20,
|
|
28
|
+
channelLen: 40,
|
|
29
|
+
topicLen: 200,
|
|
30
|
+
quitMessage: 'explicit quit',
|
|
31
|
+
});
|
|
32
|
+
expect(srv.port).toBeGreaterThan(0);
|
|
33
|
+
await srv.close();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('startLocalServer — admission defaults', () => {
|
|
38
|
+
it('falls back to the per-IP / per-user defaults when only the rate limit is set', async () => {
|
|
39
|
+
// Admission enabled, but maxConnectionsPerIp / maxConnectionsPerUser are
|
|
40
|
+
// left undefined → the `?? DEFAULT_*` arms apply.
|
|
41
|
+
const srv = await startLocalServer({
|
|
42
|
+
port: 0,
|
|
43
|
+
hostname: '127.0.0.1',
|
|
44
|
+
serverName: 'rate.example.com',
|
|
45
|
+
networkName: 'RateNet',
|
|
46
|
+
perIpConnectionRate: { max: 5, windowMs: 60_000 },
|
|
47
|
+
});
|
|
48
|
+
expect(srv.port).toBeGreaterThan(0);
|
|
49
|
+
await srv.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('falls back to the per-IP cap and rate defaults when only per-user cap is set', async () => {
|
|
53
|
+
// Admission enabled, but maxConnectionsPerIp / perIpConnectionRate are
|
|
54
|
+
// left undefined → both `?? DEFAULT_*` arms apply.
|
|
55
|
+
const srv = await startLocalServer({
|
|
56
|
+
port: 0,
|
|
57
|
+
hostname: '127.0.0.1',
|
|
58
|
+
serverName: 'user.example.com',
|
|
59
|
+
networkName: 'UserNet',
|
|
60
|
+
maxConnectionsPerUser: 3,
|
|
61
|
+
});
|
|
62
|
+
expect(srv.port).toBeGreaterThan(0);
|
|
63
|
+
await srv.close();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated coverage for {@link loadMotdFile}'s non-Error throw wrap
|
|
3
|
+
* (motd-file.ts:41 — the `String(err)` branch). The rest of the motd-file
|
|
4
|
+
* suite exercises real `node:fs` I/O against a temp dir; this file
|
|
5
|
+
* `vi.mock`s `node:fs` so `readFileSync` rejects with a value that is NOT
|
|
6
|
+
* an `Error`, driving the `err instanceof Error ? ... : String(err)` ternary
|
|
7
|
+
* down its non-Error path. Lives in its own file so the mock never leaks
|
|
8
|
+
* into the real-I/O tests.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
|
|
13
|
+
vi.mock('node:fs', () => ({
|
|
14
|
+
// Throw a non-Error value so the catch wraps it via `String(err)`.
|
|
15
|
+
readFileSync: () => {
|
|
16
|
+
throw 'boom-non-error';
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Imported AFTER `vi.mock` is established (vitest hoists the mock).
|
|
21
|
+
import { loadMotdFile } from '../src/motd-file';
|
|
22
|
+
|
|
23
|
+
describe('loadMotdFile — wraps a non-Error throw via String()', () => {
|
|
24
|
+
it('rethrows a descriptive Error whose detail is String(<thrown value>)', () => {
|
|
25
|
+
expect(() => loadMotdFile('/irrelevant/motd.txt')).toThrowError(
|
|
26
|
+
/motd: cannot read --motd-file at .*: boom-non-error/u,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oper `REHASH` end-to-end through the local CLI.
|
|
3
|
+
*
|
|
4
|
+
* Drives the `configLoader` closure bound at server.ts:358 — the REHASH
|
|
5
|
+
* reload source. An oper's `REHASH` re-invokes that closure (via the runtime's
|
|
6
|
+
* `reloadConfig`) and swaps the live ServerConfig; the actor emits
|
|
7
|
+
* `382 RPL_REHASHING` on success. A non-oper receives `481 ERR_NOPRIVILEGES`
|
|
8
|
+
* and no reload is attempted.
|
|
9
|
+
*
|
|
10
|
+
* Oper credentials are threaded through the local CLI's `operCreds` option
|
|
11
|
+
* (the shared schema already accepted them; `startLocalServer` now wires them
|
|
12
|
+
* to the reducer-facing ServerConfig).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
16
|
+
import { WebSocket } from 'ws';
|
|
17
|
+
import { type LocalServer, startLocalServer } from '../src/server';
|
|
18
|
+
|
|
19
|
+
class IrcClient {
|
|
20
|
+
readonly ws: WebSocket;
|
|
21
|
+
readonly received: string[] = [];
|
|
22
|
+
|
|
23
|
+
constructor(url: string) {
|
|
24
|
+
this.ws = new WebSocket(url);
|
|
25
|
+
this.ws.on('message', (data) => {
|
|
26
|
+
const text = data.toString('utf8');
|
|
27
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
28
|
+
if (line.length > 0) this.received.push(line);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
opened(): Promise<void> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
if (this.ws.readyState === WebSocket.OPEN) resolve();
|
|
36
|
+
else {
|
|
37
|
+
this.ws.once('open', () => resolve());
|
|
38
|
+
this.ws.once('error', reject);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
send(line: string): Promise<void> {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
this.ws.send(`${line}\r\n`, (err) => (err ? reject(err) : resolve()));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
waitFor(predicate: (line: string) => boolean, timeoutMs = 1_000): Promise<string> {
|
|
50
|
+
const check = (line: string): boolean => predicate(line);
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const timeout = setTimeout(
|
|
53
|
+
() => reject(new Error(`timeout; received: ${JSON.stringify(this.received)}`)),
|
|
54
|
+
timeoutMs,
|
|
55
|
+
);
|
|
56
|
+
const onLine = (line: string): void => {
|
|
57
|
+
if (check(line)) {
|
|
58
|
+
clearTimeout(timeout);
|
|
59
|
+
this.ws.off('message', onMessage);
|
|
60
|
+
resolve(line);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const onMessage = (data: { toString: (enc: string) => string }): void => {
|
|
64
|
+
for (const line of data.toString('utf8').split(/\r?\n/u)) {
|
|
65
|
+
if (line.length > 0) onLine(line);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
for (const line of this.received) {
|
|
69
|
+
if (check(line)) {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
resolve(line);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
this.ws.on('message', onMessage);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
close(): Promise<void> {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
this.ws.once('close', () => resolve());
|
|
82
|
+
this.ws.close();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('local-cli e2e — oper REHASH re-invokes the bound configLoader', () => {
|
|
88
|
+
let server: LocalServer;
|
|
89
|
+
|
|
90
|
+
beforeAll(async () => {
|
|
91
|
+
server = await startLocalServer({
|
|
92
|
+
port: 0,
|
|
93
|
+
hostname: '127.0.0.1',
|
|
94
|
+
serverName: 'rehash.example.com',
|
|
95
|
+
networkName: 'RehashNet',
|
|
96
|
+
operCreds: [{ user: 'admin', password: 's3cret' }],
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterAll(async () => {
|
|
101
|
+
await server.close();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('grants oper then reloads config on REHASH (382 RPL_REHASHING, no error)', async () => {
|
|
105
|
+
const client = new IrcClient(`ws://127.0.0.1:${server.port}/`);
|
|
106
|
+
await client.opened();
|
|
107
|
+
await client.send('NICK oper1');
|
|
108
|
+
await client.send('USER oper1 0 * :Oper');
|
|
109
|
+
await client.waitFor((l) => l.startsWith(':rehash.example.com 001 '));
|
|
110
|
+
|
|
111
|
+
// Oper up so the REHASH oper gate passes.
|
|
112
|
+
await client.send('OPER admin s3cret');
|
|
113
|
+
const operUp = await client.waitFor((l) => l.includes(' 381 '));
|
|
114
|
+
expect(operUp).toContain('You are now an IRC operator');
|
|
115
|
+
|
|
116
|
+
// REHASH — the actor re-invokes the runtime's reloadConfig, which calls
|
|
117
|
+
// the configLoader closure (server.ts:358). A clean `382` (no `failed`)
|
|
118
|
+
// proves the reload succeeded.
|
|
119
|
+
await client.send('REHASH');
|
|
120
|
+
const rehashLine = await client.waitFor((l) => l.includes(' 382 '), 2_000);
|
|
121
|
+
expect(rehashLine).toContain('Rehashing from');
|
|
122
|
+
expect(rehashLine).not.toContain('failed');
|
|
123
|
+
|
|
124
|
+
await client.close();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('rejects REHASH from a non-oper with 481 ERR_NOPRIVILEGES (no reload)', async () => {
|
|
128
|
+
const client = new IrcClient(`ws://127.0.0.1:${server.port}/`);
|
|
129
|
+
await client.opened();
|
|
130
|
+
await client.send('NICK peon');
|
|
131
|
+
await client.send('USER peon 0 * :Peon');
|
|
132
|
+
await client.waitFor((l) => l.startsWith(':rehash.example.com 001 '));
|
|
133
|
+
|
|
134
|
+
await client.send('REHASH');
|
|
135
|
+
const denied = await client.waitFor((l) => l.includes(' 481 '));
|
|
136
|
+
expect(denied).toContain('Permission Denied');
|
|
137
|
+
await client.close();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('directly exercises the configLoader closure via runtime.reloadConfig()', async () => {
|
|
141
|
+
// Independent of the actor path: the closure bound at server.ts:358 must
|
|
142
|
+
// return the live ServerConfig the actor was bootstrapped with.
|
|
143
|
+
const reloaded = await server.runtime.reloadConfig();
|
|
144
|
+
expect(reloaded.serverName).toBe('rehash.example.com');
|
|
145
|
+
expect(reloaded.networkName).toBe('RehashNet');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the small transport/adapter helpers extracted from
|
|
3
|
+
* `server.ts` as test seams. These wrap the genuinely-unreachable defensive
|
|
4
|
+
* branches (peer-IP fallback, listener-port fallback, write-to-unwritable,
|
|
5
|
+
* destroy-already-destroyed) so each branch is deterministically exercisable
|
|
6
|
+
* with a fake — the live WS/TCP integration paths only ever observe the
|
|
7
|
+
* non-defensive arm.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { listenerPort, peerHost, safeDestroy, safeSocketWrite } from '../src/server';
|
|
12
|
+
|
|
13
|
+
describe('peerHost', () => {
|
|
14
|
+
it('returns the remote address when present', () => {
|
|
15
|
+
expect(peerHost('203.0.113.7')).toBe('203.0.113.7');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("falls back to 'unknown' when no remote address is exposed", () => {
|
|
19
|
+
expect(peerHost(undefined)).toBe('unknown');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('listenerPort', () => {
|
|
24
|
+
it('reads the port from an AddressInfo-shaped object', () => {
|
|
25
|
+
expect(listenerPort({ port: 6667 }, 0)).toBe(6667);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('falls back when the address is a non-IP transport (string)', () => {
|
|
29
|
+
expect(listenerPort('/run/irc.sock', 7000)).toBe(7000);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('falls back when the address is null', () => {
|
|
33
|
+
expect(listenerPort(null, 7000)).toBe(7000);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('safeSocketWrite', () => {
|
|
38
|
+
it('writes when the socket is writable', () => {
|
|
39
|
+
const write = vi.fn();
|
|
40
|
+
safeSocketWrite({ writable: true, write }, 'PONG :tok');
|
|
41
|
+
expect(write).toHaveBeenCalledWith('PONG :tok');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('is a silent no-op when the socket is no longer writable', () => {
|
|
45
|
+
const write = vi.fn();
|
|
46
|
+
safeSocketWrite({ writable: false, write }, 'PONG :tok');
|
|
47
|
+
expect(write).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('safeDestroy', () => {
|
|
52
|
+
it('destroys a live socket', () => {
|
|
53
|
+
const destroy = vi.fn();
|
|
54
|
+
safeDestroy({ destroyed: false, destroy });
|
|
55
|
+
expect(destroy).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('is idempotent — skips a socket that is already destroyed', () => {
|
|
59
|
+
const destroy = vi.fn();
|
|
60
|
+
safeDestroy({ destroyed: true, destroy });
|
|
61
|
+
expect(destroy).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -215,6 +215,24 @@ describe('local-cli TCP transport — IRC flows', () => {
|
|
|
215
215
|
await client.close();
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
+
it('skips an empty line delivered over TCP without dispatching it', async () => {
|
|
219
|
+
// The LineScanner yields an empty string for a bare CRLF; the data
|
|
220
|
+
// handler's `if (line.length === 0) continue` must skip it without
|
|
221
|
+
// feeding an empty IRC line to the actor (server.ts:539).
|
|
222
|
+
const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
|
|
223
|
+
await client.opened();
|
|
224
|
+
await client.send('NICK blanker');
|
|
225
|
+
await client.send('USER blanker 0 * :Blanker');
|
|
226
|
+
await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
227
|
+
|
|
228
|
+
// A bare CRLF (blank line) followed by a valid PING: the blank line is
|
|
229
|
+
// ignored and the PING still resolves to a PONG.
|
|
230
|
+
await client.writeRaw('\r\n');
|
|
231
|
+
await client.send('PING :after-blank');
|
|
232
|
+
await client.waitFor((l) => l === 'PONG :after-blank');
|
|
233
|
+
await client.close();
|
|
234
|
+
});
|
|
235
|
+
|
|
218
236
|
it('processes multiple IRC lines delivered in a single TCP write', async () => {
|
|
219
237
|
const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
|
|
220
238
|
await client.opened();
|
|
@@ -367,6 +385,31 @@ describe('local-cli TCP transport — shutdown', () => {
|
|
|
367
385
|
expect(client.socket.destroyed).toBe(true);
|
|
368
386
|
});
|
|
369
387
|
|
|
388
|
+
it('close() destroys a still-live registered TCP connection via the close sweep', async () => {
|
|
389
|
+
// Registering the client (awaiting 001) guarantees the server-side
|
|
390
|
+
// connection handler has run and the socket is in `tcpConnections`, so
|
|
391
|
+
// `close()` deterministically enters the close sweep (server.ts:765) and
|
|
392
|
+
// destroys the live socket. (Connecting without registering races the
|
|
393
|
+
// handler under coverage instrumentation.)
|
|
394
|
+
const srv = await startLocalServer({
|
|
395
|
+
port: 0,
|
|
396
|
+
tcpPort: 0,
|
|
397
|
+
hostname: '127.0.0.1',
|
|
398
|
+
serverName: 'irc.local.example.com',
|
|
399
|
+
networkName: 'LocalNet',
|
|
400
|
+
});
|
|
401
|
+
const client = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
|
|
402
|
+
await client.opened();
|
|
403
|
+
await client.send('NICK sweepUser');
|
|
404
|
+
await client.send('USER sweepUser 0 * :Sweep');
|
|
405
|
+
await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
|
|
406
|
+
|
|
407
|
+
const closed = new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
|
|
408
|
+
await srv.close();
|
|
409
|
+
await closed;
|
|
410
|
+
expect(client.socket.destroyed).toBe(true);
|
|
411
|
+
});
|
|
412
|
+
|
|
370
413
|
it('close() is a no-op for TCP when tcpPort was never enabled', async () => {
|
|
371
414
|
const srv = await startLocalServer({
|
|
372
415
|
port: 0,
|
|
@@ -408,3 +451,49 @@ describe('local-cli TCP transport — shutdown', () => {
|
|
|
408
451
|
await srv.close();
|
|
409
452
|
});
|
|
410
453
|
});
|
|
454
|
+
|
|
455
|
+
describe('local-cli TCP transport — admission gate', () => {
|
|
456
|
+
it('emits ERROR :Closing link and destroys the socket when admitConnection denies', async () => {
|
|
457
|
+
// Tight per-IP cap with a permissive rate limit so only the per-IP
|
|
458
|
+
// simultaneous-connection gate refuses the second connection.
|
|
459
|
+
const srv = await startLocalServer({
|
|
460
|
+
port: 0,
|
|
461
|
+
tcpPort: 0,
|
|
462
|
+
hostname: '127.0.0.1',
|
|
463
|
+
serverName: 'admit.example.com',
|
|
464
|
+
networkName: 'AdmitNet',
|
|
465
|
+
maxConnectionsPerIp: 1,
|
|
466
|
+
maxConnectionsPerUser: 1_000,
|
|
467
|
+
perIpConnectionRate: { max: 1_000, windowMs: 60_000 },
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
// First connection fills the per-IP cap (maxConnectionsPerIp: 1).
|
|
472
|
+
const first = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
|
|
473
|
+
await first.opened();
|
|
474
|
+
|
|
475
|
+
// Second connection from the same IP is denied at the TCP admission
|
|
476
|
+
// gate BEFORE an actor is attached. The server writes the RFC-style
|
|
477
|
+
// ERROR notice then destroys the socket (server.ts 511-517).
|
|
478
|
+
const rejected = new TcpTestClient('127.0.0.1', srv.tcpPort as number);
|
|
479
|
+
const errorLine = await rejected.waitFor((l) => l.startsWith('ERROR :Closing link'), 2_000);
|
|
480
|
+
expect(errorLine).toContain('connection limit reached');
|
|
481
|
+
expect(errorLine).toContain('max_connections_per_ip');
|
|
482
|
+
|
|
483
|
+
// The server destroys the socket after the ERROR notice.
|
|
484
|
+
await new Promise<void>((resolve) => {
|
|
485
|
+
if (rejected.socket.destroyed) resolve();
|
|
486
|
+
else rejected.socket.once('close', () => resolve());
|
|
487
|
+
});
|
|
488
|
+
expect(rejected.socket.destroyed).toBe(true);
|
|
489
|
+
|
|
490
|
+
// The denied socket is removed from the server's tracked set so the
|
|
491
|
+
// runtime admission counters don't leak it.
|
|
492
|
+
expect(srv.testTcpSockets?.has(rejected.socket)).toBe(false);
|
|
493
|
+
|
|
494
|
+
await first.close();
|
|
495
|
+
} finally {
|
|
496
|
+
await srv.close();
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
});
|