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
|
@@ -12,10 +12,10 @@ 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,
|
|
16
15
|
type ChannelState,
|
|
17
16
|
type Clock,
|
|
18
17
|
type ConnectionState,
|
|
18
|
+
FRAME_FLOOD_ERROR_LINE,
|
|
19
19
|
type IdFactory,
|
|
20
20
|
type MessageStore,
|
|
21
21
|
type MotdProvider,
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
SystemClock,
|
|
27
27
|
UuidIdFactory,
|
|
28
28
|
type WsFrameMode,
|
|
29
|
+
admitFrameWindow,
|
|
29
30
|
createConnection,
|
|
30
31
|
frameToLines,
|
|
31
32
|
} from '@serverless-ircd/irc-core';
|
|
@@ -63,19 +64,14 @@ export interface DefaultParams {
|
|
|
63
64
|
* behaviour of callers that have not opted in.
|
|
64
65
|
*/
|
|
65
66
|
messages?: MessageStore;
|
|
66
|
-
/**
|
|
67
|
-
* SASL account verification source. When omitted the actor runs with
|
|
68
|
-
* `ctx.accounts` undefined so `AUTHENTICATE PLAIN` fails with `904`
|
|
69
|
-
* — preserves the behaviour of callers that have not opted in.
|
|
70
|
-
*/
|
|
71
|
-
accounts?: AccountStore;
|
|
72
67
|
/**
|
|
73
68
|
* Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
|
|
74
|
-
* OperServ + read-marker
|
|
75
|
-
* `ctx.services` undefined so
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
69
|
+
* OperServ + read-marker + the unified SASL/PASS credential home). When
|
|
70
|
+
* omitted the actor runs with `ctx.services` undefined so
|
|
71
|
+
* `PRIVMSG NickServ` / ChanServ routing is disabled and every SASL/PASS
|
|
72
|
+
* credential verify fails — preserves the behaviour of callers that
|
|
73
|
+
* have not opted in. On success the handler flushes pending
|
|
74
|
+
* write-behind ops after the actor run.
|
|
79
75
|
*/
|
|
80
76
|
services?: DynamoServicesStore;
|
|
81
77
|
/**
|
|
@@ -128,113 +124,194 @@ export interface DefaultParams {
|
|
|
128
124
|
export async function handleDefault(params: DefaultParams): Promise<{ statusCode: number }> {
|
|
129
125
|
const clock = params.clock ?? SystemClock;
|
|
130
126
|
const ids = params.ids ?? new UuidIdFactory();
|
|
131
|
-
// Load persisted state. Strongly consistent: $connect writes this row and
|
|
132
|
-
// $default reads it on the very next frame — an eventually-consistent Get
|
|
133
|
-
// can miss the just-written row (DynamoDB replication lag) and silently
|
|
134
|
-
// drop the client's first frame as a 410. Strong consistency closes that
|
|
135
|
-
// read-after-write window.
|
|
136
|
-
const result = await params.dynamo.send(
|
|
137
|
-
new GetCommand({
|
|
138
|
-
TableName: params.tables.Connections,
|
|
139
|
-
Key: { connectionId: params.connectionId },
|
|
140
|
-
ConsistentRead: true,
|
|
141
|
-
}),
|
|
142
|
-
);
|
|
143
|
-
if (result.Item === undefined) {
|
|
144
|
-
// Previously this branch returned silently, making a missing-row
|
|
145
|
-
// $default invisible to operators (client sees nothing, logs show
|
|
146
|
-
// nothing). Warn so the 410 path is diagnosable.
|
|
147
|
-
console.warn(
|
|
148
|
-
`[irc-handler] $default connId=${params.connectionId} wsMode=? row=missing -> 410`,
|
|
149
|
-
);
|
|
150
|
-
return { statusCode: 410 };
|
|
151
|
-
}
|
|
152
|
-
const persisted = result.Item as unknown as MarshalledConnection;
|
|
153
|
-
const state = unmarshalConnection(persisted);
|
|
154
127
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
//
|
|
128
|
+
// Optimistic-concurrency retry loop. API Gateway dispatches a client's
|
|
129
|
+
// rapid frames (CAP END, NICK, USER; or two PRIVMSG) as CONCURRENT
|
|
130
|
+
// Lambda invocations that each read-modify-write the same Connections
|
|
131
|
+
// row. Each attempt:
|
|
132
|
+
// 1. Strongly-consistent read (closes the $connect→$default window).
|
|
133
|
+
// 2. Run the actor over the frame against the fresh state.
|
|
134
|
+
// 3. Conditional write requiring `revision = :expected`; on conflict
|
|
135
|
+
// (another invocation wrote first) re-read and re-run.
|
|
136
|
+
// Membership writes (TransactWriteItems + ADD/DELETE on a String Set)
|
|
137
|
+
// are idempotent by design, so a retried frame never doubles roster
|
|
138
|
+
// state. NOTE: peer `postToConnection` calls issued inside the actor
|
|
139
|
+
// run MAY double-deliver on the rare retry; registration (the deadlock
|
|
140
|
+
// this fixes) has no peer sends, and a duplicated PRIVMSG on a rare
|
|
141
|
+
// retry is strictly better than the total state-loss this loop fixes.
|
|
142
|
+
// Full peer-send buffering is a documented follow-up.
|
|
143
|
+
const MAX_RETRIES = 8;
|
|
165
144
|
const outbound: string[] = [];
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
// management API in production; in tests we collect for assertions.
|
|
170
|
-
for (const l of lines) outbound.push(l.text);
|
|
171
|
-
},
|
|
172
|
-
disconnect: () => {
|
|
173
|
-
// No-op: a Lambda `$default` invocation cannot close its own APIGW
|
|
174
|
-
// socket. The canonical teardown is the `$disconnect` route (which
|
|
175
|
-
// fires when APIGW observes the close) plus the sweeper/ping-checker
|
|
176
|
-
// for connections that vanish without one. The actor's state
|
|
177
|
-
// mutations (nick release, roster deltas) are persisted below before
|
|
178
|
-
// this invocation returns, so the eventual `$disconnect` completes
|
|
179
|
-
// the fanout. See docs/AWS-Deployment.md §14.1.
|
|
180
|
-
},
|
|
181
|
-
snapshot: () => state,
|
|
182
|
-
};
|
|
183
|
-
const runtime = new AwsRuntime({
|
|
184
|
-
dynamo: params.dynamo,
|
|
185
|
-
tables: params.tables,
|
|
186
|
-
connId: params.connectionId,
|
|
187
|
-
handlers,
|
|
188
|
-
managementApi: params.managementApi,
|
|
189
|
-
clock,
|
|
190
|
-
...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
|
|
191
|
-
});
|
|
145
|
+
// Captured from the committed attempt so the post-loop outbound flush
|
|
146
|
+
// knows the connection's frame mode (spec vs legacy line framing).
|
|
147
|
+
let wsModeHolder: WsFrameMode = 'legacy';
|
|
192
148
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
149
|
+
/** Builds a fresh actor for `state` and runs this invocation's frame. */
|
|
150
|
+
const runFrame = async (state: ConnectionState, wsMode: WsFrameMode): Promise<void> => {
|
|
151
|
+
const handlers: AwsRuntimeHandlers = {
|
|
152
|
+
send: (lines) => {
|
|
153
|
+
// Self-send: APIGW echoes back to the caller's open socket via the
|
|
154
|
+
// management API in production; in tests we collect for assertions.
|
|
155
|
+
for (const l of lines) outbound.push(l.text);
|
|
156
|
+
},
|
|
157
|
+
disconnect: () => {
|
|
158
|
+
// No-op: a Lambda `$default` invocation cannot close its own APIGW
|
|
159
|
+
// socket. The canonical teardown is the `$disconnect` route (which
|
|
160
|
+
// fires when APIGW observes the close) plus the sweeper/ping-checker
|
|
161
|
+
// for connections that vanish without one. The actor's state
|
|
162
|
+
// mutations (nick release, roster deltas) are persisted below before
|
|
163
|
+
// this invocation returns, so the eventual `$disconnect` completes
|
|
164
|
+
// the fanout. See docs/AWS-Deployment.md §14.1.
|
|
165
|
+
},
|
|
166
|
+
snapshot: () => state,
|
|
167
|
+
};
|
|
168
|
+
const runtime = new AwsRuntime({
|
|
210
169
|
dynamo: params.dynamo,
|
|
211
170
|
tables: params.tables,
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
171
|
+
connId: params.connectionId,
|
|
172
|
+
handlers,
|
|
173
|
+
managementApi: params.managementApi,
|
|
174
|
+
clock,
|
|
175
|
+
...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
|
|
176
|
+
});
|
|
177
|
+
const channelAccess = new LambdaChannelAccess(runtime, clock.now());
|
|
178
|
+
const actor = new ConnectionActor({
|
|
179
|
+
state,
|
|
180
|
+
runtime,
|
|
181
|
+
channels: channelAccess,
|
|
182
|
+
serverConfig: params.serverConfig,
|
|
183
|
+
configSource: 'Secrets Manager',
|
|
184
|
+
clock,
|
|
185
|
+
ids,
|
|
186
|
+
motd: params.motd,
|
|
187
|
+
transport: { feed: (chunk) => frameToLines(chunk, wsMode) },
|
|
188
|
+
// AwsStats Scans Connections + ChannelMeta for LUSERS/STATS.
|
|
189
|
+
// `uptimeStartedAt` is captured at the Lambda's cold start so `STATS u`
|
|
190
|
+
// reports per-isolate uptime; a deployment-wide uptime would require a
|
|
191
|
+
// persistent record (future work).
|
|
192
|
+
stats: new AwsStats({
|
|
193
|
+
dynamo: params.dynamo,
|
|
194
|
+
tables: params.tables,
|
|
195
|
+
uptimeStartedAt: LAMBDA_STARTUP_AT,
|
|
196
|
+
}),
|
|
197
|
+
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
198
|
+
...(params.services !== undefined ? { services: params.services } : {}),
|
|
199
|
+
...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
|
|
200
|
+
...(params.history !== undefined ? { history: params.history } : {}),
|
|
201
|
+
// API Gateway terminates TLS before the Lambda is invoked, so every
|
|
202
|
+
// WebSocket connection is secure → user mode `S`.
|
|
203
|
+
secure: true,
|
|
204
|
+
});
|
|
225
205
|
await actor.receiveTextFrame(params.body);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
await params.
|
|
231
|
-
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
for (let attempt = 0; ; attempt++) {
|
|
209
|
+
// 1. Strongly-consistent read.
|
|
210
|
+
const result = await params.dynamo.send(
|
|
211
|
+
new GetCommand({
|
|
212
|
+
TableName: params.tables.Connections,
|
|
213
|
+
Key: { connectionId: params.connectionId },
|
|
214
|
+
ConsistentRead: true,
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
if (result.Item === undefined) {
|
|
218
|
+
// Row gone (client reconnected elsewhere, swept, or stale connId).
|
|
219
|
+
// Not retriable: surface the 410 so the client reconnects.
|
|
220
|
+
console.warn(
|
|
221
|
+
`[irc-handler] $default connId=${params.connectionId} wsMode=? row=missing -> 410`,
|
|
222
|
+
);
|
|
223
|
+
return { statusCode: 410 };
|
|
224
|
+
}
|
|
225
|
+
const persisted = result.Item as unknown as MarshalledConnection & { frameTimes?: unknown };
|
|
226
|
+
const state = unmarshalConnection(persisted);
|
|
227
|
+
const expectedRevision = persisted.revision ?? 0;
|
|
228
|
+
const wsMode: WsFrameMode = persisted.wsMode ?? 'legacy';
|
|
229
|
+
wsModeHolder = wsMode;
|
|
230
|
+
|
|
231
|
+
// 1a. Adapter-boundary frame-rate gate. Each frame is a billed read +
|
|
232
|
+
// write (and the actor run); a hostile client must not be able to
|
|
233
|
+
// drive that cost unboundedly with low-cost commands. The sliding
|
|
234
|
+
// window persists on the row (admitted frames persist it atomically
|
|
235
|
+
// with the state write below), so the limit holds across
|
|
236
|
+
// invocations. An over-limit frame never reaches the actor: the
|
|
237
|
+
// canonical ERROR line is delivered, the socket is closed via
|
|
238
|
+
// DeleteConnection (which fires the $disconnect cleanup), and the
|
|
239
|
+
// invocation returns without a write.
|
|
240
|
+
const frameDecision = admitFrameWindow(
|
|
241
|
+
readFrameTimes(persisted),
|
|
242
|
+
clock.now(),
|
|
243
|
+
params.serverConfig.adapter.maxFramesPerWindow,
|
|
244
|
+
params.serverConfig.adapter.frameWindowSeconds * 1000,
|
|
245
|
+
);
|
|
246
|
+
if (!frameDecision.admitted) {
|
|
247
|
+
console.warn(
|
|
248
|
+
`[irc-handler] $default connId=${params.connectionId} frame limit exceeded ` +
|
|
249
|
+
`(${params.serverConfig.adapter.maxFramesPerWindow}/${params.serverConfig.adapter.frameWindowSeconds}s) -> closing`,
|
|
250
|
+
);
|
|
251
|
+
if (params.managementApi !== null) {
|
|
252
|
+
try {
|
|
253
|
+
await postOutbound(
|
|
254
|
+
params.managementApi,
|
|
255
|
+
params.connectionId,
|
|
256
|
+
[FRAME_FLOOD_ERROR_LINE],
|
|
257
|
+
wsMode,
|
|
258
|
+
);
|
|
259
|
+
await closeConnectionIfSupported(params.managementApi, params.connectionId);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
// The socket may already be gone (close raced a concurrent
|
|
262
|
+
// over-limit frame) — surface for observability, nothing to retry.
|
|
263
|
+
console.error('[irc-handler] flood close post failed', err);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { statusCode: 200 };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 2. Mark activity + run the actor. Self-sends reset per attempt.
|
|
270
|
+
state.lastSeen = clock.now();
|
|
271
|
+
outbound.length = 0;
|
|
272
|
+
try {
|
|
273
|
+
await runFrame(state, wsMode);
|
|
274
|
+
} catch (err: unknown) {
|
|
275
|
+
console.error('[irc-handler] actor.receiveTextFrame failed', err);
|
|
276
|
+
// Best-effort persist of partial state (swallow a conflict here —
|
|
277
|
+
// the actor already failed, no point retrying).
|
|
278
|
+
await persistState(
|
|
279
|
+
params.dynamo,
|
|
280
|
+
params.tables,
|
|
281
|
+
state,
|
|
282
|
+
clock.now(),
|
|
283
|
+
expectedRevision,
|
|
284
|
+
frameDecision.times,
|
|
285
|
+
).catch(() => {});
|
|
286
|
+
await params.services?.flush();
|
|
287
|
+
return { statusCode: 500 };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 3. Conditional persist.
|
|
291
|
+
try {
|
|
292
|
+
await persistState(
|
|
293
|
+
params.dynamo,
|
|
294
|
+
params.tables,
|
|
295
|
+
state,
|
|
296
|
+
clock.now(),
|
|
297
|
+
expectedRevision,
|
|
298
|
+
frameDecision.times,
|
|
299
|
+
);
|
|
300
|
+
break; // committed — exit the retry loop.
|
|
301
|
+
} catch (err: unknown) {
|
|
302
|
+
if (isConditionalCheckFailed(err) && attempt < MAX_RETRIES) {
|
|
303
|
+
// Another invocation wrote first; re-read + re-run against fresh state.
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
// Exhausted retries or an unrelated DynamoDB error.
|
|
307
|
+
console.error('[irc-handler] persist failed (retries exhausted or unrecoverable)', err);
|
|
308
|
+
await params.services?.flush();
|
|
309
|
+
return { statusCode: 500 };
|
|
310
|
+
}
|
|
232
311
|
}
|
|
233
312
|
|
|
234
|
-
// Persist
|
|
235
|
-
|
|
236
|
-
// Drain any services write-behind ops (NickServ/ChanServ/...) so a
|
|
237
|
-
// Lambda freeze / evict does not lose state.
|
|
313
|
+
// Persist committed. Drain any services write-behind ops (NickServ/ChanServ/...)
|
|
314
|
+
// so a Lambda freeze / evict does not lose state.
|
|
238
315
|
await params.services?.flush();
|
|
239
316
|
|
|
240
317
|
// In production, deliver outbound bytes back to the caller via APIGW.
|
|
@@ -243,7 +320,7 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
243
320
|
// receive every line CRLF-joined in a single message.
|
|
244
321
|
if (params.managementApi !== null && outbound.length > 0) {
|
|
245
322
|
try {
|
|
246
|
-
await postOutbound(params.managementApi, params.connectionId, outbound,
|
|
323
|
+
await postOutbound(params.managementApi, params.connectionId, outbound, wsModeHolder);
|
|
247
324
|
} catch (err) {
|
|
248
325
|
// Surface the failure (gone connection, stale MANAGEMENT_URL, …)
|
|
249
326
|
// so it shows in CloudWatch instead of vanishing. The handler still
|
|
@@ -255,6 +332,35 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
255
332
|
return { statusCode: 200 };
|
|
256
333
|
}
|
|
257
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Reads the persisted inbound frame-window timestamps off a Connections
|
|
337
|
+
* row. Rows written before the frame gate shipped (or by paths that do
|
|
338
|
+
* not track frames, e.g. NLB chunks) carry no attribute — an empty
|
|
339
|
+
* window starts the connection fresh.
|
|
340
|
+
*/
|
|
341
|
+
function readFrameTimes(row: MarshalledConnection & { frameTimes?: unknown }): number[] {
|
|
342
|
+
const raw = row.frameTimes;
|
|
343
|
+
if (!Array.isArray(raw)) return [];
|
|
344
|
+
return raw as number[];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Closes the client's APIGW WebSocket via the management API
|
|
349
|
+
* (`DeleteConnection` — API Gateway observes the close and fires the
|
|
350
|
+
* `$disconnect` route, which runs the canonical QUIT cleanup). The
|
|
351
|
+
* `PostToConnection` test-double arm has no delete method; the close is
|
|
352
|
+
* skipped there (the ERROR post alone still informs the client).
|
|
353
|
+
*/
|
|
354
|
+
async function closeConnectionIfSupported(
|
|
355
|
+
api: ApiGatewayManagementApi | PostToConnection,
|
|
356
|
+
connectionId: string,
|
|
357
|
+
): Promise<void> {
|
|
358
|
+
const deleter = api as Partial<Pick<ApiGatewayManagementApi, 'deleteConnection'>>;
|
|
359
|
+
if (typeof deleter.deleteConnection === 'function') {
|
|
360
|
+
await deleter.deleteConnection({ ConnectionId: connectionId });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
258
364
|
/**
|
|
259
365
|
* Delivers `lines` to `connectionId` via APIGW according to `mode`:
|
|
260
366
|
* - `legacy` — all lines joined with `\r\n` in a single `postToConnection`.
|
|
@@ -280,28 +386,35 @@ async function postOutbound(
|
|
|
280
386
|
/**
|
|
281
387
|
* Writes the actor's mutated `state` back to the Connections row.
|
|
282
388
|
*
|
|
283
|
-
* Uses
|
|
284
|
-
* `joinedChannels
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
389
|
+
* Uses a conditional `UpdateCommand` that SETs every actor-owned field
|
|
390
|
+
* EXCEPT `joinedChannels`, bumps the `revision` counter, and requires
|
|
391
|
+
* the row's `revision` to still equal `expectedRevision` (the value read
|
|
392
|
+
* at the start of this invocation). A `ConditionalCheckFailedException`
|
|
393
|
+
* means another concurrent `$default` for the same connection wrote in
|
|
394
|
+
* between; {@link handleDefault} catches it and retries the whole
|
|
395
|
+
* read → run → persist cycle against the fresh row. The membership
|
|
396
|
+
* transaction (see `AwsRuntime`) owns `joinedChannels` atomically and
|
|
397
|
+
* is idempotent (`ADD`/`DELETE` on a String Set), so a retried frame
|
|
398
|
+
* never doubles roster state. Optional fields (nick, user, …) are SET
|
|
399
|
+
* when present and REMOVE'd when absent so a cleared value does not
|
|
400
|
+
* linger from a previous frame.
|
|
290
401
|
*/
|
|
291
402
|
async function persistState(
|
|
292
403
|
dynamo: DynamoDBDocumentClient,
|
|
293
404
|
tables: TablesConfig,
|
|
294
|
-
_connId: string,
|
|
295
405
|
state: ConnectionState,
|
|
296
406
|
now: number,
|
|
407
|
+
expectedRevision: number,
|
|
408
|
+
frameTimes: readonly number[],
|
|
297
409
|
): Promise<void> {
|
|
298
|
-
const expr = buildPersistUpdate(state, now);
|
|
410
|
+
const expr = buildPersistUpdate(state, now, expectedRevision, frameTimes);
|
|
299
411
|
const hasNames = Object.keys(expr.expressionAttributeNames).length > 0;
|
|
300
412
|
await dynamo.send(
|
|
301
413
|
new UpdateCommand({
|
|
302
414
|
TableName: tables.Connections,
|
|
303
415
|
Key: { connectionId: state.id },
|
|
304
416
|
UpdateExpression: expr.updateExpression,
|
|
417
|
+
ConditionExpression: expr.conditionExpression,
|
|
305
418
|
ExpressionAttributeValues: expr.expressionAttributeValues,
|
|
306
419
|
...(hasNames ? { ExpressionAttributeNames: expr.expressionAttributeNames } : {}),
|
|
307
420
|
}),
|
|
@@ -326,12 +439,28 @@ const OPTIONAL_FIELDS = [
|
|
|
326
439
|
* the required fields; SETs optional fields that are present and
|
|
327
440
|
* REMOVEs those that are absent. `joinedChannels` is intentionally
|
|
328
441
|
* excluded — owned by the membership transaction.
|
|
442
|
+
*
|
|
443
|
+
* **Optimistic concurrency:** the expression also `ADD`s `1` to the
|
|
444
|
+
* `revision` counter and attaches a `ConditionExpression` requiring
|
|
445
|
+
* `revision = :expectedRev` (or `attribute_not_exists(#rev)` for rows
|
|
446
|
+
* written before the field shipped). {@link handleDefault} captures the
|
|
447
|
+
* row's revision at read time and passes it as `expectedRevision`; a
|
|
448
|
+
* `ConditionalCheckFailedException` means another concurrent invocation
|
|
449
|
+
* for the same connection wrote in between → the caller re-reads and
|
|
450
|
+
* re-runs the frame (see {@link handleDefault}'s retry loop). Without
|
|
451
|
+
* this guard, two concurrent `$default` invocations (API Gateway
|
|
452
|
+
* dispatches a client's rapid frames as overlapping Lambdas) clobber
|
|
453
|
+
* each other's state — the registration deadlock where `CAP END`'s
|
|
454
|
+
* `capNegotiating=false` was overwritten by `NICK`/`USER`'s stale read.
|
|
329
455
|
*/
|
|
330
|
-
function buildPersistUpdate(
|
|
456
|
+
export function buildPersistUpdate(
|
|
331
457
|
state: ConnectionState,
|
|
332
458
|
now: number,
|
|
459
|
+
expectedRevision: number,
|
|
460
|
+
frameTimes: readonly number[],
|
|
333
461
|
): {
|
|
334
462
|
updateExpression: string;
|
|
463
|
+
conditionExpression: string;
|
|
335
464
|
expressionAttributeNames: Record<string, string>;
|
|
336
465
|
expressionAttributeValues: Record<string, unknown>;
|
|
337
466
|
} {
|
|
@@ -343,6 +472,15 @@ function buildPersistUpdate(
|
|
|
343
472
|
':ls': state.lastSeen,
|
|
344
473
|
':is': now,
|
|
345
474
|
':v': CONNECTION_VERSION,
|
|
475
|
+
// Sliding-window timestamps for the adapter-boundary frame limit.
|
|
476
|
+
// Committed atomically with the state write (same revision
|
|
477
|
+
// condition), so each frame is counted exactly once even when
|
|
478
|
+
// concurrent invocations race through the retry loop.
|
|
479
|
+
':ft': [...frameTimes],
|
|
480
|
+
// Optimistic-concurrency: increment the revision counter, gated by
|
|
481
|
+
// a condition that the row is still at the revision we read.
|
|
482
|
+
':revInc': 1,
|
|
483
|
+
':expectedRev': expectedRevision,
|
|
346
484
|
};
|
|
347
485
|
const setNames = [
|
|
348
486
|
'registration = :reg',
|
|
@@ -352,9 +490,10 @@ function buildPersistUpdate(
|
|
|
352
490
|
'lastSeen = :ls',
|
|
353
491
|
'idleSince = :is',
|
|
354
492
|
'version = :v',
|
|
493
|
+
'frameTimes = :ft',
|
|
355
494
|
];
|
|
356
495
|
const removeNames: string[] = [];
|
|
357
|
-
const nameMap: Record<string, string> = {};
|
|
496
|
+
const nameMap: Record<string, string> = { '#rev': 'revision' };
|
|
358
497
|
|
|
359
498
|
for (const field of OPTIONAL_FIELDS) {
|
|
360
499
|
const placeholder = `:${field}`;
|
|
@@ -373,13 +512,30 @@ function buildPersistUpdate(
|
|
|
373
512
|
if (removeNames.length > 0) {
|
|
374
513
|
parts.push(`REMOVE ${removeNames.join(', ')}`);
|
|
375
514
|
}
|
|
515
|
+
// Bump the optimistic-concurrency counter atomically with the state write.
|
|
516
|
+
parts.push('ADD #rev :revInc');
|
|
376
517
|
return {
|
|
377
518
|
updateExpression: parts.join(' '),
|
|
519
|
+
// `attribute_not_exists(#rev)` lets the first write after deploy land on
|
|
520
|
+
// pre-existing rows that predate the column (treats them as revision 0).
|
|
521
|
+
conditionExpression: 'attribute_not_exists(#rev) OR #rev = :expectedRev',
|
|
378
522
|
expressionAttributeNames: nameMap,
|
|
379
523
|
expressionAttributeValues: values,
|
|
380
524
|
};
|
|
381
525
|
}
|
|
382
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Returns true when a DynamoDB error is a conditional-check failure
|
|
529
|
+
* (the row's `revision` no longer matches the value the invocation
|
|
530
|
+
* read). Such failures are retried by {@link handleDefault}'s
|
|
531
|
+
* optimistic-concurrency loop after re-reading and re-running the frame.
|
|
532
|
+
*/
|
|
533
|
+
export function isConditionalCheckFailed(err: unknown): boolean {
|
|
534
|
+
if (err === null || typeof err !== 'object') return false;
|
|
535
|
+
const name = (err as { name?: string }).name;
|
|
536
|
+
return name === 'ConditionalCheckFailedException';
|
|
537
|
+
}
|
|
538
|
+
|
|
383
539
|
/**
|
|
384
540
|
* `ActorChannelAccess` backed by the per-invocation {@link AwsRuntime}.
|
|
385
541
|
*
|