serverless-ircd 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -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 +177 -52
- 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 +267 -92
- 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/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- 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 +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- 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 +20 -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 +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -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 +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/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 +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/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/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- 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
|
@@ -413,6 +413,31 @@ function handleList(
|
|
|
413
413
|
// vhost application helpers
|
|
414
414
|
// ============================================================================
|
|
415
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Consults the bound {@link ServicesStore} for an assigned vhost for `account`
|
|
418
|
+
* and, when one is present, applies it to {@link ConnectionState} via
|
|
419
|
+
* {@link applyVhost}. This is the shared login-time hook invoked by every
|
|
420
|
+
* account-success path (SASL PLAIN, PASS-auth via {@link applyAccountSuccess},
|
|
421
|
+
* and NickServ `IDENTIFY` via {@link applyIdentify}) so a reconnecting user
|
|
422
|
+
* with an assigned vhost does not have to re-run `HostServ ON` every session.
|
|
423
|
+
*
|
|
424
|
+
* No-op (no effects emitted, no state change) when no vhost is recorded. The
|
|
425
|
+
* CHGHOST fanout appended to `effects` is empty at SASL / PASS-auth time
|
|
426
|
+
* (registration has not completed and/or no channels are joined yet) but fires
|
|
427
|
+
* for a post-registration NickServ `IDENTIFY` when the user is already in
|
|
428
|
+
* channels — reusing {@link applyVhost} so the wire shape matches `HostServ ON`.
|
|
429
|
+
*/
|
|
430
|
+
export function applyAssignedVhost(
|
|
431
|
+
state: ConnectionState,
|
|
432
|
+
services: ServicesStore,
|
|
433
|
+
account: string,
|
|
434
|
+
effects: EffectType[],
|
|
435
|
+
): void {
|
|
436
|
+
const vhost = services.getVhost(account);
|
|
437
|
+
if (vhost === undefined) return;
|
|
438
|
+
applyVhost(state, vhost, effects);
|
|
439
|
+
}
|
|
440
|
+
|
|
416
441
|
/**
|
|
417
442
|
* Applies `vhost` to {@link ConnectionState.host}, records the prior host
|
|
418
443
|
* on {@link ConnectionState.preVhostHost}, flips
|
|
@@ -421,7 +446,7 @@ function handleList(
|
|
|
421
446
|
* {@link emitChghost} helper so the wire shape matches every other
|
|
422
447
|
* user/host mutation.
|
|
423
448
|
*/
|
|
424
|
-
function applyVhost(state: ConnectionState, vhost: string, effects: EffectType[]): void {
|
|
449
|
+
export function applyVhost(state: ConnectionState, vhost: string, effects: EffectType[]): void {
|
|
425
450
|
const oldUser = state.user ?? '';
|
|
426
451
|
const oldHost = state.host ?? '';
|
|
427
452
|
state.preVhostHost = oldHost;
|
|
@@ -457,13 +482,23 @@ function notice(state: ConnectionState, text: string): EffectType {
|
|
|
457
482
|
return Effect.send(state.id, [servicesNotice(HOSTSERV_NICK, state, text)]);
|
|
458
483
|
}
|
|
459
484
|
|
|
485
|
+
/** User-visible HostServ subcommands (shown to every caller). */
|
|
486
|
+
const USER_COMMANDS = 'ON, OFF, REQUEST';
|
|
487
|
+
|
|
488
|
+
/** Oper-only HostServ subcommands, appended to {@link USER_COMMANDS} for opers. */
|
|
489
|
+
const OPER_COMMANDS = 'SET, APPROVE, REJECT, LIST';
|
|
490
|
+
|
|
460
491
|
/** Builds a help `:HostServ!HostServ@services NOTICE <nick> :…` line. */
|
|
461
492
|
function helpNotice(state: ConnectionState): EffectType {
|
|
462
|
-
|
|
493
|
+
const commands =
|
|
494
|
+
state.userModes.oper === true ? `${USER_COMMANDS}, ${OPER_COMMANDS}` : USER_COMMANDS;
|
|
495
|
+
return notice(state, `Available commands: ${commands}`);
|
|
463
496
|
}
|
|
464
497
|
|
|
465
498
|
function unknownNotice(state: ConnectionState): EffectType {
|
|
466
|
-
|
|
499
|
+
const commands =
|
|
500
|
+
state.userModes.oper === true ? `${USER_COMMANDS}, ${OPER_COMMANDS}` : USER_COMMANDS;
|
|
501
|
+
return notice(state, `Unknown command. Available: ${commands}`);
|
|
467
502
|
}
|
|
468
503
|
|
|
469
504
|
/**
|
|
@@ -27,6 +27,7 @@ import type { ConnectionState } from '../state/connection.js';
|
|
|
27
27
|
import type { Ctx, Reducer } from '../types.js';
|
|
28
28
|
import { CHANSERV_HOSTMASK, DEFAULT_CHANNEL_LEVELS } from './chanserv.js';
|
|
29
29
|
import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
|
|
30
|
+
import { topicNumerics } from './topic.js';
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* RFC 1459/2812 channel-name grammar. The first character must be a channel
|
|
@@ -265,16 +266,27 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
265
266
|
}
|
|
266
267
|
}
|
|
267
268
|
|
|
268
|
-
// All checks passed: add to roster. First joiner gets op per RFC 2812
|
|
269
|
+
// All checks passed: add to roster. First joiner gets op per RFC 2812,
|
|
270
|
+
// unless the channel is ChanServ-registered — in that case the first
|
|
271
|
+
// joiner is opped only when they are the founder or meet the AUTOOP
|
|
272
|
+
// level (computeChanservAutoPrefix), so a random user reconnecting
|
|
273
|
+
// first after a roster-wiping deploy cannot seize op in a channel they
|
|
274
|
+
// do not own. The real founder / access-list users who join later are
|
|
275
|
+
// opped by the same access check on the non-empty path.
|
|
269
276
|
const wasEmpty = state.members.size === 0;
|
|
270
|
-
// ChanServ ACCESS auto-prefix:
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
const
|
|
277
|
+
// ChanServ ACCESS auto-prefix: always look up the joiner's access level
|
|
278
|
+
// so the empty-channel gating above and the non-empty case share one
|
|
279
|
+
// code path. `autoPrefix` is non-null only when ChanServ could resolve
|
|
280
|
+
// the joiner's access (services bound, channel registered, joiner
|
|
281
|
+
// identified); it carries {op:false,voice:false} when the joiner is on
|
|
282
|
+
// no list, and null when ChanServ is not authoritative for this join
|
|
283
|
+
// (services unbound / channel unregistered / joiner unidentified).
|
|
284
|
+
const autoPrefix = computeChanservAutoPrefix(ctx, state.nameLower);
|
|
285
|
+
const channelRegistered = ctx.services?.getChannel(state.nameLower) !== undefined;
|
|
286
|
+
// RFC 2812 first-joiner op applies only when the channel is NOT
|
|
287
|
+
// registered. A registered channel's first joiner gets op solely via
|
|
288
|
+
// the access check (founder / AUTOOP), never by the empty-channel rule.
|
|
289
|
+
const grantOp = (wasEmpty && !channelRegistered) || (autoPrefix?.op ?? false);
|
|
278
290
|
const grantVoice = autoPrefix?.voice ?? false;
|
|
279
291
|
const joinerNick = ctx.connection.nick ?? '?';
|
|
280
292
|
state.members.set(ctx.connId, {
|
|
@@ -288,13 +300,12 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
288
300
|
// before this join and services have a persisted topic snapshot for a
|
|
289
301
|
// keep-topic-enabled registration, restore the topic onto the
|
|
290
302
|
// authoritative ChannelState. The 332/333 numerics are emitted to the
|
|
291
|
-
// joiner below alongside the NAMES list.
|
|
292
|
-
let restoredTopic: ChannelTopic | undefined;
|
|
303
|
+
// joiner below alongside the NAMES list, keyed off `state.topic`.
|
|
293
304
|
if (wasEmpty) {
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
state.topic =
|
|
297
|
-
effects.push(Effect.applyChannelDelta(state.name, { topic:
|
|
305
|
+
const restored = restoreKeepTopic(ctx, state.nameLower);
|
|
306
|
+
if (restored !== undefined) {
|
|
307
|
+
state.topic = restored;
|
|
308
|
+
effects.push(Effect.applyChannelDelta(state.name, { topic: restored }));
|
|
298
309
|
}
|
|
299
310
|
}
|
|
300
311
|
|
|
@@ -323,13 +334,15 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
323
334
|
}),
|
|
324
335
|
);
|
|
325
336
|
|
|
326
|
-
// ChanServ auto-prefix broadcast: when ChanServ granted op or voice
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
337
|
+
// ChanServ auto-prefix broadcast: when ChanServ granted op or voice on
|
|
338
|
+
// a non-empty channel, emit a `:ChanServ!ChanServ@services MODE #chan
|
|
339
|
+
// +o nick` broadcast so existing clients update their prefix display.
|
|
340
|
+
// Suppressed on the empty-channel first-join (wasEmpty) because the
|
|
341
|
+
// joiner is the channel's only client and already sees their prefix in
|
|
342
|
+
// the NAMES list — the op grant is silent. Supersedes the implicit
|
|
343
|
+
// voice when op is granted (the channel-prefix grammar already renders
|
|
344
|
+
// `@` for ops).
|
|
345
|
+
if (!wasEmpty && autoPrefix !== null && (autoPrefix.op || autoPrefix.voice)) {
|
|
333
346
|
effects.push(buildChanservPrefixBroadcast(state.name, joinerNick, autoPrefix));
|
|
334
347
|
}
|
|
335
348
|
|
|
@@ -379,19 +392,12 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
|
|
|
379
392
|
|
|
380
393
|
effects.push(Effect.send(ctx.connId, lines));
|
|
381
394
|
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
{ text: `:${ctx.serverName} 332 ${nick} ${chanName} :${restoredTopic.text}` },
|
|
389
|
-
{
|
|
390
|
-
text: `:${ctx.serverName} 333 ${nick} ${chanName} ${restoredTopic.setter} ${restoredTopic.setAt}`,
|
|
391
|
-
},
|
|
392
|
-
]),
|
|
393
|
-
);
|
|
394
|
-
}
|
|
395
|
+
// Emit 332/333 topic numerics to the joiner whenever a topic is set on
|
|
396
|
+
// the channel, regardless of whether it came from a ChanServ KEEPTOPIC
|
|
397
|
+
// restore this join, a deploy snapshot, or an earlier joiner. Mirrors
|
|
398
|
+
// the TOPIC read-form reply shape (RFC 2812 §3.2.1).
|
|
399
|
+
const topicReply = topicNumerics(state, ctx);
|
|
400
|
+
if (topicReply !== undefined) effects.push(topicReply);
|
|
395
401
|
|
|
396
402
|
return { state, effects };
|
|
397
403
|
};
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*
|
|
20
20
|
* The reducer is a pure `Reducer<ConnectionState>`: all side effects are
|
|
21
21
|
* emitted as {@link Effect}s. The services store is synchronous (mirrors
|
|
22
|
-
* the {@link
|
|
22
|
+
* the {@link MessageStore} pattern).
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { caseFold } from '../case-fold.js';
|
|
@@ -26,16 +26,17 @@
|
|
|
26
26
|
*
|
|
27
27
|
* The reducer is a pure `Reducer<ConnectionState>`: all side effects are
|
|
28
28
|
* emitted as {@link Effect}s. The services store is synchronous (mirrors
|
|
29
|
-
* the {@link
|
|
29
|
+
* the {@link MessageStore} pattern).
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import { DEFAULT_NICK_ENFORCE_GRACE_MS } from '../config.js';
|
|
32
|
+
import { DEFAULT_MIN_PASSWORD_LENGTH, DEFAULT_NICK_ENFORCE_GRACE_MS } from '../config.js';
|
|
33
33
|
import { Effect } from '../effects.js';
|
|
34
34
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
35
35
|
import type { NickEnforcePolicy, ServicesStore } from '../ports.js';
|
|
36
36
|
import type { ConnectionState } from '../state/connection.js';
|
|
37
37
|
import type { Ctx, Reducer } from '../types.js';
|
|
38
38
|
import { emitAccountNotify } from './account-notify.js';
|
|
39
|
+
import { applyAssignedVhost } from './hostserv.js';
|
|
39
40
|
import { deliverUnreadMemos } from './memoserv.js';
|
|
40
41
|
|
|
41
42
|
/** Canonical NickServ pseudo-client nick. */
|
|
@@ -47,6 +48,30 @@ const SERVICES_HOST = 'services';
|
|
|
47
48
|
/** Policies accepted by `SET ENFORCE` (lowercased input forms). */
|
|
48
49
|
const ENFORCE_POLICIES: ReadonlySet<NickEnforcePolicy> = new Set(['none', 'ghost', 'kill']);
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Fixed maximum password length accepted by `SET PASSWORD`. Bounds the
|
|
53
|
+
* scrypt input budget (256 is the practical limit of the scrypt input);
|
|
54
|
+
* not configurable. Over-long passwords are rejected before hashing.
|
|
55
|
+
*/
|
|
56
|
+
const MAX_PASSWORD_LENGTH = 256;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Gate NOTICE sent to an unidentified caller (or one identified as a
|
|
60
|
+
* different account) attempting `SET ENFORCE`. Wording kept verbatim from
|
|
61
|
+
* the original inline literal so the existing ENFORCE test suite is not
|
|
62
|
+
* churned by the addition of `SET PASSWORD` (a sibling constant covers the
|
|
63
|
+
* PASSWORD gate).
|
|
64
|
+
*/
|
|
65
|
+
const ENFORCE_GATE_NOTICE = 'You must identify before changing enforcement.';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Gate NOTICE sent to an unidentified caller (or one identified as a
|
|
69
|
+
* different account) attempting `SET PASSWORD`. Wording mirrors the ENFORCE
|
|
70
|
+
* gate's intent but speaks to "password" rather than "enforcement" so a
|
|
71
|
+
* caller who hits it understands which subcommand they tripped.
|
|
72
|
+
*/
|
|
73
|
+
const PASSWORD_GATE_NOTICE = 'You must identify before changing your password.';
|
|
74
|
+
|
|
50
75
|
/**
|
|
51
76
|
* Reducer for `PRIVMSG NickServ :<subcommand> <args…>`.
|
|
52
77
|
* Authority: the invoking connection's {@link ConnectionState}.
|
|
@@ -74,6 +99,7 @@ export const nickservReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
74
99
|
switch (sub) {
|
|
75
100
|
case 'REGISTER':
|
|
76
101
|
return handleRegister(state, args, ctx, effects);
|
|
102
|
+
case 'ID':
|
|
77
103
|
case 'IDENTIFY':
|
|
78
104
|
return handleIdentify(state, args, ctx, effects);
|
|
79
105
|
case 'DROP':
|
|
@@ -166,9 +192,10 @@ function handleIdentify(
|
|
|
166
192
|
}
|
|
167
193
|
|
|
168
194
|
/**
|
|
169
|
-
* Applies a successful identify: records the account, sets `+r`,
|
|
170
|
-
*
|
|
171
|
-
*
|
|
195
|
+
* Applies a successful identify: records the account, sets `+r`, re-applies any
|
|
196
|
+
* assigned HostServ vhost (so a reconnecting user does not have to re-run
|
|
197
|
+
* `HostServ ON`), emits the confirmation NOTICE, fans `ACCOUNT` out to peers,
|
|
198
|
+
* and delivers queued memos.
|
|
172
199
|
*/
|
|
173
200
|
function applyIdentify(
|
|
174
201
|
state: ConnectionState,
|
|
@@ -178,6 +205,10 @@ function applyIdentify(
|
|
|
178
205
|
): void {
|
|
179
206
|
state.account = account;
|
|
180
207
|
state.userModes.registered = true;
|
|
208
|
+
// Re-apply an assigned HostServ vhost. At identify time the connection is
|
|
209
|
+
// usually pre-JOIN so the CHGHOST fanout is empty, but a post-JOIN IDENTIFY
|
|
210
|
+
// fans CHGHOST out to the user's current `chghost`-capable peers.
|
|
211
|
+
applyAssignedVhost(state, services, account, effects);
|
|
181
212
|
effects.push(notice(state, `You are now identified for nick ${account}.`));
|
|
182
213
|
effects.push(...emitAccountNotify({ conn: state, account }));
|
|
183
214
|
deliverUnreadMemos(state, services, account, effects);
|
|
@@ -241,7 +272,12 @@ function handleInfo(
|
|
|
241
272
|
|
|
242
273
|
effects.push(notice(state, `Nick: ${rec.nick}`));
|
|
243
274
|
effects.push(notice(state, `Account: ${rec.account}`));
|
|
244
|
-
|
|
275
|
+
// Email is private: only the account owner or an oper may see it. Mirrors
|
|
276
|
+
// the `state.account === rec.account` gate used by SET ENFORCE / the
|
|
277
|
+
// enforcement hook, extended to opers.
|
|
278
|
+
if (state.account === rec.account || state.userModes.oper) {
|
|
279
|
+
effects.push(notice(state, `Email: ${rec.email}`));
|
|
280
|
+
}
|
|
245
281
|
return { state, effects };
|
|
246
282
|
}
|
|
247
283
|
|
|
@@ -306,12 +342,24 @@ export function enforceRegisteredNick(state: ConnectionState, ctx: Ctx): EffectT
|
|
|
306
342
|
// ============================================================================
|
|
307
343
|
|
|
308
344
|
/**
|
|
309
|
-
* Handles `SET <subkey> <args…>`.
|
|
310
|
-
*
|
|
345
|
+
* Handles `SET <subkey> <args…>`. Supported subkeys:
|
|
346
|
+
*
|
|
347
|
+
* - `ENFORCE none|ghost|kill` — sets the per-account enforcement policy.
|
|
348
|
+
* - `PASSWORD <old> <new>` — self-service password change. Requires the
|
|
349
|
+
* current password to be re-supplied as a defence against a hijacked
|
|
350
|
+
* identified session (a stolen `+r` session should not let an attacker
|
|
351
|
+
* silently rotate the password and lock the real owner out — this is
|
|
352
|
+
* Atheme's shape, not the alternative "trust the gate" one-arg form).
|
|
353
|
+
* The new password is re-hashed via `hashAccountCredential` and
|
|
354
|
+
* overwrites the stored credential via
|
|
355
|
+
* {@link ServicesStore.setNickPassword}; the current session stays
|
|
356
|
+
* `+r` and other sessions of the same account are NOT kicked.
|
|
311
357
|
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
358
|
+
* Unknown subkeys fall back to the ENFORCE syntax notice.
|
|
359
|
+
*
|
|
360
|
+
* Both subkeys require the caller to be identified as the account bound to
|
|
361
|
+
* the current nick: an unidentified client (or one identified as a
|
|
362
|
+
* different account) cannot mutate the account.
|
|
315
363
|
*/
|
|
316
364
|
function handleSet(
|
|
317
365
|
state: ConnectionState,
|
|
@@ -319,8 +367,10 @@ function handleSet(
|
|
|
319
367
|
ctx: Ctx,
|
|
320
368
|
effects: EffectType[],
|
|
321
369
|
): { state: ConnectionState; effects: EffectType[] } {
|
|
322
|
-
const services = ctx.services as ServicesStore;
|
|
323
370
|
const subkey = args[0]?.toUpperCase();
|
|
371
|
+
if (subkey === 'PASSWORD') {
|
|
372
|
+
return handleSetPassword(state, args.slice(1), ctx, effects);
|
|
373
|
+
}
|
|
324
374
|
if (subkey !== 'ENFORCE') {
|
|
325
375
|
effects.push(enforceSyntaxNotice(state));
|
|
326
376
|
return { state, effects };
|
|
@@ -335,10 +385,11 @@ function handleSet(
|
|
|
335
385
|
|
|
336
386
|
const nick = state.nick;
|
|
337
387
|
if (nick === undefined || state.account === undefined) {
|
|
338
|
-
effects.push(notice(state,
|
|
388
|
+
effects.push(notice(state, ENFORCE_GATE_NOTICE));
|
|
339
389
|
return { state, effects };
|
|
340
390
|
}
|
|
341
391
|
|
|
392
|
+
const services = ctx.services as ServicesStore;
|
|
342
393
|
const rec = services.getNick(nick);
|
|
343
394
|
if (rec === undefined) {
|
|
344
395
|
effects.push(notice(state, `Nick ${nick} is not registered.`));
|
|
@@ -346,7 +397,7 @@ function handleSet(
|
|
|
346
397
|
}
|
|
347
398
|
// The caller must be identified as the account that owns this nick.
|
|
348
399
|
if (state.account !== rec.account) {
|
|
349
|
-
effects.push(notice(state,
|
|
400
|
+
effects.push(notice(state, ENFORCE_GATE_NOTICE));
|
|
350
401
|
return { state, effects };
|
|
351
402
|
}
|
|
352
403
|
|
|
@@ -355,11 +406,80 @@ function handleSet(
|
|
|
355
406
|
return { state, effects };
|
|
356
407
|
}
|
|
357
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Handles `SET PASSWORD <old> <new>`. Mirrors the `SET ENFORCE` identify
|
|
411
|
+
* gate (`state.account === rec.account`), then re-verifies the current
|
|
412
|
+
* password (no-leak parity with `IDENTIFY`'s `Invalid password.` notice)
|
|
413
|
+
* and enforces the configurable minimum / fixed maximum length on the new
|
|
414
|
+
* password before re-hashing and persisting.
|
|
415
|
+
*/
|
|
416
|
+
function handleSetPassword(
|
|
417
|
+
state: ConnectionState,
|
|
418
|
+
args: string[],
|
|
419
|
+
ctx: Ctx,
|
|
420
|
+
effects: EffectType[],
|
|
421
|
+
): { state: ConnectionState; effects: EffectType[] } {
|
|
422
|
+
const oldPassword = args[0];
|
|
423
|
+
const newPassword = args[1];
|
|
424
|
+
if (oldPassword === undefined || newPassword === undefined) {
|
|
425
|
+
effects.push(passwordSyntaxNotice(state));
|
|
426
|
+
return { state, effects };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const nick = state.nick;
|
|
430
|
+
if (nick === undefined || state.account === undefined) {
|
|
431
|
+
effects.push(notice(state, PASSWORD_GATE_NOTICE));
|
|
432
|
+
return { state, effects };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const services = ctx.services as ServicesStore;
|
|
436
|
+
const rec = services.getNick(nick);
|
|
437
|
+
if (rec === undefined) {
|
|
438
|
+
effects.push(notice(state, `Nick ${nick} is not registered.`));
|
|
439
|
+
return { state, effects };
|
|
440
|
+
}
|
|
441
|
+
// The caller must be identified as the account that owns this nick.
|
|
442
|
+
if (state.account !== rec.account) {
|
|
443
|
+
effects.push(notice(state, PASSWORD_GATE_NOTICE));
|
|
444
|
+
return { state, effects };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Re-verify the current password (defence against a hijacked `+r`
|
|
448
|
+
// session). Same `verifyNick` path `IDENTIFY` uses → same `Invalid
|
|
449
|
+
// password.` notice → no information leak about whether the account
|
|
450
|
+
// exists (the gate above already proved it does).
|
|
451
|
+
if (!services.verifyNick(nick, oldPassword).ok) {
|
|
452
|
+
effects.push(notice(state, 'Invalid password.'));
|
|
453
|
+
return { state, effects };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const minLen = ctx.serverConfig.nickServ?.minPasswordLength ?? DEFAULT_MIN_PASSWORD_LENGTH;
|
|
457
|
+
if (newPassword.length < minLen) {
|
|
458
|
+
effects.push(notice(state, `Password is too short (minimum ${minLen} characters).`));
|
|
459
|
+
return { state, effects };
|
|
460
|
+
}
|
|
461
|
+
if (newPassword.length > MAX_PASSWORD_LENGTH) {
|
|
462
|
+
effects.push(
|
|
463
|
+
notice(state, `Password is too long (maximum ${MAX_PASSWORD_LENGTH} characters).`),
|
|
464
|
+
);
|
|
465
|
+
return { state, effects };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
services.setNickPassword(nick, newPassword);
|
|
469
|
+
effects.push(notice(state, 'Password changed.'));
|
|
470
|
+
return { state, effects };
|
|
471
|
+
}
|
|
472
|
+
|
|
358
473
|
/** Builds the `SET ENFORCE` syntax NOTICE. */
|
|
359
474
|
function enforceSyntaxNotice(state: ConnectionState): EffectType {
|
|
360
475
|
return notice(state, 'Syntax: SET ENFORCE none|ghost|kill');
|
|
361
476
|
}
|
|
362
477
|
|
|
478
|
+
/** Builds the `SET PASSWORD` syntax NOTICE. */
|
|
479
|
+
function passwordSyntaxNotice(state: ConnectionState): EffectType {
|
|
480
|
+
return notice(state, 'Syntax: SET PASSWORD <old-password> <new-password>');
|
|
481
|
+
}
|
|
482
|
+
|
|
363
483
|
// ============================================================================
|
|
364
484
|
// Notice helpers
|
|
365
485
|
// ============================================================================
|
|
@@ -375,7 +495,10 @@ function helpNotice(state: ConnectionState): EffectType {
|
|
|
375
495
|
}
|
|
376
496
|
|
|
377
497
|
function unknownNotice(state: ConnectionState): EffectType {
|
|
378
|
-
return notice(
|
|
498
|
+
return notice(
|
|
499
|
+
state,
|
|
500
|
+
'Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET (ENFORCE, PASSWORD)',
|
|
501
|
+
);
|
|
379
502
|
}
|
|
380
503
|
|
|
381
504
|
/**
|
|
@@ -22,7 +22,7 @@ import type { ChanName } from '../state/channel.js';
|
|
|
22
22
|
import { hostmaskOf } from '../state/connection.js';
|
|
23
23
|
import type { ConnectionState } from '../state/connection.js';
|
|
24
24
|
import type { Ctx, Reducer } from '../types.js';
|
|
25
|
-
import { passBasedAccountAuth } from './account-auth.js';
|
|
25
|
+
import { attemptPassAccountAuth, passBasedAccountAuth } from './account-auth.js';
|
|
26
26
|
import { generateIsupport } from './isupport.js';
|
|
27
27
|
import { buildMotdNumerics } from './motd-lines.js';
|
|
28
28
|
import { enforceRegisteredNick } from './nickserv.js';
|
|
@@ -119,7 +119,7 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
|
|
|
119
119
|
// the account-login numerics (900/903 + downstream effects), satisfying
|
|
120
120
|
// the gate as a side effect. On every other outcome it returns `[]` and
|
|
121
121
|
// the gate below decides. No-op when already identified (SASL), when no
|
|
122
|
-
// `<nick>:<password>` form is present, or when no
|
|
122
|
+
// `<nick>:<password>` form is present, or when no ServicesStore is bound.
|
|
123
123
|
const authEffects = passBasedAccountAuth(state, ctx);
|
|
124
124
|
|
|
125
125
|
const gateFailure = passwordGateFailure(state, ctx);
|
|
@@ -161,12 +161,16 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
|
|
|
161
161
|
* Replaces `state.host` with a deterministic cloak derived from the
|
|
162
162
|
* deployment's cloaking secret + the current (real) host. No-op when
|
|
163
163
|
* cloaking is disabled or no host is set yet (e.g. transport didn't supply
|
|
164
|
-
* one, or the connection sets it later via CHGHOST).
|
|
164
|
+
* one, or the connection sets it later via CHGHOST). Also a no-op when a
|
|
165
|
+
* HostServ vhost is already active (`vhostActive`) — the vhost was re-applied
|
|
166
|
+
* by {@link applyAccountSuccess} during SASL / PASS-auth and must win over the
|
|
167
|
+
* cloak so it remains visible in the `001` welcome.
|
|
165
168
|
*/
|
|
166
169
|
function applyCloakIfEnabled(state: ConnectionState, ctx: Ctx): void {
|
|
167
170
|
const cloaking = ctx.serverConfig.cloaking;
|
|
168
171
|
if (cloaking === undefined || !cloaking.enabled) return;
|
|
169
172
|
if (state.host === undefined) return;
|
|
173
|
+
if (state.vhostActive === true) return;
|
|
170
174
|
state.host = cloakHost(state.host, cloaking, ctx.networkName);
|
|
171
175
|
}
|
|
172
176
|
|
|
@@ -340,33 +344,40 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
|
340
344
|
/**
|
|
341
345
|
* Reducer for `PASS <password>`.
|
|
342
346
|
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* {@link
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
347
|
+
* Pre-registration: stashes the attempt as `state.passAttempt` for the
|
|
348
|
+
* server-password gate ({@link passwordGateFailure}) and the at-completion
|
|
349
|
+
* PASS-auth ({@link passBasedAccountAuth}). A second `PASS` before
|
|
350
|
+
* registration completes is rejected with `462 ERR_ALREADYREGISTRED`.
|
|
351
|
+
*
|
|
352
|
+
* Post-registration: a bare server password is meaningless, so only the
|
|
353
|
+
* `<nick>:<password>` form is honoured — as a late account login via
|
|
354
|
+
* {@link attemptPassAccountAuth} against the bound `ctx.services`. Every
|
|
355
|
+
* other outcome (bare value, no store, already identified, verify failure)
|
|
356
|
+
* is a silent no-op: no `462`, no disconnect. This lets a client that
|
|
357
|
+
* completed `NICK`+`USER` before sending `PASS` identify after the fact.
|
|
352
358
|
*/
|
|
353
359
|
export const passReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
354
360
|
const effects: EffectType[] = [];
|
|
355
361
|
|
|
356
|
-
|
|
362
|
+
const password = msg.params[0];
|
|
363
|
+
if (password === undefined) {
|
|
357
364
|
effects.push(
|
|
358
365
|
Effect.send(ctx.connId, [
|
|
359
|
-
numericErr(ctx, Numerics.
|
|
366
|
+
numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'PASS'),
|
|
360
367
|
]),
|
|
361
368
|
);
|
|
362
369
|
return { state, effects };
|
|
363
370
|
}
|
|
364
371
|
|
|
365
|
-
|
|
366
|
-
|
|
372
|
+
if (state.registration === 'registered') {
|
|
373
|
+
effects.push(...attemptPassAccountAuth(state, ctx, password));
|
|
374
|
+
return { state, effects };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (state.passAttempt !== undefined) {
|
|
367
378
|
effects.push(
|
|
368
379
|
Effect.send(ctx.connId, [
|
|
369
|
-
numericErr(ctx, Numerics.
|
|
380
|
+
numericErr(ctx, Numerics.ERR_ALREADYREGISTRED, 'You may not reregister'),
|
|
370
381
|
]),
|
|
371
382
|
);
|
|
372
383
|
return { state, effects };
|
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
* Anything else gets `908 ERR_SASLMECHS` listing what is available.
|
|
14
14
|
* 2. `AUTHENTICATE <b64>` / `AUTHENTICATE +` — delivers the payload (whole
|
|
15
15
|
* or in `<=400`-byte chunks; `+` marks an empty/final chunk). The
|
|
16
|
-
* decoded bytes are
|
|
17
|
-
* success the connection records the account name and the reducer
|
|
18
|
-
* `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, on failure
|
|
16
|
+
* decoded bytes are verified against the bound {@link ServicesStore};
|
|
17
|
+
* on success the connection records the account name and the reducer
|
|
18
|
+
* emits `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, on failure
|
|
19
|
+
* `904 ERR_SASLFAIL`. PLAIN consults `services.verifyNick`, EXTERNAL
|
|
20
|
+
* consults `services.verifyCertFP` against the mTLS-resolved subject.
|
|
19
21
|
* 3. `AUTHENTICATE *` — client abort → `906 ERR_SASLABORT`.
|
|
20
22
|
*
|
|
21
|
-
* The
|
|
23
|
+
* The {@link ServicesStore} port is **synchronous** (reducers stay pure):
|
|
22
24
|
* adapters pre-load credentials so the reducer stays pure. Async backends
|
|
23
25
|
* resolve their lookups before the actor invokes this reducer.
|
|
24
26
|
*
|
|
@@ -29,7 +31,7 @@
|
|
|
29
31
|
|
|
30
32
|
import { Effect } from '../effects.js';
|
|
31
33
|
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
32
|
-
import type {
|
|
34
|
+
import type { ServicesAuthResult } from '../ports.js';
|
|
33
35
|
import { decodeBase64 } from '../protocol/base64.js';
|
|
34
36
|
import { Numerics } from '../protocol/numerics.js';
|
|
35
37
|
import type { ConnectionState } from '../state/connection.js';
|
|
@@ -143,7 +145,7 @@ function handlePayload(
|
|
|
143
145
|
return finalize(state, next, mech, ctx);
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
/** Decodes the accumulated payload and
|
|
148
|
+
/** Decodes the accumulated payload and verifies it against the ServicesStore. */
|
|
147
149
|
function finalize(
|
|
148
150
|
state: ConnectionState,
|
|
149
151
|
payloadB64: string,
|
|
@@ -169,28 +171,16 @@ function finalizePlain(
|
|
|
169
171
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
username: parsed.username,
|
|
179
|
-
password: parsed.password,
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
if (result === undefined || !result.ok) {
|
|
183
|
-
const servicesResult = ctx.services?.verifyNick(parsed.username, parsed.password);
|
|
184
|
-
if (servicesResult?.ok) {
|
|
185
|
-
result = servicesResult;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (result === undefined) {
|
|
174
|
+
// Single credential home: services.verifyNick covers every NickServ
|
|
175
|
+
// registration, every SASL_ACCOUNTS-seeded account (carried into
|
|
176
|
+
// services via the boot ingest), and every PASS-auth share. No separate
|
|
177
|
+
// static credential fallback — there is no second credential table.
|
|
178
|
+
const services = ctx.services;
|
|
179
|
+
if (services === undefined) {
|
|
190
180
|
clearSasl(state);
|
|
191
181
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
192
182
|
}
|
|
193
|
-
|
|
183
|
+
const result = services.verifyNick(parsed.username, parsed.password);
|
|
194
184
|
return applyResult(state, result, ctx);
|
|
195
185
|
}
|
|
196
186
|
|
|
@@ -207,21 +197,22 @@ function finalizeExternal(
|
|
|
207
197
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
208
198
|
}
|
|
209
199
|
|
|
210
|
-
|
|
211
|
-
|
|
200
|
+
// Cert subjects are mapped to accounts via services.verifyCertFP — the
|
|
201
|
+
// same single credential home as SASL PLAIN / PASS-auth. No separate
|
|
202
|
+
// services lookup.
|
|
203
|
+
const services = ctx.services;
|
|
204
|
+
if (services === undefined) {
|
|
212
205
|
clearSasl(state);
|
|
213
206
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
214
207
|
}
|
|
215
|
-
|
|
216
|
-
const result = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity });
|
|
217
|
-
|
|
208
|
+
const result = services.verifyCertFP(identity);
|
|
218
209
|
return applyResult(state, result, ctx);
|
|
219
210
|
}
|
|
220
211
|
|
|
221
212
|
/** Shared success/failure handling after a `verify` call. */
|
|
222
213
|
function applyResult(
|
|
223
214
|
state: ConnectionState,
|
|
224
|
-
result:
|
|
215
|
+
result: ServicesAuthResult,
|
|
225
216
|
ctx: Ctx,
|
|
226
217
|
): { state: ConnectionState; effects: EffectType[] } {
|
|
227
218
|
clearSasl(state);
|