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
@@ -239,15 +239,42 @@ export class AwsRuntime implements IrcRuntime {
239
239
  * phase — the APIGW call count is bounded by the genuine recipient set.
240
240
  */
241
241
  async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
242
+ await this.fanOutByUserMode(lines, except, 'wallops');
243
+ }
244
+
245
+ /**
246
+ * Oper-only notice fanout (the OPER reducer's per-IP lockout notice).
247
+ * Identical enumeration to {@link broadcastWallops} but the recipient
248
+ * gate is user mode `+o`: a filtered `Scan` of the `Connections` table
249
+ * followed by a `PostToConnection` per oper. The same cost note applies
250
+ * — the scan reads the whole table — but lockout notices only fire when
251
+ * a host trips the failed-OPER budget, so the rate is bounded by
252
+ * attacker behaviour, not by legitimate traffic.
253
+ */
254
+ async broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void> {
255
+ await this.fanOutByUserMode(lines, except, 'oper');
256
+ }
257
+
258
+ /**
259
+ * Shared fan-out for {@link broadcastWallops} / {@link broadcastOperNotice}:
260
+ * paginated filtered `Scan` over `userModes.<mode>`, skipping `except`,
261
+ * delivering via `send` (which short-cuts the bound connection through
262
+ * the in-process handlers and tolerates vanished sockets).
263
+ */
264
+ private async fanOutByUserMode(
265
+ lines: RawLine[],
266
+ except: ConnId | undefined,
267
+ mode: 'wallops' | 'oper',
268
+ ): Promise<void> {
242
269
  let startKey: Record<string, NativeAttributeValue> | undefined;
243
270
  // Paginate the scan: DynamoDB caps a single Scan at 1 MB. Loop until
244
- // no `LastEvaluatedKey` remains so every +w connection is reached.
271
+ // no `LastEvaluatedKey` remains so every matching connection is reached.
245
272
  do {
246
273
  const result = await this.dynamo.send(
247
274
  new ScanCommand({
248
275
  TableName: this.tables.Connections,
249
276
  FilterExpression: 'userModes.#m = :true',
250
- ExpressionAttributeNames: { '#m': 'wallops' },
277
+ ExpressionAttributeNames: { '#m': mode },
251
278
  ExpressionAttributeValues: { ':true': true },
252
279
  ...(startKey !== undefined ? { ExclusiveStartKey: startKey } : {}),
253
280
  }),
@@ -259,7 +286,7 @@ export class AwsRuntime implements IrcRuntime {
259
286
  // `send` short-cuts the bound connection through the in-process
260
287
  // handlers and catches GoneException so vanished sockets are
261
288
  // cleaned up rather than aborting the fan-out mid-loop.
262
- if (row.userModes.wallops) {
289
+ if (mode === 'wallops' ? row.userModes.wallops : row.userModes.oper) {
263
290
  await this.send(connId, lines);
264
291
  }
265
292
  }
@@ -27,13 +27,25 @@
27
27
  * are reaped automatically in addition to the explicit sweeper.
28
28
  */
29
29
 
30
- import { AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
31
- import type { TableProps } from 'aws-cdk-lib/aws-dynamodb';
32
- import type { TableName } from './tables.js';
30
+ import { AttributeType, BillingMode, ProjectionType } from 'aws-cdk-lib/aws-dynamodb';
31
+ import type { GlobalSecondaryIndexProps, TableProps } from 'aws-cdk-lib/aws-dynamodb';
32
+ import { SOURCE_IP_INDEX_NAME, type TableName } from './tables.js';
33
33
 
34
34
  /**
35
- * All four tables keyed by their logical construct id. The id doubles as the
36
- * CloudFormation logical id; the physical `TableName` is set on each entry.
35
+ * A table definition: the CDK `TableProps` plus an optional side-channel
36
+ * for secondary indexes. Current aws-cdk-lib moved GSI declaration out
37
+ * of `TableProps` (indexes are added via `Table.addGlobalSecondaryIndex`
38
+ * after construction), so the index list rides alongside the construct
39
+ * props here — the stack pops it before constructing the `Table` and the
40
+ * DynamoDB Local fixtures read it when issuing raw `CreateTableCommand`s.
41
+ */
42
+ export interface TableDef extends TableProps {
43
+ globalSecondaryIndexes?: GlobalSecondaryIndexProps[];
44
+ }
45
+
46
+ /**
47
+ * All four tables keyed by their logical construct id. The id doubles as
48
+ * the CloudFormation logical id; the physical `TableName` is set on each entry.
37
49
  *
38
50
  * NOTE: the deployed physical name is **environment-prefixed** by
39
51
  * `IrcAwsStack` (`<Env><LogicalId>`, e.g. `StagingConnections`) — the
@@ -42,12 +54,28 @@ import type { TableName } from './tables.js';
42
54
  * every physical name (to isolate parallel runs) consume the
43
55
  * `TABLE_DEFS` entries' key/attribute names only.
44
56
  */
45
- export const TABLE_DEFS: Record<TableName, TableProps> = {
57
+ export const TABLE_DEFS: Record<TableName, TableDef> = {
46
58
  Connections: {
47
59
  tableName: 'Connections',
48
60
  partitionKey: { name: 'connectionId', type: AttributeType.STRING },
49
61
  billingMode: BillingMode.PAY_PER_REQUEST,
50
62
  timeToLiveAttribute: 'idleSince',
63
+ // Per-IP admission index: `$connect` counts an IP's live connections
64
+ // (maxConnectionsPerIp) and its in-window establishments
65
+ // (perIpConnectionRate) against this GSI; the NLB new-flow gate
66
+ // counts the window. Rows surface in the index only when both key
67
+ // attributes are present (handlers stamp `sourceIp` at
68
+ // connect/flow-establishment; legacy rows are invisible — the caps
69
+ // apply from this change forward). TTL-reaped rows leave the index
70
+ // with them, so the index self-cleans.
71
+ globalSecondaryIndexes: [
72
+ {
73
+ indexName: SOURCE_IP_INDEX_NAME,
74
+ partitionKey: { name: 'sourceIp', type: AttributeType.STRING },
75
+ sortKey: { name: 'connectedSince', type: AttributeType.NUMBER },
76
+ projectionType: ProjectionType.ALL,
77
+ },
78
+ ],
51
79
  },
52
80
  ChannelMeta: {
53
81
  tableName: 'ChannelMeta',
@@ -11,6 +11,15 @@
11
11
 
12
12
  import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
13
13
 
14
+ /**
15
+ * Documented schema defaults for `perIpConnectionRate`. Duplicated here
16
+ * (the schema inlines its `.default(...)`) so an operator setting only
17
+ * one half of the knob via env gets the documented default for the other
18
+ * half rather than a parse error.
19
+ */
20
+ const DEFAULT_PER_IP_RATE_MAX = 5;
21
+ const DEFAULT_PER_IP_RATE_WINDOW_MS = 60_000;
22
+
14
23
  /**
15
24
  * The subset of `process.env` this loader reads. Optional members are
16
25
  * omitted from the input object when undefined so the schema's defaults
@@ -35,6 +44,20 @@ export interface LambdaConfigEnv {
35
44
  CHANNEL_PREFIXES?: string;
36
45
  OPER_USER?: string;
37
46
  OPER_PASSWORD?: string;
47
+ /**
48
+ * Scrypt salt (base64) for the hashed oper credential form. When set
49
+ * alongside `OPER_USER` + `OPER_HASH`, the loader builds a hashed
50
+ * `{user, salt, hash}` cred instead of the legacy plaintext
51
+ * `{user, password}` shape. The hashed form is preferred when both
52
+ * are present so deployments can rotate without removing the legacy
53
+ * vars in the same step. Generate via `tools/hash-oper-cred.ts`.
54
+ */
55
+ OPER_SALT?: string;
56
+ /**
57
+ * Scrypt hash (base64) for the hashed oper credential form. See
58
+ * `OPER_SALT` for the full set of vars.
59
+ */
60
+ OPER_HASH?: string;
38
61
  /**
39
62
  * Server-password gate. Treat as an AWS secret (Secrets Manager or
40
63
  * SSM `SecureString`); the loader threads it through to
@@ -74,6 +97,41 @@ export interface LambdaConfigEnv {
74
97
  * send `Origin` and always pass through regardless.
75
98
  */
76
99
  WEB_ORIGINS?: string;
100
+ /**
101
+ * Per-connection inbound frame window ceiling enforced by the
102
+ * `$default` handler BEFORE the actor / DynamoDB write. Parsed into
103
+ * `ServerConfig.adapter.maxFramesPerWindow`.
104
+ */
105
+ MAX_FRAMES_PER_WINDOW?: string;
106
+ /**
107
+ * Sliding-window length (seconds) for the inbound frame limit; frames
108
+ * older than the window stop counting. Parsed into
109
+ * `ServerConfig.adapter.frameWindowSeconds`.
110
+ */
111
+ FRAME_WINDOW_SECONDS?: string;
112
+ /**
113
+ * Per-IP simultaneous-connection cap (`maxConnectionsPerIp`). Enforced
114
+ * by the `$connect` admission gate against the `sourceIp` GSI; mirrors
115
+ * the CF loader's var of the same name.
116
+ */
117
+ MAX_CONNECTIONS_PER_IP?: string;
118
+ /**
119
+ * Per-IP connection-rate budget (`perIpConnectionRate.max`) — how many
120
+ * new connections one source IP may open per sliding window. Enforced
121
+ * by `$connect` and the NLB new-flow gate. The unset half of the knob
122
+ * falls back to the schema default.
123
+ */
124
+ PER_IP_CONNECTION_RATE_MAX?: string;
125
+ /** Sliding window length in ms (`perIpConnectionRate.windowMs`). */
126
+ PER_IP_CONNECTION_RATE_WINDOW_MS?: string;
127
+ /**
128
+ * Operator opt-in for certificate-backed SASL EXTERNAL. 'true'/'1'
129
+ * (case-insensitive) map to `sasl.externalEnabled: true`; any other
130
+ * value (and unset) leaves the mechanism off. EXTERNAL additionally
131
+ * requires edge mTLS (APIGW client certs on the custom domain) and a
132
+ * secure (TLS) connection — the flag alone advertises nothing.
133
+ */
134
+ EXTERNAL_ENABLED?: string;
77
135
  }
78
136
 
79
137
  /**
@@ -105,12 +163,36 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
105
163
  input.channelPrefixes = env.CHANNEL_PREFIXES;
106
164
  }
107
165
  if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
108
- input.operCreds = [
109
- {
110
- user: env.OPER_USER ?? '',
111
- password: env.OPER_PASSWORD ?? '',
112
- },
113
- ];
166
+ // Hashed form (OPER_USER + OPER_SALT + OPER_HASH) is preferred when
167
+ // both salt + hash are supplied — even if OPER_PASSWORD is also set,
168
+ // so a deployment can rotate to the hashed form without dropping the
169
+ // legacy var in the same release. The legacy plaintext
170
+ // `{user, password}` shape is retained for a single deprecation-
171
+ // cycle window.
172
+ const hasHashed = env.OPER_SALT !== undefined && env.OPER_HASH !== undefined;
173
+ if (hasHashed) {
174
+ input.operCreds = [
175
+ {
176
+ user: env.OPER_USER ?? '',
177
+ salt: env.OPER_SALT ?? '',
178
+ hash: env.OPER_HASH ?? '',
179
+ },
180
+ ];
181
+ } else {
182
+ // Build the legacy cred object from whatever was supplied; the
183
+ // schema surfaces a `user`/`password` error if only one half was
184
+ // provided. If only OPER_SALT/OPER_HASH (partial) are set, the
185
+ // schema will surface the missing hashed-form field via the
186
+ // superRefine error path.
187
+ input.operCreds = [
188
+ {
189
+ user: env.OPER_USER ?? '',
190
+ password: env.OPER_PASSWORD ?? '',
191
+ ...(env.OPER_SALT !== undefined ? { salt: env.OPER_SALT } : {}),
192
+ ...(env.OPER_HASH !== undefined ? { hash: env.OPER_HASH } : {}),
193
+ },
194
+ ];
195
+ }
114
196
  }
115
197
  if (env.SERVER_PASSWORD !== undefined) {
116
198
  input.serverPassword = env.SERVER_PASSWORD;
@@ -139,9 +221,55 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
139
221
  if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
140
222
  input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
141
223
  }
224
+ if (env.MAX_FRAMES_PER_WINDOW !== undefined || env.FRAME_WINDOW_SECONDS !== undefined) {
225
+ // Partial sections are fine: the per-field schema defaults fill the
226
+ // knob(s) the env did not supply.
227
+ input.adapter = {
228
+ ...(env.MAX_FRAMES_PER_WINDOW !== undefined
229
+ ? { maxFramesPerWindow: Number.parseInt(env.MAX_FRAMES_PER_WINDOW, 10) }
230
+ : {}),
231
+ ...(env.FRAME_WINDOW_SECONDS !== undefined
232
+ ? { frameWindowSeconds: Number.parseInt(env.FRAME_WINDOW_SECONDS, 10) }
233
+ : {}),
234
+ };
235
+ }
236
+ if (env.MAX_CONNECTIONS_PER_IP !== undefined) {
237
+ input.maxConnectionsPerIp = Number.parseInt(env.MAX_CONNECTIONS_PER_IP, 10);
238
+ }
239
+ if (
240
+ env.PER_IP_CONNECTION_RATE_MAX !== undefined ||
241
+ env.PER_IP_CONNECTION_RATE_WINDOW_MS !== undefined
242
+ ) {
243
+ // The schema requires both halves; the unset one falls back to the
244
+ // documented schema defaults (mirrors the CF loader's vars of the
245
+ // same names so operators tune both platforms identically).
246
+ input.perIpConnectionRate = {
247
+ max:
248
+ env.PER_IP_CONNECTION_RATE_MAX !== undefined
249
+ ? Number.parseInt(env.PER_IP_CONNECTION_RATE_MAX, 10)
250
+ : DEFAULT_PER_IP_RATE_MAX,
251
+ windowMs:
252
+ env.PER_IP_CONNECTION_RATE_WINDOW_MS !== undefined
253
+ ? Number.parseInt(env.PER_IP_CONNECTION_RATE_WINDOW_MS, 10)
254
+ : DEFAULT_PER_IP_RATE_WINDOW_MS,
255
+ };
256
+ }
257
+ if (env.EXTERNAL_ENABLED !== undefined) {
258
+ input.sasl = { externalEnabled: parseEnabledFlag(env.EXTERNAL_ENABLED) };
259
+ }
142
260
  return input;
143
261
  }
144
262
 
263
+ /**
264
+ * Parses a boolean feature-flag env var: 'true' / '1' (case-insensitive,
265
+ * whitespace-trimmed) are truthy; every other value is false. Flags
266
+ * default off, so a typo must never silently enable the feature.
267
+ */
268
+ function parseEnabledFlag(raw: string): boolean {
269
+ const normalized = raw.trim().toLowerCase();
270
+ return normalized === 'true' || normalized === '1';
271
+ }
272
+
145
273
  /**
146
274
  * Parses the `CREATED_AT` env var into the schema's `createdAt` field.
147
275
  *
@@ -199,6 +199,8 @@ export class DynamoServicesStore extends PersistentServicesStore {
199
199
  createdAt: row.createdAt,
200
200
  enforce: row.enforce,
201
201
  certSubjects: row.certSubjects ?? [],
202
+ identifyFailures: row.identifyFailures ?? [],
203
+ pendingIdentifyFailureNotice: row.pendingIdentifyFailureNotice ?? 0,
202
204
  algorithm: row.credential.algorithm,
203
205
  salt: row.credential.salt,
204
206
  hash: row.credential.hash,
@@ -542,6 +544,14 @@ function coerceNick(item: Record<string, unknown>): ServicesNickRow | undefined
542
544
  const certSubjects = Array.isArray(item.certSubjects)
543
545
  ? item.certSubjects.filter((s): s is string => typeof s === 'string')
544
546
  : [];
547
+ // The identify-throttle fields are additive in the same way: absent (a
548
+ // pre-throttle item) or ill-typed attributes collapse to the neutral
549
+ // unfrozen defaults.
550
+ const identifyFailures = Array.isArray(item.identifyFailures)
551
+ ? item.identifyFailures.filter((t): t is number => typeof t === 'number')
552
+ : [];
553
+ const pendingIdentifyFailureNotice =
554
+ typeof item.pendingIdentifyFailureNotice === 'number' ? item.pendingIdentifyFailureNotice : 0;
545
555
  return {
546
556
  nick: item.nick,
547
557
  account: item.account,
@@ -549,6 +559,8 @@ function coerceNick(item: Record<string, unknown>): ServicesNickRow | undefined
549
559
  createdAt: item.createdAt,
550
560
  enforce: item.enforce as NickEnforcePolicy,
551
561
  certSubjects,
562
+ identifyFailures,
563
+ pendingIdentifyFailureNotice,
552
564
  credential: {
553
565
  account: item.account,
554
566
  algorithm: 'scrypt',
@@ -24,6 +24,7 @@ import {
24
24
  } from '@serverless-ircd/irc-core';
25
25
  import { type AdmissionOutcome, decideConnectAdmission } from '../admission.js';
26
26
  import { decrementConnectionCount, incrementConnectionCount } from '../connection-counter.js';
27
+ import { countConnectionsForIp } from '../ip-admission.js';
27
28
  import { decideConnectOrigin } from '../origin-allowlist.js';
28
29
  import { marshalConnection } from '../serialize.js';
29
30
  import type { TablesConfig } from '../tables.js';
@@ -61,6 +62,16 @@ export interface ConnectParams {
61
62
  * the Origin check (the default — defence is opt-in).
62
63
  */
63
64
  webOrigins?: Set<string> | undefined;
65
+ /**
66
+ * Source IP of the connecting client (`event.requestContext.identity
67
+ * .sourceIp` on APIGW). When present, the per-IP admission gates run:
68
+ * a paginated Query against the `sourceIp` GSI supplies the IP's live
69
+ * count (`maxConnectionsPerIp`) and its in-window count
70
+ * (`perIpConnectionRate`), and the previously-dormant per-IP branch
71
+ * of {@link decideConnectAdmission} goes live. Absent (older event
72
+ * shapes) the per-IP checks are skipped entirely.
73
+ */
74
+ sourceIp?: string | null | undefined;
64
75
  }
65
76
 
66
77
  /**
@@ -109,11 +120,40 @@ export async function handleConnect(params: ConnectParams): Promise<ConnectOutco
109
120
  return { admitted: false, statusCode: 403, reason: 'origin not allowed' };
110
121
  }
111
122
 
123
+ // Per-IP admission counts (GSI walk). Runs before the global counter
124
+ // reservation so an over-budget IP never churns the meta-row counter.
125
+ // An empty/absent source IP skips the per-IP gates (branch stays
126
+ // dormant, matching pre-GSI event shapes).
127
+ let perIp: number | undefined;
128
+ let recentPerIp: number | undefined;
129
+ if (typeof params.sourceIp === 'string' && params.sourceIp.length > 0) {
130
+ const ipCounts = await countConnectionsForIp(
131
+ params.dynamo,
132
+ params.tables.Connections,
133
+ params.sourceIp,
134
+ now,
135
+ params.serverConfig.perIpConnectionRate.windowMs,
136
+ );
137
+ perIp = ipCounts.total;
138
+ recentPerIp = ipCounts.recent;
139
+ }
140
+
112
141
  // Reserve a slot atomically. The returned value includes this reservation,
113
142
  // so the pre-existing count (`total`, as expected by the policy) is one
114
143
  // less than the post-increment value.
115
144
  const reservedCount = await incrementConnectionCount(params.dynamo, params.tables.Connections);
116
- const outcome = decideConnectAdmission({ total: reservedCount - 1 }, { maxClients });
145
+ const outcome = decideConnectAdmission(
146
+ {
147
+ total: reservedCount - 1,
148
+ ...(perIp !== undefined ? { perIp } : {}),
149
+ ...(recentPerIp !== undefined ? { recentPerIp } : {}),
150
+ },
151
+ {
152
+ maxClients,
153
+ maxConnectionsPerIp: params.serverConfig.maxConnectionsPerIp,
154
+ perIpRate: params.serverConfig.perIpConnectionRate,
155
+ },
156
+ );
117
157
  if (!outcome.admitted) {
118
158
  // Over capacity — release the reservation so the counter does not drift
119
159
  // upward on every rejected connect.
@@ -144,6 +184,12 @@ export async function handleConnect(params: ConnectParams): Promise<ConnectOutco
144
184
  if (mode !== 'legacy') {
145
185
  row.wsMode = mode;
146
186
  }
187
+ // Persist the source IP so the `sourceIp` GSI carries this connection
188
+ // for future per-IP admission walks (rows without it are invisible to
189
+ // the index — see ip-admission.ts).
190
+ if (typeof params.sourceIp === 'string' && params.sourceIp.length > 0) {
191
+ row.sourceIp = params.sourceIp;
192
+ }
147
193
  try {
148
194
  await params.dynamo.send(
149
195
  new PutCommand({
@@ -15,6 +15,7 @@ import {
15
15
  type ChannelState,
16
16
  type Clock,
17
17
  type ConnectionState,
18
+ FRAME_FLOOD_ERROR_LINE,
18
19
  type IdFactory,
19
20
  type MessageStore,
20
21
  type MotdProvider,
@@ -25,6 +26,7 @@ import {
25
26
  SystemClock,
26
27
  UuidIdFactory,
27
28
  type WsFrameMode,
29
+ admitFrameWindow,
28
30
  createConnection,
29
31
  frameToLines,
30
32
  } from '@serverless-ircd/irc-core';
@@ -220,12 +222,50 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
220
222
  );
221
223
  return { statusCode: 410 };
222
224
  }
223
- const persisted = result.Item as unknown as MarshalledConnection;
225
+ const persisted = result.Item as unknown as MarshalledConnection & { frameTimes?: unknown };
224
226
  const state = unmarshalConnection(persisted);
225
227
  const expectedRevision = persisted.revision ?? 0;
226
228
  const wsMode: WsFrameMode = persisted.wsMode ?? 'legacy';
227
229
  wsModeHolder = wsMode;
228
230
 
231
+ // 1a. Adapter-boundary frame-rate gate. Each frame is a billed read +
232
+ // write (and the actor run); a hostile client must not be able to
233
+ // drive that cost unboundedly with low-cost commands. The sliding
234
+ // window persists on the row (admitted frames persist it atomically
235
+ // with the state write below), so the limit holds across
236
+ // invocations. An over-limit frame never reaches the actor: the
237
+ // canonical ERROR line is delivered, the socket is closed via
238
+ // DeleteConnection (which fires the $disconnect cleanup), and the
239
+ // invocation returns without a write.
240
+ const frameDecision = admitFrameWindow(
241
+ readFrameTimes(persisted),
242
+ clock.now(),
243
+ params.serverConfig.adapter.maxFramesPerWindow,
244
+ params.serverConfig.adapter.frameWindowSeconds * 1000,
245
+ );
246
+ if (!frameDecision.admitted) {
247
+ console.warn(
248
+ `[irc-handler] $default connId=${params.connectionId} frame limit exceeded ` +
249
+ `(${params.serverConfig.adapter.maxFramesPerWindow}/${params.serverConfig.adapter.frameWindowSeconds}s) -> closing`,
250
+ );
251
+ if (params.managementApi !== null) {
252
+ try {
253
+ await postOutbound(
254
+ params.managementApi,
255
+ params.connectionId,
256
+ [FRAME_FLOOD_ERROR_LINE],
257
+ wsMode,
258
+ );
259
+ await closeConnectionIfSupported(params.managementApi, params.connectionId);
260
+ } catch (err) {
261
+ // The socket may already be gone (close raced a concurrent
262
+ // over-limit frame) — surface for observability, nothing to retry.
263
+ console.error('[irc-handler] flood close post failed', err);
264
+ }
265
+ }
266
+ return { statusCode: 200 };
267
+ }
268
+
229
269
  // 2. Mark activity + run the actor. Self-sends reset per attempt.
230
270
  state.lastSeen = clock.now();
231
271
  outbound.length = 0;
@@ -235,16 +275,28 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
235
275
  console.error('[irc-handler] actor.receiveTextFrame failed', err);
236
276
  // Best-effort persist of partial state (swallow a conflict here —
237
277
  // the actor already failed, no point retrying).
238
- await persistState(params.dynamo, params.tables, state, clock.now(), expectedRevision).catch(
239
- () => {},
240
- );
278
+ await persistState(
279
+ params.dynamo,
280
+ params.tables,
281
+ state,
282
+ clock.now(),
283
+ expectedRevision,
284
+ frameDecision.times,
285
+ ).catch(() => {});
241
286
  await params.services?.flush();
242
287
  return { statusCode: 500 };
243
288
  }
244
289
 
245
290
  // 3. Conditional persist.
246
291
  try {
247
- await persistState(params.dynamo, params.tables, state, clock.now(), expectedRevision);
292
+ await persistState(
293
+ params.dynamo,
294
+ params.tables,
295
+ state,
296
+ clock.now(),
297
+ expectedRevision,
298
+ frameDecision.times,
299
+ );
248
300
  break; // committed — exit the retry loop.
249
301
  } catch (err: unknown) {
250
302
  if (isConditionalCheckFailed(err) && attempt < MAX_RETRIES) {
@@ -280,6 +332,35 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
280
332
  return { statusCode: 200 };
281
333
  }
282
334
 
335
+ /**
336
+ * Reads the persisted inbound frame-window timestamps off a Connections
337
+ * row. Rows written before the frame gate shipped (or by paths that do
338
+ * not track frames, e.g. NLB chunks) carry no attribute — an empty
339
+ * window starts the connection fresh.
340
+ */
341
+ function readFrameTimes(row: MarshalledConnection & { frameTimes?: unknown }): number[] {
342
+ const raw = row.frameTimes;
343
+ if (!Array.isArray(raw)) return [];
344
+ return raw as number[];
345
+ }
346
+
347
+ /**
348
+ * Closes the client's APIGW WebSocket via the management API
349
+ * (`DeleteConnection` — API Gateway observes the close and fires the
350
+ * `$disconnect` route, which runs the canonical QUIT cleanup). The
351
+ * `PostToConnection` test-double arm has no delete method; the close is
352
+ * skipped there (the ERROR post alone still informs the client).
353
+ */
354
+ async function closeConnectionIfSupported(
355
+ api: ApiGatewayManagementApi | PostToConnection,
356
+ connectionId: string,
357
+ ): Promise<void> {
358
+ const deleter = api as Partial<Pick<ApiGatewayManagementApi, 'deleteConnection'>>;
359
+ if (typeof deleter.deleteConnection === 'function') {
360
+ await deleter.deleteConnection({ ConnectionId: connectionId });
361
+ }
362
+ }
363
+
283
364
  /**
284
365
  * Delivers `lines` to `connectionId` via APIGW according to `mode`:
285
366
  * - `legacy` — all lines joined with `\r\n` in a single `postToConnection`.
@@ -324,8 +405,9 @@ async function persistState(
324
405
  state: ConnectionState,
325
406
  now: number,
326
407
  expectedRevision: number,
408
+ frameTimes: readonly number[],
327
409
  ): Promise<void> {
328
- const expr = buildPersistUpdate(state, now, expectedRevision);
410
+ const expr = buildPersistUpdate(state, now, expectedRevision, frameTimes);
329
411
  const hasNames = Object.keys(expr.expressionAttributeNames).length > 0;
330
412
  await dynamo.send(
331
413
  new UpdateCommand({
@@ -375,6 +457,7 @@ export function buildPersistUpdate(
375
457
  state: ConnectionState,
376
458
  now: number,
377
459
  expectedRevision: number,
460
+ frameTimes: readonly number[],
378
461
  ): {
379
462
  updateExpression: string;
380
463
  conditionExpression: string;
@@ -389,6 +472,11 @@ export function buildPersistUpdate(
389
472
  ':ls': state.lastSeen,
390
473
  ':is': now,
391
474
  ':v': CONNECTION_VERSION,
475
+ // Sliding-window timestamps for the adapter-boundary frame limit.
476
+ // Committed atomically with the state write (same revision
477
+ // condition), so each frame is counted exactly once even when
478
+ // concurrent invocations race through the retry loop.
479
+ ':ft': [...frameTimes],
392
480
  // Optimistic-concurrency: increment the revision counter, gated by
393
481
  // a condition that the row is still at the revision we read.
394
482
  ':revInc': 1,
@@ -402,6 +490,7 @@ export function buildPersistUpdate(
402
490
  'lastSeen = :ls',
403
491
  'idleSince = :is',
404
492
  'version = :v',
493
+ 'frameTimes = :ft',
405
494
  ];
406
495
  const removeNames: string[] = [];
407
496
  const nameMap: Record<string, string> = { '#rev': 'revision' };
@@ -147,7 +147,12 @@ export interface HandlerDeps {
147
147
  * construct events directly.
148
148
  */
149
149
  export type ConnectEvent = {
150
- requestContext: { routeKey: '$connect'; connectionId: string };
150
+ requestContext: {
151
+ routeKey: '$connect';
152
+ connectionId: string;
153
+ /** Client source IP — feeds the per-IP admission gates. */
154
+ identity?: { sourceIp?: string };
155
+ };
151
156
  headers?: Record<string, string>;
152
157
  };
153
158
  export type DisconnectEvent = {
@@ -206,6 +211,10 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
206
211
  // cannot silently bypass the CSWSH defence.
207
212
  originHeader: readHeader(event.headers, 'Origin'),
208
213
  webOrigins: deps.webOrigins,
214
+ // Per-IP admission: APIGW stamps the client IP on every
215
+ // `$connect` event; absent (older shapes) the per-IP gates
216
+ // stay dormant.
217
+ sourceIp: event.requestContext.identity?.sourceIp,
209
218
  });
210
219
  if (outcome.admitted) {
211
220
  if (outcome.subprotocol !== null) {
@@ -341,7 +350,24 @@ export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStream
341
350
  cachedDeps = await cachedDepsPromise;
342
351
  cachedDepsPromise = undefined;
343
352
  }
344
- const deps = cachedDeps;
353
+ return dispatchNlbStream(event, cachedDeps);
354
+ }
355
+
356
+ /**
357
+ * Pure NLB dispatcher — exported for unit tests that inject deps directly
358
+ * (mirrors {@link dispatch} on the wss path).
359
+ *
360
+ * The CDK stack wires this Lambda target exclusively behind a
361
+ * `Protocol.TLS` NLB listener (an ACM-cert-terminated `irc+tls://`
362
+ * listener — see `aws-stack.ts`), so every connection routed here is
363
+ * TLS-protected at the edge. The secure fact is asserted so the actor
364
+ * surfaces read-only user mode `S`, STS advertises the duration-only
365
+ * TLS form, and SASL EXTERNAL is negotiable.
366
+ */
367
+ export async function dispatchNlbStream(
368
+ event: NlbStreamEvent,
369
+ deps: HandlerDeps,
370
+ ): Promise<NlbStreamResponse> {
345
371
  return handleNlbStream(event, {
346
372
  dynamo: deps.dynamo,
347
373
  tables: deps.tables,
@@ -351,6 +377,9 @@ export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStream
351
377
  managementApi: deps.managementApi,
352
378
  ...(deps.services !== undefined ? { services: deps.services } : {}),
353
379
  ...(deps.history !== undefined ? { history: deps.history } : {}),
380
+ // TLS fact: the NLB listener terminates TLS before the target sees
381
+ // the flow (mirrors the wss handler's hard-coded `secure: true`).
382
+ secure: true,
354
383
  });
355
384
  }
356
385