serverless-ircd 0.9.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-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- 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 +18 -14
- package/apps/cf-worker/package.json +3 -4
- 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 +95 -77
- 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 +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- 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 +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- 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-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- 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 +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -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/account-migration.ts +140 -0
- 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/account-auth.ts +16 -19
- 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/memoserv.ts +1 -1
- 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 +161 -11
- 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 +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- 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 +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- 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 +9 -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 +529 -190
- 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 +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- 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/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- 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/message-store.test.ts +5 -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 +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- 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 +563 -54
- 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/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- 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 +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -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-aws-oidc.test.ts +96 -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/tests/validate.test.ts +42 -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/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
12
|
-
import { GetCommand } from '@aws-sdk/lib-dynamodb';
|
|
12
|
+
import { type DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
|
|
13
13
|
import { type ParsedServerConfig, StaticMotdProvider } from '@serverless-ircd/irc-core';
|
|
14
14
|
import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
|
|
15
15
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
@@ -19,8 +19,10 @@ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
|
19
19
|
import { createDynamoDocumentClient } from '../src/dynamo.js';
|
|
20
20
|
import {
|
|
21
21
|
type NlbStreamEvent,
|
|
22
|
+
type NlbStreamParams,
|
|
22
23
|
deriveNlbConnectionId,
|
|
23
24
|
handleNlbStream,
|
|
25
|
+
parseNlbFlowHeaders,
|
|
24
26
|
} from '../src/handlers/nlb-stream.js';
|
|
25
27
|
import { bindMessageStore } from '../src/message-store.js';
|
|
26
28
|
import type { TablesConfig } from '../src/tables.js';
|
|
@@ -61,7 +63,7 @@ async function makeFixture(): Promise<Fixture> {
|
|
|
61
63
|
const pkName = props.partitionKey?.name;
|
|
62
64
|
const skName = props.sortKey?.name;
|
|
63
65
|
if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
|
|
64
|
-
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
|
|
66
|
+
const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' | 'N' }> = [
|
|
65
67
|
{ AttributeName: pkName, AttributeType: 'S' },
|
|
66
68
|
];
|
|
67
69
|
const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
@@ -71,11 +73,35 @@ async function makeFixture(): Promise<Fixture> {
|
|
|
71
73
|
attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
|
|
72
74
|
keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
|
|
73
75
|
}
|
|
76
|
+
// Secondary indexes declared by the CDK definition (the Connections
|
|
77
|
+
// per-IP admission GSI) are created here too — the NLB new-flow rate
|
|
78
|
+
// gate Queries the index, so a fixture without it diverges from the
|
|
79
|
+
// deployed schema.
|
|
80
|
+
const globalSecondaryIndexes = (props.globalSecondaryIndexes ?? []).map((g) => {
|
|
81
|
+
const gpk = g.partitionKey;
|
|
82
|
+
if (gpk === undefined) throw new Error(`index ${g.indexName} missing partitionKey`);
|
|
83
|
+
const gsiKeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
|
|
84
|
+
{ AttributeName: gpk.name, KeyType: 'HASH' },
|
|
85
|
+
];
|
|
86
|
+
attributeDefinitions.push({ AttributeName: gpk.name, AttributeType: 'S' });
|
|
87
|
+
if (g.sortKey !== undefined) {
|
|
88
|
+
gsiKeySchema.push({ AttributeName: g.sortKey.name, KeyType: 'RANGE' });
|
|
89
|
+
attributeDefinitions.push({ AttributeName: g.sortKey.name, AttributeType: 'N' });
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
IndexName: g.indexName,
|
|
93
|
+
KeySchema: gsiKeySchema,
|
|
94
|
+
Projection: { ProjectionType: 'ALL' as const },
|
|
95
|
+
};
|
|
96
|
+
});
|
|
74
97
|
await client.send(
|
|
75
98
|
new CreateTableCommand({
|
|
76
99
|
TableName: `${prefix}${logical}`,
|
|
77
100
|
AttributeDefinitions: attributeDefinitions,
|
|
78
101
|
KeySchema: keySchema,
|
|
102
|
+
...(globalSecondaryIndexes.length > 0
|
|
103
|
+
? { GlobalSecondaryIndexes: globalSecondaryIndexes }
|
|
104
|
+
: {}),
|
|
79
105
|
BillingMode: 'PAY_PER_REQUEST',
|
|
80
106
|
}),
|
|
81
107
|
);
|
|
@@ -130,6 +156,184 @@ describe.skipIf(!available)('deriveNlbConnectionId', () => {
|
|
|
130
156
|
});
|
|
131
157
|
});
|
|
132
158
|
|
|
159
|
+
describe('parseNlbFlowHeaders', () => {
|
|
160
|
+
it('parses a well-formed IPv4 flow', () => {
|
|
161
|
+
expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.1', '50001')))).toEqual({
|
|
162
|
+
sourceIp: '10.0.0.1',
|
|
163
|
+
sourcePort: '50001',
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('parses a well-formed IPv6 flow', () => {
|
|
168
|
+
expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('2001:db8::1', '50002')))).toEqual({
|
|
169
|
+
sourceIp: '2001:db8::1',
|
|
170
|
+
sourcePort: '50002',
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('matches header names case-insensitively', () => {
|
|
175
|
+
expect(
|
|
176
|
+
parseNlbFlowHeaders(
|
|
177
|
+
nlbEventWithHeaders({ 'X-Forwarded-For': '10.0.0.2', 'X-Forwarded-Port': '50003' }),
|
|
178
|
+
),
|
|
179
|
+
).toEqual({ sourceIp: '10.0.0.2', sourcePort: '50003' });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('accepts the port boundaries 1 and 65535', () => {
|
|
183
|
+
expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.3', '1')))).not.toBeNull();
|
|
184
|
+
expect(
|
|
185
|
+
parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.3', '65535'))),
|
|
186
|
+
).not.toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('rejects port 0 and ports above 65535', () => {
|
|
190
|
+
expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.4', '0')))).toBeNull();
|
|
191
|
+
expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.4', '65536')))).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('rejects empty header values', () => {
|
|
195
|
+
expect(
|
|
196
|
+
parseNlbFlowHeaders(nlbEventWithHeaders({ 'x-forwarded-for': '', 'x-forwarded-port': '1' })),
|
|
197
|
+
).toBeNull();
|
|
198
|
+
expect(
|
|
199
|
+
parseNlbFlowHeaders(
|
|
200
|
+
nlbEventWithHeaders({ 'x-forwarded-for': '10.0.0.5', 'x-forwarded-port': '' }),
|
|
201
|
+
),
|
|
202
|
+
).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('rejects an out-of-range IPv4 octet', () => {
|
|
206
|
+
expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.256', '50005')))).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('rejects a port with non-digit characters', () => {
|
|
210
|
+
expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.6', '12a')))).toBeNull();
|
|
211
|
+
expect(parseNlbFlowHeaders(nlbEventWithHeaders(headersFor('10.0.0.6', '+12')))).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/** Builds the canonical NLB flow header pair. */
|
|
216
|
+
function headersFor(sourceIp: string, sourcePort: string): Record<string, string> {
|
|
217
|
+
return { 'x-forwarded-for': sourceIp, 'x-forwarded-port': sourcePort };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Flow header validation: a flow the NLB cannot identify (missing or
|
|
222
|
+
// malformed x-forwarded-for / x-forwarded-port) must be rejected with a
|
|
223
|
+
// 400 before any DynamoDB read, row creation, or actor invocation —
|
|
224
|
+
// otherwise every such client collapses onto the single literal
|
|
225
|
+
// connection id `nlb-unknown-0` and the flows overwrite each other's
|
|
226
|
+
// persisted ConnectionState per frame. These rejection paths never touch
|
|
227
|
+
// DynamoDB, so they run without the DDB Local fixture.
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
describe('NLB stream handler — flow header validation', () => {
|
|
231
|
+
it('rejects a flow missing x-forwarded-for with 400 before any DynamoDB I/O', async () => {
|
|
232
|
+
const { params, send } = rejectParams();
|
|
233
|
+
const res = await handleNlbStream(nlbEventWithHeaders({ 'x-forwarded-port': '50001' }), params);
|
|
234
|
+
expect(res.statusCode).toBe(400);
|
|
235
|
+
expect(send).not.toHaveBeenCalled();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('rejects a flow missing x-forwarded-port with 400 before any DynamoDB I/O', async () => {
|
|
239
|
+
const { params, send } = rejectParams();
|
|
240
|
+
const res = await handleNlbStream(
|
|
241
|
+
nlbEventWithHeaders({ 'x-forwarded-for': '10.0.0.1' }),
|
|
242
|
+
params,
|
|
243
|
+
);
|
|
244
|
+
expect(res.statusCode).toBe(400);
|
|
245
|
+
expect(send).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('rejects a flow with no headers object at all with 400', async () => {
|
|
249
|
+
const { params, send } = rejectParams();
|
|
250
|
+
const event: NlbStreamEvent = {
|
|
251
|
+
requestContext: {
|
|
252
|
+
elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:::targetgroup/test' },
|
|
253
|
+
},
|
|
254
|
+
version: '2.0',
|
|
255
|
+
body: Buffer.from('PING :tok\r\n', 'utf8').toString('base64'),
|
|
256
|
+
isBase64Encoded: true,
|
|
257
|
+
};
|
|
258
|
+
const res = await handleNlbStream(event, params);
|
|
259
|
+
expect(res.statusCode).toBe(400);
|
|
260
|
+
expect(send).not.toHaveBeenCalled();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('rejects a flow with a malformed (non-IP) x-forwarded-for with 400', async () => {
|
|
264
|
+
const { params, send } = rejectParams();
|
|
265
|
+
const res = await handleNlbStream(
|
|
266
|
+
nlbEventWithHeaders({ 'x-forwarded-for': 'not-an-ip', 'x-forwarded-port': '50001' }),
|
|
267
|
+
params,
|
|
268
|
+
);
|
|
269
|
+
expect(res.statusCode).toBe(400);
|
|
270
|
+
expect(send).not.toHaveBeenCalled();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('rejects a comma-separated x-forwarded-for list with 400', async () => {
|
|
274
|
+
// NLB sends exactly one client IP; a list cannot identify a flow.
|
|
275
|
+
const { params, send } = rejectParams();
|
|
276
|
+
const res = await handleNlbStream(
|
|
277
|
+
nlbEventWithHeaders({
|
|
278
|
+
'x-forwarded-for': '10.0.0.1, 192.168.0.1',
|
|
279
|
+
'x-forwarded-port': '50001',
|
|
280
|
+
}),
|
|
281
|
+
params,
|
|
282
|
+
);
|
|
283
|
+
expect(res.statusCode).toBe(400);
|
|
284
|
+
expect(send).not.toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('rejects a flow with a malformed (non-numeric) x-forwarded-port with 400', async () => {
|
|
288
|
+
const { params, send } = rejectParams();
|
|
289
|
+
const res = await handleNlbStream(
|
|
290
|
+
nlbEventWithHeaders({ 'x-forwarded-for': '10.0.0.1', 'x-forwarded-port': 'port' }),
|
|
291
|
+
params,
|
|
292
|
+
);
|
|
293
|
+
expect(res.statusCode).toBe(400);
|
|
294
|
+
expect(send).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('never produces the literal nlb-unknown-0 connection id for a happy-path flow', async () => {
|
|
298
|
+
// A well-formed flow derives the expected id — the shared-row hazard
|
|
299
|
+
// (`nlb-unknown-0`) is unreachable by construction: every input that
|
|
300
|
+
// could produce it is rejected above.
|
|
301
|
+
expect(deriveNlbConnectionId('10.0.0.1', '50001')).toBe('nlb-10-0-0-1-50001');
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
/** Builds an NLB event with arbitrary headers (base64-encodes the body). */
|
|
306
|
+
function nlbEventWithHeaders(headers: Record<string, string>): NlbStreamEvent {
|
|
307
|
+
return {
|
|
308
|
+
requestContext: { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:::targetgroup/test' } },
|
|
309
|
+
version: '2.0',
|
|
310
|
+
body: Buffer.from('PING :tok\r\n', 'utf8').toString('base64'),
|
|
311
|
+
isBase64Encoded: true,
|
|
312
|
+
headers,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Params double for rejection tests: a `send` spy that must never be
|
|
318
|
+
* invoked (rejections happen before any DynamoDB I/O).
|
|
319
|
+
*/
|
|
320
|
+
function rejectParams(): { params: NlbStreamParams; send: ReturnType<typeof vi.fn> } {
|
|
321
|
+
const send = vi.fn();
|
|
322
|
+
const params: NlbStreamParams = {
|
|
323
|
+
dynamo: { send } as unknown as DynamoDBDocumentClient,
|
|
324
|
+
tables: {
|
|
325
|
+
Connections: 'Connections',
|
|
326
|
+
ChannelMeta: 'ChannelMeta',
|
|
327
|
+
ChannelMembers: 'ChannelMembers',
|
|
328
|
+
Nicks: 'Nicks',
|
|
329
|
+
},
|
|
330
|
+
serverConfig: SERVER_CONFIG,
|
|
331
|
+
motd: MOTD,
|
|
332
|
+
managementApi: null,
|
|
333
|
+
};
|
|
334
|
+
return { params, send };
|
|
335
|
+
}
|
|
336
|
+
|
|
133
337
|
describe.skipIf(!available)('NLB stream handler — byte framing', () => {
|
|
134
338
|
beforeEach(async () => {
|
|
135
339
|
fx = await makeFixture();
|
|
@@ -515,10 +719,11 @@ describe.skipIf(!available)('NLB stream handler — streaming back', () => {
|
|
|
515
719
|
expect(res.statusCode).toBe(200);
|
|
516
720
|
});
|
|
517
721
|
|
|
518
|
-
it('falls back to
|
|
519
|
-
// `readHeader` returns undefined when no header key matches
|
|
520
|
-
//
|
|
521
|
-
//
|
|
722
|
+
it('falls back to rejecting an event with unrelated headers only (no NLB flow metadata)', async () => {
|
|
723
|
+
// `readHeader` returns undefined when no header key matches; the
|
|
724
|
+
// handler must reject the flow with a 400 (see the flow-header
|
|
725
|
+
// validation block above) rather than admit it under a shared
|
|
726
|
+
// fallback id.
|
|
522
727
|
const f = requireFx();
|
|
523
728
|
const eventWithoutHeaders: NlbStreamEvent = {
|
|
524
729
|
requestContext: {
|
|
@@ -529,6 +734,7 @@ describe.skipIf(!available)('NLB stream handler — streaming back', () => {
|
|
|
529
734
|
isBase64Encoded: true,
|
|
530
735
|
headers: { 'some-other-header': 'value' },
|
|
531
736
|
};
|
|
737
|
+
const sendSpy = vi.spyOn(f.client, 'send');
|
|
532
738
|
const res = await handleNlbStream(eventWithoutHeaders, {
|
|
533
739
|
dynamo: f.client,
|
|
534
740
|
tables: f.tables,
|
|
@@ -537,9 +743,9 @@ describe.skipIf(!available)('NLB stream handler — streaming back', () => {
|
|
|
537
743
|
messages: bindMessageStore(SERVER_CONFIG),
|
|
538
744
|
managementApi: f.mgmt,
|
|
539
745
|
});
|
|
540
|
-
expect(res.statusCode).toBe(
|
|
541
|
-
|
|
542
|
-
|
|
746
|
+
expect(res.statusCode).toBe(400);
|
|
747
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
748
|
+
sendSpy.mockRestore();
|
|
543
749
|
});
|
|
544
750
|
});
|
|
545
751
|
|
|
@@ -558,3 +764,416 @@ class LocalPostToConnection implements PostToConnection {
|
|
|
558
764
|
return {};
|
|
559
765
|
}
|
|
560
766
|
}
|
|
767
|
+
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
// Input size limits: two independent guards.
|
|
770
|
+
// 1. Payload pre-check — an event body decoding to more than 8 KiB is a
|
|
771
|
+
// misbehaving NLB integration (or an attacker); it is rejected with a
|
|
772
|
+
// 400 BEFORE any DynamoDB read or actor invocation.
|
|
773
|
+
// 2. Transport buffer cap — an unterminated tail that accumulates past
|
|
774
|
+
// 8 KiB (possibly across invocations via the persisted buffer) closes
|
|
775
|
+
// the connection: the ERROR line is streamed back and the Connections
|
|
776
|
+
// row is dropped so the oversized buffer is never persisted.
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
describe.skipIf(!available)('NLB stream handler — input size limits', () => {
|
|
780
|
+
beforeEach(async () => {
|
|
781
|
+
fx = await makeFixture();
|
|
782
|
+
});
|
|
783
|
+
afterEach(async () => {
|
|
784
|
+
if (fx) await fx.cleanup();
|
|
785
|
+
fx = null;
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('rejects an oversized base64 event body with 400 before touching DynamoDB or the actor', async () => {
|
|
789
|
+
const f = requireFx();
|
|
790
|
+
const sendSpy = vi.spyOn(f.client, 'send');
|
|
791
|
+
const res = await handleNlbStream(nlbEvent('10.0.0.60', '50060', 'x'.repeat(9 * 1024)), {
|
|
792
|
+
dynamo: f.client,
|
|
793
|
+
tables: f.tables,
|
|
794
|
+
serverConfig: SERVER_CONFIG,
|
|
795
|
+
motd: MOTD,
|
|
796
|
+
messages: bindMessageStore(SERVER_CONFIG),
|
|
797
|
+
managementApi: f.mgmt,
|
|
798
|
+
});
|
|
799
|
+
expect(res.statusCode).toBe(400);
|
|
800
|
+
// Rejected before any bookkeeping: not a single DynamoDB command ran.
|
|
801
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
802
|
+
sendSpy.mockRestore();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it('rejects an oversized plain (non-base64) event body with 400', async () => {
|
|
806
|
+
const f = requireFx();
|
|
807
|
+
const event: NlbStreamEvent = {
|
|
808
|
+
requestContext: {
|
|
809
|
+
elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:::targetgroup/test' },
|
|
810
|
+
},
|
|
811
|
+
version: '2.0',
|
|
812
|
+
body: 'y'.repeat(9 * 1024),
|
|
813
|
+
isBase64Encoded: false,
|
|
814
|
+
headers: { 'x-forwarded-for': '10.0.0.61', 'x-forwarded-port': '50061' },
|
|
815
|
+
};
|
|
816
|
+
const sendSpy = vi.spyOn(f.client, 'send');
|
|
817
|
+
const res = await handleNlbStream(event, {
|
|
818
|
+
dynamo: f.client,
|
|
819
|
+
tables: f.tables,
|
|
820
|
+
serverConfig: SERVER_CONFIG,
|
|
821
|
+
motd: MOTD,
|
|
822
|
+
messages: bindMessageStore(SERVER_CONFIG),
|
|
823
|
+
managementApi: f.mgmt,
|
|
824
|
+
});
|
|
825
|
+
expect(res.statusCode).toBe(400);
|
|
826
|
+
expect(sendSpy).not.toHaveBeenCalled();
|
|
827
|
+
sendSpy.mockRestore();
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('accepts a payload exactly at the 8 KiB cap (regression guard on legal large chunks)', async () => {
|
|
831
|
+
const f = requireFx();
|
|
832
|
+
// 8191 bytes of a terminated line + CRLF = 8193 bytes... use exactly
|
|
833
|
+
// 8190 content bytes + CRLF so the decoded body is exactly 8192.
|
|
834
|
+
const body = `${'a'.repeat(8190)}\r\n`;
|
|
835
|
+
expect(Buffer.byteLength(body, 'utf8')).toBe(8192);
|
|
836
|
+
const res = await handleNlbStream(nlbEvent('10.0.0.62', '50062', body), {
|
|
837
|
+
dynamo: f.client,
|
|
838
|
+
tables: f.tables,
|
|
839
|
+
serverConfig: SERVER_CONFIG,
|
|
840
|
+
motd: MOTD,
|
|
841
|
+
messages: bindMessageStore(SERVER_CONFIG),
|
|
842
|
+
managementApi: f.mgmt,
|
|
843
|
+
});
|
|
844
|
+
expect(res.statusCode).toBe(200);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it('closes the connection with ERROR and drops the row when a chunk pushes the buffer past the cap', async () => {
|
|
848
|
+
const f = requireFx();
|
|
849
|
+
const opts = {
|
|
850
|
+
dynamo: f.client,
|
|
851
|
+
tables: f.tables,
|
|
852
|
+
serverConfig: SERVER_CONFIG,
|
|
853
|
+
motd: MOTD,
|
|
854
|
+
messages: bindMessageStore(SERVER_CONFIG),
|
|
855
|
+
managementApi: f.mgmt,
|
|
856
|
+
};
|
|
857
|
+
// Establish the connection with a 7 KiB unterminated tail (under both
|
|
858
|
+
// the payload pre-check and the buffer cap — buffered and persisted).
|
|
859
|
+
await handleNlbStream(nlbEvent('10.0.0.63', '50063', 'x'.repeat(7 * 1024)), opts);
|
|
860
|
+
// A 2 KiB chunk (itself under the pre-check cap) crosses the 8 KiB
|
|
861
|
+
// accumulation cap: the transport buffer overflows.
|
|
862
|
+
const res = await handleNlbStream(nlbEvent('10.0.0.63', '50063', 'x'.repeat(2 * 1024)), opts);
|
|
863
|
+
expect(res.statusCode).toBe(200);
|
|
864
|
+
expect(res.isBase64Encoded).toBe(true);
|
|
865
|
+
const decoded = Buffer.from(res.body, 'base64').toString('utf8');
|
|
866
|
+
expect(decoded).toContain('ERROR :Closing link: input buffer overflow');
|
|
867
|
+
// The Connections row is dropped entirely.
|
|
868
|
+
const row = await f.client.send(
|
|
869
|
+
new GetCommand({
|
|
870
|
+
TableName: f.tables.Connections,
|
|
871
|
+
Key: { connectionId: deriveNlbConnectionId('10.0.0.63', '50063') },
|
|
872
|
+
}),
|
|
873
|
+
);
|
|
874
|
+
expect(row.Item).toBeUndefined();
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it('closes on overflow accumulated across invocations and never persists an oversized buffer', async () => {
|
|
878
|
+
const f = requireFx();
|
|
879
|
+
const opts = {
|
|
880
|
+
dynamo: f.client,
|
|
881
|
+
tables: f.tables,
|
|
882
|
+
serverConfig: SERVER_CONFIG,
|
|
883
|
+
motd: MOTD,
|
|
884
|
+
messages: bindMessageStore(SERVER_CONFIG),
|
|
885
|
+
managementApi: f.mgmt,
|
|
886
|
+
};
|
|
887
|
+
await handleNlbStream(nlbEvent('10.0.0.64', '50064', ''), opts);
|
|
888
|
+
// 8 KiB unterminated: at the cap — buffered and persisted, NOT closed.
|
|
889
|
+
await handleNlbStream(nlbEvent('10.0.0.64', '50064', 'a'.repeat(8192)), opts);
|
|
890
|
+
const midRow = await f.client.send(
|
|
891
|
+
new GetCommand({
|
|
892
|
+
TableName: f.tables.Connections,
|
|
893
|
+
Key: { connectionId: deriveNlbConnectionId('10.0.0.64', '50064') },
|
|
894
|
+
}),
|
|
895
|
+
);
|
|
896
|
+
expect(midRow.Item?.transportBuffer).toBe('a'.repeat(8192));
|
|
897
|
+
|
|
898
|
+
// One more byte-past-cap chunk: the restored buffer + chunk overflow.
|
|
899
|
+
const res = await handleNlbStream(nlbEvent('10.0.0.64', '50064', 'b'.repeat(1024)), opts);
|
|
900
|
+
const decoded = Buffer.from(res.body, 'base64').toString('utf8');
|
|
901
|
+
expect(decoded).toContain('ERROR :Closing link: input buffer overflow');
|
|
902
|
+
// The row is deleted — the oversized buffer never reaches a write.
|
|
903
|
+
const row = await f.client.send(
|
|
904
|
+
new GetCommand({
|
|
905
|
+
TableName: f.tables.Connections,
|
|
906
|
+
Key: { connectionId: deriveNlbConnectionId('10.0.0.64', '50064') },
|
|
907
|
+
}),
|
|
908
|
+
);
|
|
909
|
+
expect(row.Item).toBeUndefined();
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// ---------------------------------------------------------------------------
|
|
914
|
+
// Per-IP new-flow rate limit — stub-client unit tests (no DynamoDB Local
|
|
915
|
+
// required, mirroring the connect.test.ts stub convention).
|
|
916
|
+
// ---------------------------------------------------------------------------
|
|
917
|
+
|
|
918
|
+
import { GetCommand as GetCmd, QueryCommand, UpdateCommand as UpdCmd } from '@aws-sdk/lib-dynamodb';
|
|
919
|
+
import type { Clock } from '@serverless-ircd/irc-core';
|
|
920
|
+
/** Fixed epoch base so window membership is deterministic. */
|
|
921
|
+
const RATE_T0 = 1_700_000_000_000;
|
|
922
|
+
|
|
923
|
+
/** A minimal existing-flow row (shape the NLB handler reads back). */
|
|
924
|
+
function existingRow(connId: string): Record<string, unknown> {
|
|
925
|
+
return {
|
|
926
|
+
connectionId: connId,
|
|
927
|
+
registration: 'registered',
|
|
928
|
+
nick: 'existing',
|
|
929
|
+
capNegotiating: false,
|
|
930
|
+
secure: true,
|
|
931
|
+
caps: [],
|
|
932
|
+
userModes: { tls: true },
|
|
933
|
+
lastSeen: RATE_T0,
|
|
934
|
+
connectedSince: RATE_T0 - 5000,
|
|
935
|
+
idleSince: RATE_T0,
|
|
936
|
+
version: 1,
|
|
937
|
+
revision: 3,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
interface NlbStub {
|
|
942
|
+
dynamo: DynamoDBDocumentClient;
|
|
943
|
+
/** QueryCommand inputs issued (rate-gate consultations). */
|
|
944
|
+
queries: Array<Record<string, unknown>>;
|
|
945
|
+
/** Whether the persist UpdateCommand ran (row written). */
|
|
946
|
+
persisted: boolean;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Stub client: `getRow` decides new-flow vs existing-flow; `gsiPages`
|
|
951
|
+
* scripts the sourceIp-GSI Query pages. Records queries + persists.
|
|
952
|
+
*/
|
|
953
|
+
function makeNlbStub(
|
|
954
|
+
getRow: Record<string, unknown> | undefined,
|
|
955
|
+
gsiPages: Array<Array<{ connectedSince: number }>> = [[]],
|
|
956
|
+
): NlbStub {
|
|
957
|
+
const queries: Array<Record<string, unknown>> = [];
|
|
958
|
+
const flags = { persisted: false };
|
|
959
|
+
let queryCall = 0;
|
|
960
|
+
const dynamo = {
|
|
961
|
+
send: async (cmd: unknown) => {
|
|
962
|
+
if (cmd instanceof GetCmd) return getRow === undefined ? {} : { Item: getRow };
|
|
963
|
+
if (cmd instanceof QueryCommand) {
|
|
964
|
+
queries.push(cmd.input as Record<string, unknown>);
|
|
965
|
+
const page = gsiPages[queryCall] ?? [];
|
|
966
|
+
const last = queryCall;
|
|
967
|
+
queryCall += 1;
|
|
968
|
+
return {
|
|
969
|
+
Items: page,
|
|
970
|
+
LastEvaluatedKey: last + 1 < gsiPages.length ? { connectionId: `p-${last}` } : undefined,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
if (cmd instanceof UpdCmd) {
|
|
974
|
+
flags.persisted = true;
|
|
975
|
+
return {};
|
|
976
|
+
}
|
|
977
|
+
throw new Error(`unexpected command: ${String(cmd)}`);
|
|
978
|
+
},
|
|
979
|
+
} as unknown as DynamoDBDocumentClient;
|
|
980
|
+
return {
|
|
981
|
+
dynamo,
|
|
982
|
+
get queries() {
|
|
983
|
+
return queries;
|
|
984
|
+
},
|
|
985
|
+
get persisted() {
|
|
986
|
+
return flags.persisted;
|
|
987
|
+
},
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** Fixed clock at `t` so the sliding-window cutoff is deterministic. */
|
|
992
|
+
function clockAt(t: number): Clock {
|
|
993
|
+
return { now: () => t };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
describe('NLB stream handler — per-IP new-flow rate limit', () => {
|
|
997
|
+
const RATE_CONFIG = makeTestServerConfig({
|
|
998
|
+
perIpConnectionRate: { max: 2, windowMs: 60_000 },
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
function opts(
|
|
1002
|
+
stub: NlbStub,
|
|
1003
|
+
clock: Clock = clockAt(RATE_T0),
|
|
1004
|
+
config: typeof RATE_CONFIG = RATE_CONFIG,
|
|
1005
|
+
) {
|
|
1006
|
+
return {
|
|
1007
|
+
dynamo: stub.dynamo,
|
|
1008
|
+
tables: {
|
|
1009
|
+
Connections: 'Connections',
|
|
1010
|
+
ChannelMeta: 'ChannelMeta',
|
|
1011
|
+
ChannelMembers: 'ChannelMembers',
|
|
1012
|
+
Nicks: 'Nicks',
|
|
1013
|
+
} as TablesConfig,
|
|
1014
|
+
serverConfig: config,
|
|
1015
|
+
motd: MOTD,
|
|
1016
|
+
managementApi: null,
|
|
1017
|
+
clock,
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
it('admits a new flow under the budget and persists its row', async () => {
|
|
1022
|
+
const stub = makeNlbStub(undefined, [[{ connectedSince: RATE_T0 - 1000 }]]);
|
|
1023
|
+
const res = await handleNlbStream(nlbEvent('10.0.1.1', '60001', 'PING :tok\r\n'), opts(stub));
|
|
1024
|
+
expect(res.statusCode).toBe(200);
|
|
1025
|
+
expect(stub.persisted).toBe(true);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it('rejects the over-budget new flow with 429 and writes no row', async () => {
|
|
1029
|
+
const stub = makeNlbStub(undefined, [
|
|
1030
|
+
[{ connectedSince: RATE_T0 - 1000 }, { connectedSince: RATE_T0 - 2000 }],
|
|
1031
|
+
]);
|
|
1032
|
+
const res = await handleNlbStream(nlbEvent('10.0.1.2', '60002', 'PING :tok\r\n'), opts(stub));
|
|
1033
|
+
expect(res.statusCode).toBe(429);
|
|
1034
|
+
expect(res.body).toBe('');
|
|
1035
|
+
expect(res.isBase64Encoded).toBe(false);
|
|
1036
|
+
expect(stub.persisted).toBe(false);
|
|
1037
|
+
expect(stub.queries.length).toBe(1);
|
|
1038
|
+
expect(stub.queries[0]?.IndexName).toBe('sourceIp-connectedSince');
|
|
1039
|
+
expect(stub.queries[0]?.ExpressionAttributeValues).toMatchObject({ ':ip': '10.0.1.2' });
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it('does not consult the rate budget for an existing flow (rate gates establishment only)', async () => {
|
|
1043
|
+
// Rate-limiting EVERY chunk invocation would starve an active IRC
|
|
1044
|
+
// session (each client line is a fresh invocation); the budget
|
|
1045
|
+
// applies to connection establishment — the first chunk of a flow.
|
|
1046
|
+
const connId = deriveNlbConnectionId('10.0.1.3', '60003');
|
|
1047
|
+
const stub = makeNlbStub(existingRow(connId), [
|
|
1048
|
+
[{ connectedSince: RATE_T0 - 1000 }, { connectedSince: RATE_T0 - 2000 }],
|
|
1049
|
+
]);
|
|
1050
|
+
const res = await handleNlbStream(nlbEvent('10.0.1.3', '60003', 'PING :tok\r\n'), opts(stub));
|
|
1051
|
+
expect(res.statusCode).toBe(200);
|
|
1052
|
+
expect(stub.queries.length).toBe(0);
|
|
1053
|
+
expect(stub.persisted).toBe(true);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it('window-reset: admissions older than the window stop consuming budget', async () => {
|
|
1057
|
+
// Budget of 1; one prior admission stamped at RATE_T0.
|
|
1058
|
+
const budgetOne = makeTestServerConfig({
|
|
1059
|
+
perIpConnectionRate: { max: 1, windowMs: 60_000 },
|
|
1060
|
+
});
|
|
1061
|
+
const row = [{ connectedSince: RATE_T0 }];
|
|
1062
|
+
// Inside the window (clock at RATE_T0+30 s): the admission counts →
|
|
1063
|
+
// the new flow is over budget and rejected.
|
|
1064
|
+
const early = makeNlbStub(undefined, [row]);
|
|
1065
|
+
const earlyRes = await handleNlbStream(
|
|
1066
|
+
nlbEvent('10.0.1.4', '60004', 'PING :tok\r\n'),
|
|
1067
|
+
opts(early, clockAt(RATE_T0 + 30_000), budgetOne),
|
|
1068
|
+
);
|
|
1069
|
+
expect(earlyRes.statusCode).toBe(429);
|
|
1070
|
+
|
|
1071
|
+
// Past the window (clock at RATE_T0+61 s): the cutoff advanced past
|
|
1072
|
+
// the admission → the budget recovered and the flow is admitted.
|
|
1073
|
+
const later = makeNlbStub(undefined, [row]);
|
|
1074
|
+
const laterRes = await handleNlbStream(
|
|
1075
|
+
nlbEvent('10.0.1.4', '60005', 'PING :tok\r\n'),
|
|
1076
|
+
opts(later, clockAt(RATE_T0 + 61_000), budgetOne),
|
|
1077
|
+
);
|
|
1078
|
+
expect(laterRes.statusCode).toBe(200);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
it('paginates the GSI query when the per-IP partition spans pages', async () => {
|
|
1082
|
+
const stub = makeNlbStub(undefined, [
|
|
1083
|
+
[{ connectedSince: RATE_T0 - 1000 }, { connectedSince: RATE_T0 - 2000 }],
|
|
1084
|
+
[{ connectedSince: RATE_T0 - 3000 }],
|
|
1085
|
+
]);
|
|
1086
|
+
const res = await handleNlbStream(nlbEvent('10.0.1.5', '60006', 'PING :tok\r\n'), opts(stub));
|
|
1087
|
+
expect(res.statusCode).toBe(429);
|
|
1088
|
+
expect(stub.queries.length).toBe(2);
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it('never consults the rate gate for a flow with missing flow headers', async () => {
|
|
1092
|
+
// Malformed/missing flow headers are rejected by header validation
|
|
1093
|
+
// (400) before any rate-gate consideration — and the stub's Get
|
|
1094
|
+
// (existing-flow probe) and Query (rate gate) both stay silent.
|
|
1095
|
+
const stub = makeNlbStub(undefined, [[]]);
|
|
1096
|
+
const event: NlbStreamEvent = {
|
|
1097
|
+
requestContext: { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:::tg' } },
|
|
1098
|
+
version: '2.0',
|
|
1099
|
+
body: Buffer.from('PING :tok\r\n', 'utf8').toString('base64'),
|
|
1100
|
+
isBase64Encoded: true,
|
|
1101
|
+
headers: { 'x-forwarded-port': '60007' },
|
|
1102
|
+
};
|
|
1103
|
+
const res = await handleNlbStream(event, opts(stub));
|
|
1104
|
+
expect(res.statusCode).toBe(400);
|
|
1105
|
+
expect(stub.queries.length).toBe(0);
|
|
1106
|
+
expect(stub.persisted).toBe(false);
|
|
1107
|
+
});
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
describe.skipIf(!available)('NLB per-IP new-flow rate limit (real GSI)', () => {
|
|
1111
|
+
beforeEach(async () => {
|
|
1112
|
+
fx = await makeFixture();
|
|
1113
|
+
});
|
|
1114
|
+
afterEach(async () => {
|
|
1115
|
+
if (fx) await fx.cleanup();
|
|
1116
|
+
fx = null;
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
/** Rate-pinned config: 2 new flows per 60 s window. */
|
|
1120
|
+
const cfg = makeTestServerConfig({ perIpConnectionRate: { max: 2, windowMs: 60_000 } });
|
|
1121
|
+
|
|
1122
|
+
function flowOpts(clock: Clock) {
|
|
1123
|
+
const f = requireFx();
|
|
1124
|
+
return {
|
|
1125
|
+
dynamo: f.client,
|
|
1126
|
+
tables: f.tables,
|
|
1127
|
+
serverConfig: cfg,
|
|
1128
|
+
motd: MOTD,
|
|
1129
|
+
messages: bindMessageStore(cfg),
|
|
1130
|
+
managementApi: f.mgmt,
|
|
1131
|
+
clock,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
it('rejects the over-budget new flow with 429 (no row written)', async () => {
|
|
1136
|
+
const t0 = { now: () => RATE_T0 };
|
|
1137
|
+
const first = await handleNlbStream(nlbEvent('10.0.3.1', '61001', 'PING :a\r\n'), flowOpts(t0));
|
|
1138
|
+
expect(first.statusCode).toBe(200);
|
|
1139
|
+
const second = await handleNlbStream(
|
|
1140
|
+
nlbEvent('10.0.3.1', '61002', 'PING :b\r\n'),
|
|
1141
|
+
flowOpts(t0),
|
|
1142
|
+
);
|
|
1143
|
+
expect(second.statusCode).toBe(200);
|
|
1144
|
+
const third = await handleNlbStream(nlbEvent('10.0.3.1', '61003', 'PING :c\r\n'), flowOpts(t0));
|
|
1145
|
+
expect(third.statusCode).toBe(429);
|
|
1146
|
+
expect(third.body).toBe('');
|
|
1147
|
+
// No row for the rejected flow.
|
|
1148
|
+
const row = await requireFx().client.send(
|
|
1149
|
+
new GetCommand({
|
|
1150
|
+
TableName: requireFx().tables.Connections,
|
|
1151
|
+
Key: { connectionId: deriveNlbConnectionId('10.0.3.1', '61003') },
|
|
1152
|
+
}),
|
|
1153
|
+
);
|
|
1154
|
+
expect(row.Item).toBeUndefined();
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
it('window-reset: the budget recovers once establishments age out', async () => {
|
|
1158
|
+
const t0 = { now: () => RATE_T0 };
|
|
1159
|
+
expect(
|
|
1160
|
+
(await handleNlbStream(nlbEvent('10.0.3.2', '61011', 'PING :a\r\n'), flowOpts(t0)))
|
|
1161
|
+
.statusCode,
|
|
1162
|
+
).toBe(200);
|
|
1163
|
+
expect(
|
|
1164
|
+
(await handleNlbStream(nlbEvent('10.0.3.2', '61012', 'PING :b\r\n'), flowOpts(t0)))
|
|
1165
|
+
.statusCode,
|
|
1166
|
+
).toBe(200);
|
|
1167
|
+
expect(
|
|
1168
|
+
(await handleNlbStream(nlbEvent('10.0.3.2', '61013', 'PING :c\r\n'), flowOpts(t0)))
|
|
1169
|
+
.statusCode,
|
|
1170
|
+
).toBe(429);
|
|
1171
|
+
// Past the window both establishments decayed → the budget recovered.
|
|
1172
|
+
const later = { now: () => RATE_T0 + 61_000 };
|
|
1173
|
+
const after = await handleNlbStream(
|
|
1174
|
+
nlbEvent('10.0.3.2', '61014', 'PING :d\r\n'),
|
|
1175
|
+
flowOpts(later),
|
|
1176
|
+
);
|
|
1177
|
+
expect(after.statusCode).toBe(200);
|
|
1178
|
+
});
|
|
1179
|
+
});
|