serverless-ircd 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- 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 +17 -4
- package/apps/cf-worker/package.json +2 -2
- 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 +86 -9
- 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 +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- 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 +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- 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-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- 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 +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -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/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- 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/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 +40 -1
- 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 +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- 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 +393 -20
- 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 +8 -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 +395 -36
- 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 +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- 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/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- 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/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 +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- 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 +544 -7
- 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/scripts/package.json +1 -1
- 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 +17 -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-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -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/tcp-ws-forwarder/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { handleJoinZero,
|
|
2
|
+
import { handleJoinZero, joinReducer } from '../../src/commands/join';
|
|
3
3
|
import { Effect } from '../../src/effects';
|
|
4
4
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
5
|
import {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type ServicesStore,
|
|
13
13
|
type StoredMessage,
|
|
14
14
|
} from '../../src/ports';
|
|
15
|
+
import { isValidChannelName } from '../../src/protocol/channel-name';
|
|
15
16
|
import { type ChannelState, createChannel } from '../../src/state/channel';
|
|
16
17
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
17
18
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
@@ -107,6 +108,30 @@ describe('isValidChannelName', () => {
|
|
|
107
108
|
expect(isValidChannelName('#foo:bar', 50)).toBe(false);
|
|
108
109
|
});
|
|
109
110
|
|
|
111
|
+
it('rejects a name containing NUL', () => {
|
|
112
|
+
expect(isValidChannelName('#foo\0bar', 50)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('rejects a name containing a control character', () => {
|
|
116
|
+
expect(isValidChannelName('#foo\x01bar', 50)).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('rejects a multi-byte name whose UTF-8 byte size exceeds the cap', () => {
|
|
120
|
+
expect(isValidChannelName(`#${'é'.repeat(25)}`, 50)).toBe(false); // 51 bytes, 26 UTF-16 units
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('accepts a multi-byte name at exactly the UTF-8 byte cap', () => {
|
|
124
|
+
expect(isValidChannelName(`#${'é'.repeat(24)}x`, 50)).toBe(true); // 1 + 48 + 1 = 50 bytes
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('continues to accept ! in a channel name', () => {
|
|
128
|
+
expect(isValidChannelName('#foo!bar', 50)).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('continues to accept @ in a channel name', () => {
|
|
132
|
+
expect(isValidChannelName('#foo@bar', 50)).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
110
135
|
it('rejects a name exceeding the length cap', () => {
|
|
111
136
|
expect(isValidChannelName(`#${'a'.repeat(50)}`, 50)).toBe(false);
|
|
112
137
|
});
|
|
@@ -395,6 +420,58 @@ describe('joinReducer — rejections', () => {
|
|
|
395
420
|
]);
|
|
396
421
|
});
|
|
397
422
|
|
|
423
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing NUL', () => {
|
|
424
|
+
const chan = makeChan('#foo');
|
|
425
|
+
const conn = makeConn();
|
|
426
|
+
const ctx = makeCtx(conn);
|
|
427
|
+
|
|
428
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo\0bar'], tags: {} }, ctx);
|
|
429
|
+
|
|
430
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
431
|
+
Effect.send('c1', [L(':irc.example.com 403 alice #foo\0bar :No such channel')]),
|
|
432
|
+
]);
|
|
433
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a control character', () => {
|
|
437
|
+
const chan = makeChan('#foo');
|
|
438
|
+
const conn = makeConn();
|
|
439
|
+
const ctx = makeCtx(conn);
|
|
440
|
+
|
|
441
|
+
const out = joinReducer(chan, { command: 'JOIN', params: ['#foo\x01bar'], tags: {} }, ctx);
|
|
442
|
+
|
|
443
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
444
|
+
Effect.send('c1', [L(':irc.example.com 403 alice #foo\x01bar :No such channel')]),
|
|
445
|
+
]);
|
|
446
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name whose UTF-8 byte size exceeds the cap', () => {
|
|
450
|
+
const long = `#${'é'.repeat(25)}`; // 51 UTF-8 bytes, only 26 UTF-16 units
|
|
451
|
+
const chan = makeChan(long);
|
|
452
|
+
const conn = makeConn();
|
|
453
|
+
const ctx = makeCtx(conn);
|
|
454
|
+
|
|
455
|
+
const out = joinReducer(chan, { command: 'JOIN', params: [long], tags: {} }, ctx);
|
|
456
|
+
|
|
457
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
458
|
+
Effect.send('c1', [L(`:irc.example.com 403 alice ${long} :No such channel`)]),
|
|
459
|
+
]);
|
|
460
|
+
expect(out.state.members.has('c1')).toBe(false);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('joins a multi-byte channel name at exactly the 50-byte cap', () => {
|
|
464
|
+
const name = `#${'é'.repeat(24)}x`; // 1 + 48 + 1 = 50 UTF-8 bytes
|
|
465
|
+
const chan = makeChan(name);
|
|
466
|
+
const conn = makeConn();
|
|
467
|
+
const ctx = makeCtx(conn);
|
|
468
|
+
|
|
469
|
+
const out = joinReducer(chan, { command: 'JOIN', params: [name], tags: {} }, ctx);
|
|
470
|
+
|
|
471
|
+
expect(out.state.members.has('c1')).toBe(true);
|
|
472
|
+
expect(conn.joinedChannels.has(name.toLowerCase())).toBe(true);
|
|
473
|
+
});
|
|
474
|
+
|
|
398
475
|
it('emits 405 ERR_TOOMANYCHANNELS when the connection is at the max-channels limit', () => {
|
|
399
476
|
const chan = makeChan('#newchan');
|
|
400
477
|
const conn = makeConn();
|
|
@@ -3,6 +3,7 @@ import { namesReducer } from '../../src/commands/names';
|
|
|
3
3
|
import { Effect } from '../../src/effects';
|
|
4
4
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
5
5
|
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
6
|
+
import { byteLength } from '../../src/protocol/bytes';
|
|
6
7
|
import { type ChannelState, createChannel } from '../../src/state/channel';
|
|
7
8
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
8
9
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
@@ -238,6 +239,43 @@ describe('namesReducer — rejections', () => {
|
|
|
238
239
|
]);
|
|
239
240
|
});
|
|
240
241
|
|
|
242
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing NUL', () => {
|
|
243
|
+
const chan = makeChan('#foo');
|
|
244
|
+
const conn = makeConn();
|
|
245
|
+
const ctx = makeCtx(conn);
|
|
246
|
+
|
|
247
|
+
const out = namesReducer(chan, { command: 'NAMES', params: ['#foo\0bar'], tags: {} }, ctx);
|
|
248
|
+
|
|
249
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
250
|
+
Effect.send('c1', [L(':irc.example.com 403 alice #foo\0bar :No such channel')]),
|
|
251
|
+
]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a control character', () => {
|
|
255
|
+
const chan = makeChan('#foo');
|
|
256
|
+
const conn = makeConn();
|
|
257
|
+
const ctx = makeCtx(conn);
|
|
258
|
+
|
|
259
|
+
const out = namesReducer(chan, { command: 'NAMES', params: ['#foo\x1fbar'], tags: {} }, ctx);
|
|
260
|
+
|
|
261
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
262
|
+
Effect.send('c1', [L(':irc.example.com 403 alice #foo\x1fbar :No such channel')]),
|
|
263
|
+
]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('emits 403 ERR_NOSUCHCHANNEL for a channel name whose UTF-8 byte size exceeds the cap', () => {
|
|
267
|
+
const long = `#${'é'.repeat(25)}`; // 51 UTF-8 bytes, only 26 UTF-16 units
|
|
268
|
+
const chan = makeChan(long);
|
|
269
|
+
const conn = makeConn();
|
|
270
|
+
const ctx = makeCtx(conn);
|
|
271
|
+
|
|
272
|
+
const out = namesReducer(chan, { command: 'NAMES', params: [long], tags: {} }, ctx);
|
|
273
|
+
|
|
274
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
275
|
+
Effect.send('c1', [L(`:irc.example.com 403 alice ${long} :No such channel`)]),
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
|
|
241
279
|
it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {
|
|
242
280
|
const chan = makeChan('#foo');
|
|
243
281
|
const conn = makeConn();
|
|
@@ -340,3 +378,158 @@ describe('namesReducer — rejections', () => {
|
|
|
340
378
|
]);
|
|
341
379
|
});
|
|
342
380
|
});
|
|
381
|
+
|
|
382
|
+
// ============================================================================
|
|
383
|
+
// namesReducer — long NAMES list splitting (RFC 2812 §3.2.5)
|
|
384
|
+
// ============================================================================
|
|
385
|
+
|
|
386
|
+
/** RFC 1459 §2.3: 512-byte line budget minus the trailing CRLF pair. */
|
|
387
|
+
const MAX_TEXT_BYTES = 510;
|
|
388
|
+
|
|
389
|
+
describe('namesReducer — long NAMES list splitting', () => {
|
|
390
|
+
/** Returns the Send effect's lines, or [] when the first effect is not a Send. */
|
|
391
|
+
function sentLines(out: { effects: EffectType[] }): RawLine[] {
|
|
392
|
+
const send = out.effects[0];
|
|
393
|
+
return send?.tag === 'Send' ? send.lines : [];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Returns the trailing parameter of a formatted numeric line (after the last ` :`). */
|
|
397
|
+
function trailingOf(text: string): string {
|
|
398
|
+
const idx = text.lastIndexOf(' :');
|
|
399
|
+
return idx === -1 ? '' : text.slice(idx + 2);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Returns only the 353 RPL_NAMREPLY lines, in order. */
|
|
403
|
+
function namReplyLines(lines: RawLine[]): RawLine[] {
|
|
404
|
+
return lines.filter((l) => l.text.includes(' 353 '));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function pad(i: number): string {
|
|
408
|
+
return i.toString().padStart(3, '0');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
it('splits a 100-member roster across multiple complete 353 lines within the byte budget', () => {
|
|
412
|
+
const chan = makeChan('#foo');
|
|
413
|
+
const expected: string[] = [];
|
|
414
|
+
for (let i = 1; i <= 100; i++) {
|
|
415
|
+
const nick = `user${pad(i)}`;
|
|
416
|
+
addMember(chan, `c${i}`, nick);
|
|
417
|
+
expected.push(nick);
|
|
418
|
+
}
|
|
419
|
+
const conn = makeConn();
|
|
420
|
+
const ctx = makeCtx(conn);
|
|
421
|
+
|
|
422
|
+
const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
|
|
423
|
+
|
|
424
|
+
const lines = sentLines(out);
|
|
425
|
+
const replies = namReplyLines(lines);
|
|
426
|
+
expect(replies.length).toBeGreaterThan(1);
|
|
427
|
+
for (const line of replies) {
|
|
428
|
+
expect(byteLength(line.text)).toBeLessThanOrEqual(MAX_TEXT_BYTES);
|
|
429
|
+
}
|
|
430
|
+
// No mid-nick truncation: the roster survives intact, in order, across lines.
|
|
431
|
+
expect(replies.map((l) => trailingOf(l.text)).join(' ')).toBe(expected.join(' '));
|
|
432
|
+
// Exactly one 366 terminator, emitted after the final 353.
|
|
433
|
+
const endLines = lines.filter((l) => l.text.includes(' 366 '));
|
|
434
|
+
expect(endLines.length).toBe(1);
|
|
435
|
+
expect(lines[lines.length - 1]?.text).toBe(
|
|
436
|
+
':irc.example.com 366 alice #foo :End of /NAMES list.',
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('counts @/+ prefix bytes in the budget: no non-final 353 could have taken the next member', () => {
|
|
441
|
+
const chan = makeChan('#foo');
|
|
442
|
+
const expected: string[] = [];
|
|
443
|
+
for (let i = 1; i <= 100; i++) {
|
|
444
|
+
const nick = `user${pad(i)}`;
|
|
445
|
+
// Alternating op (`@userNNN`, 8 bytes) and op+voice (`@+userNNN`, 9 bytes).
|
|
446
|
+
addMember(chan, `c${i}`, nick, true, i % 2 === 0);
|
|
447
|
+
expected.push(i % 2 === 0 ? `@+${nick}` : `@${nick}`);
|
|
448
|
+
}
|
|
449
|
+
const conn = makeConn();
|
|
450
|
+
conn.caps.add('multi-prefix');
|
|
451
|
+
const ctx = makeCtx(conn);
|
|
452
|
+
|
|
453
|
+
const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
|
|
454
|
+
|
|
455
|
+
const lines = sentLines(out);
|
|
456
|
+
const replies = namReplyLines(lines);
|
|
457
|
+
expect(replies.length).toBeGreaterThan(1);
|
|
458
|
+
for (const line of replies) {
|
|
459
|
+
expect(byteLength(line.text)).toBeLessThanOrEqual(MAX_TEXT_BYTES);
|
|
460
|
+
}
|
|
461
|
+
// Roster intact with prefixes.
|
|
462
|
+
expect(replies.map((l) => trailingOf(l.text)).join(' ')).toBe(expected.join(' '));
|
|
463
|
+
// Greedy maximality: appending the next line's first (prefixed) member to
|
|
464
|
+
// any non-final line would blow the budget — proof the prefix bytes were
|
|
465
|
+
// counted, not just the bare nicks.
|
|
466
|
+
for (let i = 0; i + 1 < replies.length; i++) {
|
|
467
|
+
const cur = replies[i]?.text ?? '';
|
|
468
|
+
const nextFirst = trailingOf(replies[i + 1]?.text ?? '').split(' ')[0] ?? '';
|
|
469
|
+
expect(nextFirst.length).toBeGreaterThan(0);
|
|
470
|
+
expect(byteLength(`${cur} ${nextFirst}`)).toBeGreaterThan(MAX_TEXT_BYTES);
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('measures the budget in UTF-8 bytes: multi-byte nicks split even when UTF-16 units fit', () => {
|
|
475
|
+
const chan = makeChan('#foo');
|
|
476
|
+
const nick = 'é'.repeat(60); // 60 UTF-16 units but 120 UTF-8 bytes per nick
|
|
477
|
+
for (let i = 1; i <= 7; i++) {
|
|
478
|
+
addMember(chan, `c${i}`, nick);
|
|
479
|
+
}
|
|
480
|
+
const conn = makeConn();
|
|
481
|
+
const ctx = makeCtx(conn);
|
|
482
|
+
|
|
483
|
+
const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
|
|
484
|
+
|
|
485
|
+
const lines = sentLines(out);
|
|
486
|
+
const replies = namReplyLines(lines);
|
|
487
|
+
// Names alone: 7*120 + 6 = 846 bytes (but only 426 UTF-16 units) — a
|
|
488
|
+
// code-unit-based packer would emit one 881-byte line.
|
|
489
|
+
expect(replies.length).toBeGreaterThan(1);
|
|
490
|
+
for (const line of replies) {
|
|
491
|
+
expect(byteLength(line.text)).toBeLessThanOrEqual(MAX_TEXT_BYTES);
|
|
492
|
+
}
|
|
493
|
+
expect(replies.map((l) => trailingOf(l.text)).join(' ')).toBe(
|
|
494
|
+
Array<string>(7).fill(nick).join(' '),
|
|
495
|
+
);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('emits exactly one 353 with an empty trailing + the 366 for a zero-member channel', () => {
|
|
499
|
+
const chan = makeChan('#foo'); // roster is completely empty
|
|
500
|
+
const conn = makeConn(); // requester is not a member either
|
|
501
|
+
const ctx = makeCtx(conn);
|
|
502
|
+
|
|
503
|
+
const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
|
|
504
|
+
|
|
505
|
+
expect(sentLines(out)).toEqual<RawLine[]>([
|
|
506
|
+
L(':irc.example.com 353 alice = #foo :'),
|
|
507
|
+
L(':irc.example.com 366 alice #foo :End of /NAMES list.'),
|
|
508
|
+
]);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('wraps every split 353 line plus the 366 inside one BATCH frame when batch is negotiated', () => {
|
|
512
|
+
const chan = makeChan('#foo');
|
|
513
|
+
for (let i = 1; i <= 100; i++) {
|
|
514
|
+
addMember(chan, `c${i}`, `user${pad(i)}`);
|
|
515
|
+
}
|
|
516
|
+
const conn = makeConn();
|
|
517
|
+
conn.caps.add('batch');
|
|
518
|
+
const ctx = makeCtx(conn);
|
|
519
|
+
|
|
520
|
+
const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
|
|
521
|
+
|
|
522
|
+
const lines = sentLines(out);
|
|
523
|
+
expect(lines[0]?.text).toBe('BATCH +batch-0 names');
|
|
524
|
+
expect(lines[lines.length - 1]?.text).toBe('BATCH -batch-0');
|
|
525
|
+
const inner = lines.slice(1, -1);
|
|
526
|
+
const replies = namReplyLines(inner);
|
|
527
|
+
expect(replies.length).toBeGreaterThan(1);
|
|
528
|
+
for (const line of replies) {
|
|
529
|
+
expect(byteLength(line.text)).toBeLessThanOrEqual(MAX_TEXT_BYTES);
|
|
530
|
+
}
|
|
531
|
+
expect(inner[inner.length - 1]?.text).toBe(
|
|
532
|
+
':irc.example.com 366 alice #foo :End of /NAMES list.',
|
|
533
|
+
);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { NICKSERV_NICK, enforceRegisteredNick, nickservReducer } from '../../src/commands/nickserv';
|
|
3
3
|
import { Effect } from '../../src/effects';
|
|
4
4
|
import type { Effect as EffectType } from '../../src/effects';
|
|
5
|
-
import type { ServicesStore } from '../../src/ports';
|
|
5
|
+
import type { ServicesAuthResult, ServicesStore } from '../../src/ports';
|
|
6
6
|
import {
|
|
7
7
|
EmptyMotdProvider,
|
|
8
8
|
FakeClock,
|
|
@@ -41,9 +41,10 @@ function makeCtx(
|
|
|
41
41
|
state: ConnectionState,
|
|
42
42
|
services?: ServicesStore,
|
|
43
43
|
clock = new FakeClock(NOW),
|
|
44
|
+
configOverrides: Partial<ServerConfig> = {},
|
|
44
45
|
): Ctx {
|
|
45
46
|
return buildCtx({
|
|
46
|
-
serverConfig,
|
|
47
|
+
serverConfig: { ...serverConfig, ...configOverrides },
|
|
47
48
|
clock,
|
|
48
49
|
ids: new SequentialIdFactory(),
|
|
49
50
|
motd: EmptyMotdProvider,
|
|
@@ -283,6 +284,185 @@ describe('nickservReducer — IDENTIFY', () => {
|
|
|
283
284
|
});
|
|
284
285
|
});
|
|
285
286
|
|
|
287
|
+
/**
|
|
288
|
+
* InMemoryServicesStore subclass that counts `verifyNick` calls so the
|
|
289
|
+
* identify-freeze tests can assert the frozen attempt is rejected BEFORE
|
|
290
|
+
* scrypt runs (the freeze must short-circuit the verification path).
|
|
291
|
+
*/
|
|
292
|
+
class VerifyCountingStore extends InMemoryServicesStore {
|
|
293
|
+
verifyCalls = 0;
|
|
294
|
+
override verifyNick(nick: string, password: string): ServicesAuthResult {
|
|
295
|
+
this.verifyCalls++;
|
|
296
|
+
return super.verifyNick(nick, password);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
describe('nickservReducer — IDENTIFY freeze (per-account throttle)', () => {
|
|
301
|
+
it('short-circuits the 6th failed IDENTIFY with a freeze notice (no scrypt)', () => {
|
|
302
|
+
const clock = new FakeClock(NOW);
|
|
303
|
+
const services = new VerifyCountingStore({ clock });
|
|
304
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
305
|
+
const conn = makeConn();
|
|
306
|
+
const ctx = makeCtx(conn, services, clock);
|
|
307
|
+
|
|
308
|
+
for (let i = 0; i < 5; i++) {
|
|
309
|
+
nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
|
|
310
|
+
}
|
|
311
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
|
|
312
|
+
|
|
313
|
+
expect(services.verifyCalls).toBe(5);
|
|
314
|
+
expect(sentTexts(out.effects)).toContain(
|
|
315
|
+
':NickServ!NickServ@services NOTICE alice :Nick alice is temporarily frozen due to too many failed identify attempts.',
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('lifts the freeze once the failures age out of the 300s window', () => {
|
|
320
|
+
const clock = new FakeClock(NOW);
|
|
321
|
+
const services = new VerifyCountingStore({ clock });
|
|
322
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
323
|
+
const conn = makeConn();
|
|
324
|
+
const ctx = makeCtx(conn, services, clock);
|
|
325
|
+
|
|
326
|
+
for (let i = 0; i < 5; i++) {
|
|
327
|
+
nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
|
|
328
|
+
}
|
|
329
|
+
// Exactly at the window boundary the failures have aged out.
|
|
330
|
+
clock.advance(300_000);
|
|
331
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
|
|
332
|
+
|
|
333
|
+
expect(services.verifyCalls).toBe(6);
|
|
334
|
+
expect(sentTexts(out.effects)).toContain(
|
|
335
|
+
':NickServ!NickServ@services NOTICE alice :Invalid password.',
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('keeps the freeze while the failures are still inside the window', () => {
|
|
340
|
+
const clock = new FakeClock(NOW);
|
|
341
|
+
const services = new VerifyCountingStore({ clock });
|
|
342
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
343
|
+
const conn = makeConn();
|
|
344
|
+
const ctx = makeCtx(conn, services, clock);
|
|
345
|
+
|
|
346
|
+
for (let i = 0; i < 5; i++) {
|
|
347
|
+
nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
|
|
348
|
+
}
|
|
349
|
+
clock.advance(300_000 - 1);
|
|
350
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
|
|
351
|
+
|
|
352
|
+
expect(services.verifyCalls).toBe(5);
|
|
353
|
+
expect(sentTexts(out.effects)).toContain(
|
|
354
|
+
':NickServ!NickServ@services NOTICE alice :Nick alice is temporarily frozen due to too many failed identify attempts.',
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('delivers a queued owner NOTICE on the next successful identify after the freeze', () => {
|
|
359
|
+
const clock = new FakeClock(NOW);
|
|
360
|
+
const services = new VerifyCountingStore({ clock });
|
|
361
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
362
|
+
const conn = makeConn();
|
|
363
|
+
const ctx = makeCtx(conn, services, clock);
|
|
364
|
+
|
|
365
|
+
for (let i = 0; i < 5; i++) {
|
|
366
|
+
nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
|
|
367
|
+
}
|
|
368
|
+
clock.advance(300_000);
|
|
369
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
|
|
370
|
+
|
|
371
|
+
expect(conn.account).toBe('alice');
|
|
372
|
+
expect(sentTexts(out.effects)).toContain(
|
|
373
|
+
':NickServ!NickServ@services NOTICE alice :Warning: 5 failed identify attempts recorded against your account since your last successful login.',
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
// The notice is delivered once — a second successful identify (fresh
|
|
377
|
+
// connection) does not repeat it.
|
|
378
|
+
const conn2 = makeConn('c2', 'alice');
|
|
379
|
+
const ctx2 = makeCtx(conn2, services, clock);
|
|
380
|
+
const out2 = nickservReducer(conn2, privmsg('IDENTIFY hunter2'), ctx2);
|
|
381
|
+
expect(sentTexts(out2.effects).some((t) => t.includes('Warning:'))).toBe(false);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('delivers the owner NOTICE with singular wording for a single failed identify', () => {
|
|
385
|
+
const clock = new FakeClock(NOW);
|
|
386
|
+
const services = new VerifyCountingStore({ clock });
|
|
387
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
388
|
+
const conn = makeConn();
|
|
389
|
+
const ctx = makeCtx(conn, services, clock);
|
|
390
|
+
|
|
391
|
+
nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
|
|
392
|
+
const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
|
|
393
|
+
|
|
394
|
+
expect(sentTexts(out.effects)).toContain(
|
|
395
|
+
':NickServ!NickServ@services NOTICE alice :Warning: 1 failed identify attempt recorded against your account since your last successful login.',
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('resets the failure counter on a successful identify', () => {
|
|
400
|
+
const clock = new FakeClock(NOW);
|
|
401
|
+
const services = new VerifyCountingStore({ clock });
|
|
402
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
403
|
+
const conn = makeConn();
|
|
404
|
+
const ctx = makeCtx(conn, services, clock);
|
|
405
|
+
|
|
406
|
+
for (let i = 0; i < 3; i++) {
|
|
407
|
+
nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
|
|
408
|
+
}
|
|
409
|
+
nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
|
|
410
|
+
|
|
411
|
+
// Fresh connection (the first is now identified). Without a reset the
|
|
412
|
+
// counter would still hold 3 failures and the account would freeze
|
|
413
|
+
// after only 2 more; with the reset all 5 new attempts must reach
|
|
414
|
+
// scrypt and return the plain invalid-password notice.
|
|
415
|
+
const conn2 = makeConn('c2', 'alice');
|
|
416
|
+
const ctx2 = makeCtx(conn2, services, clock);
|
|
417
|
+
for (let i = 0; i < 5; i++) {
|
|
418
|
+
const out = nickservReducer(conn2, privmsg('IDENTIFY wrongpass'), ctx2);
|
|
419
|
+
expect(sentTexts(out.effects)).toContain(
|
|
420
|
+
':NickServ!NickServ@services NOTICE alice :Invalid password.',
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
expect(services.verifyCalls).toBe(3 + 1 + 5);
|
|
424
|
+
|
|
425
|
+
// The 6th new failure crosses the (reset) threshold → frozen.
|
|
426
|
+
const frozen = nickservReducer(conn2, privmsg('IDENTIFY wrongpass'), ctx2);
|
|
427
|
+
expect(services.verifyCalls).toBe(9);
|
|
428
|
+
expect(sentTexts(frozen.effects)).toContain(
|
|
429
|
+
':NickServ!NickServ@services NOTICE alice :Nick alice is temporarily frozen due to too many failed identify attempts.',
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('honours nickServ.maxIdentifyFailures and identifyFreezeSeconds overrides', () => {
|
|
434
|
+
const clock = new FakeClock(NOW);
|
|
435
|
+
const services = new VerifyCountingStore({ clock });
|
|
436
|
+
services.registerNick('alice', 'hunter2', 'alice@example.com');
|
|
437
|
+
const conn = makeConn();
|
|
438
|
+
const ctx = makeCtx(conn, services, clock, {
|
|
439
|
+
nickServ: { maxIdentifyFailures: 2, identifyFreezeSeconds: 60 },
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
|
|
443
|
+
const second = nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
|
|
444
|
+
expect(services.verifyCalls).toBe(2);
|
|
445
|
+
expect(sentTexts(second.effects)).toContain(
|
|
446
|
+
':NickServ!NickServ@services NOTICE alice :Invalid password.',
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
// Threshold 2 reached → the next attempt is frozen without scrypt.
|
|
450
|
+
const frozen = nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
|
|
451
|
+
expect(services.verifyCalls).toBe(2);
|
|
452
|
+
expect(sentTexts(frozen.effects)).toContain(
|
|
453
|
+
':NickServ!NickServ@services NOTICE alice :Nick alice is temporarily frozen due to too many failed identify attempts.',
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// The configured 60s window ages the failures out sooner than default.
|
|
457
|
+
clock.advance(60_000);
|
|
458
|
+
const after = nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
|
|
459
|
+
expect(services.verifyCalls).toBe(3);
|
|
460
|
+
expect(sentTexts(after.effects)).toContain(
|
|
461
|
+
':NickServ!NickServ@services NOTICE alice :Invalid password.',
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
286
466
|
describe('nickservReducer — ID (alias for IDENTIFY)', () => {
|
|
287
467
|
it('sets +r and account on correct password (ID <password>)', () => {
|
|
288
468
|
const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
|