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.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +17 -4
- package/apps/cf-worker/package.json +2 -2
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +86 -9
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +40 -1
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +393 -20
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +8 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +395 -36
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +544 -7
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/scripts/package.json +1 -1
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +17 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- 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
|
|
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
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
*
|
|
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
|
// -------------------------------------------------------------------------
|