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
|
@@ -29,13 +29,15 @@
|
|
|
29
29
|
|
|
30
30
|
import { DurableObject } from 'cloudflare:workers';
|
|
31
31
|
import {
|
|
32
|
-
type
|
|
32
|
+
type CertIdentity,
|
|
33
33
|
type ChanName,
|
|
34
34
|
type ChannelState,
|
|
35
35
|
type Clock,
|
|
36
36
|
type ConnId,
|
|
37
37
|
type ConnectionState,
|
|
38
38
|
ConsoleLogger,
|
|
39
|
+
FRAME_FLOOD_CLOSE_CODE,
|
|
40
|
+
FRAME_FLOOD_ERROR_LINE,
|
|
39
41
|
type IdFactory,
|
|
40
42
|
InMemoryMessageStore,
|
|
41
43
|
InMemoryMtlsIdentityProvider,
|
|
@@ -46,12 +48,14 @@ import {
|
|
|
46
48
|
type MotdProvider,
|
|
47
49
|
type MtlsIdentityProvider,
|
|
48
50
|
type NickHistoryStore,
|
|
51
|
+
type ParsedServerConfig,
|
|
49
52
|
type RawLine,
|
|
50
|
-
type SaslAccountCredential,
|
|
51
53
|
type ServerConfig,
|
|
52
54
|
SystemClock,
|
|
53
55
|
UuidIdFactory,
|
|
54
56
|
type WsFrameMode,
|
|
57
|
+
admitFrameWindow,
|
|
58
|
+
certIdentityFromSubject,
|
|
55
59
|
createChannel,
|
|
56
60
|
createConnection,
|
|
57
61
|
frameToLines,
|
|
@@ -66,9 +70,9 @@ import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
|
|
|
66
70
|
import { makeCfRuntime } from './cf-runtime.js';
|
|
67
71
|
import type { CfConnectionHandlers } from './cf-runtime.js';
|
|
68
72
|
import { loadServerConfigFromCfEnv } from './config-loader.js';
|
|
69
|
-
import {
|
|
73
|
+
import { COUNTER_INSTANCE_NAME } from './counter-do.js';
|
|
70
74
|
import { type D1ServicesStore, loadD1ServicesStore } from './d1-services-store.js';
|
|
71
|
-
import type { Env } from './env.js';
|
|
75
|
+
import type { CounterRpc, Env } from './env.js';
|
|
72
76
|
import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
|
|
73
77
|
import type { PersistedConnectionState } from './serialize.js';
|
|
74
78
|
import { CfStats } from './stats.js';
|
|
@@ -100,9 +104,11 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
100
104
|
* Cached reducer-facing server config. Parsed once from `this.env`
|
|
101
105
|
* (bindings are stable for the DO's lifetime) so the schema-validated
|
|
102
106
|
* `loadServerConfigFromCfEnv` runs a single time rather than on every
|
|
103
|
-
* WebSocket frame.
|
|
107
|
+
* WebSocket frame. Held as {@link ParsedServerConfig} so the adapter
|
|
108
|
+
* can read extra fields (e.g. `saslAccounts` for the services seed)
|
|
109
|
+
* the reducer-facing `ServerConfig` view omits.
|
|
104
110
|
*/
|
|
105
|
-
private
|
|
111
|
+
private cachedParsedConfig: ParsedServerConfig | undefined;
|
|
106
112
|
/**
|
|
107
113
|
* Per-instance channel-state cache. Reused across every WebSocket frame
|
|
108
114
|
* the connection handles (within a single hibernation cycle) so that
|
|
@@ -142,43 +148,35 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
142
148
|
* keyed by nick) is a documented follow-up. Lazily constructed.
|
|
143
149
|
*/
|
|
144
150
|
private history: NickHistoryStore | undefined;
|
|
145
|
-
/**
|
|
146
|
-
* SASL account store, resolved once via {@link loadAccountStore} before the
|
|
147
|
-
* first frame dispatches. The two-phase load mirrors AWS's
|
|
148
|
-
* `loadDynamoAccountStore`: D1 `accounts` table (authoritative when it has
|
|
149
|
-
* rows) is queried at boot; when empty/unreachable the `SASL_ACCOUNTS`
|
|
150
|
-
* env-var seed provides the fallback `InMemoryAccountStore`. The
|
|
151
|
-
* `AccountStore.verify` port is synchronous, so pre-loading MUST complete
|
|
152
|
-
* before the first reducer runs.
|
|
153
|
-
*/
|
|
154
|
-
private accounts: AccountStore | undefined;
|
|
155
|
-
/** Guards {@link loadAccountStore} so the D1 query runs at most once. */
|
|
156
|
-
private accountsResolved = false;
|
|
157
151
|
/**
|
|
158
152
|
* Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
|
|
159
153
|
* OperServ + read-marker), hydrated once via {@link loadServicesStore}
|
|
160
154
|
* before the first frame dispatches. The store mutates the in-memory
|
|
161
155
|
* cache synchronously and enqueues write-behind ops; {@link flushServices}
|
|
162
156
|
* drains them into D1 after each frame / at teardown. `undefined` when
|
|
163
|
-
* D1 is not configured (services unbound → no NickServ/ChanServ routing
|
|
157
|
+
* D1 is not configured (services unbound → no NickServ/ChanServ routing,
|
|
158
|
+
* no SASL/PASS credential verify).
|
|
164
159
|
*/
|
|
165
160
|
private services: D1ServicesStore | undefined;
|
|
166
161
|
/** Guards {@link loadServicesStore} so the D1 scan runs at most once. */
|
|
167
162
|
private servicesResolved = false;
|
|
168
163
|
/**
|
|
169
|
-
* Verified client-cert
|
|
164
|
+
* Verified client-cert identity captured at WebSocket upgrade time when
|
|
170
165
|
* CF API Shield mTLS is configured. Surfaced to the SASL EXTERNAL reducer
|
|
171
166
|
* via an {@link MtlsIdentityProvider} so `AUTHENTICATE EXTERNAL` can map
|
|
172
|
-
* the
|
|
167
|
+
* the certificate to an account (DER SHA-256 fingerprint preferred,
|
|
168
|
+
* canonical DN fallback — see `docs/SASL-EXTERNAL.md`).
|
|
173
169
|
*
|
|
174
170
|
* CF API Shield mTLS setup:
|
|
175
171
|
* 1. Upload the client CA to API Shield (dashboard → Security → API Shield).
|
|
176
172
|
* 2. Create an mTLS policy on the custom hostname that requires client certs.
|
|
177
173
|
* 3. The verified subject is injected into `request.cf.tlsClientAuthCertSubject`
|
|
178
|
-
*
|
|
174
|
+
* (and `request.cf.tlsClientAuth.certFingerprintSHA256` carries the DER
|
|
175
|
+
* SHA-256 fingerprint) on every request, which the Worker reads at
|
|
176
|
+
* upgrade time.
|
|
179
177
|
* When unset (no mTLS configured), EXTERNAL auth fails with `904`.
|
|
180
178
|
*/
|
|
181
|
-
private
|
|
179
|
+
private mtlsCert: CertIdentity | undefined;
|
|
182
180
|
/**
|
|
183
181
|
* Client source IP captured at WebSocket upgrade time, surfaced onto the
|
|
184
182
|
* {@link ConnectionState.host} field so WHOIS / hostmasks resolve instead
|
|
@@ -190,6 +188,23 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
190
188
|
* this raw IP with a deterministic cloak at registration time.
|
|
191
189
|
*/
|
|
192
190
|
private sourceHost: string | undefined;
|
|
191
|
+
/**
|
|
192
|
+
* Timestamps of recently admitted inbound frames (the sliding window
|
|
193
|
+
* backing the adapter-boundary rate limit, see `admitFrameWindow`).
|
|
194
|
+
* Kept in memory only: while frames are flowing the DO stays resident
|
|
195
|
+
* (so an active flood is always counted), and after eviction — which
|
|
196
|
+
* requires idleness far longer than the 5 s default window — every
|
|
197
|
+
* retained timestamp would have decayed out anyway. The gate fires
|
|
198
|
+
* BEFORE the actor / storage write so over-limit frames cost neither.
|
|
199
|
+
*/
|
|
200
|
+
private inboundFrameTimes: number[] = [];
|
|
201
|
+
/**
|
|
202
|
+
* Test-only override for the inbound frame window (`maxFrames` /
|
|
203
|
+
* `windowMs`), bypassing the env-sourced `adapter` config so the decay
|
|
204
|
+
* behaviour is testable without real multi-second sleeps. `undefined`
|
|
205
|
+
* in production.
|
|
206
|
+
*/
|
|
207
|
+
private frameWindowOverride: { maxFrames: number; windowMs: number } | undefined;
|
|
193
208
|
private readonly clock: Clock = SystemClock;
|
|
194
209
|
private readonly ids: IdFactory = new UuidIdFactory();
|
|
195
210
|
private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
|
|
@@ -218,14 +233,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
218
233
|
const pair = new WebSocketPair();
|
|
219
234
|
const server = pair[0];
|
|
220
235
|
const client = pair[1];
|
|
221
|
-
|
|
222
|
-
// active. `request.cf` is a Cloudflare-specific extension; the property
|
|
223
|
-
// is present only when a client certificate was verified against the
|
|
224
|
-
// uploaded CA pool.
|
|
225
|
-
const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
|
|
226
|
-
if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
|
|
227
|
-
this.mtlsCertSubject = cf.tlsClientAuthCertSubject;
|
|
228
|
-
}
|
|
236
|
+
this.captureMtlsSubject(request);
|
|
229
237
|
// Capture the client source IP for ConnectionState.host. CF-Connecting-IP
|
|
230
238
|
// is set by the Cloudflare edge on every proxied request; X-Real-IP is
|
|
231
239
|
// the conventional fallback for non-CF reverse proxies. Stashed on the
|
|
@@ -259,6 +267,16 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
259
267
|
/** Frame → actor → dispatch → persist. */
|
|
260
268
|
override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
261
269
|
const mode = this.wsFrameMode(ws);
|
|
270
|
+
// Adapter-boundary rate limit: consult the sliding window BEFORE any
|
|
271
|
+
// per-frame work (state load, actor run, storage write, D1 flush).
|
|
272
|
+
// An over-limit frame delivers the canonical ERROR line and closes
|
|
273
|
+
// the socket with RFC 6455 close code 1008 (Policy Violation); the
|
|
274
|
+
// normal close handler runs the QUIT teardown.
|
|
275
|
+
if (!this.admitInboundFrame()) {
|
|
276
|
+
this.sendOutbound(ws, [{ text: FRAME_FLOOD_ERROR_LINE }], mode);
|
|
277
|
+
ws.close(FRAME_FLOOD_CLOSE_CODE, 'flooding');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
262
280
|
// text.ircv3.net is a text-only subprotocol: a binary frame on such a
|
|
263
281
|
// connection is a protocol violation. RFC 6455 close code 1003 (unsupported
|
|
264
282
|
// data) is the spec-mandated response.
|
|
@@ -283,7 +301,6 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
283
301
|
const state = await this.loadState();
|
|
284
302
|
const beforeNick = state.nick;
|
|
285
303
|
|
|
286
|
-
await this.loadAccountStore();
|
|
287
304
|
await this.loadServicesStore();
|
|
288
305
|
const actor = this.buildActor(ws, state, mode);
|
|
289
306
|
try {
|
|
@@ -360,6 +377,10 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
360
377
|
const socket = ws as WebSocket;
|
|
361
378
|
this.sendOutbound(socket, [{ text: `PING :${token}` }], this.wsFrameMode(socket));
|
|
362
379
|
}
|
|
380
|
+
// Refresh the admission slot so the counter's lazy TTL reconcile
|
|
381
|
+
// only evicts entries from DOs that stopped heartbeating (crashed or
|
|
382
|
+
// evicted without a close event). No-op for unregistered connections.
|
|
383
|
+
await this.refreshConnectionSlot();
|
|
363
384
|
await this.ctx.storage.setAlarm(now + this.pingIntervalMs);
|
|
364
385
|
}
|
|
365
386
|
|
|
@@ -388,17 +409,39 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
388
409
|
* socket count without sending anything. Used by ChannelDO.sweep.
|
|
389
410
|
*/
|
|
390
411
|
async deliver(lines: RawLine[]): Promise<{ delivered: number }> {
|
|
412
|
+
return this.deliverToSockets(this.ctx.getWebSockets() as unknown as WebSocket[], lines);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Iterates `sockets`, delivering `lines` to each OPEN one and counting
|
|
417
|
+
* the writes. Extracted from {@link deliver} so the false arm of the
|
|
418
|
+
* tryDeliver check is testable with a fake non-OPEN socket — the real
|
|
419
|
+
* `ctx.getWebSockets()` only returns OPEN sockets in the test runtime.
|
|
420
|
+
*/
|
|
421
|
+
public deliverToSockets(sockets: WebSocket[], lines: RawLine[]): { delivered: number } {
|
|
391
422
|
let delivered = 0;
|
|
392
|
-
for (const ws of
|
|
393
|
-
|
|
394
|
-
if (socket.readyState !== WebSocket.OPEN) continue;
|
|
395
|
-
const mode = this.wsFrameMode(socket);
|
|
396
|
-
this.sendOutbound(socket, lines, mode);
|
|
397
|
-
delivered++;
|
|
423
|
+
for (const ws of sockets) {
|
|
424
|
+
delivered += this.tryDeliver(ws, lines) ? 1 : 0;
|
|
398
425
|
}
|
|
399
426
|
return { delivered };
|
|
400
427
|
}
|
|
401
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Delivers `lines` to a single attached WebSocket. Returns `false`
|
|
431
|
+
* (without writing) when the socket is no longer OPEN, `true` after a
|
|
432
|
+
* successful write. Public so the readyState guard is testable with a
|
|
433
|
+
* fake socket — the real `ctx.getWebSockets()` only returns OPEN
|
|
434
|
+
* sockets in the test runtime, but production may briefly surface
|
|
435
|
+
* CLOSING sockets between the unilateral-close and the close event
|
|
436
|
+
* reaching the DO.
|
|
437
|
+
*/
|
|
438
|
+
public tryDeliver(ws: WebSocket, lines: RawLine[]): boolean {
|
|
439
|
+
if (ws.readyState !== WebSocket.OPEN) return false;
|
|
440
|
+
const mode = this.wsFrameMode(ws);
|
|
441
|
+
this.sendOutbound(ws, lines, mode);
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
|
|
402
445
|
// -------------------------------------------------------------------------
|
|
403
446
|
// Internal helpers
|
|
404
447
|
// -------------------------------------------------------------------------
|
|
@@ -441,6 +484,48 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
441
484
|
ws.send(`${lines.map((l) => l.text).join('\r\n')}\r\n`);
|
|
442
485
|
}
|
|
443
486
|
|
|
487
|
+
/**
|
|
488
|
+
* Test-only seam that exposes {@link sendOutbound} so the readyState
|
|
489
|
+
* early-return is reachable with a fake non-OPEN socket. Production
|
|
490
|
+
* callers always pass an OPEN socket (the {@link deliver} and
|
|
491
|
+
* {@link tryDeliver} guards filter first), so the path cannot be
|
|
492
|
+
* exercised via real RPC.
|
|
493
|
+
*/
|
|
494
|
+
public __sendOutboundForTest(ws: WebSocket, lines: RawLine[], mode: WsFrameMode): void {
|
|
495
|
+
this.sendOutbound(ws, lines, mode);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Applies the per-connection inbound frame window to the frame being
|
|
500
|
+
* received: prunes decayed timestamps, records this frame when it
|
|
501
|
+
* fits, and returns whether it was admitted. The window config comes
|
|
502
|
+
* from the schema-validated `adapter` section (default 50 frames /
|
|
503
|
+
* 5 s) unless the test-only override is installed.
|
|
504
|
+
*/
|
|
505
|
+
private admitInboundFrame(): boolean {
|
|
506
|
+
const cfg = this.frameWindowOverride ?? {
|
|
507
|
+
maxFrames: this.parsedConfig().adapter.maxFramesPerWindow,
|
|
508
|
+
windowMs: this.parsedConfig().adapter.frameWindowSeconds * 1000,
|
|
509
|
+
};
|
|
510
|
+
const decision = admitFrameWindow(
|
|
511
|
+
this.inboundFrameTimes,
|
|
512
|
+
this.clock.now(),
|
|
513
|
+
cfg.maxFrames,
|
|
514
|
+
cfg.windowMs,
|
|
515
|
+
);
|
|
516
|
+
this.inboundFrameTimes = decision.times;
|
|
517
|
+
return decision.admitted;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Test-only seam installing a tight inbound frame window (or clearing
|
|
522
|
+
* it with `undefined`) without redeploying the worker env. See
|
|
523
|
+
* {@link frameWindowOverride}.
|
|
524
|
+
*/
|
|
525
|
+
public __setFrameWindowForTest(cfg: { maxFrames: number; windowMs: number } | undefined): void {
|
|
526
|
+
this.frameWindowOverride = cfg;
|
|
527
|
+
}
|
|
528
|
+
|
|
444
529
|
/** Loads the connection state from storage, caching for the event. */
|
|
445
530
|
private async loadState(): Promise<ConnectionState> {
|
|
446
531
|
if (this.cached !== undefined) return this.cached;
|
|
@@ -484,10 +569,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
484
569
|
this.sendOutbound(ws, lines, mode);
|
|
485
570
|
},
|
|
486
571
|
disconnect: (reason?: string): void => {
|
|
487
|
-
|
|
488
|
-
this.sendOutbound(ws, [{ text: `ERROR :Closing link: (${reason})` }], mode);
|
|
489
|
-
}
|
|
490
|
-
ws.close();
|
|
572
|
+
this.writeDisconnect(ws, mode, reason);
|
|
491
573
|
},
|
|
492
574
|
snapshot: (): ConnectionState | undefined => this.cached,
|
|
493
575
|
};
|
|
@@ -503,11 +585,10 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
503
585
|
// Bound `connectionId` so every record the actor emits is filterable
|
|
504
586
|
// per-connection in the Workers dashboard.
|
|
505
587
|
const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
|
|
506
|
-
const accounts = this.accountStore();
|
|
507
588
|
const services = this.servicesStore();
|
|
508
589
|
const mtlsIdentity: MtlsIdentityProvider | undefined =
|
|
509
|
-
this.
|
|
510
|
-
? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.
|
|
590
|
+
this.mtlsCert !== undefined
|
|
591
|
+
? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.mtlsCert }])
|
|
511
592
|
: undefined;
|
|
512
593
|
return new ConnectionActor({
|
|
513
594
|
state,
|
|
@@ -529,17 +610,48 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
529
610
|
// Cloudflare terminates TLS at the edge before the WebSocket reaches
|
|
530
611
|
// the DO, so every WS connection is secure → user mode `S`.
|
|
531
612
|
secure: true,
|
|
532
|
-
...(
|
|
533
|
-
...(services !== undefined ? { services } : {}),
|
|
534
|
-
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
613
|
+
...buildOptionalActorFields(services, mtlsIdentity),
|
|
535
614
|
logger,
|
|
536
615
|
});
|
|
537
616
|
}
|
|
538
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Writes the optional `ERROR :Closing link` line and closes the socket.
|
|
620
|
+
* Extracted from the {@link buildActor} disconnect callback so the
|
|
621
|
+
* reason-gated branch is testable without driving a full actor flow
|
|
622
|
+
* that emits a self-Disconnect with a reason (rare — OperServ GHOST,
|
|
623
|
+
* KILL, flood, bad server-password).
|
|
624
|
+
*/
|
|
625
|
+
private writeDisconnect(ws: WebSocket, mode: WsFrameMode, reason?: string): void {
|
|
626
|
+
if (reason !== undefined) {
|
|
627
|
+
this.sendOutbound(ws, [{ text: `ERROR :Closing link: (${reason})` }], mode);
|
|
628
|
+
}
|
|
629
|
+
ws.close();
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Test-only seam that exposes {@link closeIfStillOpen} so the
|
|
634
|
+
* readyState-guarded `ws.close()` is reachable with a fake OPEN
|
|
635
|
+
* socket. The real QUIT flow always closes the WS via the actor's
|
|
636
|
+
* self-Disconnect handler before tearDown reaches this guard.
|
|
637
|
+
*/
|
|
638
|
+
public __closeIfStillOpenForTest(ws: WebSocket): void {
|
|
639
|
+
this.closeIfStillOpen(ws);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Test-only seam that exposes {@link writeDisconnect} so the
|
|
644
|
+
* reason-gated `ERROR :Closing link` write is reachable without
|
|
645
|
+
* driving a full actor flow that emits a self-Disconnect with a
|
|
646
|
+
* reason (rare — OperServ GHOST, KILL, flood, bad server-password).
|
|
647
|
+
*/
|
|
648
|
+
public __writeDisconnectForTest(ws: WebSocket, mode: WsFrameMode, reason?: string): void {
|
|
649
|
+
this.writeDisconnect(ws, mode, reason);
|
|
650
|
+
}
|
|
651
|
+
|
|
539
652
|
/** Tears down a connection: emit QUIT effects, release nick, close WS. */
|
|
540
653
|
private async tearDown(ws: WebSocket): Promise<void> {
|
|
541
654
|
const state = await this.loadState();
|
|
542
|
-
await this.loadAccountStore();
|
|
543
655
|
await this.loadServicesStore();
|
|
544
656
|
const actor = this.buildActor(ws, state, this.wsFrameMode(ws));
|
|
545
657
|
// Drive QUIT through the actor so the same effect pipeline handles
|
|
@@ -547,9 +659,8 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
547
659
|
await actor.receiveTextFrame('QUIT\r\n');
|
|
548
660
|
await this.releaseAndPersist(state);
|
|
549
661
|
await this.flushServices();
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
}
|
|
662
|
+
await this.releaseConnectionSlot();
|
|
663
|
+
this.closeIfStillOpen(ws);
|
|
553
664
|
try {
|
|
554
665
|
await this.ctx.storage.deleteAlarm();
|
|
555
666
|
} catch {
|
|
@@ -557,6 +668,21 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
557
668
|
}
|
|
558
669
|
}
|
|
559
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Closes `ws` when it is still in a state where calling `close()` is
|
|
673
|
+
* meaningful (OPEN or CONNECTING). The QUIT flow drives a self-Disconnect
|
|
674
|
+
* that already closes the socket, so by the time {@link tearDown} reaches
|
|
675
|
+
* this guard the WS is usually CLOSING/CLOSED; the helper is a defensive
|
|
676
|
+
* backstop for the rare path where the actor does not emit a self-
|
|
677
|
+
* Disconnect. Extracted as a public seam so both arms are testable with
|
|
678
|
+
* a fake socket — the real QUIT flow always hits the false arm.
|
|
679
|
+
*/
|
|
680
|
+
private closeIfStillOpen(ws: WebSocket): void {
|
|
681
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
682
|
+
ws.close();
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
560
686
|
/** Releases the nick via the registry and persists the (possibly mutated) state. */
|
|
561
687
|
private async releaseAndPersist(state: ConnectionState): Promise<void> {
|
|
562
688
|
if (state.nick !== undefined) {
|
|
@@ -569,6 +695,32 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
569
695
|
await this.persistState(state);
|
|
570
696
|
}
|
|
571
697
|
|
|
698
|
+
/**
|
|
699
|
+
* Releases this connection's admission slot in the global counter
|
|
700
|
+
* (`maxClients` cap). The key is this DO's hex id — exactly what the
|
|
701
|
+
* worker edge registered at upgrade time (`idFromName(uuid).toString()`),
|
|
702
|
+
* so no id needs to be passed through the upgrade request. Idempotent
|
|
703
|
+
* (deleting an absent key is a no-op): connections opened directly
|
|
704
|
+
* against the DO (integration harness, cross-DO RPC) never registered
|
|
705
|
+
* a slot and tear down harmlessly.
|
|
706
|
+
*/
|
|
707
|
+
private async releaseConnectionSlot(): Promise<void> {
|
|
708
|
+
const stub = this.env.COUNTER_DO.get(this.env.COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME));
|
|
709
|
+
await (stub as unknown as CounterRpc).release(this.ctx.id.toString());
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Refreshes the admission slot's `lastSeen` so the counter's lazy TTL
|
|
714
|
+
* reconcile only evicts entries whose ConnectionDO stopped
|
|
715
|
+
* heartbeating (crashed / evicted without delivering a close event).
|
|
716
|
+
* Called from the PING alarm; no-op for connections that never
|
|
717
|
+
* registered a slot.
|
|
718
|
+
*/
|
|
719
|
+
private async refreshConnectionSlot(): Promise<void> {
|
|
720
|
+
const stub = this.env.COUNTER_DO.get(this.env.COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME));
|
|
721
|
+
await (stub as unknown as CounterRpc).heartbeat(this.ctx.id.toString());
|
|
722
|
+
}
|
|
723
|
+
|
|
572
724
|
private serverConfig(): ServerConfig {
|
|
573
725
|
// Single source of truth: delegate to the schema-validated loader so
|
|
574
726
|
// every reducer-facing field (operCreds, serverPassword, motdLines,
|
|
@@ -577,19 +729,24 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
577
729
|
// silently dropped fields like operCreds, which left OPER unable to
|
|
578
730
|
// authenticate (491 ERR_NOOPERHOST) even when OPER_USER/OPER_PASSWORD
|
|
579
731
|
// were bound. See config-loader.ts and config.ts.
|
|
580
|
-
|
|
581
|
-
|
|
732
|
+
return this.parsedConfig();
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Returns the full parsed config (the schema-validated superset of
|
|
737
|
+
* `ServerConfig`). The adapter reads fields the reducer-facing
|
|
738
|
+
* `ServerConfig` omits — `saslAccounts` (services seed), `motdLines`
|
|
739
|
+
* (MOTD provider), `maxClients` (admission), …
|
|
740
|
+
*/
|
|
741
|
+
private parsedConfig(): ParsedServerConfig {
|
|
742
|
+
if (this.cachedParsedConfig === undefined) {
|
|
743
|
+
this.cachedParsedConfig = loadServerConfigFromCfEnv(this.env);
|
|
582
744
|
}
|
|
583
|
-
return this.
|
|
745
|
+
return this.cachedParsedConfig;
|
|
584
746
|
}
|
|
585
747
|
|
|
586
748
|
private motdProvider(): MotdProvider {
|
|
587
|
-
|
|
588
|
-
if (raw === undefined || raw.length === 0) {
|
|
589
|
-
return { lines: () => DEFAULT_MOTD };
|
|
590
|
-
}
|
|
591
|
-
// MOTD_LINES is a newline-delimited env var.
|
|
592
|
-
return { lines: () => raw.split('\n') };
|
|
749
|
+
return chooseMotdProvider(this.env.MOTD_LINES);
|
|
593
750
|
}
|
|
594
751
|
|
|
595
752
|
/**
|
|
@@ -617,41 +774,32 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
617
774
|
return this.history;
|
|
618
775
|
}
|
|
619
776
|
|
|
620
|
-
/**
|
|
621
|
-
* Resolves the SASL account store once (D1 table → `SASL_ACCOUNTS` seed),
|
|
622
|
-
* caching the result. Safe to call on every frame; the D1 query runs at
|
|
623
|
-
* most once per ConnectionDO instance. Must complete before the first
|
|
624
|
-
* reducer dispatches so the synchronous `AccountStore.verify` port has its
|
|
625
|
-
* data ready.
|
|
626
|
-
*/
|
|
627
|
-
private async loadAccountStore(): Promise<void> {
|
|
628
|
-
if (this.accountsResolved) return;
|
|
629
|
-
this.accountsResolved = true;
|
|
630
|
-
this.accounts = await resolveAccountStore(this.env.ACCOUNTS_DB, this.env.SASL_ACCOUNTS);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
/**
|
|
634
|
-
* Returns the resolved account store, or `undefined` when no accounts are
|
|
635
|
-
* configured (so the actor's `ctx.accounts` stays unset: `AUTHENTICATE
|
|
636
|
-
* PLAIN` → `904`). Populated by {@link loadAccountStore}; callers MUST
|
|
637
|
-
* `await loadAccountStore()` before reading this.
|
|
638
|
-
*/
|
|
639
|
-
private accountStore(): AccountStore | undefined {
|
|
640
|
-
return this.accounts;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
777
|
/**
|
|
644
778
|
* Resolves the persistent services store once (D1 → write-behind cache),
|
|
645
779
|
* caching the result. Safe to call on every frame; the D1 scan runs at
|
|
646
780
|
* most once per ConnectionDO instance. Must complete before the first
|
|
647
781
|
* reducer dispatches so the synchronous `ServicesStore` port has its
|
|
648
782
|
* snapshot ready. Leaves `services` `undefined` when D1 is absent so the
|
|
649
|
-
* actor's `ctx.services` stays unset (no NickServ/ChanServ routing
|
|
783
|
+
* actor's `ctx.services` stays unset (no NickServ/ChanServ routing,
|
|
784
|
+
* no SASL/PASS credential verify).
|
|
785
|
+
*
|
|
786
|
+
* After the D1 load, the `SASL_ACCOUNTS` env-var seed is ingested:
|
|
787
|
+
* each `username:password` pair is registered via `registerNick` when
|
|
788
|
+
* the nick is not already present. Idempotent — re-ingesting on every
|
|
789
|
+
* cold start is a no-op for nicks the services table already holds
|
|
790
|
+
* (including NickServ-registered nicks whose password wins). New
|
|
791
|
+
* env-seed accounts are persisted by the write-behind flush.
|
|
650
792
|
*/
|
|
651
793
|
private async loadServicesStore(): Promise<void> {
|
|
652
794
|
if (this.servicesResolved) return;
|
|
653
795
|
this.servicesResolved = true;
|
|
654
796
|
this.services = await loadD1ServicesStore(this.env.ACCOUNTS_DB, this.clock);
|
|
797
|
+
if (this.services !== undefined) {
|
|
798
|
+
const cfg = this.parsedConfig();
|
|
799
|
+
for (const acct of cfg.saslAccounts) {
|
|
800
|
+
this.services.registerNick(acct.username, acct.password, '');
|
|
801
|
+
}
|
|
802
|
+
}
|
|
655
803
|
}
|
|
656
804
|
|
|
657
805
|
/**
|
|
@@ -674,6 +822,18 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
674
822
|
await this.services.flush();
|
|
675
823
|
}
|
|
676
824
|
|
|
825
|
+
/**
|
|
826
|
+
* Test-only seam that exposes {@link flushServices} so the
|
|
827
|
+
* "services unbound" early-return is reachable. Production always
|
|
828
|
+
* drives flushServices after `loadServicesStore()` has run (in
|
|
829
|
+
* `webSocketMessage` and `tearDown*`), so the field is populated;
|
|
830
|
+
* the undefined arm fires only on a DO that has never processed a
|
|
831
|
+
* frame.
|
|
832
|
+
*/
|
|
833
|
+
public async __flushServicesForTest(): Promise<void> {
|
|
834
|
+
await this.flushServices();
|
|
835
|
+
}
|
|
836
|
+
|
|
677
837
|
// -------------------------------------------------------------------------
|
|
678
838
|
// Test hooks (only invoked from `runInDurableObject` in tests)
|
|
679
839
|
// -------------------------------------------------------------------------
|
|
@@ -699,13 +859,12 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
699
859
|
private async tearDownNoSocket(state: ConnectionState): Promise<void> {
|
|
700
860
|
// Emit QUIT effects without dispatching transport calls (the socket
|
|
701
861
|
// is already gone). The cross-DO fanout still runs.
|
|
702
|
-
await this.loadAccountStore();
|
|
703
862
|
await this.loadServicesStore();
|
|
704
863
|
const services = this.servicesStore();
|
|
705
864
|
const noOpHandlers: CfConnectionHandlers = {
|
|
706
|
-
send:
|
|
707
|
-
disconnect:
|
|
708
|
-
snapshot: ()
|
|
865
|
+
send: NO_OP_SEND,
|
|
866
|
+
disconnect: NO_OP_DISCONNECT,
|
|
867
|
+
snapshot: this.peekCachedSync.bind(this),
|
|
709
868
|
};
|
|
710
869
|
const registryKey = this.ctx.id.toString();
|
|
711
870
|
const runtime = makeCfRuntime(this.env, state.id, registryKey, noOpHandlers);
|
|
@@ -723,11 +882,12 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
723
882
|
motd: this.motdProvider(),
|
|
724
883
|
messages: this.messageStore(),
|
|
725
884
|
history: this.historyStore(),
|
|
726
|
-
...(services
|
|
885
|
+
...buildOptionalActorFields(services, undefined),
|
|
727
886
|
});
|
|
728
887
|
await actor.receiveTextFrame('QUIT\r\n');
|
|
729
888
|
await this.releaseAndPersist(state);
|
|
730
889
|
await this.flushServices();
|
|
890
|
+
await this.releaseConnectionSlot();
|
|
731
891
|
try {
|
|
732
892
|
await this.ctx.storage.deleteAlarm();
|
|
733
893
|
} catch {
|
|
@@ -735,6 +895,11 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
735
895
|
}
|
|
736
896
|
}
|
|
737
897
|
|
|
898
|
+
/** Synchronous cached-state peek used by the no-socket teardown handlers. */
|
|
899
|
+
private peekCachedSync(): ConnectionState | undefined {
|
|
900
|
+
return this.cached;
|
|
901
|
+
}
|
|
902
|
+
|
|
738
903
|
/** Drops the in-memory state cache, simulating wake-from-hibernation. */
|
|
739
904
|
public __resetCache(): void {
|
|
740
905
|
this.cached = undefined;
|
|
@@ -742,7 +907,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
742
907
|
|
|
743
908
|
/** Returns the live cached state (without forcing a storage read). */
|
|
744
909
|
public async __peekState(): Promise<ConnectionState | undefined> {
|
|
745
|
-
return this.
|
|
910
|
+
return this.peekCachedSync();
|
|
746
911
|
}
|
|
747
912
|
|
|
748
913
|
/** Backdates `lastSeen` to test the alarm-driven disconnect path. */
|
|
@@ -761,6 +926,58 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
761
926
|
this.__suppressCloseTeardown = true;
|
|
762
927
|
}
|
|
763
928
|
|
|
929
|
+
/**
|
|
930
|
+
* Test-only: sets the captured mTLS cert subject, simulating an upgrade
|
|
931
|
+
* request that arrived through CF API Shield mTLS. Used to exercise the
|
|
932
|
+
* `mtlsIdentity` construction branch in {@link buildActor} without
|
|
933
|
+
* having to inject `request.cf` (which is read-only in the workerd
|
|
934
|
+
* test runtime).
|
|
935
|
+
*/
|
|
936
|
+
/**
|
|
937
|
+
* Captures the verified mTLS cert identity off an inbound request and
|
|
938
|
+
* stores it on the instance. Extracted so the assignment branch is
|
|
939
|
+
* testable via {@link __captureMtlsSubjectForTest} (the workerd test
|
|
940
|
+
* runtime strips `request.cf` across the DO RPC boundary, so the
|
|
941
|
+
* inline fetch path cannot be exercised end-to-end).
|
|
942
|
+
*/
|
|
943
|
+
private captureMtlsSubject(request: Request): void {
|
|
944
|
+
const identity = extractMtlsSubject(request);
|
|
945
|
+
if (identity !== undefined) {
|
|
946
|
+
this.mtlsCert = identity;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
public __setMtlsCertSubject(subject: string | undefined): void {
|
|
951
|
+
this.mtlsCert = subject === undefined ? undefined : certIdentityFromSubject(subject);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Test-only: returns the captured mTLS cert subject (or undefined). */
|
|
955
|
+
public async __peekMtlsCertSubject(): Promise<string | undefined> {
|
|
956
|
+
return this.mtlsCert?.subject;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Test-only seam that exposes {@link captureMtlsSubject} so the
|
|
961
|
+
* if-defined assignment can be exercised with a crafted request.cf.
|
|
962
|
+
*/
|
|
963
|
+
public __captureMtlsSubjectForTest(request: Request): void {
|
|
964
|
+
this.captureMtlsSubject(request);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Test-only: invokes {@link webSocketError} against the first attached
|
|
969
|
+
* WebSocket. The platform normally fires this asynchronously on
|
|
970
|
+
* transport errors; calling it here deterministically exercises the
|
|
971
|
+
* handler body. The WebSocket must have been accepted via `fetch` so
|
|
972
|
+
* `ctx.getWebSockets()` returns it.
|
|
973
|
+
*/
|
|
974
|
+
public async __triggerWsError(error: unknown): Promise<void> {
|
|
975
|
+
const sockets = this.ctx.getWebSockets();
|
|
976
|
+
const ws = sockets[0];
|
|
977
|
+
if (ws === undefined) return;
|
|
978
|
+
await this.webSocketError(ws as WebSocket, error);
|
|
979
|
+
}
|
|
980
|
+
|
|
764
981
|
/** Returns this DO's hex id — the canonical ConnId used in RPC routing. */
|
|
765
982
|
public __peekHexId(): string {
|
|
766
983
|
return this.ctx.id.toString();
|
|
@@ -813,6 +1030,104 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
813
1030
|
/** Default MOTD when no `MOTD_LINES` env is supplied. */
|
|
814
1031
|
const DEFAULT_MOTD: string[] = ['Welcome to the ServerlessIRCd Cloudflare adapter.'];
|
|
815
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Reads the verified mTLS client-cert identity off an inbound Request, when
|
|
1035
|
+
* CF API Shield mTLS is configured. Extracted as a pure helper so the
|
|
1036
|
+
* `cf.tlsClientAuthCertSubject` / `cf.tlsClientAuth` branches are
|
|
1037
|
+
* unit-testable directly (the workerd test runtime does not surface
|
|
1038
|
+
* `request.cf` the way the CF edge does, and `Request.cf` is read-only so
|
|
1039
|
+
* tests cannot inject it via the standard constructor).
|
|
1040
|
+
*
|
|
1041
|
+
* Identifier resolution (strongest first):
|
|
1042
|
+
* 1. Subject from `cf.tlsClientAuthCertSubject` (legacy documented field),
|
|
1043
|
+
* falling back to `cf.tlsClientAuth.certSubjectDN` when absent.
|
|
1044
|
+
* 2. DER SHA-256 fingerprint from `cf.tlsClientAuth.certFingerprintSHA256`
|
|
1045
|
+
* when it is a non-empty string (Cloudflare documents it as `""` for
|
|
1046
|
+
* non-mTLS connections).
|
|
1047
|
+
*
|
|
1048
|
+
* Returns the {@link CertIdentity} (raw subject + canonical DN + optional
|
|
1049
|
+
* fingerprint) when a subject was captured, or `undefined` when mTLS is
|
|
1050
|
+
* not configured.
|
|
1051
|
+
*/
|
|
1052
|
+
export function extractMtlsSubject(request: Request): CertIdentity | undefined {
|
|
1053
|
+
const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
|
|
1054
|
+
if (cf === undefined) return undefined;
|
|
1055
|
+
const subject = readCertSubject(cf);
|
|
1056
|
+
if (subject === undefined) return undefined;
|
|
1057
|
+
return certIdentityFromSubject(subject, readCertFingerprint(cf));
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** Reads the cert subject: legacy `tlsClientAuthCertSubject`, then `tlsClientAuth.certSubjectDN`. */
|
|
1061
|
+
function readCertSubject(cf: Record<string, unknown>): string | undefined {
|
|
1062
|
+
if (typeof cf.tlsClientAuthCertSubject === 'string') {
|
|
1063
|
+
return cf.tlsClientAuthCertSubject;
|
|
1064
|
+
}
|
|
1065
|
+
const tlsClientAuth = cf.tlsClientAuth;
|
|
1066
|
+
if (isRecord(tlsClientAuth) && typeof tlsClientAuth.certSubjectDN === 'string') {
|
|
1067
|
+
return tlsClientAuth.certSubjectDN;
|
|
1068
|
+
}
|
|
1069
|
+
return undefined;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** Reads the DER SHA-256 fingerprint off `tlsClientAuth` (empty string = absent). */
|
|
1073
|
+
function readCertFingerprint(cf: Record<string, unknown>): string | undefined {
|
|
1074
|
+
const tlsClientAuth = cf.tlsClientAuth;
|
|
1075
|
+
if (!isRecord(tlsClientAuth)) return undefined;
|
|
1076
|
+
const fp = tlsClientAuth.certFingerprintSHA256;
|
|
1077
|
+
return typeof fp === 'string' && fp.length > 0 ? fp : undefined;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
1081
|
+
return typeof v === 'object' && v !== null;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* No-op transport callbacks used by {@link ConnectionDO.tearDownNoSocket}
|
|
1086
|
+
* when the WebSocket has already been dropped. Exported so the function
|
|
1087
|
+
* bodies are exercised by a direct unit test (the actor never invokes
|
|
1088
|
+
* `send`/`disconnect` during the no-socket QUIT path because there is
|
|
1089
|
+
* no peer to deliver to).
|
|
1090
|
+
*/
|
|
1091
|
+
export const NO_OP_SEND: (lines: RawLine[]) => void = () => {};
|
|
1092
|
+
export const NO_OP_DISCONNECT: (reason?: string) => void = () => {};
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Builds the optional `services` / `mtlsIdentity` fields for a
|
|
1096
|
+
* {@link ConnectionActor} constructor. Each field is included only when
|
|
1097
|
+
* the source resolved a value; the `: {}` arms of the conditional spreads
|
|
1098
|
+
* are exercised by both the with-everything and with-nothing test paths.
|
|
1099
|
+
*
|
|
1100
|
+
* Extracted as a pure helper so the conditional-spread branches are
|
|
1101
|
+
* testable without driving a full DO event cycle that leaves the
|
|
1102
|
+
* underlying stores unset (the test env binds D1 + services, so production
|
|
1103
|
+
* builds always hit the defined arm for `services`).
|
|
1104
|
+
*/
|
|
1105
|
+
export function buildOptionalActorFields(
|
|
1106
|
+
services: D1ServicesStore | undefined,
|
|
1107
|
+
mtlsIdentity: MtlsIdentityProvider | undefined,
|
|
1108
|
+
): {
|
|
1109
|
+
services?: D1ServicesStore;
|
|
1110
|
+
mtlsIdentity?: MtlsIdentityProvider;
|
|
1111
|
+
} {
|
|
1112
|
+
return {
|
|
1113
|
+
...(services !== undefined ? { services } : {}),
|
|
1114
|
+
...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Resolves the {@link MotdProvider} for a raw `MOTD_LINES` env value.
|
|
1120
|
+
* Returns the default single-line MOTD when the env is unset or empty;
|
|
1121
|
+
* otherwise splits the env on newlines. Extracted so both branches are
|
|
1122
|
+
* unit-testable without changing the bound env in the test worker.
|
|
1123
|
+
*/
|
|
1124
|
+
export function chooseMotdProvider(raw: string | undefined): MotdProvider {
|
|
1125
|
+
if (raw === undefined || raw.length === 0) {
|
|
1126
|
+
return { lines: () => DEFAULT_MOTD };
|
|
1127
|
+
}
|
|
1128
|
+
return { lines: () => raw.split('\n') };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
816
1131
|
/**
|
|
817
1132
|
* `ActorChannelAccess` backed by a per-instance cache that is refreshed
|
|
818
1133
|
* from the authoritative ChannelDO before each reducer that needs to
|
|
@@ -919,31 +1234,6 @@ export class PassthroughChannelAccess {
|
|
|
919
1234
|
}
|
|
920
1235
|
}
|
|
921
1236
|
|
|
922
|
-
/**
|
|
923
|
-
* Parses the `SASL_ACCOUNTS` env var into credential entries.
|
|
924
|
-
*
|
|
925
|
-
* Format: newline-delimited `username:password` pairs (mirroring the
|
|
926
|
-
* `MOTD_LINES` convention). Blank lines and malformed entries (missing
|
|
927
|
-
* colon or empty username/password) are skipped so a trailing newline or a
|
|
928
|
-
* partial edit does not break boot. Exported so the parsing boundary is
|
|
929
|
-
* unit-testable in isolation.
|
|
930
|
-
*/
|
|
931
|
-
export function parseSaslAccountsEnv(raw: string | undefined): SaslAccountCredential[] {
|
|
932
|
-
if (raw === undefined || raw.length === 0) return [];
|
|
933
|
-
const out: SaslAccountCredential[] = [];
|
|
934
|
-
for (const line of raw.split('\n')) {
|
|
935
|
-
const trimmed = line.trim();
|
|
936
|
-
if (trimmed.length === 0) continue;
|
|
937
|
-
const sep = trimmed.indexOf(':');
|
|
938
|
-
if (sep <= 0) continue; // missing colon or empty username
|
|
939
|
-
const username = trimmed.slice(0, sep);
|
|
940
|
-
const password = trimmed.slice(sep + 1);
|
|
941
|
-
if (username.length === 0 || password.length === 0) continue;
|
|
942
|
-
out.push({ username, password });
|
|
943
|
-
}
|
|
944
|
-
return out;
|
|
945
|
-
}
|
|
946
|
-
|
|
947
1237
|
/**
|
|
948
1238
|
* Hibernation-tag prefixes written by {@link ConnectionDO.fetch}. Each tag
|
|
949
1239
|
* encodes the negotiated {@link WsFrameMode} so it survives DO eviction and
|