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
@@ -15,10 +15,14 @@
15
15
  * peers see the revert. Requires identification.
16
16
  * - `REQUEST <vhost>` — proposes a vhost for the account. Behaviour
17
17
  * depends on {@link ServerConfig.hostservAutoApproveVhosts}: when the
18
- * knob is on (the default) the vhost is recorded against the account
19
- * immediately (legacy auto-approve); when the knob is off the request
20
- * is queued for oper review via {@link ServicesStore.requestVhost}
21
- * and the requester is told it is pending. Requires identification.
18
+ * knob is off (the default) the request is queued for oper review via
19
+ * {@link ServicesStore.requestVhost} and the requester is told it is
20
+ * pending. When the knob is on (deprecated legacy auto-approve) the
21
+ * vhost is recorded against the account immediately, but only after
22
+ * clearing a built-in denylist (reserved labels such as `admin` /
23
+ * `staff` at any position, plus this server's own hostname) and
24
+ * matching the mandatory {@link ServerConfig.hostservAutoApproveAllowlist}
25
+ * (exact or `*.suffix` wildcard entries). Requires identification.
22
26
  * - `SET <account> <vhost>` — oper-only. Records (or, with the literal
23
27
  * `*` argument, clears) the vhost for `account` directly without
24
28
  * going through REQUEST. The target account must be a registered nick.
@@ -67,6 +71,59 @@ function isValidVhost(vhost: string): boolean {
67
71
  return VHOST_RE.test(vhost);
68
72
  }
69
73
 
74
+ /**
75
+ * Labels a user may never self-assign via auto-approved REQUEST, at any
76
+ * dot-separated position of the vhost (case-insensitive — DNS labels are).
77
+ * These names impersonate staff, operators, or off-network services, so the
78
+ * denylist is enforced even when the operator explicitly allowlists them.
79
+ */
80
+ const RESERVED_VHOST_LABELS = new Set(['admin', 'oper', 'staff', 'irc', 'www', 'root', 'security']);
81
+
82
+ /**
83
+ * Built-in denylist for auto-approved vhosts. Rejects any vhost containing
84
+ * a {@link RESERVED_VHOST_LABELS} label, plus the deployment's own hostname
85
+ * (exactly or as a suffix) so a user cannot cloak as
86
+ * `<anything>.<this-server>` and phish off its identity. Oper-vetted paths
87
+ * (SET, queue-mode APPROVE) intentionally bypass this — opers are trusted.
88
+ */
89
+ function isDeniedVhost(vhost: string, serverName: string): boolean {
90
+ const lower = vhost.toLowerCase();
91
+ for (const label of lower.split('.')) {
92
+ if (RESERVED_VHOST_LABELS.has(label)) {
93
+ return true;
94
+ }
95
+ }
96
+ const server = serverName.toLowerCase();
97
+ return lower === server || lower.endsWith(`.${server}`);
98
+ }
99
+
100
+ /**
101
+ * Allowlist check for auto-approved vhosts. An entry matches when it is
102
+ * exactly the vhost (`users.example.net`) or a wildcard suffix
103
+ * (`*.users.example.com` matches `a.users.example.com` and any depth
104
+ * beneath it, but NOT the bare suffix). Matching is case-insensitive
105
+ * (DNS semantics). A missing allowlist matches nothing — the reducer
106
+ * fails closed; the config layer separately refuses to boot auto-approve
107
+ * mode without a non-empty allowlist.
108
+ */
109
+ function isAllowlistedVhost(vhost: string, allowlist: ReadonlyArray<string> | undefined): boolean {
110
+ if (allowlist === undefined) {
111
+ return false;
112
+ }
113
+ const lower = vhost.toLowerCase();
114
+ for (const entry of allowlist) {
115
+ const normalized = entry.toLowerCase();
116
+ if (normalized.startsWith('*.')) {
117
+ if (lower.endsWith(normalized.slice(1))) {
118
+ return true;
119
+ }
120
+ } else if (lower === normalized) {
121
+ return true;
122
+ }
123
+ }
124
+ return false;
125
+ }
126
+
70
127
  /**
71
128
  * Reducer for `PRIVMSG HostServ :<subcommand> <args…>`.
72
129
  * Authority: the invoking connection's {@link ConnectionState}.
@@ -185,13 +242,20 @@ function handleOff(
185
242
  /**
186
243
  * Handles `REQUEST <vhost>`. Behaviour is selected by
187
244
  * {@link ServerConfig.hostservAutoApproveVhosts}:
188
- * - `true` (auto-approve) — the vhost is recorded against the account
189
- * immediately (the path that shipped before the oper queue landed). The
190
- * user is notified that the vhost has been requested and approved.
191
245
  * - `false` or omitted (the default — queue mode) — the request is
192
246
  * queued for oper review via {@link ServicesStore.requestVhost}; the
193
247
  * vhost is NOT activated and no `CHGHOST` is emitted. The user is
194
- * notified that the request is pending oper approval.
248
+ * notified that the request is pending oper approval. Oper-vetted
249
+ * vhosts are exempt from the denylist/allowlist below.
250
+ * - `true` (auto-approve, deprecated) — the vhost is recorded against
251
+ * the account immediately, but only if it survives hardening: the
252
+ * built-in denylist ({@link isDeniedVhost}: reserved labels at any
253
+ * position + this deployment's own hostname) rejects impersonation
254
+ * vectors even when the operator allowlisted them, and the mandatory
255
+ * allowlist ({@link isAllowlistedVhost}) must match the vhost exactly
256
+ * or via a `*.suffix` wildcard entry. Config parsing refuses to boot
257
+ * auto-approve mode without a non-empty allowlist; the reducer fails
258
+ * closed regardless.
195
259
  *
196
260
  * Identified-only — otherwise a guest could plant a vhost (or a pending
197
261
  * request) against a registered account they do not own.
@@ -225,6 +289,18 @@ function handleRequest(
225
289
  return { state, effects };
226
290
  }
227
291
 
292
+ if (isDeniedVhost(vhost, ctx.serverName)) {
293
+ effects.push(
294
+ notice(state, `Vhost ${vhost} is not permitted (reserved label or this server's hostname).`),
295
+ );
296
+ return { state, effects };
297
+ }
298
+
299
+ if (!isAllowlistedVhost(vhost, ctx.serverConfig.hostservAutoApproveAllowlist)) {
300
+ effects.push(notice(state, `Vhost ${vhost} is not in the auto-approve allowlist.`));
301
+ return { state, effects };
302
+ }
303
+
228
304
  services.setVhost(state.account, vhost);
229
305
  effects.push(notice(state, `Vhost ${vhost} has been requested and approved.`));
230
306
  return { state, effects };
@@ -15,7 +15,7 @@ export {
15
15
  userReducer,
16
16
  } from './registration.js';
17
17
  export { quitReducer } from './quit.js';
18
- export { handleJoinZero, isValidChannelName, joinReducer } from './join.js';
18
+ export { handleJoinZero, joinReducer } from './join.js';
19
19
  export { partReducer } from './part.js';
20
20
  export { motdReducer } from './motd.js';
21
21
  export { operReducer, matchOperCred } from './oper.js';
@@ -61,6 +61,7 @@ export { tagmsgChannelReducer, tagmsgUserReducer } from './tagmsg.js';
61
61
  export { markreadChannelReducer } from './markread.js';
62
62
  export {
63
63
  DEFAULT_MULTILINE_MAX_BYTES,
64
+ DEFAULT_MULTILINE_MAX_ENTRIES,
64
65
  MULTILINE_CAP,
65
66
  type FinalizeMultilineResult,
66
67
  type MultilineEntry,
@@ -23,19 +23,13 @@
23
23
  import { caseFold } from '../case-fold.js';
24
24
  import { Effect } from '../effects.js';
25
25
  import type { Effect as EffectType, RawLine } from '../effects.js';
26
+ import { isValidChannelName } from '../protocol/channel-name.js';
26
27
  import { Numerics } from '../protocol/numerics.js';
27
28
  import type { ChannelState } from '../state/channel.js';
28
29
  import { hostmaskOf } from '../state/connection.js';
29
30
  import type { ConnectionState } from '../state/connection.js';
30
31
  import type { Ctx, Reducer } from '../types.js';
31
32
 
32
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
33
-
34
- function isValidChannelName(name: string, maxLen: number): boolean {
35
- if (name.length === 0 || name.length > maxLen) return false;
36
- return CHANNEL_NAME_RE.test(name);
37
- }
38
-
39
33
  /** Returns the caller's hostmask, falling back to nick then `?`. */
40
34
  function callerHostmask(conn: ConnectionState): string {
41
35
  return hostmaskOf(conn) ?? conn.nick ?? '?';
@@ -20,6 +20,7 @@ import { Effect } from '../effects.js';
20
20
  import type { Effect as EffectType, RawLine } from '../effects.js';
21
21
  import type { PersistedTopic, StoredMessage } from '../ports.js';
22
22
  import { wrapBatch } from '../protocol/batch.js';
23
+ import { isValidChannelName } from '../protocol/channel-name.js';
23
24
  import { Numerics } from '../protocol/numerics.js';
24
25
  import { type ChanName, type ChannelState, type ChannelTopic, prefixOf } from '../state/channel.js';
25
26
  import { hostmaskOf } from '../state/connection.js';
@@ -29,22 +30,6 @@ import { CHANSERV_HOSTMASK, DEFAULT_CHANNEL_LEVELS } from './chanserv.js';
29
30
  import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
30
31
  import { topicNumerics } from './topic.js';
31
32
 
32
- /**
33
- * RFC 1459/2812 channel-name grammar. The first character must be a channel
34
- * prefix (`#` or `&`); the rest may be any printable char except space, comma,
35
- * or colon (which all carry protocol meaning).
36
- */
37
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
38
-
39
- /**
40
- * Returns true iff `name` matches the IRC channel-name grammar and is within
41
- * the configured length cap.
42
- */
43
- export function isValidChannelName(name: string, maxLen: number): boolean {
44
- if (name.length === 0 || name.length > maxLen) return false;
45
- return CHANNEL_NAME_RE.test(name);
46
- }
47
-
48
33
  /**
49
34
  * Returns the 353 NAMES sigil for a channel given its modes:
50
35
  * `@` for `+p` (private), `*` for `+s` (secret), `=` otherwise (public).
@@ -16,20 +16,13 @@
16
16
  import { caseFold } from '../case-fold.js';
17
17
  import { Effect } from '../effects.js';
18
18
  import type { Effect as EffectType, RawLine } from '../effects.js';
19
+ import { isValidChannelName } from '../protocol/channel-name.js';
19
20
  import { Numerics } from '../protocol/numerics.js';
20
21
  import type { ChannelState } from '../state/channel.js';
21
22
  import { hostmaskOf } from '../state/connection.js';
22
23
  import type { ConnectionState } from '../state/connection.js';
23
24
  import type { Ctx, Reducer } from '../types.js';
24
25
 
25
- /** Channel-name validation mirrors JOIN: same grammar, same length cap. */
26
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
27
-
28
- function isValidChannelName(name: string, maxLen: number): boolean {
29
- if (name.length === 0 || name.length > maxLen) return false;
30
- return CHANNEL_NAME_RE.test(name);
31
- }
32
-
33
26
  /**
34
27
  * Formats a single numeric error line addressed to the connection's current
35
28
  * nick (or `*` when unregistered). Optional `middle` is emitted before the
@@ -29,18 +29,11 @@
29
29
  import { caseFold } from '../case-fold.js';
30
30
  import { Effect } from '../effects.js';
31
31
  import type { Effect as EffectType, RawLine } from '../effects.js';
32
+ import { isValidChannelName } from '../protocol/channel-name.js';
32
33
  import { Numerics } from '../protocol/numerics.js';
33
34
  import type { ChanSnapshot } from '../state/channel.js';
34
35
  import type { Ctx, Reducer } from '../types.js';
35
36
 
36
- /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
37
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
38
-
39
- function isValidChannelName(name: string, maxLen: number): boolean {
40
- if (name.length === 0 || name.length > maxLen) return false;
41
- return CHANNEL_NAME_RE.test(name);
42
- }
43
-
44
37
  /**
45
38
  * Returns true iff the requester is permitted to see `chan` in LIST output.
46
39
  * Secret channels require membership; everything else is public.
@@ -31,6 +31,7 @@
31
31
  import { caseFold } from '../case-fold.js';
32
32
  import { Effect } from '../effects.js';
33
33
  import type { Effect as EffectType, RawLine } from '../effects.js';
34
+ import { isValidChannelName } from '../protocol/channel-name.js';
34
35
  import { Numerics } from '../protocol/numerics.js';
35
36
  import type { BooleanChannelMode, ChannelState } from '../state/channel.js';
36
37
  import type {
@@ -45,14 +46,6 @@ import type { ConnectionState } from '../state/connection.js';
45
46
  import type { Ctx, Reducer } from '../types.js';
46
47
  import { positiveMlockLetters } from './chanserv.js';
47
48
 
48
- /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
49
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
50
-
51
- function isValidChannelName(name: string, maxLen: number): boolean {
52
- if (name.length === 0 || name.length > maxLen) return false;
53
- return CHANNEL_NAME_RE.test(name);
54
- }
55
-
56
49
  /** Returns the caller's hostmask, falling back to nick then `?`. */
57
50
  function callerHostmask(conn: ConnectionState): string {
58
51
  return hostmaskOf(conn) ?? conn.nick ?? '?';
@@ -26,11 +26,13 @@
26
26
 
27
27
  import {
28
28
  DEFAULT_MULTILINE_MAX_BYTES,
29
+ DEFAULT_MULTILINE_MAX_ENTRIES,
29
30
  MULTILINE_CAP_NAME,
30
31
  multilineCapValue,
31
32
  } from '../caps/capabilities.js';
32
33
  import { Effect } from '../effects.js';
33
34
  import type { Effect as EffectType, RawLine } from '../effects.js';
35
+ import { byteLength } from '../protocol/bytes.js';
34
36
  import { hostmaskOf } from '../state/connection.js';
35
37
  import type { Ctx } from '../types.js';
36
38
 
@@ -41,7 +43,7 @@ import type { Ctx } from '../types.js';
41
43
  */
42
44
  export const MULTILINE_CAP = MULTILINE_CAP_NAME;
43
45
 
44
- export { DEFAULT_MULTILINE_MAX_BYTES, multilineCapValue };
46
+ export { DEFAULT_MULTILINE_MAX_BYTES, DEFAULT_MULTILINE_MAX_ENTRIES, multilineCapValue };
45
47
 
46
48
  /**
47
49
  * One accumulated inner line of a multi-line batch. The actor collects these
@@ -79,21 +81,13 @@ export function multilineByteLength(entries: readonly MultilineEntry[]): number
79
81
  if (entries.length === 0) return 0;
80
82
  let total = 0;
81
83
  for (const entry of entries) {
82
- total += utf8ByteLength(entry.text);
84
+ total += byteLength(entry.text);
83
85
  }
84
86
  // `(n - 1)` separators join `n` entries.
85
87
  total += entries.length - 1;
86
88
  return total;
87
89
  }
88
90
 
89
- /** Shared `TextEncoder` for UTF-8 byte-length accounting (Web standard). */
90
- const TEXT_ENCODER = new TextEncoder();
91
-
92
- /** Returns the UTF-8 byte length of `s` (the wire encoding for IRC content). */
93
- function utf8ByteLength(s: string): number {
94
- return TEXT_ENCODER.encode(s).length;
95
- }
96
-
97
91
  /** Resolves the sender's hostmask, falling back to nick then `?`. */
98
92
  function senderHostmask(conn: Ctx['connection']): string {
99
93
  return hostmaskOf(conn) ?? conn.nick ?? '?';
@@ -31,17 +31,22 @@
31
31
  import { Effect } from '../effects.js';
32
32
  import type { Effect as EffectType, RawLine } from '../effects.js';
33
33
  import { wrapBatch } from '../protocol/batch.js';
34
+ import { byteLength } from '../protocol/bytes.js';
35
+ import { isValidChannelName } from '../protocol/channel-name.js';
34
36
  import { Numerics } from '../protocol/numerics.js';
35
37
  import type { ChannelState, RosterEntry } from '../state/channel.js';
36
38
  import type { Ctx, Reducer } from '../types.js';
37
39
 
38
- /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
39
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
40
+ /**
41
+ * RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing
42
+ * CR-LF. Transports append `\r\n` after the text produced here, so the
43
+ * budget for the line text itself is 512 − 2 = 510 bytes. Declared locally
44
+ * (mirroring `cap.ts` / `motd-lines.ts`) to keep this module self-contained.
45
+ */
46
+ const MAX_LINE_BYTES = 512;
40
47
 
41
- function isValidChannelName(name: string, maxLen: number): boolean {
42
- if (name.length === 0 || name.length > maxLen) return false;
43
- return CHANNEL_NAME_RE.test(name);
44
- }
48
+ /** Length of the trailing CR-LF the wire layer appends to each line. */
49
+ const TRAILING_CRLF_LEN = 2;
45
50
 
46
51
  /** Formats a numeric error/reply line addressed to the connection's nick. */
47
52
  function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
@@ -90,14 +95,50 @@ function prefixFormatter(ctx: Ctx): (entry: RosterEntry) => string {
90
95
  return ctx.connection.caps.has('multi-prefix') ? allPrefixes : highestPrefix;
91
96
  }
92
97
 
93
- /** Builds the space-separated NAMES list with mode prefixes for a channel. */
94
- function buildNamesList(state: ChannelState, ctx: Ctx): string {
98
+ /**
99
+ * Greedy-packs the formatted member list into one or more `353 RPL_NAMREPLY`
100
+ * lines so every emitted line fits the 510-byte text budget (RFC 2812 §3.2.5:
101
+ * long NAMES lists are split across multiple `353` replies rather than
102
+ * truncated mid-nick by the outbound line limiter).
103
+ *
104
+ * The full line is measured: the fixed `:{server} 353 {nick} {sigil} {chan} :`
105
+ * header, each member's mode-prefix bytes (`@`/`+`), the nicks themselves
106
+ * (UTF-8 bytes, not UTF-16 code units), and the separating spaces. A member
107
+ * that alone overflows the budget (only possible with pathological config —
108
+ * nicks are capped at `nickLen`) still gets its own line rather than being
109
+ * dropped, leaving any last-resort truncation to the outbound limiter.
110
+ *
111
+ * A channel with no members still yields exactly one `353` with an empty
112
+ * trailing parameter, so the reply shape (`353`…`366`) is stable for clients.
113
+ */
114
+ function packNamesLines(state: ChannelState, ctx: Ctx, nick: string, sigil: string): RawLine[] {
95
115
  const format = prefixFormatter(ctx);
96
- const names: string[] = [];
116
+ const header = `:${ctx.serverName} 353 ${nick} ${sigil} ${state.name} :`;
117
+ const budget = MAX_LINE_BYTES - TRAILING_CRLF_LEN;
118
+ const headerBytes = byteLength(header);
119
+
120
+ const lines: RawLine[] = [];
121
+ let current = '';
122
+ let usedBytes = 0;
123
+ let empty = true;
124
+
97
125
  for (const entry of state.members.values()) {
98
- names.push(format(entry) + entry.nick);
126
+ const token = format(entry) + entry.nick;
127
+ const tokenBytes = byteLength(token);
128
+ // One extra byte for the space separator when appending to a non-empty list.
129
+ const costBytes = empty ? tokenBytes : tokenBytes + 1;
130
+ if (!empty && headerBytes + usedBytes + costBytes > budget) {
131
+ lines.push({ text: `${header}${current}` });
132
+ current = token;
133
+ usedBytes = tokenBytes;
134
+ } else {
135
+ current = empty ? token : `${current} ${token}`;
136
+ usedBytes += costBytes;
137
+ }
138
+ empty = false;
99
139
  }
100
- return names.join(' ');
140
+ lines.push({ text: `${header}${current}` });
141
+ return lines;
101
142
  }
102
143
 
103
144
  /**
@@ -143,9 +184,8 @@ export const namesReducer: Reducer<ChannelState> = (state, msg, ctx) => {
143
184
 
144
185
  const nick = ctx.connection.nick ?? '*';
145
186
  const sigil = namesSigil(state);
146
- const names = buildNamesList(state, ctx);
147
187
  const replyLines: RawLine[] = [
148
- { text: `:${ctx.serverName} 353 ${nick} ${sigil} ${state.name} :${names}` },
188
+ ...packNamesLines(state, ctx, nick, sigil),
149
189
  { text: `:${ctx.serverName} 366 ${nick} ${state.name} :End of /NAMES list.` },
150
190
  ];
151
191
 
@@ -29,7 +29,12 @@
29
29
  * the {@link MessageStore} pattern).
30
30
  */
31
31
 
32
- import { DEFAULT_MIN_PASSWORD_LENGTH, DEFAULT_NICK_ENFORCE_GRACE_MS } from '../config.js';
32
+ import {
33
+ DEFAULT_IDENTIFY_FREEZE_SECONDS,
34
+ DEFAULT_MAX_IDENTIFY_FAILURES,
35
+ DEFAULT_MIN_PASSWORD_LENGTH,
36
+ DEFAULT_NICK_ENFORCE_GRACE_MS,
37
+ } from '../config.js';
33
38
  import { Effect } from '../effects.js';
34
39
  import type { Effect as EffectType, RawLine } from '../effects.js';
35
40
  import type { NickEnforcePolicy, ServicesStore } from '../ports.js';
@@ -72,6 +77,17 @@ const ENFORCE_GATE_NOTICE = 'You must identify before changing enforcement.';
72
77
  */
73
78
  const PASSWORD_GATE_NOTICE = 'You must identify before changing your password.';
74
79
 
80
+ /**
81
+ * Formats the owner NOTICE delivered on the first successful identify
82
+ * after failed identifies were recorded against the account. Delivered
83
+ * once (the queued count is cleared with the delivery), so the owner
84
+ * learns their account was targeted even though every attempt failed.
85
+ */
86
+ function identifyFailureNoticeText(count: number): string {
87
+ const plural = count === 1 ? 'attempt' : 'attempts';
88
+ return `Warning: ${count} failed identify ${plural} recorded against your account since your last successful login.`;
89
+ }
90
+
75
91
  /**
76
92
  * Reducer for `PRIVMSG NickServ :<subcommand> <args…>`.
77
93
  * Authority: the invoking connection's {@link ConnectionState}.
@@ -181,13 +197,36 @@ function handleIdentify(
181
197
  return { state, effects };
182
198
  }
183
199
 
200
+ // Per-account freeze: once maxIdentifyFailures failed attempts were
201
+ // recorded within the sliding freeze window, reject BEFORE running
202
+ // scrypt (the freeze is the cheap rejection path; verifyNick is the
203
+ // expensive one an attacker would otherwise keep hitting).
204
+ const maxFailures =
205
+ ctx.serverConfig.nickServ?.maxIdentifyFailures ?? DEFAULT_MAX_IDENTIFY_FAILURES;
206
+ const freezeWindowMs =
207
+ (ctx.serverConfig.nickServ?.identifyFreezeSeconds ?? DEFAULT_IDENTIFY_FREEZE_SECONDS) * 1000;
208
+ if (services.isNickIdentifyFrozen(nick, { maxFailures, windowMs: freezeWindowMs })) {
209
+ effects.push(
210
+ notice(state, `Nick ${nick} is temporarily frozen due to too many failed identify attempts.`),
211
+ );
212
+ return { state, effects };
213
+ }
214
+
184
215
  const result = services.verifyNick(nick, password);
185
216
  if (!result.ok) {
217
+ services.recordIdentifyFailure(nick);
186
218
  effects.push(notice(state, 'Invalid password.'));
187
219
  return { state, effects };
188
220
  }
189
221
 
222
+ // A successful login delivers the queued owner notice (if any failed
223
+ // identifies were recorded since the last successful login) and resets
224
+ // the per-account failure accounting.
225
+ const failedSinceLogin = services.clearIdentifyFailures(nick);
190
226
  applyIdentify(state, result.account, services, effects);
227
+ if (failedSinceLogin > 0) {
228
+ effects.push(notice(state, identifyFailureNoticeText(failedSinceLogin)));
229
+ }
191
230
  return { state, effects };
192
231
  }
193
232