serverless-ircd 0.3.0 → 0.4.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 +2 -2
- package/.github/workflows/deploy-aws.yml +1 -3
- package/.github/workflows/deploy-cf-tcp.yml +87 -0
- package/.github/workflows/deploy-cf.yml +1 -1
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/CHANGELOG.md +174 -18
- package/README.md +63 -30
- package/apps/aws-stack/README.md +2 -2
- package/apps/aws-stack/bin/aws.ts +7 -0
- package/apps/aws-stack/package.json +4 -4
- package/apps/aws-stack/src/aws-stack.ts +118 -6
- package/apps/aws-stack/tests/stack.test.ts +98 -3
- package/apps/cf-tcp-container/Dockerfile +69 -0
- package/apps/cf-tcp-container/package.json +34 -0
- package/apps/cf-tcp-container/src/config-loader.ts +145 -0
- package/apps/cf-tcp-container/src/container-do.ts +38 -0
- package/apps/cf-tcp-container/src/container-server.ts +363 -0
- package/apps/cf-tcp-container/src/main.ts +77 -0
- package/apps/cf-tcp-container/src/persistence.ts +144 -0
- package/apps/cf-tcp-container/src/worker.ts +41 -0
- package/apps/cf-tcp-container/terraform/provider.tf +24 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
- package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
- package/apps/cf-tcp-container/tsconfig.build.json +17 -0
- package/apps/cf-tcp-container/tsconfig.test.json +15 -0
- package/apps/cf-tcp-container/vitest.config.ts +26 -0
- package/apps/cf-tcp-container/wrangler.toml +63 -0
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/wrangler.test.toml +6 -0
- package/apps/cf-worker/wrangler.toml +28 -2
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +10 -0
- package/apps/local-cli/src/server.ts +20 -3
- package/package.json +14 -10
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +1 -1
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +6 -4
- package/packages/aws-adapter/src/config-loader.ts +32 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +35 -2
- package/packages/aws-adapter/src/handlers/index.ts +69 -3
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
- package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
- package/packages/aws-adapter/src/index.ts +6 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
- package/packages/aws-adapter/tests/account-store.test.ts +19 -20
- package/packages/aws-adapter/tests/admission.test.ts +70 -0
- package/packages/aws-adapter/tests/aws-harness.ts +13 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +154 -53
- package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
- package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
- package/packages/aws-adapter/tests/transactions.test.ts +25 -20
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +2 -4
- package/packages/cf-adapter/src/config-loader.ts +33 -0
- package/packages/cf-adapter/src/connection-do.ts +111 -46
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +33 -9
- package/packages/cf-adapter/src/index.ts +9 -8
- package/packages/cf-adapter/tests/cf-harness.ts +11 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
- package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
- package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
- package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
- package/packages/cf-adapter/tests/worker/main.ts +8 -1
- package/packages/cf-adapter/wrangler.test.toml +8 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +6 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
- package/packages/irc-core/src/caps/capabilities.ts +23 -2
- package/packages/irc-core/src/cloak.ts +1 -1
- package/packages/irc-core/src/commands/cap.ts +8 -1
- package/packages/irc-core/src/commands/index.ts +9 -0
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +18 -12
- package/packages/irc-core/src/commands/sasl.ts +72 -9
- package/packages/irc-core/src/commands/server-info.ts +129 -0
- package/packages/irc-core/src/commands/userhost.ts +84 -0
- package/packages/irc-core/src/commands/whowas.ts +113 -0
- package/packages/irc-core/src/config.ts +58 -0
- package/packages/irc-core/src/credential-hashing.ts +124 -0
- package/packages/irc-core/src/effects.ts +6 -29
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +181 -12
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +38 -1
- package/packages/irc-core/tests/account-store.test.ts +45 -2
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/commands/cap.test.ts +33 -1
- package/packages/irc-core/tests/commands/ison.test.ts +166 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +151 -6
- package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
- package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +95 -1
- package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
- package/packages/irc-core/tests/effects.test.ts +0 -27
- package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
- package/packages/irc-core/tests/numerics.test.ts +12 -0
- package/packages/irc-core/tests/types.test.ts +35 -1
- package/packages/irc-core/tsconfig.build.json +1 -1
- package/packages/irc-core/tsconfig.test.json +1 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +158 -14
- package/packages/irc-server/src/dispatch.ts +0 -3
- package/packages/irc-server/src/index.ts +10 -2
- package/packages/irc-server/src/routing.ts +12 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +400 -3
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +4 -0
- package/packages/irc-server/tests/transport.test.ts +230 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/harness.ts +44 -9
- package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
- package/packages/irc-test-support/src/index.ts +3 -0
- package/packages/irc-test-support/src/scenarios.ts +132 -2
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
- package/pnpm-workspace.yaml +9 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +5 -6
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
- package/docs/ADR-002-location-of-authority.md +0 -82
- package/docs/ADR-003-durable-object-sharding.md +0 -93
- package/docs/ADR-004-dynamodb-schema.md +0 -96
- package/docs/ADR-005-wss-only-transport-v1.md +0 -83
- package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
- package/docs/ADR-007-deterministic-ports.md +0 -82
- package/docs/ADR-008-monorepo-tooling.md +0 -60
- package/docs/AWS-Adapter-Architecture.md +0 -496
- package/docs/AWS-Deployment.md +0 -1186
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -11
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -443
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NLB TCP+TLS Lambda streaming handler.
|
|
3
|
+
*
|
|
4
|
+
* AWS Network Load Balancer with a `tls` listener terminates TLS at the edge
|
|
5
|
+
* and invokes a Lambda function as the target for each chunk of client data.
|
|
6
|
+
* The Lambda response body is streamed back to the client over the same TCP
|
|
7
|
+
* connection the NLB holds open.
|
|
8
|
+
*
|
|
9
|
+
* Connection identity: NLB does not hand out a stable connection id the way
|
|
10
|
+
* API Gateway does. The flow is identified by the 4-tuple
|
|
11
|
+
* (source-IP, source-port), exposed as `x-forwarded-for` /
|
|
12
|
+
* `x-forwarded-port` headers. {@link deriveNlbConnectionId} maps that tuple
|
|
13
|
+
* to the existing `connectionId` scheme (`nlb-<ip>-<port>`) so the same
|
|
14
|
+
* DynamoDB-backed state path serves both the wss and TCP+TLS transports.
|
|
15
|
+
*
|
|
16
|
+
* Transport buffer persistence: the `TcpByteStreamTransport` buffers
|
|
17
|
+
* partial lines (a chunk may split an IRC command at an arbitrary byte
|
|
18
|
+
* boundary). Because each chunk arrives in a fresh Lambda invocation, the
|
|
19
|
+
* buffered tail is snapshotted to a `transportBuffer` attribute on the
|
|
20
|
+
* Connections row and rehydrated on the next invocation — no bytes are lost
|
|
21
|
+
* across the compute boundary.
|
|
22
|
+
*
|
|
23
|
+
* Outbound bytes: self-send lines (PONG, welcome block, error numerics, …)
|
|
24
|
+
* are collected and returned as the Lambda response body (base64-encoded so
|
|
25
|
+
* binary-safe). Cross-connection fanout (PRIVMSG broadcast, QUIT fanout, …)
|
|
26
|
+
* flows through the bound `managementApi` exactly as the wss path does —
|
|
27
|
+
* when the deploy also exposes an APIGW management endpoint the two
|
|
28
|
+
* transports share one fanout backend; when `managementApi` is `null` the
|
|
29
|
+
* AwsRuntime silently no-ops cross-connection sends (documented platform
|
|
30
|
+
* limit, see `docs/AWS-Deployment.md`).
|
|
31
|
+
*
|
|
32
|
+
* NLB + Lambda streaming limits:
|
|
33
|
+
* - Idle timeout: the NLB idle timeout (10–350 s) governs how long a
|
|
34
|
+
* connection with no client data stays open. The sweeper + ping-checker
|
|
35
|
+
* keep the DynamoDB row fresh and eventually prune it via `idleSince` TTL.
|
|
36
|
+
* - No server-push: the Lambda can only respond to a client chunk; it
|
|
37
|
+
* cannot proactively push bytes. Real-time fanout piggybacks on the
|
|
38
|
+
* recipient's next chunk (response) or via the APIGW management API.
|
|
39
|
+
* - Max connection duration: NLB has no hard max for TCP, but the
|
|
40
|
+
* underlying flow may be rebalanced by the NLB at any time.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
|
|
44
|
+
import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
|
|
45
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
46
|
+
import {
|
|
47
|
+
type AccountStore,
|
|
48
|
+
type ChannelState,
|
|
49
|
+
type Clock,
|
|
50
|
+
type ConnectionState,
|
|
51
|
+
type IdFactory,
|
|
52
|
+
type MessageStore,
|
|
53
|
+
type MotdProvider,
|
|
54
|
+
type NickHistoryStore,
|
|
55
|
+
type ParsedServerConfig,
|
|
56
|
+
SystemClock,
|
|
57
|
+
UuidIdFactory,
|
|
58
|
+
} from '@serverless-ircd/irc-core';
|
|
59
|
+
import {
|
|
60
|
+
type ActorChannelAccess,
|
|
61
|
+
ConnectionActor,
|
|
62
|
+
WsTextFrameTransport,
|
|
63
|
+
} from '@serverless-ircd/irc-server';
|
|
64
|
+
import { TcpByteStreamTransport } from '@serverless-ircd/irc-server';
|
|
65
|
+
import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
|
|
66
|
+
import type { MarshalledConnection } from '../serialize.js';
|
|
67
|
+
import { CONNECTION_VERSION } from '../serialize.js';
|
|
68
|
+
import type { TablesConfig } from '../tables.js';
|
|
69
|
+
import { createInitialConnectionState } from './state.js';
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Event / response shapes
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* NLB Lambda target event (version 2.0). The `body` carries the client's
|
|
77
|
+
* raw TCP bytes, base64-encoded. Connection-identifying metadata is in
|
|
78
|
+
* the `headers` (`x-forwarded-for`, `x-forwarded-port`).
|
|
79
|
+
*/
|
|
80
|
+
export interface NlbStreamEvent {
|
|
81
|
+
requestContext: {
|
|
82
|
+
elb: {
|
|
83
|
+
targetGroupArn: string;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
version: string;
|
|
87
|
+
body: string;
|
|
88
|
+
isBase64Encoded: boolean;
|
|
89
|
+
headers?: Record<string, string>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Lambda response consumed by the NLB. The `body` (base64-encoded) is the
|
|
94
|
+
* raw bytes the NLB forwards to the client over the held-open TCP socket.
|
|
95
|
+
*/
|
|
96
|
+
export interface NlbStreamResponse {
|
|
97
|
+
statusCode: number;
|
|
98
|
+
body: string;
|
|
99
|
+
isBase64Encoded: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Connection identity
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Prefix distinguishing NLB-derived connection ids from APIGW ids in
|
|
108
|
+
* DynamoDB (so a client on both transports cannot collide).
|
|
109
|
+
*/
|
|
110
|
+
const NLB_CONN_PREFIX = 'nlb-';
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Derives a stable connection id from the NLB flow metadata (source IP +
|
|
114
|
+
* source port). Dots and colons in the IP are replaced with dashes so the
|
|
115
|
+
* id is safe for DynamoDB partition keys and logging.
|
|
116
|
+
*
|
|
117
|
+
* The NLB holds the TCP connection open for the lifetime of the flow, so
|
|
118
|
+
* the same `(ip, port)` pair maps to the same connection across every
|
|
119
|
+
* Lambda invocation for that flow.
|
|
120
|
+
*/
|
|
121
|
+
export function deriveNlbConnectionId(sourceIp: string, sourcePort: string): string {
|
|
122
|
+
const safeIp = sourceIp.replace(/[.:]/g, '-');
|
|
123
|
+
return `${NLB_CONN_PREFIX}${safeIp}-${sourcePort}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Handler
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/** Parameters accepted by {@link handleNlbStream}. */
|
|
131
|
+
export interface NlbStreamParams {
|
|
132
|
+
dynamo: DynamoDBDocumentClient;
|
|
133
|
+
tables: TablesConfig;
|
|
134
|
+
serverConfig: ParsedServerConfig;
|
|
135
|
+
motd: MotdProvider;
|
|
136
|
+
/** Chat-history persistence source (same as the wss `$default` path). */
|
|
137
|
+
messages?: MessageStore;
|
|
138
|
+
/** SASL account verification source. */
|
|
139
|
+
accounts?: AccountStore;
|
|
140
|
+
/** mTLS identity source for SASL EXTERNAL. */
|
|
141
|
+
mtlsIdentity?: import('@serverless-ircd/irc-core').MtlsIdentityProvider;
|
|
142
|
+
/** Nick-history source for WHOWAS. */
|
|
143
|
+
history?: NickHistoryStore;
|
|
144
|
+
/**
|
|
145
|
+
* Backend for cross-connection fanout. `null` when the deploy is NLB-only
|
|
146
|
+
* (no APIGW management endpoint) — cross-connection sends are silent
|
|
147
|
+
* no-ops in that mode.
|
|
148
|
+
*/
|
|
149
|
+
managementApi: ApiGatewayManagementApi | PostToConnection | null;
|
|
150
|
+
/** Injected for tests; defaults to {@link SystemClock}. */
|
|
151
|
+
clock?: Clock;
|
|
152
|
+
/** Injected for tests; defaults to {@link UuidIdFactory}. */
|
|
153
|
+
ids?: IdFactory;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Processes one NLB chunk: decodes the client bytes, frames them through
|
|
158
|
+
* the persistent TCP transport buffer, runs complete lines through the
|
|
159
|
+
* actor, persists state + buffer, and returns the outbound bytes for the
|
|
160
|
+
* NLB to stream back to the client.
|
|
161
|
+
*
|
|
162
|
+
* On the first chunk for a flow (no Connections row), a fresh row is
|
|
163
|
+
* created before processing — there is no separate `$connect` event from
|
|
164
|
+
* the NLB.
|
|
165
|
+
*/
|
|
166
|
+
export async function handleNlbStream(
|
|
167
|
+
event: NlbStreamEvent,
|
|
168
|
+
params: NlbStreamParams,
|
|
169
|
+
): Promise<NlbStreamResponse> {
|
|
170
|
+
const clock = params.clock ?? SystemClock;
|
|
171
|
+
const ids = params.ids ?? new UuidIdFactory();
|
|
172
|
+
const now = clock.now();
|
|
173
|
+
|
|
174
|
+
// 1. Decode the client bytes.
|
|
175
|
+
const data = event.isBase64Encoded
|
|
176
|
+
? Buffer.from(event.body, 'base64').toString('utf8')
|
|
177
|
+
: event.body;
|
|
178
|
+
|
|
179
|
+
// 2. Derive the connection id from NLB flow metadata.
|
|
180
|
+
const sourceIp = readHeader(event, 'x-forwarded-for') ?? 'unknown';
|
|
181
|
+
const sourcePort = readHeader(event, 'x-forwarded-port') ?? '0';
|
|
182
|
+
const connId = deriveNlbConnectionId(sourceIp, sourcePort);
|
|
183
|
+
|
|
184
|
+
// 3. Load the existing row (if any) and split the transport buffer.
|
|
185
|
+
const existing = await loadRow(params.dynamo, params.tables.Connections, connId);
|
|
186
|
+
let state: ConnectionState;
|
|
187
|
+
let transportBuffer: string;
|
|
188
|
+
if (existing === null) {
|
|
189
|
+
state = createInitialConnectionState(connId, now);
|
|
190
|
+
if (sourceIp !== 'unknown') state.host = sourceIp;
|
|
191
|
+
transportBuffer = '';
|
|
192
|
+
} else {
|
|
193
|
+
state = unmarshalState(existing);
|
|
194
|
+
transportBuffer = typeof existing.transportBuffer === 'string' ? existing.transportBuffer : '';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 4. Frame the chunk through the persistent TCP transport.
|
|
198
|
+
const transport = new TcpByteStreamTransport();
|
|
199
|
+
transport.restore(transportBuffer);
|
|
200
|
+
const lines = transport.feed(data);
|
|
201
|
+
const newBuffer = transport.getBuffer();
|
|
202
|
+
|
|
203
|
+
// Mark activity.
|
|
204
|
+
state.lastSeen = now;
|
|
205
|
+
|
|
206
|
+
// 5. Run complete lines through the actor (if any).
|
|
207
|
+
const outbound: string[] = [];
|
|
208
|
+
const handlers: AwsRuntimeHandlers = {
|
|
209
|
+
send: (sentLines) => {
|
|
210
|
+
for (const l of sentLines) outbound.push(l.text);
|
|
211
|
+
},
|
|
212
|
+
disconnect: () => {
|
|
213
|
+
// The Lambda cannot force-close the NLB-held TCP socket from inside
|
|
214
|
+
// the invocation. The canonical teardown is the sweeper + idleSince
|
|
215
|
+
// TTL (same pattern as the APIGW `$default` path, which also cannot
|
|
216
|
+
// close its own socket — see docs/AWS-Deployment.md §14.1).
|
|
217
|
+
},
|
|
218
|
+
snapshot: () => state,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const runtime = new AwsRuntime({
|
|
222
|
+
dynamo: params.dynamo,
|
|
223
|
+
tables: params.tables,
|
|
224
|
+
connId,
|
|
225
|
+
handlers,
|
|
226
|
+
managementApi: params.managementApi,
|
|
227
|
+
clock,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const channelAccess = new NlbChannelAccess(runtime, now);
|
|
231
|
+
|
|
232
|
+
const actor = new ConnectionActor({
|
|
233
|
+
state,
|
|
234
|
+
runtime,
|
|
235
|
+
channels: channelAccess,
|
|
236
|
+
serverConfig: params.serverConfig,
|
|
237
|
+
clock,
|
|
238
|
+
ids,
|
|
239
|
+
motd: params.motd,
|
|
240
|
+
// The actor receives already-framed complete lines; a stateless
|
|
241
|
+
// WsTextFrameTransport splits on \r\n (no-op for already-split lines).
|
|
242
|
+
transport: new WsTextFrameTransport(),
|
|
243
|
+
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
244
|
+
...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
|
|
245
|
+
...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
|
|
246
|
+
...(params.history !== undefined ? { history: params.history } : {}),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (lines.length > 0) {
|
|
250
|
+
try {
|
|
251
|
+
await actor.receiveTextFrame(lines.join('\r\n'));
|
|
252
|
+
} catch (err: unknown) {
|
|
253
|
+
console.error('[nlb-stream] actor.receiveTextFrame failed', err);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 6. Persist state + transport buffer.
|
|
258
|
+
await persistStateAndBuffer(
|
|
259
|
+
params.dynamo,
|
|
260
|
+
params.tables.Connections,
|
|
261
|
+
connId,
|
|
262
|
+
state,
|
|
263
|
+
now,
|
|
264
|
+
newBuffer,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// 7. Return outbound bytes as the NLB response body.
|
|
268
|
+
const responseBody = outbound.length > 0 ? `${outbound.join('\r\n')}\r\n` : '';
|
|
269
|
+
return {
|
|
270
|
+
statusCode: 200,
|
|
271
|
+
body: Buffer.from(responseBody, 'utf8').toString('base64'),
|
|
272
|
+
isBase64Encoded: true,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// Internal — DynamoDB helpers
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
/** Raw row shape (extends the marshalled connection with the transport buffer). */
|
|
281
|
+
type RawConnectionRow = MarshalledConnection & { transportBuffer?: string };
|
|
282
|
+
|
|
283
|
+
/** Reads a header case-insensitively (NLB lowercases header names). */
|
|
284
|
+
function readHeader(event: NlbStreamEvent, name: string): string | undefined {
|
|
285
|
+
const headers = event.headers;
|
|
286
|
+
if (headers === undefined) return undefined;
|
|
287
|
+
const lower = name.toLowerCase();
|
|
288
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
289
|
+
if (key.toLowerCase() === lower) return value;
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Loads a raw Connections row (includes transportBuffer if present). */
|
|
295
|
+
async function loadRow(
|
|
296
|
+
dynamo: DynamoDBDocumentClient,
|
|
297
|
+
tableName: string,
|
|
298
|
+
connId: string,
|
|
299
|
+
): Promise<RawConnectionRow | null> {
|
|
300
|
+
const result = await dynamo.send(
|
|
301
|
+
new GetCommand({ TableName: tableName, Key: { connectionId: connId } }),
|
|
302
|
+
);
|
|
303
|
+
if (result.Item === undefined) return null;
|
|
304
|
+
return result.Item as unknown as RawConnectionRow;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Unmarshals a raw row into a ConnectionState (ignores transportBuffer). */
|
|
308
|
+
function unmarshalState(row: RawConnectionRow): ConnectionState {
|
|
309
|
+
// Strip transportBuffer before unmarshalling so it is not treated as a
|
|
310
|
+
// stray ConnectionState field.
|
|
311
|
+
const { transportBuffer: _drop, ...connFields } = row;
|
|
312
|
+
void _drop;
|
|
313
|
+
return unmarshalConnectionTyped(connFields);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Thin wrapper around `unmarshalConnection` that accepts the stripped row.
|
|
318
|
+
* Importing lazily keeps the module boundary clean.
|
|
319
|
+
*/
|
|
320
|
+
import { unmarshalConnection } from '../serialize.js';
|
|
321
|
+
function unmarshalConnectionTyped(row: MarshalledConnection): ConnectionState {
|
|
322
|
+
return unmarshalConnection(row);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Persists the mutated state and the new transport buffer in a single
|
|
327
|
+
* UpdateCommand. The buffer is written as a plain string attribute — when
|
|
328
|
+
* empty it is REMOVE'd so stale buffers from a previous chunk do not
|
|
329
|
+
* linger after the line completes.
|
|
330
|
+
*/
|
|
331
|
+
async function persistStateAndBuffer(
|
|
332
|
+
dynamo: DynamoDBDocumentClient,
|
|
333
|
+
tableName: string,
|
|
334
|
+
connId: string,
|
|
335
|
+
state: ConnectionState,
|
|
336
|
+
now: number,
|
|
337
|
+
transportBuffer: string,
|
|
338
|
+
): Promise<void> {
|
|
339
|
+
// Build the standard persist expression (same fields as handleDefault's
|
|
340
|
+
// buildPersistUpdate, minus the joinedChannels which the membership
|
|
341
|
+
// transaction owns).
|
|
342
|
+
const values: Record<string, unknown> = {
|
|
343
|
+
':reg': state.registration,
|
|
344
|
+
':capN': state.capNegotiating,
|
|
345
|
+
':caps': [...state.caps],
|
|
346
|
+
':um': { ...state.userModes },
|
|
347
|
+
':ls': state.lastSeen,
|
|
348
|
+
':is': now,
|
|
349
|
+
':v': CONNECTION_VERSION,
|
|
350
|
+
};
|
|
351
|
+
const setNames = [
|
|
352
|
+
'registration = :reg',
|
|
353
|
+
'capNegotiating = :capN',
|
|
354
|
+
'caps = :caps',
|
|
355
|
+
'userModes = :um',
|
|
356
|
+
'lastSeen = :ls',
|
|
357
|
+
'idleSince = :is',
|
|
358
|
+
'version = :v',
|
|
359
|
+
];
|
|
360
|
+
const removeNames: string[] = [];
|
|
361
|
+
const nameMap: Record<string, string> = {};
|
|
362
|
+
|
|
363
|
+
for (const field of OPTIONAL_CONN_FIELDS) {
|
|
364
|
+
const placeholder = `:${field}`;
|
|
365
|
+
const value = state[field] as string | undefined;
|
|
366
|
+
const nameKey = `#${field}`;
|
|
367
|
+
nameMap[nameKey] = field;
|
|
368
|
+
if (value !== undefined) {
|
|
369
|
+
values[placeholder] = value;
|
|
370
|
+
setNames.push(`${nameKey} = ${placeholder}`);
|
|
371
|
+
} else {
|
|
372
|
+
removeNames.push(nameKey);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Transport buffer: SET when non-empty, REMOVE when empty.
|
|
377
|
+
nameMap['#tb'] = 'transportBuffer';
|
|
378
|
+
if (transportBuffer.length > 0) {
|
|
379
|
+
values[':tb'] = transportBuffer;
|
|
380
|
+
setNames.push('#tb = :tb');
|
|
381
|
+
} else {
|
|
382
|
+
removeNames.push('#tb');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const parts: string[] = [`SET ${setNames.join(', ')}`];
|
|
386
|
+
if (removeNames.length > 0) {
|
|
387
|
+
parts.push(`REMOVE ${removeNames.join(', ')}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
await dynamo.send(
|
|
391
|
+
new UpdateCommand({
|
|
392
|
+
TableName: tableName,
|
|
393
|
+
Key: { connectionId: connId },
|
|
394
|
+
UpdateExpression: parts.join(' '),
|
|
395
|
+
ExpressionAttributeValues: values,
|
|
396
|
+
ExpressionAttributeNames: nameMap,
|
|
397
|
+
}),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Optional connection fields mirrored into the SET/REMOVE split. */
|
|
402
|
+
const OPTIONAL_CONN_FIELDS = [
|
|
403
|
+
'nick',
|
|
404
|
+
'user',
|
|
405
|
+
'host',
|
|
406
|
+
'realname',
|
|
407
|
+
'passAttempt',
|
|
408
|
+
'account',
|
|
409
|
+
'away',
|
|
410
|
+
'saslMech',
|
|
411
|
+
'saslBuffer',
|
|
412
|
+
] as const;
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// Channel access (mirrors LambdaChannelAccess from default.ts)
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* `ActorChannelAccess` backed by the per-invocation {@link AwsRuntime}.
|
|
420
|
+
* Identical to `LambdaChannelAccess` in `default.ts` — duplicated rather
|
|
421
|
+
* than shared so the NLB handler stays self-contained (no cross-handler
|
|
422
|
+
* import coupling).
|
|
423
|
+
*/
|
|
424
|
+
class NlbChannelAccess implements ActorChannelAccess {
|
|
425
|
+
private readonly cache = new Map<string, ChannelState>();
|
|
426
|
+
constructor(
|
|
427
|
+
private readonly runtime: AwsRuntime,
|
|
428
|
+
private readonly now: number,
|
|
429
|
+
) {}
|
|
430
|
+
|
|
431
|
+
getOrCreateChannel(name: string): ChannelState {
|
|
432
|
+
const key = name.toLowerCase();
|
|
433
|
+
const existing = this.cache.get(key);
|
|
434
|
+
if (existing !== undefined) return existing;
|
|
435
|
+
const created: ChannelState = {
|
|
436
|
+
name,
|
|
437
|
+
nameLower: key,
|
|
438
|
+
modes: {
|
|
439
|
+
inviteOnly: false,
|
|
440
|
+
topicLock: false,
|
|
441
|
+
noExternal: false,
|
|
442
|
+
moderated: false,
|
|
443
|
+
secret: false,
|
|
444
|
+
private: false,
|
|
445
|
+
},
|
|
446
|
+
banMasks: new Set<string>(),
|
|
447
|
+
members: new Map(),
|
|
448
|
+
pendingInvites: new Set<string>(),
|
|
449
|
+
createdAt: this.now,
|
|
450
|
+
};
|
|
451
|
+
this.cache.set(key, created);
|
|
452
|
+
return created;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async refreshChannel(name: string): Promise<void> {
|
|
456
|
+
const key = name.toLowerCase();
|
|
457
|
+
const snap = await this.runtime.getChannelSnapshot(name);
|
|
458
|
+
if (snap === null) return;
|
|
459
|
+
const existing = this.cache.get(key);
|
|
460
|
+
if (snap.members.size === 0) {
|
|
461
|
+
if (existing !== undefined) {
|
|
462
|
+
existing.modes = { ...snap.modes };
|
|
463
|
+
existing.banMasks = new Set(snap.banMasks);
|
|
464
|
+
existing.pendingInvites = new Set(snap.pendingInvites);
|
|
465
|
+
if (snap.topic !== undefined) existing.topic = snap.topic;
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const rebuilt: ChannelState = {
|
|
470
|
+
name,
|
|
471
|
+
nameLower: key,
|
|
472
|
+
modes: { ...snap.modes },
|
|
473
|
+
banMasks: new Set(snap.banMasks),
|
|
474
|
+
members: new Map(snap.members),
|
|
475
|
+
pendingInvites: new Set(snap.pendingInvites),
|
|
476
|
+
createdAt: snap.createdAt,
|
|
477
|
+
};
|
|
478
|
+
if (snap.topic !== undefined) rebuilt.topic = snap.topic;
|
|
479
|
+
this.cache.set(key, rebuilt);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
@@ -187,7 +187,7 @@ export async function handlePingCheck(params: PingCheckParams): Promise<PingChec
|
|
|
187
187
|
// Internals
|
|
188
188
|
// ---------------------------------------------------------------------------
|
|
189
189
|
|
|
190
|
-
/** Default token source — Node
|
|
190
|
+
/** Default token source — Node 24 Lambda runtime exposes `crypto.randomUUID` globally. */
|
|
191
191
|
function defaultToken(): string {
|
|
192
192
|
return randomUUID();
|
|
193
193
|
}
|
|
@@ -51,6 +51,9 @@ export {
|
|
|
51
51
|
handler,
|
|
52
52
|
dispatch,
|
|
53
53
|
buildDepsFromEnv,
|
|
54
|
+
nlbStreamHandler,
|
|
55
|
+
handleNlbStream,
|
|
56
|
+
deriveNlbConnectionId,
|
|
54
57
|
pingCheckerHandler,
|
|
55
58
|
sweeperHandler,
|
|
56
59
|
handlePingCheck,
|
|
@@ -66,6 +69,9 @@ export type {
|
|
|
66
69
|
DisconnectEvent,
|
|
67
70
|
HandlerDeps,
|
|
68
71
|
LambdaResponse,
|
|
72
|
+
NlbStreamEvent,
|
|
73
|
+
NlbStreamParams,
|
|
74
|
+
NlbStreamResponse,
|
|
69
75
|
PingCheckParams,
|
|
70
76
|
PingCheckResult,
|
|
71
77
|
SweepParams,
|
|
@@ -31,6 +31,11 @@ interface Fx {
|
|
|
31
31
|
|
|
32
32
|
let fx: Fx | null = null;
|
|
33
33
|
|
|
34
|
+
function requireFx(): Fx {
|
|
35
|
+
if (!fx) throw new Error('test fixture not initialized');
|
|
36
|
+
return fx;
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
async function makeFx(): Promise<Fx> {
|
|
35
40
|
if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
|
|
36
41
|
const tableName = `acct-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
|
@@ -69,55 +74,57 @@ describe.skipIf(!available)('Accounts table round-trip', () => {
|
|
|
69
74
|
});
|
|
70
75
|
|
|
71
76
|
it('putAccountCredential then loadDynamoAccountStore verifies the account', async () => {
|
|
72
|
-
await putAccountCredential(
|
|
73
|
-
const store = await loadDynamoAccountStore(
|
|
77
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 's3cret');
|
|
78
|
+
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
74
79
|
expect(store).toBeDefined();
|
|
75
|
-
expect(
|
|
76
|
-
{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
);
|
|
80
|
+
expect(
|
|
81
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' }),
|
|
82
|
+
).toEqual({
|
|
83
|
+
ok: true,
|
|
84
|
+
account: 'alice',
|
|
85
|
+
});
|
|
81
86
|
});
|
|
82
87
|
|
|
83
88
|
it('loadDynamoAccountStore returns undefined when the table has no rows', async () => {
|
|
84
|
-
const store = await loadDynamoAccountStore(
|
|
89
|
+
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
85
90
|
expect(store).toBeUndefined();
|
|
86
91
|
});
|
|
87
92
|
|
|
88
93
|
it('putAccountCredential writes multiple distinct accounts', async () => {
|
|
89
|
-
await putAccountCredential(
|
|
90
|
-
await putAccountCredential(
|
|
91
|
-
const store = await loadDynamoAccountStore(
|
|
94
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'a1');
|
|
95
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'bob', 'b2');
|
|
96
|
+
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
92
97
|
expect(store).toBeDefined();
|
|
93
|
-
expect(store
|
|
94
|
-
expect(store
|
|
98
|
+
expect(store?.size).toBe(2);
|
|
99
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
|
|
100
|
+
true,
|
|
101
|
+
);
|
|
102
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(
|
|
95
103
|
true,
|
|
96
104
|
);
|
|
97
|
-
expect(store!.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(true);
|
|
98
105
|
});
|
|
99
106
|
|
|
100
107
|
it('putAccountCredential overwrites an existing account (re-seed)', async () => {
|
|
101
|
-
await putAccountCredential(
|
|
102
|
-
await putAccountCredential(
|
|
103
|
-
const store = await loadDynamoAccountStore(
|
|
108
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'old');
|
|
109
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'new');
|
|
110
|
+
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
104
111
|
expect(store).toBeDefined();
|
|
105
|
-
expect(store
|
|
106
|
-
expect(store
|
|
112
|
+
expect(store?.size).toBe(1);
|
|
113
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
|
|
107
114
|
false,
|
|
108
115
|
);
|
|
109
|
-
expect(store
|
|
116
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
|
|
110
117
|
true,
|
|
111
118
|
);
|
|
112
119
|
});
|
|
113
120
|
|
|
114
121
|
it('rejects a wrong password after round-trip', async () => {
|
|
115
|
-
await putAccountCredential(
|
|
116
|
-
const store = await loadDynamoAccountStore(
|
|
122
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'right');
|
|
123
|
+
const store = await loadDynamoAccountStore(requireFx().client, requireFx().tableName);
|
|
117
124
|
expect(store).toBeDefined();
|
|
118
|
-
expect(
|
|
119
|
-
|
|
120
|
-
)
|
|
125
|
+
expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
|
|
126
|
+
false,
|
|
127
|
+
);
|
|
121
128
|
});
|
|
122
129
|
});
|
|
123
130
|
|
|
@@ -131,19 +138,19 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
|
|
|
131
138
|
});
|
|
132
139
|
|
|
133
140
|
it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
|
|
134
|
-
await putAccountCredential(
|
|
141
|
+
await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'table-secret');
|
|
135
142
|
const cfgWithSasl = {
|
|
136
143
|
...DEFAULT_SERVER_CONFIG,
|
|
137
144
|
saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
|
|
138
145
|
};
|
|
139
|
-
const store = await resolveAccountStore(
|
|
146
|
+
const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
|
|
140
147
|
expect(store).toBeDefined();
|
|
141
148
|
// Table account verifies; config account does NOT (table is authoritative).
|
|
142
149
|
expect(
|
|
143
|
-
store
|
|
150
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
|
|
144
151
|
).toBe(true);
|
|
145
152
|
expect(
|
|
146
|
-
store
|
|
153
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
|
|
147
154
|
).toBe(false);
|
|
148
155
|
});
|
|
149
156
|
|
|
@@ -152,20 +159,24 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
|
|
|
152
159
|
...DEFAULT_SERVER_CONFIG,
|
|
153
160
|
saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
|
|
154
161
|
};
|
|
155
|
-
const store = await resolveAccountStore(
|
|
162
|
+
const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
|
|
156
163
|
expect(store).toBeDefined();
|
|
157
164
|
expect(
|
|
158
|
-
store
|
|
165
|
+
store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
|
|
159
166
|
).toBe(true);
|
|
160
167
|
});
|
|
161
168
|
|
|
162
169
|
it('returns undefined when the table is empty and no config accounts are set', async () => {
|
|
163
|
-
const store = await resolveAccountStore(
|
|
170
|
+
const store = await resolveAccountStore(
|
|
171
|
+
requireFx().client,
|
|
172
|
+
requireFx().tableName,
|
|
173
|
+
DEFAULT_SERVER_CONFIG,
|
|
174
|
+
);
|
|
164
175
|
expect(store).toBeUndefined();
|
|
165
176
|
});
|
|
166
177
|
|
|
167
178
|
it('returns undefined when the table is empty and serverConfig is undefined', async () => {
|
|
168
|
-
const store = await resolveAccountStore(
|
|
179
|
+
const store = await resolveAccountStore(requireFx().client, requireFx().tableName, undefined);
|
|
169
180
|
expect(store).toBeUndefined();
|
|
170
181
|
});
|
|
171
182
|
});
|