serverless-ircd 0.10.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-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- 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 +17 -4
- package/apps/cf-worker/package.json +2 -2
- 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 +86 -9
- 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 +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- 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 +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- 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-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- 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 +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -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/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- 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/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 +40 -1
- 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 +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- 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 +393 -20
- 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 +8 -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 +395 -36
- 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 +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- 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/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- 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/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 +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- 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 +544 -7
- 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/scripts/package.json +1 -1
- 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 +17 -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-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -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/tcp-ws-forwarder/package.json +1 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `CounterDO` — global live-connection admission counter.
|
|
3
|
+
*
|
|
4
|
+
* TDD outline:
|
|
5
|
+
* "Red on admit-under-cap / reject-at-cap; green; then TTL reap,
|
|
6
|
+
* heartbeat refresh, and the lazy reconcile on admit."
|
|
7
|
+
*
|
|
8
|
+
* Tests address the single global counter instance directly over RPC,
|
|
9
|
+
* driving every branch of admit / release / heartbeat / sweep with
|
|
10
|
+
* explicit timestamps so the TTL boundary is deterministic (no wall
|
|
11
|
+
* clock dependence). Each test gets an isolated storage namespace, so
|
|
12
|
+
* the shared `global` instance never bleeds state across tests.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { env, reset } from 'cloudflare:test';
|
|
16
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
17
|
+
import { COUNTER_INSTANCE_NAME, DEFAULT_CONNECTION_TTL_MS } from '../src/counter-do';
|
|
18
|
+
import type { CounterRpc } from '../src/env';
|
|
19
|
+
|
|
20
|
+
declare global {
|
|
21
|
+
namespace Cloudflare {
|
|
22
|
+
interface Env {
|
|
23
|
+
COUNTER_DO: DurableObjectNamespace;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// DO storage persists across tests in the same file; reset between each so
|
|
29
|
+
// admission entries from one test never leak into the next. Every test
|
|
30
|
+
// addresses the same `global` counter instance, so this is essential.
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
await reset();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Fixed epoch base for deterministic TTL arithmetic. */
|
|
36
|
+
const T0 = 1_700_000_000_000;
|
|
37
|
+
/** Alias for readability in the TTL-boundary tests. */
|
|
38
|
+
const TTL = DEFAULT_CONNECTION_TTL_MS;
|
|
39
|
+
|
|
40
|
+
/** RPC handle on the single global counter instance. */
|
|
41
|
+
function counter(): CounterRpc {
|
|
42
|
+
const stub = env.COUNTER_DO.get(env.COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME));
|
|
43
|
+
return stub as unknown as CounterRpc;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// admit
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
describe('CounterDO — admit', () => {
|
|
51
|
+
it('admits when under the cap and reports the live count', async () => {
|
|
52
|
+
const c = counter();
|
|
53
|
+
expect(await c.admit('conn-a', 2, T0)).toEqual({ admitted: true, live: 1 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('admits a second connection while still under the cap', async () => {
|
|
57
|
+
const c = counter();
|
|
58
|
+
await c.admit('conn-b1', 2, T0);
|
|
59
|
+
expect(await c.admit('conn-b2', 2, T0)).toEqual({ admitted: true, live: 2 });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects once the cap is reached', async () => {
|
|
63
|
+
const c = counter();
|
|
64
|
+
await c.admit('conn-c1', 1, T0);
|
|
65
|
+
expect(await c.admit('conn-c2', 1, T0)).toEqual({ admitted: false, live: 1 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('a rejected admission registers no entry', async () => {
|
|
69
|
+
const c = counter();
|
|
70
|
+
await c.admit('conn-d1', 1, T0);
|
|
71
|
+
await c.admit('conn-d2', 1, T0);
|
|
72
|
+
expect(await c.liveCount()).toBe(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('re-admits after the capped connection is released', async () => {
|
|
76
|
+
const c = counter();
|
|
77
|
+
await c.admit('conn-e1', 1, T0);
|
|
78
|
+
await c.release('conn-e1');
|
|
79
|
+
expect(await c.admit('conn-e2', 1, T0)).toEqual({ admitted: true, live: 1 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('exactly one of two concurrent admissions wins at cap 1', async () => {
|
|
83
|
+
// Two worker-edge fetches race on the single counter instance. The
|
|
84
|
+
// DO's serialized event loop linearises the admits; exactly one
|
|
85
|
+
// reservation survives. Mirrors the RegistryDO concurrency suite.
|
|
86
|
+
const c = counter();
|
|
87
|
+
const [r1, r2] = await Promise.all([c.admit('race-1', 1, T0), c.admit('race-2', 1, T0)]);
|
|
88
|
+
const results = [r1, r2];
|
|
89
|
+
expect(results.filter((r) => r.admitted).length).toBe(1);
|
|
90
|
+
expect(results.filter((r) => !r.admitted).length).toBe(1);
|
|
91
|
+
expect(await c.liveCount()).toBe(1);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// release
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe('CounterDO — release', () => {
|
|
100
|
+
it('removes the entry so the slot frees', async () => {
|
|
101
|
+
const c = counter();
|
|
102
|
+
await c.admit('rel-a', 5, T0);
|
|
103
|
+
await c.release('rel-a');
|
|
104
|
+
expect(await c.liveCount()).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('is idempotent for an unknown connection id', async () => {
|
|
108
|
+
const c = counter();
|
|
109
|
+
await c.release('never-registered');
|
|
110
|
+
expect(await c.liveCount()).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// TTL reap — housekeeping for DO instances that crashed without a close
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
describe('CounterDO — TTL reap (housekeeping)', () => {
|
|
119
|
+
it('sweep reaps entries older than the TTL and keeps fresh ones', async () => {
|
|
120
|
+
const c = counter();
|
|
121
|
+
await c.admit('stale', 5, T0);
|
|
122
|
+
await c.admit('fresh', 5, T0 + 60_000);
|
|
123
|
+
const result = await c.sweep(T0 + TTL + 60_000);
|
|
124
|
+
expect(result.reaped).toBe(1);
|
|
125
|
+
expect(result.live).toBe(1);
|
|
126
|
+
expect(await c.liveCount()).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('an entry aged exactly the TTL is still live (only strictly-older reaps)', async () => {
|
|
130
|
+
const c = counter();
|
|
131
|
+
await c.admit('boundary', 5, T0);
|
|
132
|
+
expect(await c.sweep(T0 + TTL)).toEqual({ reaped: 0, live: 1 });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('admit lazily reaps stale entries first (a crashed DO frees its slot)', async () => {
|
|
136
|
+
// The production failure mode: a ConnectionDO isolate is dropped
|
|
137
|
+
// without ever delivering a close event, so `release` never fires and
|
|
138
|
+
// the entry goes stale. The next admission must sweep it out instead
|
|
139
|
+
// of rejecting the newcomer.
|
|
140
|
+
const c = counter();
|
|
141
|
+
await c.admit('crashed', 1, T0);
|
|
142
|
+
expect(await c.admit('newcomer', 1, T0 + TTL + 1)).toEqual({ admitted: true, live: 1 });
|
|
143
|
+
expect(await c.liveCount()).toBe(1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// heartbeat — keeps live connections safe from the TTL reap
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
describe('CounterDO — heartbeat', () => {
|
|
152
|
+
it('refreshes the entry so a live connection survives the TTL reap', async () => {
|
|
153
|
+
const c = counter();
|
|
154
|
+
await c.admit('beat', 5, T0);
|
|
155
|
+
// The ConnectionDO's PING alarm refreshes the entry well inside the
|
|
156
|
+
// TTL window; by T0+TTL+60s the lastSeen has moved past the original
|
|
157
|
+
// admission stamp, so the sweep at T0+TTL+60s+1ms must keep it.
|
|
158
|
+
await c.heartbeat('beat', T0 + TTL + 60_000);
|
|
159
|
+
expect(await c.sweep(T0 + TTL + 60_001)).toEqual({ reaped: 0, live: 1 });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('does not resurrect a released entry', async () => {
|
|
163
|
+
// A late heartbeat (e.g. racing the close handler) must not re-create
|
|
164
|
+
// a slot that was already released — that would overcount.
|
|
165
|
+
const c = counter();
|
|
166
|
+
await c.admit('gone', 5, T0);
|
|
167
|
+
await c.release('gone');
|
|
168
|
+
await c.heartbeat('gone', T0 + 1000);
|
|
169
|
+
expect(await c.liveCount()).toBe(0);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// liveCount
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
describe('CounterDO — liveCount', () => {
|
|
178
|
+
it('reports zero for a fresh counter', async () => {
|
|
179
|
+
expect(await counter().liveCount()).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { env } from 'cloudflare:test';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
FakeClock,
|
|
14
|
+
type ServicesSnapshot,
|
|
15
|
+
certIdentityFromSubject,
|
|
16
|
+
} from '@serverless-ircd/irc-core';
|
|
13
17
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
18
|
import {
|
|
15
19
|
CREATE_SERVICES_TABLES_SQL,
|
|
@@ -163,6 +167,119 @@ describe('D1ServicesStore — schema migration (password → algorithm/salt/hash
|
|
|
163
167
|
});
|
|
164
168
|
});
|
|
165
169
|
|
|
170
|
+
describe('D1ServicesStore — schema migration (cert_subjects era → identify-throttle columns)', () => {
|
|
171
|
+
/** Pre-throttle schema: every current column except the two new ones. */
|
|
172
|
+
const CERT_ERA_NICKSERV_DDL =
|
|
173
|
+
"CREATE TABLE nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, cert_subjects TEXT NOT NULL DEFAULT '[]', algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)";
|
|
174
|
+
|
|
175
|
+
afterEach(async () => {
|
|
176
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('adds the identify-throttle columns on load and keeps existing rows (defaults)', async () => {
|
|
180
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
|
|
181
|
+
await env.ACCOUNTS_DB.exec(CERT_ERA_NICKSERV_DDL);
|
|
182
|
+
await env.ACCOUNTS_DB.prepare(
|
|
183
|
+
'INSERT INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
184
|
+
)
|
|
185
|
+
.bind('alice', 'alice', 'alice', 'a@x', 1, 'none', '[]', 'scrypt', 'c2FsdA==', 'aGFzaA==')
|
|
186
|
+
.run();
|
|
187
|
+
|
|
188
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
189
|
+
expect(store).toBeDefined();
|
|
190
|
+
|
|
191
|
+
// The additive columns now exist.
|
|
192
|
+
const info = await env.ACCOUNTS_DB.prepare('PRAGMA table_info(nickserv_accounts)').all<{
|
|
193
|
+
name: string;
|
|
194
|
+
}>();
|
|
195
|
+
const cols = info.results.map((r) => r.name);
|
|
196
|
+
expect(cols).toContain('identify_failures');
|
|
197
|
+
expect(cols).toContain('pending_identify_notice');
|
|
198
|
+
|
|
199
|
+
// The pre-existing row survived and loads with empty throttle defaults.
|
|
200
|
+
expect(store?.isRegisteredNick('alice')).toBe(true);
|
|
201
|
+
expect(store?.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
|
|
202
|
+
expect(store?.clearIdentifyFailures('alice')).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('is idempotent — a second load does not re-add the columns or drop rows', async () => {
|
|
206
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
|
|
207
|
+
await env.ACCOUNTS_DB.exec(CERT_ERA_NICKSERV_DDL);
|
|
208
|
+
const first = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
209
|
+
first?.registerNick('carol', 'pw', 'c@x');
|
|
210
|
+
await first?.flush();
|
|
211
|
+
|
|
212
|
+
const second = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW + 1));
|
|
213
|
+
expect(second?.isRegisteredNick('carol')).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('adds the cert_subjects column too when migrating from the algorithm era', async () => {
|
|
217
|
+
// Schema after the credential unification but before cert_subjects:
|
|
218
|
+
// exercises the cert_subjects ALTER arm on its own.
|
|
219
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
|
|
220
|
+
await env.ACCOUNTS_DB.exec(
|
|
221
|
+
'CREATE TABLE 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, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)',
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
225
|
+
const info = await env.ACCOUNTS_DB.prepare('PRAGMA table_info(nickserv_accounts)').all<{
|
|
226
|
+
name: string;
|
|
227
|
+
}>();
|
|
228
|
+
const cols = info.results.map((r) => r.name);
|
|
229
|
+
expect(cols).toContain('cert_subjects');
|
|
230
|
+
expect(cols).toContain('identify_failures');
|
|
231
|
+
expect(cols).toContain('pending_identify_notice');
|
|
232
|
+
expect(store).toBeDefined();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('loads rows with malformed JSON columns as empty arrays (defensive)', async () => {
|
|
236
|
+
await resetDb();
|
|
237
|
+
const insert = async (
|
|
238
|
+
nick: string,
|
|
239
|
+
certSubjects: string,
|
|
240
|
+
identifyFailures: string,
|
|
241
|
+
): Promise<void> => {
|
|
242
|
+
await env.ACCOUNTS_DB.prepare(
|
|
243
|
+
'INSERT INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, identify_failures, pending_identify_notice, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
244
|
+
)
|
|
245
|
+
.bind(
|
|
246
|
+
nick,
|
|
247
|
+
nick,
|
|
248
|
+
nick,
|
|
249
|
+
'x@x',
|
|
250
|
+
1,
|
|
251
|
+
'none',
|
|
252
|
+
certSubjects,
|
|
253
|
+
identifyFailures,
|
|
254
|
+
0,
|
|
255
|
+
'scrypt',
|
|
256
|
+
'c2FsdA==',
|
|
257
|
+
'aGFzaA==',
|
|
258
|
+
)
|
|
259
|
+
.run();
|
|
260
|
+
};
|
|
261
|
+
// Every degenerate column shape: malformed JSON, empty string,
|
|
262
|
+
// non-array JSON, and arrays with ill-typed entries.
|
|
263
|
+
await insert('a-malformed', 'not json', 'also not json');
|
|
264
|
+
await insert('b-empty', '', '');
|
|
265
|
+
await insert('c-nonarray', '{"a":1}', '{"a":1}');
|
|
266
|
+
await insert('d-illtyped', '["ok",7]', '[1,"x",2]');
|
|
267
|
+
|
|
268
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
269
|
+
for (const nick of ['a-malformed', 'b-empty', 'c-nonarray']) {
|
|
270
|
+
expect(store?.isRegisteredNick(nick)).toBe(true);
|
|
271
|
+
expect(store?.getNick(nick)?.certSubjects).toEqual([]);
|
|
272
|
+
expect(store?.isNickIdentifyFrozen(nick, { maxFailures: 1, windowMs: 300_000 })).toBe(false);
|
|
273
|
+
}
|
|
274
|
+
// Ill-typed entries are filtered; well-typed ones survive. The window
|
|
275
|
+
// reaches back to the epoch so the literal timestamps count.
|
|
276
|
+
expect(store?.getNick('d-illtyped')?.certSubjects).toEqual(['ok']);
|
|
277
|
+
expect(store?.isNickIdentifyFrozen('d-illtyped', { maxFailures: 2, windowMs: NOW + 1 })).toBe(
|
|
278
|
+
true,
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
166
283
|
describe('D1ServicesStore — NickServ round-trip across reload', () => {
|
|
167
284
|
beforeEach(async () => {
|
|
168
285
|
await resetDb();
|
|
@@ -207,6 +324,31 @@ describe('D1ServicesStore — NickServ round-trip across reload', () => {
|
|
|
207
324
|
expect(reloaded?.verifyNick('alice', 'hunter2').ok).toBe(false);
|
|
208
325
|
});
|
|
209
326
|
|
|
327
|
+
it('persists the identify-failure throttle and round-trips the freeze', async () => {
|
|
328
|
+
const clock = new FakeClock(NOW);
|
|
329
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, clock);
|
|
330
|
+
store?.registerNick('alice', 'hunter2', 'a@x');
|
|
331
|
+
clock.advance(10);
|
|
332
|
+
store?.recordIdentifyFailure('alice');
|
|
333
|
+
clock.advance(10);
|
|
334
|
+
store?.recordIdentifyFailure('alice');
|
|
335
|
+
await store?.flush();
|
|
336
|
+
|
|
337
|
+
// Simulate a fresh connection (fresh store) after a deploy: the reload
|
|
338
|
+
// still sees the recorded failures and the queued owner-notice count.
|
|
339
|
+
const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW + 20));
|
|
340
|
+
expect(reloaded?.isNickIdentifyFrozen('alice', { maxFailures: 2, windowMs: 300_000 })).toBe(
|
|
341
|
+
true,
|
|
342
|
+
);
|
|
343
|
+
expect(reloaded?.clearIdentifyFailures('alice')).toBe(2);
|
|
344
|
+
|
|
345
|
+
// The cleared state round-trips too.
|
|
346
|
+
await reloaded?.flush();
|
|
347
|
+
const after = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW + 20));
|
|
348
|
+
expect(after?.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
|
|
349
|
+
expect(after?.clearIdentifyFailures('alice')).toBe(0);
|
|
350
|
+
});
|
|
351
|
+
|
|
210
352
|
it('drops a NickServ registration after flush + reload', async () => {
|
|
211
353
|
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
212
354
|
store?.registerNick('alice', 'pw', 'a@x');
|
|
@@ -219,6 +361,55 @@ describe('D1ServicesStore — NickServ round-trip across reload', () => {
|
|
|
219
361
|
const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
220
362
|
expect(reloaded?.isRegisteredNick('alice')).toBe(false);
|
|
221
363
|
});
|
|
364
|
+
|
|
365
|
+
it('round-trips a bound cert fingerprint and verifies EXTERNAL after reload', async () => {
|
|
366
|
+
// A cert binding attached before a deploy must still authenticate a
|
|
367
|
+
// SASL EXTERNAL client after the store rehydrates from D1 — the
|
|
368
|
+
// production path for certificate-backed login.
|
|
369
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
370
|
+
store?.registerNick('alice', 'hunter2', 'a@x');
|
|
371
|
+
store?.addCertFingerprint('alice', 'AA112233445566778899AABBCCDDEEFF');
|
|
372
|
+
await store?.flush();
|
|
373
|
+
|
|
374
|
+
const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
375
|
+
// The fingerprint is normalised (lowercase, colon-free) on the round-trip.
|
|
376
|
+
expect(reloaded?.getNick('alice')?.certSubjects).toEqual([
|
|
377
|
+
'fp:aa112233445566778899aabbccddeeff',
|
|
378
|
+
]);
|
|
379
|
+
expect(
|
|
380
|
+
reloaded?.verifyCertFP(
|
|
381
|
+
certIdentityFromSubject('CN=alice', 'aa112233445566778899aabbccddeeff'),
|
|
382
|
+
),
|
|
383
|
+
).toEqual({ ok: true, account: 'alice' });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('round-trips a bound subject DN and verifies EXTERNAL via the canonical DN arm', async () => {
|
|
387
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
388
|
+
store?.registerNick('alice', 'hunter2', 'a@x');
|
|
389
|
+
store?.addCertFP('alice', 'CN=alice,O=Example Inc');
|
|
390
|
+
await store?.flush();
|
|
391
|
+
|
|
392
|
+
const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
393
|
+
// Platform-surfaced DN in a different RDN order still matches (canonical compare).
|
|
394
|
+
expect(reloaded?.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice'))).toEqual({
|
|
395
|
+
ok: true,
|
|
396
|
+
account: 'alice',
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('keeps EXTERNAL verify failing for an unbound cert after reload', async () => {
|
|
401
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
402
|
+
store?.registerNick('alice', 'hunter2', 'a@x');
|
|
403
|
+
store?.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
|
|
404
|
+
await store?.flush();
|
|
405
|
+
|
|
406
|
+
const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
407
|
+
expect(
|
|
408
|
+
reloaded?.verifyCertFP(
|
|
409
|
+
certIdentityFromSubject('CN=alice', 'ffffffffffffffffffffffffffffffff'),
|
|
410
|
+
)?.ok,
|
|
411
|
+
).toBe(false);
|
|
412
|
+
});
|
|
222
413
|
});
|
|
223
414
|
|
|
224
415
|
describe('D1ServicesStore — ChanServ round-trip across reload', () => {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RateLimitDO` — per-IP upgrade rate limiter.
|
|
3
|
+
*
|
|
4
|
+
* TDD outline:
|
|
5
|
+
* "Red on allow-under-budget / reject-over-budget / window-decay;
|
|
6
|
+
* green; then per-IP isolation and the concurrency linearisation."
|
|
7
|
+
*
|
|
8
|
+
* Tests address the single global limiter instance directly over RPC,
|
|
9
|
+
* driving every branch of `check` with explicit timestamps so the
|
|
10
|
+
* sliding-window boundary is deterministic (no wall clock dependence).
|
|
11
|
+
* Mirrors the CounterDO suite; each test gets isolated storage.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { env, reset } from 'cloudflare:test';
|
|
15
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
16
|
+
import type { RateLimitRpc } from '../src/env';
|
|
17
|
+
import { RATE_LIMIT_INSTANCE_NAME } from '../src/rate-limit-do';
|
|
18
|
+
|
|
19
|
+
declare global {
|
|
20
|
+
namespace Cloudflare {
|
|
21
|
+
interface Env {
|
|
22
|
+
RATE_LIMIT_DO: DurableObjectNamespace;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// DO storage persists across tests in the same file; reset between each so
|
|
28
|
+
// rate entries from one test never leak into the next. Every test addresses
|
|
29
|
+
// the same `global` limiter instance, so this is essential.
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await reset();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Fixed epoch base for deterministic window arithmetic. */
|
|
35
|
+
const T0 = 1_700_000_000_000;
|
|
36
|
+
/** Window used by every test (1 minute, the schema default order). */
|
|
37
|
+
const WINDOW = 60_000;
|
|
38
|
+
|
|
39
|
+
/** RPC handle on the single global limiter instance. */
|
|
40
|
+
function limiter(): RateLimitRpc {
|
|
41
|
+
const stub = env.RATE_LIMIT_DO.get(env.RATE_LIMIT_DO.idFromName(RATE_LIMIT_INSTANCE_NAME));
|
|
42
|
+
return stub as unknown as RateLimitRpc;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// check — allow / reject
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
describe('RateLimitDO — check', () => {
|
|
50
|
+
it('allows the first N upgrades from one IP and reports the count', async () => {
|
|
51
|
+
const rl = limiter();
|
|
52
|
+
expect(await rl.check('203.0.113.1', 2, WINDOW, T0)).toEqual({ allowed: true, count: 1 });
|
|
53
|
+
expect(await rl.check('203.0.113.1', 2, WINDOW, T0 + 1000)).toEqual({
|
|
54
|
+
allowed: true,
|
|
55
|
+
count: 2,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects the over-budget upgrade from the same IP', async () => {
|
|
60
|
+
const rl = limiter();
|
|
61
|
+
await rl.check('203.0.113.2', 1, WINDOW, T0);
|
|
62
|
+
expect(await rl.check('203.0.113.2', 1, WINDOW, T0 + 1000)).toEqual({
|
|
63
|
+
allowed: false,
|
|
64
|
+
count: 1,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('a rejected check does not append a timestamp (count stays at the cap)', async () => {
|
|
69
|
+
const rl = limiter();
|
|
70
|
+
await rl.check('203.0.113.3', 1, WINDOW, T0);
|
|
71
|
+
await rl.check('203.0.113.3', 1, WINDOW, T0 + 1000);
|
|
72
|
+
// Still rejected at the original count: the rejection itself must not
|
|
73
|
+
// consume budget (otherwise a blocked IP could never recover).
|
|
74
|
+
expect(await rl.check('203.0.113.3', 1, WINDOW, T0 + 2000)).toEqual({
|
|
75
|
+
allowed: false,
|
|
76
|
+
count: 1,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keys budgets per IP (one saturated IP does not block another)', async () => {
|
|
81
|
+
const rl = limiter();
|
|
82
|
+
await rl.check('198.51.100.1', 1, WINDOW, T0);
|
|
83
|
+
expect(await rl.check('198.51.100.1', 1, WINDOW, T0 + 1000)).toEqual({
|
|
84
|
+
allowed: false,
|
|
85
|
+
count: 1,
|
|
86
|
+
});
|
|
87
|
+
expect(await rl.check('198.51.100.2', 1, WINDOW, T0 + 1000)).toEqual({
|
|
88
|
+
allowed: true,
|
|
89
|
+
count: 1,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// sliding window — decay after the window
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
describe('RateLimitDO — window reset', () => {
|
|
99
|
+
it('an entry aged past the window stops counting (budget recovers)', async () => {
|
|
100
|
+
const rl = limiter();
|
|
101
|
+
await rl.check('203.0.113.4', 1, WINDOW, T0);
|
|
102
|
+
// At T0 + WINDOW + 1 the T0 admission is strictly older than the
|
|
103
|
+
// cutoff (T0 + 1) → decayed out of the window.
|
|
104
|
+
expect(await rl.check('203.0.113.4', 1, WINDOW, T0 + WINDOW + 1)).toEqual({
|
|
105
|
+
allowed: true,
|
|
106
|
+
count: 1,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('an entry exactly windowMs old still counts (boundary is inclusive)', async () => {
|
|
111
|
+
// Mirrors irc-core `recentAdmissions`: entries with t >= cutoff count,
|
|
112
|
+
// so an admission exactly one window old is still inside the window.
|
|
113
|
+
const rl = limiter();
|
|
114
|
+
await rl.check('203.0.113.5', 1, WINDOW, T0);
|
|
115
|
+
expect(await rl.check('203.0.113.5', 1, WINDOW, T0 + WINDOW)).toEqual({
|
|
116
|
+
allowed: false,
|
|
117
|
+
count: 1,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('prunes stale entries so a saturated IP recovers exactly one slot per decayed entry', async () => {
|
|
122
|
+
const rl = limiter();
|
|
123
|
+
await rl.check('203.0.113.6', 2, WINDOW, T0);
|
|
124
|
+
await rl.check('203.0.113.6', 2, WINDOW, T0 + 30_000);
|
|
125
|
+
// Cutoff at T0+45_000 is T0-15_000 → both entries still count.
|
|
126
|
+
expect(await rl.check('203.0.113.6', 2, WINDOW, T0 + 45_000)).toEqual({
|
|
127
|
+
allowed: false,
|
|
128
|
+
count: 2,
|
|
129
|
+
});
|
|
130
|
+
// At T0+WINDOW+1 the cutoff is T0+1: the T0 entry decayed, the
|
|
131
|
+
// T0+30_000 entry remains → one slot free → allowed (and this check
|
|
132
|
+
// itself consumes it).
|
|
133
|
+
expect(await rl.check('203.0.113.6', 2, WINDOW, T0 + WINDOW + 1)).toEqual({
|
|
134
|
+
allowed: true,
|
|
135
|
+
count: 2,
|
|
136
|
+
});
|
|
137
|
+
// Window members are now {T0+30_000, T0+WINDOW+1} → exhausted again.
|
|
138
|
+
expect(await rl.check('203.0.113.6', 2, WINDOW, T0 + WINDOW + 2)).toEqual({
|
|
139
|
+
allowed: false,
|
|
140
|
+
count: 2,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// concurrency — the DO's serialized event loop linearises checks
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
describe('RateLimitDO — concurrency', () => {
|
|
150
|
+
it('exactly one of two concurrent checks wins at budget 1', async () => {
|
|
151
|
+
const rl = limiter();
|
|
152
|
+
const [r1, r2] = await Promise.all([
|
|
153
|
+
rl.check('203.0.113.7', 1, WINDOW, T0),
|
|
154
|
+
rl.check('203.0.113.7', 1, WINDOW, T0),
|
|
155
|
+
]);
|
|
156
|
+
const results = [r1, r2];
|
|
157
|
+
expect(results.filter((r) => r.allowed).length).toBe(1);
|
|
158
|
+
expect(results.filter((r) => !r.allowed).length).toBe(1);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
import { ChannelDO } from '../../src/channel-do';
|
|
22
22
|
import { ChannelRegistryDO } from '../../src/channel-registry-do';
|
|
23
23
|
import { ConnectionDO } from '../../src/connection-do';
|
|
24
|
+
import { CounterDO } from '../../src/counter-do';
|
|
25
|
+
import { RateLimitDO } from '../../src/rate-limit-do';
|
|
24
26
|
import { RegistryDO } from '../../src/registry-do';
|
|
25
27
|
import { RecordingChannelDO } from './stubs/channel-stub';
|
|
26
28
|
import { RecordingRegistryDO } from './stubs/registry-stub';
|
|
@@ -29,6 +31,8 @@ export {
|
|
|
29
31
|
ChannelDO,
|
|
30
32
|
ChannelRegistryDO,
|
|
31
33
|
ConnectionDO,
|
|
34
|
+
CounterDO,
|
|
35
|
+
RateLimitDO,
|
|
32
36
|
RegistryDO,
|
|
33
37
|
RecordingRegistryDO,
|
|
34
38
|
RecordingChannelDO,
|
|
@@ -49,6 +49,23 @@ class_name = "ChannelDO"
|
|
|
49
49
|
name = "CHANNEL_REGISTRY_DO"
|
|
50
50
|
class_name = "ChannelRegistryDO"
|
|
51
51
|
|
|
52
|
+
# Global live-connection admission counter — single DO instance keyed by
|
|
53
|
+
# the fixed name `global`. The worker edge reserves a slot per upgrade
|
|
54
|
+
# (maxClients cap); ConnectionDO releases it on every teardown path and
|
|
55
|
+
# heartbeats it from the PING alarm so the lazy TTL reap only evicts
|
|
56
|
+
# entries from DOs that stopped heartbeating.
|
|
57
|
+
[[durable_objects.bindings]]
|
|
58
|
+
name = "COUNTER_DO"
|
|
59
|
+
class_name = "CounterDO"
|
|
60
|
+
|
|
61
|
+
# Per-IP upgrade rate limiter — single DO instance keyed by the fixed
|
|
62
|
+
# name `global`, maintaining one sliding-window admission-timestamp list
|
|
63
|
+
# per CF-Connecting-IP. The worker edge checks the budget before
|
|
64
|
+
# forwarding an upgrade and rejects over-budget IPs with 429.
|
|
65
|
+
[[durable_objects.bindings]]
|
|
66
|
+
name = "RATE_LIMIT_DO"
|
|
67
|
+
class_name = "RateLimitDO"
|
|
68
|
+
|
|
52
69
|
# Recording stubs (kept under separate names for any test that wants to
|
|
53
70
|
# assert "this exact RPC was emitted" rather than the end-to-end effect).
|
|
54
71
|
[[durable_objects.bindings]]
|
|
@@ -71,4 +88,4 @@ class_name = "ChannelDO"
|
|
|
71
88
|
|
|
72
89
|
[[migrations]]
|
|
73
90
|
tag = "v1"
|
|
74
|
-
new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "RecordingRegistryDO", "RecordingChannelDO"]
|
|
91
|
+
new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "CounterDO", "RateLimitDO", "RecordingRegistryDO", "RecordingChannelDO"]
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type ConnSnapshot,
|
|
26
26
|
type ConnectionState,
|
|
27
27
|
type Nick,
|
|
28
|
+
OperFailureStats,
|
|
28
29
|
type RawLine,
|
|
29
30
|
type ServerConfig,
|
|
30
31
|
applyChannelDelta as applyDelta,
|
|
@@ -84,10 +85,20 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
84
85
|
private readonly admissionStats: AdmissionStats | undefined;
|
|
85
86
|
private readonly admissionConfig: AdmissionConfig | undefined;
|
|
86
87
|
private readonly configLoader: (() => Promise<ServerConfig>) | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* Per-IP failed-`OPER` counter shared across every connection's actor
|
|
90
|
+
* (see `ctx.operFailures`). Bound to the runtime's clock so the OPER
|
|
91
|
+
* reducer's lockout window advances with the same time source the
|
|
92
|
+
* actors observe. Adapters hand `runtime.operFailures` to each
|
|
93
|
+
* `ConnectionActor`'s options; the counter is then per-IP across the
|
|
94
|
+
* whole process, not per-connection.
|
|
95
|
+
*/
|
|
96
|
+
readonly operFailures: OperFailureStats;
|
|
87
97
|
|
|
88
98
|
constructor(opts: InMemoryRuntimeOptions) {
|
|
89
99
|
this.clock = opts.clock;
|
|
90
100
|
this.configLoader = opts.configLoader;
|
|
101
|
+
this.operFailures = new OperFailureStats(opts.clock);
|
|
91
102
|
if (opts.admission !== undefined) {
|
|
92
103
|
this.admissionConfig = opts.admission;
|
|
93
104
|
this.admissionStats = new AdmissionStats(this.clock);
|
|
@@ -270,6 +281,20 @@ export class InMemoryRuntime implements IrcRuntime {
|
|
|
270
281
|
}
|
|
271
282
|
}
|
|
272
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Oper-only notice fanout — the recipient gate for the OPER reducer's
|
|
286
|
+
* per-IP lockout notice (`:<server> NOTICE * :OPER lockout triggered
|
|
287
|
+
* for <host>`). Identical enumeration to {@link broadcastWallops} but
|
|
288
|
+
* filtered on user mode `+o` instead of `+w`.
|
|
289
|
+
*/
|
|
290
|
+
async broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
291
|
+
for (const [connId, tracked] of this.connections) {
|
|
292
|
+
if (connId === except) continue;
|
|
293
|
+
if (!tracked.state.userModes.oper) continue;
|
|
294
|
+
tracked.handlers.send(lines);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
273
298
|
// ------------------------------------------------------------------
|
|
274
299
|
// IrcRuntime — nick registry
|
|
275
300
|
// ------------------------------------------------------------------
|