serverless-ircd 0.8.0 → 0.10.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 +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
import { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
|
|
15
15
|
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
16
16
|
import {
|
|
17
|
-
type AccountStore,
|
|
18
17
|
EmptyMotdProvider,
|
|
19
18
|
InMemoryNickHistoryStore,
|
|
20
19
|
type MessageStore,
|
|
@@ -25,11 +24,11 @@ import {
|
|
|
25
24
|
StaticMotdProvider,
|
|
26
25
|
SystemClock,
|
|
27
26
|
} from '@serverless-ircd/irc-core';
|
|
28
|
-
import { resolveAccountStore } from '../account-store.js';
|
|
29
27
|
import { type LambdaConfigEnv, loadServerConfigFromLambdaEnv } from '../config-loader.js';
|
|
30
28
|
import { type DynamoServicesStore, loadDynamoServicesStore } from '../dynamo-services-store.js';
|
|
31
29
|
import { createDynamoDocumentClient } from '../dynamo.js';
|
|
32
30
|
import { bindMessageStore } from '../message-store.js';
|
|
31
|
+
import { parseWebOrigins } from '../origin-allowlist.js';
|
|
33
32
|
import { TABLE_KEYS, type TablesConfig } from '../tables.js';
|
|
34
33
|
import { handleConnect } from './connect.js';
|
|
35
34
|
import { handleDefault } from './default.js';
|
|
@@ -105,20 +104,15 @@ export interface HandlerDeps {
|
|
|
105
104
|
* DynamoDB-backed variant is a documented follow-up.
|
|
106
105
|
*/
|
|
107
106
|
messages: MessageStore;
|
|
108
|
-
/**
|
|
109
|
-
* SASL account verification source bound to every actor. Constructed
|
|
110
|
-
* once per cold start (memoised via {@link cachedDeps}) so it survives
|
|
111
|
-
* across warm Lambda invocations. `undefined` when no accounts are
|
|
112
|
-
* configured (the default) so `ctx.accounts` stays unset.
|
|
113
|
-
*/
|
|
114
|
-
accounts?: AccountStore;
|
|
115
107
|
/**
|
|
116
108
|
* Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
|
|
117
|
-
* OperServ + read-marker
|
|
118
|
-
* `Services` DynamoDB table into a
|
|
119
|
-
* `SERVICES_TABLE` is unset
|
|
120
|
-
*
|
|
121
|
-
*
|
|
109
|
+
* OperServ + read-marker + the unified SASL/PASS credential home),
|
|
110
|
+
* hydrated once per cold start from the `Services` DynamoDB table into a
|
|
111
|
+
* write-behind cache. `undefined` when `SERVICES_TABLE` is unset
|
|
112
|
+
* (services unbound → no NickServ/ChanServ routing, no SASL/PASS
|
|
113
|
+
* credential verify). The handler flushes pending writes after each
|
|
114
|
+
* `$default` / NLB frame so a Lambda freeze / evict does not lose
|
|
115
|
+
* services state.
|
|
122
116
|
*/
|
|
123
117
|
services?: DynamoServicesStore;
|
|
124
118
|
/**
|
|
@@ -136,6 +130,16 @@ export interface HandlerDeps {
|
|
|
136
130
|
* path applies (error-suffixed `382`).
|
|
137
131
|
*/
|
|
138
132
|
configLoader?: () => Promise<ServerConfig>;
|
|
133
|
+
/**
|
|
134
|
+
* Parsed `WEB_ORIGINS` allowlist for the `$connect` CSWSH defence.
|
|
135
|
+
* Empty set (the default) disables the check; non-empty causes
|
|
136
|
+
* {@link handleConnect} to reject browser-sent `Origin` headers that
|
|
137
|
+
* are not in the set with `403`. Built once per cold start from
|
|
138
|
+
* `process.env.WEB_ORIGINS` via {@link parseWebOrigins}; non-browser
|
|
139
|
+
* clients (curl, WeeChat, the `tcp-ws-forwarder`) never send `Origin`
|
|
140
|
+
* and always proceed regardless.
|
|
141
|
+
*/
|
|
142
|
+
webOrigins?: Set<string> | undefined;
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
/**
|
|
@@ -196,7 +200,12 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
|
|
|
196
200
|
tables: deps.tables,
|
|
197
201
|
connectionId: connId,
|
|
198
202
|
serverConfig: deps.serverConfig,
|
|
199
|
-
secWebSocketProtocol: event.headers
|
|
203
|
+
secWebSocketProtocol: readHeader(event.headers, 'Sec-WebSocket-Protocol'),
|
|
204
|
+
// APIGW normalises header keys to lowercase, but readHeader
|
|
205
|
+
// tolerates either casing so a future proxy / stage mutation
|
|
206
|
+
// cannot silently bypass the CSWSH defence.
|
|
207
|
+
originHeader: readHeader(event.headers, 'Origin'),
|
|
208
|
+
webOrigins: deps.webOrigins,
|
|
200
209
|
});
|
|
201
210
|
if (outcome.admitted) {
|
|
202
211
|
if (outcome.subprotocol !== null) {
|
|
@@ -233,7 +242,6 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
|
|
|
233
242
|
serverConfig: deps.serverConfig,
|
|
234
243
|
motd: deps.motd,
|
|
235
244
|
messages: deps.messages,
|
|
236
|
-
...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
|
|
237
245
|
...(deps.services !== undefined ? { services: deps.services } : {}),
|
|
238
246
|
...(deps.history !== undefined ? { history: deps.history } : {}),
|
|
239
247
|
...(deps.configLoader !== undefined ? { configLoader: deps.configLoader } : {}),
|
|
@@ -341,7 +349,6 @@ export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStream
|
|
|
341
349
|
motd: deps.motd,
|
|
342
350
|
messages: deps.messages,
|
|
343
351
|
managementApi: deps.managementApi,
|
|
344
|
-
...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
|
|
345
352
|
...(deps.services !== undefined ? { services: deps.services } : {}),
|
|
346
353
|
...(deps.history !== undefined ? { history: deps.history } : {}),
|
|
347
354
|
});
|
|
@@ -359,10 +366,16 @@ export {
|
|
|
359
366
|
* Builds a {@link HandlerDeps} from the current `process.env`.
|
|
360
367
|
*
|
|
361
368
|
* Exposed so tests can construct production-shaped deps without re-running
|
|
362
|
-
* the env parsing. Async because it scans the `
|
|
363
|
-
* (via {@link
|
|
364
|
-
* start — the {@link
|
|
365
|
-
* complete before any reducer calls `
|
|
369
|
+
* the env parsing. Async because it scans the `Services` DynamoDB table
|
|
370
|
+
* (via {@link loadDynamoServicesStore}) to pre-load the services snapshot
|
|
371
|
+
* at cold start — the {@link ServicesStore} port is synchronous, so the
|
|
372
|
+
* scan must complete before any reducer calls `verifyNick` /
|
|
373
|
+
* `verifyCertFP`.
|
|
374
|
+
*
|
|
375
|
+
* After the services scan, the `SASL_ACCOUNTS` env-var seed is ingested
|
|
376
|
+
* into the services store via `registerNick` (idempotent — existing
|
|
377
|
+
* registrations, including NickServ-registered nicks, win over the env
|
|
378
|
+
* seed).
|
|
366
379
|
*/
|
|
367
380
|
export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<HandlerDeps> {
|
|
368
381
|
const cfg = loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
|
|
@@ -378,13 +391,24 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
|
|
|
378
391
|
const motd = cfg.motdLines.length > 0 ? new StaticMotdProvider(cfg.motdLines) : EmptyMotdProvider;
|
|
379
392
|
const messages = bindMessageStore(cfg);
|
|
380
393
|
const history = new InMemoryNickHistoryStore(SystemClock);
|
|
381
|
-
const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
|
|
382
394
|
const servicesTable = typeof env.SERVICES_TABLE === 'string' ? env.SERVICES_TABLE : undefined;
|
|
383
395
|
const services = await loadDynamoServicesStore(dynamo, servicesTable, SystemClock);
|
|
396
|
+
if (services !== undefined) {
|
|
397
|
+
for (const acct of cfg.saslAccounts) {
|
|
398
|
+
services.registerNick(acct.username, acct.password, '');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
384
401
|
// REHASH reload source: re-read the live env so a rotated oper password
|
|
385
402
|
// or MOTD takes effect on the next REHASH without a redeploy.
|
|
386
403
|
const configLoader = async (): Promise<ServerConfig> =>
|
|
387
404
|
loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
|
|
405
|
+
// CSWSH defence — parsed once per cold start so every `$connect`
|
|
406
|
+
// reuses the same set. Empty set (the default when `WEB_ORIGINS` is
|
|
407
|
+
// unset) disables the check; non-empty denies browser-sent origins
|
|
408
|
+
// that are not in the set. Non-browser clients never send `Origin`.
|
|
409
|
+
const webOrigins = parseWebOrigins(
|
|
410
|
+
typeof env.WEB_ORIGINS === 'string' ? env.WEB_ORIGINS : undefined,
|
|
411
|
+
);
|
|
388
412
|
return {
|
|
389
413
|
dynamo,
|
|
390
414
|
tables,
|
|
@@ -394,7 +418,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
|
|
|
394
418
|
messages,
|
|
395
419
|
history,
|
|
396
420
|
configLoader,
|
|
397
|
-
...(
|
|
421
|
+
...(webOrigins.size > 0 ? { webOrigins } : {}),
|
|
398
422
|
...(services !== undefined ? { services } : {}),
|
|
399
423
|
};
|
|
400
424
|
}
|
|
@@ -411,3 +435,23 @@ function tablesConfigFromEnv(env: NodeJS.ProcessEnv): TablesConfig {
|
|
|
411
435
|
}
|
|
412
436
|
return out;
|
|
413
437
|
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Case-insensitive header lookup against an APIGW `event.headers` record.
|
|
441
|
+
*
|
|
442
|
+
* API Gateway WebSocket normalises header keys to lowercase, but HTTP
|
|
443
|
+
* proxies in front of it (ALB, CloudFront, custom domains) can preserve
|
|
444
|
+
* the original case. A case-sensitive lookup would silently miss the
|
|
445
|
+
* header under one of those routes — for the `Origin` CSWSH check that
|
|
446
|
+
* is a security hole, so this helper walks both casings. Returns `null`
|
|
447
|
+
* when absent so callers can pass it straight to handlers that
|
|
448
|
+
* distinguish "header absent" from "header empty".
|
|
449
|
+
*/
|
|
450
|
+
function readHeader(headers: Record<string, string> | undefined, name: string): string | null {
|
|
451
|
+
if (headers === undefined) return null;
|
|
452
|
+
const direct = headers[name];
|
|
453
|
+
if (typeof direct === 'string') return direct;
|
|
454
|
+
const lower = headers[name.toLowerCase()];
|
|
455
|
+
if (typeof lower === 'string') return lower;
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
@@ -44,7 +44,6 @@ import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanageme
|
|
|
44
44
|
import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
|
|
45
45
|
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
46
46
|
import {
|
|
47
|
-
type AccountStore,
|
|
48
47
|
type ChannelState,
|
|
49
48
|
type Clock,
|
|
50
49
|
type ConnectionState,
|
|
@@ -137,12 +136,11 @@ export interface NlbStreamParams {
|
|
|
137
136
|
motd: MotdProvider;
|
|
138
137
|
/** Chat-history persistence source (same as the wss `$default` path). */
|
|
139
138
|
messages?: MessageStore;
|
|
140
|
-
/** SASL account verification source. */
|
|
141
|
-
accounts?: AccountStore;
|
|
142
139
|
/**
|
|
143
140
|
* Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
|
|
144
|
-
* OperServ + read-marker
|
|
145
|
-
* does not lose services
|
|
141
|
+
* OperServ + read-marker + the unified SASL/PASS credential home).
|
|
142
|
+
* Flushed after each frame so a Lambda freeze does not lose services
|
|
143
|
+
* state.
|
|
146
144
|
*/
|
|
147
145
|
services?: DynamoServicesStore;
|
|
148
146
|
/** mTLS identity source for SASL EXTERNAL. */
|
|
@@ -264,7 +262,6 @@ export async function handleNlbStream(
|
|
|
264
262
|
// WsTextFrameTransport splits on \r\n (no-op for already-split lines).
|
|
265
263
|
transport: new WsTextFrameTransport(),
|
|
266
264
|
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
267
|
-
...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
|
|
268
265
|
...(params.services !== undefined ? { services: params.services } : {}),
|
|
269
266
|
...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
|
|
270
267
|
...(params.history !== undefined ? { history: params.history } : {}),
|
|
@@ -321,14 +318,22 @@ function readHeader(event: NlbStreamEvent, name: string): string | undefined {
|
|
|
321
318
|
return undefined;
|
|
322
319
|
}
|
|
323
320
|
|
|
324
|
-
/**
|
|
321
|
+
/**
|
|
322
|
+
* Loads a raw Connections row (includes transportBuffer if present).
|
|
323
|
+
*
|
|
324
|
+
* Strongly consistent: the NLB handler has no `$connect` event — it writes
|
|
325
|
+
* the row itself on the first chunk and reads it back on the very next
|
|
326
|
+
* invocation. An eventually-consistent Get can miss that just-written row
|
|
327
|
+
* under DynamoDB replication lag and drop the first frame(s) of a
|
|
328
|
+
* raw-IRC-over-`:6697` connection.
|
|
329
|
+
*/
|
|
325
330
|
async function loadRow(
|
|
326
331
|
dynamo: DynamoDBDocumentClient,
|
|
327
332
|
tableName: string,
|
|
328
333
|
connId: string,
|
|
329
334
|
): Promise<RawConnectionRow | null> {
|
|
330
335
|
const result = await dynamo.send(
|
|
331
|
-
new GetCommand({ TableName: tableName, Key: { connectionId: connId } }),
|
|
336
|
+
new GetCommand({ TableName: tableName, Key: { connectionId: connId }, ConsistentRead: true }),
|
|
332
337
|
);
|
|
333
338
|
if (result.Item === undefined) return null;
|
|
334
339
|
return result.Item as unknown as RawConnectionRow;
|
|
@@ -16,17 +16,15 @@ export { cleanupConnection } from './aws-runtime.js';
|
|
|
16
16
|
export { TABLE_KEYS, TABLE_NAMES, tablesConfigFromNames } from './tables.js';
|
|
17
17
|
export type { TableName, TablesConfig } from './tables.js';
|
|
18
18
|
export { createDynamoDocumentClient } from './dynamo.js';
|
|
19
|
-
export {
|
|
20
|
-
DynamoAccountStore,
|
|
21
|
-
type HashedAccountCredential,
|
|
22
|
-
hashAccountCredential,
|
|
23
|
-
loadDynamoAccountStore,
|
|
24
|
-
} from './dynamo-account-store.js';
|
|
25
|
-
export { bindAccountStore, putAccountCredential, resolveAccountStore } from './account-store.js';
|
|
26
19
|
export {
|
|
27
20
|
DynamoServicesStore,
|
|
28
21
|
loadDynamoServicesStore,
|
|
29
22
|
} from './dynamo-services-store.js';
|
|
23
|
+
export {
|
|
24
|
+
type HashedAccountCredential,
|
|
25
|
+
hashAccountCredential,
|
|
26
|
+
verifyHashedPassword,
|
|
27
|
+
} from '@serverless-ircd/irc-core';
|
|
30
28
|
export { AwsStats } from './stats.js';
|
|
31
29
|
export type { AwsStatsOptions } from './stats.js';
|
|
32
30
|
export {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket `Origin` policy for the AWS adapter's `$connect` Lambda —
|
|
3
|
+
* Cross-Site WebSocket Hijacking (CSWSH) defence.
|
|
4
|
+
*
|
|
5
|
+
* Browsers send an `Origin` header on every cross-origin WebSocket
|
|
6
|
+
* upgrade (RFC 6454); WebSocket upgrades do not otherwise enforce
|
|
7
|
+
* same-origin. Without a check, a malicious page can open a WebSocket
|
|
8
|
+
* to the IRC server from the victim's browser and drive the session
|
|
9
|
+
* with the victim's credentials. API Gateway WebSocket has no
|
|
10
|
+
* built-in `Origin` validation — the `$connect` Lambda receives
|
|
11
|
+
* `event.headers.Origin` and must enforce the allowlist itself.
|
|
12
|
+
*
|
|
13
|
+
* This module mirrors the canonical Cloudflare Worker policy
|
|
14
|
+
* (`apps/cf-worker/src/origin-allowlist.ts`) for the parsing
|
|
15
|
+
* semantics (comma-separated, trimmed, lowercased) and the
|
|
16
|
+
* "missing `Origin` = non-browser → allow" branch.
|
|
17
|
+
*
|
|
18
|
+
* **Deliberate divergence from the CF policy:** the AWS adapter
|
|
19
|
+
* supports ONLY the explicit-allowlist mode. The CF Worker's Mode 2
|
|
20
|
+
* (auto-derive the expected origin from the request's own `Host`
|
|
21
|
+
* header — same-origin enforcement) does not apply on AWS, because
|
|
22
|
+
* the SPA is served from a different CloudFront origin than the API
|
|
23
|
+
* Gateway WebSocket endpoint (`wss://{api-id}.execute-api.…`), so
|
|
24
|
+
* the request's own host is never the SPA's origin. The CSWSH
|
|
25
|
+
* defence is therefore opt-in: it only fires when `WEB_ORIGINS` is
|
|
26
|
+
* set. Unset → no Origin policy (existing AWS deployments without a
|
|
27
|
+
* web frontend are unchanged on upgrade).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Outcome of evaluating a `$connect` upgrade's `Origin` header.
|
|
32
|
+
*
|
|
33
|
+
* • `'allow'` — proceed: the origin is allowlisted, or the
|
|
34
|
+
* allowlist is empty (defence disabled), or the
|
|
35
|
+
* header is absent (non-browser client).
|
|
36
|
+
* • `'deny'` — reject (`403`): a browser sent an `Origin` that
|
|
37
|
+
* is not in the configured allowlist.
|
|
38
|
+
*/
|
|
39
|
+
export type ConnectOriginDecision = 'allow' | 'deny';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse the comma-separated `WEB_ORIGINS` env var into a normalised
|
|
43
|
+
* set of lowercased origins. Whitespace-only and empty entries are
|
|
44
|
+
* dropped so trailing commas / stray spaces do not pollute the set.
|
|
45
|
+
* An empty/undefined var yields an empty set, which
|
|
46
|
+
* {@link decideConnectOrigin} treats as "defence disabled" (NOT
|
|
47
|
+
* deny-all).
|
|
48
|
+
*
|
|
49
|
+
* Origins are lowercased wholesale for case-insensitive matching;
|
|
50
|
+
* the scheme and host are case-insensitive per RFC 3986, and
|
|
51
|
+
* lowercasing the whole string is the simplest normalisation that
|
|
52
|
+
* handles the real-world browser-emitted form
|
|
53
|
+
* (`https://Example.com` → `https://example.com`). Port numbers —
|
|
54
|
+
* the only case-sensitive trailing component per the URI spec — are
|
|
55
|
+
* always digits in practice, so lowercasing is a no-op on them.
|
|
56
|
+
*/
|
|
57
|
+
export function parseWebOrigins(raw: string | undefined): Set<string> {
|
|
58
|
+
return new Set(
|
|
59
|
+
(raw ?? '')
|
|
60
|
+
.split(',')
|
|
61
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
62
|
+
.filter((entry) => entry.length > 0),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Decide whether a `$connect` upgrade's `Origin` header is permitted.
|
|
68
|
+
*
|
|
69
|
+
* @param originHeader the raw `Origin` header value from
|
|
70
|
+
* `event.headers.Origin` (`null` / `undefined` /
|
|
71
|
+
* `''` when absent — non-browser clients).
|
|
72
|
+
* @param allowlist the parsed {@link parseWebOrigins} set. When
|
|
73
|
+
* empty, the defence is disabled and every
|
|
74
|
+
* upgrade is allowed regardless of its `Origin`.
|
|
75
|
+
* @returns `'allow'` to proceed with the upgrade, or `'deny'` to
|
|
76
|
+
* return `403` so API Gateway closes the connection.
|
|
77
|
+
*/
|
|
78
|
+
export function decideConnectOrigin(
|
|
79
|
+
originHeader: string | null | undefined,
|
|
80
|
+
allowlist: Set<string>,
|
|
81
|
+
): ConnectOriginDecision {
|
|
82
|
+
// Missing Origin = non-browser client (curl, WeeChat, tcp-ws-forwarder).
|
|
83
|
+
if (originHeader === null || originHeader === undefined || originHeader === '') {
|
|
84
|
+
return 'allow';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Defence disabled — WEB_ORIGINS unset/empty. Every Origin proceeds.
|
|
88
|
+
if (allowlist.size === 0) {
|
|
89
|
+
return 'allow';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const origin = originHeader.trim().toLowerCase();
|
|
93
|
+
return allowlist.has(origin) ? 'allow' : 'deny';
|
|
94
|
+
}
|
|
@@ -84,6 +84,20 @@ export interface MarshalledConnection {
|
|
|
84
84
|
away?: string;
|
|
85
85
|
saslMech?: string;
|
|
86
86
|
saslBuffer?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Optimistic-concurrency revision counter. Incremented on every
|
|
89
|
+
* `$default`/NLB state write (conditional `UpdateItem` with
|
|
90
|
+
* `ConditionExpression: revision = :expected`) so two concurrent
|
|
91
|
+
* Lambda invocations for the same connection cannot silently
|
|
92
|
+
* clobber each other's state (the registration deadlock where
|
|
93
|
+
* `CAP END`'s `capNegotiating=false` got overwritten by a later
|
|
94
|
+
* frame's stale read). Absent on rows written before this field
|
|
95
|
+
* shipped; the persist path treats a missing attribute as revision
|
|
96
|
+
* `0` via an `attribute_not_exists(#rev) OR #rev = :expected`
|
|
97
|
+
* condition, so the migration is seamless. Seeded to `0` by
|
|
98
|
+
* {@link marshalConnection} at `$connect`.
|
|
99
|
+
*/
|
|
100
|
+
revision?: number;
|
|
87
101
|
/**
|
|
88
102
|
* Negotiated IRCv3 WebSocket frame mode persisted at `$connect` time.
|
|
89
103
|
* Absent (`undefined`) on legacy connections (no subprotocol agreed) so
|
|
@@ -116,6 +130,7 @@ export function marshalConnection(state: ConnectionState, idleSince: number): Ma
|
|
|
116
130
|
connectedSince: state.connectedSince,
|
|
117
131
|
idleSince,
|
|
118
132
|
version: CONNECTION_VERSION,
|
|
133
|
+
revision: 0,
|
|
119
134
|
};
|
|
120
135
|
if (state.joinedChannels.size > 0) {
|
|
121
136
|
// Copy so later mutations to `state` don't alias the stored value.
|
|
@@ -14,13 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
/** Logical table names — also used as CDK construct ids. */
|
|
17
|
-
export type TableName =
|
|
18
|
-
| 'Connections'
|
|
19
|
-
| 'ChannelMeta'
|
|
20
|
-
| 'ChannelMembers'
|
|
21
|
-
| 'Nicks'
|
|
22
|
-
| 'Accounts'
|
|
23
|
-
| 'Services';
|
|
17
|
+
export type TableName = 'Connections' | 'ChannelMeta' | 'ChannelMembers' | 'Nicks' | 'Services';
|
|
24
18
|
|
|
25
19
|
/** Logical table names in stable order. */
|
|
26
20
|
export const TABLE_NAMES: readonly TableName[] = [
|
|
@@ -28,7 +22,6 @@ export const TABLE_NAMES: readonly TableName[] = [
|
|
|
28
22
|
'ChannelMeta',
|
|
29
23
|
'ChannelMembers',
|
|
30
24
|
'Nicks',
|
|
31
|
-
'Accounts',
|
|
32
25
|
'Services',
|
|
33
26
|
];
|
|
34
27
|
|
|
@@ -47,7 +40,6 @@ export const TABLE_KEYS = {
|
|
|
47
40
|
ChannelMeta: { pk: 'channelName' },
|
|
48
41
|
ChannelMembers: { pk: 'channelName', sk: 'connectionId' },
|
|
49
42
|
Nicks: { pk: 'nickLower' },
|
|
50
|
-
Accounts: { pk: 'account' },
|
|
51
43
|
} as const;
|
|
52
44
|
|
|
53
45
|
/**
|
|
@@ -58,14 +50,13 @@ export const TABLE_KEYS = {
|
|
|
58
50
|
* `Services` is optional: the services-persistence table is provisioned by
|
|
59
51
|
* {@link TABLE_DEFS} but the runtime reads `SERVICES_TABLE` directly and
|
|
60
52
|
* tolerates an unset var (services unbound). A `TablesConfig` built from
|
|
61
|
-
* the required-
|
|
53
|
+
* the required-four env vars still type-checks.
|
|
62
54
|
*/
|
|
63
55
|
export type TablesConfig = {
|
|
64
56
|
Connections: string;
|
|
65
57
|
ChannelMeta: string;
|
|
66
58
|
ChannelMembers: string;
|
|
67
59
|
Nicks: string;
|
|
68
|
-
Accounts: string;
|
|
69
60
|
Services?: string;
|
|
70
61
|
};
|
|
71
62
|
|
|
@@ -75,7 +66,6 @@ export function tablesConfigFromNames(names: {
|
|
|
75
66
|
ChannelMeta: string;
|
|
76
67
|
ChannelMembers: string;
|
|
77
68
|
Nicks: string;
|
|
78
|
-
Accounts: string;
|
|
79
69
|
Services?: string;
|
|
80
70
|
}): TablesConfig {
|
|
81
71
|
const out: TablesConfig = { ...names };
|
|
@@ -97,7 +97,6 @@ export class AwsHarness implements IrcHarness {
|
|
|
97
97
|
ChannelMeta: `${this.prefix}ChannelMeta`,
|
|
98
98
|
ChannelMembers: `${this.prefix}ChannelMembers`,
|
|
99
99
|
Nicks: `${this.prefix}Nicks`,
|
|
100
|
-
Accounts: `${this.prefix}Accounts`,
|
|
101
100
|
};
|
|
102
101
|
this.client = createDynamoDocumentClient({ endpoint });
|
|
103
102
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
10
|
+
import { GetCommand } from '@aws-sdk/lib-dynamodb';
|
|
10
11
|
import {
|
|
11
12
|
type ChannelDelta,
|
|
12
13
|
type Clock,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
type ServerConfig,
|
|
16
17
|
createConnection,
|
|
17
18
|
} from '@serverless-ircd/irc-core';
|
|
18
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
19
20
|
import { AwsRuntime } from '../src/aws-runtime.js';
|
|
20
21
|
import type { AwsRuntimeHandlers, PostToConnection } from '../src/aws-runtime.js';
|
|
21
22
|
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
@@ -235,6 +236,27 @@ describe.skipIf(!available)('AwsRuntime', () => {
|
|
|
235
236
|
const got = await rt.getConnection('self');
|
|
236
237
|
expect(got).toBe(state);
|
|
237
238
|
});
|
|
239
|
+
|
|
240
|
+
it('getConnectionInfo reads a peer Connections row strongly consistently', async () => {
|
|
241
|
+
// Peer lookups (WHOIS, roster hydration via readConnectionRow) read
|
|
242
|
+
// another connection's row. Strong consistency avoids presenting a
|
|
243
|
+
// stale peer snapshot under DynamoDB replication lag.
|
|
244
|
+
const rt = makeRuntime('self', noopHandlers());
|
|
245
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
246
|
+
state.nick = 'Alice';
|
|
247
|
+
await rt.persistConnectionState(state);
|
|
248
|
+
const sendSpy = vi.spyOn(fx.client, 'send');
|
|
249
|
+
await rt.getConnectionInfo('c1');
|
|
250
|
+
const conGets = sendSpy.mock.calls
|
|
251
|
+
.map((call) => call[0] as unknown)
|
|
252
|
+
.filter(
|
|
253
|
+
(cmd): cmd is GetCommand =>
|
|
254
|
+
cmd instanceof GetCommand && (cmd.input.TableName?.endsWith('Connections') ?? false),
|
|
255
|
+
);
|
|
256
|
+
expect(conGets.length).toBeGreaterThan(0);
|
|
257
|
+
expect((conGets[0]?.input as { ConsistentRead?: boolean }).ConsistentRead).toBe(true);
|
|
258
|
+
sendSpy.mockRestore();
|
|
259
|
+
});
|
|
238
260
|
});
|
|
239
261
|
|
|
240
262
|
// -------------------------------------------------------------------------
|
|
@@ -155,6 +155,72 @@ describe('buildLambdaConfigInput', () => {
|
|
|
155
155
|
const out = buildLambdaConfigInput({ SASL_ACCOUNTS: ' alice:secret ' });
|
|
156
156
|
expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
|
|
157
157
|
});
|
|
158
|
+
|
|
159
|
+
it('does NOT thread WEB_ORIGINS into the ServerConfig input', () => {
|
|
160
|
+
// WEB_ORIGINS is an adapter-level operational concern (consumed by
|
|
161
|
+
// `$connect`'s CSWSH defence), NOT a server-config knob. Routing
|
|
162
|
+
// it through the shared Zod schema would either be silently
|
|
163
|
+
// stripped (the schema is non-strict) or pollute the canonical
|
|
164
|
+
// `ServerConfig` type with an AWS-only field. The buildLambdaConfigInput
|
|
165
|
+
// function therefore accepts the typed env field but never emits
|
|
166
|
+
// it — `buildDepsFromEnv` parses it directly via `parseWebOrigins`.
|
|
167
|
+
const out = buildLambdaConfigInput({ WEB_ORIGINS: 'https://app.example.com' });
|
|
168
|
+
expect('webOrigins' in out).toBe(false);
|
|
169
|
+
expect('WEB_ORIGINS' in out).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('types WEB_ORIGINS as an optional string field on LambdaConfigEnv', () => {
|
|
173
|
+
// Compile-time assertion: the env interface accepts the field.
|
|
174
|
+
const env: LambdaConfigEnv = { WEB_ORIGINS: 'https://a.example.com,https://b.example.com' };
|
|
175
|
+
expect(env.WEB_ORIGINS).toBe('https://a.example.com,https://b.example.com');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('buildDepsFromEnv — WEB_ORIGINS wiring', () => {
|
|
180
|
+
// Build a minimal env shape that satisfies `tablesConfigFromEnv` and the
|
|
181
|
+
// shared config schema, pointing DynamoDB at an unreachable endpoint so
|
|
182
|
+
// `resolveAccountStore` / `loadDynamoServicesStore` fall through to
|
|
183
|
+
// defaults rather than scanning a live table. We are only asserting the
|
|
184
|
+
// env→deps wiring for `WEB_ORIGINS`.
|
|
185
|
+
function makeBaseEnv(): NodeJS.ProcessEnv {
|
|
186
|
+
return {
|
|
187
|
+
SERVER_NAME: 'irc.test',
|
|
188
|
+
NETWORK_NAME: 'TestNet',
|
|
189
|
+
CONNECTIONS_TABLE: 'Connections',
|
|
190
|
+
CHANNELMETA_TABLE: 'ChannelMeta',
|
|
191
|
+
CHANNELMEMBERS_TABLE: 'ChannelMembers',
|
|
192
|
+
NICKS_TABLE: 'Nicks',
|
|
193
|
+
ACCOUNTS_TABLE: 'Accounts',
|
|
194
|
+
DYNAMO_ENDPOINT: 'http://127.0.0.1:1',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
it('parses WEB_ORIGINS into a Set on the returned deps', async () => {
|
|
199
|
+
// The env→deps wiring is the production path: `WEB_ORIGINS` is read
|
|
200
|
+
// once at cold start, parsed via `parseWebOrigins`, and stored on
|
|
201
|
+
// `HandlerDeps.webOrigins` so every `$connect` invocation reuses the
|
|
202
|
+
// same set.
|
|
203
|
+
const { buildDepsFromEnv } = await import('../src/handlers/index.js');
|
|
204
|
+
const deps = await buildDepsFromEnv({
|
|
205
|
+
...makeBaseEnv(),
|
|
206
|
+
WEB_ORIGINS: 'https://app.example.com, https://staging.app.example.com',
|
|
207
|
+
});
|
|
208
|
+
expect(deps.webOrigins).toEqual(
|
|
209
|
+
new Set(['https://app.example.com', 'https://staging.app.example.com']),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('omits webOrigins from deps when WEB_ORIGINS is unset', async () => {
|
|
214
|
+
const { buildDepsFromEnv } = await import('../src/handlers/index.js');
|
|
215
|
+
const deps = await buildDepsFromEnv(makeBaseEnv());
|
|
216
|
+
expect(deps.webOrigins).toBeUndefined();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('omits webOrigins from deps when WEB_ORIGINS is an empty string', async () => {
|
|
220
|
+
const { buildDepsFromEnv } = await import('../src/handlers/index.js');
|
|
221
|
+
const deps = await buildDepsFromEnv({ ...makeBaseEnv(), WEB_ORIGINS: '' });
|
|
222
|
+
expect(deps.webOrigins).toBeUndefined();
|
|
223
|
+
});
|
|
158
224
|
});
|
|
159
225
|
|
|
160
226
|
describe('loadServerConfigFromLambdaEnv', () => {
|