serverless-ircd 0.3.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 +174 -18
- package/README.md +63 -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/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +20 -3
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +6 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +35 -2
- package/packages/aws-adapter/src/handlers/index.ts +69 -3
- 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 +6 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- 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 +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +154 -53
- 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 +2 -4
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +111 -46
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +33 -9
- package/packages/cf-adapter/src/index.ts +9 -8
- 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/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/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 +8 -1
- package/packages/cf-adapter/wrangler.test.toml +8 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +6 -1
- 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/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- 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/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +58 -0
- 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 +1 -0
- package/packages/irc-core/src/ports.ts +181 -12
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +38 -1
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- 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/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- 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 +9 -35
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -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 +158 -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 +12 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +400 -3
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +4 -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 +73 -9
- 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 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure admission policy for the AWS `$connect` handler.
|
|
3
|
+
*
|
|
4
|
+
* The I/O (counting live rows in DynamoDB) lives in `handlers/connect.ts`;
|
|
5
|
+
* the *decision* — "does this count fit under the caps?" — is a pure
|
|
6
|
+
* function extracted here so it is trivial to unit-test every branch
|
|
7
|
+
* without standing up DynamoDB Local.
|
|
8
|
+
*
|
|
9
|
+
* This is deliberately separate from irc-core's `decideAdmission` /
|
|
10
|
+
* `AdmissionStats`: that helper is an in-memory counter design that
|
|
11
|
+
* assumes a single long-lived process. AWS Lambda invocations are
|
|
12
|
+
* independent processes with no shared memory, so the authoritative
|
|
13
|
+
* counts MUST come from DynamoDB (the shared `Connections` table) and
|
|
14
|
+
* are fed into this pure policy.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Live connection counts gathered from DynamoDB.
|
|
19
|
+
*
|
|
20
|
+
* - `total` is always supplied (a paginated `Scan COUNT` over
|
|
21
|
+
* `Connections`).
|
|
22
|
+
* - `perIp` is optional: counting connections per source IP needs a
|
|
23
|
+
* GSI on `sourceIp` (see `handlers/connect.ts` for the deferred
|
|
24
|
+
* per-IP follow-up). Until that lands, callers omit `perIp` and the
|
|
25
|
+
* per-IP branch of {@link decideConnectAdmission} is skipped.
|
|
26
|
+
*/
|
|
27
|
+
export interface ConnectionCounts {
|
|
28
|
+
readonly total: number;
|
|
29
|
+
readonly perIp?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Effective caps sourced from {@link ParsedServerConfig}. `perIp` is
|
|
34
|
+
* optional because per-IP enforcement is not yet wired on the AWS path.
|
|
35
|
+
*/
|
|
36
|
+
export interface AdmissionLimits {
|
|
37
|
+
readonly maxClients: number;
|
|
38
|
+
readonly maxConnectionsPerIp?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Outcome of an admission decision. Mirrors the {@link ConnectOutcome}
|
|
43
|
+
* shape returned by `handleConnect` so the handler can return the
|
|
44
|
+
* decision verbatim.
|
|
45
|
+
*/
|
|
46
|
+
export type AdmissionOutcome =
|
|
47
|
+
| { readonly admitted: true }
|
|
48
|
+
| { readonly admitted: false; readonly statusCode: number; readonly reason: string };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decides whether a new connection should be admitted given the live
|
|
52
|
+
* counts and the configured caps. Pure: same inputs ⇒ same output, no
|
|
53
|
+
* ambient state.
|
|
54
|
+
*
|
|
55
|
+
* Precedence: the global `maxClients` cap is checked first (it is the
|
|
56
|
+
* cheap, always-available gate), then the optional per-IP cap when both
|
|
57
|
+
* the limit and the `perIp` count are present.
|
|
58
|
+
*/
|
|
59
|
+
export function decideConnectAdmission(
|
|
60
|
+
counts: ConnectionCounts,
|
|
61
|
+
limits: AdmissionLimits,
|
|
62
|
+
): AdmissionOutcome {
|
|
63
|
+
if (counts.total >= limits.maxClients) {
|
|
64
|
+
return { admitted: false, statusCode: 429, reason: 'server full' };
|
|
65
|
+
}
|
|
66
|
+
if (
|
|
67
|
+
limits.maxConnectionsPerIp !== undefined &&
|
|
68
|
+
counts.perIp !== undefined &&
|
|
69
|
+
counts.perIp >= limits.maxConnectionsPerIp
|
|
70
|
+
) {
|
|
71
|
+
return { admitted: false, statusCode: 429, reason: 'too many connections from this IP' };
|
|
72
|
+
}
|
|
73
|
+
return { admitted: true };
|
|
74
|
+
}
|
|
@@ -80,7 +80,8 @@ import type { TablesConfig } from './tables.js';
|
|
|
80
80
|
* is the in-memory `received` array.
|
|
81
81
|
* - `disconnect(reason?)` closes the bound transport. In Lambda this
|
|
82
82
|
* is a no-op — the canonical disconnect path is the APIGW `$disconnect`
|
|
83
|
-
* route, which fires after the socket is already gone.
|
|
83
|
+
* route, which fires after the socket is already gone. See
|
|
84
|
+
* `docs/AWS-Deployment.md` §14.1 for the full platform-limit write-up.
|
|
84
85
|
* - `snapshot()` returns the in-memory ConnectionState the actor is
|
|
85
86
|
* mutating this frame. Critical so `getConnection(boundId)` returns
|
|
86
87
|
* the live view (and dispatch's reducer-observable effects see
|
|
@@ -185,11 +186,12 @@ export class AwsRuntime implements IrcRuntime {
|
|
|
185
186
|
this.handlers.disconnect(reason);
|
|
186
187
|
return;
|
|
187
188
|
}
|
|
188
|
-
// Cross-connection disconnect is a no-op
|
|
189
|
-
//
|
|
189
|
+
// Cross-connection disconnect is a no-op: the Lambda runtime cannot
|
|
190
|
+
// directly close another connection's WebSocket. The canonical
|
|
190
191
|
// disconnect path is the APIGW `$disconnect` route, which fires when
|
|
191
192
|
// the socket is already gone. KICK/PART remove membership rows but
|
|
192
|
-
// do not need to force-close the socket.
|
|
193
|
+
// do not need to force-close the socket. See docs/AWS-Deployment.md
|
|
194
|
+
// §14.1 for the full platform-limit write-up and the mitigations.
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
async sendToNick(
|
|
@@ -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;
|
|
@@ -53,6 +64,12 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
|
|
|
53
64
|
serverName: env.SERVER_NAME,
|
|
54
65
|
networkName: env.NETWORK_NAME,
|
|
55
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
|
+
}
|
|
56
73
|
if (env.MOTD !== undefined && env.MOTD.length > 0) {
|
|
57
74
|
input.motdLines = env.MOTD.split('\n');
|
|
58
75
|
}
|
|
@@ -97,6 +114,21 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
|
|
|
97
114
|
return input;
|
|
98
115
|
}
|
|
99
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
|
+
|
|
100
132
|
/**
|
|
101
133
|
* Parses a newline-delimited `username:password` string into the config's
|
|
102
134
|
* `saslAccounts` shape. Malformed entries (missing colon, empty fields)
|
|
@@ -16,108 +16,37 @@
|
|
|
16
16
|
* `irc-core/src/ports.ts` and used by `MotdProvider` / `MessageStore`:
|
|
17
17
|
* adapters that need an async backend pre-load the data at boot into a
|
|
18
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.
|
|
19
25
|
*/
|
|
20
26
|
|
|
21
|
-
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
22
27
|
import type { ScanCommandOutput } from '@aws-sdk/client-dynamodb';
|
|
23
28
|
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
24
29
|
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
25
|
-
import
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
import {
|
|
31
|
+
type HashedAccountCredential,
|
|
32
|
+
HashedAccountStore,
|
|
33
|
+
hashAccountCredential,
|
|
34
|
+
verifyHashedPassword,
|
|
35
|
+
} from '@serverless-ircd/irc-core';
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
* Every field is a DynamoDB attribute — this object IS the table row
|
|
34
|
-
* (plus the PK `account`). `algorithm` is retained so future migrations
|
|
35
|
-
* to Argon2/bcrypt can be detected and handled gracefully.
|
|
36
|
-
*/
|
|
37
|
-
export interface HashedAccountCredential {
|
|
38
|
-
readonly account: string;
|
|
39
|
-
readonly algorithm: 'scrypt';
|
|
40
|
-
readonly salt: string;
|
|
41
|
-
readonly hash: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Hashes a plaintext password into a {@link HashedAccountCredential}
|
|
46
|
-
* suitable for writing to the `Accounts` table via `putAccountCredential`.
|
|
47
|
-
*
|
|
48
|
-
* Uses `scryptSync` (memory-hard, GPU-resistant) with a random salt.
|
|
49
|
-
* Pass `{ salt }` for deterministic test scenarios; pass `{ keyLen }` to
|
|
50
|
-
* override the default 64-byte derived-key length.
|
|
51
|
-
*/
|
|
52
|
-
export function hashAccountCredential(
|
|
53
|
-
username: string,
|
|
54
|
-
password: string,
|
|
55
|
-
opts?: { salt?: Buffer; keyLen?: number },
|
|
56
|
-
): HashedAccountCredential {
|
|
57
|
-
const salt = opts?.salt ?? randomBytes(SALT_LEN);
|
|
58
|
-
const keyLen = opts?.keyLen ?? SCRYPT_KEY_LEN;
|
|
59
|
-
const hash = scryptSync(password, salt, keyLen);
|
|
60
|
-
return {
|
|
61
|
-
account: username,
|
|
62
|
-
algorithm: 'scrypt',
|
|
63
|
-
salt: salt.toString('base64'),
|
|
64
|
-
hash: hash.toString('base64'),
|
|
65
|
-
};
|
|
66
|
-
}
|
|
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 };
|
|
67
40
|
|
|
68
41
|
/**
|
|
69
|
-
*
|
|
70
|
-
* using `timingSafeEqual` (no short-circuit on first mismatched byte).
|
|
42
|
+
* Synchronous {@link AccountStore} backed by pre-loaded hashed credentials.
|
|
71
43
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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.
|
|
74
48
|
*/
|
|
75
|
-
export
|
|
76
|
-
if (entry.algorithm !== 'scrypt') return false;
|
|
77
|
-
let salt: Buffer;
|
|
78
|
-
let expected: Buffer;
|
|
79
|
-
try {
|
|
80
|
-
salt = Buffer.from(entry.salt, 'base64');
|
|
81
|
-
expected = Buffer.from(entry.hash, 'base64');
|
|
82
|
-
} catch {
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
if (expected.length === 0) return false;
|
|
86
|
-
const computed = scryptSync(password, salt, expected.length);
|
|
87
|
-
return computed.length === expected.length && timingSafeEqual(computed, expected);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Synchronous {@link AccountStore} backed by pre-loaded hashed
|
|
92
|
-
* credentials. Construct with entries from {@link loadDynamoAccountStore}
|
|
93
|
-
* (or {@link hashAccountCredential} for unit tests).
|
|
94
|
-
*/
|
|
95
|
-
export class DynamoAccountStore implements AccountStore {
|
|
96
|
-
private readonly entries: ReadonlyMap<string, HashedAccountCredential>;
|
|
97
|
-
|
|
98
|
-
constructor(entries: ReadonlyArray<HashedAccountCredential>) {
|
|
99
|
-
this.entries = new Map(entries.map((e) => [e.account, e]));
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
103
|
-
if (mech.toUpperCase() !== 'PLAIN' || payload.kind !== 'PLAIN') {
|
|
104
|
-
return { ok: false, reason: `unsupported mechanism: ${mech}` };
|
|
105
|
-
}
|
|
106
|
-
const entry = this.entries.get(payload.username);
|
|
107
|
-
if (entry === undefined) {
|
|
108
|
-
return { ok: false, reason: 'invalid credentials' };
|
|
109
|
-
}
|
|
110
|
-
if (!verifyHashedPassword(payload.password, entry)) {
|
|
111
|
-
return { ok: false, reason: 'invalid credentials' };
|
|
112
|
-
}
|
|
113
|
-
return { ok: true, account: payload.username };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Number of loaded accounts (diagnostics / logging). */
|
|
117
|
-
get size(): number {
|
|
118
|
-
return this.entries.size;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
49
|
+
export { HashedAccountStore as DynamoAccountStore };
|
|
121
50
|
|
|
122
51
|
/**
|
|
123
52
|
* Scans the `Accounts` table and returns a fully-populated
|
|
@@ -130,7 +59,7 @@ export class DynamoAccountStore implements AccountStore {
|
|
|
130
59
|
export async function loadDynamoAccountStore(
|
|
131
60
|
docClient: DynamoDBDocumentClient,
|
|
132
61
|
tableName: string,
|
|
133
|
-
): Promise<
|
|
62
|
+
): Promise<HashedAccountStore | undefined> {
|
|
134
63
|
const entries: HashedAccountCredential[] = [];
|
|
135
64
|
let exclusiveStartKey: Record<string, unknown> | undefined = undefined;
|
|
136
65
|
do {
|
|
@@ -145,12 +74,22 @@ export async function loadDynamoAccountStore(
|
|
|
145
74
|
const algorithm = typeof item.algorithm === 'string' ? item.algorithm : undefined;
|
|
146
75
|
const salt = typeof item.salt === 'string' ? item.salt : undefined;
|
|
147
76
|
const hash = typeof item.hash === 'string' ? item.hash : undefined;
|
|
148
|
-
if (
|
|
149
|
-
|
|
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
|
+
});
|
|
150
89
|
}
|
|
151
90
|
}
|
|
152
91
|
exclusiveStartKey = result.LastEvaluatedKey;
|
|
153
92
|
} while (exclusiveStartKey !== undefined);
|
|
154
93
|
if (entries.length === 0) return undefined;
|
|
155
|
-
return new
|
|
94
|
+
return new HashedAccountStore(entries);
|
|
156
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
|
}
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
type IdFactory,
|
|
20
20
|
type MessageStore,
|
|
21
21
|
type MotdProvider,
|
|
22
|
+
type MtlsIdentityProvider,
|
|
23
|
+
type NickHistoryStore,
|
|
22
24
|
type ParsedServerConfig,
|
|
23
25
|
SystemClock,
|
|
24
26
|
UuidIdFactory,
|
|
@@ -53,6 +55,30 @@ export interface DefaultParams {
|
|
|
53
55
|
* — preserves the behaviour of callers that have not opted in.
|
|
54
56
|
*/
|
|
55
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;
|
|
56
82
|
managementApi: ApiGatewayManagementApi | PostToConnection | null;
|
|
57
83
|
/** Injected for tests; defaults to {@link SystemClock}. */
|
|
58
84
|
clock?: Clock;
|
|
@@ -98,8 +124,13 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
98
124
|
for (const l of lines) outbound.push(l.text);
|
|
99
125
|
},
|
|
100
126
|
disconnect: () => {
|
|
101
|
-
// Lambda cannot close its own
|
|
102
|
-
//
|
|
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.
|
|
103
134
|
},
|
|
104
135
|
snapshot: () => state,
|
|
105
136
|
};
|
|
@@ -124,6 +155,8 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
124
155
|
motd: params.motd,
|
|
125
156
|
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
126
157
|
...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
|
|
158
|
+
...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
|
|
159
|
+
...(params.history !== undefined ? { history: params.history } : {}),
|
|
127
160
|
});
|
|
128
161
|
|
|
129
162
|
try {
|
|
@@ -16,10 +16,13 @@ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
|
16
16
|
import {
|
|
17
17
|
type AccountStore,
|
|
18
18
|
EmptyMotdProvider,
|
|
19
|
+
InMemoryNickHistoryStore,
|
|
19
20
|
type MessageStore,
|
|
20
21
|
type MotdProvider,
|
|
22
|
+
type NickHistoryStore,
|
|
21
23
|
type ParsedServerConfig,
|
|
22
24
|
StaticMotdProvider,
|
|
25
|
+
SystemClock,
|
|
23
26
|
} from '@serverless-ircd/irc-core';
|
|
24
27
|
import { resolveAccountStore } from '../account-store.js';
|
|
25
28
|
import { type LambdaConfigEnv, loadServerConfigFromLambdaEnv } from '../config-loader.js';
|
|
@@ -29,6 +32,13 @@ import { TABLE_KEYS, type TablesConfig } from '../tables.js';
|
|
|
29
32
|
import { handleConnect } from './connect.js';
|
|
30
33
|
import { handleDefault } from './default.js';
|
|
31
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';
|
|
32
42
|
import {
|
|
33
43
|
DEFAULT_PING_INTERVAL_MS,
|
|
34
44
|
DEFAULT_PONG_TIMEOUT_MS,
|
|
@@ -50,6 +60,13 @@ export interface WebSocketEvent {
|
|
|
50
60
|
connectionId: string;
|
|
51
61
|
domainName?: string;
|
|
52
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 };
|
|
53
70
|
};
|
|
54
71
|
body?: string;
|
|
55
72
|
}
|
|
@@ -81,6 +98,13 @@ export interface HandlerDeps {
|
|
|
81
98
|
* configured (the default) so `ctx.accounts` stays unset.
|
|
82
99
|
*/
|
|
83
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;
|
|
84
108
|
}
|
|
85
109
|
|
|
86
110
|
/**
|
|
@@ -132,14 +156,16 @@ export const handler = async (event: WebSocketEvent): Promise<LambdaResponse> =>
|
|
|
132
156
|
export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promise<LambdaResponse> {
|
|
133
157
|
const connId = event.requestContext.connectionId;
|
|
134
158
|
switch (event.requestContext.routeKey) {
|
|
135
|
-
case '$connect':
|
|
136
|
-
await handleConnect({
|
|
159
|
+
case '$connect': {
|
|
160
|
+
const outcome = await handleConnect({
|
|
137
161
|
dynamo: deps.dynamo,
|
|
138
162
|
tables: deps.tables,
|
|
139
163
|
connectionId: connId,
|
|
140
164
|
serverConfig: deps.serverConfig,
|
|
141
165
|
});
|
|
142
|
-
return { statusCode: 200 };
|
|
166
|
+
if (outcome.admitted) return { statusCode: 200 };
|
|
167
|
+
return { statusCode: outcome.statusCode, body: outcome.reason };
|
|
168
|
+
}
|
|
143
169
|
|
|
144
170
|
case '$disconnect':
|
|
145
171
|
await handleDisconnect({
|
|
@@ -165,6 +191,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
|
|
|
165
191
|
motd: deps.motd,
|
|
166
192
|
messages: deps.messages,
|
|
167
193
|
...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
|
|
194
|
+
...(deps.history !== undefined ? { history: deps.history } : {}),
|
|
168
195
|
managementApi: deps.managementApi,
|
|
169
196
|
});
|
|
170
197
|
return result;
|
|
@@ -245,6 +272,43 @@ export {
|
|
|
245
272
|
type SweepResult,
|
|
246
273
|
};
|
|
247
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
|
+
|
|
248
312
|
/**
|
|
249
313
|
* Builds a {@link HandlerDeps} from the current `process.env`.
|
|
250
314
|
*
|
|
@@ -267,6 +331,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
|
|
|
267
331
|
}
|
|
268
332
|
const motd = cfg.motdLines.length > 0 ? new StaticMotdProvider(cfg.motdLines) : EmptyMotdProvider;
|
|
269
333
|
const messages = bindMessageStore(cfg);
|
|
334
|
+
const history = new InMemoryNickHistoryStore(SystemClock);
|
|
270
335
|
const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
|
|
271
336
|
return {
|
|
272
337
|
dynamo,
|
|
@@ -275,6 +340,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
|
|
|
275
340
|
motd,
|
|
276
341
|
managementApi,
|
|
277
342
|
messages,
|
|
343
|
+
history,
|
|
278
344
|
...(accounts !== undefined ? { accounts } : {}),
|
|
279
345
|
};
|
|
280
346
|
}
|