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
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
import { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
|
|
15
15
|
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
16
16
|
import {
|
|
17
|
-
type AccountStore,
|
|
18
17
|
EmptyMotdProvider,
|
|
19
18
|
InMemoryNickHistoryStore,
|
|
20
19
|
type MessageStore,
|
|
@@ -25,11 +24,11 @@ import {
|
|
|
25
24
|
StaticMotdProvider,
|
|
26
25
|
SystemClock,
|
|
27
26
|
} from '@serverless-ircd/irc-core';
|
|
28
|
-
import { resolveAccountStore } from '../account-store.js';
|
|
29
27
|
import { type LambdaConfigEnv, loadServerConfigFromLambdaEnv } from '../config-loader.js';
|
|
30
28
|
import { type DynamoServicesStore, loadDynamoServicesStore } from '../dynamo-services-store.js';
|
|
31
29
|
import { createDynamoDocumentClient } from '../dynamo.js';
|
|
32
30
|
import { bindMessageStore } from '../message-store.js';
|
|
31
|
+
import { parseWebOrigins } from '../origin-allowlist.js';
|
|
33
32
|
import { TABLE_KEYS, type TablesConfig } from '../tables.js';
|
|
34
33
|
import { handleConnect } from './connect.js';
|
|
35
34
|
import { handleDefault } from './default.js';
|
|
@@ -105,20 +104,15 @@ export interface HandlerDeps {
|
|
|
105
104
|
* DynamoDB-backed variant is a documented follow-up.
|
|
106
105
|
*/
|
|
107
106
|
messages: MessageStore;
|
|
108
|
-
/**
|
|
109
|
-
* SASL account verification source bound to every actor. Constructed
|
|
110
|
-
* once per cold start (memoised via {@link cachedDeps}) so it survives
|
|
111
|
-
* across warm Lambda invocations. `undefined` when no accounts are
|
|
112
|
-
* configured (the default) so `ctx.accounts` stays unset.
|
|
113
|
-
*/
|
|
114
|
-
accounts?: AccountStore;
|
|
115
107
|
/**
|
|
116
108
|
* Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
|
|
117
|
-
* OperServ + read-marker
|
|
118
|
-
* `Services` DynamoDB table into a
|
|
119
|
-
* `SERVICES_TABLE` is unset
|
|
120
|
-
*
|
|
121
|
-
*
|
|
109
|
+
* OperServ + read-marker + the unified SASL/PASS credential home),
|
|
110
|
+
* hydrated once per cold start from the `Services` DynamoDB table into a
|
|
111
|
+
* write-behind cache. `undefined` when `SERVICES_TABLE` is unset
|
|
112
|
+
* (services unbound → no NickServ/ChanServ routing, no SASL/PASS
|
|
113
|
+
* credential verify). The handler flushes pending writes after each
|
|
114
|
+
* `$default` / NLB frame so a Lambda freeze / evict does not lose
|
|
115
|
+
* services state.
|
|
122
116
|
*/
|
|
123
117
|
services?: DynamoServicesStore;
|
|
124
118
|
/**
|
|
@@ -136,6 +130,16 @@ export interface HandlerDeps {
|
|
|
136
130
|
* path applies (error-suffixed `382`).
|
|
137
131
|
*/
|
|
138
132
|
configLoader?: () => Promise<ServerConfig>;
|
|
133
|
+
/**
|
|
134
|
+
* Parsed `WEB_ORIGINS` allowlist for the `$connect` CSWSH defence.
|
|
135
|
+
* Empty set (the default) disables the check; non-empty causes
|
|
136
|
+
* {@link handleConnect} to reject browser-sent `Origin` headers that
|
|
137
|
+
* are not in the set with `403`. Built once per cold start from
|
|
138
|
+
* `process.env.WEB_ORIGINS` via {@link parseWebOrigins}; non-browser
|
|
139
|
+
* clients (curl, WeeChat, the `tcp-ws-forwarder`) never send `Origin`
|
|
140
|
+
* and always proceed regardless.
|
|
141
|
+
*/
|
|
142
|
+
webOrigins?: Set<string> | undefined;
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
/**
|
|
@@ -143,7 +147,12 @@ export interface HandlerDeps {
|
|
|
143
147
|
* construct events directly.
|
|
144
148
|
*/
|
|
145
149
|
export type ConnectEvent = {
|
|
146
|
-
requestContext: {
|
|
150
|
+
requestContext: {
|
|
151
|
+
routeKey: '$connect';
|
|
152
|
+
connectionId: string;
|
|
153
|
+
/** Client source IP — feeds the per-IP admission gates. */
|
|
154
|
+
identity?: { sourceIp?: string };
|
|
155
|
+
};
|
|
147
156
|
headers?: Record<string, string>;
|
|
148
157
|
};
|
|
149
158
|
export type DisconnectEvent = {
|
|
@@ -196,7 +205,16 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
|
|
|
196
205
|
tables: deps.tables,
|
|
197
206
|
connectionId: connId,
|
|
198
207
|
serverConfig: deps.serverConfig,
|
|
199
|
-
secWebSocketProtocol: event.headers
|
|
208
|
+
secWebSocketProtocol: readHeader(event.headers, 'Sec-WebSocket-Protocol'),
|
|
209
|
+
// APIGW normalises header keys to lowercase, but readHeader
|
|
210
|
+
// tolerates either casing so a future proxy / stage mutation
|
|
211
|
+
// cannot silently bypass the CSWSH defence.
|
|
212
|
+
originHeader: readHeader(event.headers, 'Origin'),
|
|
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,
|
|
200
218
|
});
|
|
201
219
|
if (outcome.admitted) {
|
|
202
220
|
if (outcome.subprotocol !== null) {
|
|
@@ -233,7 +251,6 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
|
|
|
233
251
|
serverConfig: deps.serverConfig,
|
|
234
252
|
motd: deps.motd,
|
|
235
253
|
messages: deps.messages,
|
|
236
|
-
...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
|
|
237
254
|
...(deps.services !== undefined ? { services: deps.services } : {}),
|
|
238
255
|
...(deps.history !== undefined ? { history: deps.history } : {}),
|
|
239
256
|
...(deps.configLoader !== undefined ? { configLoader: deps.configLoader } : {}),
|
|
@@ -333,7 +350,24 @@ export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStream
|
|
|
333
350
|
cachedDeps = await cachedDepsPromise;
|
|
334
351
|
cachedDepsPromise = undefined;
|
|
335
352
|
}
|
|
336
|
-
|
|
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> {
|
|
337
371
|
return handleNlbStream(event, {
|
|
338
372
|
dynamo: deps.dynamo,
|
|
339
373
|
tables: deps.tables,
|
|
@@ -341,9 +375,11 @@ export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStream
|
|
|
341
375
|
motd: deps.motd,
|
|
342
376
|
messages: deps.messages,
|
|
343
377
|
managementApi: deps.managementApi,
|
|
344
|
-
...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
|
|
345
378
|
...(deps.services !== undefined ? { services: deps.services } : {}),
|
|
346
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,
|
|
347
383
|
});
|
|
348
384
|
}
|
|
349
385
|
|
|
@@ -359,10 +395,16 @@ export {
|
|
|
359
395
|
* Builds a {@link HandlerDeps} from the current `process.env`.
|
|
360
396
|
*
|
|
361
397
|
* Exposed so tests can construct production-shaped deps without re-running
|
|
362
|
-
* the env parsing. Async because it scans the `
|
|
363
|
-
* (via {@link
|
|
364
|
-
* start — the {@link
|
|
365
|
-
* complete before any reducer calls `
|
|
398
|
+
* the env parsing. Async because it scans the `Services` DynamoDB table
|
|
399
|
+
* (via {@link loadDynamoServicesStore}) to pre-load the services snapshot
|
|
400
|
+
* at cold start — the {@link ServicesStore} port is synchronous, so the
|
|
401
|
+
* scan must complete before any reducer calls `verifyNick` /
|
|
402
|
+
* `verifyCertFP`.
|
|
403
|
+
*
|
|
404
|
+
* After the services scan, the `SASL_ACCOUNTS` env-var seed is ingested
|
|
405
|
+
* into the services store via `registerNick` (idempotent — existing
|
|
406
|
+
* registrations, including NickServ-registered nicks, win over the env
|
|
407
|
+
* seed).
|
|
366
408
|
*/
|
|
367
409
|
export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<HandlerDeps> {
|
|
368
410
|
const cfg = loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
|
|
@@ -378,13 +420,24 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
|
|
|
378
420
|
const motd = cfg.motdLines.length > 0 ? new StaticMotdProvider(cfg.motdLines) : EmptyMotdProvider;
|
|
379
421
|
const messages = bindMessageStore(cfg);
|
|
380
422
|
const history = new InMemoryNickHistoryStore(SystemClock);
|
|
381
|
-
const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
|
|
382
423
|
const servicesTable = typeof env.SERVICES_TABLE === 'string' ? env.SERVICES_TABLE : undefined;
|
|
383
424
|
const services = await loadDynamoServicesStore(dynamo, servicesTable, SystemClock);
|
|
425
|
+
if (services !== undefined) {
|
|
426
|
+
for (const acct of cfg.saslAccounts) {
|
|
427
|
+
services.registerNick(acct.username, acct.password, '');
|
|
428
|
+
}
|
|
429
|
+
}
|
|
384
430
|
// REHASH reload source: re-read the live env so a rotated oper password
|
|
385
431
|
// or MOTD takes effect on the next REHASH without a redeploy.
|
|
386
432
|
const configLoader = async (): Promise<ServerConfig> =>
|
|
387
433
|
loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
|
|
434
|
+
// CSWSH defence — parsed once per cold start so every `$connect`
|
|
435
|
+
// reuses the same set. Empty set (the default when `WEB_ORIGINS` is
|
|
436
|
+
// unset) disables the check; non-empty denies browser-sent origins
|
|
437
|
+
// that are not in the set. Non-browser clients never send `Origin`.
|
|
438
|
+
const webOrigins = parseWebOrigins(
|
|
439
|
+
typeof env.WEB_ORIGINS === 'string' ? env.WEB_ORIGINS : undefined,
|
|
440
|
+
);
|
|
388
441
|
return {
|
|
389
442
|
dynamo,
|
|
390
443
|
tables,
|
|
@@ -394,7 +447,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
|
|
|
394
447
|
messages,
|
|
395
448
|
history,
|
|
396
449
|
configLoader,
|
|
397
|
-
...(
|
|
450
|
+
...(webOrigins.size > 0 ? { webOrigins } : {}),
|
|
398
451
|
...(services !== undefined ? { services } : {}),
|
|
399
452
|
};
|
|
400
453
|
}
|
|
@@ -411,3 +464,23 @@ function tablesConfigFromEnv(env: NodeJS.ProcessEnv): TablesConfig {
|
|
|
411
464
|
}
|
|
412
465
|
return out;
|
|
413
466
|
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Case-insensitive header lookup against an APIGW `event.headers` record.
|
|
470
|
+
*
|
|
471
|
+
* API Gateway WebSocket normalises header keys to lowercase, but HTTP
|
|
472
|
+
* proxies in front of it (ALB, CloudFront, custom domains) can preserve
|
|
473
|
+
* the original case. A case-sensitive lookup would silently miss the
|
|
474
|
+
* header under one of those routes — for the `Origin` CSWSH check that
|
|
475
|
+
* is a security hole, so this helper walks both casings. Returns `null`
|
|
476
|
+
* when absent so callers can pass it straight to handlers that
|
|
477
|
+
* distinguish "header absent" from "header empty".
|
|
478
|
+
*/
|
|
479
|
+
function readHeader(headers: Record<string, string> | undefined, name: string): string | null {
|
|
480
|
+
if (headers === undefined) return null;
|
|
481
|
+
const direct = headers[name];
|
|
482
|
+
if (typeof direct === 'string') return direct;
|
|
483
|
+
const lower = headers[name.toLowerCase()];
|
|
484
|
+
if (typeof lower === 'string') return lower;
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
@@ -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,15 +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
|
-
type AccountStore,
|
|
48
56
|
type ChannelState,
|
|
49
57
|
type Clock,
|
|
50
58
|
type ConnectionState,
|
|
51
59
|
type IdFactory,
|
|
60
|
+
MAX_BUFFER_BYTES,
|
|
52
61
|
type MessageStore,
|
|
53
62
|
type MotdProvider,
|
|
54
63
|
type NickHistoryStore,
|
|
@@ -56,6 +65,7 @@ import {
|
|
|
56
65
|
type ServerConfig,
|
|
57
66
|
SystemClock,
|
|
58
67
|
UuidIdFactory,
|
|
68
|
+
byteLength,
|
|
59
69
|
} from '@serverless-ircd/irc-core';
|
|
60
70
|
import {
|
|
61
71
|
type ActorChannelAccess,
|
|
@@ -65,6 +75,7 @@ import {
|
|
|
65
75
|
import { TcpByteStreamTransport } from '@serverless-ircd/irc-server';
|
|
66
76
|
import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
|
|
67
77
|
import type { DynamoServicesStore } from '../dynamo-services-store.js';
|
|
78
|
+
import { countConnectionsForIp } from '../ip-admission.js';
|
|
68
79
|
import type { MarshalledConnection } from '../serialize.js';
|
|
69
80
|
import { CONNECTION_VERSION } from '../serialize.js';
|
|
70
81
|
import type { TablesConfig } from '../tables.js';
|
|
@@ -125,6 +136,30 @@ export function deriveNlbConnectionId(sourceIp: string, sourcePort: string): str
|
|
|
125
136
|
return `${NLB_CONN_PREFIX}${safeIp}-${sourcePort}`;
|
|
126
137
|
}
|
|
127
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
|
+
|
|
128
163
|
// ---------------------------------------------------------------------------
|
|
129
164
|
// Handler
|
|
130
165
|
// ---------------------------------------------------------------------------
|
|
@@ -137,12 +172,11 @@ export interface NlbStreamParams {
|
|
|
137
172
|
motd: MotdProvider;
|
|
138
173
|
/** Chat-history persistence source (same as the wss `$default` path). */
|
|
139
174
|
messages?: MessageStore;
|
|
140
|
-
/** SASL account verification source. */
|
|
141
|
-
accounts?: AccountStore;
|
|
142
175
|
/**
|
|
143
176
|
* Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
|
|
144
|
-
* OperServ + read-marker
|
|
145
|
-
* does not lose services
|
|
177
|
+
* OperServ + read-marker + the unified SASL/PASS credential home).
|
|
178
|
+
* Flushed after each frame so a Lambda freeze does not lose services
|
|
179
|
+
* state.
|
|
146
180
|
*/
|
|
147
181
|
services?: DynamoServicesStore;
|
|
148
182
|
/** mTLS identity source for SASL EXTERNAL. */
|
|
@@ -169,7 +203,9 @@ export interface NlbStreamParams {
|
|
|
169
203
|
* Whether the NLB listener terminates TLS for this flow. Set to `true`
|
|
170
204
|
* when the target group fronts a TLS listener (the `irc+tls://` port) so
|
|
171
205
|
* the connection surfaces user mode `S`; omit / set `false` for a plain
|
|
172
|
-
* 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.
|
|
173
209
|
*/
|
|
174
210
|
secure?: boolean;
|
|
175
211
|
}
|
|
@@ -197,9 +233,27 @@ export async function handleNlbStream(
|
|
|
197
233
|
? Buffer.from(event.body, 'base64').toString('utf8')
|
|
198
234
|
: event.body;
|
|
199
235
|
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
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;
|
|
203
257
|
const connId = deriveNlbConnectionId(sourceIp, sourcePort);
|
|
204
258
|
|
|
205
259
|
// 3. Load the existing row (if any) and split the transport buffer.
|
|
@@ -207,8 +261,30 @@ export async function handleNlbStream(
|
|
|
207
261
|
let state: ConnectionState;
|
|
208
262
|
let transportBuffer: string;
|
|
209
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
|
+
}
|
|
210
286
|
state = createInitialConnectionState(connId, now);
|
|
211
|
-
|
|
287
|
+
state.host = sourceIp;
|
|
212
288
|
transportBuffer = '';
|
|
213
289
|
} else {
|
|
214
290
|
state = unmarshalState(existing);
|
|
@@ -216,11 +292,39 @@ export async function handleNlbStream(
|
|
|
216
292
|
}
|
|
217
293
|
|
|
218
294
|
// 4. Frame the chunk through the persistent TCP transport.
|
|
219
|
-
|
|
295
|
+
let overflowed = false;
|
|
296
|
+
const transport = new TcpByteStreamTransport({
|
|
297
|
+
onOverflow: () => {
|
|
298
|
+
overflowed = true;
|
|
299
|
+
},
|
|
300
|
+
});
|
|
220
301
|
transport.restore(transportBuffer);
|
|
221
302
|
const lines = transport.feed(data);
|
|
222
303
|
const newBuffer = transport.getBuffer();
|
|
223
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
|
+
|
|
224
328
|
// Mark activity.
|
|
225
329
|
state.lastSeen = now;
|
|
226
330
|
|
|
@@ -264,7 +368,6 @@ export async function handleNlbStream(
|
|
|
264
368
|
// WsTextFrameTransport splits on \r\n (no-op for already-split lines).
|
|
265
369
|
transport: new WsTextFrameTransport(),
|
|
266
370
|
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
267
|
-
...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
|
|
268
371
|
...(params.services !== undefined ? { services: params.services } : {}),
|
|
269
372
|
...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
|
|
270
373
|
...(params.history !== undefined ? { history: params.history } : {}),
|
|
@@ -288,6 +391,7 @@ export async function handleNlbStream(
|
|
|
288
391
|
state,
|
|
289
392
|
now,
|
|
290
393
|
newBuffer,
|
|
394
|
+
sourceIp !== 'unknown' ? sourceIp : undefined,
|
|
291
395
|
);
|
|
292
396
|
|
|
293
397
|
// Drain any services write-behind ops so a Lambda freeze / evict does
|
|
@@ -365,6 +469,14 @@ function unmarshalConnectionTyped(row: MarshalledConnection): ConnectionState {
|
|
|
365
469
|
* UpdateCommand. The buffer is written as a plain string attribute — when
|
|
366
470
|
* empty it is REMOVE'd so stale buffers from a previous chunk do not
|
|
367
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).
|
|
368
480
|
*/
|
|
369
481
|
async function persistStateAndBuffer(
|
|
370
482
|
dynamo: DynamoDBDocumentClient,
|
|
@@ -373,6 +485,7 @@ async function persistStateAndBuffer(
|
|
|
373
485
|
state: ConnectionState,
|
|
374
486
|
now: number,
|
|
375
487
|
transportBuffer: string,
|
|
488
|
+
sourceIp?: string,
|
|
376
489
|
): Promise<void> {
|
|
377
490
|
// Build the standard persist expression (same fields as handleDefault's
|
|
378
491
|
// buildPersistUpdate, minus the joinedChannels which the membership
|
|
@@ -382,19 +495,27 @@ async function persistStateAndBuffer(
|
|
|
382
495
|
':capN': state.capNegotiating,
|
|
383
496
|
':caps': [...state.caps],
|
|
384
497
|
':um': { ...state.userModes },
|
|
498
|
+
':sec': state.secure,
|
|
385
499
|
':ls': state.lastSeen,
|
|
386
500
|
':is': now,
|
|
387
501
|
':v': CONNECTION_VERSION,
|
|
502
|
+
':cs': state.connectedSince ?? now,
|
|
388
503
|
};
|
|
389
504
|
const setNames = [
|
|
390
505
|
'registration = :reg',
|
|
391
506
|
'capNegotiating = :capN',
|
|
392
507
|
'caps = :caps',
|
|
393
508
|
'userModes = :um',
|
|
509
|
+
'secure = :sec',
|
|
394
510
|
'lastSeen = :ls',
|
|
395
511
|
'idleSince = :is',
|
|
396
512
|
'version = :v',
|
|
513
|
+
'connectedSince = :cs',
|
|
397
514
|
];
|
|
515
|
+
if (sourceIp !== undefined) {
|
|
516
|
+
values[':sip'] = sourceIp;
|
|
517
|
+
setNames.push('sourceIp = :sip');
|
|
518
|
+
}
|
|
398
519
|
const removeNames: string[] = [];
|
|
399
520
|
const nameMap: Record<string, string> = {};
|
|
400
521
|
|
|
@@ -16,17 +16,15 @@ export { cleanupConnection } from './aws-runtime.js';
|
|
|
16
16
|
export { TABLE_KEYS, TABLE_NAMES, tablesConfigFromNames } from './tables.js';
|
|
17
17
|
export type { TableName, TablesConfig } from './tables.js';
|
|
18
18
|
export { createDynamoDocumentClient } from './dynamo.js';
|
|
19
|
-
export {
|
|
20
|
-
DynamoAccountStore,
|
|
21
|
-
type HashedAccountCredential,
|
|
22
|
-
hashAccountCredential,
|
|
23
|
-
loadDynamoAccountStore,
|
|
24
|
-
} from './dynamo-account-store.js';
|
|
25
|
-
export { bindAccountStore, putAccountCredential, resolveAccountStore } from './account-store.js';
|
|
26
19
|
export {
|
|
27
20
|
DynamoServicesStore,
|
|
28
21
|
loadDynamoServicesStore,
|
|
29
22
|
} from './dynamo-services-store.js';
|
|
23
|
+
export {
|
|
24
|
+
type HashedAccountCredential,
|
|
25
|
+
hashAccountCredential,
|
|
26
|
+
verifyHashedPassword,
|
|
27
|
+
} from '@serverless-ircd/irc-core';
|
|
30
28
|
export { AwsStats } from './stats.js';
|
|
31
29
|
export type { AwsStatsOptions } from './stats.js';
|
|
32
30
|
export {
|
|
@@ -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
|
+
}
|