serverless-ircd 0.9.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.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -9,12 +9,18 @@ import { type ContainerServer, startContainerServer } from '../src/container-ser
|
|
|
9
9
|
/**
|
|
10
10
|
* Minimal IRC-over-TCP test client. Writes lines with CRLF, parses inbound
|
|
11
11
|
* bytes into IRC lines (tolerant of CRLF and bare LF).
|
|
12
|
+
*
|
|
13
|
+
* By default it emulates the Cloudflare Spectrum edge: the connection
|
|
14
|
+
* opens with a PROXY protocol v1 header carrying `proxySourceIp` (the
|
|
15
|
+
* "real client IP" as the edge would report it). Pass
|
|
16
|
+
* `{ proxySourceIp: null }` for direct-connection scenarios where the
|
|
17
|
+
* client speaks raw IRC with no header.
|
|
12
18
|
*/
|
|
13
19
|
class TcpClient extends EventEmitter {
|
|
14
20
|
readonly socket: Socket;
|
|
15
21
|
readonly received: string[] = [];
|
|
16
22
|
|
|
17
|
-
constructor(host: string, port: number) {
|
|
23
|
+
constructor(host: string, port: number, opts: { proxySourceIp?: string | null } = {}) {
|
|
18
24
|
super();
|
|
19
25
|
this.socket = createConnection({ host, port });
|
|
20
26
|
this.socket.setEncoding('utf8');
|
|
@@ -26,6 +32,15 @@ class TcpClient extends EventEmitter {
|
|
|
26
32
|
}
|
|
27
33
|
}
|
|
28
34
|
});
|
|
35
|
+
const src = opts.proxySourceIp === undefined ? '127.0.0.1' : opts.proxySourceIp;
|
|
36
|
+
if (src !== null) {
|
|
37
|
+
const family = src.includes(':') ? 'TCP6' : 'TCP4';
|
|
38
|
+
const dst = family === 'TCP6' ? '2001:db8::2' : '192.0.2.1';
|
|
39
|
+
// Writing on a connecting socket is safe: Node queues the bytes
|
|
40
|
+
// until the TCP connection is established, so the header always
|
|
41
|
+
// lands before any subsequent send().
|
|
42
|
+
this.socket.write(`PROXY ${family} ${src} ${dst} 40000 6697\r\n`);
|
|
43
|
+
}
|
|
29
44
|
}
|
|
30
45
|
|
|
31
46
|
opened(): Promise<void> {
|
|
@@ -93,7 +108,8 @@ describe('container-server — lifecycle', () => {
|
|
|
93
108
|
serverName: 'irc.test',
|
|
94
109
|
networkName: 'TestNet',
|
|
95
110
|
});
|
|
96
|
-
expect(srv.port).
|
|
111
|
+
expect(srv.port).toBeDefined();
|
|
112
|
+
expect(srv.port as number).toBeGreaterThan(0);
|
|
97
113
|
expect(srv.host).toBe('127.0.0.1');
|
|
98
114
|
await srv.close();
|
|
99
115
|
});
|
|
@@ -141,7 +157,7 @@ describe('container-server — option wiring (coverage matrix)', () => {
|
|
|
141
157
|
});
|
|
142
158
|
expect(srv.host).toBe('0.0.0.0');
|
|
143
159
|
// 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);
|
|
160
|
+
const c = new TcpClient('127.0.0.1', srv.port as number);
|
|
145
161
|
await c.opened();
|
|
146
162
|
await c.close();
|
|
147
163
|
await srv.close();
|
|
@@ -157,7 +173,7 @@ describe('container-server — option wiring (coverage matrix)', () => {
|
|
|
157
173
|
networkName: 'AdmissionNet',
|
|
158
174
|
maxConnectionsPerUser: 5,
|
|
159
175
|
});
|
|
160
|
-
const c = new TcpClient('127.0.0.1', srv.port);
|
|
176
|
+
const c = new TcpClient('127.0.0.1', srv.port as number);
|
|
161
177
|
await c.opened();
|
|
162
178
|
await c.close();
|
|
163
179
|
await srv.close();
|
|
@@ -173,7 +189,7 @@ describe('container-server — option wiring (coverage matrix)', () => {
|
|
|
173
189
|
cloaking: { enabled: true, secret: 'cloak-secret', cloakedSuffix: '.hidden' },
|
|
174
190
|
saslAccounts: [{ username: 'alice', password: 'pw' }],
|
|
175
191
|
});
|
|
176
|
-
const c = new TcpClient('127.0.0.1', srv.port);
|
|
192
|
+
const c = new TcpClient('127.0.0.1', srv.port as number);
|
|
177
193
|
await c.opened();
|
|
178
194
|
await c.close();
|
|
179
195
|
await srv.close();
|
|
@@ -197,8 +213,8 @@ describe('container-server — IRC flows over TCP', () => {
|
|
|
197
213
|
});
|
|
198
214
|
|
|
199
215
|
it('completes register → join → privmsg → quit', async () => {
|
|
200
|
-
const alice = new TcpClient('127.0.0.1', server.port);
|
|
201
|
-
const bob = new TcpClient('127.0.0.1', server.port);
|
|
216
|
+
const alice = new TcpClient('127.0.0.1', server.port as number);
|
|
217
|
+
const bob = new TcpClient('127.0.0.1', server.port as number);
|
|
202
218
|
await alice.opened();
|
|
203
219
|
await bob.opened();
|
|
204
220
|
|
|
@@ -223,7 +239,7 @@ describe('container-server — IRC flows over TCP', () => {
|
|
|
223
239
|
});
|
|
224
240
|
|
|
225
241
|
it('routes PING → PONG', async () => {
|
|
226
|
-
const client = new TcpClient('127.0.0.1', server.port);
|
|
242
|
+
const client = new TcpClient('127.0.0.1', server.port as number);
|
|
227
243
|
await client.opened();
|
|
228
244
|
await client.send('NICK pinger');
|
|
229
245
|
await client.send('USER pinger 0 * :Pinger');
|
|
@@ -235,7 +251,7 @@ describe('container-server — IRC flows over TCP', () => {
|
|
|
235
251
|
});
|
|
236
252
|
|
|
237
253
|
it('reassembles a single IRC message split across two TCP chunks', async () => {
|
|
238
|
-
const client = new TcpClient('127.0.0.1', server.port);
|
|
254
|
+
const client = new TcpClient('127.0.0.1', server.port as number);
|
|
239
255
|
await client.opened();
|
|
240
256
|
await client.send('NICK splitter');
|
|
241
257
|
await client.send('USER splitter 0 * :Splitter');
|
|
@@ -248,7 +264,7 @@ describe('container-server — IRC flows over TCP', () => {
|
|
|
248
264
|
});
|
|
249
265
|
|
|
250
266
|
it('processes multiple IRC lines delivered in a single TCP chunk', async () => {
|
|
251
|
-
const client = new TcpClient('127.0.0.1', server.port);
|
|
267
|
+
const client = new TcpClient('127.0.0.1', server.port as number);
|
|
252
268
|
await client.opened();
|
|
253
269
|
await client.writeRaw('NICK bundler\r\nUSER bundler 0 * :Bundler\r\n');
|
|
254
270
|
await client.waitFor((l) => l.startsWith(':irc.example.com 001 bundler'));
|
|
@@ -256,7 +272,7 @@ describe('container-server — IRC flows over TCP', () => {
|
|
|
256
272
|
});
|
|
257
273
|
|
|
258
274
|
it('cleans up the runtime connection on abrupt socket termination', async () => {
|
|
259
|
-
const client = new TcpClient('127.0.0.1', server.port);
|
|
275
|
+
const client = new TcpClient('127.0.0.1', server.port as number);
|
|
260
276
|
await client.opened();
|
|
261
277
|
await client.send('NICK termUser');
|
|
262
278
|
await client.send('USER termUser 0 * :Term');
|
|
@@ -272,7 +288,7 @@ describe('container-server — IRC flows over TCP', () => {
|
|
|
272
288
|
});
|
|
273
289
|
|
|
274
290
|
it('delivers an ERROR notice when a reducer supplies a disconnect reason', async () => {
|
|
275
|
-
const client = new TcpClient('127.0.0.1', server.port);
|
|
291
|
+
const client = new TcpClient('127.0.0.1', server.port as number);
|
|
276
292
|
await client.opened();
|
|
277
293
|
await client.send('NICK floodUser');
|
|
278
294
|
await client.send('USER floodUser 0 * :Flood');
|
|
@@ -285,6 +301,35 @@ describe('container-server — IRC flows over TCP', () => {
|
|
|
285
301
|
await expect(wait).resolves.toContain('Excess Flood');
|
|
286
302
|
await client.close();
|
|
287
303
|
});
|
|
304
|
+
|
|
305
|
+
it('closes the connection with ERROR when the input buffer cap is exceeded', async () => {
|
|
306
|
+
// A peer streaming > 8 KiB with no line terminator would grow the
|
|
307
|
+
// transport buffer unboundedly; the origin must answer with the
|
|
308
|
+
// RFC-style ERROR notice, destroy the socket, and unregister the
|
|
309
|
+
// connection from the runtime.
|
|
310
|
+
const client = new TcpClient('127.0.0.1', server.port as number);
|
|
311
|
+
await client.opened();
|
|
312
|
+
await client.send('NICK overflower');
|
|
313
|
+
await client.send('USER overflower 0 * :Over');
|
|
314
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
315
|
+
|
|
316
|
+
const connId = await server.runtime.lookupNick('overflower');
|
|
317
|
+
expect(connId).not.toBeNull();
|
|
318
|
+
expect(server.runtime.hasConnection(connId as string)).toBe(true);
|
|
319
|
+
|
|
320
|
+
const closed = new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
|
|
321
|
+
await client.writeRaw('x'.repeat(9 * 1024)); // no terminator anywhere
|
|
322
|
+
const errorLine = await client.waitFor(
|
|
323
|
+
(l) => l === 'ERROR :Closing link: input buffer overflow',
|
|
324
|
+
2_000,
|
|
325
|
+
);
|
|
326
|
+
expect(errorLine).toBe('ERROR :Closing link: input buffer overflow');
|
|
327
|
+
await closed;
|
|
328
|
+
expect(client.socket.destroyed).toBe(true);
|
|
329
|
+
// Cleanup ran: the runtime no longer tracks the connection.
|
|
330
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
331
|
+
expect(server.runtime.hasConnection(connId as string)).toBe(false);
|
|
332
|
+
});
|
|
288
333
|
});
|
|
289
334
|
|
|
290
335
|
describe('container-server — persistence across restart', () => {
|
|
@@ -309,7 +354,7 @@ describe('container-server — persistence across restart', () => {
|
|
|
309
354
|
networkName: 'PersistNet',
|
|
310
355
|
persistencePath: statePath,
|
|
311
356
|
});
|
|
312
|
-
const c1 = new TcpClient('127.0.0.1', srv1.port);
|
|
357
|
+
const c1 = new TcpClient('127.0.0.1', srv1.port as number);
|
|
313
358
|
await c1.opened();
|
|
314
359
|
await c1.send('NICK op');
|
|
315
360
|
await c1.send('USER op 0 * :Op');
|
|
@@ -331,7 +376,7 @@ describe('container-server — persistence across restart', () => {
|
|
|
331
376
|
networkName: 'PersistNet',
|
|
332
377
|
persistencePath: statePath,
|
|
333
378
|
});
|
|
334
|
-
const c2 = new TcpClient('127.0.0.1', srv2.port);
|
|
379
|
+
const c2 = new TcpClient('127.0.0.1', srv2.port as number);
|
|
335
380
|
await c2.opened();
|
|
336
381
|
await c2.send('NICK late');
|
|
337
382
|
await c2.send('USER late 0 * :Late');
|
|
@@ -359,10 +404,15 @@ describe('container-server — admission gate', () => {
|
|
|
359
404
|
networkName: 'GateNet',
|
|
360
405
|
maxConnectionsPerIp: 1,
|
|
361
406
|
});
|
|
362
|
-
const c1 = new TcpClient('127.0.0.1', srv.port);
|
|
407
|
+
const c1 = new TcpClient('127.0.0.1', srv.port as number);
|
|
363
408
|
await c1.opened();
|
|
364
|
-
//
|
|
365
|
-
|
|
409
|
+
// Admission is committed once the PROXY header has been parsed, so
|
|
410
|
+
// let c1 fully register before a second flow from the same IP.
|
|
411
|
+
await c1.send('NICK gateOne');
|
|
412
|
+
await c1.send('USER gateOne 0 * :Gate');
|
|
413
|
+
await c1.waitFor((l) => l.startsWith(':irc.gate 001 '), 2000);
|
|
414
|
+
// Second connection from the same source IP should be rejected.
|
|
415
|
+
const c2 = new TcpClient('127.0.0.1', srv.port as number);
|
|
366
416
|
const error = await new Promise<string | null>((resolve) => {
|
|
367
417
|
c2.socket.setEncoding('utf8');
|
|
368
418
|
c2.socket.once('data', (d: string) => resolve(d));
|
|
@@ -389,7 +439,7 @@ describe('container-server — snapshot error resilience', () => {
|
|
|
389
439
|
persistencePath: path,
|
|
390
440
|
});
|
|
391
441
|
// Server should still work — starts fresh.
|
|
392
|
-
const c = new TcpClient('127.0.0.1', srv.port);
|
|
442
|
+
const c = new TcpClient('127.0.0.1', srv.port as number);
|
|
393
443
|
await c.opened();
|
|
394
444
|
await c.send('NICK test');
|
|
395
445
|
await c.send('USER test 0 * :Test');
|
|
@@ -410,7 +460,7 @@ describe('container-server — snapshot error resilience', () => {
|
|
|
410
460
|
persistencePath: path,
|
|
411
461
|
snapshotIntervalMs: 999_999,
|
|
412
462
|
});
|
|
413
|
-
const c = new TcpClient('127.0.0.1', srv.port);
|
|
463
|
+
const c = new TcpClient('127.0.0.1', srv.port as number);
|
|
414
464
|
await c.opened();
|
|
415
465
|
// Wait for the server-side socket to be registered in the sockets set.
|
|
416
466
|
for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
|
|
@@ -435,7 +485,7 @@ describe('container-server — snapshot error resilience', () => {
|
|
|
435
485
|
serverName: 'irc.skip',
|
|
436
486
|
networkName: 'SkipNet',
|
|
437
487
|
});
|
|
438
|
-
const c = new TcpClient('127.0.0.1', srv.port);
|
|
488
|
+
const c = new TcpClient('127.0.0.1', srv.port as number);
|
|
439
489
|
await c.opened();
|
|
440
490
|
for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
|
|
441
491
|
await new Promise((r) => setTimeout(r, 5));
|
|
@@ -466,7 +516,7 @@ describe('container-server — snapshot error resilience', () => {
|
|
|
466
516
|
persistencePath: dirPath,
|
|
467
517
|
snapshotIntervalMs: 999_999,
|
|
468
518
|
});
|
|
469
|
-
const c = new TcpClient('127.0.0.1', srv.port);
|
|
519
|
+
const c = new TcpClient('127.0.0.1', srv.port as number);
|
|
470
520
|
await c.opened();
|
|
471
521
|
await c.send('NICK finalFail');
|
|
472
522
|
await c.send('USER finalFail 0 * :FinalFail');
|
|
@@ -493,8 +543,11 @@ describe('container-server — error paths', () => {
|
|
|
493
543
|
srv.runtime.send = async () => {
|
|
494
544
|
throw new Error('synthetic send failure');
|
|
495
545
|
};
|
|
496
|
-
const client = new TcpClient('127.0.0.1', srv.port);
|
|
546
|
+
const client = new TcpClient('127.0.0.1', srv.port as number);
|
|
497
547
|
await client.opened();
|
|
548
|
+
// Settle so the PROXY header is consumed as its own chunk; every
|
|
549
|
+
// subsequent IRC line then flows through the post-attach data path.
|
|
550
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
498
551
|
await client.send('NICK errUser');
|
|
499
552
|
await client.send('USER errUser 0 * :Err');
|
|
500
553
|
// PING triggers a Send → synthetic throw → actor catch → logError.
|
|
@@ -503,8 +556,9 @@ describe('container-server — error paths', () => {
|
|
|
503
556
|
|
|
504
557
|
srv.runtime.send = original;
|
|
505
558
|
// Server should still serve new connections.
|
|
506
|
-
const next = new TcpClient('127.0.0.1', srv.port);
|
|
559
|
+
const next = new TcpClient('127.0.0.1', srv.port as number);
|
|
507
560
|
await next.opened();
|
|
561
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
508
562
|
await next.send('NICK recover');
|
|
509
563
|
await next.send('USER recover 0 * :Recover');
|
|
510
564
|
await next.waitFor((l) => l.startsWith(':irc.err 001 '), 3000);
|
|
@@ -527,16 +581,18 @@ describe('container-server — error paths', () => {
|
|
|
527
581
|
// eslint-disable-next-line no-throw-literal
|
|
528
582
|
throw { code: 'SYNTHETIC', detail: 'not an Error' };
|
|
529
583
|
};
|
|
530
|
-
const client = new TcpClient('127.0.0.1', srv.port);
|
|
584
|
+
const client = new TcpClient('127.0.0.1', srv.port as number);
|
|
531
585
|
await client.opened();
|
|
586
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
532
587
|
await client.send('NICK nonErrUser');
|
|
533
588
|
await client.send('USER nonErrUser 0 * :NonErr');
|
|
534
589
|
await client.send('PING :will-throw-non-error');
|
|
535
590
|
await new Promise((r) => setTimeout(r, 100));
|
|
536
591
|
|
|
537
592
|
srv.runtime.send = original;
|
|
538
|
-
const next = new TcpClient('127.0.0.1', srv.port);
|
|
593
|
+
const next = new TcpClient('127.0.0.1', srv.port as number);
|
|
539
594
|
await next.opened();
|
|
595
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
540
596
|
await next.send('NICK recover2');
|
|
541
597
|
await next.send('USER recover2 0 * :Recover2');
|
|
542
598
|
await next.waitFor((l) => l.startsWith(':irc.nonerr 001 '), 3000);
|
|
@@ -554,7 +610,7 @@ describe('container-server — error paths', () => {
|
|
|
554
610
|
serverName: 'irc.noreason',
|
|
555
611
|
networkName: 'NoReasonNet',
|
|
556
612
|
});
|
|
557
|
-
const client = new TcpClient('127.0.0.1', srv.port);
|
|
613
|
+
const client = new TcpClient('127.0.0.1', srv.port as number);
|
|
558
614
|
await client.opened();
|
|
559
615
|
await client.send('NICK noReason');
|
|
560
616
|
await client.send('USER noReason 0 * :NoReason');
|
|
@@ -580,7 +636,7 @@ describe('container-server — error paths', () => {
|
|
|
580
636
|
serverName: 'irc.unwritable',
|
|
581
637
|
networkName: 'UnwritableNet',
|
|
582
638
|
});
|
|
583
|
-
const client = new TcpClient('127.0.0.1', srv.port);
|
|
639
|
+
const client = new TcpClient('127.0.0.1', srv.port as number);
|
|
584
640
|
await client.opened();
|
|
585
641
|
await client.send('NICK unwritable');
|
|
586
642
|
await client.send('USER unwritable 0 * :Unwritable');
|
|
@@ -609,7 +665,7 @@ describe('container-server — error paths', () => {
|
|
|
609
665
|
serverName: 'irc.sockerr',
|
|
610
666
|
networkName: 'SockErrNet',
|
|
611
667
|
});
|
|
612
|
-
const client = new TcpClient('127.0.0.1', srv.port);
|
|
668
|
+
const client = new TcpClient('127.0.0.1', srv.port as number);
|
|
613
669
|
await client.opened();
|
|
614
670
|
for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
|
|
615
671
|
await new Promise((r) => setTimeout(r, 5));
|
|
@@ -619,7 +675,7 @@ describe('container-server — error paths', () => {
|
|
|
619
675
|
serverSideSocket.emit('error', new Error('synthetic socket failure'));
|
|
620
676
|
await new Promise((r) => setImmediate(r));
|
|
621
677
|
// Server should still serve new connections.
|
|
622
|
-
const next = new TcpClient('127.0.0.1', srv.port);
|
|
678
|
+
const next = new TcpClient('127.0.0.1', srv.port as number);
|
|
623
679
|
await next.opened();
|
|
624
680
|
await next.close();
|
|
625
681
|
await client.close();
|
|
@@ -638,7 +694,7 @@ describe('container-server — error paths', () => {
|
|
|
638
694
|
snapshotIntervalMs: 100,
|
|
639
695
|
});
|
|
640
696
|
// Create a channel so the snapshot has content.
|
|
641
|
-
const c = new TcpClient('127.0.0.1', srv.port);
|
|
697
|
+
const c = new TcpClient('127.0.0.1', srv.port as number);
|
|
642
698
|
await c.opened();
|
|
643
699
|
await c.send('NICK autoUser');
|
|
644
700
|
await c.send('USER autoUser 0 * :Auto');
|
|
@@ -666,7 +722,7 @@ describe('container-server — draft/multiline BATCH to a nick target', () => {
|
|
|
666
722
|
|
|
667
723
|
/** Negotiates `batch` + `draft/multiline`, then registers the nick. */
|
|
668
724
|
async function registerMultilineCapable(srv: ContainerServer, nick: string): Promise<TcpClient> {
|
|
669
|
-
const c = new TcpClient('127.0.0.1', srv.port);
|
|
725
|
+
const c = new TcpClient('127.0.0.1', srv.port as number);
|
|
670
726
|
await c.opened();
|
|
671
727
|
await c.send('CAP LS 302');
|
|
672
728
|
await c.waitFor((l) => l.startsWith(':irc.ml CAP * LS :'), 2000);
|
|
@@ -711,3 +767,274 @@ describe('container-server — draft/multiline BATCH to a nick target', () => {
|
|
|
711
767
|
await srv.close();
|
|
712
768
|
});
|
|
713
769
|
});
|
|
770
|
+
|
|
771
|
+
describe('container-server — PROXY protocol v1', () => {
|
|
772
|
+
// Spectrum is configured with `proxy_protocol = "v1"`: every forwarded
|
|
773
|
+
// flow starts with `PROXY TCP4|TCP6 <src> <dst> <sport> <dport>\r\n`.
|
|
774
|
+
// The origin must peel that header off before the bytes reach the IRC
|
|
775
|
+
// actor and must key per-IP admission on the recovered source IP.
|
|
776
|
+
|
|
777
|
+
it('admits a headered connection with no 421 on the first frame', async () => {
|
|
778
|
+
const srv = await startContainerServer({
|
|
779
|
+
port: 0,
|
|
780
|
+
host: '127.0.0.1',
|
|
781
|
+
serverName: 'irc.proxy',
|
|
782
|
+
networkName: 'ProxyNet',
|
|
783
|
+
});
|
|
784
|
+
const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: '203.0.113.5' });
|
|
785
|
+
await c.opened();
|
|
786
|
+
await c.send('NICK viaProxy');
|
|
787
|
+
await c.send('USER viaProxy 0 * :Via');
|
|
788
|
+
await c.waitFor((l) => l.startsWith(':irc.proxy 001 '), 2000);
|
|
789
|
+
// The PROXY line must never surface as an unknown IRC command.
|
|
790
|
+
expect(c.received.some((l) => l.includes(' 421 '))).toBe(false);
|
|
791
|
+
await c.close();
|
|
792
|
+
await srv.close();
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it('surfaces the PROXY source IP as the connection host', async () => {
|
|
796
|
+
const srv = await startContainerServer({
|
|
797
|
+
port: 0,
|
|
798
|
+
host: '127.0.0.1',
|
|
799
|
+
serverName: 'irc.hostip',
|
|
800
|
+
networkName: 'HostIpNet',
|
|
801
|
+
});
|
|
802
|
+
const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: '203.0.113.77' });
|
|
803
|
+
await c.opened();
|
|
804
|
+
await c.send('NICK hostcheck');
|
|
805
|
+
await c.send('USER hostcheck 0 * :Host');
|
|
806
|
+
await c.waitFor((l) => l.startsWith(':irc.hostip 001 '), 2000);
|
|
807
|
+
const connId = await srv.runtime.lookupNick('hostcheck');
|
|
808
|
+
expect(connId).not.toBeNull();
|
|
809
|
+
const state = await srv.runtime.getConnection(connId as string);
|
|
810
|
+
// Not the socket peer address (127.0.0.1) — the PROXY-header IP.
|
|
811
|
+
expect(state?.host).toBe('203.0.113.77');
|
|
812
|
+
await c.close();
|
|
813
|
+
await srv.close();
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it('parses TCP6-family headers', async () => {
|
|
817
|
+
const srv = await startContainerServer({
|
|
818
|
+
port: 0,
|
|
819
|
+
host: '127.0.0.1',
|
|
820
|
+
serverName: 'irc.v6',
|
|
821
|
+
networkName: 'V6Net',
|
|
822
|
+
});
|
|
823
|
+
const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: '2001:db8::1' });
|
|
824
|
+
await c.opened();
|
|
825
|
+
await c.send('NICK v6user');
|
|
826
|
+
await c.send('USER v6user 0 * :V6');
|
|
827
|
+
await c.waitFor((l) => l.startsWith(':irc.v6 001 '), 2000);
|
|
828
|
+
const connId = await srv.runtime.lookupNick('v6user');
|
|
829
|
+
expect(connId).not.toBeNull();
|
|
830
|
+
const state = await srv.runtime.getConnection(connId as string);
|
|
831
|
+
expect(state?.host).toBe('2001:db8::1');
|
|
832
|
+
await c.close();
|
|
833
|
+
await srv.close();
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('reassembles a header split across two TCP chunks', async () => {
|
|
837
|
+
const srv = await startContainerServer({
|
|
838
|
+
port: 0,
|
|
839
|
+
host: '127.0.0.1',
|
|
840
|
+
serverName: 'irc.split',
|
|
841
|
+
networkName: 'SplitNet',
|
|
842
|
+
});
|
|
843
|
+
const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: null });
|
|
844
|
+
await c.opened();
|
|
845
|
+
await c.writeRaw('PROXY TCP4 192.0.2.5');
|
|
846
|
+
// Let the partial header be delivered as its own chunk (the server
|
|
847
|
+
// must buffer it), then complete the header in a second chunk that
|
|
848
|
+
// also carries IRC bytes behind it.
|
|
849
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
850
|
+
await c.writeRaw(' 198.51.100.7 40001 6697\r\nNICK hdrsplit\r\n');
|
|
851
|
+
await c.send('USER hdrsplit 0 * :Split');
|
|
852
|
+
await c.waitFor((l) => l.startsWith(':irc.split 001 '), 2000);
|
|
853
|
+
expect(c.received.some((l) => l.includes(' 421 '))).toBe(false);
|
|
854
|
+
const connId = await srv.runtime.lookupNick('hdrsplit');
|
|
855
|
+
expect(connId).not.toBeNull();
|
|
856
|
+
const state = await srv.runtime.getConnection(connId as string);
|
|
857
|
+
expect(state?.host).toBe('192.0.2.5');
|
|
858
|
+
await c.close();
|
|
859
|
+
await srv.close();
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it('rejects a direct connection that speaks IRC without a PROXY header', async () => {
|
|
863
|
+
const srv = await startContainerServer({
|
|
864
|
+
port: 0,
|
|
865
|
+
host: '127.0.0.1',
|
|
866
|
+
serverName: 'irc.direct',
|
|
867
|
+
networkName: 'DirectNet',
|
|
868
|
+
});
|
|
869
|
+
const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: null });
|
|
870
|
+
await c.opened();
|
|
871
|
+
const closed = new Promise<void>((resolve) => c.socket.once('close', () => resolve()));
|
|
872
|
+
await c.send('NICK direct');
|
|
873
|
+
const errorLine = await c.waitFor((l) => l.startsWith('ERROR :Closing link:'), 2000);
|
|
874
|
+
expect(errorLine).toContain('PROXY');
|
|
875
|
+
await closed;
|
|
876
|
+
expect(c.socket.destroyed).toBe(true);
|
|
877
|
+
// No IRC identity was ever created for the rejected flow.
|
|
878
|
+
expect(await srv.runtime.lookupNick('direct')).toBeNull();
|
|
879
|
+
await srv.close();
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('rejects a malformed PROXY header', async () => {
|
|
883
|
+
const srv = await startContainerServer({
|
|
884
|
+
port: 0,
|
|
885
|
+
host: '127.0.0.1',
|
|
886
|
+
serverName: 'irc.badhdr',
|
|
887
|
+
networkName: 'BadHdrNet',
|
|
888
|
+
});
|
|
889
|
+
const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: null });
|
|
890
|
+
await c.opened();
|
|
891
|
+
const closed = new Promise<void>((resolve) => c.socket.once('close', () => resolve()));
|
|
892
|
+
await c.writeRaw('PROXY UNKNOWN\r\n');
|
|
893
|
+
const errorLine = await c.waitFor((l) => l.startsWith('ERROR :Closing link:'), 2000);
|
|
894
|
+
expect(errorLine).toContain('PROXY');
|
|
895
|
+
await closed;
|
|
896
|
+
expect(c.socket.destroyed).toBe(true);
|
|
897
|
+
await srv.close();
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it('logs an actor failure for IRC bytes coalesced with the header', async () => {
|
|
901
|
+
const srv = await startContainerServer({
|
|
902
|
+
port: 0,
|
|
903
|
+
host: '127.0.0.1',
|
|
904
|
+
serverName: 'irc.hdrerr',
|
|
905
|
+
networkName: 'HdrErrNet',
|
|
906
|
+
});
|
|
907
|
+
const original = srv.runtime.send.bind(srv.runtime);
|
|
908
|
+
srv.runtime.send = async () => {
|
|
909
|
+
throw new Error('synthetic send failure');
|
|
910
|
+
};
|
|
911
|
+
const c = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: null });
|
|
912
|
+
await c.opened();
|
|
913
|
+
// One write: the header and the full registration share a single TCP
|
|
914
|
+
// chunk, so the welcome send fails inside the remainder receive.
|
|
915
|
+
await c.writeRaw(
|
|
916
|
+
'PROXY TCP4 203.0.113.99 192.0.2.1 40000 6697\r\nNICK hdrErr\r\nUSER hdrErr 0 * :E\r\n',
|
|
917
|
+
);
|
|
918
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
919
|
+
srv.runtime.send = original;
|
|
920
|
+
|
|
921
|
+
// The server must survive the failure and serve a fresh connection.
|
|
922
|
+
const next = new TcpClient('127.0.0.1', srv.port as number);
|
|
923
|
+
await next.opened();
|
|
924
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
925
|
+
await next.send('NICK hdrRecover');
|
|
926
|
+
await next.send('USER hdrRecover 0 * :R');
|
|
927
|
+
await next.waitFor((l) => l.startsWith(':irc.hdrerr 001 '), 2000);
|
|
928
|
+
await c.close();
|
|
929
|
+
await next.close();
|
|
930
|
+
await srv.close();
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
it('tracks admission counters per real source IP, not the edge address', async () => {
|
|
934
|
+
// With the pre-PROXY bug, every flow was admitted under the single
|
|
935
|
+
// Spectrum edge IP, so `maxConnectionsPerIp: 1` capped the aggregate.
|
|
936
|
+
// With per-real-IP counters, clients from distinct PROXY IPs are
|
|
937
|
+
// independent and only same-IP flows share a counter.
|
|
938
|
+
const srv = await startContainerServer({
|
|
939
|
+
port: 0,
|
|
940
|
+
host: '127.0.0.1',
|
|
941
|
+
serverName: 'irc.perip',
|
|
942
|
+
networkName: 'PerIpNet',
|
|
943
|
+
maxConnectionsPerIp: 1,
|
|
944
|
+
});
|
|
945
|
+
const a1 = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: '203.0.113.10' });
|
|
946
|
+
await a1.opened();
|
|
947
|
+
await a1.send('NICK ipA');
|
|
948
|
+
await a1.send('USER ipA 0 * :A');
|
|
949
|
+
await a1.waitFor((l) => l.startsWith(':irc.perip 001 '), 2000);
|
|
950
|
+
|
|
951
|
+
// A client from a distinct PROXY IP: independently admitted.
|
|
952
|
+
const b = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: '198.51.100.20' });
|
|
953
|
+
await b.opened();
|
|
954
|
+
await b.send('NICK ipB');
|
|
955
|
+
await b.send('USER ipB 0 * :B');
|
|
956
|
+
await b.waitFor((l) => l.startsWith(':irc.perip 001 '), 2000);
|
|
957
|
+
|
|
958
|
+
// A second flow from ipA's address: rejected by the per-IP cap.
|
|
959
|
+
const a2 = new TcpClient('127.0.0.1', srv.port as number, { proxySourceIp: '203.0.113.10' });
|
|
960
|
+
const error = await new Promise<string | null>((resolve) => {
|
|
961
|
+
a2.socket.once('data', (d: string) => resolve(d));
|
|
962
|
+
a2.socket.once('close', () => resolve(null));
|
|
963
|
+
setTimeout(() => resolve(null), 2000);
|
|
964
|
+
});
|
|
965
|
+
expect(error).toContain('Closing link');
|
|
966
|
+
|
|
967
|
+
await a1.close();
|
|
968
|
+
await b.close();
|
|
969
|
+
a2.destroy();
|
|
970
|
+
await srv.close();
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
describe('container-server — SASL EXTERNAL non-support', () => {
|
|
975
|
+
// The TCP container origin has no mTLS identity source (Spectrum
|
|
976
|
+
// speaks PROXY v1, which carries no client-cert subject), so SASL
|
|
977
|
+
// EXTERNAL is explicitly unsupported on this transport: the mechanism
|
|
978
|
+
// is rejected up front with a stable, transport-specific 904. The
|
|
979
|
+
// literal is asserted verbatim so the message cannot drift silently.
|
|
980
|
+
const EXTERNAL_904 =
|
|
981
|
+
':irc.example.com 904 extuser :SASL EXTERNAL not supported on this transport';
|
|
982
|
+
|
|
983
|
+
let server: ContainerServer;
|
|
984
|
+
|
|
985
|
+
beforeAll(async () => {
|
|
986
|
+
server = await startContainerServer({
|
|
987
|
+
port: 0,
|
|
988
|
+
host: '127.0.0.1',
|
|
989
|
+
serverName: 'irc.example.com',
|
|
990
|
+
networkName: 'ExampleNet',
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
afterAll(async () => {
|
|
995
|
+
await server.close();
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('advertises sasl=PLAIN only (EXTERNAL excluded on this transport)', async () => {
|
|
999
|
+
const client = new TcpClient('127.0.0.1', server.port as number);
|
|
1000
|
+
await client.opened();
|
|
1001
|
+
await client.send('CAP LS 302');
|
|
1002
|
+
// Wait for the final LS line (a `LS *` continuation is not last).
|
|
1003
|
+
await client.waitFor((l) => /^:irc\.example\.com CAP \S+ LS :/u.test(l));
|
|
1004
|
+
const lsBody = client.received.filter((l) => l.includes(' LS ')).join(' ');
|
|
1005
|
+
expect(lsBody).toContain('sasl=PLAIN');
|
|
1006
|
+
expect(lsBody).not.toContain('EXTERNAL');
|
|
1007
|
+
await client.close();
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it('rejects AUTHENTICATE EXTERNAL with the transport-specific 904', async () => {
|
|
1011
|
+
const client = new TcpClient('127.0.0.1', server.port as number);
|
|
1012
|
+
await client.opened();
|
|
1013
|
+
await client.send('CAP LS 302');
|
|
1014
|
+
await client.waitFor((l) => /^:irc\.example\.com CAP \S+ LS :/u.test(l));
|
|
1015
|
+
await client.send('NICK extuser');
|
|
1016
|
+
await client.send('AUTHENTICATE EXTERNAL');
|
|
1017
|
+
await client.waitFor((l) => l === EXTERNAL_904);
|
|
1018
|
+
await client.close();
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
it('does not trip the SASL failure lockout on repeated EXTERNAL attempts', async () => {
|
|
1022
|
+
// The transport-level rejection is not a credential attempt: with
|
|
1023
|
+
// the default lockout (3 failures) a counting implementation would
|
|
1024
|
+
// disconnect by the third attempt. Four attempts must all be
|
|
1025
|
+
// answered and the connection must stay alive.
|
|
1026
|
+
const client = new TcpClient('127.0.0.1', server.port as number);
|
|
1027
|
+
await client.opened();
|
|
1028
|
+
await client.send('CAP LS 302');
|
|
1029
|
+
await client.waitFor((l) => /^:irc\.example\.com CAP \S+ LS :/u.test(l));
|
|
1030
|
+
await client.send('NICK extuser');
|
|
1031
|
+
for (let i = 0; i < 4; i++) {
|
|
1032
|
+
await client.send('AUTHENTICATE EXTERNAL');
|
|
1033
|
+
}
|
|
1034
|
+
await client.send('PING :lockout-probe');
|
|
1035
|
+
await client.waitFor((l) => l === 'PONG :lockout-probe');
|
|
1036
|
+
expect(client.received.filter((l) => l === EXTERNAL_904)).toHaveLength(4);
|
|
1037
|
+
expect(client.received.some((l) => l.startsWith('ERROR :Closing link'))).toBe(false);
|
|
1038
|
+
await client.close();
|
|
1039
|
+
});
|
|
1040
|
+
});
|