serverless-ircd 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
import { ChannelDO } from '../../src/channel-do';
|
|
22
22
|
import { ChannelRegistryDO } from '../../src/channel-registry-do';
|
|
23
23
|
import { ConnectionDO } from '../../src/connection-do';
|
|
24
|
+
import { CounterDO } from '../../src/counter-do';
|
|
25
|
+
import { RateLimitDO } from '../../src/rate-limit-do';
|
|
24
26
|
import { RegistryDO } from '../../src/registry-do';
|
|
25
27
|
import { RecordingChannelDO } from './stubs/channel-stub';
|
|
26
28
|
import { RecordingRegistryDO } from './stubs/registry-stub';
|
|
@@ -29,6 +31,8 @@ export {
|
|
|
29
31
|
ChannelDO,
|
|
30
32
|
ChannelRegistryDO,
|
|
31
33
|
ConnectionDO,
|
|
34
|
+
CounterDO,
|
|
35
|
+
RateLimitDO,
|
|
32
36
|
RegistryDO,
|
|
33
37
|
RecordingRegistryDO,
|
|
34
38
|
RecordingChannelDO,
|
|
@@ -49,6 +49,23 @@ class_name = "ChannelDO"
|
|
|
49
49
|
name = "CHANNEL_REGISTRY_DO"
|
|
50
50
|
class_name = "ChannelRegistryDO"
|
|
51
51
|
|
|
52
|
+
# Global live-connection admission counter — single DO instance keyed by
|
|
53
|
+
# the fixed name `global`. The worker edge reserves a slot per upgrade
|
|
54
|
+
# (maxClients cap); ConnectionDO releases it on every teardown path and
|
|
55
|
+
# heartbeats it from the PING alarm so the lazy TTL reap only evicts
|
|
56
|
+
# entries from DOs that stopped heartbeating.
|
|
57
|
+
[[durable_objects.bindings]]
|
|
58
|
+
name = "COUNTER_DO"
|
|
59
|
+
class_name = "CounterDO"
|
|
60
|
+
|
|
61
|
+
# Per-IP upgrade rate limiter — single DO instance keyed by the fixed
|
|
62
|
+
# name `global`, maintaining one sliding-window admission-timestamp list
|
|
63
|
+
# per CF-Connecting-IP. The worker edge checks the budget before
|
|
64
|
+
# forwarding an upgrade and rejects over-budget IPs with 429.
|
|
65
|
+
[[durable_objects.bindings]]
|
|
66
|
+
name = "RATE_LIMIT_DO"
|
|
67
|
+
class_name = "RateLimitDO"
|
|
68
|
+
|
|
52
69
|
# Recording stubs (kept under separate names for any test that wants to
|
|
53
70
|
# assert "this exact RPC was emitted" rather than the end-to-end effect).
|
|
54
71
|
[[durable_objects.bindings]]
|
|
@@ -71,4 +88,4 @@ class_name = "ChannelDO"
|
|
|
71
88
|
|
|
72
89
|
[[migrations]]
|
|
73
90
|
tag = "v1"
|
|
74
|
-
new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "RecordingRegistryDO", "RecordingChannelDO"]
|
|
91
|
+
new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "CounterDO", "RateLimitDO", "RecordingRegistryDO", "RecordingChannelDO"]
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type ConnSnapshot,
|
|
26
26
|
type ConnectionState,
|
|
27
27
|
type Nick,
|
|
28
|
+
OperFailureStats,
|
|
28
29
|
type RawLine,
|
|
29
30
|
type ServerConfig,
|
|
30
31
|
applyChannelDelta as applyDelta,
|
|
@@ -84,10 +85,20 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
84
85
|
private readonly admissionStats: AdmissionStats | undefined;
|
|
85
86
|
private readonly admissionConfig: AdmissionConfig | undefined;
|
|
86
87
|
private readonly configLoader: (() => Promise<ServerConfig>) | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* Per-IP failed-`OPER` counter shared across every connection's actor
|
|
90
|
+
* (see `ctx.operFailures`). Bound to the runtime's clock so the OPER
|
|
91
|
+
* reducer's lockout window advances with the same time source the
|
|
92
|
+
* actors observe. Adapters hand `runtime.operFailures` to each
|
|
93
|
+
* `ConnectionActor`'s options; the counter is then per-IP across the
|
|
94
|
+
* whole process, not per-connection.
|
|
95
|
+
*/
|
|
96
|
+
readonly operFailures: OperFailureStats;
|
|
87
97
|
|
|
88
98
|
constructor(opts: InMemoryRuntimeOptions) {
|
|
89
99
|
this.clock = opts.clock;
|
|
90
100
|
this.configLoader = opts.configLoader;
|
|
101
|
+
this.operFailures = new OperFailureStats(opts.clock);
|
|
91
102
|
if (opts.admission !== undefined) {
|
|
92
103
|
this.admissionConfig = opts.admission;
|
|
93
104
|
this.admissionStats = new AdmissionStats(this.clock);
|
|
@@ -270,6 +281,20 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
270
281
|
}
|
|
271
282
|
}
|
|
272
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Oper-only notice fanout — the recipient gate for the OPER reducer's
|
|
286
|
+
* per-IP lockout notice (`:<server> NOTICE * :OPER lockout triggered
|
|
287
|
+
* for <host>`). Identical enumeration to {@link broadcastWallops} but
|
|
288
|
+
* filtered on user mode `+o` instead of `+w`.
|
|
289
|
+
*/
|
|
290
|
+
async broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
291
|
+
for (const [connId, tracked] of this.connections) {
|
|
292
|
+
if (connId === except) continue;
|
|
293
|
+
if (!tracked.state.userModes.oper) continue;
|
|
294
|
+
tracked.handlers.send(lines);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
273
298
|
// ------------------------------------------------------------------
|
|
274
299
|
// IrcRuntime — nick registry
|
|
275
300
|
// ------------------------------------------------------------------
|
|
@@ -893,3 +893,77 @@ describe('InMemoryRuntime — broadcastWallops', () => {
|
|
|
893
893
|
expect(c1Received).toBe(0);
|
|
894
894
|
});
|
|
895
895
|
});
|
|
896
|
+
|
|
897
|
+
describe('InMemoryRuntime — broadcastOperNotice', () => {
|
|
898
|
+
it('delivers lines to every connection with the +o user mode set', async () => {
|
|
899
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
900
|
+
const received: Record<string, RawLine[][]> = { c1: [], c2: [], c3: [] };
|
|
901
|
+
const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
|
|
902
|
+
// c1 has +w but NOT +o; c2 and c3 are opers.
|
|
903
|
+
const c1 = makeConn('c1', 'alice');
|
|
904
|
+
c1.userModes.wallops = true;
|
|
905
|
+
rt.registerConnection(c1, { send: push('c1'), disconnect: () => {} });
|
|
906
|
+
const c2 = makeConn('c2', 'bob');
|
|
907
|
+
c2.userModes.oper = true;
|
|
908
|
+
rt.registerConnection(c2, { send: push('c2'), disconnect: () => {} });
|
|
909
|
+
const c3 = makeConn('c3', 'carol');
|
|
910
|
+
c3.userModes.oper = true;
|
|
911
|
+
rt.registerConnection(c3, { send: push('c3'), disconnect: () => {} });
|
|
912
|
+
|
|
913
|
+
await rt.broadcastOperNotice([L(':srv NOTICE * :OPER lockout triggered for 10.0.0.1')]);
|
|
914
|
+
|
|
915
|
+
// +w without +o is NOT enough — the oper notice is oper-only.
|
|
916
|
+
expect(received.c1).toHaveLength(0);
|
|
917
|
+
expect(received.c2).toHaveLength(1);
|
|
918
|
+
expect(received.c3).toHaveLength(1);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it('skips the except connection even when it is an oper', async () => {
|
|
922
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
923
|
+
const received: Record<string, RawLine[][]> = { c1: [], c2: [] };
|
|
924
|
+
const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
|
|
925
|
+
const oper = makeConn('c1', 'alice');
|
|
926
|
+
oper.userModes.oper = true;
|
|
927
|
+
rt.registerConnection(oper, { send: push('c1'), disconnect: () => {} });
|
|
928
|
+
const peer = makeConn('c2', 'bob');
|
|
929
|
+
peer.userModes.oper = true;
|
|
930
|
+
rt.registerConnection(peer, { send: push('c2'), disconnect: () => {} });
|
|
931
|
+
|
|
932
|
+
await rt.broadcastOperNotice([L(':srv NOTICE * :hi')], 'c1');
|
|
933
|
+
|
|
934
|
+
expect(received.c1).toHaveLength(0);
|
|
935
|
+
expect(received.c2).toHaveLength(1);
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it('is a no-op when no connection has +o', async () => {
|
|
939
|
+
const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
|
|
940
|
+
let c1Received = 0;
|
|
941
|
+
rt.registerConnection(makeConn('c1', 'alice'), {
|
|
942
|
+
send: () => {
|
|
943
|
+
c1Received++;
|
|
944
|
+
},
|
|
945
|
+
disconnect: () => {},
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
await rt.broadcastOperNotice([L(':srv NOTICE * :hi')]);
|
|
949
|
+
expect(c1Received).toBe(0);
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
describe('InMemoryRuntime — operFailures tracker', () => {
|
|
954
|
+
it('exposes one shared OperFailureStats instance bound to the runtime clock', async () => {
|
|
955
|
+
const clock = new FakeClock(1_000);
|
|
956
|
+
const rt = new InMemoryRuntime({ clock });
|
|
957
|
+
|
|
958
|
+
const tracker = rt.operFailures;
|
|
959
|
+
expect(tracker).toBeDefined();
|
|
960
|
+
// Same instance every read: adapters hand it to every connection's
|
|
961
|
+
// actor so the per-IP budget is shared fleet-wide.
|
|
962
|
+
expect(rt.operFailures).toBe(tracker);
|
|
963
|
+
|
|
964
|
+
tracker.recordFailure('10.0.0.1');
|
|
965
|
+
clock.advance(1_000);
|
|
966
|
+
tracker.recordFailure('10.0.0.1');
|
|
967
|
+
expect(tracker.recentFailures('10.0.0.1', 300_000)).toBe(2);
|
|
968
|
+
});
|
|
969
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the one-shot `accounts` → `nickserv_accounts` /
|
|
3
|
+
* `Services` migration (`tools/migrate-accounts-to-services.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Lives in `irc-core` so both adapter test suites can import the helpers
|
|
6
|
+
* without a cross-adapter dependency. The CLI entry point (wrangler /
|
|
7
|
+
* DynamoDB IO) lives in the tools script; these helpers are side-effect-
|
|
8
|
+
* free and fully unit-testable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { caseFold } from './case-fold.js';
|
|
12
|
+
import { hashAccountCredential } from './credential-hashing.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* DDL for the `nickserv_accounts` table (the migration target). Inlined
|
|
16
|
+
* rather than imported from `@serverless-ircd/cf-adapter` so the helpers
|
|
17
|
+
* are testable from any package. Kept in sync with
|
|
18
|
+
* `cf-adapter/src/d1-services-store.ts`.
|
|
19
|
+
*/
|
|
20
|
+
export const MIGRATION_NICKSERV_ACCOUNTS_SQL =
|
|
21
|
+
"CREATE TABLE IF NOT EXISTS nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, cert_subjects TEXT NOT NULL DEFAULT '[]', algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)";
|
|
22
|
+
|
|
23
|
+
/** Parsed `SASL_ACCOUNTS` env var entry. */
|
|
24
|
+
export interface ParsedSaslAccount {
|
|
25
|
+
username: string;
|
|
26
|
+
password: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A raw legacy `accounts` row read from D1 or DynamoDB. */
|
|
30
|
+
export interface LegacyAccountRow {
|
|
31
|
+
account: string;
|
|
32
|
+
algorithm: string;
|
|
33
|
+
salt: string;
|
|
34
|
+
hash: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parses the `SASL_ACCOUNTS` env var into credential entries. */
|
|
38
|
+
export function parseSaslAccountsForMigration(raw: string | undefined): ParsedSaslAccount[] {
|
|
39
|
+
if (raw === undefined || raw.length === 0) return [];
|
|
40
|
+
const out: ParsedSaslAccount[] = [];
|
|
41
|
+
for (const line of raw.split('\n')) {
|
|
42
|
+
const trimmed = line.trim();
|
|
43
|
+
if (trimmed.length === 0) continue;
|
|
44
|
+
const sep = trimmed.indexOf(':');
|
|
45
|
+
if (sep <= 0) continue;
|
|
46
|
+
const username = trimmed.slice(0, sep);
|
|
47
|
+
const password = trimmed.slice(sep + 1);
|
|
48
|
+
if (username.length === 0 || password.length === 0) continue;
|
|
49
|
+
out.push({ username, password });
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Builds the set of `INSERT OR IGNORE` SQL statements that backfill the
|
|
56
|
+
* legacy `accounts` rows plus the `SASL_ACCOUNTS` env-seed entries into
|
|
57
|
+
* `nickserv_accounts`. Pure (no IO).
|
|
58
|
+
*
|
|
59
|
+
* - Legacy rows are copied verbatim (the pre-hashed scrypt credential is
|
|
60
|
+
* preserved; the plaintext is NOT re-hashed). The services row carries
|
|
61
|
+
* `cert_subjects = '[]'`, email `''`, enforce `'none'`, `created_at 0`.
|
|
62
|
+
* - `SASL_ACCOUNTS` env-seed entries are hashed fresh (the env var carries
|
|
63
|
+
* plaintext passwords). Only entries whose username is NOT already
|
|
64
|
+
* present in the legacy rows are emitted (legacy rows win over the env
|
|
65
|
+
* seed).
|
|
66
|
+
* - `INSERT OR IGNORE` guarantees idempotency: an existing
|
|
67
|
+
* `nickserv_accounts` row (registered via NickServ or a prior seed) is
|
|
68
|
+
* never clobbered.
|
|
69
|
+
*
|
|
70
|
+
* @returns The array of executable SQL statements (CREATE TABLE + INSERTs).
|
|
71
|
+
*/
|
|
72
|
+
export function buildMigrationSql(
|
|
73
|
+
legacyRows: ReadonlyArray<LegacyAccountRow>,
|
|
74
|
+
saslAccountsRaw: string | undefined,
|
|
75
|
+
): string[] {
|
|
76
|
+
const statements: string[] = [MIGRATION_NICKSERV_ACCOUNTS_SQL];
|
|
77
|
+
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
|
|
80
|
+
for (const row of legacyRows) {
|
|
81
|
+
const nickKey = caseFold('rfc1459', row.account);
|
|
82
|
+
seen.add(nickKey);
|
|
83
|
+
statements.push(buildInsertOrIgnore(row.account, row));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const acct of parseSaslAccountsForMigration(saslAccountsRaw)) {
|
|
87
|
+
const nickKey = caseFold('rfc1459', acct.username);
|
|
88
|
+
if (seen.has(nickKey)) continue;
|
|
89
|
+
seen.add(nickKey);
|
|
90
|
+
const cred = hashAccountCredential(acct.username, acct.password);
|
|
91
|
+
statements.push(buildInsertOrIgnore(acct.username, cred));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return statements;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildInsertOrIgnore(
|
|
98
|
+
username: string,
|
|
99
|
+
cred: { algorithm: string; salt: string; hash: string },
|
|
100
|
+
): string {
|
|
101
|
+
const nickKey = caseFold('rfc1459', username);
|
|
102
|
+
return `INSERT OR IGNORE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, algorithm, salt, hash) VALUES ('${sqlEscape(nickKey)}', '${sqlEscape(username)}', '${sqlEscape(username)}', '', 0, 'none', '[]', '${sqlEscape(cred.algorithm)}', '${sqlEscape(cred.salt)}', '${sqlEscape(cred.hash)}')`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Doubles single quotes for safe embedding in a D1 SQL string literal. */
|
|
106
|
+
function sqlEscape(value: string): string {
|
|
107
|
+
return value.replace(/'/g, "''");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Builds the DynamoDB `PutItem` input for a legacy row, targeting the
|
|
112
|
+
* `Services` table's `NICK:<fold>` / `#` item. Pure.
|
|
113
|
+
*
|
|
114
|
+
* The caller wraps this in a conditional PutItem
|
|
115
|
+
* (`ConditionExpression: attribute_not_exists(pk)`) so an existing nick
|
|
116
|
+
* registration is never clobbered.
|
|
117
|
+
*/
|
|
118
|
+
export function buildServicesPutItem(
|
|
119
|
+
row: { account: string; algorithm: string; salt: string; hash: string },
|
|
120
|
+
tableName: string,
|
|
121
|
+
): { TableName: string; Item: Record<string, unknown> } {
|
|
122
|
+
const fold = caseFold('rfc1459', row.account);
|
|
123
|
+
return {
|
|
124
|
+
TableName: tableName,
|
|
125
|
+
Item: {
|
|
126
|
+
pk: `NICK:${fold}`,
|
|
127
|
+
sk: '#',
|
|
128
|
+
type: 'nick',
|
|
129
|
+
nick: row.account,
|
|
130
|
+
account: row.account,
|
|
131
|
+
email: '',
|
|
132
|
+
createdAt: 0,
|
|
133
|
+
enforce: 'none',
|
|
134
|
+
certSubjects: [],
|
|
135
|
+
algorithm: row.algorithm,
|
|
136
|
+
salt: row.salt,
|
|
137
|
+
hash: row.hash,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -16,7 +16,8 @@ export interface Capability {
|
|
|
16
16
|
/**
|
|
17
17
|
* Optional value parameter. For `sasl` this is the base mechanism list
|
|
18
18
|
* (`PLAIN`); the {@link saslCapValue} helper upgrades it to include
|
|
19
|
-
* `EXTERNAL` when
|
|
19
|
+
* `EXTERNAL` when the mechanism is available for the connection (mTLS
|
|
20
|
+
* source + operator opt-in + secure transport).
|
|
20
21
|
*/
|
|
21
22
|
value?: string;
|
|
22
23
|
}
|
|
@@ -40,6 +41,14 @@ export const PRE_AWAY_CAP_NAME = 'draft/pre-away';
|
|
|
40
41
|
*/
|
|
41
42
|
export const DEFAULT_MULTILINE_MAX_BYTES = 4096;
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Default per-batch entry ceiling enforced incrementally while a
|
|
46
|
+
* `draft/multiline` batch is open. Defense-in-depth against a client that
|
|
47
|
+
* streams many tiny lines (each under the byte budget) and never closes the
|
|
48
|
+
* batch; deployments override via `ServerConfig.multilineMaxEntries`.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_MULTILINE_MAX_ENTRIES = 100;
|
|
51
|
+
|
|
43
52
|
/**
|
|
44
53
|
* Builds the `draft/multiline` cap *value* (the substring after `=`) for the
|
|
45
54
|
* advertised byte budget. The {@link CapReducer} and the multi-line reducer
|
|
@@ -79,17 +88,18 @@ export const SUPPORTED_CAPABILITIES: ReadonlyArray<Capability> = Object.freeze([
|
|
|
79
88
|
]);
|
|
80
89
|
|
|
81
90
|
/**
|
|
82
|
-
* Returns the `sasl` cap value to advertise given whether
|
|
83
|
-
*
|
|
91
|
+
* Returns the `sasl` cap value to advertise given whether the SASL
|
|
92
|
+
* EXTERNAL mechanism is available for the connection.
|
|
84
93
|
*
|
|
85
|
-
* PLAIN is always available. EXTERNAL is advertised only when
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
94
|
+
* PLAIN is always available. EXTERNAL is advertised only when the
|
|
95
|
+
* caller's three-way gate holds (mTLS identity source bound +
|
|
96
|
+
* `sasl.externalEnabled` operator opt-in + secure transport — see
|
|
97
|
+
* `commands/sasl.ts`), so clients on connections where the mechanism
|
|
98
|
+
* cannot succeed do not attempt an exchange the server would refuse
|
|
99
|
+
* with `908 ERR_SASLMECHS`.
|
|
90
100
|
*/
|
|
91
|
-
export function saslCapValue(
|
|
92
|
-
return
|
|
101
|
+
export function saslCapValue(externalAvailable: boolean): string {
|
|
102
|
+
return externalAvailable ? 'PLAIN,EXTERNAL' : 'PLAIN';
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
/** Set form for fast membership checks during `CAP REQ`. */
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CertFP — canonical client-certificate identity for SASL EXTERNAL.
|
|
3
|
+
*
|
|
4
|
+
* The edge platforms terminate TLS and surface the verified client
|
|
5
|
+
* certificate to the adapter, but neither Cloudflare Workers nor AWS API
|
|
6
|
+
* Gateway hands over the certificate DER itself:
|
|
7
|
+
*
|
|
8
|
+
* - Cloudflare surfaces the SHA-256 fingerprint of the DER on
|
|
9
|
+
* `request.cf.tlsClientAuth.certFingerprintSHA256` (lowercase hex) — the
|
|
10
|
+
* strongest identifier available and the preferred account binding.
|
|
11
|
+
* - AWS API Gateway surfaces only `requestContext.identity.clientCertSubjectDN`
|
|
12
|
+
* (no fingerprint, no DER) — the subject DN is the only identifier.
|
|
13
|
+
*
|
|
14
|
+
* To make subject-DN bindings robust across those platforms (and across
|
|
15
|
+
* cert re-issuances that reorder or re-space the same DN), the DN is
|
|
16
|
+
* canonicalised before comparison:
|
|
17
|
+
*
|
|
18
|
+
* 1. Attribute **types** are lowercased (`CN=` → `cn=`); attribute
|
|
19
|
+
* **values** keep their case (values are compared case-sensitively).
|
|
20
|
+
* 2. Whitespace around types/values is trimmed and runs of whitespace
|
|
21
|
+
* inside a value collapse to a single space (escaped whitespace, e.g.
|
|
22
|
+
* a trailing `\ `, is preserved verbatim).
|
|
23
|
+
* 3. RDNs (`a,b`) and the AVAs of a multi-valued RDN (`a+b`) are sorted,
|
|
24
|
+
* so two semantically-equal DNs written in different orders collapse
|
|
25
|
+
* to one canonical spelling.
|
|
26
|
+
* 4. RFC 4514 escapes (`\,`, `\+`, `\=` …) are kept intact — the
|
|
27
|
+
* canonicaliser never splits on an escaped separator.
|
|
28
|
+
*
|
|
29
|
+
* Accounts bind to either a DER fingerprint (`fp:<hex>` entries in
|
|
30
|
+
* `RegisteredNick.certSubjects`, preferred) or a canonical DN. Legacy
|
|
31
|
+
* verbatim subject bindings still verify — `ServicesStore.verifyCertFP`
|
|
32
|
+
* canonicalises stored entries at compare time and rewrites them to the
|
|
33
|
+
* canonical spelling on first use.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Marker prefix for fingerprint bindings stored in
|
|
38
|
+
* `RegisteredNick.certSubjects` (vs canonical-DN bindings).
|
|
39
|
+
*/
|
|
40
|
+
export const CERT_FP_PREFIX = 'fp:';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The verified client-certificate identity of one connection, as resolved
|
|
44
|
+
* by an {@link MtlsIdentityProvider}.
|
|
45
|
+
*
|
|
46
|
+
* - `subject` — the raw platform-surfaced subject string, verbatim.
|
|
47
|
+
* - `canonicalDn` — {@link canonicalizeCertSubject} of `subject`.
|
|
48
|
+
* - `fingerprint` — the SHA-256 fingerprint of the certificate DER
|
|
49
|
+
* (lowercase hex) when the platform surfaces one; absent otherwise
|
|
50
|
+
* (API Gateway surfaces no fingerprint today).
|
|
51
|
+
*/
|
|
52
|
+
export interface CertIdentity {
|
|
53
|
+
readonly subject: string;
|
|
54
|
+
readonly canonicalDn: string;
|
|
55
|
+
readonly fingerprint?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Splits `s` on unescaped `sep` characters, keeping RFC 4514 `\x` escape
|
|
60
|
+
* pairs intact within each part.
|
|
61
|
+
*/
|
|
62
|
+
function splitUnescaped(s: string, sep: string): string[] {
|
|
63
|
+
const parts: string[] = [];
|
|
64
|
+
let current = '';
|
|
65
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
66
|
+
const ch = s.charAt(i);
|
|
67
|
+
const next = s.charAt(i + 1);
|
|
68
|
+
if (ch === '\\' && next !== '') {
|
|
69
|
+
current += ch + next;
|
|
70
|
+
i += 1;
|
|
71
|
+
} else if (ch === sep) {
|
|
72
|
+
parts.push(current);
|
|
73
|
+
current = '';
|
|
74
|
+
} else {
|
|
75
|
+
current += ch;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
parts.push(current);
|
|
79
|
+
return parts;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Index of the first unescaped occurrence of `ch`, or `-1`. Used to split
|
|
84
|
+
* an AVA into type and value without splitting on `\=`.
|
|
85
|
+
*/
|
|
86
|
+
function indexUnescaped(s: string, ch: string): number {
|
|
87
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
88
|
+
if (s.charAt(i) === '\\') {
|
|
89
|
+
i += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (s.charAt(i) === ch) return i;
|
|
93
|
+
}
|
|
94
|
+
return -1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Collapses runs of unescaped whitespace to single spaces and trims
|
|
99
|
+
* leading/trailing unescaped whitespace. Escape pairs (`\x`) pass through
|
|
100
|
+
* verbatim and terminate any pending whitespace run.
|
|
101
|
+
*/
|
|
102
|
+
function collapseWhitespace(s: string): string {
|
|
103
|
+
let out = '';
|
|
104
|
+
let pendingSpace = false;
|
|
105
|
+
let started = false;
|
|
106
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
107
|
+
const ch = s.charAt(i);
|
|
108
|
+
const next = s.charAt(i + 1);
|
|
109
|
+
if (ch === '\\' && next !== '') {
|
|
110
|
+
if (pendingSpace) {
|
|
111
|
+
out += ' ';
|
|
112
|
+
pendingSpace = false;
|
|
113
|
+
}
|
|
114
|
+
out += ch + next;
|
|
115
|
+
i += 1;
|
|
116
|
+
started = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (/\s/.test(ch)) {
|
|
120
|
+
if (started) pendingSpace = true;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (pendingSpace) {
|
|
124
|
+
out += ' ';
|
|
125
|
+
pendingSpace = false;
|
|
126
|
+
}
|
|
127
|
+
out += ch;
|
|
128
|
+
started = true;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Canonicalises one attribute-value assertion (`type=value`). */
|
|
134
|
+
function canonicalAva(ava: string): string {
|
|
135
|
+
const eq = indexUnescaped(ava, '=');
|
|
136
|
+
if (eq === -1) return collapseWhitespace(ava);
|
|
137
|
+
const type = collapseWhitespace(ava.slice(0, eq)).toLowerCase();
|
|
138
|
+
const value = collapseWhitespace(ava.slice(eq + 1));
|
|
139
|
+
return `${type}=${value}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Canonicalises an RFC 4514-style DN for byte-stable comparison:
|
|
144
|
+
* lowercase attribute types (values keep their case), collapsed/trimmed
|
|
145
|
+
* whitespace, sorted RDNs, and sorted AVAs inside multi-valued RDNs.
|
|
146
|
+
* Escape sequences pass through unchanged.
|
|
147
|
+
*/
|
|
148
|
+
export function canonicalizeCertSubject(dn: string): string {
|
|
149
|
+
return splitUnescaped(dn, ',')
|
|
150
|
+
.map((rdn) =>
|
|
151
|
+
splitUnescaped(rdn, '+')
|
|
152
|
+
.map((ava) => canonicalAva(ava))
|
|
153
|
+
.sort()
|
|
154
|
+
.join('+'),
|
|
155
|
+
)
|
|
156
|
+
.sort()
|
|
157
|
+
.join(',');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Normalises a DER SHA-256 fingerprint into the stored binding form:
|
|
162
|
+
* `fp:` + trimmed, lowercased, colon-free hex.
|
|
163
|
+
*/
|
|
164
|
+
export function certFingerprintId(fingerprint: string): string {
|
|
165
|
+
return `${CERT_FP_PREFIX}${fingerprint.trim().toLowerCase().replace(/:/g, '')}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Builds the {@link CertIdentity} for a platform-surfaced subject (and the
|
|
170
|
+
* platform fingerprint, when one was provided). Pure; adapters call this
|
|
171
|
+
* at admission time.
|
|
172
|
+
*/
|
|
173
|
+
export function certIdentityFromSubject(subject: string, fingerprint?: string): CertIdentity {
|
|
174
|
+
const canonicalDn = canonicalizeCertSubject(subject);
|
|
175
|
+
return fingerprint === undefined
|
|
176
|
+
? { subject, canonicalDn }
|
|
177
|
+
: { subject, canonicalDn, fingerprint };
|
|
178
|
+
}
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
*
|
|
14
14
|
* {@link passBasedAccountAuth} is the legacy IRC convention of carrying
|
|
15
15
|
* NickServ credentials in `PASS <nick>:<password>` at registration; it
|
|
16
|
-
* resolves at registration completion via the
|
|
17
|
-
* {@link
|
|
16
|
+
* resolves at registration completion via the bound
|
|
17
|
+
* {@link ServicesStore} (`services.verifyNick` — the same single
|
|
18
|
+
* credential home NickServ `REGISTER`, SASL PLAIN, and NickServ
|
|
19
|
+
* `IDENTIFY` consult) and, on success, delegates to
|
|
18
20
|
* {@link applyAccountSuccess}.
|
|
19
21
|
*/
|
|
20
22
|
|
|
@@ -129,17 +131,20 @@ export function passBasedAccountAuth(state: ConnectionState, ctx: Ctx): EffectTy
|
|
|
129
131
|
* in `passReducer` (reading the `PASS` param directly), so the two entry
|
|
130
132
|
* points share one verify+success path and never diverge.
|
|
131
133
|
*
|
|
132
|
-
*
|
|
134
|
+
* Credentials are verified against the bound {@link ServicesStore} via
|
|
135
|
+
* `services.verifyNick` — the same single credential home NickServ
|
|
136
|
+
* `REGISTER`, SASL PLAIN, and NickServ `IDENTIFY` consult. Returns the
|
|
137
|
+
* account-login success effects (`900`/`903`/`+r`/read-marker
|
|
133
138
|
* seeding/away replay/memo delivery/`account-notify`), or `[]` for every
|
|
134
139
|
* other outcome:
|
|
135
140
|
* - already identified (`state.account` set, e.g. via SASL) → no-op;
|
|
136
141
|
* - no `<nick>:<password>` form (bare value or undefined) → no-op;
|
|
137
142
|
* - payload nick does not match `state.nick` (when set) → no-op;
|
|
138
|
-
* - no {@link
|
|
143
|
+
* - no {@link ServicesStore} bound → no-op;
|
|
139
144
|
* - verify failure (unknown nick or wrong password) → no-op.
|
|
140
145
|
*
|
|
141
146
|
* A failed verify is followed by a verify against a fixed dummy entry so
|
|
142
|
-
* the failure path performs the same
|
|
147
|
+
* the failure path performs the same scrypt work whether the payload
|
|
143
148
|
* nick was unknown or the password was wrong — the observable outcome
|
|
144
149
|
* (no numerics, no disconnect, no state change) is identical for both
|
|
145
150
|
* cases, giving no information to a remote attacker.
|
|
@@ -153,24 +158,16 @@ export function attemptPassAccountAuth(
|
|
|
153
158
|
const parsed = parsePassAccountAttempt(attempt);
|
|
154
159
|
if (parsed === null) return [];
|
|
155
160
|
if (state.nick !== undefined && state.nick !== parsed.nick) return [];
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
158
|
-
const result =
|
|
159
|
-
kind: 'PLAIN',
|
|
160
|
-
username: parsed.nick,
|
|
161
|
-
password: parsed.password,
|
|
162
|
-
});
|
|
161
|
+
const services = ctx.services;
|
|
162
|
+
if (services === undefined) return [];
|
|
163
|
+
const result = services.verifyNick(parsed.nick, parsed.password);
|
|
163
164
|
if (result.ok) {
|
|
164
165
|
return applyAccountSuccess(state, ctx, result.account);
|
|
165
166
|
}
|
|
166
167
|
// Equalise timing: perform a verify against a fixed dummy entry so a
|
|
167
|
-
// failed PASS-auth always costs one extra
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
kind: 'PLAIN',
|
|
171
|
-
username: PASS_AUTH_DUMMY_NICK,
|
|
172
|
-
password: PASS_AUTH_DUMMY_PASSWORD,
|
|
173
|
-
});
|
|
168
|
+
// failed PASS-auth always costs one extra scrypt verify regardless of
|
|
169
|
+
// whether the payload nick was unknown or the password was wrong.
|
|
170
|
+
services.verifyNick(PASS_AUTH_DUMMY_NICK, PASS_AUTH_DUMMY_PASSWORD);
|
|
174
171
|
return [];
|
|
175
172
|
}
|
|
176
173
|
|
|
@@ -94,11 +94,19 @@ function handleLs(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionSta
|
|
|
94
94
|
state.capNegotiating = true;
|
|
95
95
|
|
|
96
96
|
const target = state.nick ?? '*';
|
|
97
|
-
|
|
97
|
+
// Same gate as the AUTHENTICATE reducer (no transport-level override +
|
|
98
|
+
// mTLS source + operator opt-in + secure transport), so the `sasl` cap
|
|
99
|
+
// never advertises a mechanism the reducer would refuse — with 908, or
|
|
100
|
+
// with the transport-specific 904 under the override.
|
|
101
|
+
const external =
|
|
102
|
+
ctx.serverConfig.sasl?.externalUnsupportedMessage === undefined &&
|
|
103
|
+
ctx.mtlsIdentity !== undefined &&
|
|
104
|
+
(ctx.serverConfig.sasl?.externalEnabled ?? false) &&
|
|
105
|
+
ctx.connection.secure;
|
|
98
106
|
const multilineMax = ctx.serverConfig.multilineMaxBytes ?? DEFAULT_MULTILINE_MAX_BYTES;
|
|
99
107
|
const tokens = SUPPORTED_CAPABILITIES.map((cap) => {
|
|
100
108
|
if (cap.name === SASL_CAP_NAME) {
|
|
101
|
-
return capToLsString({ ...cap, value: saslCapValue(
|
|
109
|
+
return capToLsString({ ...cap, value: saslCapValue(external) });
|
|
102
110
|
}
|
|
103
111
|
if (cap.name === MULTILINE_CAP_NAME) {
|
|
104
112
|
return capToLsString({ ...cap, value: multilineCapValue(multilineMax) });
|