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
@@ -12,13 +12,21 @@
12
12
  * `x-forwarded-port` headers. {@link deriveNlbConnectionId} maps that tuple
13
13
  * to the existing `connectionId` scheme (`nlb-<ip>-<port>`) so the same
14
14
  * DynamoDB-backed state path serves both the wss and TCP+TLS transports.
15
+ * A flow arriving without both headers in well-formed shape (missing,
16
+ * non-IP, or non-numeric-port — e.g. a misconfigured integration) is
17
+ * rejected with a 400 before any state is read or written; every such
18
+ * flow would otherwise share one `nlb-unknown-0` Connections row.
15
19
  *
16
20
  * Transport buffer persistence: the `TcpByteStreamTransport` buffers
17
21
  * partial lines (a chunk may split an IRC command at an arbitrary byte
18
22
  * boundary). Because each chunk arrives in a fresh Lambda invocation, the
19
23
  * buffered tail is snapshotted to a `transportBuffer` attribute on the
20
24
  * Connections row and rehydrated on the next invocation — no bytes are lost
21
- * across the compute boundary.
25
+ * across the compute boundary. The tail is capped at MAX_BUFFER_BYTES
26
+ * (shared constant from irc-core): a body over the cap is rejected with a
27
+ * 400 before any processing, and an accumulated tail over the cap closes
28
+ * the connection (ERROR line + Connections row deleted) so the buffer can
29
+ * never grow unboundedly or persist oversized.
22
30
  *
23
31
  * Outbound bytes: self-send lines (PONG, welcome block, error numerics, …)
