serverless-ircd 0.2.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/CHANGELOG.md +84 -0
- package/README.md +28 -19
- 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 +41 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +1 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +28 -2
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- 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 +6 -4
- package/docs/AWS-Deployment.md +86 -7
- package/docs/Cloudflare-Deployment-Guide.md +5 -5
- package/docs/Home.md +11 -1
- package/docs/Release-Process.md +9 -6
- package/package.json +14 -15
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +118 -30
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +30 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/handlers/default.ts +8 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +51 -6
- package/packages/aws-adapter/src/index.ts +7 -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/config-loader.test.ts +55 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/handlers.test.ts +46 -0
- package/packages/cf-adapter/package.json +1 -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 +29 -0
- package/packages/cf-adapter/src/connection-do.ts +85 -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-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +40 -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 +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +3 -2
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/commands/index.ts +2 -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 +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/registration.ts +8 -7
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +26 -3
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +68 -4
- package/packages/irc-core/src/types.ts +22 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -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/registration.test.ts +106 -14
- 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 +75 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +24 -0
- package/packages/irc-server/src/routing.ts +9 -0
- package/packages/irc-server/tests/actor.test.ts +220 -1
- package/packages/irc-server/tests/routing.test.ts +3 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
- package/pnpm-workspace.yaml +1 -0
- package/tools/seed-aws-accounts.ts +80 -0
- 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/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
package/docs/Home.md
CHANGED
|
@@ -1 +1,11 @@
|
|
|
1
|
-
Welcome to the Wiki.
|
|
1
|
+
Welcome to the Wiki.
|
|
2
|
+
|
|
3
|
+
## Pages
|
|
4
|
+
|
|
5
|
+
- [AWS Adapter Architecture](AWS-Adapter-Architecture)
|
|
6
|
+
- [AWS Deployment Guide](AWS-Deployment)
|
|
7
|
+
- [Cloudflare Deployment Guide](Cloudflare-Deployment-Guide)
|
|
8
|
+
- [Observability](Observability)
|
|
9
|
+
- [IRCv3 WebSocket Extension Plan](PlanIRCv3Websocket)
|
|
10
|
+
- [Web Client Plan](PlanWebClient)
|
|
11
|
+
- [Release Process](Release-Process)
|
package/docs/Release-Process.md
CHANGED
|
@@ -110,11 +110,15 @@ pnpm typecheck # tsc --noEmit across workspace
|
|
|
110
110
|
pnpm test # full suite
|
|
111
111
|
pnpm build # every package produces dist/
|
|
112
112
|
|
|
113
|
-
# 4. Coverage gate.
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
113
|
+
# 4. Coverage gate (enforced in CI per package).
|
|
114
|
+
# The per-package coverage gate (TICKET-048) runs in CI via
|
|
115
|
+
# `pnpm coverage`; Vitest fails the build on any threshold regression.
|
|
116
|
+
# Thresholds: 100% (lines/functions/branches/statements) on
|
|
117
|
+
# irc-core / irc-server / in-memory-runtime, and ≥90% on every
|
|
118
|
+
# other package (cf-adapter / local-cli / tcp-ws-forwarder). See the
|
|
119
|
+
# "Coverage gate" step in .github/workflows/ci.yml. CI must already
|
|
120
|
+
# be green (step 2), so locally re-run `pnpm coverage` as a
|
|
121
|
+
# belt-and-braces check before tagging.
|
|
118
122
|
|
|
119
123
|
# 5. Staging smoke e2e passes against the deployed tip-of-main.
|
|
120
124
|
node apps/cf-worker/scripts/smoke.mjs \
|
|
@@ -135,7 +139,6 @@ checklist — do not tag over a broken tip.
|
|
|
135
139
|
- Flood control (TICKET-028).
|
|
136
140
|
- Server password / rate limits / cloaking (TICKET-046).
|
|
137
141
|
- Config loader (TICKET-047).
|
|
138
|
-
- CI coverage gate ≥90% (TICKET-048).
|
|
139
142
|
|
|
140
143
|
These belong in a later 0.x release. Their absence is **not** a
|
|
141
144
|
blocker and should be called out under "Known limitations" in the
|
package/package.json
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "serverless-ircd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
7
|
-
"packageManager": "pnpm@9.15.9",
|
|
8
7
|
"engines": {
|
|
9
8
|
"node": ">=20"
|
|
10
9
|
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@biomejs/biome": "^1.9.0",
|
|
12
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
13
|
+
"fast-check": "^3.19.0",
|
|
14
|
+
"rimraf": "^6.0.0",
|
|
15
|
+
"turbo": "^2.0.0",
|
|
16
|
+
"typescript": "^5.6.0",
|
|
17
|
+
"vite": "^7.3.0",
|
|
18
|
+
"vitest": "^4.1.0"
|
|
19
|
+
},
|
|
11
20
|
"scripts": {
|
|
12
21
|
"build": "turbo run build",
|
|
13
22
|
"typecheck": "turbo run typecheck",
|
|
@@ -24,18 +33,8 @@
|
|
|
24
33
|
"deploy:cf:staging": "pnpm --filter @serverless-ircd/cf-worker deploy:staging",
|
|
25
34
|
"deploy:cf:prod": "pnpm --filter @serverless-ircd/cf-worker deploy:prod",
|
|
26
35
|
"smoke:cf:staging": "pnpm --filter @serverless-ircd/cf-worker smoke:staging",
|
|
27
|
-
"deploy:aws:staging": "pnpm --filter @serverless-ircd/aws-stack deploy:staging",
|
|
28
|
-
"deploy:aws:prod": "pnpm --filter @serverless-ircd/aws-stack deploy:prod",
|
|
36
|
+
"deploy:aws:staging": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:staging",
|
|
37
|
+
"deploy:aws:prod": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:prod",
|
|
29
38
|
"smoke:aws:staging": "pnpm --filter @serverless-ircd/aws-stack smoke:staging"
|
|
30
|
-
},
|
|
31
|
-
"devDependencies": {
|
|
32
|
-
"@biomejs/biome": "^1.9.0",
|
|
33
|
-
"@vitest/coverage-v8": "^4.1.0",
|
|
34
|
-
"fast-check": "^3.19.0",
|
|
35
|
-
"rimraf": "^6.0.0",
|
|
36
|
-
"turbo": "^2.0.0",
|
|
37
|
-
"typescript": "^5.6.0",
|
|
38
|
-
"vite": "^7.3.0",
|
|
39
|
-
"vitest": "^4.1.0"
|
|
40
39
|
}
|
|
41
|
-
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SASL account store construction + resolution for the AWS adapter.
|
|
3
|
+
*
|
|
4
|
+
* Centralises `AccountStore` creation so the actor-construction site
|
|
5
|
+
* (`handleDefault`) binds a single helper rather than constructing the
|
|
6
|
+
* store inline. Two sources of credentials are reconciled here:
|
|
7
|
+
*
|
|
8
|
+
* 1. The DynamoDB `Accounts` table (authoritative when populated) —
|
|
9
|
+
* loaded at cold start into a {@link DynamoAccountStore} by
|
|
10
|
+
* {@link resolveAccountStore}. Credentials are stored as scrypt
|
|
11
|
+
* hashes, never plaintext.
|
|
12
|
+
* 2. The parsed server config (`saslAccounts`, sourced from the
|
|
13
|
+
* `SASL_ACCOUNTS` env var) — the legacy/config fallback used only
|
|
14
|
+
* when the table is empty, preserving the config-seed behaviour for
|
|
15
|
+
* deployments that have not migrated to the table.
|
|
16
|
+
*
|
|
17
|
+
* The resolved store is scoped to the Lambda execution context: it
|
|
18
|
+
* persists across warm invocations (via the memoised `HandlerDeps`) but
|
|
19
|
+
* is rebuilt on cold start, picking up account changes made since the
|
|
20
|
+
* last cold start.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
24
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
25
|
+
import {
|
|
26
|
+
type AccountStore,
|
|
27
|
+
InMemoryAccountStore,
|
|
28
|
+
type ParsedServerConfig,
|
|
29
|
+
} from '@serverless-ircd/irc-core';
|
|
30
|
+
import {
|
|
31
|
+
type HashedAccountCredential,
|
|
32
|
+
DynamoAccountStore,
|
|
33
|
+
hashAccountCredential,
|
|
34
|
+
loadDynamoAccountStore,
|
|
35
|
+
} from './dynamo-account-store.js';
|
|
36
|
+
|
|
37
|
+
// Re-export so callers can import everything from one module.
|
|
38
|
+
export {
|
|
39
|
+
DynamoAccountStore,
|
|
40
|
+
type HashedAccountCredential,
|
|
41
|
+
hashAccountCredential,
|
|
42
|
+
loadDynamoAccountStore,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Constructs the config-seeded in-memory account store (the legacy
|
|
47
|
+
* fallback). Returns `undefined` when no accounts are configured so the
|
|
48
|
+
* actor's `ctx.accounts` stays unset (preserving the no-store behaviour:
|
|
49
|
+
* `AUTHENTICATE PLAIN` → `904`).
|
|
50
|
+
*
|
|
51
|
+
* @param serverConfig Parsed config carrying the `saslAccounts` seed list.
|
|
52
|
+
*/
|
|
53
|
+
export function bindAccountStore(serverConfig?: ParsedServerConfig): AccountStore | undefined {
|
|
54
|
+
if (serverConfig === undefined || serverConfig.saslAccounts.length === 0) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return new InMemoryAccountStore(serverConfig.saslAccounts);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolves the SASL account store for a deployment, applying the
|
|
62
|
+
* table-then-config precedence.
|
|
63
|
+
*
|
|
64
|
+
* 1. Scans the DynamoDB `Accounts` table (pre-loading every hashed
|
|
65
|
+
* credential into a {@link DynamoAccountStore}). When the table has
|
|
66
|
+
* one or more rows it is authoritative — the table wins and the
|
|
67
|
+
* config seed is ignored.
|
|
68
|
+
* 2. When the table is empty, falls back to {@link bindAccountStore}
|
|
69
|
+
* (the `SASL_ACCOUNTS` env-var seed) so existing deployments that
|
|
70
|
+
* have not migrated to the table keep working unchanged.
|
|
71
|
+
* 3. Returns `undefined` when neither source has accounts (the default)
|
|
72
|
+
* so `ctx.accounts` stays unset and `AUTHENTICATE PLAIN` → `904`.
|
|
73
|
+
*
|
|
74
|
+
* Scan failures (table not found, network error) are swallowed and
|
|
75
|
+
* treated as an empty table, falling through to the config seed.
|
|
76
|
+
*
|
|
77
|
+
* @param docClient DynamoDB client addressed at the deployment's tables.
|
|
78
|
+
* @param tableName Physical name of the `Accounts` table for this env.
|
|
79
|
+
* @param serverConfig Parsed config (the config-seed fallback source).
|
|
80
|
+
*/
|
|
81
|
+
export async function resolveAccountStore(
|
|
82
|
+
docClient: DynamoDBDocumentClient,
|
|
83
|
+
tableName: string,
|
|
84
|
+
serverConfig?: ParsedServerConfig,
|
|
85
|
+
): Promise<AccountStore | undefined> {
|
|
86
|
+
try {
|
|
87
|
+
const store = await loadDynamoAccountStore(docClient, tableName);
|
|
88
|
+
if (store !== undefined) return store;
|
|
89
|
+
} catch {
|
|
90
|
+
// Table might not exist or be unreachable; fall through to config seed.
|
|
91
|
+
}
|
|
92
|
+
return bindAccountStore(serverConfig);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Writes (or re-seeds) a SASL PLAIN account into the `Accounts` table.
|
|
97
|
+
*
|
|
98
|
+
* The password is hashed via {@link hashAccountCredential} (scrypt,
|
|
99
|
+
* random salt) and stored as a {@link HashedAccountCredential} row —
|
|
100
|
+
* plaintext is never written. A repeat call for the same `username`
|
|
101
|
+
* overwrites the prior row (the PK is `account`), so this is the
|
|
102
|
+
* canonical CRUD/seed entry point for oper and SASL account
|
|
103
|
+
* provisioning.
|
|
104
|
+
*
|
|
105
|
+
* @returns The stored {@link HashedAccountCredential} (for tooling/tests).
|
|
106
|
+
*/
|
|
107
|
+
export async function putAccountCredential(
|
|
108
|
+
docClient: DynamoDBDocumentClient,
|
|
109
|
+
tableName: string,
|
|
110
|
+
username: string,
|
|
111
|
+
password: string,
|
|
112
|
+
): Promise<HashedAccountCredential> {
|
|
113
|
+
const entry = hashAccountCredential(username, password);
|
|
114
|
+
await docClient.send(
|
|
115
|
+
new PutCommand({
|
|
116
|
+
TableName: tableName,
|
|
117
|
+
Item: entry,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
return entry;
|
|
121
|
+
}
|
|
@@ -627,56 +627,144 @@ export class AwsRuntime implements IrcRuntime {
|
|
|
627
627
|
|
|
628
628
|
/**
|
|
629
629
|
* Tears down a connection: deletes its Connections row, removes every
|
|
630
|
-
* ChannelMembers row owned by it,
|
|
630
|
+
* ChannelMembers row owned by it, releases its nick (if any), and — when
|
|
631
|
+
* a `managementApi` is supplied — broadcasts a `QUIT` line to every peer
|
|
632
|
+
* that shares a channel with the disconnecting connection.
|
|
631
633
|
*
|
|
632
634
|
* Idempotent: every step tolerates a missing row. Safe to call from
|
|
633
|
-
* `$disconnect`, from the `send` GoneException path, and
|
|
634
|
-
*
|
|
635
|
+
* `$disconnect`, from the `send`/`broadcast` `GoneException` path, and
|
|
636
|
+
* from the sweeper Lambda.
|
|
635
637
|
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
+
* Recursion safety: the Connections row is deleted *before* the QUIT
|
|
639
|
+
* fanout so a recursive `cleanupConnection` triggered by a gone peer
|
|
640
|
+
* during fanout finds the row already gone and returns immediately —
|
|
641
|
+
* no unbounded recursion.
|
|
642
|
+
*
|
|
643
|
+
* `managementApi` is optional because callers that never need fanout
|
|
644
|
+
* (the sweeper Lambda, or a no-op test) may pass `null`.
|
|
638
645
|
*/
|
|
639
646
|
export async function cleanupConnection(
|
|
640
647
|
dynamo: DynamoDBDocumentClient,
|
|
641
648
|
tables: TablesConfig,
|
|
642
649
|
connId: ConnId,
|
|
643
650
|
managementApi: ApiGatewayManagementApi | PostToConnection | null,
|
|
651
|
+
quitMessage = 'Client Quit',
|
|
644
652
|
): Promise<void> {
|
|
645
|
-
void managementApi; // reserved for a future "broadcast QUIT to peers" step
|
|
646
653
|
const connRow = await dynamo.send(
|
|
647
654
|
new GetCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
|
|
648
655
|
);
|
|
649
656
|
const item = connRow.Item as unknown as MarshalledConnection | undefined;
|
|
650
|
-
if (item
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
657
|
+
if (item === undefined) return; // idempotent — already cleaned up
|
|
658
|
+
|
|
659
|
+
// Delete the Connections row FIRST. A recursive cleanup triggered by
|
|
660
|
+
// a gone peer during QUIT fanout reads this same row, finds it gone,
|
|
661
|
+
// and returns — bounding the recursion at one extra hop.
|
|
662
|
+
await dynamo.send(
|
|
663
|
+
new DeleteCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
if (item.nick !== undefined) {
|
|
667
|
+
try {
|
|
668
|
+
await dynamo.send(
|
|
669
|
+
new DeleteCommand({
|
|
670
|
+
TableName: tables.Nicks,
|
|
671
|
+
Key: { nickLower: item.nick.toLowerCase() },
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
674
|
+
} catch {
|
|
675
|
+
// Idempotent — the nick may have been released already.
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Broadcast QUIT to peers in shared channels *before* removing the
|
|
680
|
+
// disconnecting connection's membership rows — the roster query needs
|
|
681
|
+
// those rows to resolve the fanout targets.
|
|
682
|
+
if (item.nick !== undefined && managementApi !== null) {
|
|
683
|
+
await broadcastQuitToChannels(dynamo, tables, connId, item, managementApi, quitMessage);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const joined = item.joinedChannels ?? [];
|
|
687
|
+
for (const chan of joined) {
|
|
688
|
+
try {
|
|
689
|
+
await dynamo.send(
|
|
690
|
+
new DeleteCommand({
|
|
691
|
+
TableName: tables.ChannelMembers,
|
|
692
|
+
Key: { channelName: chan.toLowerCase(), connectionId: connId },
|
|
693
|
+
}),
|
|
694
|
+
);
|
|
695
|
+
} catch {
|
|
696
|
+
// Idempotent — the membership may have been removed already.
|
|
662
697
|
}
|
|
663
|
-
|
|
664
|
-
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Builds the canonical `:nick!user@host QUIT :<reason>` line for the
|
|
703
|
+
* disconnecting connection and fans it out to every *other* member of
|
|
704
|
+
* each channel the connection had joined. A peer whose APIGW socket is
|
|
705
|
+
* already gone raises `GoneException`, which is caught and routed back
|
|
706
|
+
* through {@link cleanupConnection} for lazy cleanup — a partially
|
|
707
|
+
* torn-down roster never crashes the fanout loop.
|
|
708
|
+
*
|
|
709
|
+
* Shared by both the `$disconnect` route and the `send()`/`broadcast()`
|
|
710
|
+
* `GoneException` cleanup path.
|
|
711
|
+
*/
|
|
712
|
+
async function broadcastQuitToChannels(
|
|
713
|
+
dynamo: DynamoDBDocumentClient,
|
|
714
|
+
tables: TablesConfig,
|
|
715
|
+
connId: ConnId,
|
|
716
|
+
item: MarshalledConnection,
|
|
717
|
+
managementApi: ApiGatewayManagementApi | PostToConnection,
|
|
718
|
+
quitMessage: string,
|
|
719
|
+
): Promise<void> {
|
|
720
|
+
const hostmask = buildHostmask(item);
|
|
721
|
+
if (hostmask === undefined) return; // no nick → nothing to attribute the QUIT to
|
|
722
|
+
const line = `:${hostmask} QUIT :${quitMessage}`;
|
|
723
|
+
const joined = item.joinedChannels ?? [];
|
|
724
|
+
for (const chan of joined) {
|
|
725
|
+
const members = await queryChannelMembersRows(dynamo, tables, chan.toLowerCase());
|
|
726
|
+
for (const member of members) {
|
|
727
|
+
if (member.connectionId === connId) continue; // never echo to self
|
|
665
728
|
try {
|
|
666
|
-
await
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
// Idempotent — the membership may have been removed already.
|
|
729
|
+
await managementApi.postToConnection({ ConnectionId: member.connectionId, Data: line });
|
|
730
|
+
} catch (err: unknown) {
|
|
731
|
+
if (err instanceof GoneException || isGone(err)) {
|
|
732
|
+
await cleanupConnection(dynamo, tables, member.connectionId, managementApi, quitMessage);
|
|
733
|
+
} else {
|
|
734
|
+
throw err;
|
|
735
|
+
}
|
|
674
736
|
}
|
|
675
737
|
}
|
|
676
738
|
}
|
|
677
|
-
|
|
678
|
-
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Builds the `nick!user@host` hostmask from a stored connection row,
|
|
743
|
+
* omitting the absent parts. Mirrors {@link hostmaskOf} in irc-core but
|
|
744
|
+
* operates on the marshalled shape (no full unmarshal needed).
|
|
745
|
+
*/
|
|
746
|
+
function buildHostmask(item: MarshalledConnection): string | undefined {
|
|
747
|
+
if (item.nick === undefined) return undefined;
|
|
748
|
+
let out: string = item.nick;
|
|
749
|
+
if (item.user !== undefined) out = `${out}!${item.user}`;
|
|
750
|
+
if (item.host !== undefined) out = `${out}@${item.host}`;
|
|
751
|
+
return out;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Queries the raw `ChannelMembers` rows for one channel (by lowercased key). */
|
|
755
|
+
async function queryChannelMembersRows(
|
|
756
|
+
dynamo: DynamoDBDocumentClient,
|
|
757
|
+
tables: TablesConfig,
|
|
758
|
+
key: string,
|
|
759
|
+
): Promise<MarshalledChannelMember[]> {
|
|
760
|
+
const result = await dynamo.send(
|
|
761
|
+
new QueryCommand({
|
|
762
|
+
TableName: tables.ChannelMembers,
|
|
763
|
+
KeyConditionExpression: 'channelName = :k',
|
|
764
|
+
ExpressionAttributeValues: { ':k': key },
|
|
765
|
+
}),
|
|
679
766
|
);
|
|
767
|
+
return (result.Items ?? []) as unknown as MarshalledChannelMember[];
|
|
680
768
|
}
|
|
681
769
|
|
|
682
770
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
*/
|
|
@@ -31,6 +31,13 @@ export interface LambdaConfigEnv {
|
|
|
31
31
|
TOPIC_LEN?: string;
|
|
32
32
|
MAX_LIST_ENTRIES?: string;
|
|
33
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;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
/**
|
|
@@ -84,9 +91,32 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
|
|
|
84
91
|
if (env.QUIT_MESSAGE !== undefined) {
|
|
85
92
|
input.quitMessage = env.QUIT_MESSAGE;
|
|
86
93
|
}
|
|
94
|
+
if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
|
|
95
|
+
input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
|
|
96
|
+
}
|
|
87
97
|
return input;
|
|
88
98
|
}
|
|
89
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
|
+
|
|
90
120
|
/**
|
|
91
121
|
* Loads and validates the server config from a Lambda env. Throws a
|
|
92
122
|
* readable `Error` listing every invalid field on failure.
|
|
@@ -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
|
+
}
|
|
@@ -12,6 +12,7 @@ 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,
|
|
@@ -46,6 +47,12 @@ export interface DefaultParams {
|
|
|
46
47
|
* behaviour of callers that have not opted in.
|
|
47
48
|
*/
|
|
48
49
|
messages?: MessageStore;
|
|
50
|
+
/**
|
|
51
|
+
* SASL account verification source. When omitted the actor runs with
|
|
52
|
+
* `ctx.accounts` undefined so `AUTHENTICATE PLAIN` fails with `904`
|
|
53
|
+
* — preserves the behaviour of callers that have not opted in.
|
|
54
|
+
*/
|
|
55
|
+
accounts?: AccountStore;
|
|
49
56
|
managementApi: ApiGatewayManagementApi | PostToConnection | null;
|
|
50
57
|
/** Injected for tests; defaults to {@link SystemClock}. */
|
|
51
58
|
clock?: Clock;
|
|
@@ -116,6 +123,7 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
116
123
|
ids,
|
|
117
124
|
motd: params.motd,
|
|
118
125
|
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
126
|
+
...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
|
|
119
127
|
});
|
|
120
128
|
|
|
121
129
|
try {
|