serverless-ircd 0.9.0 → 0.11.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 +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -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 +398 -67
- 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 +714 -105
- 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/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +18 -14
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +95 -77
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- 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 +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- 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 +0 -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 +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/account-auth.ts +16 -19
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +161 -11
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +9 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +529 -190
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- 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/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -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 +123 -22
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +563 -54
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- 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 +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- 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/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
|
@@ -18,22 +18,34 @@
|
|
|
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
|
-
* •
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* • SASL / NickServ credentials live in the `Services` table under the
|
|
22
|
+
* `NICK:<fold>` partition. The standalone `Accounts` table was dropped
|
|
23
|
+
* — `ServicesStore` (`registerNick` / `verifyNick` / `verifyCertFP`)
|
|
24
|
+
* is the single credential home for SASL PLAIN, SASL EXTERNAL,
|
|
25
|
+
* PASS-auth, and NickServ IDENTIFY.
|
|
26
26
|
* • `Connections.idleSince` is the DynamoDB TTL attribute; idle connections
|
|
27
27
|
* are reaped automatically in addition to the explicit sweeper.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import { AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
|
|
31
|
-
import type { TableProps } from 'aws-cdk-lib/aws-dynamodb';
|
|
32
|
-
import type
|
|
30
|
+
import { AttributeType, BillingMode, ProjectionType } from 'aws-cdk-lib/aws-dynamodb';
|
|
31
|
+
import type { GlobalSecondaryIndexProps, TableProps } from 'aws-cdk-lib/aws-dynamodb';
|
|
32
|
+
import { SOURCE_IP_INDEX_NAME, type TableName } from './tables.js';
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
35
|
+
* A table definition: the CDK `TableProps` plus an optional side-channel
|
|
36
|
+
* for secondary indexes. Current aws-cdk-lib moved GSI declaration out
|
|
37
|
+
* of `TableProps` (indexes are added via `Table.addGlobalSecondaryIndex`
|
|
38
|
+
* after construction), so the index list rides alongside the construct
|
|
39
|
+
* props here — the stack pops it before constructing the `Table` and the
|
|
40
|
+
* DynamoDB Local fixtures read it when issuing raw `CreateTableCommand`s.
|
|
41
|
+
*/
|
|
42
|
+
export interface TableDef extends TableProps {
|
|
43
|
+
globalSecondaryIndexes?: GlobalSecondaryIndexProps[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* All four tables keyed by their logical construct id. The id doubles as
|
|
48
|
+
* the CloudFormation logical id; the physical `TableName` is set on each entry.
|
|
37
49
|
*
|
|
38
50
|
* NOTE: the deployed physical name is **environment-prefixed** by
|
|
39
51
|
* `IrcAwsStack` (`<Env><LogicalId>`, e.g. `StagingConnections`) — the
|
|
@@ -42,12 +54,28 @@ import type { TableName } from './tables.js';
|
|
|
42
54
|
* every physical name (to isolate parallel runs) consume the
|
|
43
55
|
* `TABLE_DEFS` entries' key/attribute names only.
|
|
44
56
|
*/
|
|
45
|
-
export const TABLE_DEFS: Record<TableName,
|
|
57
|
+
export const TABLE_DEFS: Record<TableName, TableDef> = {
|
|
46
58
|
Connections: {
|
|
47
59
|
tableName: 'Connections',
|
|
48
60
|
partitionKey: { name: 'connectionId', type: AttributeType.STRING },
|
|
49
61
|
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
50
62
|
timeToLiveAttribute: 'idleSince',
|
|
63
|
+
// Per-IP admission index: `$connect` counts an IP's live connections
|
|
64
|
+
// (maxConnectionsPerIp) and its in-window establishments
|
|
65
|
+
// (perIpConnectionRate) against this GSI; the NLB new-flow gate
|
|
66
|
+
// counts the window. Rows surface in the index only when both key
|
|
67
|
+
// attributes are present (handlers stamp `sourceIp` at
|
|
68
|
+
// connect/flow-establishment; legacy rows are invisible — the caps
|
|
69
|
+
// apply from this change forward). TTL-reaped rows leave the index
|
|
70
|
+
// with them, so the index self-cleans.
|
|
71
|
+
globalSecondaryIndexes: [
|
|
72
|
+
{
|
|
73
|
+
indexName: SOURCE_IP_INDEX_NAME,
|
|
74
|
+
partitionKey: { name: 'sourceIp', type: AttributeType.STRING },
|
|
75
|
+
sortKey: { name: 'connectedSince', type: AttributeType.NUMBER },
|
|
76
|
+
projectionType: ProjectionType.ALL,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
51
79
|
},
|
|
52
80
|
ChannelMeta: {
|
|
53
81
|
tableName: 'ChannelMeta',
|
|
@@ -65,11 +93,6 @@ export const TABLE_DEFS: Record<TableName, TableProps> = {
|
|
|
65
93
|
partitionKey: { name: 'nickLower', type: AttributeType.STRING },
|
|
66
94
|
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
67
95
|
},
|
|
68
|
-
Accounts: {
|
|
69
|
-
tableName: 'Accounts',
|
|
70
|
-
partitionKey: { name: 'account', type: AttributeType.STRING },
|
|
71
|
-
billingMode: BillingMode.PAY_PER_REQUEST,
|
|
72
|
-
},
|
|
73
96
|
Services: {
|
|
74
97
|
tableName: 'Services',
|
|
75
98
|
partitionKey: { name: 'pk', type: AttributeType.STRING },
|
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
|
|
12
12
|
import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Documented schema defaults for `perIpConnectionRate`. Duplicated here
|
|
16
|
+
* (the schema inlines its `.default(...)`) so an operator setting only
|
|
17
|
+
* one half of the knob via env gets the documented default for the other
|
|
18
|
+
* half rather than a parse error.
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_PER_IP_RATE_MAX = 5;
|
|
21
|
+
const DEFAULT_PER_IP_RATE_WINDOW_MS = 60_000;
|
|
22
|
+
|
|
14
23
|
/**
|
|
15
24
|
* The subset of `process.env` this loader reads. Optional members are
|
|
16
25
|
* omitted from the input object when undefined so the schema's defaults
|
|
@@ -35,6 +44,20 @@ export interface LambdaConfigEnv {
|
|
|
35
44
|
CHANNEL_PREFIXES?: string;
|
|
36
45
|
OPER_USER?: string;
|
|
37
46
|
OPER_PASSWORD?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Scrypt salt (base64) for the hashed oper credential form. When set
|
|
49
|
+
* alongside `OPER_USER` + `OPER_HASH`, the loader builds a hashed
|
|
50
|
+
* `{user, salt, hash}` cred instead of the legacy plaintext
|
|
51
|
+
* `{user, password}` shape. The hashed form is preferred when both
|
|
52
|
+
* are present so deployments can rotate without removing the legacy
|
|
53
|
+
* vars in the same step. Generate via `tools/hash-oper-cred.ts`.
|
|
54
|
+
*/
|
|
55
|
+
OPER_SALT?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Scrypt hash (base64) for the hashed oper credential form. See
|
|
58
|
+
* `OPER_SALT` for the full set of vars.
|
|
59
|
+
*/
|
|
60
|
+
OPER_HASH?: string;
|
|
38
61
|
/**
|
|
39
62
|
* Server-password gate. Treat as an AWS secret (Secrets Manager or
|
|
40
63
|
* SSM `SecureString`); the loader threads it through to
|
|
@@ -53,10 +76,62 @@ export interface LambdaConfigEnv {
|
|
|
53
76
|
/**
|
|
54
77
|
* SASL PLAIN accounts. Newline-delimited `username:password` pairs
|
|
55
78
|
* (same convention as the CF adapter's `SASL_ACCOUNTS`). Parsed into
|
|
56
|
-
* the `saslAccounts` config field so
|
|
57
|
-
*
|
|
79
|
+
* the `saslAccounts` config field so the boot path can seed the
|
|
80
|
+
* `ServicesStore` via `registerNick`.
|
|
58
81
|
*/
|
|
59
82
|
SASL_ACCOUNTS?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Comma-separated allowlist of web origins permitted to open
|
|
85
|
+
* WebSocket upgrades (Cross-Site WebSocket Hijacking defence —
|
|
86
|
+
* browsers send `Origin` on every cross-origin upgrade; WebSocket
|
|
87
|
+
* upgrades do not otherwise enforce same-origin, and API Gateway
|
|
88
|
+
* WebSocket has no built-in Origin check). Mirrors the CF Worker's
|
|
89
|
+
* `WEB_ORIGINS` env.
|
|
90
|
+
*
|
|
91
|
+
* Only read by the `$connect` handler at
|
|
92
|
+
* {@link buildDepsFromEnv} — it is NOT threaded through the shared
|
|
93
|
+
* `ServerConfig` schema (it is an adapter-level operational concern,
|
|
94
|
+
* not a server-config knob). When unset/empty, the defence is
|
|
95
|
+
* disabled and every Origin proceeds (the default — opt-in).
|
|
96
|
+
* Non-browser clients (curl, WeeChat, the `tcp-ws-forwarder`) never
|
|
97
|
+
* send `Origin` and always pass through regardless.
|
|
98
|
+
*/
|
|
99
|
+
WEB_ORIGINS?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Per-connection inbound frame window ceiling enforced by the
|
|
102
|
+
* `$default` handler BEFORE the actor / DynamoDB write. Parsed into
|
|
103
|
+
* `ServerConfig.adapter.maxFramesPerWindow`.
|
|
104
|
+
*/
|
|
105
|
+
MAX_FRAMES_PER_WINDOW?: string;
|
|
106
|
+
/**
|
|
107
|
+
* Sliding-window length (seconds) for the inbound frame limit; frames
|
|
108
|
+
* older than the window stop counting. Parsed into
|
|
109
|
+
* `ServerConfig.adapter.frameWindowSeconds`.
|
|
110
|
+
*/
|
|
111
|
+
FRAME_WINDOW_SECONDS?: string;
|
|
112
|
+
/**
|
|
113
|
+
* Per-IP simultaneous-connection cap (`maxConnectionsPerIp`). Enforced
|
|
114
|
+
* by the `$connect` admission gate against the `sourceIp` GSI; mirrors
|
|
115
|
+
* the CF loader's var of the same name.
|
|
116
|
+
*/
|
|
117
|
+
MAX_CONNECTIONS_PER_IP?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Per-IP connection-rate budget (`perIpConnectionRate.max`) — how many
|
|
120
|
+
* new connections one source IP may open per sliding window. Enforced
|
|
121
|
+
* by `$connect` and the NLB new-flow gate. The unset half of the knob
|
|
122
|
+
* falls back to the schema default.
|
|
123
|
+
*/
|
|
124
|
+
PER_IP_CONNECTION_RATE_MAX?: string;
|
|
125
|
+
/** Sliding window length in ms (`perIpConnectionRate.windowMs`). */
|
|
126
|
+
PER_IP_CONNECTION_RATE_WINDOW_MS?: string;
|
|
127
|
+
/**
|
|
128
|
+
* Operator opt-in for certificate-backed SASL EXTERNAL. 'true'/'1'
|
|
129
|
+
* (case-insensitive) map to `sasl.externalEnabled: true`; any other
|
|
130
|
+
* value (and unset) leaves the mechanism off. EXTERNAL additionally
|
|
131
|
+
* requires edge mTLS (APIGW client certs on the custom domain) and a
|
|
132
|
+
* secure (TLS) connection — the flag alone advertises nothing.
|
|
133
|
+
*/
|
|
134
|
+
EXTERNAL_ENABLED?: string;
|
|
60
135
|
}
|
|
61
136
|
|
|
62
137
|
/**
|
|
@@ -88,12 +163,36 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
|
|
|
88
163
|
input.channelPrefixes = env.CHANNEL_PREFIXES;
|
|
89
164
|
}
|
|
90
165
|
if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
166
|
+
// Hashed form (OPER_USER + OPER_SALT + OPER_HASH) is preferred when
|
|
167
|
+
// both salt + hash are supplied — even if OPER_PASSWORD is also set,
|
|
168
|
+
// so a deployment can rotate to the hashed form without dropping the
|
|
169
|
+
// legacy var in the same release. The legacy plaintext
|
|
170
|
+
// `{user, password}` shape is retained for a single deprecation-
|
|
171
|
+
// cycle window.
|
|
172
|
+
const hasHashed = env.OPER_SALT !== undefined && env.OPER_HASH !== undefined;
|
|
173
|
+
if (hasHashed) {
|
|
174
|
+
input.operCreds = [
|
|
175
|
+
{
|
|
176
|
+
user: env.OPER_USER ?? '',
|
|
177
|
+
salt: env.OPER_SALT ?? '',
|
|
178
|
+
hash: env.OPER_HASH ?? '',
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
} else {
|
|
182
|
+
// Build the legacy cred object from whatever was supplied; the
|
|
183
|
+
// schema surfaces a `user`/`password` error if only one half was
|
|
184
|
+
// provided. If only OPER_SALT/OPER_HASH (partial) are set, the
|
|
185
|
+
// schema will surface the missing hashed-form field via the
|
|
186
|
+
// superRefine error path.
|
|
187
|
+
input.operCreds = [
|
|
188
|
+
{
|
|
189
|
+
user: env.OPER_USER ?? '',
|
|
190
|
+
password: env.OPER_PASSWORD ?? '',
|
|
191
|
+
...(env.OPER_SALT !== undefined ? { salt: env.OPER_SALT } : {}),
|
|
192
|
+
...(env.OPER_HASH !== undefined ? { hash: env.OPER_HASH } : {}),
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
}
|
|
97
196
|
}
|
|
98
197
|
if (env.SERVER_PASSWORD !== undefined) {
|
|
99
198
|
input.serverPassword = env.SERVER_PASSWORD;
|
|
@@ -122,9 +221,55 @@ export function buildLambdaConfigInput(env: LambdaConfigEnv): Record<string, unk
|
|
|
122
221
|
if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
|
|
123
222
|
input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
|
|
124
223
|
}
|
|
224
|
+
if (env.MAX_FRAMES_PER_WINDOW !== undefined || env.FRAME_WINDOW_SECONDS !== undefined) {
|
|
225
|
+
// Partial sections are fine: the per-field schema defaults fill the
|
|
226
|
+
// knob(s) the env did not supply.
|
|
227
|
+
input.adapter = {
|
|
228
|
+
...(env.MAX_FRAMES_PER_WINDOW !== undefined
|
|
229
|
+
? { maxFramesPerWindow: Number.parseInt(env.MAX_FRAMES_PER_WINDOW, 10) }
|
|
230
|
+
: {}),
|
|
231
|
+
...(env.FRAME_WINDOW_SECONDS !== undefined
|
|
232
|
+
? { frameWindowSeconds: Number.parseInt(env.FRAME_WINDOW_SECONDS, 10) }
|
|
233
|
+
: {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (env.MAX_CONNECTIONS_PER_IP !== undefined) {
|
|
237
|
+
input.maxConnectionsPerIp = Number.parseInt(env.MAX_CONNECTIONS_PER_IP, 10);
|
|
238
|
+
}
|
|
239
|
+
if (
|
|
240
|
+
env.PER_IP_CONNECTION_RATE_MAX !== undefined ||
|
|
241
|
+
env.PER_IP_CONNECTION_RATE_WINDOW_MS !== undefined
|
|
242
|
+
) {
|
|
243
|
+
// The schema requires both halves; the unset one falls back to the
|
|
244
|
+
// documented schema defaults (mirrors the CF loader's vars of the
|
|
245
|
+
// same names so operators tune both platforms identically).
|
|
246
|
+
input.perIpConnectionRate = {
|
|
247
|
+
max:
|
|
248
|
+
env.PER_IP_CONNECTION_RATE_MAX !== undefined
|
|
249
|
+
? Number.parseInt(env.PER_IP_CONNECTION_RATE_MAX, 10)
|
|
250
|
+
: DEFAULT_PER_IP_RATE_MAX,
|
|
251
|
+
windowMs:
|
|
252
|
+
env.PER_IP_CONNECTION_RATE_WINDOW_MS !== undefined
|
|
253
|
+
? Number.parseInt(env.PER_IP_CONNECTION_RATE_WINDOW_MS, 10)
|
|
254
|
+
: DEFAULT_PER_IP_RATE_WINDOW_MS,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (env.EXTERNAL_ENABLED !== undefined) {
|
|
258
|
+
input.sasl = { externalEnabled: parseEnabledFlag(env.EXTERNAL_ENABLED) };
|
|
259
|
+
}
|
|
125
260
|
return input;
|
|
126
261
|
}
|
|
127
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Parses a boolean feature-flag env var: 'true' / '1' (case-insensitive,
|
|
265
|
+
* whitespace-trimmed) are truthy; every other value is false. Flags
|
|
266
|
+
* default off, so a typo must never silently enable the feature.
|
|
267
|
+
*/
|
|
268
|
+
function parseEnabledFlag(raw: string): boolean {
|
|
269
|
+
const normalized = raw.trim().toLowerCase();
|
|
270
|
+
return normalized === 'true' || normalized === '1';
|
|
271
|
+
}
|
|
272
|
+
|
|
128
273
|
/**
|
|
129
274
|
* Parses the `CREATED_AT` env var into the schema's `createdAt` field.
|
|
130
275
|
*
|
|
@@ -198,6 +198,9 @@ export class DynamoServicesStore extends PersistentServicesStore {
|
|
|
198
198
|
email: row.email,
|
|
199
199
|
createdAt: row.createdAt,
|
|
200
200
|
enforce: row.enforce,
|
|
201
|
+
certSubjects: row.certSubjects ?? [],
|
|
202
|
+
identifyFailures: row.identifyFailures ?? [],
|
|
203
|
+
pendingIdentifyFailureNotice: row.pendingIdentifyFailureNotice ?? 0,
|
|
201
204
|
algorithm: row.credential.algorithm,
|
|
202
205
|
salt: row.credential.salt,
|
|
203
206
|
hash: row.credential.hash,
|
|
@@ -536,12 +539,28 @@ function coerceNick(item: Record<string, unknown>): ServicesNickRow | undefined
|
|
|
536
539
|
) {
|
|
537
540
|
return undefined;
|
|
538
541
|
}
|
|
542
|
+
// certSubjects is additive — older rows written before the column existed
|
|
543
|
+
// load as []. Accepts a JSON-array or a string[]; anything else → [].
|
|
544
|
+
const certSubjects = Array.isArray(item.certSubjects)
|
|
545
|
+
? item.certSubjects.filter((s): s is string => typeof s === 'string')
|
|
546
|
+
: [];
|
|
547
|
+
// The identify-throttle fields are additive in the same way: absent (a
|
|
548
|
+
// pre-throttle item) or ill-typed attributes collapse to the neutral
|
|
549
|
+
// unfrozen defaults.
|
|
550
|
+
const identifyFailures = Array.isArray(item.identifyFailures)
|
|
551
|
+
? item.identifyFailures.filter((t): t is number => typeof t === 'number')
|
|
552
|
+
: [];
|
|
553
|
+
const pendingIdentifyFailureNotice =
|
|
554
|
+
typeof item.pendingIdentifyFailureNotice === 'number' ? item.pendingIdentifyFailureNotice : 0;
|
|
539
555
|
return {
|
|
540
556
|
nick: item.nick,
|
|
541
557
|
account: item.account,
|
|
542
558
|
email: item.email,
|
|
543
559
|
createdAt: item.createdAt,
|
|
544
560
|
enforce: item.enforce as NickEnforcePolicy,
|
|
561
|
+
certSubjects,
|
|
562
|
+
identifyFailures,
|
|
563
|
+
pendingIdentifyFailureNotice,
|
|
545
564
|
credential: {
|
|
546
565
|
account: item.account,
|
|
547
566
|
algorithm: 'scrypt',
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
} from '@serverless-ircd/irc-core';
|
|
25
25
|
import { type AdmissionOutcome, decideConnectAdmission } from '../admission.js';
|
|
26
26
|
import { decrementConnectionCount, incrementConnectionCount } from '../connection-counter.js';
|
|
27
|
+
import { countConnectionsForIp } from '../ip-admission.js';
|
|
28
|
+
import { decideConnectOrigin } from '../origin-allowlist.js';
|
|
27
29
|
import { marshalConnection } from '../serialize.js';
|
|
28
30
|
import type { TablesConfig } from '../tables.js';
|
|
29
31
|
import { createInitialConnectionState } from './state.js';
|
|
@@ -44,6 +46,32 @@ export interface ConnectParams {
|
|
|
44
46
|
* none match or the header is absent).
|
|
45
47
|
*/
|
|
46
48
|
secWebSocketProtocol?: string | null;
|
|
49
|
+
/**
|
|
50
|
+
* Raw `Origin` header from `event.headers.Origin` (case-insensitive).
|
|
51
|
+
* When {@link ConnectParams.webOrigins} is non-empty and this is a
|
|
52
|
+
* browser-sent origin that is not on the allowlist, the upgrade is
|
|
53
|
+
* rejected with `403` (Cross-Site WebSocket Hijacking defence —
|
|
54
|
+
* API Gateway has no built-in Origin check). `null` / `undefined`
|
|
55
|
+
* (non-browser clients: curl, WeeChat, the `tcp-ws-forwarder`)
|
|
56
|
+
* always proceed; the defence is opt-in (only fires when the
|
|
57
|
+
* allowlist is non-empty).
|
|
58
|
+
*/
|
|
59
|
+
originHeader?: string | null | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* Parsed `WEB_ORIGINS` allowlist. Empty set (or `undefined`) disables
|
|
62
|
+
* the Origin check (the default — defence is opt-in).
|
|
63
|
+
*/
|
|
64
|
+
webOrigins?: Set<string> | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* Source IP of the connecting client (`event.requestContext.identity
|
|
67
|
+
* .sourceIp` on APIGW). When present, the per-IP admission gates run:
|
|
68
|
+
* a paginated Query against the `sourceIp` GSI supplies the IP's live
|
|
69
|
+
* count (`maxConnectionsPerIp`) and its in-window count
|
|
70
|
+
* (`perIpConnectionRate`), and the previously-dormant per-IP branch
|
|
71
|
+
* of {@link decideConnectAdmission} goes live. Absent (older event
|
|
72
|
+
* shapes) the per-IP checks are skipped entirely.
|
|
73
|
+
*/
|
|
74
|
+
sourceIp?: string | null | undefined;
|
|
47
75
|
}
|
|
48
76
|
|
|
49
77
|
/**
|
|
@@ -83,11 +111,49 @@ export async function handleConnect(params: ConnectParams): Promise<ConnectOutco
|
|
|
83
111
|
const now = params.now ?? Date.now();
|
|
84
112
|
const maxClients = params.serverConfig.maxClients;
|
|
85
113
|
|
|
114
|
+
// CSWSH defence — Origin allowlist. Runs before the admission counter
|
|
115
|
+
// so a hostile-origin flood cannot consume `maxClients` slots. Only
|
|
116
|
+
// fires when `WEB_ORIGINS` is set (defence is opt-in); a missing
|
|
117
|
+
// `Origin` header (non-browser clients) always proceeds.
|
|
118
|
+
const originDecision = decideConnectOrigin(params.originHeader, params.webOrigins ?? new Set());
|
|
119
|
+
if (originDecision === 'deny') {
|
|
120
|
+
return { admitted: false, statusCode: 403, reason: 'origin not allowed' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Per-IP admission counts (GSI walk). Runs before the global counter
|
|
124
|
+
// reservation so an over-budget IP never churns the meta-row counter.
|
|
125
|
+
// An empty/absent source IP skips the per-IP gates (branch stays
|
|
126
|
+
// dormant, matching pre-GSI event shapes).
|
|
127
|
+
let perIp: number | undefined;
|
|
128
|
+
let recentPerIp: number | undefined;
|
|
129
|
+
if (typeof params.sourceIp === 'string' && params.sourceIp.length > 0) {
|
|
130
|
+
const ipCounts = await countConnectionsForIp(
|
|
131
|
+
params.dynamo,
|
|
132
|
+
params.tables.Connections,
|
|
133
|
+
params.sourceIp,
|
|
134
|
+
now,
|
|
135
|
+
params.serverConfig.perIpConnectionRate.windowMs,
|
|
136
|
+
);
|
|
137
|
+
perIp = ipCounts.total;
|
|
138
|
+
recentPerIp = ipCounts.recent;
|
|
139
|
+
}
|
|
140
|
+
|
|
86
141
|
// Reserve a slot atomically. The returned value includes this reservation,
|
|
87
142
|
// so the pre-existing count (`total`, as expected by the policy) is one
|
|
88
143
|
// less than the post-increment value.
|
|
89
144
|
const reservedCount = await incrementConnectionCount(params.dynamo, params.tables.Connections);
|
|
90
|
-
const outcome = decideConnectAdmission(
|
|
145
|
+
const outcome = decideConnectAdmission(
|
|
146
|
+
{
|
|
147
|
+
total: reservedCount - 1,
|
|
148
|
+
...(perIp !== undefined ? { perIp } : {}),
|
|
149
|
+
...(recentPerIp !== undefined ? { recentPerIp } : {}),
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
maxClients,
|
|
153
|
+
maxConnectionsPerIp: params.serverConfig.maxConnectionsPerIp,
|
|
154
|
+
perIpRate: params.serverConfig.perIpConnectionRate,
|
|
155
|
+
},
|
|
156
|
+
);
|
|
91
157
|
if (!outcome.admitted) {
|
|
92
158
|
// Over capacity — release the reservation so the counter does not drift
|
|
93
159
|
// upward on every rejected connect.
|
|
@@ -118,6 +184,12 @@ export async function handleConnect(params: ConnectParams): Promise<ConnectOutco
|
|
|
118
184
|
if (mode !== 'legacy') {
|
|
119
185
|
row.wsMode = mode;
|
|
120
186
|
}
|
|
187
|
+
// Persist the source IP so the `sourceIp` GSI carries this connection
|
|
188
|
+
// for future per-IP admission walks (rows without it are invisible to
|
|
189
|
+
// the index — see ip-admission.ts).
|
|
190
|
+
if (typeof params.sourceIp === 'string' && params.sourceIp.length > 0) {
|
|
191
|
+
row.sourceIp = params.sourceIp;
|
|
192
|
+
}
|
|
121
193
|
try {
|
|
122
194
|
await params.dynamo.send(
|
|
123
195
|
new PutCommand({
|