serverless-ircd 0.2.0 → 0.4.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 +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +258 -18
- package/README.md +72 -30
- package/apps/aws-stack/README.md +2 -2
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +11 -1
- package/apps/cf-worker/wrangler.toml +69 -19
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +11 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +46 -3
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/package.json +19 -16
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +124 -34
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +62 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +43 -2
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +120 -9
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +13 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
- package/packages/aws-adapter/tests/account-store.test.ts +279 -0
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +194 -47
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +40 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +62 -0
- package/packages/cf-adapter/src/connection-do.ts +181 -31
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +58 -8
- package/packages/cf-adapter/src/index.ts +11 -9
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/worker/main.ts +9 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- 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 +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +8 -2
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +11 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +4 -3
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +26 -19
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +84 -3
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +3 -0
- package/packages/irc-core/src/ports.ts +240 -7
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +60 -1
- package/packages/irc-core/tests/account-store.test.ts +131 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +256 -19
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +170 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +182 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +21 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +617 -1
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +7 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +9 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +79 -0
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/docs/AWS-Adapter-Architecture.md +0 -494
- package/docs/AWS-Deployment.md +0 -1107
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -1
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -440
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- package/webircgateway/webircgateway.svg +0 -3
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
* so a future `TransactWriteItems` can span Connections ↔ membership
|
|
19
19
|
* atomically. (Atomicity across Connections/ChannelMembers lands in the
|
|
20
20
|
* membership-transaction follow-up.)
|
|
21
|
-
* • `Accounts`
|
|
21
|
+
* • `Accounts` stores SASL PLAIN credentials as scrypt
|
|
22
|
+
* `HashedAccountCredential` rows (`{ account, algorithm, salt,
|
|
23
|
+
* hash }`, base64) — never plaintext. Loaded at Lambda cold start
|
|
24
|
+
* by `loadDynamoAccountStore` and seeded via
|
|
25
|
+
* `putAccountCredential`.
|
|
22
26
|
* • `Connections.idleSince` is the DynamoDB TTL attribute; idle connections
|
|
23
27
|
* are reaped automatically in addition to the explicit sweeper.
|
|
24
28
|
*/
|
|
@@ -19,6 +19,17 @@ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc
|
|
|
19
19
|
export interface LambdaConfigEnv {
|
|
20
20
|
SERVER_NAME?: string;
|
|
21
21
|
NETWORK_NAME?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Server version surfaced in `002`/`004`/`351`/`371`. When unset the
|
|
24
|
+
* schema default ({@link DEFAULT_SERVER_VERSION}) applies.
|
|
25
|
+
*/
|
|
26
|
+
SERVER_VERSION?: string;
|
|
27
|
+
/**
|
|
28
|
+
* "Created" text for `003 RPL_CREATED`. A numeric string is parsed into
|
|
29
|
+
* an epoch-ms number so the reducer formats it as a UTC timestamp; any
|
|
30
|
+
* other value is passed through verbatim.
|
|
31
|
+
*/
|
|
32
|
+
CREATED_AT?: string;
|
|
22
33
|
MOTD?: string;
|
|
23
34
|
MAX_CLIENTS?: string;
|
|
24
35
|
CHANNEL_PREFIXES?: string;
|
|
@@ -31,6 +42,13 @@ export interface LambdaConfigEnv {
|
|
|
31
42
|
TOPIC_LEN?: string;
|
|
32
43
|
MAX_LIST_ENTRIES?: string;
|
|
33
44
|
QUIT_MESSAGE?: string;
|
|
45
|
+
/**
|
|
46
|
+
* SASL PLAIN accounts. Newline-delimited `username:password` pairs
|
|
47
|
+
* (same convention as the CF adapter's `SASL_ACCOUNTS`). Parsed into
|
|
48
|
+
* the `saslAccounts` config field so {@link bindAccountStore} can seed
|
|
49
|
+
* an `InMemoryAccountStore` at boot.
|
|
50
|
+
*/
|
|
51
|
+
SASL_ACCOUNTS?: string;
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
/**
|
|
@@ -46,6 +64,12 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
|
|
|
46
64
|
serverName: env.SERVER_NAME,
|
|
47
65
|
networkName: env.NETWORK_NAME,
|
|
48
66
|
};
|
|
67
|
+
if (env.SERVER_VERSION !== undefined) {
|
|
68
|
+
input.serverVersion = env.SERVER_VERSION;
|
|
69
|
+
}
|
|
70
|
+
if (env.CREATED_AT !== undefined) {
|
|
71
|
+
input.createdAt = parseCreatedAt(env.CREATED_AT);
|
|
72
|
+
}
|
|
49
73
|
if (env.MOTD !== undefined && env.MOTD.length > 0) {
|
|
50
74
|
input.motdLines = env.MOTD.split('\n');
|
|
51
75
|
}
|
|
@@ -84,9 +108,47 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
|
|
|
84
108
|
if (env.QUIT_MESSAGE !== undefined) {
|
|
85
109
|
input.quitMessage = env.QUIT_MESSAGE;
|
|
86
110
|
}
|
|
111
|
+
if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
|
|
112
|
+
input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
|
|
113
|
+
}
|
|
87
114
|
return input;
|
|
88
115
|
}
|
|
89
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Parses the `CREATED_AT` env var into the schema's `createdAt` field.
|
|
119
|
+
*
|
|
120
|
+
* A purely numeric string is treated as epoch-ms (returned as a number so
|
|
121
|
+
* the reducer formats it as a UTC timestamp); any other value is returned
|
|
122
|
+
* verbatim as a human-readable string.
|
|
123
|
+
*/
|
|
124
|
+
function parseCreatedAt(raw: string): string | number {
|
|
125
|
+
if (/^\d+$/.test(raw.trim())) {
|
|
126
|
+
const n = Number(raw.trim());
|
|
127
|
+
if (Number.isFinite(n)) return n;
|
|
128
|
+
}
|
|
129
|
+
return raw;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parses a newline-delimited `username:password` string into the config's
|
|
134
|
+
* `saslAccounts` shape. Malformed entries (missing colon, empty fields)
|
|
135
|
+
* are skipped so a trailing newline or partial edit does not break boot.
|
|
136
|
+
*/
|
|
137
|
+
function parseSaslAccountsLines(raw: string): Array<{ username: string; password: string }> {
|
|
138
|
+
const out: Array<{ username: string; password: string }> = [];
|
|
139
|
+
for (const line of raw.split('\n')) {
|
|
140
|
+
const trimmed = line.trim();
|
|
141
|
+
if (trimmed.length === 0) continue;
|
|
142
|
+
const sep = trimmed.indexOf(':');
|
|
143
|
+
if (sep <= 0) continue;
|
|
144
|
+
const username = trimmed.slice(0, sep);
|
|
145
|
+
const password = trimmed.slice(sep + 1);
|
|
146
|
+
if (username.length === 0 || password.length === 0) continue;
|
|
147
|
+
out.push({ username, password });
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
90
152
|
/**
|
|
91
153
|
* Loads and validates the server config from a Lambda env. Throws a
|
|
92
154
|
* readable `Error` listing every invalid field on failure.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB-backed {@link AccountStore} primitives for the AWS adapter.
|
|
3
|
+
*
|
|
4
|
+
* The `Accounts` DynamoDB table stores SASL PLAIN credentials as
|
|
5
|
+
* **scrypt hashes** (never plaintext). Each row is a flat
|
|
6
|
+
* {@link HashedAccountCredential} (`{ account, algorithm, salt, hash }`).
|
|
7
|
+
*
|
|
8
|
+
* Two-phase construction keeps the `AccountStore` port synchronous:
|
|
9
|
+
* 1. {@link loadDynamoAccountStore} (async) scans the table at Lambda
|
|
10
|
+
* cold start and returns a fully-populated `DynamoAccountStore`.
|
|
11
|
+
* 2. `DynamoAccountStore(entries)` (sync constructor) takes the
|
|
12
|
+
* pre-loaded entries. The class itself has zero DynamoDB
|
|
13
|
+
* coupling — it works equally well with entries from any source.
|
|
14
|
+
*
|
|
15
|
+
* This mirrors the pre-load pattern documented in
|
|
16
|
+
* `irc-core/src/ports.ts` and used by `MotdProvider` / `MessageStore`:
|
|
17
|
+
* adapters that need an async backend pre-load the data at boot into a
|
|
18
|
+
* synchronously-readable store.
|
|
19
|
+
*
|
|
20
|
+
* The scrypt hashing helpers (`hashAccountCredential` /
|
|
21
|
+
* `verifyHashedPassword` + the `HashedAccountCredential` type) live in
|
|
22
|
+
* `irc-core` and are shared with the CF adapter's `D1AccountStore`. They
|
|
23
|
+
* are re-exported below so existing call sites importing from this module
|
|
24
|
+
* keep working.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ScanCommandOutput } from '@aws-sdk/client-dynamodb';
|
|
28
|
+
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
29
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
30
|
+
import {
|
|
31
|
+
type HashedAccountCredential,
|
|
32
|
+
HashedAccountStore,
|
|
33
|
+
hashAccountCredential,
|
|
34
|
+
verifyHashedPassword,
|
|
35
|
+
} from '@serverless-ircd/irc-core';
|
|
36
|
+
|
|
37
|
+
// Re-export so callers importing these from `@serverless-ircd/aws-adapter`
|
|
38
|
+
// (the historical home of the hashing helpers) keep compiling.
|
|
39
|
+
export { type HashedAccountCredential, hashAccountCredential, verifyHashedPassword };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Synchronous {@link AccountStore} backed by pre-loaded hashed credentials.
|
|
43
|
+
*
|
|
44
|
+
* Now a re-export of the shared `HashedAccountStore` from `irc-core` —
|
|
45
|
+
* both adapters use one store implementation. The AWS-specific surface
|
|
46
|
+
* (`loadDynamoAccountStore`) lives below; it diverges from CF only at the
|
|
47
|
+
* scan boundary.
|
|
48
|
+
*/
|
|
49
|
+
export { HashedAccountStore as DynamoAccountStore };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Scans the `Accounts` table and returns a fully-populated
|
|
53
|
+
* {@link DynamoAccountStore}, or `undefined` when the table has no rows.
|
|
54
|
+
*
|
|
55
|
+
* Handles pagination (`LastEvaluatedKey`) so tables larger than the 1 MB
|
|
56
|
+
* scan page are fully loaded. Rows missing required attributes are
|
|
57
|
+
* silently skipped.
|
|
58
|
+
*/
|
|
59
|
+
export async function loadDynamoAccountStore(
|
|
60
|
+
docClient: DynamoDBDocumentClient,
|
|
61
|
+
tableName: string,
|
|
62
|
+
): Promise<HashedAccountStore | undefined> {
|
|
63
|
+
const entries: HashedAccountCredential[] = [];
|
|
64
|
+
let exclusiveStartKey: Record<string, unknown> | undefined = undefined;
|
|
65
|
+
do {
|
|
66
|
+
const result: ScanCommandOutput = await docClient.send(
|
|
67
|
+
new ScanCommand({
|
|
68
|
+
TableName: tableName,
|
|
69
|
+
ExclusiveStartKey: exclusiveStartKey,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
for (const item of result.Items ?? []) {
|
|
73
|
+
const account = typeof item.account === 'string' ? item.account : undefined;
|
|
74
|
+
const algorithm = typeof item.algorithm === 'string' ? item.algorithm : undefined;
|
|
75
|
+
const salt = typeof item.salt === 'string' ? item.salt : undefined;
|
|
76
|
+
const hash = typeof item.hash === 'string' ? item.hash : undefined;
|
|
77
|
+
if (
|
|
78
|
+
account !== undefined &&
|
|
79
|
+
algorithm !== undefined &&
|
|
80
|
+
salt !== undefined &&
|
|
81
|
+
hash !== undefined
|
|
82
|
+
) {
|
|
83
|
+
entries.push({
|
|
84
|
+
account,
|
|
85
|
+
algorithm: algorithm as HashedAccountCredential['algorithm'],
|
|
86
|
+
salt,
|
|
87
|
+
hash,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
exclusiveStartKey = result.LastEvaluatedKey;
|
|
92
|
+
} while (exclusiveStartKey !== undefined);
|
|
93
|
+
if (entries.length === 0) return undefined;
|
|
94
|
+
return new HashedAccountStore(entries);
|
|
95
|
+
}
|
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
* Persists a fresh `Connections` row keyed by the APIGW connection id.
|
|
5
5
|
* Subsequent `$default` invocations load this row, run the actor, and
|
|
6
6
|
* persist the mutated state back.
|
|
7
|
+
*
|
|
8
|
+
* Admission gate: before writing the row, the handler counts the live
|
|
9
|
+
* `Connections` rows and rejects the upgrade with `429 Too Many Requests`
|
|
10
|
+
* once `serverConfig.maxClients` is reached. A `429` (not `403`) is used
|
|
11
|
+
* because the rejection is a capacity / back-pressure signal — the client
|
|
12
|
+
* is welcome to retry — rather than an authorisation failure.
|
|
7
13
|
*/
|
|
8
14
|
|
|
9
15
|
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
10
|
-
import { PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
16
|
+
import { PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
11
17
|
import type { ParsedServerConfig } from '@serverless-ircd/irc-core';
|
|
18
|
+
import { type AdmissionOutcome, decideConnectAdmission } from '../admission.js';
|
|
12
19
|
import { marshalConnection } from '../serialize.js';
|
|
13
20
|
import type { TablesConfig } from '../tables.js';
|
|
14
21
|
import { createInitialConnectionState } from './state.js';
|
|
@@ -20,20 +27,43 @@ export interface ConnectParams {
|
|
|
20
27
|
connectionId: string;
|
|
21
28
|
/** Wall clock used to seed `connectedSince` / `idleSince` (default: now). */
|
|
22
29
|
now?: number;
|
|
23
|
-
/**
|
|
24
|
-
serverConfig
|
|
30
|
+
/** Server config — consulted for the `maxClients` admission cap. */
|
|
31
|
+
serverConfig: ParsedServerConfig;
|
|
25
32
|
}
|
|
26
33
|
|
|
34
|
+
/** Re-exported so the dispatcher imports the outcome type from one place. */
|
|
35
|
+
export type { AdmissionOutcome as ConnectOutcome } from '../admission.js';
|
|
36
|
+
|
|
27
37
|
/**
|
|
28
|
-
*
|
|
38
|
+
* Enforces the `maxClients` admission cap, then inserts the Connections
|
|
39
|
+
* row for a fresh WebSocket upgrade.
|
|
40
|
+
*
|
|
41
|
+
* Idempotent on success: APIGW retries `$connect` on transient failures,
|
|
42
|
+
* so the write uses `PutCommand` (overwrites) rather than a conditional
|
|
43
|
+
* insert.
|
|
44
|
+
*
|
|
45
|
+
* TOCTOU note: DynamoDB cannot conditionally write based on a row count,
|
|
46
|
+
* so the count-then-put sequence has a race window. This is an accepted
|
|
47
|
+
* trade-off for an admission cap — it bounds growth under steady load
|
|
48
|
+
* rather than enforcing a hard invariant. Two concurrent upgrades that
|
|
49
|
+
* both observe `count == cap - 1` will both be admitted.
|
|
29
50
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
51
|
+
* Per-IP admission (`maxConnectionsPerIp`) is intentionally NOT enforced
|
|
52
|
+
* here. irc-core's in-memory `AdmissionStats` is unsuitable for Lambda
|
|
53
|
+
* (each invocation is a fresh process with no shared counters), and a
|
|
54
|
+
* filtered full-table `Scan` per `$connect` would double the DynamoDB
|
|
55
|
+
* cost on every connect (and `maxConnectionsPerIp` is on by default).
|
|
56
|
+
* The correct fix is to persist `sourceIp` on the Connections row and
|
|
57
|
+
* add a DynamoDB GSI keyed by `sourceIp`, then feed the GSI's count
|
|
58
|
+
* into {@link decideConnectAdmission}'s `perIp` branch. That schema
|
|
59
|
+
* change is tracked as a follow-up; the pure policy already supports it.
|
|
32
60
|
*/
|
|
33
|
-
export async function handleConnect(params: ConnectParams): Promise<
|
|
61
|
+
export async function handleConnect(params: ConnectParams): Promise<AdmissionOutcome> {
|
|
34
62
|
const now = params.now ?? Date.now();
|
|
63
|
+
const total = await countConnections(params.dynamo, params.tables.Connections);
|
|
64
|
+
const outcome = decideConnectAdmission({ total }, { maxClients: params.serverConfig.maxClients });
|
|
65
|
+
if (!outcome.admitted) return outcome;
|
|
35
66
|
const state = createInitialConnectionState(params.connectionId, now);
|
|
36
|
-
void params.serverConfig; // reserved for max-clients gating (later ticket)
|
|
37
67
|
const row = marshalConnection(state, now);
|
|
38
68
|
await params.dynamo.send(
|
|
39
69
|
new PutCommand({
|
|
@@ -41,4 +71,30 @@ export async function handleConnect(params: ConnectParams): Promise<void> {
|
|
|
41
71
|
Item: row,
|
|
42
72
|
}),
|
|
43
73
|
);
|
|
74
|
+
return outcome;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Paginated `Scan` with `Select: COUNT` over the `Connections` table.
|
|
79
|
+
* A single Scan page caps at 1MB, so the loop follows
|
|
80
|
+
* `LastEvaluatedKey` until every page has been summed.
|
|
81
|
+
*/
|
|
82
|
+
async function countConnections(
|
|
83
|
+
dynamo: DynamoDBDocumentClient,
|
|
84
|
+
tableName: string,
|
|
85
|
+
): Promise<number> {
|
|
86
|
+
let total = 0;
|
|
87
|
+
let lastKey: unknown = undefined;
|
|
88
|
+
do {
|
|
89
|
+
const res = await dynamo.send(
|
|
90
|
+
new ScanCommand({
|
|
91
|
+
TableName: tableName,
|
|
92
|
+
Select: 'COUNT',
|
|
93
|
+
...(lastKey !== undefined ? { ExclusiveStartKey: lastKey as never } : {}),
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
total += res.Count ?? 0;
|
|
97
|
+
lastKey = res.LastEvaluatedKey;
|
|
98
|
+
} while (lastKey !== undefined);
|
|
99
|
+
return total;
|
|
44
100
|
}
|
|
@@ -12,12 +12,15 @@ import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanageme
|
|
|
12
12
|
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
13
13
|
import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
|
|
14
14
|
import {
|
|
15
|
+
type AccountStore,
|
|
15
16
|
type ChannelState,
|
|
16
17
|
type Clock,
|
|
17
18
|
type ConnectionState,
|
|
18
19
|
type IdFactory,
|
|
19
20
|
type MessageStore,
|
|
20
21
|
type MotdProvider,
|
|
22
|
+
type MtlsIdentityProvider,
|
|
23
|
+
type NickHistoryStore,
|
|
21
24
|
type ParsedServerConfig,
|
|
22
25
|
SystemClock,
|
|
23
26
|
UuidIdFactory,
|
|
@@ -46,6 +49,36 @@ export interface DefaultParams {
|
|
|
46
49
|
* behaviour of callers that have not opted in.
|
|
47
50
|
*/
|
|
48
51
|
messages?: MessageStore;
|
|
52
|
+
/**
|
|
53
|
+
* SASL account verification source. When omitted the actor runs with
|
|
54
|
+
* `ctx.accounts` undefined so `AUTHENTICATE PLAIN` fails with `904`
|
|
55
|
+
* — preserves the behaviour of callers that have not opted in.
|
|
56
|
+
*/
|
|
57
|
+
accounts?: AccountStore;
|
|
58
|
+
/**
|
|
59
|
+
* mTLS client-cert identity source for SASL EXTERNAL. When omitted the
|
|
60
|
+
* actor runs with `ctx.mtlsIdentity` undefined so `AUTHENTICATE EXTERNAL`
|
|
61
|
+
* fails with `904` — preserves the behaviour of callers that have not
|
|
62
|
+
* opted in.
|
|
63
|
+
*
|
|
64
|
+
* AWS APIGW custom-domain mTLS setup:
|
|
65
|
+
* 1. Upload a PEM trust store to S3 (the CA bundle clients must present).
|
|
66
|
+
* 2. Create a custom domain with `mutualTlsAuthentication` pointing at the
|
|
67
|
+
* S3 trust store URI.
|
|
68
|
+
* 3. Map the custom domain to the WebSocket API stage.
|
|
69
|
+
* 4. The `$connect` event's `requestContext.identity.clientCertSubjectDN`
|
|
70
|
+
* carries the verified cert subject — capture it at connect time and
|
|
71
|
+
* persist it alongside the connection row, then construct an
|
|
72
|
+
* {@link MtlsIdentityProvider} that resolves it by connection ID.
|
|
73
|
+
*/
|
|
74
|
+
mtlsIdentity?: MtlsIdentityProvider;
|
|
75
|
+
/**
|
|
76
|
+
* Nick-history source for the `WHOWAS` command. When omitted the actor
|
|
77
|
+
* runs with `ctx.history` undefined so `WHOWAS` always returns
|
|
78
|
+
* `406`+`369` — preserves the behaviour of callers that have not opted
|
|
79
|
+
* in. A DynamoDB-backed persistent variant is a documented follow-up.
|
|
80
|
+
*/
|
|
81
|
+
history?: NickHistoryStore;
|
|
49
82
|
managementApi: ApiGatewayManagementApi | PostToConnection | null;
|
|
50
83
|
/** Injected for tests; defaults to {@link SystemClock}. */
|
|
51
84
|
clock?: Clock;
|
|
@@ -91,8 +124,13 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
91
124
|
for (const l of lines) outbound.push(l.text);
|
|
92
125
|
},
|
|
93
126
|
disconnect: () => {
|
|
94
|
-
// Lambda cannot close its own
|
|
95
|
-
//
|
|
127
|
+
// No-op: a Lambda `$default` invocation cannot close its own APIGW
|
|
128
|
+
// socket. The canonical teardown is the `$disconnect` route (which
|
|
129
|
+
// fires when APIGW observes the close) plus the sweeper/ping-checker
|
|
130
|
+
// for connections that vanish without one. The actor's state
|
|
131
|
+
// mutations (nick release, roster deltas) are persisted below before
|
|
132
|
+
// this invocation returns, so the eventual `$disconnect` completes
|
|
133
|
+
// the fanout. See docs/AWS-Deployment.md §14.1.
|
|
96
134
|
},
|
|
97
135
|
snapshot: () => state,
|
|
98
136
|
};
|
|
@@ -116,6 +154,9 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
116
154
|
ids,
|
|
117
155
|
motd: params.motd,
|
|
118
156
|
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
157
|
+
...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
|
|
158
|
+
...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
|
|
159
|
+
...(params.history !== undefined ? { history: params.history } : {}),
|
|
119
160
|
});
|
|
120
161
|
|
|
121
162
|
try {
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* `$disconnect` handler — Lambda entry for `event.requestContext.routeKey === '$disconnect'`.
|
|
3
3
|
*
|
|
4
4
|
* Tears down the connection via {@link cleanupConnection} — deletes the
|
|
5
|
-
* Connections row, every ChannelMembers row owned by it,
|
|
6
|
-
* reservation if any
|
|
5
|
+
* Connections row, every ChannelMembers row owned by it, the nick
|
|
6
|
+
* reservation if any, and broadcasts a `QUIT` line to every peer sharing
|
|
7
|
+
* a channel with the disconnecting connection. Idempotent: every step
|
|
8
|
+
* tolerates a missing row.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
|
|
10
12
|
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
11
|
-
import { cleanupConnection } from '../aws-runtime.js';
|
|
13
|
+
import { type PostToConnection, cleanupConnection } from '../aws-runtime.js';
|
|
12
14
|
import type { TablesConfig } from '../tables.js';
|
|
13
15
|
|
|
14
16
|
/** Parameters accepted by {@link handleDisconnect}. */
|
|
@@ -16,14 +18,31 @@ export interface DisconnectParams {
|
|
|
16
18
|
dynamo: DynamoDBDocumentClient;
|
|
17
19
|
tables: TablesConfig;
|
|
18
20
|
connectionId: string;
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Backend used to push the `QUIT` fanout to peers. `null` skips the
|
|
23
|
+
* fanout (the socket is already gone, but peers still need the QUIT
|
|
24
|
+
* line so their rosters prune the ghost member).
|
|
25
|
+
*/
|
|
26
|
+
managementApi?: ApiGatewayManagementApi | PostToConnection | null;
|
|
27
|
+
/**
|
|
28
|
+
* QUIT reason advertised to peers on fanout. Defaults to
|
|
29
|
+
* `'Client Quit'` (the same default the QUIT reducer uses when a
|
|
30
|
+
* client sends `QUIT` with no reason).
|
|
31
|
+
*/
|
|
32
|
+
quitMessage?: string;
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
/**
|
|
24
|
-
* Cleans up the connection state
|
|
25
|
-
*
|
|
36
|
+
* Cleans up the connection state and broadcasts QUIT to shared-channel
|
|
37
|
+
* peers. Safe to call after APIGW has already torn the WebSocket down —
|
|
38
|
+
* the management API is best-effort only.
|
|
26
39
|
*/
|
|
27
40
|
export async function handleDisconnect(params: DisconnectParams): Promise<void> {
|
|
28
|
-
await cleanupConnection(
|
|
41
|
+
await cleanupConnection(
|
|
42
|
+
params.dynamo,
|
|
43
|
+
params.tables,
|
|
44
|
+
params.connectionId,
|
|
45
|
+
params.managementApi ?? null,
|
|
46
|
+
params.quitMessage,
|
|
47
|
+
);
|
|
29
48
|
}
|
|
@@ -14,12 +14,17 @@
|
|
|
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,
|
|
17
18
|
EmptyMotdProvider,
|
|
19
|
+
InMemoryNickHistoryStore,
|
|
18
20
|
type MessageStore,
|
|
19
21
|
type MotdProvider,
|
|
22
|
+
type NickHistoryStore,
|
|
20
23
|
type ParsedServerConfig,
|
|
21
24
|
StaticMotdProvider,
|
|
25
|
+
SystemClock,
|
|
22
26
|
} from '@serverless-ircd/irc-core';
|
|
27
|
+
import { resolveAccountStore } from '../account-store.js';
|
|
23
28
|
import { type LambdaConfigEnv, loadServerConfigFromLambdaEnv } from '../config-loader.js';
|
|
24
29
|
import { createDynamoDocumentClient } from '../dynamo.js';
|
|
25
30
|
import { bindMessageStore } from '../message-store.js';
|
|
@@ -27,6 +32,13 @@ import { TABLE_KEYS, type TablesConfig } from '../tables.js';
|
|
|
27
32
|
import { handleConnect } from './connect.js';
|
|
28
33
|
import { handleDefault } from './default.js';
|
|
29
34
|
import { handleDisconnect } from './disconnect.js';
|
|
35
|
+
import {
|
|
36
|
+
type NlbStreamEvent,
|
|
37
|
+
type NlbStreamParams,
|
|
38
|
+
type NlbStreamResponse,
|
|
39
|
+
deriveNlbConnectionId,
|
|
40
|
+
handleNlbStream,
|
|
41
|
+
} from './nlb-stream.js';
|
|
30
42
|
import {
|
|
31
43
|
DEFAULT_PING_INTERVAL_MS,
|
|
32
44
|
DEFAULT_PONG_TIMEOUT_MS,
|
|
@@ -48,6 +60,13 @@ export interface WebSocketEvent {
|
|
|
48
60
|
connectionId: string;
|
|
49
61
|
domainName?: string;
|
|
50
62
|
stage?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Source IP of the connecting client. APIGW populates
|
|
65
|
+
* `requestContext.identity.sourceIp` on every `$connect`. Carried on
|
|
66
|
+
* the event type so the (currently deferred) per-IP admission gate
|
|
67
|
+
* can consume it without reshaping the handler signature.
|
|
68
|
+
*/
|
|
69
|
+
identity?: { sourceIp?: string };
|
|
51
70
|
};
|
|
52
71
|
body?: string;
|
|
53
72
|
}
|
|
@@ -72,6 +91,20 @@ export interface HandlerDeps {
|
|
|
72
91
|
* DynamoDB-backed variant is a documented follow-up.
|
|
73
92
|
*/
|
|
74
93
|
messages: MessageStore;
|
|
94
|
+
/**
|
|
95
|
+
* SASL account verification source bound to every actor. Constructed
|
|
96
|
+
* once per cold start (memoised via {@link cachedDeps}) so it survives
|
|
97
|
+
* across warm Lambda invocations. `undefined` when no accounts are
|
|
98
|
+
* configured (the default) so `ctx.accounts` stays unset.
|
|
99
|
+
*/
|
|
100
|
+
accounts?: AccountStore;
|
|
101
|
+
/**
|
|
102
|
+
* Nick-history source bound to every actor. Constructed once per cold
|
|
103
|
+
* start (memoised via {@link cachedDeps}) as an in-memory store so it
|
|
104
|
+
* survives across warm Lambda invocations. A DynamoDB-backed persistent
|
|
105
|
+
* variant is a documented follow-up.
|
|
106
|
+
*/
|
|
107
|
+
history?: NickHistoryStore;
|
|
75
108
|
}
|
|
76
109
|
|
|
77
110
|
/**
|
|
@@ -90,6 +123,15 @@ export type DefaultEvent = {
|
|
|
90
123
|
/** Memoised `HandlerDeps`; built on the first cold-start invocation. */
|
|
91
124
|
let cachedDeps: HandlerDeps | undefined;
|
|
92
125
|
|
|
126
|
+
/**
|
|
127
|
+
* In-flight cold-start build. `buildDepsFromEnv` is async (it scans the
|
|
128
|
+
* `Accounts` DynamoDB table for SASL credentials), so under concurrent
|
|
129
|
+
* warm-up two events could race past the `cachedDeps` check and each
|
|
130
|
+
* trigger a full scan. Memoising the promise guarantees the scan runs
|
|
131
|
+
* exactly once per cold start.
|
|
132
|
+
*/
|
|
133
|
+
let cachedDepsPromise: Promise<HandlerDeps> | undefined;
|
|
134
|
+
|
|
93
135
|
/**
|
|
94
136
|
* The Lambda entry point. Routes on `event.requestContext.routeKey`.
|
|
95
137
|
*
|
|
@@ -98,7 +140,11 @@ let cachedDeps: HandlerDeps | undefined;
|
|
|
98
140
|
*/
|
|
99
141
|
export const handler = async (event: WebSocketEvent): Promise<LambdaResponse> => {
|
|
100
142
|
if (cachedDeps === undefined) {
|
|
101
|
-
|
|
143
|
+
if (cachedDepsPromise === undefined) {
|
|
144
|
+
cachedDepsPromise = buildDepsFromEnv();
|
|
145
|
+
}
|
|
146
|
+
cachedDeps = await cachedDepsPromise;
|
|
147
|
+
cachedDepsPromise = undefined;
|
|
102
148
|
}
|
|
103
149
|
const deps = cachedDeps;
|
|
104
150
|
return dispatch(event, deps);
|
|
@@ -110,14 +156,16 @@ export const handler = async (event: WebSocketEvent): Promise<LambdaResponse> =>
|
|
|
110
156
|
export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promise<LambdaResponse> {
|
|
111
157
|
const connId = event.requestContext.connectionId;
|
|
112
158
|
switch (event.requestContext.routeKey) {
|
|
113
|
-
case '$connect':
|
|
114
|
-
await handleConnect({
|
|
159
|
+
case '$connect': {
|
|
160
|
+
const outcome = await handleConnect({
|
|
115
161
|
dynamo: deps.dynamo,
|
|
116
162
|
tables: deps.tables,
|
|
117
163
|
connectionId: connId,
|
|
118
164
|
serverConfig: deps.serverConfig,
|
|
119
165
|
});
|
|
120
|
-
return { statusCode: 200 };
|
|
166
|
+
if (outcome.admitted) return { statusCode: 200 };
|
|
167
|
+
return { statusCode: outcome.statusCode, body: outcome.reason };
|
|
168
|
+
}
|
|
121
169
|
|
|
122
170
|
case '$disconnect':
|
|
123
171
|
await handleDisconnect({
|
|
@@ -125,6 +173,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
|
|
|
125
173
|
tables: deps.tables,
|
|
126
174
|
connectionId: connId,
|
|
127
175
|
managementApi: deps.managementApi,
|
|
176
|
+
quitMessage: deps.serverConfig.quitMessage,
|
|
128
177
|
});
|
|
129
178
|
return { statusCode: 200 };
|
|
130
179
|
|
|
@@ -141,6 +190,8 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
|
|
|
141
190
|
serverConfig: deps.serverConfig,
|
|
142
191
|
motd: deps.motd,
|
|
143
192
|
messages: deps.messages,
|
|
193
|
+
...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
|
|
194
|
+
...(deps.history !== undefined ? { history: deps.history } : {}),
|
|
144
195
|
managementApi: deps.managementApi,
|
|
145
196
|
});
|
|
146
197
|
return result;
|
|
@@ -159,6 +210,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
|
|
|
159
210
|
*/
|
|
160
211
|
export function __resetHandlerCacheForTests(): void {
|
|
161
212
|
cachedDeps = undefined;
|
|
213
|
+
cachedDepsPromise = undefined;
|
|
162
214
|
}
|
|
163
215
|
|
|
164
216
|
/**
|
|
@@ -172,7 +224,11 @@ export function __resetHandlerCacheForTests(): void {
|
|
|
172
224
|
*/
|
|
173
225
|
export async function sweeperHandler(_event: unknown): Promise<SweepResult> {
|
|
174
226
|
if (cachedDeps === undefined) {
|
|
175
|
-
|
|
227
|
+
if (cachedDepsPromise === undefined) {
|
|
228
|
+
cachedDepsPromise = buildDepsFromEnv();
|
|
229
|
+
}
|
|
230
|
+
cachedDeps = await cachedDepsPromise;
|
|
231
|
+
cachedDepsPromise = undefined;
|
|
176
232
|
}
|
|
177
233
|
const deps = cachedDeps;
|
|
178
234
|
return handleSweep({ dynamo: deps.dynamo, tables: deps.tables });
|
|
@@ -190,7 +246,11 @@ export async function sweeperHandler(_event: unknown): Promise<SweepResult> {
|
|
|
190
246
|
*/
|
|
191
247
|
export async function pingCheckerHandler(_event: unknown): Promise<PingCheckResult> {
|
|
192
248
|
if (cachedDeps === undefined) {
|
|
193
|
-
|
|
249
|
+
if (cachedDepsPromise === undefined) {
|
|
250
|
+
cachedDepsPromise = buildDepsFromEnv();
|
|
251
|
+
}
|
|
252
|
+
cachedDeps = await cachedDepsPromise;
|
|
253
|
+
cachedDepsPromise = undefined;
|
|
194
254
|
}
|
|
195
255
|
const deps = cachedDeps;
|
|
196
256
|
return handlePingCheck({
|
|
@@ -212,13 +272,53 @@ export {
|
|
|
212
272
|
type SweepResult,
|
|
213
273
|
};
|
|
214
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Lambda entry for the NLB TCP+TLS streaming target. Wired
|
|
277
|
+
* to the NLB target group in the CDK stack. Shares the cached
|
|
278
|
+
* {@link HandlerDeps} with the wss {@link handler} — same tables, same
|
|
279
|
+
* config, same management API (when available). Each NLB client chunk
|
|
280
|
+
* arrives as one invocation; the handler returns the outbound bytes for
|
|
281
|
+
* the NLB to stream back over the held-open TCP connection.
|
|
282
|
+
*/
|
|
283
|
+
export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStreamResponse> {
|
|
284
|
+
if (cachedDeps === undefined) {
|
|
285
|
+
if (cachedDepsPromise === undefined) {
|
|
286
|
+
cachedDepsPromise = buildDepsFromEnv();
|
|
287
|
+
}
|
|
288
|
+
cachedDeps = await cachedDepsPromise;
|
|
289
|
+
cachedDepsPromise = undefined;
|
|
290
|
+
}
|
|
291
|
+
const deps = cachedDeps;
|
|
292
|
+
return handleNlbStream(event, {
|
|
293
|
+
dynamo: deps.dynamo,
|
|
294
|
+
tables: deps.tables,
|
|
295
|
+
serverConfig: deps.serverConfig,
|
|
296
|
+
motd: deps.motd,
|
|
297
|
+
messages: deps.messages,
|
|
298
|
+
managementApi: deps.managementApi,
|
|
299
|
+
...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
|
|
300
|
+
...(deps.history !== undefined ? { history: deps.history } : {}),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export {
|
|
305
|
+
handleNlbStream,
|
|
306
|
+
deriveNlbConnectionId,
|
|
307
|
+
type NlbStreamEvent,
|
|
308
|
+
type NlbStreamParams,
|
|
309
|
+
type NlbStreamResponse,
|
|
310
|
+
};
|
|
311
|
+
|
|
215
312
|
/**
|
|
216
313
|
* Builds a {@link HandlerDeps} from the current `process.env`.
|
|
217
314
|
*
|
|
218
315
|
* Exposed so tests can construct production-shaped deps without re-running
|
|
219
|
-
* the env parsing.
|
|
316
|
+
* the env parsing. Async because it scans the `Accounts` DynamoDB table
|
|
317
|
+
* (via {@link resolveAccountStore}) to pre-load SASL credentials at cold
|
|
318
|
+
* start — the {@link AccountStore} port is synchronous, so the scan must
|
|
319
|
+
* complete before any reducer calls `verify`.
|
|
220
320
|
*/
|
|
221
|
-
export function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): HandlerDeps {
|
|
321
|
+
export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<HandlerDeps> {
|
|
222
322
|
const cfg = loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
|
|
223
323
|
const tables = tablesConfigFromEnv(env);
|
|
224
324
|
const region = typeof env.AWS_REGION === 'string' ? env.AWS_REGION : undefined;
|
|
@@ -231,7 +331,18 @@ export function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): HandlerD
|
|
|
231
331
|
}
|
|
232
332
|
const motd = cfg.motdLines.length > 0 ? new StaticMotdProvider(cfg.motdLines) : EmptyMotdProvider;
|
|
233
333
|
const messages = bindMessageStore(cfg);
|
|
234
|
-
|
|
334
|
+
const history = new InMemoryNickHistoryStore(SystemClock);
|
|
335
|
+
const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
|
|
336
|
+
return {
|
|
337
|
+
dynamo,
|
|
338
|
+
tables,
|
|
339
|
+
serverConfig: cfg,
|
|
340
|
+
motd,
|
|
341
|
+
managementApi,
|
|
342
|
+
messages,
|
|
343
|
+
history,
|
|
344
|
+
...(accounts !== undefined ? { accounts } : {}),
|
|
345
|
+
};
|
|
235
346
|
}
|
|
236
347
|
|
|
237
348
|
function tablesConfigFromEnv(env: NodeJS.ProcessEnv): TablesConfig {
|