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,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards on the production `apps/cf-worker/wrangler.toml`.
|
|
3
|
+
*
|
|
4
|
+
* The `[observability]` block controls how much of the worker's console
|
|
5
|
+
* output (every structured log record the ConnectionActor emits) is
|
|
6
|
+
* retained by Workers Analytics / `wrangler tail` / Logpush. Full-rate
|
|
7
|
+
* sampling is unnecessary for triage — every record is scrubbed to
|
|
8
|
+
* non-sensitive fields — and multiplies the volume of client-adjacent
|
|
9
|
+
* data retained, so the default is capped. This test pins the cap so a
|
|
10
|
+
* casual edit cannot silently restore 100% sampling.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import wranglerToml from '../wrangler.toml?raw';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extracts the raw right-hand side of `key = value` from a top-level
|
|
18
|
+
* `[section]` table of the production wrangler config. Walks lines and
|
|
19
|
+
* switches tables on whole-line headers only (so a comment that merely
|
|
20
|
+
* mentions `[observability]` cannot confuse the parse). Returns
|
|
21
|
+
* `undefined` when either the table or the binding is absent so
|
|
22
|
+
* missing-value regressions fail loudly.
|
|
23
|
+
*/
|
|
24
|
+
function tableBinding(section: string, key: string): string | undefined {
|
|
25
|
+
let inSection = false;
|
|
26
|
+
for (const line of wranglerToml.split('\n')) {
|
|
27
|
+
const header = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
28
|
+
if (header !== null) {
|
|
29
|
+
inSection = header[1] === section;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (!inSection) continue;
|
|
33
|
+
const binding = line.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*(?:#.*)?$`));
|
|
34
|
+
if (binding !== null) return binding[1];
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('wrangler.toml observability', () => {
|
|
40
|
+
it('defaults head sampling to a reduced rate instead of 100%', () => {
|
|
41
|
+
expect(tableBinding('observability', 'head_sampling_rate')).toBe('0.1');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('keeps observability enabled (records must still reach the pipeline)', () => {
|
|
45
|
+
expect(tableBinding('observability', 'enabled')).toBe('true');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-edge admission cap (`maxClients`) integration tests.
|
|
3
|
+
*
|
|
4
|
+
* TDD outline:
|
|
5
|
+
* "Red: maxClients = 1; a second upgrade is admitted today; assert it
|
|
6
|
+
* receives 429 (with the `ERROR :Closing link: server full` body).
|
|
7
|
+
* Green: CounterDO admission check at the edge."
|
|
8
|
+
*
|
|
9
|
+
* Drives the production worker entry (`src/worker.ts`) end-to-end through
|
|
10
|
+
* `worker.fetch` exactly like `ws-origin.test.ts`: the admission check,
|
|
11
|
+
* the 429 + ERROR-line rejection, and the slot lifecycle (release on
|
|
12
|
+
* close → re-admit) are asserted at the HTTP boundary, with the real
|
|
13
|
+
* CounterDO + ConnectionDO behind the bindings.
|
|
14
|
+
*
|
|
15
|
+
* `MAX_CLIENTS` is not set in `wrangler.test.toml` (schema default
|
|
16
|
+
* 10 000); cap-pinned tests override it per call via `{ ...env,
|
|
17
|
+
* MAX_CLIENTS: '1' }`, the same per-test env-override pattern the
|
|
18
|
+
* origin suite uses for `WEB_ORIGINS`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { env, reset } from 'cloudflare:test';
|
|
22
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
23
|
+
import worker from '../src/worker';
|
|
24
|
+
|
|
25
|
+
declare global {
|
|
26
|
+
namespace Cloudflare {
|
|
27
|
+
interface Env {
|
|
28
|
+
WEB_ORIGINS: string;
|
|
29
|
+
/** Optional admission cap (parsed by the shared config loader). */
|
|
30
|
+
MAX_CLIENTS?: string;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const UPGRADE_URL = 'https://irc.example.com/';
|
|
36
|
+
|
|
37
|
+
function upgradeRequest(): Request {
|
|
38
|
+
// No Origin header — non-browser client, so the CSWSH policy passes
|
|
39
|
+
// regardless of the WEB_ORIGINS value in the test vars.
|
|
40
|
+
return new Request(UPGRADE_URL, { headers: { Upgrade: 'websocket' } });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Env with the global admission cap pinned to a single client. */
|
|
44
|
+
const capOne = { ...env, MAX_CLIENTS: '1' };
|
|
45
|
+
|
|
46
|
+
// DO storage persists across tests in the same file; reset between each
|
|
47
|
+
// so admission entries (and any half-closed connections) from one test
|
|
48
|
+
// never leak into the next.
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
await reset();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Polls upgrade attempts until one is admitted (101). The ConnectionDO
|
|
55
|
+
* teardown after a client-side close is event-driven and can lag in the
|
|
56
|
+
* test runtime, so the re-admit assertion polls instead of assuming
|
|
57
|
+
* synchrony. Every rejected attempt is a pure 429 (no socket, no state).
|
|
58
|
+
*/
|
|
59
|
+
async function waitForAdmission(testEnv: typeof env, timeoutMs = 8000): Promise<Response> {
|
|
60
|
+
const deadline = Date.now() + timeoutMs;
|
|
61
|
+
for (;;) {
|
|
62
|
+
const response = await worker.fetch(upgradeRequest(), testEnv);
|
|
63
|
+
if (response.status === 101) return response;
|
|
64
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
65
|
+
if (Date.now() > deadline) {
|
|
66
|
+
throw new Error(`upgrade was not admitted before timeout (${response.status})`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('WS admission cap (maxClients) — worker edge', () => {
|
|
72
|
+
it('admits an upgrade while under the cap (101)', async () => {
|
|
73
|
+
const response = await worker.fetch(upgradeRequest(), env);
|
|
74
|
+
expect(response.status).toBe(101);
|
|
75
|
+
response.webSocket?.accept();
|
|
76
|
+
response.webSocket?.close();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects the next upgrade with 429 once maxClients is reached', async () => {
|
|
80
|
+
const first = await worker.fetch(upgradeRequest(), capOne);
|
|
81
|
+
expect(first.status).toBe(101);
|
|
82
|
+
first.webSocket?.accept();
|
|
83
|
+
|
|
84
|
+
const second = await worker.fetch(upgradeRequest(), capOne);
|
|
85
|
+
expect(second.status).toBe(429);
|
|
86
|
+
expect(second.webSocket).toBeNull();
|
|
87
|
+
const body = await second.text();
|
|
88
|
+
// The IRC-style ERROR line travels in the 429 body: pre-handshake
|
|
89
|
+
// there is no WebSocket to carry a frame, so the body is the only
|
|
90
|
+
// channel a non-browser client can read.
|
|
91
|
+
expect(body).toBe('ERROR :Closing link: server full');
|
|
92
|
+
|
|
93
|
+
first.webSocket?.close();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('frees the slot on close so a later upgrade is admitted again', async () => {
|
|
97
|
+
const first = await worker.fetch(upgradeRequest(), capOne);
|
|
98
|
+
expect(first.status).toBe(101);
|
|
99
|
+
first.webSocket?.accept();
|
|
100
|
+
|
|
101
|
+
const second = await worker.fetch(upgradeRequest(), capOne);
|
|
102
|
+
expect(second.status).toBe(429);
|
|
103
|
+
|
|
104
|
+
// Close the admitted client; its ConnectionDO teardown releases the
|
|
105
|
+
// counter slot, so a subsequent upgrade must be admitted.
|
|
106
|
+
first.webSocket?.close();
|
|
107
|
+
const third = await waitForAdmission(capOne);
|
|
108
|
+
expect(third.status).toBe(101);
|
|
109
|
+
third.webSocket?.accept();
|
|
110
|
+
third.webSocket?.close();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-edge per-IP upgrade rate limit (`perIpConnectionRate`) tests.
|
|
3
|
+
*
|
|
4
|
+
* TDD outline:
|
|
5
|
+
* "Red: the N+1th upgrade from one `CF-Connecting-IP` within the
|
|
6
|
+
* window is admitted today; assert it receives 429. Green:
|
|
7
|
+
* RateLimitDO check at the edge, keyed by CF-Connecting-IP."
|
|
8
|
+
*
|
|
9
|
+
* Drives the production worker entry (`src/worker.ts`) end-to-end
|
|
10
|
+
* through `worker.fetch` exactly like `ws-admission.test.ts`. The rate
|
|
11
|
+
* knobs are overridden per call via `{ ...env, PER_IP_... }` env vars,
|
|
12
|
+
* the same per-test override pattern the admission suite uses for
|
|
13
|
+
* `MAX_CLIENTS`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { env, reset } from 'cloudflare:test';
|
|
17
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
18
|
+
import worker from '../src/worker';
|
|
19
|
+
|
|
20
|
+
declare global {
|
|
21
|
+
namespace Cloudflare {
|
|
22
|
+
interface Env {
|
|
23
|
+
WEB_ORIGINS: string;
|
|
24
|
+
MAX_CLIENTS?: string;
|
|
25
|
+
/** Optional per-IP upgrade rate budget (parsed by the config loader). */
|
|
26
|
+
PER_IP_CONNECTION_RATE_MAX?: string;
|
|
27
|
+
/** Optional per-IP rate window in milliseconds. */
|
|
28
|
+
PER_IP_CONNECTION_RATE_WINDOW_MS?: string;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const UPGRADE_URL = 'https://irc.example.com/';
|
|
34
|
+
|
|
35
|
+
function upgradeRequest(ip?: string): Request {
|
|
36
|
+
// No Origin header — non-browser client, so the CSWSH policy passes
|
|
37
|
+
// regardless of the WEB_ORIGINS value in the test vars.
|
|
38
|
+
const headers: Record<string, string> = { Upgrade: 'websocket' };
|
|
39
|
+
if (ip !== undefined) {
|
|
40
|
+
headers['CF-Connecting-IP'] = ip;
|
|
41
|
+
}
|
|
42
|
+
return new Request(UPGRADE_URL, { headers });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Env with the per-IP upgrade budget pinned to 2 per 60 s. */
|
|
46
|
+
const rateTwo = { ...env, PER_IP_CONNECTION_RATE_MAX: '2' };
|
|
47
|
+
|
|
48
|
+
// DO storage persists across tests in the same file; reset between each so
|
|
49
|
+
// rate entries (and any half-closed connections) never leak across tests.
|
|
50
|
+
afterEach(async () => {
|
|
51
|
+
await reset();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('WS per-IP upgrade rate limit — worker edge', () => {
|
|
55
|
+
it('admits upgrades while the IP is within its budget (101)', async () => {
|
|
56
|
+
const first = await worker.fetch(upgradeRequest('203.0.113.10'), rateTwo);
|
|
57
|
+
expect(first.status).toBe(101);
|
|
58
|
+
first.webSocket?.accept();
|
|
59
|
+
first.webSocket?.close();
|
|
60
|
+
|
|
61
|
+
const second = await worker.fetch(upgradeRequest('203.0.113.10'), rateTwo);
|
|
62
|
+
expect(second.status).toBe(101);
|
|
63
|
+
second.webSocket?.accept();
|
|
64
|
+
second.webSocket?.close();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects the over-budget upgrade from the same IP with 429', async () => {
|
|
68
|
+
for (let i = 0; i < 2; i++) {
|
|
69
|
+
const r = await worker.fetch(upgradeRequest('203.0.113.11'), rateTwo);
|
|
70
|
+
expect(r.status).toBe(101);
|
|
71
|
+
r.webSocket?.accept();
|
|
72
|
+
}
|
|
73
|
+
const third = await worker.fetch(upgradeRequest('203.0.113.11'), rateTwo);
|
|
74
|
+
expect(third.status).toBe(429);
|
|
75
|
+
expect(third.webSocket).toBeNull();
|
|
76
|
+
const body = await third.text();
|
|
77
|
+
// The IRC-style ERROR line travels in the 429 body (same convention as
|
|
78
|
+
// the server-full rejection) so non-browser clients see a reason.
|
|
79
|
+
expect(body).toBe('ERROR :Closing link: connection rate exceeded');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('keys the budget on CF-Connecting-IP (a saturated IP does not block another)', async () => {
|
|
83
|
+
const a1 = await worker.fetch(upgradeRequest('203.0.113.12'), rateTwo);
|
|
84
|
+
expect(a1.status).toBe(101);
|
|
85
|
+
a1.webSocket?.accept();
|
|
86
|
+
const a2 = await worker.fetch(upgradeRequest('203.0.113.12'), rateTwo);
|
|
87
|
+
expect(a2.status).toBe(101);
|
|
88
|
+
a2.webSocket?.accept();
|
|
89
|
+
expect(await worker.fetch(upgradeRequest('203.0.113.12'), rateTwo)).toHaveProperty(
|
|
90
|
+
'status',
|
|
91
|
+
429,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const other = await worker.fetch(upgradeRequest('198.51.100.7'), rateTwo);
|
|
95
|
+
expect(other.status).toBe(101);
|
|
96
|
+
other.webSocket?.accept();
|
|
97
|
+
other.webSocket?.close();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('recovers the budget after the sliding window decays', async () => {
|
|
101
|
+
const shortWindow = {
|
|
102
|
+
...env,
|
|
103
|
+
PER_IP_CONNECTION_RATE_MAX: '1',
|
|
104
|
+
PER_IP_CONNECTION_RATE_WINDOW_MS: '500',
|
|
105
|
+
};
|
|
106
|
+
const first = await worker.fetch(upgradeRequest('203.0.113.13'), shortWindow);
|
|
107
|
+
expect(first.status).toBe(101);
|
|
108
|
+
first.webSocket?.accept();
|
|
109
|
+
expect(await worker.fetch(upgradeRequest('203.0.113.13'), shortWindow)).toHaveProperty(
|
|
110
|
+
'status',
|
|
111
|
+
429,
|
|
112
|
+
);
|
|
113
|
+
// Wait out the 500 ms window; the single admission decays and the
|
|
114
|
+
// budget recovers.
|
|
115
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
116
|
+
const after = await worker.fetch(upgradeRequest('203.0.113.13'), shortWindow);
|
|
117
|
+
expect(after.status).toBe(101);
|
|
118
|
+
after.webSocket?.accept();
|
|
119
|
+
after.webSocket?.close();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('skips the rate check when CF-Connecting-IP is absent (non-edge request)', async () => {
|
|
123
|
+
// Only the Cloudflare edge sets CF-Connecting-IP; a request lacking it
|
|
124
|
+
// (e.g. a direct service-binding call) bypasses the per-IP budget —
|
|
125
|
+
// there is no key to budget on. Production traffic always enters via
|
|
126
|
+
// the edge, which stamps the header.
|
|
127
|
+
for (let i = 0; i < 3; i++) {
|
|
128
|
+
const r = await worker.fetch(upgradeRequest(), rateTwo);
|
|
129
|
+
expect(r.status).toBe(101);
|
|
130
|
+
r.webSocket?.accept();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -55,6 +55,20 @@ class_name = "ChannelDO"
|
|
|
55
55
|
name = "CHANNEL_REGISTRY_DO"
|
|
56
56
|
class_name = "ChannelRegistryDO"
|
|
57
57
|
|
|
58
|
+
# Global live-connection admission counter (maxClients cap). Mirrors the
|
|
59
|
+
# production binding; ws-admission.test.ts drives the worker edge through
|
|
60
|
+
# admit / reject / re-admit after close.
|
|
61
|
+
[[durable_objects.bindings]]
|
|
62
|
+
name = "COUNTER_DO"
|
|
63
|
+
class_name = "CounterDO"
|
|
64
|
+
|
|
65
|
+
# Per-IP upgrade rate limiter (perIpConnectionRate). Mirrors the
|
|
66
|
+
# production binding; ws-rate-limit.test.ts drives the worker edge
|
|
67
|
+
# through allow / over-budget reject / window decay.
|
|
68
|
+
[[durable_objects.bindings]]
|
|
69
|
+
name = "RATE_LIMIT_DO"
|
|
70
|
+
class_name = "RateLimitDO"
|
|
71
|
+
|
|
58
72
|
# D1 backing for persistent SASL accounts
|
|
59
73
|
[[d1_databases]]
|
|
60
74
|
binding = "ACCOUNTS_DB"
|
|
@@ -63,4 +77,4 @@ database_id = "test-d1-worker-accounts"
|
|
|
63
77
|
|
|
64
78
|
[[migrations]]
|
|
65
79
|
tag = "v1"
|
|
66
|
-
new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
|
|
80
|
+
new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "CounterDO", "RateLimitDO"]
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
## Cloudflare Worker production config for ServerlessIRCd.
|
|
4
4
|
##
|
|
5
5
|
## Deploy:
|
|
6
|
-
## pnpm deploy:cf
|
|
7
|
-
## pnpm deploy:cf:prod # wrangler deploy
|
|
6
|
+
## pnpm deploy:cf # wrangler deploy
|
|
8
7
|
##
|
|
9
8
|
## The `apps/cf-worker` worker is the public edge for the IRC service.
|
|
10
9
|
## It owns the three Durable Object namespaces from the cf-adapter
|
|
@@ -12,11 +11,45 @@
|
|
|
12
11
|
## upgrades to per-connection ConnectionDO instances. See
|
|
13
12
|
## `src/worker.ts` and PLAN §6.1.
|
|
14
13
|
##
|
|
15
|
-
## Secrets: NEVER put secrets
|
|
16
|
-
##
|
|
17
|
-
##
|
|
18
|
-
##
|
|
19
|
-
##
|
|
14
|
+
## Secrets: NEVER put secrets in this file's [vars] table. Vars marked
|
|
15
|
+
## [secret] below live in the Workers secrets store, not the repo — set
|
|
16
|
+
## them with `wrangler secret put <NAME>`.
|
|
17
|
+
##
|
|
18
|
+
## Every env var the Worker consumes is enumerated in the block below,
|
|
19
|
+
## classified `[vars]` (plaintext knob) or `[secret]` (credential
|
|
20
|
+
## material). The block is drift-guarded by `tools/ci-hardening`
|
|
21
|
+
## (tests/env-var-drift.test.ts): adding a var to `CfConfigEnv`
|
|
22
|
+
## (`packages/cf-adapter/src/config-loader.ts`) without a matching
|
|
23
|
+
## entry here fails CI.
|
|
24
|
+
## BEGIN consumed-env-vars
|
|
25
|
+
## [vars] SERVER_NAME — REQUIRED. Public hostname clients see in numerics (001/005). The deploy hostname guard refuses placeholders.
|
|
26
|
+
## [vars] NETWORK_NAME — Network name surfaced in 002 / ISUPPORT (default "ServerlessIRCd").
|
|
27
|
+
## [vars] SERVER_VERSION — Version string for 002/004/351/371 (schema default when unset).
|
|
28
|
+
## [vars] CREATED_AT — Epoch-ms number or free text for 003 RPL_CREATED.
|
|
29
|
+
## [vars] MOTD_LINES — Newline-delimited MOTD text.
|
|
30
|
+
## [vars] MAX_CLIENTS — Global live-connection cap enforced by COUNTER_DO admission (default 10000).
|
|
31
|
+
## [vars] CHANNEL_PREFIXES — Allowed channel-type prefixes.
|
|
32
|
+
## [vars] MAX_CHANNELS_PER_USER — Per-user joined-channel cap.
|
|
33
|
+
## [vars] MAX_TARGETS_PER_COMMAND — Cap on comma-split targets for PRIVMSG/NOTICE/PART.
|
|
34
|
+
## [vars] NICK_LEN — Maximum nick byte length.
|
|
35
|
+
## [vars] CHANNEL_LEN — Maximum channel-name byte length.
|
|
36
|
+
## [vars] TOPIC_LEN — Maximum topic byte length.
|
|
37
|
+
## [vars] MAX_LIST_ENTRIES — Cap on LIST reply entries.
|
|
38
|
+
## [vars] QUIT_MESSAGE — Default QUIT part-message.
|
|
39
|
+
## [vars] MAX_FRAMES_PER_WINDOW — Per-connection inbound frame ceiling enforced before the actor write.
|
|
40
|
+
## [vars] FRAME_WINDOW_SECONDS — Sliding-window length (seconds) for the frame limit.
|
|
41
|
+
## [vars] MAX_CONNECTIONS_PER_IP — Per-IP simultaneous-connection cap (admission gates).
|
|
42
|
+
## [vars] PER_IP_CONNECTION_RATE_MAX — Per-IP new-connection budget per window (RATE_LIMIT_DO edge check).
|
|
43
|
+
## [vars] PER_IP_CONNECTION_RATE_WINDOW_MS — Per-IP rate window length in ms.
|
|
44
|
+
## [vars] EXTERNAL_ENABLED — Operator opt-in ('true'/'1') for certificate-backed SASL EXTERNAL; additionally requires a bound mTLS identity source and a TLS connection (default off).
|
|
45
|
+
## [vars] OPER_USER — Oper username (pair with a credential secret below).
|
|
46
|
+
## [secret] OPER_PASSWORD — Legacy plaintext oper password (prefer the hashed OPER_SALT + OPER_HASH form; rotate with tools/hash-oper-cred.ts).
|
|
47
|
+
## [secret] OPER_SALT — Base64 scrypt salt of the hashed oper credential (generate with tools/hash-oper-cred.ts).
|
|
48
|
+
## [secret] OPER_HASH — Base64 scrypt hash of the hashed oper credential (generate with tools/hash-oper-cred.ts).
|
|
49
|
+
## [secret] SERVER_PASSWORD — Server-wide PASS gate (`PASS <value>` required before 001; SASL-identified connections are exempt).
|
|
50
|
+
## [secret] SASL_ACCOUNTS — Newline-delimited `user:password` SASL PLAIN seed list (see tools/seed-cf-accounts.ts; supersedes the D1 store at boot when set).
|
|
51
|
+
## [vars] WEB_ORIGINS — OPTIONAL comma-separated Origin allowlist (CSWSH defence). Read directly by the worker edge, not the config loader; unset = same-origin auto-derive.
|
|
52
|
+
## END consumed-env-vars
|
|
20
53
|
|
|
21
54
|
name = "serverless-ircd"
|
|
22
55
|
main = "src/worker.ts"
|
|
@@ -35,10 +68,16 @@ compatibility_flags = ["nodejs_compat"]
|
|
|
35
68
|
## `dispatch.error`, `frame.parse-error`) are the canonical query keys.
|
|
36
69
|
[observability]
|
|
37
70
|
enabled = true
|
|
38
|
-
## `head_sampling_rate`
|
|
39
|
-
##
|
|
40
|
-
##
|
|
41
|
-
|
|
71
|
+
## `head_sampling_rate` caps the share of console output Workers
|
|
72
|
+
## Analytics retains. The default is 0.1 (10%): every record the actor
|
|
73
|
+
## emits is scrubbed to non-sensitive fields (e.g. `frame.parse-error`
|
|
74
|
+
## carries only token/length/reason, never the raw line), so full-rate
|
|
75
|
+
## sampling is unnecessary for triage and only multiplies how much
|
|
76
|
+
## client-adjacent data log destinations retain. Production operators
|
|
77
|
+
## can tune this per environment by overriding the `[observability]`
|
|
78
|
+
## block in an `[env.<name>]` section (e.g. staging at 1.0 while
|
|
79
|
+
## debugging an incident).
|
|
80
|
+
head_sampling_rate = 0.1
|
|
42
81
|
|
|
43
82
|
[observability.logs]
|
|
44
83
|
enabled = true
|
|
@@ -55,9 +94,15 @@ invocation_logs = true
|
|
|
55
94
|
## listed origins proceed. When unset/empty (the default), the Worker
|
|
56
95
|
## auto-derives the expected origin from the request's own `Host` header
|
|
57
96
|
## (same-origin enforcement) — no per-env config needed. Set this only for
|
|
58
|
-
## cross-origin deployments where the SPA is served from a different
|
|
59
|
-
## than the Worker. Non-browser clients (curl, WeeChat, tcp-ws-forwarder)
|
|
97
|
+
## cross-origin deployments where the SPA is served from a different
|
|
98
|
+
## domain than the Worker. Non-browser clients (curl, WeeChat, tcp-ws-forwarder)
|
|
60
99
|
## omit `Origin` and pass through unchanged regardless.
|
|
100
|
+
##
|
|
101
|
+
## `MAX_CLIENTS` is the OPTIONAL global live-connection cap (default
|
|
102
|
+
## 10000). The Worker reserves a slot in the COUNTER_DO counter before
|
|
103
|
+
## forwarding an upgrade and rejects with 429 (ERROR :Closing link:
|
|
104
|
+
## server full) once the cap is reached; slots are released on
|
|
105
|
+
## connection close and TTL-reaped if a DO dies without one.
|
|
61
106
|
[vars]
|
|
62
107
|
SERVER_NAME = "irc.your-domain.invalid"
|
|
63
108
|
NETWORK_NAME = "ServerlessIRCd"
|
|
@@ -104,55 +149,20 @@ not_found_handling = "none"
|
|
|
104
149
|
run_worker_first = true
|
|
105
150
|
|
|
106
151
|
## ---------------------------------------------------------------------------
|
|
107
|
-
##
|
|
108
|
-
## ---------------------------------------------------------------------------
|
|
109
|
-
## `wrangler deploy --env staging` ships to the staging Worker + DO
|
|
110
|
-
## namespace. The CI workflow (`.github/workflows/deploy-cf.yml`) deploys
|
|
111
|
-
## here on every push to `main` and then runs the smoke e2e.
|
|
112
|
-
[env.staging]
|
|
113
|
-
name = "serverless-ircd-staging"
|
|
114
|
-
|
|
115
|
-
[env.staging.observability]
|
|
116
|
-
enabled = true
|
|
117
|
-
head_sampling_rate = 1
|
|
118
|
-
|
|
119
|
-
[env.staging.vars]
|
|
120
|
-
SERVER_NAME = "irc-staging.example.com"
|
|
121
|
-
NETWORK_NAME = "ServerlessIRCd (staging)"
|
|
122
|
-
MOTD_LINES = """
|
|
123
|
-
Welcome to ServerlessIRCd staging.
|
|
124
|
-
This is a deployed Cloudflare Worker.
|
|
125
|
-
This Worker was deployed by CI from the main branch.
|
|
126
|
-
"""
|
|
127
|
-
|
|
128
|
-
# Staging serves the same `apps/web/dist` asset layer. Wrangler requires the
|
|
129
|
-
# per-env assets block so `--env staging` deploys both the Worker and its
|
|
130
|
-
# assets in a single `wrangler deploy --env staging` (the same `pnpm build`
|
|
131
|
-
# populates `dist/` ahead of the deploy step).
|
|
132
|
-
[env.staging.assets]
|
|
133
|
-
directory = "../../apps/web/dist"
|
|
134
|
-
binding = "ASSETS"
|
|
135
|
-
not_found_handling = "none"
|
|
136
|
-
run_worker_first = true
|
|
137
|
-
|
|
138
|
-
## ---------------------------------------------------------------------------
|
|
139
|
-
## D1 database — SASL account persistence.
|
|
152
|
+
## D1 database — unified services persistence (NickServ / ChanServ / SASL).
|
|
140
153
|
## ---------------------------------------------------------------------------
|
|
141
|
-
## The `
|
|
142
|
-
##
|
|
143
|
-
##
|
|
154
|
+
## The `nickserv_accounts` table is the single credential home for SASL
|
|
155
|
+
## PLAIN, SASL EXTERNAL, PASS-auth, and NickServ IDENTIFY. All scrypt-hashed
|
|
156
|
+
## credentials live here; the standalone `accounts` table was dropped.
|
|
157
|
+
## Provision with:
|
|
144
158
|
## wrangler d1 create serverless-ircd-accounts
|
|
145
|
-
##
|
|
146
|
-
##
|
|
147
|
-
##
|
|
159
|
+
## The services tables self-provision on first load (ConnectionDO runs
|
|
160
|
+
## `CREATE TABLE IF NOT EXISTS` for each table). Seed accounts via
|
|
161
|
+
## `tools/seed-cf-accounts.ts`.
|
|
148
162
|
[[d1_databases]]
|
|
149
163
|
binding = "ACCOUNTS_DB"
|
|
150
164
|
database_name = "serverless-ircd-accounts"
|
|
151
|
-
# database_id is filled in by `wrangler d1 create
|
|
152
|
-
|
|
153
|
-
[[env.staging.d1_databases]]
|
|
154
|
-
binding = "ACCOUNTS_DB"
|
|
155
|
-
database_name = "serverless-ircd-accounts-staging"
|
|
165
|
+
# database_id is filled in by `wrangler d1 create`.
|
|
156
166
|
|
|
157
167
|
## ---------------------------------------------------------------------------
|
|
158
168
|
## Durable Object bindings.
|
|
@@ -179,22 +189,26 @@ class_name = "ChannelDO"
|
|
|
179
189
|
name = "CHANNEL_REGISTRY_DO"
|
|
180
190
|
class_name = "ChannelRegistryDO"
|
|
181
191
|
|
|
182
|
-
#
|
|
183
|
-
|
|
184
|
-
name
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
192
|
+
# Global live-connection counter enforcing `MAX_CLIENTS` (the
|
|
193
|
+
# `maxClients` server-config cap). A single CounterDO instance (fixed
|
|
194
|
+
# name) tracks one admission entry per live WebSocket; the Worker's
|
|
195
|
+
# `fetch` reserves a slot per upgrade and rejects with 429 when full,
|
|
196
|
+
# ConnectionDO releases the slot on every teardown path, and a lazy TTL
|
|
197
|
+
# reap on each admit reconciles entries from DO instances that died
|
|
198
|
+
# without delivering a close event.
|
|
199
|
+
[[durable_objects.bindings]]
|
|
200
|
+
name = "COUNTER_DO"
|
|
201
|
+
class_name = "CounterDO"
|
|
202
|
+
|
|
203
|
+
# Per-IP upgrade rate limiter enforcing `perIpConnectionRate` (knobs:
|
|
204
|
+
# `PER_IP_CONNECTION_RATE_MAX` / `PER_IP_CONNECTION_RATE_WINDOW_MS`
|
|
205
|
+
# vars, defaults 5 per 60 s). A single RateLimitDO instance (fixed name)
|
|
206
|
+
# keeps one sliding-window admission-timestamp list per
|
|
207
|
+
# CF-Connecting-IP; the Worker's `fetch` checks the budget before
|
|
208
|
+
# forwarding an upgrade and rejects over-budget IPs with 429.
|
|
209
|
+
[[durable_objects.bindings]]
|
|
210
|
+
name = "RATE_LIMIT_DO"
|
|
211
|
+
class_name = "RateLimitDO"
|
|
198
212
|
|
|
199
213
|
# ## ---------------------------------------------------------------------------
|
|
200
214
|
# ## Durable Object migrations.
|
|
@@ -210,10 +224,6 @@ class_name = "ChannelRegistryDO"
|
|
|
210
224
|
# tag = "v1"
|
|
211
225
|
# new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
|
|
212
226
|
|
|
213
|
-
# [[env.staging.migrations]]
|
|
214
|
-
# tag = "v1"
|
|
215
|
-
# new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
|
|
216
|
-
|
|
217
227
|
[exports.ConnectionDO]
|
|
218
228
|
type = "durable-object"
|
|
219
229
|
storage = "sqlite"
|
|
@@ -229,3 +239,11 @@ storage = "sqlite"
|
|
|
229
239
|
[exports.ChannelRegistryDO]
|
|
230
240
|
type = "durable-object"
|
|
231
241
|
storage = "sqlite"
|
|
242
|
+
|
|
243
|
+
[exports.CounterDO]
|
|
244
|
+
type = "durable-object"
|
|
245
|
+
storage = "sqlite"
|
|
246
|
+
|
|
247
|
+
[exports.RateLimitDO]
|
|
248
|
+
type = "durable-object"
|
|
249
|
+
storage = "sqlite"
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* surfaced at boot rather than mid-session.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
type OperCred,
|
|
14
|
+
type ParsedServerConfig,
|
|
15
|
+
parseServerConfig,
|
|
16
|
+
} from '@serverless-ircd/irc-core';
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
19
|
* CLI / programmatic input for `loadServerConfigFromCliArgs`. Every
|
|
@@ -34,7 +38,15 @@ export interface CliConfigInput {
|
|
|
34
38
|
motdLines?: string[];
|
|
35
39
|
channelPrefixes?: string;
|
|
36
40
|
maxClients?: number;
|
|
37
|
-
|
|
41
|
+
/**
|
|
42
|
+
* IRC operator credentials. Each entry is EITHER the at-rest hashed
|
|
43
|
+
* form `{user, salt, hash}` (recommended — generate via
|
|
44
|
+
* `tools/hash-oper-cred.ts`) OR the legacy plaintext form
|
|
45
|
+
* `{user, password}` (deprecated — retained for a single
|
|
46
|
+
* deprecation-cycle window). The two shapes can be mixed in the same
|
|
47
|
+
* array; the shared `OperCredSchema` validates either.
|
|
48
|
+
*/
|
|
49
|
+
operCreds?: ReadonlyArray<OperCred>;
|
|
38
50
|
/**
|
|
39
51
|
* Server-password gate. Treat as a secret; the loader threads it
|
|
40
52
|
* straight through (via `stripUndefined`) to `ServerConfig.serverPassword`
|
|
@@ -18,9 +18,26 @@
|
|
|
18
18
|
* exactly when a real IRC client connects to local-cli directly.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { MAX_BUFFER_BYTES } from '@serverless-ircd/irc-core';
|
|
22
|
+
|
|
23
|
+
/** Options accepted by {@link LineScanner}. */
|
|
24
|
+
export interface LineScannerOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Invoked when a pushed chunk grows the retained carry past
|
|
27
|
+
* {@link MAX_BUFFER_BYTES} (raw bytes, checked once per push). The
|
|
28
|
+
* scanner drops the oversized carry and returns no lines; the owner
|
|
29
|
+
* should write `ERROR :Closing link: ...` and destroy the socket — this
|
|
30
|
+
* seam has no write path. Mirrors the `TcpByteStreamTransport` cap so
|
|
31
|
+
* both sides of the WS↔TCP bridge enforce one identical budget.
|
|
32
|
+
*/
|
|
33
|
+
onOverflow?: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
export class LineScanner {
|
|
22
37
|
private carry: Buffer = Buffer.alloc(0);
|
|
23
38
|
|
|
39
|
+
constructor(private readonly options?: LineScannerOptions) {}
|
|
40
|
+
|
|
24
41
|
/**
|
|
25
42
|
* Feeds a chunk of TCP bytes. Returns the complete lines found in this
|
|
26
43
|
* chunk (joined with any leftover carry from prior chunks), in order,
|
|
@@ -42,6 +59,15 @@ export class LineScanner {
|
|
|
42
59
|
nl = buf.indexOf(0x0a, start);
|
|
43
60
|
}
|
|
44
61
|
this.carry = buf.subarray(start);
|
|
62
|
+
// Cap the retained (unterminated) tail — checked once per push, in raw
|
|
63
|
+
// bytes. Complete lines flushed by this chunk never count against the
|
|
64
|
+
// budget, so legitimate multi-line batches far larger than the cap pass
|
|
65
|
+
// through; only an unterminated tail can grow unboundedly.
|
|
66
|
+
if (this.carry.length > MAX_BUFFER_BYTES) {
|
|
67
|
+
this.carry = Buffer.alloc(0);
|
|
68
|
+
this.options?.onOverflow?.();
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
45
71
|
return lines;
|
|
46
72
|
}
|
|
47
73
|
|