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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CounterDO` — global live-connection admission counter.
|
|
3
|
+
*
|
|
4
|
+
* A single Durable Object instance (fixed name {@link COUNTER_INSTANCE_NAME})
|
|
5
|
+
* maintains one admission entry per live WebSocket connection, keyed by the
|
|
6
|
+
* owning ConnectionDO's hex id. The worker edge reserves a slot via
|
|
7
|
+
* `admit` before forwarding an upgrade (enforcing `maxClients`); the
|
|
8
|
+
* ConnectionDO releases the slot via `release` on every teardown path
|
|
9
|
+
* (clean close, transport error, socketless teardown) and refreshes it
|
|
10
|
+
* via `heartbeat` from its PING alarm.
|
|
11
|
+
*
|
|
12
|
+
* Storage layout:
|
|
13
|
+
* - One key per connection: `conn:<connId>` → lastSeen epoch ms
|
|
14
|
+
*
|
|
15
|
+
* Housekeeping (DO-instance eviction): teardown relies on the close /
|
|
16
|
+
* error events reaching the ConnectionDO. When an isolate is dropped
|
|
17
|
+
* without delivering either (platform failure mode), `release` never
|
|
18
|
+
* fires and the entry would go stale, permanently consuming a slot.
|
|
19
|
+
* Instead of a periodic alarm sweep, every `admit` call first reaps
|
|
20
|
+
* entries whose `lastSeen` is older than the TTL — a lazy reconcile
|
|
21
|
+
* that keeps admission O(live connections) with no extra infrastructure.
|
|
22
|
+
* Live connections are safe from the reap because the ConnectionDO's
|
|
23
|
+
* PING alarm (60s cadence) heartbeats their entry ~5x within the
|
|
24
|
+
* 5-minute TTL window; only DOs that stopped heartbeating (crashed or
|
|
25
|
+
* evicted) let their entries age out.
|
|
26
|
+
*
|
|
27
|
+
* Atomicity: the DO's serialized event loop linearises concurrent
|
|
28
|
+
* `admit` calls, so the check-then-register sequence is race-free —
|
|
29
|
+
* exactly one of N concurrent upgrades can take the last slot. This
|
|
30
|
+
* mirrors the AWS adapter's atomic `__meta:connectionCount__` counter
|
|
31
|
+
* semantics without needing a conditional-write primitive.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
35
|
+
import type { CounterAdmissionResult, CounterRpc } from './env.js';
|
|
36
|
+
|
|
37
|
+
/** Storage key prefix for per-connection admission entries. */
|
|
38
|
+
const CONN_KEY_PREFIX = 'conn:';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fixed instance name for the single global counter. The worker edge and
|
|
42
|
+
* every ConnectionDO address the same instance via
|
|
43
|
+
* `COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME)`.
|
|
44
|
+
*/
|
|
45
|
+
export const COUNTER_INSTANCE_NAME = 'global';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Default TTL (ms) after which an admission entry is considered stale and
|
|
49
|
+
* reaped. Comfortably above the ConnectionDO PING alarm cadence (60s) so
|
|
50
|
+
* a live connection refreshes its entry several times per window; short
|
|
51
|
+
* enough that a crashed DO's slot self-heals within minutes.
|
|
52
|
+
*/
|
|
53
|
+
export const DEFAULT_CONNECTION_TTL_MS = 300_000;
|
|
54
|
+
|
|
55
|
+
/** Returns the storage key for `connId`. */
|
|
56
|
+
function connKey(connId: string): string {
|
|
57
|
+
return `${CONN_KEY_PREFIX}${connId}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Global live-connection counter authority.
|
|
62
|
+
*
|
|
63
|
+
* The class is exported as a binding target in `wrangler.toml`; the
|
|
64
|
+
* Workers runtime instantiates it with `(ctx, env)`. Implements the
|
|
65
|
+
* {@link CounterRpc} contract the worker edge and ConnectionDO call.
|
|
66
|
+
*/
|
|
67
|
+
export class CounterDO extends DurableObject implements CounterRpc {
|
|
68
|
+
/**
|
|
69
|
+
* Admission check + slot reservation in one serialized step.
|
|
70
|
+
*
|
|
71
|
+
* Stale entries (aged past the TTL — their ConnectionDO stopped
|
|
72
|
+
* heartbeating) are reaped first, so a crashed DO frees capacity for
|
|
73
|
+
* the newcomer instead of wedgeing the cap shut. When the surviving
|
|
74
|
+
* live count is at or above `maxClients` the caller is rejected and
|
|
75
|
+
* nothing is registered; otherwise the connection's entry is written
|
|
76
|
+
* with `now` as its initial `lastSeen`.
|
|
77
|
+
*/
|
|
78
|
+
async admit(
|
|
79
|
+
connId: string,
|
|
80
|
+
maxClients: number,
|
|
81
|
+
now: number = Date.now(),
|
|
82
|
+
ttlMs: number = DEFAULT_CONNECTION_TTL_MS,
|
|
83
|
+
): Promise<CounterAdmissionResult> {
|
|
84
|
+
const { live } = await this.sweep(now, ttlMs);
|
|
85
|
+
if (live >= maxClients) {
|
|
86
|
+
return { admitted: false, live };
|
|
87
|
+
}
|
|
88
|
+
await this.ctx.storage.put(connKey(connId), now);
|
|
89
|
+
return { admitted: true, live: live + 1 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Releases a connection's slot. Idempotent: deleting an absent key is
|
|
94
|
+
* a no-op, so teardown of a connection that never registered (direct
|
|
95
|
+
* DO access outside the worker edge) is harmless.
|
|
96
|
+
*/
|
|
97
|
+
async release(connId: string): Promise<void> {
|
|
98
|
+
await this.ctx.storage.delete(connKey(connId));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Refreshes a live entry's `lastSeen` so the TTL reap spares it.
|
|
103
|
+
* No-op for unknown ids — a late heartbeat racing a release must not
|
|
104
|
+
* resurrect the slot (that would overcount).
|
|
105
|
+
*/
|
|
106
|
+
async heartbeat(connId: string, now: number = Date.now()): Promise<void> {
|
|
107
|
+
const key = connKey(connId);
|
|
108
|
+
const existing = await this.ctx.storage.get<number>(key);
|
|
109
|
+
if (existing === undefined) return;
|
|
110
|
+
await this.ctx.storage.put(key, now);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Reconcile sweep: deletes every entry strictly older than the TTL and
|
|
115
|
+
* reports how many were reaped vs. how many remain live. Invoked
|
|
116
|
+
* lazily by {@link admit}; exposed as an RPC so operators (and tests)
|
|
117
|
+
* can reconcile explicitly.
|
|
118
|
+
*/
|
|
119
|
+
async sweep(
|
|
120
|
+
now: number = Date.now(),
|
|
121
|
+
ttlMs: number = DEFAULT_CONNECTION_TTL_MS,
|
|
122
|
+
): Promise<{ reaped: number; live: number }> {
|
|
123
|
+
const entries = await this.ctx.storage.list({ prefix: CONN_KEY_PREFIX });
|
|
124
|
+
let reaped = 0;
|
|
125
|
+
let live = 0;
|
|
126
|
+
for (const [key, seenAt] of entries) {
|
|
127
|
+
if (now - (seenAt as number) > ttlMs) {
|
|
128
|
+
await this.ctx.storage.delete(key);
|
|
129
|
+
reaped++;
|
|
130
|
+
} else {
|
|
131
|
+
live++;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { reaped, live };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Returns the raw entry count (no reap). Observability / tests. */
|
|
138
|
+
async liveCount(): Promise<number> {
|
|
139
|
+
const entries = await this.ctx.storage.list({ prefix: CONN_KEY_PREFIX });
|
|
140
|
+
return entries.size;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -78,7 +78,7 @@ interface D1QueryClient {
|
|
|
78
78
|
* the list on a warm deploy is a no-op.
|
|
79
79
|
*/
|
|
80
80
|
export const CREATE_NICKSERV_ACCOUNTS_SQL =
|
|
81
|
-
|
|
81
|
+
"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 '[]', identify_failures TEXT NOT NULL DEFAULT '[]', pending_identify_notice INTEGER NOT NULL DEFAULT 0, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)";
|
|
82
82
|
|
|
83
83
|
export const CREATE_SERVICES_TABLES_SQL: readonly string[] = [
|
|
84
84
|
CREATE_NICKSERV_ACCOUNTS_SQL,
|
|
@@ -136,7 +136,7 @@ export class D1ServicesStore extends PersistentServicesStore {
|
|
|
136
136
|
return [
|
|
137
137
|
this.db
|
|
138
138
|
.prepare(
|
|
139
|
-
'INSERT OR REPLACE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
139
|
+
'INSERT OR REPLACE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, identify_failures, pending_identify_notice, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
140
140
|
)
|
|
141
141
|
.bind(
|
|
142
142
|
fold(r.nick),
|
|
@@ -145,6 +145,9 @@ export class D1ServicesStore extends PersistentServicesStore {
|
|
|
145
145
|
r.email,
|
|
146
146
|
r.createdAt,
|
|
147
147
|
r.enforce,
|
|
148
|
+
JSON.stringify(r.certSubjects ?? []),
|
|
149
|
+
JSON.stringify(r.identifyFailures ?? []),
|
|
150
|
+
r.pendingIdentifyFailureNotice ?? 0,
|
|
148
151
|
r.credential.algorithm,
|
|
149
152
|
r.credential.salt,
|
|
150
153
|
r.credential.hash,
|
|
@@ -361,12 +364,15 @@ async function loadNicks(db: D1QueryClient): Promise<ServicesNickRow[]> {
|
|
|
361
364
|
email: unknown;
|
|
362
365
|
created_at: unknown;
|
|
363
366
|
enforce: unknown;
|
|
367
|
+
cert_subjects: unknown;
|
|
368
|
+
identify_failures: unknown;
|
|
369
|
+
pending_identify_notice: unknown;
|
|
364
370
|
algorithm: unknown;
|
|
365
371
|
salt: unknown;
|
|
366
372
|
hash: unknown;
|
|
367
373
|
}>(
|
|
368
374
|
db,
|
|
369
|
-
'SELECT nick, account, email, created_at, enforce, algorithm, salt, hash FROM nickserv_accounts',
|
|
375
|
+
'SELECT nick, account, email, created_at, enforce, cert_subjects, identify_failures, pending_identify_notice, algorithm, salt, hash FROM nickserv_accounts',
|
|
370
376
|
);
|
|
371
377
|
const out: ServicesNickRow[] = [];
|
|
372
378
|
for (const r of rows) {
|
|
@@ -388,6 +394,10 @@ async function loadNicks(db: D1QueryClient): Promise<ServicesNickRow[]> {
|
|
|
388
394
|
email: r.email,
|
|
389
395
|
createdAt: r.created_at,
|
|
390
396
|
enforce: r.enforce as NickEnforcePolicy,
|
|
397
|
+
certSubjects: parseCertSubjects(r.cert_subjects),
|
|
398
|
+
identifyFailures: parseIdentifyFailures(r.identify_failures),
|
|
399
|
+
pendingIdentifyFailureNotice:
|
|
400
|
+
typeof r.pending_identify_notice === 'number' ? r.pending_identify_notice : 0,
|
|
391
401
|
credential: {
|
|
392
402
|
account: r.account,
|
|
393
403
|
algorithm: 'scrypt',
|
|
@@ -399,6 +409,41 @@ async function loadNicks(db: D1QueryClient): Promise<ServicesNickRow[]> {
|
|
|
399
409
|
return out;
|
|
400
410
|
}
|
|
401
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Parses the `cert_subjects` TEXT column (JSON-encoded array) into a
|
|
414
|
+
* `string[]`. Tolerates `undefined` (a row written before the column
|
|
415
|
+
* existed), `null`, or malformed JSON — all collapse to `[]` so a stale or
|
|
416
|
+
* partially-migrated row loads cleanly.
|
|
417
|
+
*/
|
|
418
|
+
function parseCertSubjects(raw: unknown): string[] {
|
|
419
|
+
if (typeof raw !== 'string' || raw.length === 0) return [];
|
|
420
|
+
try {
|
|
421
|
+
const parsed = JSON.parse(raw);
|
|
422
|
+
if (!Array.isArray(parsed)) return [];
|
|
423
|
+
return parsed.filter((s): s is string => typeof s === 'string');
|
|
424
|
+
} catch {
|
|
425
|
+
return [];
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Parses the `identify_failures` TEXT column (JSON-encoded array of
|
|
431
|
+
* epoch-ms timestamps) into a `number[]`. Tolerates `undefined` (a row
|
|
432
|
+
* written before the column existed), `null`, non-numeric entries, or
|
|
433
|
+
* malformed JSON — all collapse to `[]` so a stale or partially-migrated
|
|
434
|
+
* row loads cleanly.
|
|
435
|
+
*/
|
|
436
|
+
function parseIdentifyFailures(raw: unknown): number[] {
|
|
437
|
+
if (typeof raw !== 'string' || raw.length === 0) return [];
|
|
438
|
+
try {
|
|
439
|
+
const parsed = JSON.parse(raw);
|
|
440
|
+
if (!Array.isArray(parsed)) return [];
|
|
441
|
+
return parsed.filter((t): t is number => typeof t === 'number');
|
|
442
|
+
} catch {
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
402
447
|
async function loadChannels(db: D1QueryClient): Promise<RegisteredChannel[]> {
|
|
403
448
|
const rows = await safeSelect<{
|
|
404
449
|
channel: unknown;
|
|
@@ -603,8 +648,13 @@ async function loadJupes(db: D1QueryClient): Promise<JupeEntry[]> {
|
|
|
603
648
|
* table does not exist yet (fresh deploy) or the binding is unreachable.
|
|
604
649
|
* Silent-skip mirrors the DynamoDB loader: a missing table is treated as
|
|
605
650
|
* an empty section rather than a fatal boot error.
|
|
651
|
+
*
|
|
652
|
+
* Exported so the catch-block fallback is unit-testable directly with a
|
|
653
|
+
* throwing D1 stub (the real binding does not fail on queries that target
|
|
654
|
+
* existing tables, so the catch arm is unreachable through integration
|
|
655
|
+
* tests).
|
|
606
656
|
*/
|
|
607
|
-
async function safeSelect<R>(db: D1QueryClient, sql: string): Promise<R[]> {
|
|
657
|
+
export async function safeSelect<R>(db: D1QueryClient, sql: string): Promise<R[]> {
|
|
608
658
|
try {
|
|
609
659
|
const result = await db.prepare(sql).all<R>();
|
|
610
660
|
return result.results;
|
|
@@ -648,35 +698,64 @@ async function ensureServicesTables(db: D1QueryClient): Promise<void> {
|
|
|
648
698
|
}
|
|
649
699
|
|
|
650
700
|
/**
|
|
651
|
-
* One-time schema
|
|
652
|
-
* (commit cfa5a3c). `CREATE TABLE IF NOT EXISTS` does NOT add columns to
|
|
653
|
-
* an existing table, so a D1 first provisioned by an older deploy keeps
|
|
654
|
-
* the original `nickserv_accounts (... enforce, password)` schema. Every
|
|
655
|
-
* current SELECT/INSERT references `algorithm`, so reads throw
|
|
656
|
-
* "no such column: algorithm" (masked by `safeSelect` as an empty
|
|
657
|
-
* snapshot) and writes throw inside `flush()` (after the success NOTICE).
|
|
701
|
+
* One-time schema migrations for `nickserv_accounts`.
|
|
658
702
|
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
703
|
+
* `CREATE TABLE IF NOT EXISTS` does NOT add columns to an existing table,
|
|
704
|
+
* so a D1 first provisioned by an older deploy keeps the original schema.
|
|
705
|
+
* Three additive migrations run idempotently, each gated on `PRAGMA
|
|
706
|
+
* table_info`:
|
|
707
|
+
*
|
|
708
|
+
* 1. **algorithm/salt/hash unification** (commit cfa5a3c). If the table
|
|
709
|
+
* lacks `algorithm`, it has the pre-unification `password` column
|
|
710
|
+
* whose credentials the current `verifyNick` cannot validate. The
|
|
711
|
+
* table is dropped and recreated with the current schema (data loss
|
|
712
|
+
* is intentional and limited to already-unusable credentials).
|
|
713
|
+
* 2. **cert_subjects**. If the table has `algorithm` but lacks
|
|
714
|
+
* `cert_subjects`, the column is added with `NOT NULL DEFAULT '[]'`
|
|
715
|
+
* so existing rows load with an empty cert-subject list. No data
|
|
716
|
+
* loss — the column is additive.
|
|
717
|
+
* 3. **identify throttle**. If the table lacks `identify_failures`, the
|
|
718
|
+
* `identify_failures` (JSON timestamp array) and
|
|
719
|
+
* `pending_identify_notice` (queued owner-notice count) columns are
|
|
720
|
+
* added with neutral defaults so pre-existing rows load unfrozen.
|
|
721
|
+
* No data loss — the columns are additive.
|
|
666
722
|
*/
|
|
667
723
|
async function migrateServicesSchema(db: D1QueryClient): Promise<void> {
|
|
668
724
|
const nickservInfo = await db
|
|
669
725
|
.prepare('PRAGMA table_info(nickserv_accounts)')
|
|
670
726
|
.all<{ name: string }>();
|
|
671
727
|
const nickservCols = new Set(nickservInfo.results.map((r) => r.name));
|
|
672
|
-
if (nickservCols.has('algorithm'))
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
db.
|
|
678
|
-
|
|
679
|
-
|
|
728
|
+
if (!nickservCols.has('algorithm')) {
|
|
729
|
+
// Old schema detected (has `password`, lacks `algorithm`). The table is
|
|
730
|
+
// guaranteed to exist (ensureServicesTables just ran CREATE IF NOT
|
|
731
|
+
// EXISTS); recreate it with the current schema (which includes
|
|
732
|
+
// cert_subjects + the identify-throttle columns).
|
|
733
|
+
await db.batch([
|
|
734
|
+
db.prepare('DROP TABLE nickserv_accounts'),
|
|
735
|
+
db.prepare(CREATE_NICKSERV_ACCOUNTS_SQL),
|
|
736
|
+
]);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
// algorithm present — check for the additive cert_subjects column.
|
|
740
|
+
if (!nickservCols.has('cert_subjects')) {
|
|
741
|
+
await db.batch([
|
|
742
|
+
db.prepare(
|
|
743
|
+
"ALTER TABLE nickserv_accounts ADD COLUMN cert_subjects TEXT NOT NULL DEFAULT '[]'",
|
|
744
|
+
),
|
|
745
|
+
]);
|
|
746
|
+
}
|
|
747
|
+
// cert_subjects present (or just added) — check for the additive
|
|
748
|
+
// identify-throttle columns.
|
|
749
|
+
if (!nickservCols.has('identify_failures')) {
|
|
750
|
+
await db.batch([
|
|
751
|
+
db.prepare(
|
|
752
|
+
"ALTER TABLE nickserv_accounts ADD COLUMN identify_failures TEXT NOT NULL DEFAULT '[]'",
|
|
753
|
+
),
|
|
754
|
+
db.prepare(
|
|
755
|
+
'ALTER TABLE nickserv_accounts ADD COLUMN pending_identify_notice INTEGER NOT NULL DEFAULT 0',
|
|
756
|
+
),
|
|
757
|
+
]);
|
|
758
|
+
}
|
|
680
759
|
}
|
|
681
760
|
|
|
682
761
|
export async function loadD1ServicesStore(
|
|
@@ -47,13 +47,32 @@ export interface Env {
|
|
|
47
47
|
*/
|
|
48
48
|
CHANNEL_REGISTRY_DO: DurableObjectNamespace;
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* Global live-connection counter authority. A single `CounterDO`
|
|
51
|
+
* instance (fixed name `global`) maintains one admission entry per
|
|
52
|
+
* live connection, keyed by the ConnectionDO hex id. The worker edge
|
|
53
|
+
* calls `admit` before forwarding an upgrade (enforcing
|
|
54
|
+
* `maxClients`); ConnectionDO calls `release` on every teardown path
|
|
55
|
+
* and `heartbeat` from its PING alarm so the counter's lazy TTL reap
|
|
56
|
+
* only evicts entries from DOs that stopped heartbeating.
|
|
57
|
+
*/
|
|
58
|
+
COUNTER_DO: DurableObjectNamespace;
|
|
59
|
+
/**
|
|
60
|
+
* Per-IP upgrade rate limiter authority. A single `RateLimitDO`
|
|
61
|
+
* instance (fixed name `global`) maintains one sliding-window
|
|
62
|
+
* admission-timestamp list per source IP (the `CF-Connecting-IP` the
|
|
63
|
+
* edge stamps). The worker edge calls `check` before forwarding an
|
|
64
|
+
* upgrade (enforcing `perIpConnectionRate`) and rejects over-budget
|
|
65
|
+
* IPs with 429.
|
|
66
|
+
*/
|
|
67
|
+
RATE_LIMIT_DO: DurableObjectNamespace;
|
|
68
|
+
/**
|
|
69
|
+
* D1 database backing persistent services (NickServ / ChanServ / HostServ /
|
|
70
|
+
* MemoServ / OperServ + the unified SASL credential store). The
|
|
71
|
+
* `nickserv_accounts` table holds scrypt-hashed credential rows, loaded at
|
|
72
|
+
* {@link ConnectionDO} construction by `loadD1ServicesStore`.
|
|
53
73
|
*
|
|
54
|
-
* Optional
|
|
55
|
-
*
|
|
56
|
-
* `SASL_ACCOUNTS` env-var seed (the legacy `InMemoryAccountStore`).
|
|
74
|
+
* Optional: when unset, services are unbound (no NickServ routing, no
|
|
75
|
+
* SASL/PASS credential verify).
|
|
57
76
|
*/
|
|
58
77
|
ACCOUNTS_DB?: D1Database;
|
|
59
78
|
/** Server-level config knobs (name, MOTD lines, limits). */
|
|
@@ -71,10 +90,11 @@ export interface Env {
|
|
|
71
90
|
CREATED_AT?: string;
|
|
72
91
|
MOTD_LINES: string;
|
|
73
92
|
/**
|
|
74
|
-
* SASL PLAIN accounts
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
93
|
+
* SASL PLAIN seed accounts. Newline-delimited `username:password` pairs;
|
|
94
|
+
* the connection-do ingests these into the `ServicesStore` at boot via
|
|
95
|
+
* `registerNick` (idempotent — existing registrations win) so
|
|
96
|
+
* `AUTHENTICATE PLAIN`, `PASS <nick>:<password>`, and NickServ `IDENTIFY`
|
|
97
|
+
* all verify for the listed accounts.
|
|
78
98
|
*/
|
|
79
99
|
SASL_ACCOUNTS?: string;
|
|
80
100
|
/**
|
|
@@ -97,6 +117,20 @@ export interface Env {
|
|
|
97
117
|
* `001 RPL_WELCOME`. Undefined / empty disables the gate.
|
|
98
118
|
*/
|
|
99
119
|
SERVER_PASSWORD?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Per-connection inbound frame window ceiling enforced by
|
|
122
|
+
* `ConnectionDO.webSocketMessage` BEFORE the actor / storage write.
|
|
123
|
+
* Parsed into `ServerConfig.adapter.maxFramesPerWindow`; unset falls
|
|
124
|
+
* back to the schema default (50).
|
|
125
|
+
*/
|
|
126
|
+
MAX_FRAMES_PER_WINDOW?: string;
|
|
127
|
+
/**
|
|
128
|
+
* Sliding-window length (seconds) for the inbound frame limit; frames
|
|
129
|
+
* older than the window stop counting. Parsed into
|
|
130
|
+
* `ServerConfig.adapter.frameWindowSeconds`; unset falls back to the
|
|
131
|
+
* schema default (5).
|
|
132
|
+
*/
|
|
133
|
+
FRAME_WINDOW_SECONDS?: string;
|
|
100
134
|
}
|
|
101
135
|
|
|
102
136
|
/**
|
|
@@ -142,3 +176,58 @@ export interface ChannelRegistryRpc {
|
|
|
142
176
|
unregister(name: string): Promise<void>;
|
|
143
177
|
list(): Promise<string[]>;
|
|
144
178
|
}
|
|
179
|
+
|
|
180
|
+
/** Result of a {@link CounterRpc.admit} admission check. */
|
|
181
|
+
export interface CounterAdmissionResult {
|
|
182
|
+
/** Whether the connection may proceed to the ConnectionDO upgrade. */
|
|
183
|
+
readonly admitted: boolean;
|
|
184
|
+
/** Live connection count after any lazy reap (excludes a rejection). */
|
|
185
|
+
readonly live: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Result of a {@link RateLimitRpc.check} budget check. */
|
|
189
|
+
export interface RateLimitResult {
|
|
190
|
+
/** Whether the caller is within the per-IP budget for this window. */
|
|
191
|
+
readonly allowed: boolean;
|
|
192
|
+
/** Admissions from this IP inside the sliding window (post-prune). */
|
|
193
|
+
readonly count: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* RPC contract the worker edge expects from the per-IP rate limiter.
|
|
198
|
+
* The real {@link RateLimitDO} implements this.
|
|
199
|
+
*/
|
|
200
|
+
export interface RateLimitRpc {
|
|
201
|
+
/**
|
|
202
|
+
* Prunes admissions aged out of the window, then allows (stamping
|
|
203
|
+
* `now`) only while the surviving count is under `max`. `now` is
|
|
204
|
+
* overridable for deterministic window tests.
|
|
205
|
+
*/
|
|
206
|
+
check(ip: string, max: number, windowMs: number, now?: number): Promise<RateLimitResult>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* RPC contract ConnectionDO / the worker edge expect from the global
|
|
211
|
+
* connection counter. The real {@link CounterDO} implements this.
|
|
212
|
+
*/
|
|
213
|
+
export interface CounterRpc {
|
|
214
|
+
/**
|
|
215
|
+
* Reaps stale entries, then registers `connId` when the surviving live
|
|
216
|
+
* count is under `maxClients`. `now` / `ttlMs` are overridable for
|
|
217
|
+
* deterministic TTL tests.
|
|
218
|
+
*/
|
|
219
|
+
admit(
|
|
220
|
+
connId: string,
|
|
221
|
+
maxClients: number,
|
|
222
|
+
now?: number,
|
|
223
|
+
ttlMs?: number,
|
|
224
|
+
): Promise<CounterAdmissionResult>;
|
|
225
|
+
/** Frees the connection's slot (idempotent). */
|
|
226
|
+
release(connId: string): Promise<void>;
|
|
227
|
+
/** Refreshes the entry's lastSeen (no-op for unknown ids). */
|
|
228
|
+
heartbeat(connId: string, now?: number): Promise<void>;
|
|
229
|
+
/** Deletes entries older than the TTL; reports reaped vs. live. */
|
|
230
|
+
sweep(now?: number, ttlMs?: number): Promise<{ reaped: number; live: number }>;
|
|
231
|
+
/** Raw entry count (no reap). */
|
|
232
|
+
liveCount(): Promise<number>;
|
|
233
|
+
}
|
|
@@ -15,6 +15,12 @@ export {
|
|
|
15
15
|
export { RegistryDO } from './registry-do.js';
|
|
16
16
|
export { ChannelDO } from './channel-do.js';
|
|
17
17
|
export { ChannelRegistryDO } from './channel-registry-do.js';
|
|
18
|
+
export {
|
|
19
|
+
COUNTER_INSTANCE_NAME,
|
|
20
|
+
CounterDO,
|
|
21
|
+
DEFAULT_CONNECTION_TTL_MS,
|
|
22
|
+
} from './counter-do.js';
|
|
23
|
+
export { RATE_LIMIT_INSTANCE_NAME, RateLimitDO } from './rate-limit-do.js';
|
|
18
24
|
export type { DeliverResult, ConnectionDeliveryRpc } from './channel-do.js';
|
|
19
25
|
export {
|
|
20
26
|
DEFAULT_REGISTRY_SHARDS,
|
|
@@ -25,13 +31,17 @@ export { makeCfRuntime } from './cf-runtime.js';
|
|
|
25
31
|
export type { CfConnectionHandlers } from './cf-runtime.js';
|
|
26
32
|
export { CfStats } from './stats.js';
|
|
27
33
|
export type { CfStatsRuntime } from './stats.js';
|
|
28
|
-
export type {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
export type {
|
|
35
|
+
ChannelRpc,
|
|
36
|
+
ChannelRegistryRpc,
|
|
37
|
+
CounterAdmissionResult,
|
|
38
|
+
CounterRpc,
|
|
39
|
+
Env,
|
|
40
|
+
RateLimitResult,
|
|
41
|
+
RateLimitRpc,
|
|
42
|
+
RegistryRpc,
|
|
43
|
+
} from './env.js';
|
|
44
|
+
export { loadServerConfigFromCfEnv } from './config-loader.js';
|
|
35
45
|
export {
|
|
36
46
|
CREATE_SERVICES_TABLES_SQL,
|
|
37
47
|
D1ServicesStore,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RateLimitDO` — per-IP upgrade rate limiter.
|
|
3
|
+
*
|
|
4
|
+
* A single Durable Object instance (fixed name {@link
|
|
5
|
+
* RATE_LIMIT_INSTANCE_NAME}) maintains one sliding-window admission
|
|
6
|
+
* timestamp list per source IP, keyed by the `CF-Connecting-IP` the
|
|
7
|
+
* Cloudflare edge stamps on every request. The worker edge calls
|
|
8
|
+
* {@link RateLimitDO.check} before forwarding a WebSocket upgrade and
|
|
9
|
+
* rejects over-budget IPs with `429` — capping how fast a single host
|
|
10
|
+
* can drive connection setup (brute-forcing `PASS` / SASL credentials
|
|
11
|
+
* requires exactly that setup rate).
|
|
12
|
+
*
|
|
13
|
+
* Storage layout:
|
|
14
|
+
* - One key per source IP: `rate:<ip>` → array of admission epoch ms
|
|
15
|
+
*
|
|
16
|
+
* Sliding window: an admission stops counting once it is strictly older
|
|
17
|
+
* than `now - windowMs` (an entry exactly `windowMs` old still counts).
|
|
18
|
+
* This mirrors irc-core's in-memory `AdmissionStats.recentAdmissions`
|
|
19
|
+
* boundary (`t >= cutoff`), so the DO and the single-process runtimes
|
|
20
|
+
* reject the same sequence of connects identically.
|
|
21
|
+
*
|
|
22
|
+
* Window semantics on rejected checks: a rejected check prunes stale
|
|
23
|
+
* timestamps but MUST NOT append one of its own — otherwise a blocked
|
|
24
|
+
* IP consumes budget from rejection attempts and can never recover.
|
|
25
|
+
*
|
|
26
|
+
* Atomicity: the DO's serialized event loop linearises concurrent
|
|
27
|
+
* `check` calls for the same IP, so N racing upgrades can never all
|
|
28
|
+
* squeeze under a budget of N-1 — mirroring the CounterDO admission
|
|
29
|
+
* semantics without a conditional-write primitive.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
33
|
+
import type { RateLimitResult, RateLimitRpc } from './env.js';
|
|
34
|
+
|
|
35
|
+
/** Storage key prefix for per-IP admission-timestamp lists. */
|
|
36
|
+
const RATE_KEY_PREFIX = 'rate:';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fixed instance name for the single global limiter. The worker edge
|
|
40
|
+
* addresses the same instance via
|
|
41
|
+
* `RATE_LIMIT_DO.idFromName(RATE_LIMIT_INSTANCE_NAME)`.
|
|
42
|
+
*/
|
|
43
|
+
export const RATE_LIMIT_INSTANCE_NAME = 'global';
|
|
44
|
+
|
|
45
|
+
/** Returns the storage key for `ip`. */
|
|
46
|
+
function rateKey(ip: string): string {
|
|
47
|
+
return `${RATE_KEY_PREFIX}${ip}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Per-IP upgrade rate authority.
|
|
52
|
+
*
|
|
53
|
+
* The class is exported as a binding target in `wrangler.toml`; the
|
|
54
|
+
* Workers runtime instantiates it with `(ctx, env)`. Implements the
|
|
55
|
+
* {@link RateLimitRpc} contract the worker edge calls.
|
|
56
|
+
*/
|
|
57
|
+
export class RateLimitDO extends DurableObject implements RateLimitRpc {
|
|
58
|
+
/**
|
|
59
|
+
* Sliding-window budget check + admission stamp in one serialized step.
|
|
60
|
+
*
|
|
61
|
+
* Prunes timestamps that aged out of the window, then allows the check
|
|
62
|
+
* (appending `now`) only while the surviving count is under `max`. A
|
|
63
|
+
* rejected check leaves the timestamp list at the pruned count —
|
|
64
|
+
* rejections never consume budget. `now` is overridable for
|
|
65
|
+
* deterministic window tests.
|
|
66
|
+
*/
|
|
67
|
+
async check(
|
|
68
|
+
ip: string,
|
|
69
|
+
max: number,
|
|
70
|
+
windowMs: number,
|
|
71
|
+
now: number = Date.now(),
|
|
72
|
+
): Promise<RateLimitResult> {
|
|
73
|
+
const key = rateKey(ip);
|
|
74
|
+
const stored = await this.ctx.storage.get<number[]>(key);
|
|
75
|
+
const cutoff = now - windowMs;
|
|
76
|
+
const recent = (stored ?? []).filter((t) => t >= cutoff);
|
|
77
|
+
if (recent.length >= max) {
|
|
78
|
+
// Persist the pruned list even on rejection so an IP that stops
|
|
79
|
+
// connecting does not keep stale timestamps alive indefinitely.
|
|
80
|
+
await this.ctx.storage.put(key, recent);
|
|
81
|
+
return { allowed: false, count: recent.length };
|
|
82
|
+
}
|
|
83
|
+
recent.push(now);
|
|
84
|
+
await this.ctx.storage.put(key, recent);
|
|
85
|
+
return { allowed: true, count: recent.length };
|
|
86
|
+
}
|
|
87
|
+
}
|