serverless-ircd 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +175 -0
- package/README.md +88 -19
- package/apps/aws-stack/README.md +4 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +32 -4
- package/apps/aws-stack/tests/stack.test.ts +47 -1
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-worker/package.json +1 -1
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +129 -21
- package/apps/local-cli/tests/e2e.test.ts +1 -1
- package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +63 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +11 -1
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
- package/packages/aws-adapter/tests/connect.test.ts +97 -1
- package/packages/aws-adapter/tests/handlers.test.ts +148 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +5 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +182 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +1 -1
- package/packages/irc-core/src/commands/index.ts +8 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +6 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +4 -8
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/config.ts +7 -0
- package/packages/irc-core/src/effects.ts +27 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +179 -0
- package/packages/irc-core/src/protocol/numerics.ts +42 -11
- package/packages/irc-core/src/protocol/outbound.ts +20 -3
- package/packages/irc-core/src/types.ts +8 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +57 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +249 -16
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +1089 -1
- package/packages/irc-server/tests/dispatch.test.ts +37 -0
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
|
|
15
15
|
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
16
16
|
import { PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
17
|
-
import
|
|
17
|
+
import {
|
|
18
|
+
type ParsedServerConfig,
|
|
19
|
+
type WsSubprotocol,
|
|
20
|
+
parseSecWsProtocolOffers,
|
|
21
|
+
selectSubprotocol,
|
|
22
|
+
wsFrameModeFor,
|
|
23
|
+
} from '@serverless-ircd/irc-core';
|
|
18
24
|
import { type AdmissionOutcome, decideConnectAdmission } from '../admission.js';
|
|
19
25
|
import { marshalConnection } from '../serialize.js';
|
|
20
26
|
import type { TablesConfig } from '../tables.js';
|
|
@@ -29,10 +35,24 @@ export interface ConnectParams {
|
|
|
29
35
|
now?: number;
|
|
30
36
|
/** Server config — consulted for the `maxClients` admission cap. */
|
|
31
37
|
serverConfig: ParsedServerConfig;
|
|
38
|
+
/**
|
|
39
|
+
* Raw `Sec-WebSocket-Protocol` header offered by the client on the
|
|
40
|
+
* `$connect` upgrade, if present. Parsed into an ordered offer list and
|
|
41
|
+
* the first supported IRCv3 subprotocol is agreed (legacy fallback when
|
|
42
|
+
* none match or the header is absent).
|
|
43
|
+
*/
|
|
44
|
+
secWebSocketProtocol?: string | null;
|
|
32
45
|
}
|
|
33
46
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Outcome of a `$connect`. Mirrors {@link AdmissionOutcome} on the rejection
|
|
49
|
+
* branch and carries the negotiated IRCv3 {@link WsSubprotocol} on success so
|
|
50
|
+
* the dispatcher can echo it back to the client in the integration response.
|
|
51
|
+
* `subprotocol === null` signals a legacy (no-subprotocol) connection.
|
|
52
|
+
*/
|
|
53
|
+
export type ConnectOutcome =
|
|
54
|
+
| { readonly admitted: true; readonly subprotocol: WsSubprotocol | null }
|
|
55
|
+
| { readonly admitted: false; readonly statusCode: number; readonly reason: string };
|
|
36
56
|
|
|
37
57
|
/**
|
|
38
58
|
* Enforces the `maxClients` admission cap, then inserts the Connections
|
|
@@ -58,20 +78,31 @@ export type { AdmissionOutcome as ConnectOutcome } from '../admission.js';
|
|
|
58
78
|
* into {@link decideConnectAdmission}'s `perIp` branch. That schema
|
|
59
79
|
* change is tracked as a follow-up; the pure policy already supports it.
|
|
60
80
|
*/
|
|
61
|
-
export async function handleConnect(params: ConnectParams): Promise<
|
|
81
|
+
export async function handleConnect(params: ConnectParams): Promise<ConnectOutcome> {
|
|
62
82
|
const now = params.now ?? Date.now();
|
|
63
83
|
const total = await countConnections(params.dynamo, params.tables.Connections);
|
|
64
84
|
const outcome = decideConnectAdmission({ total }, { maxClients: params.serverConfig.maxClients });
|
|
65
85
|
if (!outcome.admitted) return outcome;
|
|
86
|
+
// IRCv3 WebSocket subprotocol negotiation: parse the offered
|
|
87
|
+
// `Sec-WebSocket-Protocol` list, select the first supported entry, and
|
|
88
|
+
// persist the resulting frame mode so every subsequent `$default`
|
|
89
|
+
// invocation recovers it (a Lambda `$connect` has no live socket to
|
|
90
|
+
// tag, unlike the CF adapter's hibernation tags). `null` means no
|
|
91
|
+
// supported subprotocol was offered → legacy framing, no mode column.
|
|
92
|
+
const chosen = selectSubprotocol(parseSecWsProtocolOffers(params.secWebSocketProtocol));
|
|
93
|
+
const mode = wsFrameModeFor(chosen ?? undefined);
|
|
66
94
|
const state = createInitialConnectionState(params.connectionId, now);
|
|
67
95
|
const row = marshalConnection(state, now);
|
|
96
|
+
if (mode !== 'legacy') {
|
|
97
|
+
row.wsMode = mode;
|
|
98
|
+
}
|
|
68
99
|
await params.dynamo.send(
|
|
69
100
|
new PutCommand({
|
|
70
101
|
TableName: params.tables.Connections,
|
|
71
102
|
Item: row,
|
|
72
103
|
}),
|
|
73
104
|
);
|
|
74
|
-
return
|
|
105
|
+
return { admitted: true, subprotocol: chosen };
|
|
75
106
|
}
|
|
76
107
|
|
|
77
108
|
/**
|
|
@@ -22,9 +22,12 @@ import {
|
|
|
22
22
|
type MtlsIdentityProvider,
|
|
23
23
|
type NickHistoryStore,
|
|
24
24
|
type ParsedServerConfig,
|
|
25
|
+
type ServerConfig,
|
|
25
26
|
SystemClock,
|
|
26
27
|
UuidIdFactory,
|
|
28
|
+
type WsFrameMode,
|
|
27
29
|
createConnection,
|
|
30
|
+
frameToLines,
|
|
28
31
|
} from '@serverless-ircd/irc-core';
|
|
29
32
|
import { type ActorChannelAccess, ConnectionActor } from '@serverless-ircd/irc-server';
|
|
30
33
|
import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
|
|
@@ -33,8 +36,18 @@ import {
|
|
|
33
36
|
type MarshalledConnection,
|
|
34
37
|
unmarshalConnection,
|
|
35
38
|
} from '../serialize.js';
|
|
39
|
+
import { AwsStats } from '../stats.js';
|
|
36
40
|
import type { TablesConfig } from '../tables.js';
|
|
37
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Wall-clock timestamp captured at this Lambda isolate's first module load
|
|
44
|
+
* (cold start). Used as the `uptimeStartedAt` anchor for {@link AwsStats}
|
|
45
|
+
* so `STATS u` reports uptime for the current isolate. A deployment-wide
|
|
46
|
+
* uptime would require a persistent record (future work); for now each
|
|
47
|
+
* isolate reports its own.
|
|
48
|
+
*/
|
|
49
|
+
const LAMBDA_STARTUP_AT = Date.now();
|
|
50
|
+
|
|
38
51
|
/** Parameters accepted by {@link handleDefault}. */
|
|
39
52
|
export interface DefaultParams {
|
|
40
53
|
dynamo: DynamoDBDocumentClient;
|
|
@@ -86,6 +99,12 @@ export interface DefaultParams {
|
|
|
86
99
|
ids?: IdFactory;
|
|
87
100
|
/** Optional management endpoint URL used when constructing a default client. */
|
|
88
101
|
managementEndpoint?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Config-reload source for `REHASH`. When omitted the runtime's
|
|
104
|
+
* `reloadConfig` rejects so the actor emits a graceful error-suffixed
|
|
105
|
+
* `382` and retains the prior config.
|
|
106
|
+
*/
|
|
107
|
+
configLoader?: () => Promise<ServerConfig>;
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
/**
|
|
@@ -112,6 +131,12 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
112
131
|
const persisted = result.Item as unknown as MarshalledConnection;
|
|
113
132
|
const state = unmarshalConnection(persisted);
|
|
114
133
|
|
|
134
|
+
// Recover the IRCv3 WebSocket frame mode persisted at `$connect`.
|
|
135
|
+
// Absent = legacy (no subprotocol negotiated). Drives both the inbound
|
|
136
|
+
// line-framing transport (spec: one IRC message per frame, never split)
|
|
137
|
+
// and the outbound delivery shape (spec: one postToConnection per line).
|
|
138
|
+
const wsMode: WsFrameMode = persisted.wsMode ?? 'legacy';
|
|
139
|
+
|
|
115
140
|
// Mark this frame's activity.
|
|
116
141
|
state.lastSeen = clock.now();
|
|
117
142
|
|
|
@@ -141,6 +166,7 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
141
166
|
handlers,
|
|
142
167
|
managementApi: params.managementApi,
|
|
143
168
|
clock,
|
|
169
|
+
...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
|
|
144
170
|
});
|
|
145
171
|
|
|
146
172
|
const channelAccess = new LambdaChannelAccess(runtime, clock.now());
|
|
@@ -150,9 +176,20 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
150
176
|
runtime,
|
|
151
177
|
channels: channelAccess,
|
|
152
178
|
serverConfig: params.serverConfig,
|
|
179
|
+
configSource: 'Secrets Manager',
|
|
153
180
|
clock,
|
|
154
181
|
ids,
|
|
155
182
|
motd: params.motd,
|
|
183
|
+
transport: { feed: (chunk) => frameToLines(chunk, wsMode) },
|
|
184
|
+
// AwsStats Scans Connections + ChannelMeta for LUSERS/STATS.
|
|
185
|
+
// `uptimeStartedAt` is captured at the Lambda's cold start so `STATS u`
|
|
186
|
+
// reports per-isolate uptime; a deployment-wide uptime would require a
|
|
187
|
+
// persistent record (future work).
|
|
188
|
+
stats: new AwsStats({
|
|
189
|
+
dynamo: params.dynamo,
|
|
190
|
+
tables: params.tables,
|
|
191
|
+
uptimeStartedAt: LAMBDA_STARTUP_AT,
|
|
192
|
+
}),
|
|
156
193
|
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
157
194
|
...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
|
|
158
195
|
...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
|
|
@@ -172,13 +209,12 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
172
209
|
await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
|
|
173
210
|
|
|
174
211
|
// In production, deliver outbound bytes back to the caller via APIGW.
|
|
212
|
+
// Spec-mode connections (text/binary.ircv3.net) receive exactly one IRC
|
|
213
|
+
// line per `postToConnection` (no trailing CRLF); legacy connections
|
|
214
|
+
// receive every line CRLF-joined in a single message.
|
|
175
215
|
if (params.managementApi !== null && outbound.length > 0) {
|
|
176
|
-
const data = outbound.join('\r\n');
|
|
177
216
|
try {
|
|
178
|
-
await params.managementApi.
|
|
179
|
-
ConnectionId: params.connectionId,
|
|
180
|
-
Data: data,
|
|
181
|
-
});
|
|
217
|
+
await postOutbound(params.managementApi, params.connectionId, outbound, wsMode);
|
|
182
218
|
} catch {
|
|
183
219
|
// The handler still returns 200 — the actor's mutations are
|
|
184
220
|
// persisted; the caller just didn't see the reply this frame.
|
|
@@ -187,6 +223,28 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
187
223
|
return { statusCode: 200 };
|
|
188
224
|
}
|
|
189
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Delivers `lines` to `connectionId` via APIGW according to `mode`:
|
|
228
|
+
* - `legacy` — all lines joined with `\r\n` in a single `postToConnection`.
|
|
229
|
+
* - `spec-text` / `spec-binary` — one `postToConnection` per line, no
|
|
230
|
+
* trailing CRLF (the IRCv3 WebSocket contract: one IRC message per
|
|
231
|
+
* WebSocket message).
|
|
232
|
+
*/
|
|
233
|
+
async function postOutbound(
|
|
234
|
+
api: ApiGatewayManagementApi | PostToConnection,
|
|
235
|
+
connectionId: string,
|
|
236
|
+
lines: string[],
|
|
237
|
+
mode: WsFrameMode,
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
if (mode === 'legacy') {
|
|
240
|
+
await api.postToConnection({ ConnectionId: connectionId, Data: lines.join('\r\n') });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
for (const line of lines) {
|
|
244
|
+
await api.postToConnection({ ConnectionId: connectionId, Data: line });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
190
248
|
/**
|
|
191
249
|
* Writes the actor's mutated `state` back to the Connections row.
|
|
192
250
|
*
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type MotdProvider,
|
|
22
22
|
type NickHistoryStore,
|
|
23
23
|
type ParsedServerConfig,
|
|
24
|
+
type ServerConfig,
|
|
24
25
|
StaticMotdProvider,
|
|
25
26
|
SystemClock,
|
|
26
27
|
} from '@serverless-ircd/irc-core';
|
|
@@ -68,6 +69,12 @@ export interface WebSocketEvent {
|
|
|
68
69
|
*/
|
|
69
70
|
identity?: { sourceIp?: string };
|
|
70
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* Request headers. APIGW populates `event.headers` on every route; the
|
|
74
|
+
* `$connect` upgrade carries the client's `Sec-WebSocket-Protocol` offer
|
|
75
|
+
* here so the handler can negotiate the IRCv3 subprotocol.
|
|
76
|
+
*/
|
|
77
|
+
headers?: Record<string, string>;
|
|
71
78
|
body?: string;
|
|
72
79
|
}
|
|
73
80
|
|
|
@@ -75,6 +82,12 @@ export interface WebSocketEvent {
|
|
|
75
82
|
export interface LambdaResponse {
|
|
76
83
|
statusCode: number;
|
|
77
84
|
body?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Response headers. The `$connect` route sets `Sec-WebSocket-Protocol`
|
|
87
|
+
* to echo the agreed IRCv3 subprotocol back to the client; the CDK
|
|
88
|
+
* integration-response maps it onto the WebSocket handshake.
|
|
89
|
+
*/
|
|
90
|
+
headers?: Record<string, string>;
|
|
78
91
|
}
|
|
79
92
|
|
|
80
93
|
/** Injectable dependencies. Tests construct one of these to bypass env loading. */
|
|
@@ -105,13 +118,24 @@ export interface HandlerDeps {
|
|
|
105
118
|
* variant is a documented follow-up.
|
|
106
119
|
*/
|
|
107
120
|
history?: NickHistoryStore;
|
|
121
|
+
/**
|
|
122
|
+
* Config-reload source for `REHASH` — re-invokes the Lambda env config
|
|
123
|
+
* loader (Secrets Manager / SSM / env vars). Bound once per cold start;
|
|
124
|
+
* each `REHASH` re-reads the live env so rotated oper creds / MOTD take
|
|
125
|
+
* effect without a redeploy. When omitted the actor's graceful failure
|
|
126
|
+
* path applies (error-suffixed `382`).
|
|
127
|
+
*/
|
|
128
|
+
configLoader?: () => Promise<ServerConfig>;
|
|
108
129
|
}
|
|
109
130
|
|
|
110
131
|
/**
|
|
111
132
|
* Strongly-typed event discriminated by routeKey. Useful for tests that
|
|
112
133
|
* construct events directly.
|
|
113
134
|
*/
|
|
114
|
-
export type ConnectEvent = {
|
|
135
|
+
export type ConnectEvent = {
|
|
136
|
+
requestContext: { routeKey: '$connect'; connectionId: string };
|
|
137
|
+
headers?: Record<string, string>;
|
|
138
|
+
};
|
|
115
139
|
export type DisconnectEvent = {
|
|
116
140
|
requestContext: { routeKey: '$disconnect'; connectionId: string };
|
|
117
141
|
};
|
|
@@ -162,8 +186,17 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
|
|
|
162
186
|
tables: deps.tables,
|
|
163
187
|
connectionId: connId,
|
|
164
188
|
serverConfig: deps.serverConfig,
|
|
189
|
+
secWebSocketProtocol: event.headers?.['Sec-WebSocket-Protocol'] ?? null,
|
|
165
190
|
});
|
|
166
|
-
if (outcome.admitted)
|
|
191
|
+
if (outcome.admitted) {
|
|
192
|
+
if (outcome.subprotocol !== null) {
|
|
193
|
+
return {
|
|
194
|
+
statusCode: 200,
|
|
195
|
+
headers: { 'Sec-WebSocket-Protocol': outcome.subprotocol },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return { statusCode: 200 };
|
|
199
|
+
}
|
|
167
200
|
return { statusCode: outcome.statusCode, body: outcome.reason };
|
|
168
201
|
}
|
|
169
202
|
|
|
@@ -192,6 +225,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
|
|
|
192
225
|
messages: deps.messages,
|
|
193
226
|
...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
|
|
194
227
|
...(deps.history !== undefined ? { history: deps.history } : {}),
|
|
228
|
+
...(deps.configLoader !== undefined ? { configLoader: deps.configLoader } : {}),
|
|
195
229
|
managementApi: deps.managementApi,
|
|
196
230
|
});
|
|
197
231
|
return result;
|
|
@@ -333,6 +367,10 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
|
|
|
333
367
|
const messages = bindMessageStore(cfg);
|
|
334
368
|
const history = new InMemoryNickHistoryStore(SystemClock);
|
|
335
369
|
const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
|
|
370
|
+
// REHASH reload source: re-read the live env so a rotated oper password
|
|
371
|
+
// or MOTD takes effect on the next REHASH without a redeploy.
|
|
372
|
+
const configLoader = async (): Promise<ServerConfig> =>
|
|
373
|
+
loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
|
|
336
374
|
return {
|
|
337
375
|
dynamo,
|
|
338
376
|
tables,
|
|
@@ -341,6 +379,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
|
|
|
341
379
|
managementApi,
|
|
342
380
|
messages,
|
|
343
381
|
history,
|
|
382
|
+
configLoader,
|
|
344
383
|
...(accounts !== undefined ? { accounts } : {}),
|
|
345
384
|
};
|
|
346
385
|
}
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
type MotdProvider,
|
|
54
54
|
type NickHistoryStore,
|
|
55
55
|
type ParsedServerConfig,
|
|
56
|
+
type ServerConfig,
|
|
56
57
|
SystemClock,
|
|
57
58
|
UuidIdFactory,
|
|
58
59
|
} from '@serverless-ircd/irc-core';
|
|
@@ -151,6 +152,12 @@ export interface NlbStreamParams {
|
|
|
151
152
|
clock?: Clock;
|
|
152
153
|
/** Injected for tests; defaults to {@link UuidIdFactory}. */
|
|
153
154
|
ids?: IdFactory;
|
|
155
|
+
/**
|
|
156
|
+
* Config-reload source for `REHASH` (same as the wss `$default` path).
|
|
157
|
+
* When omitted the runtime's `reloadConfig` rejects so the actor emits a
|
|
158
|
+
* graceful error-suffixed `382`.
|
|
159
|
+
*/
|
|
160
|
+
configLoader?: () => Promise<ServerConfig>;
|
|
154
161
|
}
|
|
155
162
|
|
|
156
163
|
/**
|
|
@@ -225,6 +232,7 @@ export async function handleNlbStream(
|
|
|
225
232
|
handlers,
|
|
226
233
|
managementApi: params.managementApi,
|
|
227
234
|
clock,
|
|
235
|
+
...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
|
|
228
236
|
});
|
|
229
237
|
|
|
230
238
|
const channelAccess = new NlbChannelAccess(runtime, now);
|
|
@@ -234,6 +242,7 @@ export async function handleNlbStream(
|
|
|
234
242
|
runtime,
|
|
235
243
|
channels: channelAccess,
|
|
236
244
|
serverConfig: params.serverConfig,
|
|
245
|
+
configSource: 'Secrets Manager',
|
|
237
246
|
clock,
|
|
238
247
|
ids,
|
|
239
248
|
motd: params.motd,
|
|
@@ -23,6 +23,8 @@ export {
|
|
|
23
23
|
loadDynamoAccountStore,
|
|
24
24
|
} from './dynamo-account-store.js';
|
|
25
25
|
export { bindAccountStore, putAccountCredential, resolveAccountStore } from './account-store.js';
|
|
26
|
+
export { AwsStats } from './stats.js';
|
|
27
|
+
export type { AwsStatsOptions } from './stats.js';
|
|
26
28
|
export {
|
|
27
29
|
loadServerConfigFromLambdaEnv,
|
|
28
30
|
type LambdaConfigEnv,
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
RegistrationState,
|
|
31
31
|
RosterEntry,
|
|
32
32
|
UserModes,
|
|
33
|
+
WsFrameMode,
|
|
33
34
|
} from '@serverless-ircd/irc-core';
|
|
34
35
|
|
|
35
36
|
// ---------------------------------------------------------------------------
|
|
@@ -76,6 +77,15 @@ export interface MarshalledConnection {
|
|
|
76
77
|
away?: string;
|
|
77
78
|
saslMech?: string;
|
|
78
79
|
saslBuffer?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Negotiated IRCv3 WebSocket frame mode persisted at `$connect` time.
|
|
82
|
+
* Absent (`undefined`) on legacy connections (no subprotocol agreed) so
|
|
83
|
+
* the DynamoDB column is null by default — mirroring the CF adapter's
|
|
84
|
+
* hibernation-tag default. Set once and never mutated: `$default` uses
|
|
85
|
+
* `UpdateCommand` for its partial writes, so this field survives every
|
|
86
|
+
* subsequent frame.
|
|
87
|
+
*/
|
|
88
|
+
wsMode?: WsFrameMode;
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
/**
|
|
@@ -118,7 +128,7 @@ export function marshalConnection(state: ConnectionState, idleSince: number): Ma
|
|
|
118
128
|
/**
|
|
119
129
|
* Materialises a {@link ConnectionState} from a stored row. Rehydrates
|
|
120
130
|
* `caps` and `joinedChannels` into Sets. Throws on unrecognised
|
|
121
|
-
* `version` values (forward migrations
|
|
131
|
+
* `version` values (forward migrations are not yet implemented).
|
|
122
132
|
*/
|
|
123
133
|
export function unmarshalConnection(row: MarshalledConnection): ConnectionState {
|
|
124
134
|
if (row.version > CONNECTION_VERSION) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS-flavoured {@link ServerStats} backend.
|
|
3
|
+
*
|
|
4
|
+
* Aggregates the network-wide counts the `LUSERS` / `STATS` reducers need
|
|
5
|
+
* directly from DynamoDB: a single `Scan` over `Connections` (the source
|
|
6
|
+
* of truth for every live connection) and one over `ChannelMeta` (one row
|
|
7
|
+
* per channel). Classification (oper / invisible / unknown) is delegated
|
|
8
|
+
* to the shared {@link computeStatsSnapshot} helper in irc-core so the
|
|
9
|
+
* classification rules live in exactly one place.
|
|
10
|
+
*
|
|
11
|
+
* Constructed per `LUSERS` / `STATS` invocation alongside the
|
|
12
|
+
* {@link AwsRuntime}; cheap to build, no caching. `uptimeStartedAt` is
|
|
13
|
+
* supplied by the caller (typically the Lambda's cold-start timestamp,
|
|
14
|
+
* stashed in module scope at first import).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
18
|
+
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
19
|
+
import { type ServerStatsSnapshot, computeStatsSnapshot } from '@serverless-ircd/irc-core';
|
|
20
|
+
import type { MarshalledConnection } from './serialize.js';
|
|
21
|
+
import { unmarshalConnection } from './serialize.js';
|
|
22
|
+
import type { TablesConfig } from './tables.js';
|
|
23
|
+
|
|
24
|
+
export interface AwsStatsOptions {
|
|
25
|
+
/** DynamoDB DocumentClient (the same client used by {@link AwsRuntime}). */
|
|
26
|
+
readonly dynamo: DynamoDBDocumentClient;
|
|
27
|
+
/** Logical→physical table-name map. */
|
|
28
|
+
readonly tables: TablesConfig;
|
|
29
|
+
/** Epoch-ms the deployment counts uptime from (Lambda cold start). */
|
|
30
|
+
readonly uptimeStartedAt: number;
|
|
31
|
+
/**
|
|
32
|
+
* Optional high-water marks for `265 RPL_LOCALUSERS` / `266
|
|
33
|
+
* RPL_GLOBALUSERS`. When omitted the live count is reported as the max
|
|
34
|
+
* (so the wire numeric never claims a max below the current count).
|
|
35
|
+
*/
|
|
36
|
+
readonly maxLocalConns?: number;
|
|
37
|
+
readonly maxGlobalConns?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class AwsStats {
|
|
41
|
+
private readonly dynamo: DynamoDBDocumentClient;
|
|
42
|
+
private readonly tables: TablesConfig;
|
|
43
|
+
private readonly uptimeStartedAt: number;
|
|
44
|
+
private readonly maxLocalConns: number | undefined;
|
|
45
|
+
private readonly maxGlobalConns: number | undefined;
|
|
46
|
+
|
|
47
|
+
constructor(opts: AwsStatsOptions) {
|
|
48
|
+
this.dynamo = opts.dynamo;
|
|
49
|
+
this.tables = opts.tables;
|
|
50
|
+
this.uptimeStartedAt = opts.uptimeStartedAt;
|
|
51
|
+
this.maxLocalConns = opts.maxLocalConns;
|
|
52
|
+
this.maxGlobalConns = opts.maxGlobalConns;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async getStats(): Promise<ServerStatsSnapshot> {
|
|
56
|
+
// Scan Connections in a single request. DynamoDB Local + production
|
|
57
|
+
// both paginate at 1MB; for the serverless-IRC scale (≤ tens of
|
|
58
|
+
// thousands of connections per deployment) a single page covers the
|
|
59
|
+
// realistic ceiling. A deployment that outgrows this should maintain
|
|
60
|
+
// rolling count records (updated on connect/disconnect) rather than
|
|
61
|
+
// Scan — a tracked follow-up.
|
|
62
|
+
const connResult = await this.dynamo.send(
|
|
63
|
+
new ScanCommand({ TableName: this.tables.Connections }),
|
|
64
|
+
);
|
|
65
|
+
const connItems = (connResult.Items ?? []) as unknown as MarshalledConnection[];
|
|
66
|
+
const connections = connItems.map(unmarshalConnection);
|
|
67
|
+
|
|
68
|
+
// Channel count: one row per channel in `ChannelMeta`, again in a
|
|
69
|
+
// single page (channel counts are small).
|
|
70
|
+
const chanResult = await this.dynamo.send(
|
|
71
|
+
new ScanCommand({ TableName: this.tables.ChannelMeta, Select: 'COUNT' }),
|
|
72
|
+
);
|
|
73
|
+
const channelCount = chanResult.Count ?? 0;
|
|
74
|
+
|
|
75
|
+
return computeStatsSnapshot(connections, channelCount, this.uptimeStartedAt, {
|
|
76
|
+
...(this.maxLocalConns !== undefined ? { maxLocalConns: this.maxLocalConns } : {}),
|
|
77
|
+
...(this.maxGlobalConns !== undefined ? { maxGlobalConns: this.maxGlobalConns } : {}),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Gated on `process.env.DDB_AVAILABLE`: the suite is skipped when
|
|
10
10
|
* DynamoDB Local cannot be booted (no Docker, no Java). When available,
|
|
11
|
-
* every scenario MUST pass — that is the
|
|
11
|
+
* every scenario MUST pass — that is the acceptance criterion.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type Clock,
|
|
13
13
|
type ConnectionState,
|
|
14
14
|
type RawLine,
|
|
15
|
+
type ServerConfig,
|
|
15
16
|
createConnection,
|
|
16
17
|
} from '@serverless-ircd/irc-core';
|
|
17
18
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
@@ -652,3 +653,63 @@ function handlersSink(sink: string[], snapshot?: ConnectionState): AwsRuntimeHan
|
|
|
652
653
|
snapshot: () => snapshot,
|
|
653
654
|
};
|
|
654
655
|
}
|
|
656
|
+
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
// reloadConfig (REHASH) — no DynamoDB access, so this group runs unconditionally.
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
describe('AwsRuntime — reloadConfig (REHASH)', () => {
|
|
662
|
+
const baseConfig: ServerConfig = {
|
|
663
|
+
serverName: 'irc.example.com',
|
|
664
|
+
networkName: 'ExampleNet',
|
|
665
|
+
maxChannelsPerUser: 30,
|
|
666
|
+
maxTargetsPerCommand: 10,
|
|
667
|
+
maxListEntries: 50,
|
|
668
|
+
nickLen: 30,
|
|
669
|
+
channelLen: 50,
|
|
670
|
+
topicLen: 390,
|
|
671
|
+
quitMessage: 'Client Quit',
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
function makeReloadRuntime(loader?: () => Promise<ServerConfig>): AwsRuntime {
|
|
675
|
+
return new AwsRuntime({
|
|
676
|
+
// reloadConfig never touches DynamoDB; a default client + empty tables
|
|
677
|
+
// suffice for the no-IO reload path.
|
|
678
|
+
dynamo: createDynamoDocumentClient({}),
|
|
679
|
+
tables: {} as TablesConfig,
|
|
680
|
+
connId: 'c1',
|
|
681
|
+
handlers: noopHandlers(),
|
|
682
|
+
managementApi: null,
|
|
683
|
+
...(loader !== undefined ? { configLoader: loader } : {}),
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
it('re-invokes the bound loader and returns the fresh config', async () => {
|
|
688
|
+
let password = 'old';
|
|
689
|
+
const rt = makeReloadRuntime(async () => ({
|
|
690
|
+
...baseConfig,
|
|
691
|
+
operCreds: [{ user: 'admin', password }],
|
|
692
|
+
}));
|
|
693
|
+
|
|
694
|
+
const first = await rt.reloadConfig();
|
|
695
|
+
expect(first.operCreds).toEqual([{ user: 'admin', password: 'old' }]);
|
|
696
|
+
|
|
697
|
+
password = 'rotated';
|
|
698
|
+
const second = await rt.reloadConfig();
|
|
699
|
+
expect(second.operCreds).toEqual([{ user: 'admin', password: 'rotated' }]);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it('propagates a loader rejection so the actor applies the graceful 382', async () => {
|
|
703
|
+
const rt = makeReloadRuntime(async () => {
|
|
704
|
+
throw new Error('Secrets Manager unreachable');
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
await expect(rt.reloadConfig()).rejects.toThrow('Secrets Manager unreachable');
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('rejects when no loader was bound at construction', async () => {
|
|
711
|
+
const rt = makeReloadRuntime();
|
|
712
|
+
|
|
713
|
+
await expect(rt.reloadConfig()).rejects.toThrow('no config loader bound');
|
|
714
|
+
});
|
|
715
|
+
});
|