serverless-ircd 0.1.0 → 0.3.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 +96 -2
- package/.github/workflows/deploy-aws.yml +129 -0
- package/.github/workflows/deploy-cf.yml +0 -2
- package/.gitmodules +3 -0
- package/CHANGELOG.md +436 -0
- package/README.md +127 -84
- package/apps/aws-stack/README.md +73 -0
- package/apps/aws-stack/bin/aws.ts +49 -0
- package/apps/aws-stack/cdk.json +10 -0
- package/apps/aws-stack/package.json +41 -0
- package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
- package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
- package/apps/aws-stack/scripts/smoke.mjs +142 -0
- package/apps/aws-stack/src/aws-stack.ts +263 -0
- package/apps/aws-stack/src/tables.ts +10 -0
- package/apps/aws-stack/tests/localstack.test.ts +46 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
- package/apps/aws-stack/tests/stack.test.ts +464 -0
- package/apps/aws-stack/tsconfig.build.json +11 -0
- package/apps/aws-stack/tsconfig.test.json +8 -0
- package/apps/aws-stack/vitest.config.ts +20 -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 +5 -1
- package/apps/cf-worker/wrangler.toml +66 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +57 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +267 -32
- package/apps/local-cli/tests/config-loader.test.ts +107 -0
- package/apps/local-cli/tests/e2e.test.ts +126 -0
- package/apps/local-cli/tests/security-e2e.test.ts +239 -0
- package/biome.json +3 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +93 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1186 -0
- package/docs/Cloudflare-Deployment-Guide.md +660 -0
- package/docs/Home.md +11 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanIRCv3Websocket.md +489 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +19 -14
- package/packages/aws-adapter/README.md +35 -0
- package/packages/aws-adapter/package.json +52 -0
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +871 -0
- package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
- package/packages/aws-adapter/src/config-loader.ts +126 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/dynamo.ts +61 -0
- package/packages/aws-adapter/src/handlers/connect.ts +44 -0
- package/packages/aws-adapter/src/handlers/default.ts +330 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
- package/packages/aws-adapter/src/handlers/index.ts +293 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
- package/packages/aws-adapter/src/handlers/state.ts +13 -0
- package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
- package/packages/aws-adapter/src/index.ts +74 -0
- package/packages/aws-adapter/src/message-store.ts +34 -0
- package/packages/aws-adapter/src/serialize.ts +283 -0
- package/packages/aws-adapter/src/tables.ts +53 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -0
- package/packages/aws-adapter/tests/aws-harness.ts +408 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
- package/packages/aws-adapter/tests/global-setup.ts +301 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
- package/packages/aws-adapter/tests/handlers.test.ts +473 -0
- package/packages/aws-adapter/tests/message-store.test.ts +55 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
- package/packages/aws-adapter/tests/serialize.test.ts +249 -0
- package/packages/aws-adapter/tests/smoke.test.ts +48 -0
- package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
- package/packages/aws-adapter/tests/tables.test.ts +74 -0
- package/packages/aws-adapter/tests/transactions.test.ts +446 -0
- package/packages/aws-adapter/tsconfig.build.json +16 -0
- package/packages/aws-adapter/tsconfig.test.json +14 -0
- package/packages/aws-adapter/vitest.config.ts +23 -0
- package/packages/cf-adapter/package.json +2 -1
- package/packages/cf-adapter/src/cf-runtime.ts +42 -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 +135 -0
- package/packages/cf-adapter/src/connection-do.ts +119 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- package/packages/cf-adapter/tests/cf-harness.ts +2 -2
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
- package/packages/in-memory-runtime/src/index.ts +1 -1
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
- package/packages/irc-core/package.json +13 -2
- package/packages/irc-core/src/admission.ts +216 -0
- package/packages/irc-core/src/caps/capabilities.ts +1 -0
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/cloak.ts +81 -0
- package/packages/irc-core/src/commands/cap.ts +1 -2
- package/packages/irc-core/src/commands/chathistory.ts +305 -0
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +63 -10
- 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/privmsg.ts +24 -0
- package/packages/irc-core/src/commands/registration.ts +79 -21
- package/packages/irc-core/src/commands/sasl.ts +251 -0
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +270 -0
- package/packages/irc-core/src/flood-control.ts +175 -0
- package/packages/irc-core/src/index.ts +7 -0
- package/packages/irc-core/src/ports.ts +719 -0
- package/packages/irc-core/src/protocol/base64.ts +16 -0
- package/packages/irc-core/src/protocol/index.ts +2 -1
- package/packages/irc-core/src/protocol/numerics.ts +11 -0
- package/packages/irc-core/src/protocol/parser.ts +27 -2
- package/packages/irc-core/src/state/connection.ts +41 -0
- package/packages/irc-core/src/types.ts +88 -2
- package/packages/irc-core/stryker.commands.conf.json +41 -0
- package/packages/irc-core/stryker.protocol.conf.json +26 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/admission.test.ts +229 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- package/packages/irc-core/tests/cloak.test.ts +78 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
- package/packages/irc-core/tests/commands/join.test.ts +246 -2
- 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/privmsg.test.ts +146 -2
- package/packages/irc-core/tests/commands/registration.test.ts +380 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -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/config.test.ts +721 -0
- package/packages/irc-core/tests/flood-control.test.ts +394 -0
- package/packages/irc-core/tests/message-store.test.ts +530 -0
- package/packages/irc-core/tests/parser.test.ts +44 -1
- package/packages/irc-core/tests/ports.test.ts +263 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +186 -44
- package/packages/irc-server/src/dispatch.ts +89 -5
- package/packages/irc-server/src/index.ts +2 -0
- package/packages/irc-server/src/routing.ts +160 -0
- package/packages/irc-server/tests/actor.test.ts +690 -15
- package/packages/irc-server/tests/dispatch.test.ts +84 -0
- package/packages/irc-server/tests/routing.test.ts +204 -0
- package/packages/irc-test-support/README.md +32 -0
- package/packages/irc-test-support/package.json +37 -0
- package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
- package/packages/irc-test-support/src/index.ts +24 -0
- package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
- package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
- package/packages/irc-test-support/tests/smoke.test.ts +11 -0
- package/packages/irc-test-support/tsconfig.build.json +16 -0
- package/packages/irc-test-support/tsconfig.test.json +14 -0
- package/packages/irc-test-support/vitest.config.ts +20 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +30 -0
- package/tools/ci-hardening/src/index.ts +6 -0
- package/tools/ci-hardening/src/validate.ts +103 -0
- package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
- package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
- package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
- package/tools/ci-hardening/tests/validate.test.ts +177 -0
- package/tools/ci-hardening/tsconfig.build.json +12 -0
- package/tools/ci-hardening/tsconfig.test.json +10 -0
- package/tools/ci-hardening/vitest.config.ts +21 -0
- package/tools/seed-aws-accounts.ts +80 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
- package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
- package/tools/tcp-ws-forwarder/src/main.ts +33 -14
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
- package/packages/cf-adapter/tests/integration/index.ts +0 -17
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
- /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDK-coupled table definitions (PLAN §4).
|
|
3
|
+
*
|
|
4
|
+
* The single source of truth for the deployed table shapes
|
|
5
|
+
* (`partitionKey`, `sortKey`, `timeToLiveAttribute`, …). Lives in
|
|
6
|
+
* `aws-adapter` (not `aws-stack`) so the runtime can reference the same
|
|
7
|
+
* logical names via {@link TABLE_NAMES} without round-tripping through
|
|
8
|
+
* the app directory.
|
|
9
|
+
*
|
|
10
|
+
* Re-exported from `apps/aws-stack/src/aws-stack.ts`. NOT re-exported
|
|
11
|
+
* from `aws-adapter`'s `index.ts` so the Lambda handler bundle does
|
|
12
|
+
* not transitively include `aws-cdk-lib`.
|
|
13
|
+
*
|
|
14
|
+
* Schema invariants (PLAN §4):
|
|
15
|
+
* • `Nicks` uniqueness is enforced at write-time via conditional PutItem
|
|
16
|
+
* (PK = lowercased nick) — not by the schema itself.
|
|
17
|
+
* • `ChannelMembers` uses a composite key (channelName PK + connectionId SK)
|
|
18
|
+
* so a future `TransactWriteItems` can span Connections ↔ membership
|
|
19
|
+
* atomically. (Atomicity across Connections/ChannelMembers lands in the
|
|
20
|
+
* membership-transaction follow-up.)
|
|
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`.
|
|
26
|
+
* • `Connections.idleSince` is the DynamoDB TTL attribute; idle connections
|
|
27
|
+
* are reaped automatically in addition to the explicit sweeper.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
|
|
31
|
+
import type { TableProps } from 'aws-cdk-lib/aws-dynamodb';
|
|
32
|
+
import type { TableName } from './tables.js';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* All five tables keyed by their logical construct id. The id doubles as the
|
|
36
|
+
* CloudFormation logical id; the physical `TableName` is set on each entry.
|
|
37
|
+
*
|
|
38
|
+
* NOTE: the deployed physical name is **environment-prefixed** by
|
|
39
|
+
* `IrcAwsStack` (`<Env><LogicalId>`, e.g. `StagingConnections`) — the
|
|
40
|
+
* `tableName` here is the schema default overridden at synth time. Both
|
|
41
|
+
* the CDK stack and the runtime read from this record. Tests that prefix
|
|
42
|
+
* every physical name (to isolate parallel runs) consume the
|
|
43
|
+
* `TABLE_DEFS` entries' key/attribute names only.
|
|
44
|
+
*/
|
|
45
|
+
export const TABLE_DEFS: Record<TableName, TableProps> = {
|
|
46
|
+
Connections: {
|
|
47
|
+
tableName: 'Connections',
|
|
48
|
+
partitionKey: { name: 'connectionId', type: AttributeType.STRING },
|
|
49
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
50
|
+
timeToLiveAttribute: 'idleSince',
|
|
51
|
+
},
|
|
52
|
+
ChannelMeta: {
|
|
53
|
+
tableName: 'ChannelMeta',
|
|
54
|
+
partitionKey: { name: 'channelName', type: AttributeType.STRING },
|
|
55
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
56
|
+
},
|
|
57
|
+
ChannelMembers: {
|
|
58
|
+
tableName: 'ChannelMembers',
|
|
59
|
+
partitionKey: { name: 'channelName', type: AttributeType.STRING },
|
|
60
|
+
sortKey: { name: 'connectionId', type: AttributeType.STRING },
|
|
61
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
62
|
+
},
|
|
63
|
+
Nicks: {
|
|
64
|
+
tableName: 'Nicks',
|
|
65
|
+
partitionKey: { name: 'nickLower', type: AttributeType.STRING },
|
|
66
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
67
|
+
},
|
|
68
|
+
Accounts: {
|
|
69
|
+
tableName: 'Accounts',
|
|
70
|
+
partitionKey: { name: 'account', type: AttributeType.STRING },
|
|
71
|
+
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lambda env config loader.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the Cloudflare (`cf-adapter/src/config-loader.ts`) and local-cli
|
|
5
|
+
* (`local-cli/src/config-loader.ts`) adapters: takes platform-native env
|
|
6
|
+
* vars and feeds them through the shared {@link parseServerConfig} so a
|
|
7
|
+
* single boot-time failure mode covers every adapter.
|
|
8
|
+
*
|
|
9
|
+
* Lambda env vars are all strings; numeric knobs are coerced here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The subset of `process.env` this loader reads. Optional members are
|
|
16
|
+
* omitted from the input object when undefined so the schema's defaults
|
|
17
|
+
* apply.
|
|
18
|
+
*/
|
|
19
|
+
export interface LambdaConfigEnv {
|
|
20
|
+
SERVER_NAME?: string;
|
|
21
|
+
NETWORK_NAME?: string;
|
|
22
|
+
MOTD?: string;
|
|
23
|
+
MAX_CLIENTS?: string;
|
|
24
|
+
CHANNEL_PREFIXES?: string;
|
|
25
|
+
OPER_USER?: string;
|
|
26
|
+
OPER_PASSWORD?: string;
|
|
27
|
+
MAX_CHANNELS_PER_USER?: string;
|
|
28
|
+
MAX_TARGETS_PER_COMMAND?: string;
|
|
29
|
+
NICK_LEN?: string;
|
|
30
|
+
CHANNEL_LEN?: string;
|
|
31
|
+
TOPIC_LEN?: string;
|
|
32
|
+
MAX_LIST_ENTRIES?: string;
|
|
33
|
+
QUIT_MESSAGE?: string;
|
|
34
|
+
/**
|
|
35
|
+
* SASL PLAIN accounts. Newline-delimited `username:password` pairs
|
|
36
|
+
* (same convention as the CF adapter's `SASL_ACCOUNTS`). Parsed into
|
|
37
|
+
* the `saslAccounts` config field so {@link bindAccountStore} can seed
|
|
38
|
+
* an `InMemoryAccountStore` at boot.
|
|
39
|
+
*/
|
|
40
|
+
SASL_ACCOUNTS?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Builds the input object the shared schema expects from a Lambda env.
|
|
45
|
+
*
|
|
46
|
+
* Exported so the assemble→parse boundary is unit-testable in isolation.
|
|
47
|
+
* Mirrors `cf-adapter`'s `buildConfigInput` line-for-line except for the
|
|
48
|
+
* MOTD env-var name (`MOTD` here vs `MOTD_LINES` in CF — Lambda env vars
|
|
49
|
+
* conventionally use bare names).
|
|
50
|
+
*/
|
|
51
|
+
export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unknown> {
|
|
52
|
+
const input: Record<string, unknown> = {
|
|
53
|
+
serverName: env.SERVER_NAME,
|
|
54
|
+
networkName: env.NETWORK_NAME,
|
|
55
|
+
};
|
|
56
|
+
if (env.MOTD !== undefined && env.MOTD.length > 0) {
|
|
57
|
+
input.motdLines = env.MOTD.split('\n');
|
|
58
|
+
}
|
|
59
|
+
if (env.MAX_CLIENTS !== undefined) {
|
|
60
|
+
input.maxClients = Number.parseInt(env.MAX_CLIENTS, 10);
|
|
61
|
+
}
|
|
62
|
+
if (env.CHANNEL_PREFIXES !== undefined) {
|
|
63
|
+
input.channelPrefixes = env.CHANNEL_PREFIXES;
|
|
64
|
+
}
|
|
65
|
+
if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
|
|
66
|
+
input.operCreds = [
|
|
67
|
+
{
|
|
68
|
+
user: env.OPER_USER ?? '',
|
|
69
|
+
password: env.OPER_PASSWORD ?? '',
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
if (env.MAX_CHANNELS_PER_USER !== undefined) {
|
|
74
|
+
input.maxChannelsPerUser = Number.parseInt(env.MAX_CHANNELS_PER_USER, 10);
|
|
75
|
+
}
|
|
76
|
+
if (env.MAX_TARGETS_PER_COMMAND !== undefined) {
|
|
77
|
+
input.maxTargetsPerCommand = Number.parseInt(env.MAX_TARGETS_PER_COMMAND, 10);
|
|
78
|
+
}
|
|
79
|
+
if (env.NICK_LEN !== undefined) {
|
|
80
|
+
input.nickLen = Number.parseInt(env.NICK_LEN, 10);
|
|
81
|
+
}
|
|
82
|
+
if (env.CHANNEL_LEN !== undefined) {
|
|
83
|
+
input.channelLen = Number.parseInt(env.CHANNEL_LEN, 10);
|
|
84
|
+
}
|
|
85
|
+
if (env.TOPIC_LEN !== undefined) {
|
|
86
|
+
input.topicLen = Number.parseInt(env.TOPIC_LEN, 10);
|
|
87
|
+
}
|
|
88
|
+
if (env.MAX_LIST_ENTRIES !== undefined) {
|
|
89
|
+
input.maxListEntries = Number.parseInt(env.MAX_LIST_ENTRIES, 10);
|
|
90
|
+
}
|
|
91
|
+
if (env.QUIT_MESSAGE !== undefined) {
|
|
92
|
+
input.quitMessage = env.QUIT_MESSAGE;
|
|
93
|
+
}
|
|
94
|
+
if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
|
|
95
|
+
input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
|
|
96
|
+
}
|
|
97
|
+
return input;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parses a newline-delimited `username:password` string into the config's
|
|
102
|
+
* `saslAccounts` shape. Malformed entries (missing colon, empty fields)
|
|
103
|
+
* are skipped so a trailing newline or partial edit does not break boot.
|
|
104
|
+
*/
|
|
105
|
+
function parseSaslAccountsLines(raw: string): Array<{ username: string; password: string }> {
|
|
106
|
+
const out: Array<{ username: string; password: string }> = [];
|
|
107
|
+
for (const line of raw.split('\n')) {
|
|
108
|
+
const trimmed = line.trim();
|
|
109
|
+
if (trimmed.length === 0) continue;
|
|
110
|
+
const sep = trimmed.indexOf(':');
|
|
111
|
+
if (sep <= 0) continue;
|
|
112
|
+
const username = trimmed.slice(0, sep);
|
|
113
|
+
const password = trimmed.slice(sep + 1);
|
|
114
|
+
if (username.length === 0 || password.length === 0) continue;
|
|
115
|
+
out.push({ username, password });
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Loads and validates the server config from a Lambda env. Throws a
|
|
122
|
+
* readable `Error` listing every invalid field on failure.
|
|
123
|
+
*/
|
|
124
|
+
export function loadServerConfigFromLambdaEnv(env: LambdaConfigEnv): ParsedServerConfig {
|
|
125
|
+
return parseServerConfig(buildLambdaConfigInput(env));
|
|
126
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
|
|
21
|
+
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
22
|
+
import type { ScanCommandOutput } from '@aws-sdk/client-dynamodb';
|
|
23
|
+
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
24
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
25
|
+
import type { AccountStore, SaslPayload, SaslResult } from '@serverless-ircd/irc-core';
|
|
26
|
+
|
|
27
|
+
const SCRYPT_KEY_LEN = 64;
|
|
28
|
+
const SALT_LEN = 16;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A single SASL PLAIN credential stored as a scrypt hash.
|
|
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
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Verifies a plaintext password against a {@link HashedAccountCredential}
|
|
70
|
+
* using `timingSafeEqual` (no short-circuit on first mismatched byte).
|
|
71
|
+
*
|
|
72
|
+
* Returns `false` for malformed entries rather than throwing — a corrupt
|
|
73
|
+
* row should not crash the SASL exchange.
|
|
74
|
+
*/
|
|
75
|
+
export function verifyHashedPassword(password: string, entry: HashedAccountCredential): boolean {
|
|
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
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Scans the `Accounts` table and returns a fully-populated
|
|
124
|
+
* {@link DynamoAccountStore}, or `undefined` when the table has no rows.
|
|
125
|
+
*
|
|
126
|
+
* Handles pagination (`LastEvaluatedKey`) so tables larger than the 1 MB
|
|
127
|
+
* scan page are fully loaded. Rows missing required attributes are
|
|
128
|
+
* silently skipped.
|
|
129
|
+
*/
|
|
130
|
+
export async function loadDynamoAccountStore(
|
|
131
|
+
docClient: DynamoDBDocumentClient,
|
|
132
|
+
tableName: string,
|
|
133
|
+
): Promise<DynamoAccountStore | undefined> {
|
|
134
|
+
const entries: HashedAccountCredential[] = [];
|
|
135
|
+
let exclusiveStartKey: Record<string, unknown> | undefined = undefined;
|
|
136
|
+
do {
|
|
137
|
+
const result: ScanCommandOutput = await docClient.send(
|
|
138
|
+
new ScanCommand({
|
|
139
|
+
TableName: tableName,
|
|
140
|
+
ExclusiveStartKey: exclusiveStartKey,
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
for (const item of result.Items ?? []) {
|
|
144
|
+
const account = typeof item.account === 'string' ? item.account : undefined;
|
|
145
|
+
const algorithm = typeof item.algorithm === 'string' ? item.algorithm : undefined;
|
|
146
|
+
const salt = typeof item.salt === 'string' ? item.salt : undefined;
|
|
147
|
+
const hash = typeof item.hash === 'string' ? item.hash : undefined;
|
|
148
|
+
if (account !== undefined && algorithm !== undefined && salt !== undefined && hash !== undefined) {
|
|
149
|
+
entries.push({ account, algorithm: algorithm as HashedAccountCredential['algorithm'], salt, hash });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
exclusiveStartKey = result.LastEvaluatedKey;
|
|
153
|
+
} while (exclusiveStartKey !== undefined);
|
|
154
|
+
if (entries.length === 0) return undefined;
|
|
155
|
+
return new DynamoAccountStore(entries);
|
|
156
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB DocumentClient factory.
|
|
3
|
+
*
|
|
4
|
+
* Centralised so the runtime, the handlers, and the test harness all
|
|
5
|
+
* build their clients with the same marshalling conventions
|
|
6
|
+
* (`removeUndefinedValues: true`, native JS values in/out).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
10
|
+
import {
|
|
11
|
+
DynamoDBDocumentClient,
|
|
12
|
+
type TranslateConfig,
|
|
13
|
+
type marshallOptions,
|
|
14
|
+
type unmarshallOptions,
|
|
15
|
+
} from '@aws-sdk/lib-dynamodb';
|
|
16
|
+
|
|
17
|
+
/** Default marshalling options — every caller uses these. */
|
|
18
|
+
const DEFAULT_TRANSLATE: TranslateConfig = {
|
|
19
|
+
marshallOptions: {
|
|
20
|
+
removeUndefinedValues: true,
|
|
21
|
+
convertClassInstanceToMap: false,
|
|
22
|
+
} as marshallOptions,
|
|
23
|
+
unmarshallOptions: {} as unmarshallOptions,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds a {@link DynamoDBDocumentClient} pointed at `endpoint`
|
|
28
|
+
* (or AWS proper when `endpoint` is omitted).
|
|
29
|
+
*
|
|
30
|
+
* Tests pass `endpoint` so they can address DynamoDB Local; production
|
|
31
|
+
* constructs the client with no endpoint and lets the SDK resolve the
|
|
32
|
+
* region from the Lambda execution role.
|
|
33
|
+
*
|
|
34
|
+
* `region` defaults to `'us-east-1'` when unset. DynamoDB Local ignores
|
|
35
|
+
* the value but the SDK requires one to be present so it can build the
|
|
36
|
+
* SigV4 signature. Production deployments inject a real region via the
|
|
37
|
+
* Lambda execution environment (`AWS_REGION`).
|
|
38
|
+
*
|
|
39
|
+
* When `endpoint` is supplied (the documented signal for DynamoDB Local
|
|
40
|
+
* / test mode) dummy static credentials are injected. The SDK v3 signs
|
|
41
|
+
* every request with SigV4 even against a local endpoint, so without
|
|
42
|
+
* credentials the default provider chain fails with
|
|
43
|
+
* `CredentialsProviderError`. Production never passes an endpoint and
|
|
44
|
+
* therefore resolves real credentials from the Lambda execution role.
|
|
45
|
+
*/
|
|
46
|
+
export function createDynamoDocumentClient(opts: {
|
|
47
|
+
endpoint?: string;
|
|
48
|
+
region?: string;
|
|
49
|
+
}): DynamoDBDocumentClient {
|
|
50
|
+
const region = opts.region ?? process.env.AWS_REGION ?? 'us-east-1';
|
|
51
|
+
const client = new DynamoDBClient({
|
|
52
|
+
region,
|
|
53
|
+
...(opts.endpoint !== undefined
|
|
54
|
+
? {
|
|
55
|
+
endpoint: opts.endpoint,
|
|
56
|
+
credentials: { accessKeyId: 'local', secretAccessKey: 'local' },
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
});
|
|
60
|
+
return DynamoDBDocumentClient.from(client, DEFAULT_TRANSLATE);
|
|
61
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `$connect` handler — Lambda entry for `event.requestContext.routeKey === '$connect'`.
|
|
3
|
+
*
|
|
4
|
+
* Persists a fresh `Connections` row keyed by the APIGW connection id.
|
|
5
|
+
* Subsequent `$default` invocations load this row, run the actor, and
|
|
6
|
+
* persist the mutated state back.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
10
|
+
import { PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
11
|
+
import type { ParsedServerConfig } from '@serverless-ircd/irc-core';
|
|
12
|
+
import { marshalConnection } from '../serialize.js';
|
|
13
|
+
import type { TablesConfig } from '../tables.js';
|
|
14
|
+
import { createInitialConnectionState } from './state.js';
|
|
15
|
+
|
|
16
|
+
/** Parameters accepted by {@link handleConnect}. */
|
|
17
|
+
export interface ConnectParams {
|
|
18
|
+
dynamo: DynamoDBDocumentClient;
|
|
19
|
+
tables: TablesConfig;
|
|
20
|
+
connectionId: string;
|
|
21
|
+
/** Wall clock used to seed `connectedSince` / `idleSince` (default: now). */
|
|
22
|
+
now?: number;
|
|
23
|
+
/** Reserved for future use; required so the signature mirrors `$default`. */
|
|
24
|
+
serverConfig?: ParsedServerConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Inserts the Connections row for a fresh WebSocket upgrade.
|
|
29
|
+
*
|
|
30
|
+
* Idempotent: APIGW retries `$connect` on transient failures, so we
|
|
31
|
+
* use `PutCommand` (overwrites) rather than a conditional insert.
|
|
32
|
+
*/
|
|
33
|
+
export async function handleConnect(params: ConnectParams): Promise<void> {
|
|
34
|
+
const now = params.now ?? Date.now();
|
|
35
|
+
const state = createInitialConnectionState(params.connectionId, now);
|
|
36
|
+
void params.serverConfig; // reserved for max-clients gating (later ticket)
|
|
37
|
+
const row = marshalConnection(state, now);
|
|
38
|
+
await params.dynamo.send(
|
|
39
|
+
new PutCommand({
|
|
40
|
+
TableName: params.tables.Connections,
|
|
41
|
+
Item: row,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
}
|