24
32
  * are collected and returned as the Lambda response body (base64-encoded so
@@ -40,14 +48,16 @@
40
48
  * underlying flow may be rebalanced by the NLB at any time.
41
49
  */
42
50
 
51
+ import { isIP } from 'node:net';
43
52
  import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
44
- import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
53
+ import { DeleteCommand, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
45
54
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
46
55
  import {
47
56
  type ChannelState,
48
57
  type Clock,
49
58
  type ConnectionState,
50
59
  type IdFactory,
60
+ MAX_BUFFER_BYTES,
51
61
  type MessageStore,
52
62
  type MotdProvider,
53
63
  type NickHistoryStore,
@@ -55,6 +65,7 @@ import {
55
65
  type ServerConfig,
56
66
  SystemClock,
57
67
  UuidIdFactory,
68
+ byteLength,
58
69
  } from '@serverless-ircd/irc-core';
59
70
  import {
60
71
  type ActorChannelAccess,
@@ -64,6 +75,7 @@ import {
64
75
  import { TcpByteStreamTransport } from '@serverless-ircd/irc-server';
65
76
  import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
66
77
  import type { DynamoServicesStore } from '../dynamo-services-store.js';
78
+ import { countConnectionsForIp } from '../ip-admission.js';
67
79
  import type { MarshalledConnection } from '../serialize.js';
68
80
  import { CONNECTION_VERSION } from '../serialize.js';
69
81
  import type { TablesConfig } from '../tables.js';
@@ -124,6 +136,30 @@ export function deriveNlbConnectionId(sourceIp: string, sourcePort: string): str
124
136
  return `${NLB_CONN_PREFIX}${safeIp}-${sourcePort}`;
125
137
  }
126
138
 
139
+ /**
140
+ * Parses and validates the NLB flow headers (`x-forwarded-for` /
141
+ * `x-forwarded-port`) that identify a connection.
142
+ *
143
+ * A flow the NLB cannot identify — missing header, a non-IP
144
+ * `x-forwarded-for` (including a comma-separated proxy list; NLB sends
145
+ * exactly one client IP), or a `x-forwarded-port` that is not a plain
146
+ * decimal in `1..65535` — yields `null`. Such an invocation must be
147
+ * rejected by the caller: admitting it would map every such client onto
148
+ * one shared connection id and a single DynamoDB `Connections` row whose
149
+ * `ConnectionState` they overwrite per frame.
150
+ */
151
+ export function parseNlbFlowHeaders(
152
+ event: NlbStreamEvent,
153
+ ): { sourceIp: string; sourcePort: string } | null {
154
+ const ip = readHeader(event, 'x-forwarded-for');
155
+ if (ip === undefined || isIP(ip) === 0) return null;
156
+ const port = readHeader(event, 'x-forwarded-port');
157
+ if (port === undefined || !/^\d{1,5}$/u.test(port) || Number(port) < 1 || Number(port) > 65535) {
158
+ return null;
159
+ }
160
+ return { sourceIp: ip, sourcePort: port };
161
+ }
162
+
127
163
  // ---------------------------------------------------------------------------
128
164
  // Handler
129
165
  // ---------------------------------------------------------------------------
@@ -167,7 +203,9 @@ export interface NlbStreamParams {
167
203
  * Whether the NLB listener terminates TLS for this flow. Set to `true`
168
204
  * when the target group fronts a TLS listener (the `irc+tls://` port) so
169
205
  * the connection surfaces user mode `S`; omit / set `false` for a plain
170
- * TCP listener. The stack code sets this from the listener protocol.
206
+ * TCP listener. The Lambda entry ({@link dispatchNlbStream}) asserts
207
+ * `true` because the stack wires this target exclusively behind a TLS
208
+ * listener.
171
209
  */
172
210
  secure?: boolean;
173
211
  }
@@ -195,9 +233,27 @@ export async function handleNlbStream(
195
233
  ? Buffer.from(event.body, 'base64').toString('utf8')
196
234
  : event.body;
197
235
 
198
- // 2. Derive the connection id from NLB flow metadata.
199
- const sourceIp = readHeader(event, 'x-forwarded-for') ?? 'unknown';
200
- const sourcePort = readHeader(event, 'x-forwarded-port') ?? '0';
236
+ // 1a. Payload pre-check: a body decoding to more than MAX_BUFFER_BYTES
237
+ // can never be legal IRC traffic (the line limit is 512 bytes), so it is
238
+ // rejected with a 400 to the NLB integration BEFORE any row read, actor
239
+ // invocation, or state write — an oversized payload must not even reach
240
+ // the transport buffer. Separate guard from the accumulation cap below:
241
+ // this one bounds a single invocation's input.
242
+ if (byteLength(data) > MAX_BUFFER_BYTES) {
243
+ return { statusCode: 400, body: '', isBase64Encoded: false };
244
+ }
245
+
246
+ // 2. Derive the connection id from NLB flow metadata. A flow missing
247
+ // either header — or carrying a malformed IP / port — cannot be
248
+ // identified: admitting it would collapse every such client onto one
249
+ // shared Connections row (the retired `nlb-unknown-0` fallback) whose
250
+ // state they overwrite per frame. Reject with a 400 before any row
251
+ // read, actor invocation, or state write.
252
+ const flow = parseNlbFlowHeaders(event);
253
+ if (flow === null) {
254
+ return { statusCode: 400, body: '', isBase64Encoded: false };
255
+ }
256
+ const { sourceIp, sourcePort } = flow;
201
257
  const connId = deriveNlbConnectionId(sourceIp, sourcePort);
202
258
 
203
259
  // 3. Load the existing row (if any) and split the transport buffer.
@@ -205,8 +261,30 @@ export async function handleNlbStream(
205
261
  let state: ConnectionState;
206
262
  let transportBuffer: string;
207
263
  if (existing === null) {
264
+ // 3a. Per-IP new-flow rate limit. The NLB path has no `$connect`
265
+ // event — flow establishment IS this first chunk — so the
266
+ // `perIpConnectionRate` budget is enforced here, counting the
267
+ // source IP's in-window establishments against the `sourceIp` GSI
268
+ // (the same index `$connect` consults). Over-budget flows get 429
269
+ // (the NLB closes the TCP flow on a non-200 target response) and NO
270
+ // row is written. Only establishment is budgeted: every subsequent
271
+ // chunk of an established flow is also an invocation, and
272
+ // rate-limiting those would starve an active IRC session.
273
+ if (sourceIp !== 'unknown') {
274
+ const rate = params.serverConfig.perIpConnectionRate;
275
+ const ipCounts = await countConnectionsForIp(
276
+ params.dynamo,
277
+ params.tables.Connections,
278
+ sourceIp,
279
+ now,
280
+ rate.windowMs,
281
+ );
282
+ if (ipCounts.recent >= rate.max) {
283
+ return { statusCode: 429, body: '', isBase64Encoded: false };
284
+ }
285
+ }
208
286
  state = createInitialConnectionState(connId, now);
209
- if (sourceIp !== 'unknown') state.host = sourceIp;
287
+ state.host = sourceIp;
210
288
  transportBuffer = '';
211
289
  } else {
212
290
  state = unmarshalState(existing);
@@ -214,11 +292,39 @@ export async function handleNlbStream(
214
292
  }
215
293
 
216
294
  // 4. Frame the chunk through the persistent TCP transport.
217
- const transport = new TcpByteStreamTransport();
295
+ let overflowed = false;
296
+ const transport = new TcpByteStreamTransport({
297
+ onOverflow: () => {
298
+ overflowed = true;
299
+ },
300
+ });
218
301
  transport.restore(transportBuffer);
219
302
  const lines = transport.feed(data);
220
303
  const newBuffer = transport.getBuffer();
221
304
 
305
+ // 4a. Buffer-cap teardown: the retained tail (restored buffer + this
306
+ // chunk, minus complete lines) exceeded MAX_BUFFER_BYTES. The peer can
307
+ // never produce a legal line from those bytes, so the connection is
308
+ // closed: the ERROR line is streamed back as the response body and the
309
+ // Connections row is DELETED (not persisted) — the oversized buffer
310
+ // never reaches a write, and the flow's bookkeeping is dropped. A fresh
311
+ // chunk from the same 4-tuple starts a clean connection row.
312
+ if (overflowed) {
313
+ await params.dynamo.send(
314
+ new DeleteCommand({
315
+ TableName: params.tables.Connections,
316
+ Key: { connectionId: connId },
317
+ }),
318
+ );
319
+ return {
320
+ statusCode: 200,
321
+ body: Buffer.from('ERROR :Closing link: input buffer overflow\r\n', 'utf8').toString(
322
+ 'base64',
323
+ ),
324
+ isBase64Encoded: true,
325
+ };
326
+ }
327
+
222
328
  // Mark activity.
223
329
  state.lastSeen = now;
224
330
 
@@ -285,6 +391,7 @@ export async function handleNlbStream(
285
391
  state,
286
392
  now,
287
393
  newBuffer,
394
+ sourceIp !== 'unknown' ? sourceIp : undefined,
288
395
  );
289
396
 
290
397
  // Drain any services write-behind ops so a Lambda freeze / evict does
@@ -362,6 +469,14 @@ function unmarshalConnectionTyped(row: MarshalledConnection): ConnectionState {
362
469
  * UpdateCommand. The buffer is written as a plain string attribute — when
363
470
  * empty it is REMOVE'd so stale buffers from a previous chunk do not
364
471
  * linger after the line completes.
472
+ *
473
+ * `connectedSince` + `sourceIp` (when known) are stamped on every write:
474
+ * the upsert creates the row on a flow's first chunk, and both key
475
+ * attributes MUST be present for the row to surface in the `sourceIp`
476
+ * GSI the per-IP admission gates consult. For rows the update creates
477
+ * the establishment timestamp is stable (`state.connectedSince` is only
478
+ * absent on legacy rows written before this stamping shipped, where
479
+ * `now` is the best available fallback).
365
480
  */
366
481
  async function persistStateAndBuffer(
367
482
  dynamo: DynamoDBDocumentClient,
@@ -370,6 +485,7 @@ async function persistStateAndBuffer(
370
485
  state: ConnectionState,
371
486
  now: number,
372
487
  transportBuffer: string,
488
+ sourceIp?: string,
373
489
  ): Promise<void> {
374
490
  // Build the standard persist expression (same fields as handleDefault's
375
491
  // buildPersistUpdate, minus the joinedChannels which the membership
@@ -379,19 +495,27 @@ async function persistStateAndBuffer(
379
495
  ':capN': state.capNegotiating,
380
496
  ':caps': [...state.caps],
381
497
  ':um': { ...state.userModes },
498
+ ':sec': state.secure,
382
499
  ':ls': state.lastSeen,
383
500
  ':is': now,
384
501
  ':v': CONNECTION_VERSION,
502
+ ':cs': state.connectedSince ?? now,
385
503
  };
386
504
  const setNames = [
387
505
  'registration = :reg',
388
506
  'capNegotiating = :capN',
389
507
  'caps = :caps',
390
508
  'userModes = :um',
509
+ 'secure = :sec',
391
510
  'lastSeen = :ls',
392
511
  'idleSince = :is',
393
512
  'version = :v',
513
+ 'connectedSince = :cs',
394
514
  ];
515
+ if (sourceIp !== undefined) {
516
+ values[':sip'] = sourceIp;
517
+ setNames.push('sourceIp = :sip');
518
+ }
395
519
  const removeNames: string[] = [];
396
520
  const nameMap: Record<string, string> = {};
397
521
 
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Per-IP connection counting against the `Connections` table's
3
+ * `sourceIp` GSI (`sourceIp` partition + `connectedSince` sort).
4
+ *
5
+ * Feeds the per-IP admission gates on both AWS transports:
6
+ * - `$connect`: simultaneous per-IP cap (`maxConnectionsPerIp`) and
7
+ * the per-IP connection-rate window (`perIpConnectionRate`).
8
+ * - NLB streaming: the rate window for new flows (flow establishment
9
+ * is the first chunk — there is no `$connect` event on that path).
10
+ *
11
+ * One paginated Query per call, projecting only `connectedSince`; the
12
+ * walk computes both counts (total + recent-inside-window) in a single
13
+ * pass. Per-IP partitions are bounded in steady state by the caps the
14
+ * gate itself enforces, so the walk is a handful of items in practice.
15
+ *
16
+ * Consistency caveat: GSI reads are eventually consistent (DynamoDB does
17
+ * not support strongly-consistent secondary-index reads), so a burst of
18
+ * concurrent connects from one IP can race past the per-IP caps — the
19
+ * same best-effort TOCTOU contract as the global connection counter.
20
+ *
21
+ * Legacy rows: items appear in the GSI only when BOTH key attributes are
22
+ * present. Rows written before this field shipped carry no `sourceIp`,
23
+ * so they are invisible to the index — the caps apply to connections
24
+ * registered from this change forward.
25
+ */
26
+
27
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
28
+ import { QueryCommand } from '@aws-sdk/lib-dynamodb';
29
+ import { SOURCE_IP_INDEX_NAME } from './tables.js';
30
+
31
+ /** Per-IP counts derived from the GSI walk. */
32
+ export interface IpConnectionCounts {
33
+ /** Live connections currently attributed to the IP (GSI item count). */
34
+ readonly total: number;
35
+ /** Connections whose `connectedSince` falls inside the window. */
36
+ readonly recent: number;
37
+ }
38
+
39
+ /**
40
+ * Counts a source IP's connections: total (simultaneous) and recent
41
+ * (established within the last `windowMs`, i.e. still inside the
42
+ * sliding-window rate budget). Admissions exactly `windowMs` old still
43
+ * count (`connectedSince >= now - windowMs`), mirroring irc-core's
44
+ * `recentAdmissions` boundary.
45
+ */
46
+ export async function countConnectionsForIp(
47
+ dynamo: DynamoDBDocumentClient,
48
+ tableName: string,
49
+ sourceIp: string,
50
+ now: number,
51
+ windowMs: number,
52
+ ): Promise<IpConnectionCounts> {
53
+ const cutoff = now - windowMs;
54
+ let total = 0;
55
+ let recent = 0;
56
+ let exclusiveStartKey: Record<string, unknown> | undefined = undefined;
57
+ do {
58
+ const result = await dynamo.send(
59
+ new QueryCommand({
60
+ TableName: tableName,
61
+ IndexName: SOURCE_IP_INDEX_NAME,
62
+ KeyConditionExpression: '#ip = :ip',
63
+ ExpressionAttributeNames: { '#ip': 'sourceIp' },
64
+ ExpressionAttributeValues: { ':ip': sourceIp },
65
+ ProjectionExpression: 'connectedSince',
66
+ ...(exclusiveStartKey !== undefined ? { ExclusiveStartKey: exclusiveStartKey } : {}),
67
+ }),
68
+ );
69
+ for (const item of result.Items ?? []) {
70
+ total++;
71
+ const since = (item as { connectedSince?: unknown }).connectedSince;
72
+ if (typeof since === 'number' && since >= cutoff) {
73
+ recent++;
74
+ }
75
+ }
76
+ exclusiveStartKey = result.LastEvaluatedKey as Record<string, unknown> | undefined;
77
+ } while (exclusiveStartKey !== undefined);
78
+ return { total, recent };
79
+ }
@@ -107,6 +107,14 @@ export interface MarshalledConnection {
107
107
  * subsequent frame.
108
108
  */
109
109
  wsMode?: WsFrameMode;
110
+ /**
111
+ * Source IP of the connecting client, persisted at `$connect` (and on
112
+ * NLB flow creation) so the `sourceIp` GSI (`sourceIp-connectedSince`)
113
+ * carries the row for the per-IP admission walks. Absent on rows
114
+ * written before this field shipped — those are invisible to the
115
+ * index (a GSI item requires both key attributes).
116
+ */
117
+ sourceIp?: string;
110
118
  }
111
119
 
112
120
  /**
@@ -71,3 +71,12 @@ export function tablesConfigFromNames(names: {
71
71
  const out: TablesConfig = { ...names };
72
72
  return out;
73
73
  }
74
+
75
+ /**
76
+ * Name of the per-IP admission GSI on the `Connections` table
77
+ * (partition key `sourceIp`, sort key `connectedSince`). Consulted by the
78
+ * `$connect` and NLB handlers' per-IP admission gates; declared on the
79
+ * deployed table by `TABLE_DEFS` in `cdk-table-defs.ts`. Shared here so
80
+ * the runtime (this module) and the CDK definition reference one name.
81
+ */
82
+ export const SOURCE_IP_INDEX_NAME = 'sourceIp-connectedSince';
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Pure unit tests for the AWS `$connect` admission policy
3
3
  * (`src/admission.ts`). No DynamoDB — these exercise every branch of
4
- * {@link decideConnectAdmission} directly so the per-IP branch is covered
5
- * even though the AWS handler does not yet feed it a `perIp` count.
4
+ * {@link decideConnectAdmission} directly so the per-IP branches are
5
+ * covered without standing up DynamoDB Local.
6
6
  */
7
7
 
8
8
  import { describe, expect, it } from 'vitest';
@@ -67,4 +67,62 @@ describe('decideConnectAdmission', () => {
67
67
  reason: 'server full',
68
68
  });
69
69
  });
70
+
71
+ it('admits when a rate budget is set but no recent count is supplied', () => {
72
+ expect(
73
+ decideConnectAdmission(
74
+ { total: 0 },
75
+ { maxClients: 10, perIpRate: { max: 5, windowMs: 60_000 } },
76
+ ),
77
+ ).toEqual({ admitted: true });
78
+ });
79
+
80
+ it('admits when the recent per-IP count is below the rate budget', () => {
81
+ expect(
82
+ decideConnectAdmission(
83
+ { total: 0, recentPerIp: 4 },
84
+ { maxClients: 10, perIpRate: { max: 5, windowMs: 60_000 } },
85
+ ),
86
+ ).toEqual({ admitted: true });
87
+ });
88
+
89
+ it('rejects with 429 once the recent per-IP count reaches the rate budget', () => {
90
+ expect(
91
+ decideConnectAdmission(
92
+ { total: 0, recentPerIp: 5 },
93
+ { maxClients: 10, perIpRate: { max: 5, windowMs: 60_000 } },
94
+ ),
95
+ ).toEqual({
96
+ admitted: false,
97
+ statusCode: 429,
98
+ reason: 'connection rate exceeded for this IP',
99
+ });
100
+ });
101
+
102
+ it('checks the per-IP simultaneous cap before the rate budget', () => {
103
+ // A caller supplying both over-budget counts gets the simultaneous-
104
+ // cap reason: the more specific signal for an already-connected IP.
105
+ expect(
106
+ decideConnectAdmission(
107
+ { total: 0, perIp: 3, recentPerIp: 5 },
108
+ { maxClients: 10, maxConnectionsPerIp: 3, perIpRate: { max: 5, windowMs: 60_000 } },
109
+ ),
110
+ ).toEqual({
111
+ admitted: false,
112
+ statusCode: 429,
113
+ reason: 'too many connections from this IP',
114
+ });
115
+ });
116
+
117
+ it('ignores a stale recent count (window decayed) and admits', () => {
118
+ // Window-reset behaviour at the policy seam: a recent count of 0
119
+ // (every prior admission aged out of the window) never trips the
120
+ // rate branch even at a budget of 1.
121
+ expect(
122
+ decideConnectAdmission(
123
+ { total: 0, recentPerIp: 0 },
124
+ { maxClients: 10, perIpRate: { max: 1, windowMs: 60_000 } },
125
+ ),
126
+ ).toEqual({ admitted: true });
127
+ });
70
128
  });
@@ -211,7 +211,7 @@ export class AwsHarness implements IrcHarness {
211
211
  const pkName = props.partitionKey?.name;
212
212
  const skName = props.sortKey?.name;
213
213
  if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
214
- const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
214
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' | 'N' }> = [
215
215
  { AttributeName: pkName, AttributeType: 'S' },
216
216
  ];
217
217
  const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
@@ -221,11 +221,33 @@ export class AwsHarness implements IrcHarness {
221
221
  attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
222
222
  keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
223
223
  }
224
+ // Mirror the CDK-declared secondary indexes (the Connections
225
+ // per-IP admission GSI) so the fixture matches the deployed schema.
226
+ const globalSecondaryIndexes = (props.globalSecondaryIndexes ?? []).map((g) => {
227
+ const gpk = g.partitionKey;
228
+ if (gpk === undefined) throw new Error(`index ${g.indexName} missing partitionKey`);
229
+ const gsiKeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
230
+ { AttributeName: gpk.name, KeyType: 'HASH' },
231
+ ];
232
+ attributeDefinitions.push({ AttributeName: gpk.name, AttributeType: 'S' });
233
+ if (g.sortKey !== undefined) {
234
+ gsiKeySchema.push({ AttributeName: g.sortKey.name, KeyType: 'RANGE' });
235
+ attributeDefinitions.push({ AttributeName: g.sortKey.name, AttributeType: 'N' });
236
+ }
237
+ return {
238
+ IndexName: g.indexName,
239
+ KeySchema: gsiKeySchema,
240
+ Projection: { ProjectionType: 'ALL' as const },
241
+ };
242
+ });
224
243
  await this.client.send(
225
244
  new CreateTableCommand({
226
245
  TableName: `${this.prefix}${logical}`,
227
246
  AttributeDefinitions: attributeDefinitions,
228
247
  KeySchema: keySchema,
248
+ ...(globalSecondaryIndexes.length > 0
249
+ ? { GlobalSecondaryIndexes: globalSecondaryIndexes }
250
+ : {}),
229
251
  BillingMode: 'PAY_PER_REQUEST',
230
252
  }),
231
253
  );
@@ -471,6 +471,70 @@ describe.skipIf(!available)('AwsRuntime', () => {
471
471
  });
472
472
  });
473
473
 
474
+ // -------------------------------------------------------------------------
475
+ // broadcastOperNotice — Scan-based fan-out to every +o connection (the
476
+ // OPER lockout gate's oper notice)
477
+ // -------------------------------------------------------------------------
478
+
479
+ describe('broadcastOperNotice', () => {
480
+ it('delivers to every +o connection and skips the except id', async () => {
481
+ const receivedA: string[] = [];
482
+ const receivedB: string[] = [];
483
+ const stateA = createConnection({ id: 'o-a', connectedSince: 0 });
484
+ stateA.userModes.oper = true;
485
+ const stateB = createConnection({ id: 'o-b', connectedSince: 0 });
486
+ stateB.userModes.oper = true;
487
+
488
+ const mgmt = new LocalPostToConnection();
489
+ mgmt.register('o-a', (data) => {
490
+ for (const line of data.split('\r\n')) {
491
+ if (line.length > 0) receivedA.push(line);
492
+ }
493
+ });
494
+ mgmt.register('o-b', (data) => {
495
+ for (const line of data.split('\r\n')) {
496
+ if (line.length > 0) receivedB.push(line);
497
+ }
498
+ });
499
+
500
+ const rtA = makeRuntime('o-a', handlersSink(receivedA, stateA), undefined, mgmt);
501
+ const rtB = makeRuntime('o-b', handlersSink(receivedB, stateB), undefined, mgmt);
502
+
503
+ await rtA.persistConnectionState(stateA);
504
+ await rtB.persistConnectionState(stateB);
505
+
506
+ // Caller 'o-a' is excepted; only 'o-b' should receive the notice.
507
+ await rtA.broadcastOperNotice(
508
+ [{ text: ':srv NOTICE * :OPER lockout triggered for h' }],
509
+ 'o-a',
510
+ );
511
+ expect(receivedA).toEqual([]);
512
+ expect(receivedB).toEqual([':srv NOTICE * :OPER lockout triggered for h']);
513
+ });
514
+
515
+ it('does not deliver to a +w-only connection (the gate is +o)', async () => {
516
+ const received: string[] = [];
517
+ const state = createConnection({ id: 'o-wonly', connectedSince: 0 });
518
+ state.userModes.wallops = true;
519
+ const rt = makeRuntime('o-caller', handlersSink(received, state), undefined, null);
520
+ await rt.persistConnectionState(state);
521
+ await rt.broadcastOperNotice([{ text: ':srv NOTICE * :hi' }]);
522
+ expect(received).toEqual([]);
523
+ });
524
+
525
+ it('delivers to the bound connection via in-process handlers when not excepted', async () => {
526
+ // The bound connection's own +o delivery goes through handlers.send,
527
+ // not APIGW. With no `except`, the bound oper conn is a recipient.
528
+ const received: string[] = [];
529
+ const state = createConnection({ id: 'self', connectedSince: 0 });
530
+ state.userModes.oper = true;
531
+ const rt = makeRuntime('self', handlersSink(received, state), undefined, null);
532
+ await rt.persistConnectionState(state);
533
+ await rt.broadcastOperNotice([{ text: ':srv NOTICE * :hi' }]);
534
+ expect(received).toEqual([':srv NOTICE * :hi']);
535
+ });
536
+ });
537
+
474
538
  // -------------------------------------------------------------------------
475
539
  // Nick registry — error propagation
476
540
  // -------------------------------------------------------------------------