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.
Files changed (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. 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
- * Two additive migrations run idempotently, each gated on `PRAGMA
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 { ChannelRpc, ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
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 the
664
- * full MODE handshake through the actor). Returns the live WebSocket and
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: boolean },
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
- ws.send(`USER ${name} 0 * :${name}\r\n`);
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 +w mode directly on the live cached state. The DO is the
684
- // authority for this state; broadcastWallops reads it back via
685
- // getConnState() so what we mutate here is exactly what the fan-out
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) state.userModes.wallops = true;
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() + 1000;
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() + 1000;
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
  // ---------------------------------------------------------------------------