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,47 @@
1
+ /**
2
+ * Guards on the production `apps/cf-worker/wrangler.toml`.
3
+ *
4
+ * The `[observability]` block controls how much of the worker's console
5
+ * output (every structured log record the ConnectionActor emits) is
6
+ * retained by Workers Analytics / `wrangler tail` / Logpush. Full-rate
7
+ * sampling is unnecessary for triage — every record is scrubbed to
8
+ * non-sensitive fields — and multiplies the volume of client-adjacent
9
+ * data retained, so the default is capped. This test pins the cap so a
10
+ * casual edit cannot silently restore 100% sampling.
11
+ */
12
+
13
+ import { describe, expect, it } from 'vitest';
14
+ import wranglerToml from '../wrangler.toml?raw';
15
+
16
+ /**
17
+ * Extracts the raw right-hand side of `key = value` from a top-level
18
+ * `[section]` table of the production wrangler config. Walks lines and
19
+ * switches tables on whole-line headers only (so a comment that merely
20
+ * mentions `[observability]` cannot confuse the parse). Returns
21
+ * `undefined` when either the table or the binding is absent so
22
+ * missing-value regressions fail loudly.
23
+ */
24
+ function tableBinding(section: string, key: string): string | undefined {
25
+ let inSection = false;
26
+ for (const line of wranglerToml.split('\n')) {
27
+ const header = line.match(/^\s*\[([^\]]+)\]\s*$/);
28
+ if (header !== null) {
29
+ inSection = header[1] === section;
30
+ continue;
31
+ }
32
+ if (!inSection) continue;
33
+ const binding = line.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*(?:#.*)?$`));
34
+ if (binding !== null) return binding[1];
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ describe('wrangler.toml observability', () => {
40
+ it('defaults head sampling to a reduced rate instead of 100%', () => {
41
+ expect(tableBinding('observability', 'head_sampling_rate')).toBe('0.1');
42
+ });
43
+
44
+ it('keeps observability enabled (records must still reach the pipeline)', () => {
45
+ expect(tableBinding('observability', 'enabled')).toBe('true');
46
+ });
47
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Worker-edge admission cap (`maxClients`) integration tests.
3
+ *
4
+ * TDD outline:
5
+ * "Red: maxClients = 1; a second upgrade is admitted today; assert it
6
+ * receives 429 (with the `ERROR :Closing link: server full` body).
7
+ * Green: CounterDO admission check at the edge."
8
+ *
9
+ * Drives the production worker entry (`src/worker.ts`) end-to-end through
10
+ * `worker.fetch` exactly like `ws-origin.test.ts`: the admission check,
11
+ * the 429 + ERROR-line rejection, and the slot lifecycle (release on
12
+ * close → re-admit) are asserted at the HTTP boundary, with the real
13
+ * CounterDO + ConnectionDO behind the bindings.
14
+ *
15
+ * `MAX_CLIENTS` is not set in `wrangler.test.toml` (schema default
16
+ * 10 000); cap-pinned tests override it per call via `{ ...env,
17
+ * MAX_CLIENTS: '1' }`, the same per-test env-override pattern the
18
+ * origin suite uses for `WEB_ORIGINS`.
19
+ */
20
+
21
+ import { env, reset } from 'cloudflare:test';
22
+ import { afterEach, describe, expect, it } from 'vitest';
23
+ import worker from '../src/worker';
24
+
25
+ declare global {
26
+ namespace Cloudflare {
27
+ interface Env {
28
+ WEB_ORIGINS: string;
29
+ /** Optional admission cap (parsed by the shared config loader). */
30
+ MAX_CLIENTS?: string;
31
+ }
32
+ }
33
+ }
34
+
35
+ const UPGRADE_URL = 'https://irc.example.com/';
36
+
37
+ function upgradeRequest(): Request {
38
+ // No Origin header — non-browser client, so the CSWSH policy passes
39
+ // regardless of the WEB_ORIGINS value in the test vars.
40
+ return new Request(UPGRADE_URL, { headers: { Upgrade: 'websocket' } });
41
+ }
42
+
43
+ /** Env with the global admission cap pinned to a single client. */
44
+ const capOne = { ...env, MAX_CLIENTS: '1' };
45
+
46
+ // DO storage persists across tests in the same file; reset between each
47
+ // so admission entries (and any half-closed connections) from one test
48
+ // never leak into the next.
49
+ afterEach(async () => {
50
+ await reset();
51
+ });
52
+
53
+ /**
54
+ * Polls upgrade attempts until one is admitted (101). The ConnectionDO
55
+ * teardown after a client-side close is event-driven and can lag in the
56
+ * test runtime, so the re-admit assertion polls instead of assuming
57
+ * synchrony. Every rejected attempt is a pure 429 (no socket, no state).
58
+ */
59
+ async function waitForAdmission(testEnv: typeof env, timeoutMs = 8000): Promise<Response> {
60
+ const deadline = Date.now() + timeoutMs;
61
+ for (;;) {
62
+ const response = await worker.fetch(upgradeRequest(), testEnv);
63
+ if (response.status === 101) return response;
64
+ await new Promise((r) => setTimeout(r, 50));
65
+ if (Date.now() > deadline) {
66
+ throw new Error(`upgrade was not admitted before timeout (${response.status})`);
67
+ }
68
+ }
69
+ }
70
+
71
+ describe('WS admission cap (maxClients) — worker edge', () => {
72
+ it('admits an upgrade while under the cap (101)', async () => {
73
+ const response = await worker.fetch(upgradeRequest(), env);
74
+ expect(response.status).toBe(101);
75
+ response.webSocket?.accept();
76
+ response.webSocket?.close();
77
+ });
78
+
79
+ it('rejects the next upgrade with 429 once maxClients is reached', async () => {
80
+ const first = await worker.fetch(upgradeRequest(), capOne);
81
+ expect(first.status).toBe(101);
82
+ first.webSocket?.accept();
83
+
84
+ const second = await worker.fetch(upgradeRequest(), capOne);
85
+ expect(second.status).toBe(429);
86
+ expect(second.webSocket).toBeNull();
87
+ const body = await second.text();
88
+ // The IRC-style ERROR line travels in the 429 body: pre-handshake
89
+ // there is no WebSocket to carry a frame, so the body is the only
90
+ // channel a non-browser client can read.
91
+ expect(body).toBe('ERROR :Closing link: server full');
92
+
93
+ first.webSocket?.close();
94
+ });
95
+
96
+ it('frees the slot on close so a later upgrade is admitted again', async () => {
97
+ const first = await worker.fetch(upgradeRequest(), capOne);
98
+ expect(first.status).toBe(101);
99
+ first.webSocket?.accept();
100
+
101
+ const second = await worker.fetch(upgradeRequest(), capOne);
102
+ expect(second.status).toBe(429);
103
+
104
+ // Close the admitted client; its ConnectionDO teardown releases the
105
+ // counter slot, so a subsequent upgrade must be admitted.
106
+ first.webSocket?.close();
107
+ const third = await waitForAdmission(capOne);
108
+ expect(third.status).toBe(101);
109
+ third.webSocket?.accept();
110
+ third.webSocket?.close();
111
+ });
112
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Worker-edge per-IP upgrade rate limit (`perIpConnectionRate`) tests.
3
+ *
4
+ * TDD outline:
5
+ * "Red: the N+1th upgrade from one `CF-Connecting-IP` within the
6
+ * window is admitted today; assert it receives 429. Green:
7
+ * RateLimitDO check at the edge, keyed by CF-Connecting-IP."
8
+ *
9
+ * Drives the production worker entry (`src/worker.ts`) end-to-end
10
+ * through `worker.fetch` exactly like `ws-admission.test.ts`. The rate
11
+ * knobs are overridden per call via `{ ...env, PER_IP_... }` env vars,
12
+ * the same per-test override pattern the admission suite uses for
13
+ * `MAX_CLIENTS`.
14
+ */
15
+
16
+ import { env, reset } from 'cloudflare:test';
17
+ import { afterEach, describe, expect, it } from 'vitest';
18
+ import worker from '../src/worker';
19
+
20
+ declare global {
21
+ namespace Cloudflare {
22
+ interface Env {
23
+ WEB_ORIGINS: string;
24
+ MAX_CLIENTS?: string;
25
+ /** Optional per-IP upgrade rate budget (parsed by the config loader). */
26
+ PER_IP_CONNECTION_RATE_MAX?: string;
27
+ /** Optional per-IP rate window in milliseconds. */
28
+ PER_IP_CONNECTION_RATE_WINDOW_MS?: string;
29
+ }
30
+ }
31
+ }
32
+
33
+ const UPGRADE_URL = 'https://irc.example.com/';
34
+
35
+ function upgradeRequest(ip?: string): Request {
36
+ // No Origin header — non-browser client, so the CSWSH policy passes
37
+ // regardless of the WEB_ORIGINS value in the test vars.
38
+ const headers: Record<string, string> = { Upgrade: 'websocket' };
39
+ if (ip !== undefined) {
40
+ headers['CF-Connecting-IP'] = ip;
41
+ }
42
+ return new Request(UPGRADE_URL, { headers });
43
+ }
44
+
45
+ /** Env with the per-IP upgrade budget pinned to 2 per 60 s. */
46
+ const rateTwo = { ...env, PER_IP_CONNECTION_RATE_MAX: '2' };
47
+
48
+ // DO storage persists across tests in the same file; reset between each so
49
+ // rate entries (and any half-closed connections) never leak across tests.
50
+ afterEach(async () => {
51
+ await reset();
52
+ });
53
+
54
+ describe('WS per-IP upgrade rate limit — worker edge', () => {
55
+ it('admits upgrades while the IP is within its budget (101)', async () => {
56
+ const first = await worker.fetch(upgradeRequest('203.0.113.10'), rateTwo);
57
+ expect(first.status).toBe(101);
58
+ first.webSocket?.accept();
59
+ first.webSocket?.close();
60
+
61
+ const second = await worker.fetch(upgradeRequest('203.0.113.10'), rateTwo);
62
+ expect(second.status).toBe(101);
63
+ second.webSocket?.accept();
64
+ second.webSocket?.close();
65
+ });
66
+
67
+ it('rejects the over-budget upgrade from the same IP with 429', async () => {
68
+ for (let i = 0; i < 2; i++) {
69
+ const r = await worker.fetch(upgradeRequest('203.0.113.11'), rateTwo);
70
+ expect(r.status).toBe(101);
71
+ r.webSocket?.accept();
72
+ }
73
+ const third = await worker.fetch(upgradeRequest('203.0.113.11'), rateTwo);
74
+ expect(third.status).toBe(429);
75
+ expect(third.webSocket).toBeNull();
76
+ const body = await third.text();
77
+ // The IRC-style ERROR line travels in the 429 body (same convention as
78
+ // the server-full rejection) so non-browser clients see a reason.
79
+ expect(body).toBe('ERROR :Closing link: connection rate exceeded');
80
+ });
81
+
82
+ it('keys the budget on CF-Connecting-IP (a saturated IP does not block another)', async () => {
83
+ const a1 = await worker.fetch(upgradeRequest('203.0.113.12'), rateTwo);
84
+ expect(a1.status).toBe(101);
85
+ a1.webSocket?.accept();
86
+ const a2 = await worker.fetch(upgradeRequest('203.0.113.12'), rateTwo);
87
+ expect(a2.status).toBe(101);
88
+ a2.webSocket?.accept();
89
+ expect(await worker.fetch(upgradeRequest('203.0.113.12'), rateTwo)).toHaveProperty(
90
+ 'status',
91
+ 429,
92
+ );
93
+
94
+ const other = await worker.fetch(upgradeRequest('198.51.100.7'), rateTwo);
95
+ expect(other.status).toBe(101);
96
+ other.webSocket?.accept();
97
+ other.webSocket?.close();
98
+ });
99
+
100
+ it('recovers the budget after the sliding window decays', async () => {
101
+ const shortWindow = {
102
+ ...env,
103
+ PER_IP_CONNECTION_RATE_MAX: '1',
104
+ PER_IP_CONNECTION_RATE_WINDOW_MS: '500',
105
+ };
106
+ const first = await worker.fetch(upgradeRequest('203.0.113.13'), shortWindow);
107
+ expect(first.status).toBe(101);
108
+ first.webSocket?.accept();
109
+ expect(await worker.fetch(upgradeRequest('203.0.113.13'), shortWindow)).toHaveProperty(
110
+ 'status',
111
+ 429,
112
+ );
113
+ // Wait out the 500 ms window; the single admission decays and the
114
+ // budget recovers.
115
+ await new Promise((r) => setTimeout(r, 700));
116
+ const after = await worker.fetch(upgradeRequest('203.0.113.13'), shortWindow);
117
+ expect(after.status).toBe(101);
118
+ after.webSocket?.accept();
119
+ after.webSocket?.close();
120
+ });
121
+
122
+ it('skips the rate check when CF-Connecting-IP is absent (non-edge request)', async () => {
123
+ // Only the Cloudflare edge sets CF-Connecting-IP; a request lacking it
124
+ // (e.g. a direct service-binding call) bypasses the per-IP budget —
125
+ // there is no key to budget on. Production traffic always enters via
126
+ // the edge, which stamps the header.
127
+ for (let i = 0; i < 3; i++) {
128
+ const r = await worker.fetch(upgradeRequest(), rateTwo);
129
+ expect(r.status).toBe(101);
130
+ r.webSocket?.accept();
131
+ }
132
+ });
133
+ });
@@ -55,6 +55,20 @@ class_name = "ChannelDO"
55
55
  name = "CHANNEL_REGISTRY_DO"
56
56
  class_name = "ChannelRegistryDO"
57
57
 
58
+ # Global live-connection admission counter (maxClients cap). Mirrors the
59
+ # production binding; ws-admission.test.ts drives the worker edge through
60
+ # admit / reject / re-admit after close.
61
+ [[durable_objects.bindings]]
62
+ name = "COUNTER_DO"
63
+ class_name = "CounterDO"
64
+
65
+ # Per-IP upgrade rate limiter (perIpConnectionRate). Mirrors the
66
+ # production binding; ws-rate-limit.test.ts drives the worker edge
67
+ # through allow / over-budget reject / window decay.
68
+ [[durable_objects.bindings]]
69
+ name = "RATE_LIMIT_DO"
70
+ class_name = "RateLimitDO"
71
+
58
72
  # D1 backing for persistent SASL accounts
59
73
  [[d1_databases]]
60
74
  binding = "ACCOUNTS_DB"
@@ -63,4 +77,4 @@ database_id = "test-d1-worker-accounts"
63
77
 
64
78
  [[migrations]]
65
79
  tag = "v1"
66
- new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
80
+ new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "CounterDO", "RateLimitDO"]
@@ -11,9 +11,45 @@
11
11
  ## upgrades to per-connection ConnectionDO instances. See
12
12
  ## `src/worker.ts` and PLAN §6.1.
13
13
  ##
14
- ## Secrets: NEVER put secrets (API tokens, server passwords, account
15
- ## credentials) in this file. Use `wrangler secret put <NAME>`;
16
- ## they live in the Workers KV secrets store, not the repo.
14
+ ## Secrets: NEVER put secrets in this file's [vars] table. Vars marked
15
+ ## [secret] below live in the Workers secrets store, not the repo — set
16
+ ## them with `wrangler secret put <NAME>`.
17
+ ##
18
+ ## Every env var the Worker consumes is enumerated in the block below,
19
+ ## classified `[vars]` (plaintext knob) or `[secret]` (credential
20
+ ## material). The block is drift-guarded by `tools/ci-hardening`
21
+ ## (tests/env-var-drift.test.ts): adding a var to `CfConfigEnv`
22
+ ## (`packages/cf-adapter/src/config-loader.ts`) without a matching
23
+ ## entry here fails CI.
24
+ ## BEGIN consumed-env-vars
25
+ ## [vars] SERVER_NAME — REQUIRED. Public hostname clients see in numerics (001/005). The deploy hostname guard refuses placeholders.
26
+ ## [vars] NETWORK_NAME — Network name surfaced in 002 / ISUPPORT (default "ServerlessIRCd").
27
+ ## [vars] SERVER_VERSION — Version string for 002/004/351/371 (schema default when unset).
28
+ ## [vars] CREATED_AT — Epoch-ms number or free text for 003 RPL_CREATED.
29
+ ## [vars] MOTD_LINES — Newline-delimited MOTD text.
30
+ ## [vars] MAX_CLIENTS — Global live-connection cap enforced by COUNTER_DO admission (default 10000).
31
+ ## [vars] CHANNEL_PREFIXES — Allowed channel-type prefixes.
32
+ ## [vars] MAX_CHANNELS_PER_USER — Per-user joined-channel cap.
33
+ ## [vars] MAX_TARGETS_PER_COMMAND — Cap on comma-split targets for PRIVMSG/NOTICE/PART.
34
+ ## [vars] NICK_LEN — Maximum nick byte length.
35
+ ## [vars] CHANNEL_LEN — Maximum channel-name byte length.
36
+ ## [vars] TOPIC_LEN — Maximum topic byte length.
37
+ ## [vars] MAX_LIST_ENTRIES — Cap on LIST reply entries.
38
+ ## [vars] QUIT_MESSAGE — Default QUIT part-message.
39
+ ## [vars] MAX_FRAMES_PER_WINDOW — Per-connection inbound frame ceiling enforced before the actor write.
40
+ ## [vars] FRAME_WINDOW_SECONDS — Sliding-window length (seconds) for the frame limit.
41
+ ## [vars] MAX_CONNECTIONS_PER_IP — Per-IP simultaneous-connection cap (admission gates).
42
+ ## [vars] PER_IP_CONNECTION_RATE_MAX — Per-IP new-connection budget per window (RATE_LIMIT_DO edge check).
43
+ ## [vars] PER_IP_CONNECTION_RATE_WINDOW_MS — Per-IP rate window length in ms.
44
+ ## [vars] EXTERNAL_ENABLED — Operator opt-in ('true'/'1') for certificate-backed SASL EXTERNAL; additionally requires a bound mTLS identity source and a TLS connection (default off).
45
+ ## [vars] OPER_USER — Oper username (pair with a credential secret below).
46
+ ## [secret] OPER_PASSWORD — Legacy plaintext oper password (prefer the hashed OPER_SALT + OPER_HASH form; rotate with tools/hash-oper-cred.ts).
47
+ ## [secret] OPER_SALT — Base64 scrypt salt of the hashed oper credential (generate with tools/hash-oper-cred.ts).
48
+ ## [secret] OPER_HASH — Base64 scrypt hash of the hashed oper credential (generate with tools/hash-oper-cred.ts).
49
+ ## [secret] SERVER_PASSWORD — Server-wide PASS gate (`PASS <value>` required before 001; SASL-identified connections are exempt).
50
+ ## [secret] SASL_ACCOUNTS — Newline-delimited `user:password` SASL PLAIN seed list (see tools/seed-cf-accounts.ts; supersedes the D1 store at boot when set).
51
+ ## [vars] WEB_ORIGINS — OPTIONAL comma-separated Origin allowlist (CSWSH defence). Read directly by the worker edge, not the config loader; unset = same-origin auto-derive.
52
+ ## END consumed-env-vars
17
53
 
18
54
  name = "serverless-ircd"
19
55
  main = "src/worker.ts"
@@ -32,10 +68,16 @@ compatibility_flags = ["nodejs_compat"]
32
68
  ## `dispatch.error`, `frame.parse-error`) are the canonical query keys.
33
69
  [observability]
34
70
  enabled = true
35
- ## `head_sampling_rate` keeps 100% of console output — valuable during v1
36
- ## and consistent with the per-frame traceId scheme (every request is
37
- ## uniquely identifiable). Lower this (e.g. 0.1) once traffic grows.
38
- head_sampling_rate = 1
71
+ ## `head_sampling_rate` caps the share of console output Workers
72
+ ## Analytics retains. The default is 0.1 (10%): every record the actor
73
+ ## emits is scrubbed to non-sensitive fields (e.g. `frame.parse-error`
74
+ ## carries only token/length/reason, never the raw line), so full-rate
75
+ ## sampling is unnecessary for triage and only multiplies how much
76
+ ## client-adjacent data log destinations retain. Production operators
77
+ ## can tune this per environment by overriding the `[observability]`
78
+ ## block in an `[env.<name>]` section (e.g. staging at 1.0 while
79
+ ## debugging an incident).
80
+ head_sampling_rate = 0.1
39
81
 
40
82
  [observability.logs]
41
83
  enabled = true
@@ -52,9 +94,15 @@ invocation_logs = true
52
94
  ## listed origins proceed. When unset/empty (the default), the Worker
53
95
  ## auto-derives the expected origin from the request's own `Host` header
54
96
  ## (same-origin enforcement) — no per-env config needed. Set this only for
55
- ## cross-origin deployments where the SPA is served from a different domain
56
- ## than the Worker. Non-browser clients (curl, WeeChat, tcp-ws-forwarder)
97
+ ## cross-origin deployments where the SPA is served from a different
98
+ ## domain than the Worker. Non-browser clients (curl, WeeChat, tcp-ws-forwarder)
57
99
  ## omit `Origin` and pass through unchanged regardless.
100
+ ##
101
+ ## `MAX_CLIENTS` is the OPTIONAL global live-connection cap (default
102
+ ## 10000). The Worker reserves a slot in the COUNTER_DO counter before
103
+ ## forwarding an upgrade and rejects with 429 (ERROR :Closing link:
104
+ ## server full) once the cap is reached; slots are released on
105
+ ## connection close and TTL-reaped if a DO dies without one.
58
106
  [vars]
59
107
  SERVER_NAME = "irc.your-domain.invalid"
60
108
  NETWORK_NAME = "ServerlessIRCd"
@@ -141,6 +189,27 @@ class_name = "ChannelDO"
141
189
  name = "CHANNEL_REGISTRY_DO"
142
190
  class_name = "ChannelRegistryDO"
143
191
 
192
+ # Global live-connection counter enforcing `MAX_CLIENTS` (the
193
+ # `maxClients` server-config cap). A single CounterDO instance (fixed
194
+ # name) tracks one admission entry per live WebSocket; the Worker's
195
+ # `fetch` reserves a slot per upgrade and rejects with 429 when full,
196
+ # ConnectionDO releases the slot on every teardown path, and a lazy TTL
197
+ # reap on each admit reconciles entries from DO instances that died
198
+ # without delivering a close event.
199
+ [[durable_objects.bindings]]
200
+ name = "COUNTER_DO"
201
+ class_name = "CounterDO"
202
+
203
+ # Per-IP upgrade rate limiter enforcing `perIpConnectionRate` (knobs:
204
+ # `PER_IP_CONNECTION_RATE_MAX` / `PER_IP_CONNECTION_RATE_WINDOW_MS`
205
+ # vars, defaults 5 per 60 s). A single RateLimitDO instance (fixed name)
206
+ # keeps one sliding-window admission-timestamp list per
207
+ # CF-Connecting-IP; the Worker's `fetch` checks the budget before
208
+ # forwarding an upgrade and rejects over-budget IPs with 429.
209
+ [[durable_objects.bindings]]
210
+ name = "RATE_LIMIT_DO"
211
+ class_name = "RateLimitDO"
212
+
144
213
  # ## ---------------------------------------------------------------------------
145
214
  # ## Durable Object migrations.
146
215
  # ## ---------------------------------------------------------------------------
@@ -170,3 +239,11 @@ storage = "sqlite"
170
239
  [exports.ChannelRegistryDO]
171
240
  type = "durable-object"
172
241
  storage = "sqlite"
242
+
243
+ [exports.CounterDO]
244
+ type = "durable-object"
245
+ storage = "sqlite"
246
+
247
+ [exports.RateLimitDO]
248
+ type = "durable-object"
249
+ storage = "sqlite"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "Runnable WebSocket IRC server using the in-memory runtime; manual-test harness and e2e fixture target",
6
6
  "license": "BSD-3-Clause",
@@ -9,7 +9,11 @@
9
9
  * surfaced at boot rather than mid-session.
10
10
  */
11
11
 
12
- import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
12
+ import {
13
+ type OperCred,
14
+ type ParsedServerConfig,
15
+ parseServerConfig,
16
+ } from '@serverless-ircd/irc-core';
13
17
 
14
18
  /**
15
19
  * CLI / programmatic input for `loadServerConfigFromCliArgs`. Every
@@ -34,7 +38,15 @@ export interface CliConfigInput {
34
38
  motdLines?: string[];
35
39
  channelPrefixes?: string;
36
40
  maxClients?: number;
37
- operCreds?: Array<{ user: string; password: string }>;
41
+ /**
42
+ * IRC operator credentials. Each entry is EITHER the at-rest hashed
43
+ * form `{user, salt, hash}` (recommended — generate via
44
+ * `tools/hash-oper-cred.ts`) OR the legacy plaintext form
45
+ * `{user, password}` (deprecated — retained for a single
46
+ * deprecation-cycle window). The two shapes can be mixed in the same
47
+ * array; the shared `OperCredSchema` validates either.
48
+ */
49
+ operCreds?: ReadonlyArray<OperCred>;
38
50
  /**
39
51
  * Server-password gate. Treat as a secret; the loader threads it
40
52
  * straight through (via `stripUndefined`) to `ServerConfig.serverPassword`
@@ -18,9 +18,26 @@
18
18
  * exactly when a real IRC client connects to local-cli directly.
19
19
  */
20
20
 
21
+ import { MAX_BUFFER_BYTES } from '@serverless-ircd/irc-core';
22
+
23
+ /** Options accepted by {@link LineScanner}. */
24
+ export interface LineScannerOptions {
25
+ /**
26
+ * Invoked when a pushed chunk grows the retained carry past
27
+ * {@link MAX_BUFFER_BYTES} (raw bytes, checked once per push). The
28
+ * scanner drops the oversized carry and returns no lines; the owner
29
+ * should write `ERROR :Closing link: ...` and destroy the socket — this
30
+ * seam has no write path. Mirrors the `TcpByteStreamTransport` cap so
31
+ * both sides of the WS↔TCP bridge enforce one identical budget.
32
+ */
33
+ onOverflow?: () => void;
34
+ }
35
+
21
36
  export class LineScanner {
22
37
  private carry: Buffer = Buffer.alloc(0);
23
38
 
39
+ constructor(private readonly options?: LineScannerOptions) {}
40
+
24
41
  /**
25
42
  * Feeds a chunk of TCP bytes. Returns the complete lines found in this
26
43
  * chunk (joined with any leftover carry from prior chunks), in order,
@@ -42,6 +59,15 @@ export class LineScanner {
42
59
  nl = buf.indexOf(0x0a, start);
43
60
  }
44
61
  this.carry = buf.subarray(start);
62
+ // Cap the retained (unterminated) tail — checked once per push, in raw
63
+ // bytes. Complete lines flushed by this chunk never count against the
64
+ // budget, so legitimate multi-line batches far larger than the cap pass
65
+ // through; only an unterminated tail can grow unboundedly.
66
+ if (this.carry.length > MAX_BUFFER_BYTES) {
67
+ this.carry = Buffer.alloc(0);
68
+ this.options?.onOverflow?.();
69
+ return [];
70
+ }
45
71
  return lines;
46
72
  }
47
73
 
@@ -44,6 +44,7 @@ import {
44
44
  type Logger,
45
45
  type MessageStore,
46
46
  type NickHistoryStore,
47
+ type OperCred,
47
48
  type RawLine,
48
49
  type ServerConfig,
49
50
  type ServicesStore,
@@ -182,8 +183,15 @@ export interface LocalServerConfig {
182
183
  * like `REHASH` are unreachable; supply a non-empty list to let a client
183
184
  * oper up. Threaded straight through to the reducer-facing
184
185
  * {@link ServerConfig} via the shared schema.
186
+ *
187
+ * Each entry is EITHER the at-rest hashed form
188
+ * `{user, salt, hash}` (recommended — generate via
189
+ * `tools/hash-oper-cred.ts`) OR the legacy plaintext form
190
+ * `{user, password}` (deprecated — retained for a single
191
+ * deprecation-cycle window). The two shapes can be mixed in the same
192
+ * array; the shared `OperCredSchema` validates either.
185
193
  */
186
- readonly operCreds?: Array<{ user: string; password: string }>;
194
+ readonly operCreds?: ReadonlyArray<OperCred>;
187
195
  }
188
196
 
189
197
  export interface StartServerOptions extends LocalServerConfig {
@@ -525,7 +533,16 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
525
533
  const tcpSockets = new Set<Socket>();
526
534
  const tcpServer = createServer((socket) => {
527
535
  tcpSockets.add(socket);
528
- const scanner = new LineScanner();
536
+ const scanner = new LineScanner({
537
+ // A > 8 KiB unterminated tail is pure attacker-controlled memory
538
+ // (no legal IRC line can exceed 512 bytes): answer with the
539
+ // RFC-style ERROR notice and destroy the socket. Cleanup runs in
540
+ // the shared 'close' handler below.
541
+ onOverflow: () => {
542
+ safeSocketWrite(socket, 'ERROR :Closing link: input buffer overflow\r\n');
543
+ socket.destroy();
544
+ },
545
+ });
529
546
 
530
547
  // Admission gate — same policy as the WS path: refuse before the
531
548
  // actor is attached so per-IP / per-user counters stay authoritative.
@@ -686,6 +703,10 @@ function attachConnection(
686
703
  messages,
687
704
  ...(services !== undefined ? { services } : {}),
688
705
  history,
706
+ // Shared per-IP failed-OPER counter: one budget per source host across
707
+ // every connection the CLI hosts (runtime-owned, bound to the runtime
708
+ // clock). Drives the OPER brute-force lockout.
709
+ operFailures: runtime.operFailures,
689
710
  logger,
690
711
  ...(transport.actorTransport !== undefined ? { transport: transport.actorTransport } : {}),
691
712
  });
@@ -79,3 +79,67 @@ describe('LineScanner', () => {
79
79
  expect(sc.flush()).toEqual([]);
80
80
  });
81
81
  });
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Input buffer cap: a peer streaming megabytes with no line terminator must
85
+ // not grow the carry unboundedly. The cap is 8 KiB of raw bytes on the
86
+ // retained (unterminated) tail, checked once per push — complete lines
87
+ // flushed by the same chunk never count against the budget.
88
+ // ---------------------------------------------------------------------------
89
+
90
+ describe('LineScanner — input buffer cap', () => {
91
+ it('signals onOverflow and drops the carry when an appended tail exceeds the 8 KiB cap', () => {
92
+ const overflows: number[] = [];
93
+ const sc = new LineScanner({ onOverflow: () => overflows.push(1) });
94
+ expect(sc.push(enc('x'.repeat(9 * 1024)))).toEqual([]);
95
+ expect(overflows.length).toBe(1);
96
+ // The oversized tail is dropped, not retained.
97
+ expect(sc.flush()).toEqual([]);
98
+ });
99
+
100
+ it('checks the cap once per push (chunk), not per byte', () => {
101
+ // A single 9 KiB chunk that overflows fires the callback exactly once —
102
+ // the guard runs on the appended carry, not per input byte.
103
+ const overflows: number[] = [];
104
+ const sc = new LineScanner({ onOverflow: () => overflows.push(1) });
105
+ sc.push(enc('y'.repeat(9 * 1024)));
106
+ expect(overflows.length).toBe(1);
107
+ });
108
+
109
+ it('signals overflow only on the push that crosses the cap (accumulated chunks)', () => {
110
+ const overflows: number[] = [];
111
+ const sc = new LineScanner({ onOverflow: () => overflows.push(1) });
112
+ expect(sc.push(enc('a'.repeat(4 * 1024)))).toEqual([]);
113
+ expect(overflows.length).toBe(0);
114
+ expect(sc.push(enc('b'.repeat(5 * 1024)))).toEqual([]);
115
+ expect(overflows.length).toBe(1);
116
+ });
117
+
118
+ it('does NOT overflow a buffered partial line at (just under) the cap', () => {
119
+ const overflows: number[] = [];
120
+ const sc = new LineScanner({ onOverflow: () => overflows.push(1) });
121
+ // Exactly 8192 bytes: at the cap, still allowed.
122
+ sc.push(enc('z'.repeat(8192)));
123
+ expect(overflows.length).toBe(0);
124
+ expect(sc.flush()).toEqual(['z'.repeat(8192)]);
125
+ });
126
+
127
+ it('does not overflow a chunk of complete lines totalling over the cap (batching)', () => {
128
+ const overflows: number[] = [];
129
+ const sc = new LineScanner({ onOverflow: () => overflows.push(1) });
130
+ // 20 terminated ~500-byte lines ≈ 10 KiB in one chunk: every line is
131
+ // complete, so the carry is empty and nothing overflows.
132
+ const chunk = `${Array.from({ length: 20 }, (_, i) => `PING :${`${i}`.padStart(495, '-')}`).join('\r\n')}\r\n`;
133
+ expect(chunk.length).toBeGreaterThan(8192);
134
+ expect(sc.push(enc(chunk)).length).toBe(20);
135
+ expect(overflows.length).toBe(0);
136
+ });
137
+
138
+ it('measures the carry in bytes (multi-byte UTF-8 counts at wire size)', () => {
139
+ // 'é' encodes to 2 UTF-8 bytes: 5000 × é = 10000 bytes > 8192.
140
+ const overflows: number[] = [];
141
+ const sc = new LineScanner({ onOverflow: () => overflows.push(1) });
142
+ sc.push(enc('é'.repeat(5000)));
143
+ expect(overflows.length).toBe(1);
144
+ });
145
+ });