serverless-ircd 0.7.0 → 0.9.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 +7 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +518 -29
- package/README.md +221 -161
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +186 -18
- package/apps/aws-stack/tests/stack.test.ts +400 -56
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/container-server.ts +21 -1
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-worker/package.json +1 -1
- package/apps/cf-worker/src/worker.ts +4 -4
- package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
- package/apps/cf-worker/tests/smoke.test.ts +5 -5
- package/apps/cf-worker/wrangler.test.toml +6 -6
- package/apps/cf-worker/wrangler.toml +7 -5
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +8 -0
- package/apps/local-cli/src/main.ts +16 -0
- package/apps/local-cli/src/server.ts +95 -31
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/index.html +237 -16
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +46 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/tests/build-smoke.test.ts +43 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +21 -8
- package/docs/Cloudflare-Deployment-Guide.md +22 -6
- package/docs/PlanExtensions.md +113 -3
- package/docs/Release-Process.md +23 -13
- package/docs/Services.md +578 -0
- package/docs/WebClientGuide.md +32 -31
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +20 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
- package/packages/aws-adapter/src/config-loader.ts +11 -0
- package/packages/aws-adapter/src/connection-counter.ts +89 -0
- package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
- package/packages/aws-adapter/src/handlers/connect.ts +55 -51
- package/packages/aws-adapter/src/handlers/default.ts +36 -4
- package/packages/aws-adapter/src/handlers/index.ts +15 -0
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
- package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
- package/packages/aws-adapter/src/index.ts +4 -0
- package/packages/aws-adapter/src/stats.ts +6 -1
- package/packages/aws-adapter/src/tables.ts +34 -4
- package/packages/aws-adapter/tests/aws-harness.ts +3 -0
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
- package/packages/aws-adapter/tests/connect.test.ts +158 -32
- package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
- package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +105 -3
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
- package/packages/aws-adapter/tests/tables.test.ts +6 -1
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/config-loader.ts +11 -0
- package/packages/cf-adapter/src/connection-do.ts +130 -8
- package/packages/cf-adapter/src/d1-services-store.ts +703 -0
- package/packages/cf-adapter/src/env.ts +8 -0
- package/packages/cf-adapter/src/index.ts +5 -0
- package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
- package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
- package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
- package/packages/cf-adapter/tests/serialize.test.ts +1 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
- package/packages/irc-core/src/commands/account-auth.ts +200 -0
- package/packages/irc-core/src/commands/chanserv.ts +1166 -0
- package/packages/irc-core/src/commands/hostserv.ts +522 -0
- package/packages/irc-core/src/commands/index.ts +13 -0
- package/packages/irc-core/src/commands/join.ts +171 -9
- package/packages/irc-core/src/commands/markread.ts +202 -0
- package/packages/irc-core/src/commands/memoserv.ts +319 -0
- package/packages/irc-core/src/commands/mode.ts +96 -4
- package/packages/irc-core/src/commands/nickserv.ts +402 -0
- package/packages/irc-core/src/commands/oper.ts +18 -1
- package/packages/irc-core/src/commands/operserv.ts +346 -0
- package/packages/irc-core/src/commands/pre-away.ts +3 -1
- package/packages/irc-core/src/commands/privmsg.ts +42 -0
- package/packages/irc-core/src/commands/read-marker.ts +8 -8
- package/packages/irc-core/src/commands/registration.ts +78 -12
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +60 -10
- package/packages/irc-core/src/config.ts +36 -5
- package/packages/irc-core/src/effects.ts +56 -1
- package/packages/irc-core/src/ports.ts +1653 -84
- package/packages/irc-core/src/protocol/numerics.ts +8 -0
- package/packages/irc-core/src/state/channel.ts +38 -1
- package/packages/irc-core/src/state/connection.ts +25 -1
- package/packages/irc-core/src/types.ts +48 -12
- package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
- package/packages/irc-core/tests/commands/join.test.ts +572 -1
- package/packages/irc-core/tests/commands/markread.test.ts +361 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
- package/packages/irc-core/tests/commands/mode.test.ts +381 -2
- package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
- package/packages/irc-core/tests/commands/oper.test.ts +13 -0
- package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
- package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
- package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
- package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
- package/packages/irc-core/tests/commands/topic.test.ts +94 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
- package/packages/irc-core/tests/config.test.ts +49 -5
- package/packages/irc-core/tests/effects.test.ts +19 -0
- package/packages/irc-core/tests/message-store.test.ts +63 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
- package/packages/irc-core/tests/services-store.test.ts +1289 -0
- package/packages/irc-core/tests/state/channel.test.ts +3 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +121 -16
- package/packages/irc-server/src/dispatch.ts +94 -7
- package/packages/irc-server/src/routing.ts +19 -0
- package/packages/irc-server/tests/actor.test.ts +988 -15
- package/packages/irc-server/tests/dispatch.test.ts +270 -2
- package/packages/irc-server/tests/routing.test.ts +6 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
- package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
|
@@ -13,15 +13,17 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
16
|
-
import { PutCommand
|
|
16
|
+
import { PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
17
17
|
import {
|
|
18
18
|
type ParsedServerConfig,
|
|
19
|
+
WS_SUBPROTO_BINARY,
|
|
19
20
|
type WsSubprotocol,
|
|
20
21
|
parseSecWsProtocolOffers,
|
|
21
22
|
selectSubprotocol,
|
|
22
23
|
wsFrameModeFor,
|
|
23
24
|
} from '@serverless-ircd/irc-core';
|
|
24
25
|
import { type AdmissionOutcome, decideConnectAdmission } from '../admission.js';
|
|
26
|
+
import { decrementConnectionCount, incrementConnectionCount } from '../connection-counter.js';
|
|
25
27
|
import { marshalConnection } from '../serialize.js';
|
|
26
28
|
import type { TablesConfig } from '../tables.js';
|
|
27
29
|
import { createInitialConnectionState } from './state.js';
|
|
@@ -58,74 +60,76 @@ export type ConnectOutcome =
|
|
|
58
60
|
* Enforces the `maxClients` admission cap, then inserts the Connections
|
|
59
61
|
* row for a fresh WebSocket upgrade.
|
|
60
62
|
*
|
|
63
|
+
* Admission is O(1): instead of a full-table `Scan COUNT` (which made
|
|
64
|
+
* `$connect` latency scale with the live connection count), the live count
|
|
65
|
+
* is kept in a single meta row and atomically incremented here. The
|
|
66
|
+
* reservation is rolled back if admission rejects or the row write fails,
|
|
67
|
+
* so the counter stays in sync with the real connection set across
|
|
68
|
+
* `$connect` / `$disconnect` / the gone-connection sweeper.
|
|
69
|
+
*
|
|
61
70
|
* Idempotent on success: APIGW retries `$connect` on transient failures,
|
|
62
71
|
* so the write uses `PutCommand` (overwrites) rather than a conditional
|
|
63
|
-
* insert.
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* so the count-then-put sequence has a race window. This is an accepted
|
|
67
|
-
* trade-off for an admission cap — it bounds growth under steady load
|
|
68
|
-
* rather than enforcing a hard invariant. Two concurrent upgrades that
|
|
69
|
-
* both observe `count == cap - 1` will both be admitted.
|
|
72
|
+
* insert. A retry re-runs the increment; the matching decrement fires when
|
|
73
|
+
* the connection eventually tears down, so the long-run counter stays
|
|
74
|
+
* correct (each live connection contributes exactly one reserved slot).
|
|
70
75
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* cost
|
|
76
|
-
* The correct fix is to persist `sourceIp` on the Connections row and
|
|
77
|
-
* add a DynamoDB GSI keyed by `sourceIp`, then feed the GSI's count
|
|
78
|
-
* into {@link decideConnectAdmission}'s `perIp` branch. That schema
|
|
79
|
-
* change is tracked as a follow-up; the pure policy already supports it.
|
|
76
|
+
* TOCTOU note: two concurrent `$connect` invocations can both reserve a
|
|
77
|
+
* slot before either decrements; the cap is therefore best-effort (it
|
|
78
|
+
* bounds growth under steady load rather than enforcing a hard invariant).
|
|
79
|
+
* This is no worse than the prior count-then-put sequence, and the
|
|
80
|
+
* per-connection cost dropped from O(connections) to O(1).
|
|
80
81
|
*/
|
|
81
82
|
export async function handleConnect(params: ConnectParams): Promise<ConnectOutcome> {
|
|
82
83
|
const now = params.now ?? Date.now();
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
const maxClients = params.serverConfig.maxClients;
|
|
85
|
+
|
|
86
|
+
// Reserve a slot atomically. The returned value includes this reservation,
|
|
87
|
+
// so the pre-existing count (`total`, as expected by the policy) is one
|
|
88
|
+
// less than the post-increment value.
|
|
89
|
+
const reservedCount = await incrementConnectionCount(params.dynamo, params.tables.Connections);
|
|
90
|
+
const outcome = decideConnectAdmission({ total: reservedCount - 1 }, { maxClients });
|
|
91
|
+
if (!outcome.admitted) {
|
|
92
|
+
// Over capacity — release the reservation so the counter does not drift
|
|
93
|
+
// upward on every rejected connect.
|
|
94
|
+
await decrementConnectionCount(params.dynamo, params.tables.Connections);
|
|
95
|
+
return outcome;
|
|
96
|
+
}
|
|
86
97
|
// IRCv3 WebSocket subprotocol negotiation: parse the offered
|
|
87
98
|
// `Sec-WebSocket-Protocol` list, select the first supported entry, and
|
|
88
99
|
// persist the resulting frame mode so every subsequent `$default`
|
|
89
100
|
// invocation recovers it (a Lambda `$connect` has no live socket to
|
|
90
101
|
// tag, unlike the CF adapter's hibernation tags). `null` means no
|
|
91
102
|
// supported subprotocol was offered → legacy framing, no mode column.
|
|
92
|
-
|
|
103
|
+
//
|
|
104
|
+
// API Gateway carries TEXT WebSocket frames only — binary frames are
|
|
105
|
+
// unsupported, and agreeing to `binary.ircv3.net` makes APIGW close the
|
|
106
|
+
// connection with code 1003 ("Binary is not supported") the instant it
|
|
107
|
+
// is negotiated. The IRCv3 relay (tcp-ws-forwarder) offers binary first
|
|
108
|
+
// by preference, so the generic core selector would agree to binary and
|
|
109
|
+
// the connection is torn down immediately. Strip binary from the offer
|
|
110
|
+
// list before selecting so only `text.ircv3.net` (or legacy fallback
|
|
111
|
+
// when only binary was offered) is ever negotiated on this transport.
|
|
112
|
+
const chosen = selectSubprotocol(
|
|
113
|
+
parseSecWsProtocolOffers(params.secWebSocketProtocol).filter((o) => o !== WS_SUBPROTO_BINARY),
|
|
114
|
+
);
|
|
93
115
|
const mode = wsFrameModeFor(chosen ?? undefined);
|
|
94
116
|
const state = createInitialConnectionState(params.connectionId, now);
|
|
95
117
|
const row = marshalConnection(state, now);
|
|
96
118
|
if (mode !== 'legacy') {
|
|
97
119
|
row.wsMode = mode;
|
|
98
120
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
);
|
|
105
|
-
return { admitted: true, subprotocol: chosen };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Paginated `Scan` with `Select: COUNT` over the `Connections` table.
|
|
110
|
-
* A single Scan page caps at 1MB, so the loop follows
|
|
111
|
-
* `LastEvaluatedKey` until every page has been summed.
|
|
112
|
-
*/
|
|
113
|
-
async function countConnections(
|
|
114
|
-
dynamo: DynamoDBDocumentClient,
|
|
115
|
-
tableName: string,
|
|
116
|
-
): Promise<number> {
|
|
117
|
-
let total = 0;
|
|
118
|
-
let lastKey: unknown = undefined;
|
|
119
|
-
do {
|
|
120
|
-
const res = await dynamo.send(
|
|
121
|
-
new ScanCommand({
|
|
122
|
-
TableName: tableName,
|
|
123
|
-
Select: 'COUNT',
|
|
124
|
-
...(lastKey !== undefined ? { ExclusiveStartKey: lastKey as never } : {}),
|
|
121
|
+
try {
|
|
122
|
+
await params.dynamo.send(
|
|
123
|
+
new PutCommand({
|
|
124
|
+
TableName: params.tables.Connections,
|
|
125
|
+
Item: row,
|
|
125
126
|
}),
|
|
126
127
|
);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
} catch (err) {
|
|
129
|
+
// Row write failed — release the reservation so a failed `Put` does not
|
|
130
|
+
// leave an inflated counter that tightens the cap on every retry.
|
|
131
|
+
await decrementConnectionCount(params.dynamo, params.tables.Connections);
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
return { admitted: true, subprotocol: chosen };
|
|
131
135
|
}
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from '@serverless-ircd/irc-core';
|
|
32
32
|
import { type ActorChannelAccess, ConnectionActor } from '@serverless-ircd/irc-server';
|
|
33
33
|
import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
|
|
34
|
+
import type { DynamoServicesStore } from '../dynamo-services-store.js';
|
|
34
35
|
import {
|
|
35
36
|
CONNECTION_VERSION,
|
|
36
37
|
type MarshalledConnection,
|
|
@@ -68,6 +69,15 @@ export interface DefaultParams {
|
|
|
68
69
|
* — preserves the behaviour of callers that have not opted in.
|
|
69
70
|
*/
|
|
70
71
|
accounts?: AccountStore;
|
|
72
|
+
/**
|
|
73
|
+
* Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
|
|
74
|
+
* OperServ + read-marker). When omitted the actor runs with
|
|
75
|
+
* `ctx.services` undefined so `PRIVMSG NickServ` / ChanServ routing is
|
|
76
|
+
* disabled — preserves the behaviour of callers that have not opted in.
|
|
77
|
+
* On success the handler flushes pending write-behind ops after the
|
|
78
|
+
* actor run.
|
|
79
|
+
*/
|
|
80
|
+
services?: DynamoServicesStore;
|
|
71
81
|
/**
|
|
72
82
|
* mTLS client-cert identity source for SASL EXTERNAL. When omitted the
|
|
73
83
|
* actor runs with `ctx.mtlsIdentity` undefined so `AUTHENTICATE EXTERNAL`
|
|
@@ -118,14 +128,25 @@ export interface DefaultParams {
|
|
|
118
128
|
export async function handleDefault(params: DefaultParams): Promise<{ statusCode: number }> {
|
|
119
129
|
const clock = params.clock ?? SystemClock;
|
|
120
130
|
const ids = params.ids ?? new UuidIdFactory();
|
|
121
|
-
// Load persisted state.
|
|
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.
|
|
122
136
|
const result = await params.dynamo.send(
|
|
123
137
|
new GetCommand({
|
|
124
138
|
TableName: params.tables.Connections,
|
|
125
139
|
Key: { connectionId: params.connectionId },
|
|
140
|
+
ConsistentRead: true,
|
|
126
141
|
}),
|
|
127
142
|
);
|
|
128
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
|
+
);
|
|
129
150
|
return { statusCode: 410 };
|
|
130
151
|
}
|
|
131
152
|
const persisted = result.Item as unknown as MarshalledConnection;
|
|
@@ -192,6 +213,7 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
192
213
|
}),
|
|
193
214
|
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
194
215
|
...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
|
|
216
|
+
...(params.services !== undefined ? { services: params.services } : {}),
|
|
195
217
|
...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
|
|
196
218
|
...(params.history !== undefined ? { history: params.history } : {}),
|
|
197
219
|
// API Gateway terminates TLS before the Lambda is invoked, so every
|
|
@@ -205,11 +227,15 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
205
227
|
console.error('[irc-handler] actor.receiveTextFrame failed', err);
|
|
206
228
|
// Still persist the state — partial mutations are valuable for debug.
|
|
207
229
|
await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
|
|
230
|
+
await params.services?.flush();
|
|
208
231
|
return { statusCode: 500 };
|
|
209
232
|
}
|
|
210
233
|
|
|
211
234
|
// Persist the mutated state and flush outbound bytes.
|
|
212
235
|
await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
|
|
236
|
+
// Drain any services write-behind ops (NickServ/ChanServ/...) so a
|
|
237
|
+
// Lambda freeze / evict does not lose state.
|
|
238
|
+
await params.services?.flush();
|
|
213
239
|
|
|
214
240
|
// In production, deliver outbound bytes back to the caller via APIGW.
|
|
215
241
|
// Spec-mode connections (text/binary.ircv3.net) receive exactly one IRC
|
|
@@ -218,9 +244,12 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
|
|
|
218
244
|
if (params.managementApi !== null && outbound.length > 0) {
|
|
219
245
|
try {
|
|
220
246
|
await postOutbound(params.managementApi, params.connectionId, outbound, wsMode);
|
|
221
|
-
} catch {
|
|
222
|
-
//
|
|
223
|
-
//
|
|
247
|
+
} catch (err) {
|
|
248
|
+
// Surface the failure (gone connection, stale MANAGEMENT_URL, …)
|
|
249
|
+
// so it shows in CloudWatch instead of vanishing. The handler still
|
|
250
|
+
// returns 200 — the actor's mutations are persisted; the caller just
|
|
251
|
+
// didn't see the reply this frame.
|
|
252
|
+
console.error('[irc-handler] postToConnection failed', err);
|
|
224
253
|
}
|
|
225
254
|
}
|
|
226
255
|
return { statusCode: 200 };
|
|
@@ -382,6 +411,9 @@ class LambdaChannelAccess implements ActorChannelAccess {
|
|
|
382
411
|
moderated: false,
|
|
383
412
|
secret: false,
|
|
384
413
|
private: false,
|
|
414
|
+
registered: false,
|
|
415
|
+
blockUnidentified: false,
|
|
416
|
+
moderatedIdent: false,
|
|
385
417
|
},
|
|
386
418
|
banMasks: new Set<string>(),
|
|
387
419
|
members: new Map(),
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from '@serverless-ircd/irc-core';
|
|
28
28
|
import { resolveAccountStore } from '../account-store.js';
|
|
29
29
|
import { type LambdaConfigEnv, loadServerConfigFromLambdaEnv } from '../config-loader.js';
|
|
30
|
+
import { type DynamoServicesStore, loadDynamoServicesStore } from '../dynamo-services-store.js';
|
|
30
31
|
import { createDynamoDocumentClient } from '../dynamo.js';
|
|
31
32
|
import { bindMessageStore } from '../message-store.js';
|
|
32
33
|
import { TABLE_KEYS, type TablesConfig } from '../tables.js';
|
|
@@ -111,6 +112,15 @@ export interface HandlerDeps {
|
|
|
111
112
|
* configured (the default) so `ctx.accounts` stays unset.
|
|
112
113
|
*/
|
|
113
114
|
accounts?: AccountStore;
|
|
115
|
+
/**
|
|
116
|
+
* Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
|
|
117
|
+
* OperServ + read-marker), hydrated once per cold start from the
|
|
118
|
+
* `Services` DynamoDB table into a write-behind cache. `undefined` when
|
|
119
|
+
* `SERVICES_TABLE` is unset (services unbound → no NickServ/ChanServ
|
|
120
|
+
* routing). The handler flushes pending writes after each `$default` /
|
|
121
|
+
* NLB frame so a Lambda freeze / evict does not lose services state.
|
|
122
|
+
*/
|
|
123
|
+
services?: DynamoServicesStore;
|
|
114
124
|
/**
|
|
115
125
|
* Nick-history source bound to every actor. Constructed once per cold
|
|
116
126
|
* start (memoised via {@link cachedDeps}) as an in-memory store so it
|
|
@@ -224,6 +234,7 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
|
|
|
224
234
|
motd: deps.motd,
|
|
225
235
|
messages: deps.messages,
|
|
226
236
|
...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
|
|
237
|
+
...(deps.services !== undefined ? { services: deps.services } : {}),
|
|
227
238
|
...(deps.history !== undefined ? { history: deps.history } : {}),
|
|
228
239
|
...(deps.configLoader !== undefined ? { configLoader: deps.configLoader } : {}),
|
|
229
240
|
managementApi: deps.managementApi,
|
|
@@ -331,6 +342,7 @@ export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStream
|
|
|
331
342
|
messages: deps.messages,
|
|
332
343
|
managementApi: deps.managementApi,
|
|
333
344
|
...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
|
|
345
|
+
...(deps.services !== undefined ? { services: deps.services } : {}),
|
|
334
346
|
...(deps.history !== undefined ? { history: deps.history } : {}),
|
|
335
347
|
});
|
|
336
348
|
}
|
|
@@ -367,6 +379,8 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
|
|
|
367
379
|
const messages = bindMessageStore(cfg);
|
|
368
380
|
const history = new InMemoryNickHistoryStore(SystemClock);
|
|
369
381
|
const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
|
|
382
|
+
const servicesTable = typeof env.SERVICES_TABLE === 'string' ? env.SERVICES_TABLE : undefined;
|
|
383
|
+
const services = await loadDynamoServicesStore(dynamo, servicesTable, SystemClock);
|
|
370
384
|
// REHASH reload source: re-read the live env so a rotated oper password
|
|
371
385
|
// or MOTD takes effect on the next REHASH without a redeploy.
|
|
372
386
|
const configLoader = async (): Promise<ServerConfig> =>
|
|
@@ -381,6 +395,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
|
|
|
381
395
|
history,
|
|
382
396
|
configLoader,
|
|
383
397
|
...(accounts !== undefined ? { accounts } : {}),
|
|
398
|
+
...(services !== undefined ? { services } : {}),
|
|
384
399
|
};
|
|
385
400
|
}
|
|
386
401
|
|
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
} from '@serverless-ircd/irc-server';
|
|
65
65
|
import { TcpByteStreamTransport } from '@serverless-ircd/irc-server';
|
|
66
66
|
import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
|
|
67
|
+
import type { DynamoServicesStore } from '../dynamo-services-store.js';
|
|
67
68
|
import type { MarshalledConnection } from '../serialize.js';
|
|
68
69
|
import { CONNECTION_VERSION } from '../serialize.js';
|
|
69
70
|
import type { TablesConfig } from '../tables.js';
|
|
@@ -138,6 +139,12 @@ export interface NlbStreamParams {
|
|
|
138
139
|
messages?: MessageStore;
|
|
139
140
|
/** SASL account verification source. */
|
|
140
141
|
accounts?: AccountStore;
|
|
142
|
+
/**
|
|
143
|
+
* Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
|
|
144
|
+
* OperServ + read-marker). Flushed after each frame so a Lambda freeze
|
|
145
|
+
* does not lose services state.
|
|
146
|
+
*/
|
|
147
|
+
services?: DynamoServicesStore;
|
|
141
148
|
/** mTLS identity source for SASL EXTERNAL. */
|
|
142
149
|
mtlsIdentity?: import('@serverless-ircd/irc-core').MtlsIdentityProvider;
|
|
143
150
|
/** Nick-history source for WHOWAS. */
|
|
@@ -258,6 +265,7 @@ export async function handleNlbStream(
|
|
|
258
265
|
transport: new WsTextFrameTransport(),
|
|
259
266
|
...(params.messages !== undefined ? { messages: params.messages } : {}),
|
|
260
267
|
...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
|
|
268
|
+
...(params.services !== undefined ? { services: params.services } : {}),
|
|
261
269
|
...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
|
|
262
270
|
...(params.history !== undefined ? { history: params.history } : {}),
|
|
263
271
|
// Surface TLS fact when the NLB listener terminates TLS (irc+tls port).
|
|
@@ -282,6 +290,10 @@ export async function handleNlbStream(
|
|
|
282
290
|
newBuffer,
|
|
283
291
|
);
|
|
284
292
|
|
|
293
|
+
// Drain any services write-behind ops so a Lambda freeze / evict does
|
|
294
|
+
// not lose NickServ/ChanServ/... state.
|
|
295
|
+
await params.services?.flush();
|
|
296
|
+
|
|
285
297
|
// 7. Return outbound bytes as the NLB response body.
|
|
286
298
|
const responseBody = outbound.length > 0 ? `${outbound.join('\r\n')}\r\n` : '';
|
|
287
299
|
return {
|
|
@@ -309,14 +321,22 @@ function readHeader(event: NlbStreamEvent, name: string): string | undefined {
|
|
|
309
321
|
return undefined;
|
|
310
322
|
}
|
|
311
323
|
|
|
312
|
-
/**
|
|
324
|
+
/**
|
|
325
|
+
* Loads a raw Connections row (includes transportBuffer if present).
|
|
326
|
+
*
|
|
327
|
+
* Strongly consistent: the NLB handler has no `$connect` event — it writes
|
|
328
|
+
* the row itself on the first chunk and reads it back on the very next
|
|
329
|
+
* invocation. An eventually-consistent Get can miss that just-written row
|
|
330
|
+
* under DynamoDB replication lag and drop the first frame(s) of a
|
|
331
|
+
* raw-IRC-over-`:6697` connection.
|
|
332
|
+
*/
|
|
313
333
|
async function loadRow(
|
|
314
334
|
dynamo: DynamoDBDocumentClient,
|
|
315
335
|
tableName: string,
|
|
316
336
|
connId: string,
|
|
317
337
|
): Promise<RawConnectionRow | null> {
|
|
318
338
|
const result = await dynamo.send(
|
|
319
|
-
new GetCommand({ TableName: tableName, Key: { connectionId: connId } }),
|
|
339
|
+
new GetCommand({ TableName: tableName, Key: { connectionId: connId }, ConsistentRead: true }),
|
|
320
340
|
);
|
|
321
341
|
if (result.Item === undefined) return null;
|
|
322
342
|
return result.Item as unknown as RawConnectionRow;
|
|
@@ -460,6 +480,9 @@ class NlbChannelAccess implements ActorChannelAccess {
|
|
|
460
480
|
moderated: false,
|
|
461
481
|
secret: false,
|
|
462
482
|
private: false,
|
|
483
|
+
registered: false,
|
|
484
|
+
blockUnidentified: false,
|
|
485
|
+
moderatedIdent: false,
|
|
463
486
|
},
|
|
464
487
|
banMasks: new Set<string>(),
|
|
465
488
|
members: new Map(),
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
20
20
|
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
21
21
|
import { cleanupConnection } from '../aws-runtime.js';
|
|
22
|
+
import { CONNECTION_COUNT_META_ID } from '../connection-counter.js';
|
|
22
23
|
import type { TablesConfig } from '../tables.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -69,8 +70,11 @@ export async function handleSweep(params: SweepParams): Promise<SweepResult> {
|
|
|
69
70
|
);
|
|
70
71
|
lastKey = res.LastEvaluatedKey;
|
|
71
72
|
for (const item of res.Items ?? []) {
|
|
72
|
-
scanned++;
|
|
73
73
|
const row = item as { connectionId?: string; idleSince?: number };
|
|
74
|
+
// Skip the admission-counter meta row (it carries no idleSince and is
|
|
75
|
+
// never a real connection to tear down).
|
|
76
|
+
if (row.connectionId === CONNECTION_COUNT_META_ID) continue;
|
|
77
|
+
scanned++;
|
|
74
78
|
const idleSince = row.idleSince;
|
|
75
79
|
if (typeof idleSince !== 'number' || idleSince >= cutoff) continue;
|
|
76
80
|
const connId = row.connectionId;
|
|
@@ -23,6 +23,10 @@ export {
|
|
|
23
23
|
loadDynamoAccountStore,
|
|
24
24
|
} from './dynamo-account-store.js';
|
|
25
25
|
export { bindAccountStore, putAccountCredential, resolveAccountStore } from './account-store.js';
|
|
26
|
+
export {
|
|
27
|
+
DynamoServicesStore,
|
|
28
|
+
loadDynamoServicesStore,
|
|
29
|
+
} from './dynamo-services-store.js';
|
|
26
30
|
export { AwsStats } from './stats.js';
|
|
27
31
|
export type { AwsStatsOptions } from './stats.js';
|
|
28
32
|
export {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
18
18
|
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
19
19
|
import { type ServerStatsSnapshot, computeStatsSnapshot } from '@serverless-ircd/irc-core';
|
|
20
|
+
import { CONNECTION_COUNT_META_ID } from './connection-counter.js';
|
|
20
21
|
import type { MarshalledConnection } from './serialize.js';
|
|
21
22
|
import { unmarshalConnection } from './serialize.js';
|
|
22
23
|
import type { TablesConfig } from './tables.js';
|
|
@@ -63,7 +64,11 @@ export class AwsStats {
|
|
|
63
64
|
new ScanCommand({ TableName: this.tables.Connections }),
|
|
64
65
|
);
|
|
65
66
|
const connItems = (connResult.Items ?? []) as unknown as MarshalledConnection[];
|
|
66
|
-
|
|
67
|
+
// The admission counter meta row lives in this table (keyed by the
|
|
68
|
+
// sentinel id); exclude it so LUSERS/STATS report real connections only.
|
|
69
|
+
const connections = connItems
|
|
70
|
+
.filter((item) => item.connectionId !== CONNECTION_COUNT_META_ID)
|
|
71
|
+
.map(unmarshalConnection);
|
|
67
72
|
|
|
68
73
|
// Channel count: one row per channel in `ChannelMeta`, again in a
|
|
69
74
|
// single page (channel counts are small).
|
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
/** Logical table names — also used as CDK construct ids. */
|
|
17
|
-
export type TableName =
|
|
17
|
+
export type TableName =
|
|
18
|
+
| 'Connections'
|
|
19
|
+
| 'ChannelMeta'
|
|
20
|
+
| 'ChannelMembers'
|
|
21
|
+
| 'Nicks'
|
|
22
|
+
| 'Accounts'
|
|
23
|
+
| 'Services';
|
|
18
24
|
|
|
19
25
|
/** Logical table names in stable order. */
|
|
20
26
|
export const TABLE_NAMES: readonly TableName[] = [
|
|
@@ -23,9 +29,19 @@ export const TABLE_NAMES: readonly TableName[] = [
|
|
|
23
29
|
'ChannelMembers',
|
|
24
30
|
'Nicks',
|
|
25
31
|
'Accounts',
|
|
32
|
+
'Services',
|
|
26
33
|
];
|
|
27
34
|
|
|
28
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Key column names used throughout the runtime (avoids stringly-typed typos).
|
|
37
|
+
*
|
|
38
|
+
* `Services` is intentionally absent: the services table is provisioned +
|
|
39
|
+
* env-injected alongside the others (via {@link TABLE_DEFS} in
|
|
40
|
+
* `cdk-table-defs.ts`) but the runtime reads `SERVICES_TABLE` directly in
|
|
41
|
+
* `buildDepsFromEnv` — services persistence is optional, so it must not be
|
|
42
|
+
* part of the required `tablesConfigFromEnv` loop (a legacy deploy without
|
|
43
|
+
* the table still boots, just with services unbound).
|
|
44
|
+
*/
|
|
29
45
|
export const TABLE_KEYS = {
|
|
30
46
|
Connections: { pk: 'connectionId' },
|
|
31
47
|
ChannelMeta: { pk: 'channelName' },
|
|
@@ -38,8 +54,20 @@ export const TABLE_KEYS = {
|
|
|
38
54
|
* Maps logical names to physical table names. Production uses the bare
|
|
39
55
|
* names; tests override with a unique prefix per harness to isolate
|
|
40
56
|
* parallel runs against a shared DynamoDB Local instance.
|
|
57
|
+
*
|
|
58
|
+
* `Services` is optional: the services-persistence table is provisioned by
|
|
59
|
+
* {@link TABLE_DEFS} but the runtime reads `SERVICES_TABLE` directly and
|
|
60
|
+
* tolerates an unset var (services unbound). A `TablesConfig` built from
|
|
61
|
+
* the required-five env vars still type-checks.
|
|
41
62
|
*/
|
|
42
|
-
export type TablesConfig =
|
|
63
|
+
export type TablesConfig = {
|
|
64
|
+
Connections: string;
|
|
65
|
+
ChannelMeta: string;
|
|
66
|
+
ChannelMembers: string;
|
|
67
|
+
Nicks: string;
|
|
68
|
+
Accounts: string;
|
|
69
|
+
Services?: string;
|
|
70
|
+
};
|
|
43
71
|
|
|
44
72
|
/** Builds a {@link TablesConfig} from the supplied physical table names. */
|
|
45
73
|
export function tablesConfigFromNames(names: {
|
|
@@ -48,6 +76,8 @@ export function tablesConfigFromNames(names: {
|
|
|
48
76
|
ChannelMembers: string;
|
|
49
77
|
Nicks: string;
|
|
50
78
|
Accounts: string;
|
|
79
|
+
Services?: string;
|
|
51
80
|
}): TablesConfig {
|
|
52
|
-
|
|
81
|
+
const out: TablesConfig = { ...names };
|
|
82
|
+
return out;
|
|
53
83
|
}
|
|
@@ -277,6 +277,9 @@ class AwsChannelAccess implements ActorChannelAccess {
|
|
|
277
277
|
moderated: false,
|
|
278
278
|
secret: false,
|
|
279
279
|
private: false,
|
|
280
|
+
registered: false,
|
|
281
|
+
blockUnidentified: false,
|
|
282
|
+
moderatedIdent: false,
|
|
280
283
|
},
|
|
281
284
|
banMasks: new Set<string>(),
|
|
282
285
|
members: new Map(),
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
|
|
10
|
+
import { GetCommand } from '@aws-sdk/lib-dynamodb';
|
|
10
11
|
import {
|
|
11
12
|
type ChannelDelta,
|
|
12
13
|
type Clock,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
type ServerConfig,
|
|
16
17
|
createConnection,
|
|
17
18
|
} from '@serverless-ircd/irc-core';
|
|
18
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
19
20
|
import { AwsRuntime } from '../src/aws-runtime.js';
|
|
20
21
|
import type { AwsRuntimeHandlers, PostToConnection } from '../src/aws-runtime.js';
|
|
21
22
|
import { TABLE_DEFS } from '../src/cdk-table-defs.js';
|
|
@@ -235,6 +236,27 @@ describe.skipIf(!available)('AwsRuntime', () => {
|
|
|
235
236
|
const got = await rt.getConnection('self');
|
|
236
237
|
expect(got).toBe(state);
|
|
237
238
|
});
|
|
239
|
+
|
|
240
|
+
it('getConnectionInfo reads a peer Connections row strongly consistently', async () => {
|
|
241
|
+
// Peer lookups (WHOIS, roster hydration via readConnectionRow) read
|
|
242
|
+
// another connection's row. Strong consistency avoids presenting a
|
|
243
|
+
// stale peer snapshot under DynamoDB replication lag.
|
|
244
|
+
const rt = makeRuntime('self', noopHandlers());
|
|
245
|
+
const state = createConnection({ id: 'c1', connectedSince: 0 });
|
|
246
|
+
state.nick = 'Alice';
|
|
247
|
+
await rt.persistConnectionState(state);
|
|
248
|
+
const sendSpy = vi.spyOn(fx.client, 'send');
|
|
249
|
+
await rt.getConnectionInfo('c1');
|
|
250
|
+
const conGets = sendSpy.mock.calls
|
|
251
|
+
.map((call) => call[0] as unknown)
|
|
252
|
+
.filter(
|
|
253
|
+
(cmd): cmd is GetCommand =>
|
|
254
|
+
cmd instanceof GetCommand && (cmd.input.TableName?.endsWith('Connections') ?? false),
|
|
255
|
+
);
|
|
256
|
+
expect(conGets.length).toBeGreaterThan(0);
|
|
257
|
+
expect((conGets[0]?.input as { ConsistentRead?: boolean }).ConsistentRead).toBe(true);
|
|
258
|
+
sendSpy.mockRestore();
|
|
259
|
+
});
|
|
238
260
|
});
|
|
239
261
|
|
|
240
262
|
// -------------------------------------------------------------------------
|
|
@@ -51,6 +51,14 @@ describe('buildLambdaConfigInput', () => {
|
|
|
51
51
|
expect(out.operCreds).toEqual([{ user: '', password: 'pw' }]);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
it('threads SERVER_PASSWORD through to serverPassword', () => {
|
|
55
|
+
expect(buildLambdaConfigInput({ SERVER_PASSWORD: 'hunter2' }).serverPassword).toBe('hunter2');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('omits serverPassword when SERVER_PASSWORD is undefined', () => {
|
|
59
|
+
expect('serverPassword' in buildLambdaConfigInput({})).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
54
62
|
it('omits operCreds when neither OPER_* is set', () => {
|
|
55
63
|
expect('operCreds' in buildLambdaConfigInput({})).toBe(false);
|
|
56
64
|
});
|