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
|
@@ -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
|
|
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':
|
|
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
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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,
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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: {
|
|
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
|
-
|
|
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
|
|