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,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
|
-
"CREATE TABLE IF NOT EXISTS nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, cert_subjects TEXT NOT NULL DEFAULT '[]', algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)";
|
|
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, cert_subjects, 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),
|
|
@@ -146,6 +146,8 @@ export class D1ServicesStore extends PersistentServicesStore {
|
|
|
146
146
|
r.createdAt,
|
|
147
147
|
r.enforce,
|
|
148
148
|
JSON.stringify(r.certSubjects ?? []),
|
|
149
|
+
JSON.stringify(r.identifyFailures ?? []),
|
|
150
|
+
r.pendingIdentifyFailureNotice ?? 0,
|
|
149
151
|
r.credential.algorithm,
|
|
150
152
|
r.credential.salt,
|
|
151
153
|
r.credential.hash,
|
|
@@ -363,12 +365,14 @@ async function loadNicks(db: D1QueryClient): Promise<ServicesNickRow[]> {
|
|
|
363
365
|
created_at: unknown;
|
|
364
366
|
enforce: unknown;
|
|
365
367
|
cert_subjects: unknown;
|
|
368
|
+
identify_failures: unknown;
|
|
369
|
+
pending_identify_notice: unknown;
|
|
366
370
|
algorithm: unknown;
|
|
367
371
|
salt: unknown;
|
|
368
372
|
hash: unknown;
|
|
369
373
|
}>(
|
|
370
374
|
db,
|
|
371
|
-
'SELECT nick, account, email, created_at, enforce, cert_subjects, 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',
|
|
372
376
|
);
|
|
373
377
|
const out: ServicesNickRow[] = [];
|
|
374
378
|
for (const r of rows) {
|
|
@@ -391,6 +395,9 @@ async function loadNicks(db: D1QueryClient): Promise<ServicesNickRow[]> {
|
|
|
391
395
|
createdAt: r.created_at,
|
|
392
396
|
enforce: r.enforce as NickEnforcePolicy,
|
|
393
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,
|
|
394
401
|
credential: {
|
|
395
402
|
account: r.account,
|
|
396
403
|
algorithm: 'scrypt',
|
|
@@ -419,6 +426,24 @@ function parseCertSubjects(raw: unknown): string[] {
|
|
|
419
426
|
}
|
|
420
427
|
}
|
|
421
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
|
+
|
|
422
447
|
async function loadChannels(db: D1QueryClient): Promise<RegisteredChannel[]> {
|
|
423
448
|
const rows = await safeSelect<{
|
|
424
449
|
channel: unknown;
|
|
@@ -677,7 +702,7 @@ async function ensureServicesTables(db: D1QueryClient): Promise<void> {
|
|
|
677
702
|
*
|
|
678
703
|
* `CREATE TABLE IF NOT EXISTS` does NOT add columns to an existing table,
|
|
679
704
|
* so a D1 first provisioned by an older deploy keeps the original schema.
|
|
680
|
-
*
|
|
705
|
+
* Three additive migrations run idempotently, each gated on `PRAGMA
|
|
681
706
|
* table_info`:
|
|
682
707
|
*
|
|
683
708
|
* 1. **algorithm/salt/hash unification** (commit cfa5a3c). If the table
|
|
@@ -689,6 +714,11 @@ async function ensureServicesTables(db: D1QueryClient): Promise<void> {
|
|
|
689
714
|
* `cert_subjects`, the column is added with `NOT NULL DEFAULT '[]'`
|
|
690
715
|
* so existing rows load with an empty cert-subject list. No data
|
|
691
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.
|
|
692
722
|
*/
|
|
693
723
|
async function migrateServicesSchema(db: D1QueryClient): Promise<void> {
|
|
694
724
|
const nickservInfo = await db
|
|
@@ -699,7 +729,7 @@ async function migrateServicesSchema(db: D1QueryClient): Promise<void> {
|
|
|
699
729
|
// Old schema detected (has `password`, lacks `algorithm`). The table is
|
|
700
730
|
// guaranteed to exist (ensureServicesTables just ran CREATE IF NOT
|
|
701
731
|
// EXISTS); recreate it with the current schema (which includes
|
|
702
|
-
// cert_subjects).
|
|
732
|
+
// cert_subjects + the identify-throttle columns).
|
|
703
733
|
await db.batch([
|
|
704
734
|
db.prepare('DROP TABLE nickserv_accounts'),
|
|
705
735
|
db.prepare(CREATE_NICKSERV_ACCOUNTS_SQL),
|
|
@@ -714,6 +744,18 @@ async function migrateServicesSchema(db: D1QueryClient): Promise<void> {
|
|
|
714
744
|
),
|
|
715
745
|
]);
|
|
716
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
|
+
}
|
|
717
759
|
}
|
|
718
760
|
|
|
719
761
|
export async function loadD1ServicesStore(
|
|
@@ -46,6 +46,25 @@ export interface Env {
|
|
|
46
46
|
* unregister / list.
|
|
47
47
|
*/
|
|
48
48
|
CHANNEL_REGISTRY_DO: DurableObjectNamespace;
|
|
49
|
+
/**
|
|
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;
|
|
49
68
|
/**
|
|
50
69
|
* D1 database backing persistent services (NickServ / ChanServ / HostServ /
|
|
51
70
|
* MemoServ / OperServ + the unified SASL credential store). The
|
|
@@ -98,6 +117,20 @@ export interface Env {
|
|
|
98
117
|
* `001 RPL_WELCOME`. Undefined / empty disables the gate.
|
|
99
118
|
*/
|
|
100
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;
|
|
101
134
|
}
|
|
102
135
|
|
|
103
136
|
/**
|
|
@@ -143,3 +176,58 @@ export interface ChannelRegistryRpc {
|
|
|
143
176
|
unregister(name: string): Promise<void>;
|
|
144
177
|
list(): Promise<string[]>;
|
|
145
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,7 +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 {
|
|
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';
|
|
29
45
|
export {
|
|
30
46
|
CREATE_SERVICES_TABLES_SQL,
|
|
31
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
|
+
}
|
|
@@ -46,6 +46,8 @@ declare global {
|
|
|
46
46
|
CHANNEL_DO_REAL: DurableObjectNamespace;
|
|
47
47
|
CHANNEL_DO_STUB: DurableObjectNamespace;
|
|
48
48
|
CHANNEL_REGISTRY_DO: DurableObjectNamespace;
|
|
49
|
+
COUNTER_DO: DurableObjectNamespace;
|
|
50
|
+
RATE_LIMIT_DO: DurableObjectNamespace;
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
}
|
|
@@ -65,6 +67,8 @@ function envWithRealDOs(): Env {
|
|
|
65
67
|
REGISTRY_DO: env.REGISTRY_DO_REAL,
|
|
66
68
|
CHANNEL_DO: env.CHANNEL_DO_REAL,
|
|
67
69
|
CHANNEL_REGISTRY_DO: env.CHANNEL_REGISTRY_DO,
|
|
70
|
+
COUNTER_DO: env.COUNTER_DO,
|
|
71
|
+
RATE_LIMIT_DO: env.RATE_LIMIT_DO,
|
|
68
72
|
SERVER_NAME: 'irc.example.com',
|
|
69
73
|
NETWORK_NAME: 'ExampleNet',
|
|
70
74
|
MOTD_LINES: '',
|
|
@@ -82,6 +86,8 @@ function envWithChannelStub(): Env {
|
|
|
82
86
|
REGISTRY_DO: env.REGISTRY_DO_REAL,
|
|
83
87
|
CHANNEL_DO: env.CHANNEL_DO_STUB,
|
|
84
88
|
CHANNEL_REGISTRY_DO: env.CHANNEL_REGISTRY_DO,
|
|
89
|
+
COUNTER_DO: env.COUNTER_DO,
|
|
90
|
+
RATE_LIMIT_DO: env.RATE_LIMIT_DO,
|
|
85
91
|
SERVER_NAME: 'irc.example.com',
|
|
86
92
|
NETWORK_NAME: 'ExampleNet',
|
|
87
93
|
MOTD_LINES: '',
|
|
@@ -660,41 +666,54 @@ describe('CfRuntime — reloadConfig (REHASH)', () => {
|
|
|
660
666
|
|
|
661
667
|
/**
|
|
662
668
|
* Opens a ConnectionDO, registers it with the given nick, and optionally
|
|
663
|
-
* flips user mode `w` on via a direct state mutation (avoids driving
|
|
664
|
-
* full MODE handshake through the actor). Returns the live
|
|
665
|
-
* the connection's canonical hex id.
|
|
669
|
+
* flips user mode `w`/`o` on via a direct state mutation (avoids driving
|
|
670
|
+
* the full MODE/OPER handshake through the actor). Returns the live
|
|
671
|
+
* WebSocket and the connection's canonical hex id.
|
|
666
672
|
*/
|
|
667
673
|
async function openWallopsCapableConn(
|
|
668
674
|
name: string,
|
|
669
|
-
opts: { wallops
|
|
675
|
+
opts: { wallops?: boolean; oper?: boolean },
|
|
670
676
|
): Promise<{ ws: WebSocket; hexId: string; received: string[] }> {
|
|
671
677
|
const ws = await openConnection(name);
|
|
672
678
|
await new Promise<void>((r) => setTimeout(r, 20));
|
|
673
679
|
ws.send(`NICK ${name}\r\n`);
|
|
674
|
-
|
|
680
|
+
// Fixed short username: the server caps USER at 16 bytes, and several
|
|
681
|
+
// connection names in these suites are longer — interpolating the name
|
|
682
|
+
// here made the server reject USER and close the link before the
|
|
683
|
+
// wallops/oper fan-out ever ran.
|
|
684
|
+
ws.send('USER chat 0 * :chat\r\n');
|
|
675
685
|
// Drain the registration numerics so they don't interfere with the
|
|
676
|
-
// assertions on wallops delivery below.
|
|
686
|
+
// assertions on wallops/oper-notice delivery below.
|
|
677
687
|
await new Promise<void>((r) => setTimeout(r, 100));
|
|
678
688
|
|
|
679
689
|
const hexId = await getConnectionHexId(name);
|
|
680
690
|
const received = drainSocket(ws);
|
|
681
691
|
|
|
682
|
-
if (opts.wallops) {
|
|
683
|
-
// Flip the
|
|
684
|
-
// authority for this state; broadcastWallops
|
|
685
|
-
// getConnState() so what we mutate here is exactly what
|
|
686
|
-
// will see.
|
|
692
|
+
if (opts.wallops === true || opts.oper === true) {
|
|
693
|
+
// Flip the mode(s) directly on the live cached state. The DO is the
|
|
694
|
+
// authority for this state; broadcastWallops/broadcastOperNotice read
|
|
695
|
+
// it back via getConnState() so what we mutate here is exactly what
|
|
696
|
+
// the fan-out will see.
|
|
687
697
|
const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromString(hexId));
|
|
688
698
|
await runInDurableObject(stub, async (instance: unknown) => {
|
|
689
699
|
const doi = instance as {
|
|
690
700
|
__peekState?: () => Promise<
|
|
691
701
|
import('@serverless-ircd/irc-core').ConnectionState | undefined
|
|
692
702
|
>;
|
|
693
|
-
__pokeState?: (s: import('@serverless-ircd/irc-core').ConnectionState) => void;
|
|
694
703
|
};
|
|
695
704
|
const state = await doi.__peekState?.();
|
|
696
|
-
if (state !== undefined)
|
|
705
|
+
if (state !== undefined) {
|
|
706
|
+
if (opts.wallops === true) state.userModes.wallops = true;
|
|
707
|
+
if (opts.oper === true) state.userModes.oper = true;
|
|
708
|
+
}
|
|
697
709
|
});
|
|
710
|
+
// Persist the mutated mode: the DO persists state only after a frame
|
|
711
|
+
// dispatches, and a DO eviction between here and the fan-out would
|
|
712
|
+
// otherwise reload the unmutated persisted copy (the cause of a
|
|
713
|
+
// pre-existing intermittent failure in the excepted-originator
|
|
714
|
+
// tests). One client PING forces the persist.
|
|
715
|
+
ws.send('PING :pin\r\n');
|
|
716
|
+
await new Promise<void>((r) => setTimeout(r, 150));
|
|
698
717
|
}
|
|
699
718
|
|
|
700
719
|
// Reserve the nick in the REAL registry under its hex id so
|
|
@@ -752,7 +771,7 @@ describe('CfRuntime — broadcastWallops', () => {
|
|
|
752
771
|
await rt.broadcastWallops([{ text: ':oper!u@h WALLOPS :except me' }], sender.hexId);
|
|
753
772
|
|
|
754
773
|
await new Promise<void>((resolve) => {
|
|
755
|
-
const deadline = Date.now() +
|
|
774
|
+
const deadline = Date.now() + 4000;
|
|
756
775
|
const tick = (): void => {
|
|
757
776
|
if (other.received.some((l) => l.includes('except me'))) {
|
|
758
777
|
resolve();
|
|
@@ -802,7 +821,7 @@ describe('CfRuntime — broadcastWallops', () => {
|
|
|
802
821
|
).resolves.toBeUndefined();
|
|
803
822
|
|
|
804
823
|
await new Promise<void>((resolve) => {
|
|
805
|
-
const deadline = Date.now() +
|
|
824
|
+
const deadline = Date.now() + 4000;
|
|
806
825
|
const tick = (): void => {
|
|
807
826
|
if (other.received.some((l) => l.includes('ghost friendly'))) {
|
|
808
827
|
resolve();
|
|
@@ -822,6 +841,76 @@ describe('CfRuntime — broadcastWallops', () => {
|
|
|
822
841
|
});
|
|
823
842
|
});
|
|
824
843
|
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
// Tests — broadcastOperNotice (global +o fan-out for oper-only notices)
|
|
846
|
+
// ---------------------------------------------------------------------------
|
|
847
|
+
|
|
848
|
+
describe('CfRuntime — broadcastOperNotice', () => {
|
|
849
|
+
it('delivers to every +o connection and skips non-opers', async () => {
|
|
850
|
+
const realEnv = envWithRealDOs();
|
|
851
|
+
|
|
852
|
+
const oper = await openWallopsCapableConn('opernote-oper', { oper: true });
|
|
853
|
+
// +w without +o must NOT receive an oper notice.
|
|
854
|
+
const wallopsOnly = await openWallopsCapableConn('opernote-wallops', { wallops: true });
|
|
855
|
+
|
|
856
|
+
const rt = new CfRuntime(realEnv, 'conn-opernote-sender', recordingHandlers());
|
|
857
|
+
await rt.broadcastOperNotice([{ text: ':server NOTICE * :*** oper lockout tripped' }]);
|
|
858
|
+
|
|
859
|
+
await new Promise<void>((resolve) => {
|
|
860
|
+
const deadline = Date.now() + 1500;
|
|
861
|
+
const tick = (): void => {
|
|
862
|
+
if (oper.received.some((l) => l.includes('oper lockout tripped'))) {
|
|
863
|
+
resolve();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (Date.now() > deadline) {
|
|
867
|
+
resolve();
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
setTimeout(tick, 20);
|
|
871
|
+
};
|
|
872
|
+
tick();
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
expect(oper.received.some((l) => l.includes('oper lockout tripped'))).toBe(true);
|
|
876
|
+
expect(wallopsOnly.received.some((l) => l.includes('oper lockout tripped'))).toBe(false);
|
|
877
|
+
|
|
878
|
+
oper.ws.close();
|
|
879
|
+
wallopsOnly.ws.close();
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it('skips the excepted originator', async () => {
|
|
883
|
+
const realEnv = envWithRealDOs();
|
|
884
|
+
const sender = await openWallopsCapableConn('opernote-except-sender', { oper: true });
|
|
885
|
+
const other = await openWallopsCapableConn('opernote-except-other', { oper: true });
|
|
886
|
+
|
|
887
|
+
const rt = new CfRuntime(realEnv, sender.hexId, recordingHandlers());
|
|
888
|
+
await rt.broadcastOperNotice([{ text: ':server NOTICE * :*** except me' }], sender.hexId);
|
|
889
|
+
|
|
890
|
+
await new Promise<void>((resolve) => {
|
|
891
|
+
const deadline = Date.now() + 4000;
|
|
892
|
+
const tick = (): void => {
|
|
893
|
+
if (other.received.some((l) => l.includes('except me'))) {
|
|
894
|
+
resolve();
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (Date.now() > deadline) {
|
|
898
|
+
resolve();
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
setTimeout(tick, 20);
|
|
902
|
+
};
|
|
903
|
+
tick();
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
expect(other.received.some((l) => l.includes('except me'))).toBe(true);
|
|
907
|
+
expect(sender.received.some((l) => l.includes('except me'))).toBe(false);
|
|
908
|
+
|
|
909
|
+
sender.ws.close();
|
|
910
|
+
other.ws.close();
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
|
|
825
914
|
// ---------------------------------------------------------------------------
|
|
826
915
|
// Tests — changeNick shard routing + failure paths
|
|
827
916
|
// ---------------------------------------------------------------------------
|