serverless-ircd 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -26,7 +26,6 @@
26
26
  */
27
27
 
28
28
  import {
29
- type AccountStore,
30
29
  type AwayStore,
31
30
  CHANSERV_NICK,
32
31
  type ChanName,
@@ -36,6 +35,7 @@ import {
36
35
  type ConnectionState,
37
36
  DEFAULT_MONITOR_LIMIT,
38
37
  DEFAULT_MULTILINE_MAX_BYTES,
38
+ DEFAULT_MULTILINE_MAX_ENTRIES,
39
39
  Effect,
40
40
  type Effect as EffectType,
41
41
  EmptyMotdProvider,
@@ -55,6 +55,7 @@ import {
55
55
  NoopLoggerInstance,
56
56
  Numerics,
57
57
  OPERSERV_NICK,
58
+ type OperFailureStats,
58
59
  type RawLine,
59
60
  type Reducer,
60
61
  SHORTCUT_TO_SERVICE,
@@ -64,6 +65,7 @@ import {
64
65
  applyLabelToEffects,
65
66
  buildCtx,
66
67
  buildMultilineFanout,
68
+ byteLength,
67
69
  caseFold,
68
70
  chathistoryReducer,
69
71
  extractLabel,
@@ -92,6 +94,7 @@ import {
92
94
  } from '@serverless-ircd/irc-core';
93
95
  import { dispatch } from './dispatch.js';
94
96
  import type { DispatchOptions } from './dispatch.js';
97
+ import { parseErrorReason, truncateToken } from './redact.js';
95
98
  import { type RoutedReducers, buildRoutedReducers } from './routing.js';
96
99
  import type { IrcRuntime } from './runtime.js';
97
100
  import { type Transport, WsTextFrameTransport } from './transport.js';
@@ -150,16 +153,6 @@ export interface ConnectionActorOptions {
150
153
  * opt in.
151
154
  */
152
155
  readonly messages?: MessageStore;
153
- /**
154
- * SASL account verification source (absent when no accounts are
155
- * configured). When bound, `ctx.accounts` is defined so the
156
- * `AUTHENTICATE` reducer can verify PLAIN credentials end-to-end.
157
- * Omitted (→ `ctx.accounts` undefined) preserves the existing
158
- * behaviour for callers/tests that do not opt in: a client completing
159
- * `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL` because no store
160
- * can vouch for the credentials.
161
- */
162
- readonly accounts?: AccountStore;
163
156
  /**
164
157
  * mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
165
158
  * is not configured). When bound, `ctx.mtlsIdentity` is defined so the
@@ -216,6 +209,19 @@ export interface ConnectionActorOptions {
216
209
  * watched nick later connects or quits.
217
210
  */
218
211
  readonly monitor?: MonitorStore;
212
+ /**
213
+ * Per-IP failed-`OPER` counter shared across every connection's actor
214
+ * (absent when the deployment's runtime does not maintain one — e.g.
215
+ * per-connection Durable Object adapters). When bound, `ctx.operFailures`
216
+ * is defined so the OPER reducer throttles brute-force guessing per
217
+ * source host: after `serverConfig.oper.maxFailures` credential
218
+ * failures within `oper.failureWindowSeconds`, further attempts from
219
+ * that host are rejected with `491` before the credential store is
220
+ * consulted and a `BroadcastOperNotice` notifies every oper. The
221
+ * instance MUST share the actor's clock so window arithmetic is
222
+ * coherent.
223
+ */
224
+ readonly operFailures?: OperFailureStats;
219
225
  /**
220
226
  * Network-statistics source for the `LUSERS` and `STATS` commands
221
227
  * (absent when no stats backend is wired). When bound, the actor
@@ -271,12 +277,12 @@ export class ConnectionActor {
271
277
  private readonly ids: IdFactory;
272
278
  private readonly motd: MotdProvider;
273
279
  private readonly messages: MessageStore | undefined;
274
- private readonly accounts: AccountStore | undefined;
275
280
  private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
276
281
  private readonly history: NickHistoryStore | undefined;
277
282
  private readonly away: AwayStore | undefined;
278
283
  private readonly services: ServicesStore | undefined;
279
284
  private readonly monitor: MonitorStore | undefined;
285
+ private readonly operFailures: OperFailureStats | undefined;
280
286
  private readonly stats: ServerStats | undefined;
281
287
  private readonly reducers: RoutedReducers;
282
288
  /**
@@ -302,9 +308,10 @@ export class ConnectionActor {
302
308
  * In-progress IRCv3 `draft/multiline` batch, if any. Set when the client
303
309
  * sends `BATCH +id draft/multiline :<target>` (and the connection
304
310
  * negotiated the cap), populated by each intervening `PRIVMSG`/`NOTICE`,
305
- * and cleared when the closing `BATCH -id` is finalized. While set, the
306
- * actor diverts `PRIVMSG`/`NOTICE` into the accumulator instead of
307
- * running the normal channel/user reducers.
311
+ * and cleared when the closing `BATCH -id` is finalized or earlier on
312
+ * an incremental-budget abort or connection teardown ({@link dispose}).
313
+ * While set, the actor diverts `PRIVMSG`/`NOTICE` into the accumulator
314
+ * instead of running the normal channel/user reducers.
308
315
  */
309
316
  private openMultilineBatch:
310
317
  | {
@@ -325,12 +332,12 @@ export class ConnectionActor {
325
332
  this.ids = opts.ids;
326
333
  this.motd = opts.motd ?? EmptyMotdProvider;
327
334
  this.messages = opts.messages;
328
- this.accounts = opts.accounts;
329
335
  this.mtlsIdentity = opts.mtlsIdentity;
330
336
  this.history = opts.history;
331
337
  this.away = opts.away;
332
338
  this.services = opts.services;
333
339
  this.monitor = opts.monitor;
340
+ this.operFailures = opts.operFailures;
334
341
  this.stats = opts.stats;
335
342
  this.logger = opts.logger ?? NoopLoggerInstance;
336
343
  this.pinnedTraceId = opts.traceId;
@@ -387,23 +394,41 @@ export class ConnectionActor {
387
394
  await this.receive(text);
388
395
  }
389
396
 
397
+ /**
398
+ * Connection teardown hook. Clears any state that must not outlive the
399
+ * connection — currently an abandoned `draft/multiline` batch (a client
400
+ * that disconnects between `BATCH +id` and `BATCH -id` would otherwise
401
+ * leave the accumulator mid-flight). Adapters call this when the
402
+ * underlying socket closes; safe to call at any lifecycle point.
403
+ */
404
+ dispose(): void {
405
+ this.openMultilineBatch = undefined;
406
+ }
407
+
390
408
  private async processLine(line: string, log: Logger, traceId: string): Promise<void> {
391
409
  let msg: IrcMessage;
392
410
  let parseFailed = false;
411
+ let parseErr: unknown;
393
412
  try {
394
413
  msg = parse(line);
395
- } catch {
414
+ } catch (err) {
396
415
  // `parse` only throws `IrcParseError` (every throw inside the parser
397
416
  // constructs one); the untyped catch is the strict-mode narrowing
398
417
  // artifact for `unknown`. Every parse failure becomes a `421` so the
399
418
  // actor never crashes on a single bad line (acceptance criteria).
400
419
  parseFailed = true;
420
+ parseErr = err;
401
421
  msg = { tags: {}, command: '', params: [] };
402
422
  }
403
423
  if (parseFailed) {
424
+ // The raw line is NEVER logged: a hostile client can smuggle a
425
+ // credential past the parser (e.g. a malformed line whose payload
426
+ // carries `PASS <secret>`). Operators get the leading token
427
+ // (capped), the length, and a stable reason code instead.
404
428
  log.warn('frame.parse-error', {
405
- line,
406
- token: firstToken(line),
429
+ token: truncateToken(firstToken(line)),
430
+ length: line.length,
431
+ reason: parseErrorReason(parseErr),
407
432
  });
408
433
  const token = firstToken(line);
409
434
  await dispatch(
@@ -511,11 +536,11 @@ export class ConnectionActor {
511
536
  motd: this.motd,
512
537
  connection: this.state,
513
538
  ...(this.messages !== undefined ? { messages: this.messages } : {}),
514
- ...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
515
539
  ...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
516
540
  ...(this.history !== undefined ? { history: this.history } : {}),
517
541
  ...(this.away !== undefined ? { away: this.away } : {}),
518
542
  ...(this.services !== undefined ? { services: this.services } : {}),
543
+ ...(this.operFailures !== undefined ? { operFailures: this.operFailures } : {}),
519
544
  ...(label !== undefined ? { label } : {}),
520
545
  });
521
546
 
@@ -605,6 +630,7 @@ export class ConnectionActor {
605
630
  return this.routeMultiChannel(
606
631
  msg,
607
632
  (chan, singleParamMsg) => this.reducers.part(chan, singleParamMsg, ctx).effects,
633
+ ctx,
608
634
  );
609
635
  case 'TOPIC':
610
636
  return this.routeSingleChannel(msg, (chan) => this.reducers.topic(chan, msg, ctx).effects);
@@ -1123,6 +1149,12 @@ export class ConnectionActor {
1123
1149
  * `draft/multiline` batch. The inner line's target must match the batch
1124
1150
  * target and (after the first line) its command must match too; a
1125
1151
  * mismatch aborts the batch with a `FAIL` and clears the accumulator.
1152
+ *
1153
+ * The byte budget (`multilineMaxBytes`) and entry ceiling
1154
+ * (`multilineMaxEntries`) are enforced incrementally on each push: the
1155
+ * line that would push the batch past either limit aborts it immediately
1156
+ * (synthesized `BATCH -id` + `FAIL`) instead of being buffered for a
1157
+ * close the client may never send.
1126
1158
  */
1127
1159
  private accumulateMultilineLine(msg: IrcMessage): EffectType[] {
1128
1160
  const open = this.openMultilineBatch;
@@ -1155,10 +1187,46 @@ export class ConnectionActor {
1155
1187
  ]),
1156
1188
  ];
1157
1189
  }
1190
+ // Incremental budget enforcement: the byte budget and entry ceiling are
1191
+ // checked on every push, not just on close, so a client cannot buffer
1192
+ // unbounded content in a batch it never closes.
1193
+ const maxEntries = this.serverConfig.multilineMaxEntries ?? DEFAULT_MULTILINE_MAX_ENTRIES;
1194
+ if (open.entries.length >= maxEntries) {
1195
+ return this.abortMultilineBatch(open, 'MULTILINE_MAX_LINES', maxEntries, 'line');
1196
+ }
1197
+ const maxBytes = this.serverConfig.multilineMaxBytes ?? DEFAULT_MULTILINE_MAX_BYTES;
1198
+ const candidateBytes =
1199
+ multilineByteLength(open.entries) + (open.entries.length === 0 ? 0 : 1) + byteLength(text);
1200
+ if (candidateBytes > maxBytes) {
1201
+ return this.abortMultilineBatch(open, 'MULTILINE_MAX_BYTES', maxBytes, 'byte');
1202
+ }
1158
1203
  open.entries.push({ text });
1159
1204
  return [];
1160
1205
  }
1161
1206
 
1207
+ /**
1208
+ * Aborts an open `draft/multiline` batch that just violated the
1209
+ * incremental budget: clears the accumulator and emits a synthesized
1210
+ * `BATCH -id` close (so batch-aware clients exit the batch) followed by
1211
+ * the `FAIL` rejection carrying the limit that was exceeded.
1212
+ */
1213
+ private abortMultilineBatch(
1214
+ open: { readonly id: string; readonly target: string },
1215
+ code: 'MULTILINE_MAX_BYTES' | 'MULTILINE_MAX_LINES',
1216
+ limit: number,
1217
+ unit: 'byte' | 'line',
1218
+ ): EffectType[] {
1219
+ this.openMultilineBatch = undefined;
1220
+ return [
1221
+ Effect.send(this.state.id, [
1222
+ { text: `BATCH -${open.id}` },
1223
+ {
1224
+ text: `:${this.serverConfig.serverName} FAIL BATCH ${code} ${open.target} :Multi-line batch exceeds the ${limit} ${unit} limit`,
1225
+ },
1226
+ ]),
1227
+ ];
1228
+ }
1229
+
1162
1230
  /**
1163
1231
  * Finalizes an open `draft/multiline` batch on `BATCH -id`. Channel targets
1164
1232
  * delegate to the pure {@link finalizeMultilineBatch} reducer (cap-split
@@ -1324,6 +1392,7 @@ export class ConnectionActor {
1324
1392
  private routeMultiChannel(
1325
1393
  msg: IrcMessage,
1326
1394
  run: (chan: ChannelState, singleParamMsg: IrcMessage) => EffectType[],
1395
+ ctx: ReturnType<typeof buildCtx>,
1327
1396
  ): EffectType[] {
1328
1397
  const effects: EffectType[] = [];
1329
1398
  const rawParam = msg.params[0] ?? '';
@@ -1336,11 +1405,19 @@ export class ConnectionActor {
1336
1405
  // 461 ERR_NEEDMOREPARAMS rather than the actor silently doing nothing.
1337
1406
  return run(this.channels.getOrCreateChannel(''), msg);
1338
1407
  }
1339
- for (const name of names) {
1408
+ // Target cap (e.g. `PART #a,#b,#c,…`): only the first
1409
+ // `maxTargetsPerCommand` channels are processed; every dropped
1410
+ // channel gets a `407 ERR_TOOMANYTARGETS` so the client can see the
1411
+ // truncation instead of the part being silently ignored.
1412
+ const cap = ctx.serverConfig.maxTargetsPerCommand;
1413
+ for (const name of names.slice(0, cap)) {
1340
1414
  const chan = this.channels.getOrCreateChannel(name);
1341
1415
  const singleParamMsg: IrcMessage = { ...msg, params: [name, ...tail] };
1342
1416
  effects.push(...run(chan, singleParamMsg));
1343
1417
  }
1418
+ for (const dropped of names.slice(cap)) {
1419
+ effects.push(this.tooManyTargetsEffect(dropped, ctx));
1420
+ }
1344
1421
  return effects;
1345
1422
  }
1346
1423
 
@@ -1370,7 +1447,12 @@ export class ConnectionActor {
1370
1447
  // proper 411 ERR_NORECIPIENT rather than silently doing nothing.
1371
1448
  return userReducer(this.state, msg, ctx).effects;
1372
1449
  }
1373
- for (const target of targets) {
1450
+ // Target cap (e.g. `PRIVMSG #a,#b,#c,…`): only the first
1451
+ // `maxTargetsPerCommand` targets are routed; every dropped target
1452
+ // gets a `407 ERR_TOOMANYTARGETS` so the client can see the
1453
+ // truncation instead of the message being silently ignored.
1454
+ const cap = ctx.serverConfig.maxTargetsPerCommand;
1455
+ for (const target of targets.slice(0, cap)) {
1374
1456
  const singleParamMsg: IrcMessage = { ...msg, params: [target, ...tail] };
1375
1457
  const serviceReducer = this.serviceReducerFor(target, msg.command);
1376
1458
  if (isChannelTarget(target)) {
@@ -1392,9 +1474,28 @@ export class ConnectionActor {
1392
1474
  effects.push(...userReducer(this.state, singleParamMsg, ctx).effects);
1393
1475
  }
1394
1476
  }
1477
+ for (const dropped of targets.slice(cap)) {
1478
+ effects.push(this.tooManyTargetsEffect(dropped, ctx));
1479
+ }
1395
1480
  return effects;
1396
1481
  }
1397
1482
 
1483
+ /**
1484
+ * Builds the `407 ERR_TOOMANYTARGETS` Send effect for one target dropped
1485
+ * by the `maxTargetsPerCommand` cap in {@link routeMessageTarget} /
1486
+ * {@link routeMultiChannel}. Emitted once per dropped target after the
1487
+ * allowed targets have been routed, so the client learns exactly which
1488
+ * targets were not delivered to.
1489
+ */
1490
+ private tooManyTargetsEffect(target: string, ctx: ReturnType<typeof buildCtx>): EffectType {
1491
+ const nick = ctx.connection.nick ?? '*';
1492
+ const code = Numerics.ERR_TOOMANYTARGETS.toString().padStart(3, '0');
1493
+ const line: RawLine = {
1494
+ text: `:${this.serverConfig.serverName} ${code} ${nick} ${target} :Too many targets`,
1495
+ };
1496
+ return Effect.send(ctx.connId, [line]);
1497
+ }
1498
+
1398
1499
  /**
1399
1500
  * Resolves the service pseudo-client reducer for a nick target, or
1400
1501
  * `undefined` when the target is not a service nick / the command is not
@@ -53,6 +53,7 @@ const handlers: { [K in Effect['tag']]: Handler<Extract<Effect, { tag: K }>> } =
53
53
  ReleaseNick: (e, r, m) => releaseNickEtc(e, r, m),
54
54
  ApplyChannelDelta: (e, r) => r.applyChannelDelta(e.chan, e.delta),
55
55
  BroadcastWallops: (e, r) => r.broadcastWallops(e.lines, e.except),
56
+ BroadcastOperNotice: (e, r) => r.broadcastOperNotice(e.lines, e.except),
56
57
  EnforceNick: (e, r, _m, opts) => enforceNick(e, r, opts),
57
58
  };
58
59
 
@@ -19,3 +19,10 @@ export {
19
19
  export type { Transport } from './transport.js';
20
20
  export { buildRoutedReducers, resolveFloodControl } from './routing.js';
21
21
  export type { RoutedReducers } from './routing.js';
22
+ export {
23
+ MAX_LOG_TOKEN_CHARS,
24
+ parseErrorReason,
25
+ REDACTED,
26
+ redactLine,
27
+ truncateToken,
28
+ } from './redact.js';
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Log-scrubbing helpers — the credential-exfiltration guards for log
3
+ * payloads.
4
+ *
5
+ * Threat model: a hostile client can deliberately smuggle secret
6
+ * material into server logs by sending a line whose payload carries a
7
+ * credential (e.g. `PASS hunter2` inside a frame that trips the parser).
8
+ * Anything derived from inbound client input that reaches a warn/error
9
+ * log record must pass through these helpers first:
10
+ *
11
+ * - A site that must log a line logs `redactLine(line)` — the command
12
+ * verb (and NickServ target + subcommand) survive for debuggability;
13
+ * the credential-bearing parameters are replaced with `[REDACTED]`.
14
+ * - Parse diagnostics never log the line at all: they log
15
+ * `truncateToken(firstToken(line))` + the line length +
16
+ * `parseErrorReason(err)` so operators can correlate without ever
17
+ * storing client payload.
18
+ */
19
+
20
+ /** Placeholder substituted for redacted credential material. */
21
+ export const REDACTED = '[REDACTED]';
22
+
23
+ /**
24
+ * Max characters of a leading token included in log records. Caps the
25
+ * volume a hostile client can push into logs with a single over-long
26
+ * garbage token (the wire itself allows 512 bytes per line).
27
+ */
28
+ export const MAX_LOG_TOKEN_CHARS = 32;
29
+
30
+ /**
31
+ * Command verbs whose parameters are credential material. `IDENTIFY`
32
+ * covers the service-shortcut form some clients send as a top-level
33
+ * command; the PRIVMSG/NOTICE-to-NickServ form is handled separately
34
+ * below.
35
+ */
36
+ const SENSITIVE_COMMANDS: ReadonlySet<string> = new Set([
37
+ 'PASS',
38
+ 'AUTHENTICATE',
39
+ 'OPER',
40
+ 'IDENTIFY',
41
+ ]);
42
+
43
+ /**
44
+ * rfc1459-lowercased service nick targets for PRIVMSG/NOTICE. Only
45
+ * messages aimed at these nicks are inspected for credential
46
+ * subcommands, so ordinary chat is never touched.
47
+ */
48
+ const SERVICE_NICKS: ReadonlySet<string> = new Set(['nickserv', 'ns']);
49
+
50
+ /**
51
+ * NickServ subcommands whose arguments are credentials (`IDENTIFY
52
+ * <password>`, the `ID <password>` alias). Anything else aimed at a
53
+ * service nick (INFO, HELP, …) is not secret and passes through.
54
+ */
55
+ const SENSITIVE_SUBCOMMANDS: ReadonlySet<string> = new Set(['identify', 'id']);
56
+
57
+ /**
58
+ * Redacts the credential-bearing parameters of a raw inbound IRC line
59
+ * while keeping the command verb (and, for service messages, the target
60
+ * and subcommand) for debuggability. Non-sensitive lines are returned
61
+ * unchanged; the input is never lengthened beyond a few placeholder
62
+ * characters.
63
+ *
64
+ * Sensitive shapes:
65
+ * `PASS <pw>` → `PASS [REDACTED]`
66
+ * `AUTHENTICATE <sasl-payload>` → `AUTHENTICATE [REDACTED]`
67
+ * `OPER <name> <pw>` → `OPER [REDACTED]`
68
+ * `IDENTIFY <pw>` → `IDENTIFY [REDACTED]`
69
+ * `PRIVMSG NickServ :IDENTIFY <pw>`→ `PRIVMSG NickServ :IDENTIFY [REDACTED]`
70
+ * (same for NOTICE, the `ID` alias, `ns`, and missing trailing colons)
71
+ *
72
+ * An optional leading `:source` prefix is preserved verbatim.
73
+ */
74
+ export function redactLine(line: string): string {
75
+ // Split off an optional leading source prefix; the next token is the
76
+ // command position.
77
+ let head = '';
78
+ let rest = line;
79
+ if (line.startsWith(':')) {
80
+ const prefixEnd = line.indexOf(' ');
81
+ if (prefixEnd === -1) return line;
82
+ head = line.slice(0, prefixEnd);
83
+ rest = line.slice(prefixEnd + 1);
84
+ }
85
+
86
+ const commandEnd = rest.indexOf(' ');
87
+ const command = commandEnd === -1 ? rest : rest.slice(0, commandEnd);
88
+ const remainder = commandEnd === -1 ? '' : rest.slice(commandEnd + 1);
89
+ const verb = command.toUpperCase();
90
+
91
+ if (SENSITIVE_COMMANDS.has(verb)) {
92
+ if (remainder.length === 0) return line;
93
+ return withPrefix(head, `${command} ${REDACTED}`);
94
+ }
95
+
96
+ if (verb === 'PRIVMSG' || verb === 'NOTICE') {
97
+ const targetEnd = remainder.indexOf(' ');
98
+ const target = targetEnd === -1 ? remainder : remainder.slice(0, targetEnd);
99
+ const body = targetEnd === -1 ? '' : remainder.slice(targetEnd + 1);
100
+ if (SERVICE_NICKS.has(target.toLowerCase())) {
101
+ // Tolerate both conforming (`:IDENTIFY pw`) and colon-less bodies.
102
+ const bare = body.startsWith(':') ? body.slice(1) : body;
103
+ const hadColon = bare.length !== body.length;
104
+ const subEnd = bare.indexOf(' ');
105
+ const sub = subEnd === -1 ? bare : bare.slice(0, subEnd);
106
+ const hasArgs = subEnd !== -1;
107
+ if (hasArgs && SENSITIVE_SUBCOMMANDS.has(sub.toLowerCase())) {
108
+ return withPrefix(head, `${command} ${target} ${hadColon ? ':' : ''}${sub} ${REDACTED}`);
109
+ }
110
+ }
111
+ }
112
+
113
+ return line;
114
+ }
115
+
116
+ /**
117
+ * Caps a leading token at {@link MAX_LOG_TOKEN_CHARS} characters,
118
+ * suffixing an ellipsis marker when truncation occurred. The marker makes
119
+ * truncated tokens visually distinct from tokens that were exactly at the
120
+ * cap.
121
+ */
122
+ export function truncateToken(token: string): string {
123
+ return token.length <= MAX_LOG_TOKEN_CHARS ? token : `${token.slice(0, MAX_LOG_TOKEN_CHARS)}...`;
124
+ }
125
+
126
+ /**
127
+ * Parser diagnostics (fixed strings or fixed prefixes) → stable reason
128
+ * codes. Codes are log query keys; they must stay stable even if the
129
+ * human-readable message is reworded.
130
+ */
131
+ const PARSE_ERROR_REASONS: ReadonlyArray<readonly [prefix: string, reason: string]> = [
132
+ ['empty message', 'empty-message'],
133
+ ['message exceeds', 'input-cap-exceeded'],
134
+ ['tag section exceeds', 'tag-section-too-large'],
135
+ ['tag section with no message body', 'tags-without-body'],
136
+ ['too many tags', 'too-many-tags'],
137
+ ['source prefix with no command', 'prefix-without-command'],
138
+ ['missing command', 'missing-command'],
139
+ ['invalid command token', 'invalid-command-token'],
140
+ ];
141
+
142
+ /**
143
+ * Maps a thrown parse error to a stable reason code for log records.
144
+ * `parse` only throws `IrcParseError` (every throw inside the parser
145
+ * constructs one), but the guard accepts any thrown value so callers can
146
+ * pass an `unknown` catch binding directly.
147
+ */
148
+ export function parseErrorReason(err: unknown): string {
149
+ const message = err instanceof Error ? err.message : String(err);
150
+ for (const [prefix, reason] of PARSE_ERROR_REASONS) {
151
+ if (message.startsWith(prefix)) return reason;
152
+ }
153
+ return 'parse-failed';
154
+ }
155
+
156
+ /** Re-attaches the (possibly absent) source prefix to a redacted tail. */
157
+ function withPrefix(head: string, tail: string): string {
158
+ return head.length === 0 ? tail : `${head} ${tail}`;
159
+ }
@@ -54,6 +54,20 @@ export interface IrcRuntime {
54
54
  * oper's WALLOPS rate.
55
55
  */
56
56
  broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void>;
57
+ /**
58
+ * Oper-only notice fanout. Delivers `lines` to every live connection
59
+ * whose `+o` (oper) user mode is set, skipping `except`. Emitted by the
60
+ * OPER reducer's per-IP brute-force lockout gate
61
+ * (`:<server> NOTICE * :OPER lockout triggered for <host>`) so the oper
62
+ * corps learns a guessing attack is being throttled. Enumeration
63
+ * strategy mirrors {@link broadcastWallops} per adapter:
64
+ * - in-memory: scans its connection map, filters `+o`.
65
+ * - Cloudflare: walks the sharded registry, fetches each state to
66
+ * check `+o`.
67
+ * - AWS: `Scan`s the `Connections` table filtered to `+o` rows and
68
+ * `PostToConnection`s each.
69
+ */
70
+ broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void>;
57
71
 
58
72
  // nickname registry
59
73
  reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }>;
@@ -15,7 +15,7 @@
15
15
  * parser/reducer/dispatch pipeline.
16
16
  */
17
17
 
18
- import { splitFrameLines } from '@serverless-ircd/irc-core';
18
+ import { MAX_BUFFER_BYTES, byteLength, splitFrameLines } from '@serverless-ircd/irc-core';
19
19
 
20
20
  export { splitFrameLines };
21
21
 
@@ -28,6 +28,22 @@ export interface Transport {
28
28
  feed(chunk: string): string[];
29
29
  }
30
30
 
31
+ /** Options accepted by {@link TcpByteStreamTransport}. */
32
+ export interface TcpByteStreamTransportOptions {
33
+ /**
34
+ * Invoked when an appended chunk grows the retained partial-line buffer
35
+ * past {@link MAX_BUFFER_BYTES} (measured in UTF-8 bytes, once per
36
+ * append). The transport drops the oversized tail and returns no lines;
37
+ * the owner should close the connection — a peer that streams more than
38
+ * 8 KiB without a line terminator can never produce a legal IRC line
39
+ * (the parser caps lines at 512 bytes), so the buffer is pure
40
+ * attacker-controlled memory. Writing `ERROR :Closing link: ...` and
41
+ * tearing down the socket is the caller's job: this seam has no write
42
+ * path.
43
+ */
44
+ onOverflow?: () => void;
45
+ }
46
+
31
47
  /**
32
48
  * Splits a WebSocket text frame into IRC lines. Re-exported from
33
49
  * `@serverless-ircd/irc-core` (`ws-framing.ts`), which owns the canonical
@@ -65,6 +81,8 @@ export class WsTextFrameTransport implements Transport {
65
81
  export class TcpByteStreamTransport implements Transport {
66
82
  private buffer = '';
67
83
 
84
+ constructor(private readonly options?: TcpByteStreamTransportOptions) {}
85
+
68
86
  feed(chunk: string): string[] {
69
87
  if (chunk.length === 0) return [];
70
88
  this.buffer += chunk;
@@ -80,6 +98,15 @@ export class TcpByteStreamTransport implements Transport {
80
98
  }
81
99
  }
82
100
  this.buffer = this.buffer.slice(start);
101
+ // Cap the retained (unterminated) tail — checked once per append, in
102
+ // UTF-8 bytes. Complete lines flushed by this chunk never count against
103
+ // the budget, so legitimate multi-line batches far larger than the cap
104
+ // pass through; only an unterminated tail can grow unboundedly.
105
+ if (byteLength(this.buffer) > MAX_BUFFER_BYTES) {
106
+ this.buffer = '';
107
+ this.options?.onOverflow?.();
108
+ return [];
109
+ }
83
110
  return lines;
84
111
  }
85
112