serverless-ircd 0.3.0 → 0.4.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 +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +174 -18
- package/README.md +63 -30
- package/apps/aws-stack/README.md +2 -2
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +20 -3
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +6 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +35 -2
- package/packages/aws-adapter/src/handlers/index.ts +69 -3
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +6 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +154 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +2 -4
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +111 -46
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +33 -9
- package/packages/cf-adapter/src/index.ts +9 -8
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/worker/main.ts +8 -1
- package/packages/cf-adapter/wrangler.test.toml +8 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +58 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +181 -12
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +38 -1
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +158 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +12 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +400 -3
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +4 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +9 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -14,8 +14,12 @@
|
|
|
14
14
|
* - nick registry: {@link ReserveNickEffect}, {@link ChangeNickEffect},
|
|
15
15
|
* {@link ReleaseNickEffect}
|
|
16
16
|
* - channel ownership: {@link ApplyChannelDeltaEffect}
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
*
|
|
18
|
+
* Lookup-shaped runtime methods (`lookupNick`, `getConnectionInfo`,
|
|
19
|
+
* `getChannelSnapshot`) are consumed directly inside `dispatch`'s
|
|
20
|
+
* broadcast helper and the actor — they are request/response and cannot
|
|
21
|
+
* be modelled as pure fire-and-forget effects, so no Effect variants
|
|
22
|
+
* wrap them.
|
|
19
23
|
*/
|
|
20
24
|
|
|
21
25
|
import type { ChanName, ChannelDelta } from './state/channel.js';
|
|
@@ -71,21 +75,6 @@ export interface ApplyChannelDeltaEffect {
|
|
|
71
75
|
delta: ChannelDelta;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
|
-
export interface LookupNickEffect {
|
|
75
|
-
tag: 'LookupNick';
|
|
76
|
-
nick: Nick;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface GetConnectionInfoEffect {
|
|
80
|
-
tag: 'GetConnectionInfo';
|
|
81
|
-
conn: ConnId;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export interface GetChannelSnapshotEffect {
|
|
85
|
-
tag: 'GetChannelSnapshot';
|
|
86
|
-
chan: ChanName;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
78
|
/**
|
|
90
79
|
* Routes `lines` to whichever connection currently owns `nick`. If the nick
|
|
91
80
|
* is offline, `notFoundLines` (when supplied) is sent back to `sender` —
|
|
@@ -109,9 +98,6 @@ export type Effect =
|
|
|
109
98
|
| ChangeNickEffect
|
|
110
99
|
| ReleaseNickEffect
|
|
111
100
|
| ApplyChannelDeltaEffect
|
|
112
|
-
| LookupNickEffect
|
|
113
|
-
| GetConnectionInfoEffect
|
|
114
|
-
| GetChannelSnapshotEffect
|
|
115
101
|
| SendToNickEffect;
|
|
116
102
|
|
|
117
103
|
/** Literal string tag of every {@link Effect} variant. */
|
|
@@ -167,15 +153,6 @@ export const Effect = {
|
|
|
167
153
|
applyChannelDelta(chan: ChanName, delta: ChannelDelta): Effect {
|
|
168
154
|
return { tag: 'ApplyChannelDelta', chan, delta };
|
|
169
155
|
},
|
|
170
|
-
lookupNick(nick: Nick): Effect {
|
|
171
|
-
return { tag: 'LookupNick', nick };
|
|
172
|
-
},
|
|
173
|
-
getConnectionInfo(conn: ConnId): Effect {
|
|
174
|
-
return { tag: 'GetConnectionInfo', conn };
|
|
175
|
-
},
|
|
176
|
-
getChannelSnapshot(chan: ChanName): Effect {
|
|
177
|
-
return { tag: 'GetChannelSnapshot', chan };
|
|
178
|
-
},
|
|
179
156
|
sendToNick(nick: Nick, sender: ConnId, lines: RawLine[], notFoundLines?: RawLine[]): Effect {
|
|
180
157
|
const e: SendToNickEffect = { tag: 'SendToNick', nick, sender, lines };
|
|
181
158
|
if (notFoundLines !== undefined) e.notFoundLines = notFoundLines;
|
|
@@ -10,6 +10,7 @@ export * from './caps/index.js';
|
|
|
10
10
|
export * from './commands/index.js';
|
|
11
11
|
export * from './effects.js';
|
|
12
12
|
export * from './ports.js';
|
|
13
|
+
export * from './credential-hashing.js';
|
|
13
14
|
export * from './flood-control.js';
|
|
14
15
|
export * from './cloak.js';
|
|
15
16
|
export * from './case-fold.js';
|
|
@@ -57,7 +57,7 @@ export interface IdFactory {
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Production id factory. Generates UUIDv4-shaped strings without relying
|
|
60
|
-
* on `crypto` so the same code runs on Node
|
|
60
|
+
* on `crypto` so the same code runs on Node 24, Cloudflare Workers, and
|
|
61
61
|
* Lambda without per-platform type wrangling. Uniqueness is statistical;
|
|
62
62
|
* for cryptographic IDs, adapters can supply their own {@link IdFactory}.
|
|
63
63
|
*/
|
|
@@ -171,7 +171,8 @@ export const EmptyMotdProvider: MotdProvider = new StaticMotdProvider([]);
|
|
|
171
171
|
*/
|
|
172
172
|
export type SaslPayload =
|
|
173
173
|
| { kind: 'PLAIN'; username: string; password: string }
|
|
174
|
-
| { kind: 'RAW'; data: string }
|
|
174
|
+
| { kind: 'RAW'; data: string }
|
|
175
|
+
| { kind: 'EXTERNAL'; identity: string };
|
|
175
176
|
|
|
176
177
|
/**
|
|
177
178
|
* Outcome of an {@link AccountStore.verify} call: either the canonical
|
|
@@ -202,6 +203,12 @@ export interface AccountStore {
|
|
|
202
203
|
export interface SaslAccountCredential {
|
|
203
204
|
username: string;
|
|
204
205
|
password: string;
|
|
206
|
+
/**
|
|
207
|
+
* Optional mTLS client-certificate subject (e.g. `CN=alice` or a
|
|
208
|
+
* fingerprint hash). When set, SASL `EXTERNAL` authentications that
|
|
209
|
+
* present this subject are mapped to {@link username} as the account.
|
|
210
|
+
*/
|
|
211
|
+
certSubject?: string;
|
|
205
212
|
}
|
|
206
213
|
|
|
207
214
|
/**
|
|
@@ -221,23 +228,37 @@ export interface SaslAccountCredential {
|
|
|
221
228
|
*/
|
|
222
229
|
export class InMemoryAccountStore implements AccountStore {
|
|
223
230
|
private readonly creds: ReadonlyMap<string, string>;
|
|
231
|
+
private readonly certMap: ReadonlyMap<string, string>;
|
|
224
232
|
|
|
225
233
|
constructor(credentials: ReadonlyArray<SaslAccountCredential>) {
|
|
226
234
|
this.creds = new Map(credentials.map((c) => [c.username, c.password]));
|
|
235
|
+
this.certMap = new Map(
|
|
236
|
+
credentials
|
|
237
|
+
.filter((c) => c.certSubject !== undefined)
|
|
238
|
+
.map((c) => [c.certSubject as string, c.username]),
|
|
239
|
+
);
|
|
227
240
|
}
|
|
228
241
|
|
|
229
242
|
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
243
|
+
const m = mech.toUpperCase();
|
|
244
|
+
if (m === 'PLAIN' && payload.kind === 'PLAIN') {
|
|
245
|
+
const expected = this.creds.get(payload.username);
|
|
246
|
+
if (expected === undefined) {
|
|
247
|
+
return { ok: false, reason: 'invalid credentials' };
|
|
248
|
+
}
|
|
249
|
+
if (!constantTimeEquals(payload.password, expected)) {
|
|
250
|
+
return { ok: false, reason: 'invalid credentials' };
|
|
251
|
+
}
|
|
252
|
+
return { ok: true, account: payload.username };
|
|
236
253
|
}
|
|
237
|
-
if (
|
|
238
|
-
|
|
254
|
+
if (m === 'EXTERNAL' && payload.kind === 'EXTERNAL') {
|
|
255
|
+
const account = this.certMap.get(payload.identity);
|
|
256
|
+
if (account === undefined) {
|
|
257
|
+
return { ok: false, reason: 'untrusted certificate' };
|
|
258
|
+
}
|
|
259
|
+
return { ok: true, account };
|
|
239
260
|
}
|
|
240
|
-
return { ok:
|
|
261
|
+
return { ok: false, reason: `unsupported mechanism: ${mech}` };
|
|
241
262
|
}
|
|
242
263
|
}
|
|
243
264
|
|
|
@@ -256,6 +277,52 @@ function constantTimeEquals(a: string, b: string): boolean {
|
|
|
256
277
|
return diff === 0;
|
|
257
278
|
}
|
|
258
279
|
|
|
280
|
+
// ============================================================================
|
|
281
|
+
// mTLS identity (SASL EXTERNAL)
|
|
282
|
+
// ============================================================================
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Port that resolves the verified client-certificate identity for a
|
|
286
|
+
* connection.
|
|
287
|
+
*
|
|
288
|
+
* At the edge (CF API Shield mTLS, AWS API Gateway custom-domain mTLS) the
|
|
289
|
+
* platform terminates TLS, validates the client certificate against the
|
|
290
|
+
* configured trust store, and surfaces the certificate subject to the
|
|
291
|
+
* Worker/Lambda. The adapter captures this at connection-admission time
|
|
292
|
+
* and stores it keyed by connection ID.
|
|
293
|
+
*
|
|
294
|
+
* The method is **synchronous** — adapters pre-populate the identity at
|
|
295
|
+
* admission time (before the first reducer runs), so the SASL EXTERNAL
|
|
296
|
+
* reducer can look it up without awaiting. When no client certificate was
|
|
297
|
+
* presented (or mTLS is not configured), `getIdentity` returns `undefined`
|
|
298
|
+
* and EXTERNAL authentication fails with `904`.
|
|
299
|
+
*/
|
|
300
|
+
export interface MtlsIdentityProvider {
|
|
301
|
+
/**
|
|
302
|
+
* @returns the verified client-cert subject for `connId`, or `undefined`
|
|
303
|
+
* when no trusted certificate was presented.
|
|
304
|
+
*/
|
|
305
|
+
getIdentity(connId: string): string | undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Reference in-memory {@link MtlsIdentityProvider} backed by a static map
|
|
310
|
+
* of connection IDs to certificate subjects. Useful for tests and the
|
|
311
|
+
* local-cli adapter; cloud adapters replace this with a lookup keyed by
|
|
312
|
+
* the platform's connection identifier.
|
|
313
|
+
*/
|
|
314
|
+
export class InMemoryMtlsIdentityProvider implements MtlsIdentityProvider {
|
|
315
|
+
private readonly identities: ReadonlyMap<string, string>;
|
|
316
|
+
|
|
317
|
+
constructor(entries: ReadonlyArray<{ connId: string; identity: string }>) {
|
|
318
|
+
this.identities = new Map(entries.map((e) => [e.connId, e.identity]));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
getIdentity(connId: string): string | undefined {
|
|
322
|
+
return this.identities.get(connId);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
259
326
|
// ============================================================================
|
|
260
327
|
// Chat-history persistence (IRCv3 draft/chathistory)
|
|
261
328
|
// ============================================================================
|
|
@@ -501,6 +568,108 @@ function tail<T>(arr: readonly T[], n: number): T[] {
|
|
|
501
568
|
return arr.slice(start);
|
|
502
569
|
}
|
|
503
570
|
|
|
571
|
+
// ============================================================================
|
|
572
|
+
// Nick history (WHOWAS)
|
|
573
|
+
// ============================================================================
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* One persisted sign-off record for a nick, written by the QUIT and
|
|
577
|
+
* post-registration NICK-change reducers and replayed by `WHOWAS`.
|
|
578
|
+
*
|
|
579
|
+
* `nick` preserves the original case the client used; `connId` is the
|
|
580
|
+
* connection that owned the nick at sign-off. `username` / `hostname` /
|
|
581
|
+
* `realname` mirror the {@link ConnectionState} fields at sign-off time
|
|
582
|
+
* (optional because a never-fully-registered connection may still quit with
|
|
583
|
+
* a nick). `signoffTime` is epoch milliseconds from the injected
|
|
584
|
+
* {@link Clock}.
|
|
585
|
+
*/
|
|
586
|
+
export interface NickHistoryEntry {
|
|
587
|
+
nick: string;
|
|
588
|
+
connId: string;
|
|
589
|
+
username?: string;
|
|
590
|
+
hostname?: string;
|
|
591
|
+
realname?: string;
|
|
592
|
+
signoffTime: number;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Persistence port for the IRC `WHOWAS` command.
|
|
597
|
+
*
|
|
598
|
+
* Mirrors the {@link MessageStore} pattern: the port is **synchronous**
|
|
599
|
+
* (reducers must stay pure and free of IO) and injected via {@link Ctx}.
|
|
600
|
+
* Adapters that need async storage (D1, DynamoDB) pre-load the relevant
|
|
601
|
+
* slice into a synchronously-readable store; Phase 8 ships the in-memory
|
|
602
|
+
* reference implementation only.
|
|
603
|
+
*
|
|
604
|
+
* `query` always returns matches most-recent-first (the order `WHOWAS`
|
|
605
|
+
* emits `314 RPL_WHOWASUSER` lines in), capped at `count`.
|
|
606
|
+
*/
|
|
607
|
+
export interface NickHistoryStore {
|
|
608
|
+
/** Records a sign-off entry for a nick (TTL eviction is impl-defined). */
|
|
609
|
+
record(entry: NickHistoryEntry): void;
|
|
610
|
+
/** Returns up to `count` most-recent entries for `nick` (case-insensitive). */
|
|
611
|
+
query(nick: string, count: number): NickHistoryEntry[];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Default retention window for nick-history entries: 24 hours. */
|
|
615
|
+
const DEFAULT_NICK_HISTORY_MAX_AGE = 24 * 60 * 60 * 1000;
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Reference in-memory {@link NickHistoryStore} backed by a per-nick
|
|
619
|
+
* chronological array with a configurable TTL.
|
|
620
|
+
*
|
|
621
|
+
* Used by the local CLI and by tests. Each nick keeps its sign-off records
|
|
622
|
+
* in arrival order; entries older than `maxAge` (measured against the
|
|
623
|
+
* injected {@link Clock}) are dropped lazily on `record` and `query` so the
|
|
624
|
+
* per-nick buffers do not grow unbounded.
|
|
625
|
+
*
|
|
626
|
+
* Nicks are keyed via {@link caseFold} (`rfc1459`) so lookups are
|
|
627
|
+
* case-insensitive, matching the IRC nick-comparison semantics advertised
|
|
628
|
+
* via `005 RPL_ISUPPORT CASEMAPPING=rfc1459`.
|
|
629
|
+
*/
|
|
630
|
+
export class InMemoryNickHistoryStore implements NickHistoryStore {
|
|
631
|
+
private readonly byNick = new Map<string, NickHistoryEntry[]>();
|
|
632
|
+
private readonly clock: Clock;
|
|
633
|
+
private readonly maxAge: number;
|
|
634
|
+
|
|
635
|
+
constructor(clock: Clock, opts?: { maxAge?: number }) {
|
|
636
|
+
this.clock = clock;
|
|
637
|
+
this.maxAge = opts?.maxAge ?? DEFAULT_NICK_HISTORY_MAX_AGE;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
record(entry: NickHistoryEntry): void {
|
|
641
|
+
const key = caseFold('rfc1459', entry.nick);
|
|
642
|
+
let arr = this.byNick.get(key);
|
|
643
|
+
if (arr === undefined) {
|
|
644
|
+
arr = [];
|
|
645
|
+
this.byNick.set(key, arr);
|
|
646
|
+
}
|
|
647
|
+
this.purgeExpired(arr);
|
|
648
|
+
arr.push(entry);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
query(nick: string, count: number): NickHistoryEntry[] {
|
|
652
|
+
if (count <= 0) return [];
|
|
653
|
+
const arr = this.byNick.get(caseFold('rfc1459', nick));
|
|
654
|
+
if (arr === undefined) return [];
|
|
655
|
+
this.purgeExpired(arr);
|
|
656
|
+
if (arr.length === 0) return [];
|
|
657
|
+
return arr.slice(Math.max(0, arr.length - count)).reverse();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Drops entries whose `signoffTime` is older than `now - maxAge`, in place.
|
|
662
|
+
* Entries arrive in chronological order (append-only), so the first alive
|
|
663
|
+
* entry marks the boundary; everything before it is expired.
|
|
664
|
+
*/
|
|
665
|
+
private purgeExpired(arr: NickHistoryEntry[]): void {
|
|
666
|
+
const cutoff = this.clock.now() - this.maxAge;
|
|
667
|
+
let firstAlive = arr.findIndex((e) => e.signoffTime >= cutoff);
|
|
668
|
+
if (firstAlive === -1) firstAlive = arr.length;
|
|
669
|
+
arr.splice(0, firstAlive);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
504
673
|
// ============================================================================
|
|
505
674
|
// Observability — structured logger
|
|
506
675
|
// ============================================================================
|
|
@@ -854,7 +1023,7 @@ export class ConsoleLogger implements Logger {
|
|
|
854
1023
|
|
|
855
1024
|
/** Returns the global `console` as a {@link ConsoleLoggerSinks}. */
|
|
856
1025
|
function globalThisConsoleSinks(): ConsoleLoggerSinks {
|
|
857
|
-
// `globalThis.console` is universal across Node
|
|
1026
|
+
// `globalThis.console` is universal across Node 24, Workers, and Lambda.
|
|
858
1027
|
// Reference it lazily so tests that stub the logger do not need to
|
|
859
1028
|
// replace the global.
|
|
860
1029
|
const c = (globalThis as { console?: GlobalConsole }).console;
|
|
@@ -13,7 +13,7 @@ export const Numerics = {
|
|
|
13
13
|
RPL_ISUPPORT: 5,
|
|
14
14
|
RPL_BOUNCE: 10,
|
|
15
15
|
|
|
16
|
-
// Capability / stats admin
|
|
16
|
+
// Capability / stats admin / version
|
|
17
17
|
RPL_ADMINME: 256,
|
|
18
18
|
|
|
19
19
|
// Away / userhost / ison
|
|
@@ -23,7 +23,10 @@ export const Numerics = {
|
|
|
23
23
|
RPL_UNAWAY: 305,
|
|
24
24
|
RPL_NOWAWAY: 306,
|
|
25
25
|
|
|
26
|
-
//
|
|
26
|
+
// Version
|
|
27
|
+
RPL_VERSION: 351,
|
|
28
|
+
|
|
29
|
+
// WHOIS / WHOWAS
|
|
27
30
|
RPL_WHOISUSER: 311,
|
|
28
31
|
RPL_WHOISSERVER: 312,
|
|
29
32
|
RPL_WHOISOPERATOR: 313,
|
|
@@ -31,6 +34,7 @@ export const Numerics = {
|
|
|
31
34
|
RPL_ENDOFWHOIS: 318,
|
|
32
35
|
RPL_WHOISCHANNELS: 319,
|
|
33
36
|
RPL_WHOISACCOUNT: 330,
|
|
37
|
+
RPL_WHOWASUSER: 314,
|
|
34
38
|
|
|
35
39
|
// LIST
|
|
36
40
|
RPL_LISTSTART: 321,
|
|
@@ -57,7 +61,8 @@ export const Numerics = {
|
|
|
57
61
|
RPL_NAMREPLY: 353,
|
|
58
62
|
RPL_ENDOFNAMES: 366,
|
|
59
63
|
|
|
60
|
-
// Links / ban / whowas
|
|
64
|
+
// Links / ban / whowas. RPL_LINKS/RPL_ENDOFLINKS retained for the deferred
|
|
65
|
+
// LINKS verb (S2S server list — PLAN non-goal; see actor default-route triage).
|
|
61
66
|
RPL_LINKS: 364,
|
|
62
67
|
RPL_ENDOFLINKS: 365,
|
|
63
68
|
RPL_BANLIST: 367,
|
|
@@ -71,7 +76,8 @@ export const Numerics = {
|
|
|
71
76
|
RPL_MOTDSTART: 375,
|
|
72
77
|
RPL_ENDOFMOTD: 376,
|
|
73
78
|
|
|
74
|
-
// Oper
|
|
79
|
+
// Oper. RPL_REHASHING retained for the deferred REHASH verb (no runtime
|
|
80
|
+
// config-reload path in serverless; see actor default-route triage).
|
|
75
81
|
RPL_YOUREOPER: 381,
|
|
76
82
|
RPL_REHASHING: 382,
|
|
77
83
|
RPL_YOURESERVICE: 383,
|
|
@@ -96,7 +102,7 @@ export const Numerics = {
|
|
|
96
102
|
ERR_TOOMANYCHANNELS: 405,
|
|
97
103
|
ERR_WASNOSUCHNICK: 406,
|
|
98
104
|
ERR_TOOMANYTARGETS: 407,
|
|
99
|
-
ERR_NOSUCHSERVICE: 408,
|
|
105
|
+
ERR_NOSUCHSERVICE: 408, // retained: deferred SERVICE verb (RFC 2812 obsolete)
|
|
100
106
|
ERR_INVALIDCAPCMD: 410,
|
|
101
107
|
ERR_NORECIPIENT: 411,
|
|
102
108
|
ERR_NOTEXTTOSEND: 412,
|
|
@@ -115,8 +121,8 @@ export const Numerics = {
|
|
|
115
121
|
ERR_NOTONCHANNEL: 442,
|
|
116
122
|
ERR_USERONCHANNEL: 443,
|
|
117
123
|
ERR_NOLOGIN: 444,
|
|
118
|
-
ERR_SUMMONDISABLED: 445,
|
|
119
|
-
ERR_USERSDISABLED: 446,
|
|
124
|
+
ERR_SUMMONDISABLED: 445, // retained: deferred SUMMON verb (RFC 2812 obsolete)
|
|
125
|
+
ERR_USERSDISABLED: 446, // retained: deferred USERS verb (RFC 2812 obsolete)
|
|
120
126
|
ERR_NOTREGISTERED: 451,
|
|
121
127
|
ERR_NEEDMOREPARAMS: 461,
|
|
122
128
|
ERR_ALREADYREGISTRED: 462,
|
|
@@ -134,7 +140,7 @@ export const Numerics = {
|
|
|
134
140
|
ERR_BANLISTFULL: 478,
|
|
135
141
|
ERR_NOPRIVILEGES: 481,
|
|
136
142
|
ERR_CHANOPRIVSNEEDED: 482,
|
|
137
|
-
ERR_CANTKILLSERVER: 483,
|
|
143
|
+
ERR_CANTKILLSERVER: 483, // retained: deferred SQUIT verb (S2S, PLAN non-goal)
|
|
138
144
|
ERR_NOOPERHOST: 491,
|
|
139
145
|
ERR_UMODEUNKNOWNFLAG: 501,
|
|
140
146
|
ERR_USERSDONTMATCH: 502,
|
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Effect } from './effects.js';
|
|
10
10
|
import type { FloodControlConfig } from './flood-control.js';
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
AccountStore,
|
|
13
|
+
Clock,
|
|
14
|
+
IdFactory,
|
|
15
|
+
MessageStore,
|
|
16
|
+
MotdProvider,
|
|
17
|
+
MtlsIdentityProvider,
|
|
18
|
+
NickHistoryStore,
|
|
19
|
+
} from './ports.js';
|
|
12
20
|
import type { IrcMessage } from './protocol/messages.js';
|
|
13
21
|
import type { ConnectionState } from './state/connection.js';
|
|
14
22
|
|
|
@@ -46,6 +54,18 @@ export interface OperCred {
|
|
|
46
54
|
export interface ServerConfig {
|
|
47
55
|
serverName: string;
|
|
48
56
|
networkName: string;
|
|
57
|
+
/**
|
|
58
|
+
* Server version surfaced in `002`/`004`/`351`/`371`. When undefined the
|
|
59
|
+
* reducer falls back to {@link DEFAULT_SERVER_VERSION} (the irc-core
|
|
60
|
+
* package version); adapters inject the deploy-time version via config.
|
|
61
|
+
*/
|
|
62
|
+
readonly serverVersion?: string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* "Created" text for `003 RPL_CREATED`. A string is used verbatim; a
|
|
65
|
+
* number is treated as epoch-ms and formatted as a UTC timestamp. When
|
|
66
|
+
* undefined the reducer falls back to {@link DEFAULT_CREATED_TEXT}.
|
|
67
|
+
*/
|
|
68
|
+
readonly createdAt?: string | number | undefined;
|
|
49
69
|
/**
|
|
50
70
|
* Server password gate. Empty string or undefined disables enforcement.
|
|
51
71
|
* When set, registration refuses to complete until the connection
|
|
@@ -115,6 +135,12 @@ export interface Ctx {
|
|
|
115
135
|
readonly motd: MotdProvider;
|
|
116
136
|
/** SASL account verification source (absent when no accounts are configured). */
|
|
117
137
|
readonly accounts?: AccountStore;
|
|
138
|
+
/**
|
|
139
|
+
* mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
|
|
140
|
+
* is not configured). When present, `AUTHENTICATE EXTERNAL` resolves the
|
|
141
|
+
* connection's verified certificate subject through this port.
|
|
142
|
+
*/
|
|
143
|
+
readonly mtlsIdentity?: MtlsIdentityProvider;
|
|
118
144
|
/**
|
|
119
145
|
* Chat-history persistence source (absent when chathistory playback is
|
|
120
146
|
* disabled for this deployment). When present, the PRIVMSG/NOTICE channel
|
|
@@ -122,6 +148,13 @@ export interface Ctx {
|
|
|
122
148
|
* cap-enabled joiners.
|
|
123
149
|
*/
|
|
124
150
|
readonly messages?: MessageStore;
|
|
151
|
+
/**
|
|
152
|
+
* Nick-history source for the `WHOWAS` command (absent when WHOWAS
|
|
153
|
+
* history retention is disabled for this deployment). When present, the
|
|
154
|
+
* QUIT and post-registration NICK-change reducers record sign-off
|
|
155
|
+
* entries and the WHOWAS reducer replays them.
|
|
156
|
+
*/
|
|
157
|
+
readonly history?: NickHistoryStore;
|
|
125
158
|
/** The connection invoking the command. Reducers mutate this freely. */
|
|
126
159
|
readonly connection: ConnectionState;
|
|
127
160
|
/** Convenience: `connection.id`. */
|
|
@@ -147,7 +180,9 @@ export interface BuildCtxOptions {
|
|
|
147
180
|
ids: IdFactory;
|
|
148
181
|
motd: MotdProvider;
|
|
149
182
|
accounts?: AccountStore;
|
|
183
|
+
mtlsIdentity?: MtlsIdentityProvider;
|
|
150
184
|
messages?: MessageStore;
|
|
185
|
+
history?: NickHistoryStore;
|
|
151
186
|
connection: ConnectionState;
|
|
152
187
|
}
|
|
153
188
|
|
|
@@ -163,7 +198,9 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
|
|
|
163
198
|
connection: opts.connection,
|
|
164
199
|
connId: opts.connection.id,
|
|
165
200
|
...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
|
|
201
|
+
...(opts.mtlsIdentity !== undefined ? { mtlsIdentity: opts.mtlsIdentity } : {}),
|
|
166
202
|
...(opts.messages !== undefined ? { messages: opts.messages } : {}),
|
|
203
|
+
...(opts.history !== undefined ? { history: opts.history } : {}),
|
|
167
204
|
};
|
|
168
205
|
return ctx;
|
|
169
206
|
}
|
|
@@ -65,9 +65,9 @@ describe('InMemoryAccountStore — PLAIN', () => {
|
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
describe('InMemoryAccountStore — mechanism handling', () => {
|
|
68
|
-
it('rejects
|
|
68
|
+
it('rejects an unsupported mechanism with a failure result', () => {
|
|
69
69
|
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
70
|
-
const out = store.verify('
|
|
70
|
+
const out = store.verify('CRAM-MD5', { kind: 'RAW', data: 'whatever' });
|
|
71
71
|
expect(out.ok).toBe(false);
|
|
72
72
|
});
|
|
73
73
|
|
|
@@ -78,6 +78,49 @@ describe('InMemoryAccountStore — mechanism handling', () => {
|
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
describe('InMemoryAccountStore — EXTERNAL (mTLS)', () => {
|
|
82
|
+
it('maps a known cert subject to its account', () => {
|
|
83
|
+
const store = new InMemoryAccountStore([
|
|
84
|
+
{ username: 'alice', password: 'secret', certSubject: 'CN=alice' },
|
|
85
|
+
]);
|
|
86
|
+
const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=alice' });
|
|
87
|
+
expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('rejects an unknown cert subject', () => {
|
|
91
|
+
const store = new InMemoryAccountStore([
|
|
92
|
+
{ username: 'alice', password: 'secret', certSubject: 'CN=alice' },
|
|
93
|
+
]);
|
|
94
|
+
const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=evil' });
|
|
95
|
+
expect(out.ok).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects EXTERNAL when no cert subjects are configured', () => {
|
|
99
|
+
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
100
|
+
const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=alice' });
|
|
101
|
+
expect(out.ok).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('treats the mechanism name case-insensitively for EXTERNAL', () => {
|
|
105
|
+
const store = new InMemoryAccountStore([
|
|
106
|
+
{ username: 'alice', password: 'secret', certSubject: 'CN=alice' },
|
|
107
|
+
]);
|
|
108
|
+
const out = store.verify('external', { kind: 'EXTERNAL', identity: 'CN=alice' });
|
|
109
|
+
expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('maps multiple accounts with distinct cert subjects', () => {
|
|
113
|
+
const store = new InMemoryAccountStore([
|
|
114
|
+
{ username: 'alice', password: 'a', certSubject: 'CN=alice' },
|
|
115
|
+
{ username: 'bob', password: 'b', certSubject: 'CN=bob' },
|
|
116
|
+
]);
|
|
117
|
+
expect(store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=bob' })).toEqual<SaslResult>({
|
|
118
|
+
ok: true,
|
|
119
|
+
account: 'bob',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
81
124
|
describe('InMemoryAccountStore — failure reason', () => {
|
|
82
125
|
it('returns a human-readable reason on failure', () => {
|
|
83
126
|
const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
|
|
@@ -31,10 +31,10 @@ describe('SUPPORTED_CAPABILITIES', () => {
|
|
|
31
31
|
);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
it('declares the sasl value as the
|
|
34
|
+
it('declares the sasl value as the base mechanism list (PLAIN only by default)', () => {
|
|
35
35
|
const sasl = SUPPORTED_CAPABILITIES.find((c) => c.name === 'sasl');
|
|
36
36
|
expect(sasl).toBeDefined();
|
|
37
|
-
expect(sasl?.value).toBe('PLAIN
|
|
37
|
+
expect(sasl?.value).toBe('PLAIN');
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
it('does not attach a value to caps that have no parameter', () => {
|
|
@@ -107,7 +107,8 @@ describe('getLsString', () => {
|
|
|
107
107
|
});
|
|
108
108
|
|
|
109
109
|
it('contains the sasl token with its value', () => {
|
|
110
|
-
expect(getLsString()).toContain('sasl=PLAIN
|
|
110
|
+
expect(getLsString()).toContain('sasl=PLAIN');
|
|
111
|
+
expect(getLsString()).not.toContain('sasl=PLAIN,EXTERNAL');
|
|
111
112
|
});
|
|
112
113
|
|
|
113
114
|
it('contains server-time as a bare name', () => {
|
|
@@ -4,6 +4,7 @@ import { capReducer } from '../../src/commands/cap';
|
|
|
4
4
|
import { userReducer } from '../../src/commands/registration';
|
|
5
5
|
import { Effect } from '../../src/effects';
|
|
6
6
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
7
|
+
import type { MtlsIdentityProvider } from '../../src/ports';
|
|
7
8
|
import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
|
|
8
9
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
9
10
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
@@ -20,13 +21,18 @@ const serverConfig: ServerConfig = {
|
|
|
20
21
|
quitMessage: 'Client Quit',
|
|
21
22
|
};
|
|
22
23
|
|
|
23
|
-
function makeCtx(
|
|
24
|
+
function makeCtx(
|
|
25
|
+
state: ConnectionState,
|
|
26
|
+
cfg: ServerConfig = serverConfig,
|
|
27
|
+
mtlsIdentity?: MtlsIdentityProvider,
|
|
28
|
+
): Ctx {
|
|
24
29
|
return buildCtx({
|
|
25
30
|
serverConfig: cfg,
|
|
26
31
|
clock: new FakeClock(1_000),
|
|
27
32
|
ids: new SequentialIdFactory(),
|
|
28
33
|
motd: EmptyMotdProvider,
|
|
29
34
|
connection: state,
|
|
35
|
+
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
30
36
|
});
|
|
31
37
|
}
|
|
32
38
|
|
|
@@ -110,6 +116,32 @@ describe('capReducer — LS', () => {
|
|
|
110
116
|
}
|
|
111
117
|
});
|
|
112
118
|
|
|
119
|
+
it('advertises sasl=PLAIN when no mTLS provider is configured', () => {
|
|
120
|
+
const state = makeState();
|
|
121
|
+
const ctx = makeCtx(state);
|
|
122
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
123
|
+
const send = out.effects[0];
|
|
124
|
+
expect(send).toBeDefined();
|
|
125
|
+
if (send?.tag === 'Send') {
|
|
126
|
+
const body = send.lines[0]?.text ?? '';
|
|
127
|
+
expect(body).toContain('sasl=PLAIN');
|
|
128
|
+
expect(body).not.toContain('sasl=PLAIN,EXTERNAL');
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('advertises sasl=PLAIN,EXTERNAL when an mTLS provider is configured', () => {
|
|
133
|
+
const state = makeState();
|
|
134
|
+
const provider: MtlsIdentityProvider = { getIdentity: () => 'CN=alice' };
|
|
135
|
+
const ctx = makeCtx(state, serverConfig, provider);
|
|
136
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
137
|
+
const send = out.effects[0];
|
|
138
|
+
expect(send).toBeDefined();
|
|
139
|
+
if (send?.tag === 'Send') {
|
|
140
|
+
const body = send.lines[0]?.text ?? '';
|
|
141
|
+
expect(body).toContain('sasl=PLAIN,EXTERNAL');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
113
145
|
it('marks the connection as in CAP negotiation so the welcome is deferred', () => {
|
|
114
146
|
const state = makeState();
|
|
115
147
|
const ctx = makeCtx(state);
|