serverless-ircd 0.2.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 +258 -18
- package/README.md +72 -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/scripts/smoke.mjs +0 -0
- package/apps/cf-worker/src/worker.ts +8 -2
- package/apps/cf-worker/tests/smoke.test.ts +1 -0
- package/apps/cf-worker/wrangler.test.toml +11 -1
- package/apps/cf-worker/wrangler.toml +69 -19
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +11 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +46 -3
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/package.json +19 -16
- package/packages/aws-adapter/package.json +3 -3
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/admission.ts +74 -0
- package/packages/aws-adapter/src/aws-runtime.ts +124 -34
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +62 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
- package/packages/aws-adapter/src/handlers/connect.ts +64 -8
- package/packages/aws-adapter/src/handlers/default.ts +43 -2
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +120 -9
- 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 +13 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
- package/packages/aws-adapter/tests/account-store.test.ts +279 -0
- 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 +75 -0
- package/packages/aws-adapter/tests/connect.test.ts +78 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
- package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
- package/packages/aws-adapter/tests/handlers.test.ts +194 -47
- 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 +40 -22
- package/packages/cf-adapter/src/channel-do.ts +40 -1
- package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
- package/packages/cf-adapter/src/config-loader.ts +62 -0
- package/packages/cf-adapter/src/connection-do.ts +181 -31
- package/packages/cf-adapter/src/d1-account-store.ts +198 -0
- package/packages/cf-adapter/src/env.ts +58 -8
- package/packages/cf-adapter/src/index.ts +11 -9
- 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/cf-runtime.test.ts +91 -0
- 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/connection-do.test.ts +40 -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 +9 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
- package/packages/irc-core/package.json +8 -2
- 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/case-fold.ts +64 -0
- 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 +11 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- package/packages/irc-core/src/commands/ison.ts +61 -0
- package/packages/irc-core/src/commands/isupport.ts +1 -1
- package/packages/irc-core/src/commands/join.ts +4 -3
- package/packages/irc-core/src/commands/kick.ts +4 -11
- package/packages/irc-core/src/commands/list.ts +5 -4
- package/packages/irc-core/src/commands/mode.ts +5 -9
- package/packages/irc-core/src/commands/motd-lines.ts +127 -0
- package/packages/irc-core/src/commands/motd.ts +6 -110
- package/packages/irc-core/src/commands/oper.ts +151 -0
- package/packages/irc-core/src/commands/quit.ts +12 -0
- package/packages/irc-core/src/commands/registration.ts +26 -19
- 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/tagmsg.ts +205 -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 +84 -3
- 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 +3 -0
- package/packages/irc-core/src/ports.ts +240 -7
- package/packages/irc-core/src/protocol/numerics.ts +14 -8
- package/packages/irc-core/src/types.ts +60 -1
- package/packages/irc-core/tests/account-store.test.ts +131 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
- package/packages/irc-core/tests/case-fold.test.ts +80 -0
- 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/kick.test.ts +15 -0
- package/packages/irc-core/tests/commands/oper.test.ts +257 -0
- package/packages/irc-core/tests/commands/quit.test.ts +69 -2
- package/packages/irc-core/tests/commands/registration.test.ts +256 -19
- 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 +662 -0
- package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
- package/packages/irc-core/tests/commands/who.test.ts +22 -4
- package/packages/irc-core/tests/commands/whois.test.ts +8 -1
- package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
- package/packages/irc-core/tests/config.test.ts +170 -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 +182 -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 +21 -0
- package/packages/irc-server/src/transport.ts +101 -0
- package/packages/irc-server/tests/actor.test.ts +617 -1
- package/packages/irc-server/tests/dispatch.test.ts +0 -17
- package/packages/irc-server/tests/routing.test.ts +7 -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 +78 -13
- 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 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/package.json +4 -0
- package/tools/seed-aws-accounts.ts +79 -0
- package/tools/seed-cf-accounts.ts +104 -0
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/AGENTS.md +0 -5
- package/MOTD.txt +0 -3
- package/PLAN-FIXES.md +0 -358
- package/PLAN.md +0 -420
- package/dashboards/cloudwatch-irc.json +0 -139
- package/docs/AWS-Adapter-Architecture.md +0 -494
- package/docs/AWS-Deployment.md +0 -1107
- package/docs/Cloudflare-Deployment-Guide.md +0 -660
- package/docs/Home.md +0 -1
- package/docs/Observability.md +0 -87
- package/docs/PlanIRCv3Websocket.md +0 -489
- package/docs/PlanWebClient.md +0 -451
- package/docs/Release-Process.md +0 -440
- package/progress.md +0 -107
- package/tickets.md +0 -2485
- package/webircgateway/LICENSE +0 -201
- package/webircgateway/Makefile +0 -44
- package/webircgateway/README.md +0 -134
- package/webircgateway/config.conf.example +0 -135
- package/webircgateway/go.mod +0 -16
- package/webircgateway/go.sum +0 -89
- package/webircgateway/main.go +0 -118
- package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
- package/webircgateway/pkg/identd/identd.go +0 -86
- package/webircgateway/pkg/identd/rpcclient.go +0 -59
- package/webircgateway/pkg/irc/isupport.go +0 -56
- package/webircgateway/pkg/irc/message.go +0 -217
- package/webircgateway/pkg/irc/state.go +0 -79
- package/webircgateway/pkg/proxy/proxy.go +0 -129
- package/webircgateway/pkg/proxy/server.go +0 -237
- package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
- package/webircgateway/pkg/webircgateway/client.go +0 -741
- package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
- package/webircgateway/pkg/webircgateway/config.go +0 -385
- package/webircgateway/pkg/webircgateway/gateway.go +0 -278
- package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
- package/webircgateway/pkg/webircgateway/hooks.go +0 -152
- package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
- package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
- package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
- package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
- package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
- package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
- package/webircgateway/pkg/webircgateway/utils.go +0 -147
- package/webircgateway/plugins/example/plugin.go +0 -11
- package/webircgateway/plugins/stats/plugin.go +0 -52
- package/webircgateway/staticcheck.conf +0 -1
- package/webircgateway/webircgateway.svg +0 -3
|
@@ -28,14 +28,18 @@ import { randomUUID } from 'node:crypto';
|
|
|
28
28
|
import { type Server, type Socket, createServer } from 'node:net';
|
|
29
29
|
import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
|
|
30
30
|
import {
|
|
31
|
+
type AccountStore,
|
|
31
32
|
type AdmissionConfig,
|
|
32
33
|
type ChanName,
|
|
33
34
|
type ConnectionState,
|
|
34
35
|
ConsoleLogger,
|
|
36
|
+
InMemoryAccountStore,
|
|
35
37
|
InMemoryMessageStore,
|
|
38
|
+
InMemoryNickHistoryStore,
|
|
36
39
|
LogLevel,
|
|
37
40
|
type Logger,
|
|
38
41
|
type MessageStore,
|
|
42
|
+
type NickHistoryStore,
|
|
39
43
|
type RawLine,
|
|
40
44
|
createConnection,
|
|
41
45
|
} from '@serverless-ircd/irc-core';
|
|
@@ -57,6 +61,7 @@ type ResolvedServerConfig = Omit<
|
|
|
57
61
|
| 'maxConnectionsPerUser'
|
|
58
62
|
| 'perIpConnectionRate'
|
|
59
63
|
| 'historyMaxPerChannel'
|
|
64
|
+
| 'saslAccounts'
|
|
60
65
|
> &
|
|
61
66
|
Pick<
|
|
62
67
|
LocalServerConfig,
|
|
@@ -65,6 +70,7 @@ type ResolvedServerConfig = Omit<
|
|
|
65
70
|
| 'maxConnectionsPerIp'
|
|
66
71
|
| 'maxConnectionsPerUser'
|
|
67
72
|
| 'perIpConnectionRate'
|
|
73
|
+
| 'saslAccounts'
|
|
68
74
|
>;
|
|
69
75
|
|
|
70
76
|
/** Default per-IP / per-user caps used when callers don't override. */
|
|
@@ -121,6 +127,13 @@ export interface LocalServerConfig {
|
|
|
121
127
|
* CLI — the store is bound unconditionally).
|
|
122
128
|
*/
|
|
123
129
|
historyMaxPerChannel?: number;
|
|
130
|
+
/**
|
|
131
|
+
* SASL PLAIN account credentials. When non-empty the server seeds an
|
|
132
|
+
* in-memory {@link AccountStore} so `AUTHENTICATE PLAIN` succeeds for the
|
|
133
|
+
* listed accounts. When omitted/empty, SASL account verification is
|
|
134
|
+
* disabled (`ctx.accounts` undefined → `904 ERR_SASLFAIL`).
|
|
135
|
+
*/
|
|
136
|
+
saslAccounts?: Array<{ username: string; password: string }>;
|
|
124
137
|
}
|
|
125
138
|
|
|
126
139
|
export interface StartServerOptions extends LocalServerConfig {
|
|
@@ -151,6 +164,12 @@ export interface LocalServer {
|
|
|
151
164
|
* one client is replayable by another. Exposed for test assertions.
|
|
152
165
|
*/
|
|
153
166
|
readonly messages: MessageStore;
|
|
167
|
+
/**
|
|
168
|
+
* The per-process nick-history store backing `WHOWAS`. Shared across
|
|
169
|
+
* every connection so a nick signed off by one client is queryable by
|
|
170
|
+
* another. Exposed for test assertions.
|
|
171
|
+
*/
|
|
172
|
+
readonly history: NickHistoryStore;
|
|
154
173
|
close(): Promise<void>;
|
|
155
174
|
/**
|
|
156
175
|
* Test-only seam: live server-side WebSockets. Exposed so tests can
|
|
@@ -178,6 +197,7 @@ const DEFAULT_CONFIG: Omit<
|
|
|
178
197
|
| 'maxConnectionsPerUser'
|
|
179
198
|
| 'perIpConnectionRate'
|
|
180
199
|
| 'historyMaxPerChannel'
|
|
200
|
+
| 'saslAccounts'
|
|
181
201
|
> = {
|
|
182
202
|
serverName: 'irc.example.com',
|
|
183
203
|
networkName: 'ExampleNet',
|
|
@@ -242,6 +262,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
242
262
|
...(opts.channelLen !== undefined ? { channelLen: opts.channelLen } : {}),
|
|
243
263
|
...(opts.topicLen !== undefined ? { topicLen: opts.topicLen } : {}),
|
|
244
264
|
...(opts.quitMessage !== undefined ? { quitMessage: opts.quitMessage } : {}),
|
|
265
|
+
...(opts.saslAccounts !== undefined ? { saslAccounts: opts.saslAccounts } : {}),
|
|
245
266
|
});
|
|
246
267
|
} catch (err) {
|
|
247
268
|
return Promise.reject(err);
|
|
@@ -268,6 +289,9 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
268
289
|
...(opts.perIpConnectionRate !== undefined
|
|
269
290
|
? { perIpConnectionRate: opts.perIpConnectionRate }
|
|
270
291
|
: {}),
|
|
292
|
+
...(opts.saslAccounts !== undefined && opts.saslAccounts.length > 0
|
|
293
|
+
? { saslAccounts: opts.saslAccounts }
|
|
294
|
+
: {}),
|
|
271
295
|
};
|
|
272
296
|
const hostname = opts.hostname ?? '127.0.0.1';
|
|
273
297
|
|
|
@@ -295,6 +319,20 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
295
319
|
// is replayable when bob queries CHATHISTORY). The cap is config-driven.
|
|
296
320
|
const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
|
|
297
321
|
|
|
322
|
+
// Per-process nick-history store: one TTL-buffer shared by every
|
|
323
|
+
// connection so cross-connection WHOWAS works (a nick alice quits is
|
|
324
|
+
// queryable by bob). Driven by the real-system clock so TTL eviction
|
|
325
|
+
// matches wall time.
|
|
326
|
+
const history = new InMemoryNickHistoryStore({ now: () => Date.now() });
|
|
327
|
+
|
|
328
|
+
// SASL account store: seeded from config so `AUTHENTICATE PLAIN` works
|
|
329
|
+
// end-to-end. `undefined` (no accounts configured) leaves the actor's
|
|
330
|
+
// `ctx.accounts` unset, preserving the no-store behaviour.
|
|
331
|
+
const accounts =
|
|
332
|
+
cfg.saslAccounts !== undefined && cfg.saslAccounts.length > 0
|
|
333
|
+
? new InMemoryAccountStore(cfg.saslAccounts)
|
|
334
|
+
: undefined;
|
|
335
|
+
|
|
298
336
|
const wss = new WebSocketServer({ port: opts.port, host: hostname });
|
|
299
337
|
const wsConnections = new Map<WebSocket, ConnectionBindings>();
|
|
300
338
|
|
|
@@ -312,7 +350,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
312
350
|
const admissionRecordId = decision.recordId;
|
|
313
351
|
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
314
352
|
|
|
315
|
-
const { state, actor } = attachConnection(runtime, cfg, messages, {
|
|
353
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
|
|
316
354
|
sendText: (text) => {
|
|
317
355
|
if (ws.readyState === WebSocket.OPEN) ws.send(text);
|
|
318
356
|
},
|
|
@@ -389,7 +427,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
389
427
|
const admissionRecordId = decision.recordId;
|
|
390
428
|
runtime.commitAdmission(ip, undefined, admissionRecordId);
|
|
391
429
|
|
|
392
|
-
const { state, actor } = attachConnection(runtime, cfg, messages, {
|
|
430
|
+
const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
|
|
393
431
|
sendText: (text) => {
|
|
394
432
|
if (socket.writable) socket.write(text);
|
|
395
433
|
},
|
|
@@ -454,6 +492,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
|
|
|
454
492
|
hostname,
|
|
455
493
|
runtime,
|
|
456
494
|
messages,
|
|
495
|
+
history,
|
|
457
496
|
testSockets: wss.clients,
|
|
458
497
|
testTcpSockets: tcpSockets,
|
|
459
498
|
close: () =>
|
|
@@ -481,6 +520,8 @@ function attachConnection(
|
|
|
481
520
|
runtime: InMemoryRuntime,
|
|
482
521
|
cfg: ResolvedServerConfig,
|
|
483
522
|
messages: MessageStore,
|
|
523
|
+
accounts: AccountStore | undefined,
|
|
524
|
+
history: NickHistoryStore,
|
|
484
525
|
transport: {
|
|
485
526
|
sendText: (text: string) => void;
|
|
486
527
|
closeTransport: () => void;
|
|
@@ -523,6 +564,8 @@ function attachConnection(
|
|
|
523
564
|
ids: DEFAULT_ID_FACTORY,
|
|
524
565
|
motd: { lines: () => cfg.motdLines },
|
|
525
566
|
messages,
|
|
567
|
+
...(accounts !== undefined ? { accounts } : {}),
|
|
568
|
+
history,
|
|
526
569
|
logger,
|
|
527
570
|
});
|
|
528
571
|
|
|
@@ -615,4 +658,4 @@ function stringifyErr(err: unknown): unknown {
|
|
|
615
658
|
}
|
|
616
659
|
|
|
617
660
|
// Suppress "ChanName imported but unused" — kept for type re-export ergonomics.
|
|
618
|
-
export type { ChanName, ConnectionState };
|
|
661
|
+
export type { ChanName, ConnectionState, NickHistoryStore };
|
|
@@ -418,3 +418,55 @@ describe('local-cli chathistory end-to-end', () => {
|
|
|
418
418
|
await srv.close();
|
|
419
419
|
});
|
|
420
420
|
});
|
|
421
|
+
|
|
422
|
+
describe('local-cli SASL PLAIN end-to-end', () => {
|
|
423
|
+
/** base64(`\0authcid\0password`) — the PLAIN payload format. */
|
|
424
|
+
function plainB64(authcid: string, password: string): string {
|
|
425
|
+
return Buffer.from(`\0${authcid}\0${password}`, 'utf8').toString('base64');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
it('completes SASL PLAIN with 900/903 for valid seeded creds', async () => {
|
|
429
|
+
const srv = await startLocalServer({
|
|
430
|
+
port: 0,
|
|
431
|
+
hostname: '127.0.0.1',
|
|
432
|
+
saslAccounts: [{ username: 'sasl-alice', password: 's3cret' }],
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
436
|
+
await client.opened();
|
|
437
|
+
client.send('CAP REQ :sasl\r\n');
|
|
438
|
+
await client.waitFor((l) => / CAP \S+ ACK :sasl/.test(l));
|
|
439
|
+
client.send('AUTHENTICATE PLAIN\r\n');
|
|
440
|
+
await client.waitFor((l) => l === 'AUTHENTICATE +');
|
|
441
|
+
client.send(`AUTHENTICATE ${plainB64('sasl-alice', 's3cret')}\r\n`);
|
|
442
|
+
await client.waitFor((l) => l.includes(' 900 '));
|
|
443
|
+
const success = await client.waitFor((l) => l.includes(' 903 '));
|
|
444
|
+
expect(success).toContain(' 903 ');
|
|
445
|
+
client.send('NICK sasl-alice\r\n');
|
|
446
|
+
client.send('USER sasl-alice 0 * :Sasl Alice\r\n');
|
|
447
|
+
client.send('CAP END\r\n');
|
|
448
|
+
await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
|
|
449
|
+
await client.close();
|
|
450
|
+
await srv.close();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('rejects SASL PLAIN with 904 for invalid creds', async () => {
|
|
454
|
+
const srv = await startLocalServer({
|
|
455
|
+
port: 0,
|
|
456
|
+
hostname: '127.0.0.1',
|
|
457
|
+
saslAccounts: [{ username: 'sasl-bob', password: 'right' }],
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
|
|
461
|
+
await client.opened();
|
|
462
|
+
client.send('CAP REQ :sasl\r\n');
|
|
463
|
+
await client.waitFor((l) => / CAP \S+ ACK :sasl/.test(l));
|
|
464
|
+
client.send('AUTHENTICATE PLAIN\r\n');
|
|
465
|
+
await client.waitFor((l) => l === 'AUTHENTICATE +');
|
|
466
|
+
client.send(`AUTHENTICATE ${plainB64('sasl-bob', 'wrong')}\r\n`);
|
|
467
|
+
const fail = await client.waitFor((l) => l.includes(' 904 '));
|
|
468
|
+
expect(fail).toContain(' 904 ');
|
|
469
|
+
await client.close();
|
|
470
|
+
await srv.close();
|
|
471
|
+
});
|
|
472
|
+
});
|
package/package.json
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "serverless-ircd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
7
|
-
"packageManager": "pnpm@9.15.9",
|
|
8
7
|
"engines": {
|
|
9
|
-
"node": ">=
|
|
8
|
+
"node": ">=24"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@biomejs/biome": "^1.9.4",
|
|
12
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
13
|
+
"fast-check": "^3.23.2",
|
|
14
|
+
"rimraf": "^6.1.3",
|
|
15
|
+
"tsx": "^4.19.0",
|
|
16
|
+
"turbo": "^2.10.7",
|
|
17
|
+
"typescript": "^5.9.3",
|
|
18
|
+
"vite": "^7.3.6",
|
|
19
|
+
"vitest": "^4.1.10",
|
|
20
|
+
"@serverless-ircd/aws-adapter": "0.4.0"
|
|
10
21
|
},
|
|
11
22
|
"scripts": {
|
|
12
23
|
"build": "turbo run build",
|
|
@@ -24,18 +35,10 @@
|
|
|
24
35
|
"deploy:cf:staging": "pnpm --filter @serverless-ircd/cf-worker deploy:staging",
|
|
25
36
|
"deploy:cf:prod": "pnpm --filter @serverless-ircd/cf-worker deploy:prod",
|
|
26
37
|
"smoke:cf:staging": "pnpm --filter @serverless-ircd/cf-worker smoke:staging",
|
|
27
|
-
"deploy:
|
|
28
|
-
"deploy:
|
|
38
|
+
"deploy:cf-tcp:staging": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:staging",
|
|
39
|
+
"deploy:cf-tcp:prod": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:prod",
|
|
40
|
+
"deploy:aws:staging": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:staging",
|
|
41
|
+
"deploy:aws:prod": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:prod",
|
|
29
42
|
"smoke:aws:staging": "pnpm --filter @serverless-ircd/aws-stack smoke:staging"
|
|
30
|
-
},
|
|
31
|
-
"devDependencies": {
|
|
32
|
-
"@biomejs/biome": "^1.9.0",
|
|
33
|
-
"@vitest/coverage-v8": "^4.1.0",
|
|
34
|
-
"fast-check": "^3.19.0",
|
|
35
|
-
"rimraf": "^6.0.0",
|
|
36
|
-
"turbo": "^2.0.0",
|
|
37
|
-
"typescript": "^5.6.0",
|
|
38
|
-
"vite": "^7.3.0",
|
|
39
|
-
"vitest": "^4.1.0"
|
|
40
43
|
}
|
|
41
|
-
}
|
|
44
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/aws-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "AWS Lambda + DynamoDB adapter: AwsRuntime implementing IrcRuntime + $connect/$disconnect/$default handlers",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@serverless-ircd/irc-test-support": "workspace:*",
|
|
39
39
|
"@types/node": "^26.1.1",
|
|
40
40
|
"@vitest/coverage-v8": "^4.1.0",
|
|
41
|
-
"aws-cdk-lib": "^2.
|
|
41
|
+
"aws-cdk-lib": "^2.262.0",
|
|
42
42
|
"aws-sdk-client-mock": "^4.1.0",
|
|
43
43
|
"constructs": "^10.4.0",
|
|
44
44
|
"rimraf": "^6.0.0",
|
|
@@ -47,6 +47,6 @@
|
|
|
47
47
|
"vitest": "^4.1.0"
|
|
48
48
|
},
|
|
49
49
|
"engines": {
|
|
50
|
-
"node": ">=
|
|
50
|
+
"node": ">=24"
|
|
51
51
|
}
|
|
52
52
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SASL account store construction + resolution for the AWS adapter.
|
|
3
|
+
*
|
|
4
|
+
* Centralises `AccountStore` creation so the actor-construction site
|
|
5
|
+
* (`handleDefault`) binds a single helper rather than constructing the
|
|
6
|
+
* store inline. Two sources of credentials are reconciled here:
|
|
7
|
+
*
|
|
8
|
+
* 1. The DynamoDB `Accounts` table (authoritative when populated) —
|
|
9
|
+
* loaded at cold start into a {@link DynamoAccountStore} by
|
|
10
|
+
* {@link resolveAccountStore}. Credentials are stored as scrypt
|
|
11
|
+
* hashes, never plaintext.
|
|
12
|
+
* 2. The parsed server config (`saslAccounts`, sourced from the
|
|
13
|
+
* `SASL_ACCOUNTS` env var) — the legacy/config fallback used only
|
|
14
|
+
* when the table is empty, preserving the config-seed behaviour for
|
|
15
|
+
* deployments that have not migrated to the table.
|
|
16
|
+
*
|
|
17
|
+
* The resolved store is scoped to the Lambda execution context: it
|
|
18
|
+
* persists across warm invocations (via the memoised `HandlerDeps`) but
|
|
19
|
+
* is rebuilt on cold start, picking up account changes made since the
|
|
20
|
+
* last cold start.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
24
|
+
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
25
|
+
import {
|
|
26
|
+
type AccountStore,
|
|
27
|
+
InMemoryAccountStore,
|
|
28
|
+
type ParsedServerConfig,
|
|
29
|
+
} from '@serverless-ircd/irc-core';
|
|
30
|
+
import {
|
|
31
|
+
DynamoAccountStore,
|
|
32
|
+
type HashedAccountCredential,
|
|
33
|
+
hashAccountCredential,
|
|
34
|
+
loadDynamoAccountStore,
|
|
35
|
+
} from './dynamo-account-store.js';
|
|
36
|
+
|
|
37
|
+
// Re-export so callers can import everything from one module.
|
|
38
|
+
export {
|
|
39
|
+
DynamoAccountStore,
|
|
40
|
+
type HashedAccountCredential,
|
|
41
|
+
hashAccountCredential,
|
|
42
|
+
loadDynamoAccountStore,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Constructs the config-seeded in-memory account store (the legacy
|
|
47
|
+
* fallback). Returns `undefined` when no accounts are configured so the
|
|
48
|
+
* actor's `ctx.accounts` stays unset (preserving the no-store behaviour:
|
|
49
|
+
* `AUTHENTICATE PLAIN` → `904`).
|
|
50
|
+
*
|
|
51
|
+
* @param serverConfig Parsed config carrying the `saslAccounts` seed list.
|
|
52
|
+
*/
|
|
53
|
+
export function bindAccountStore(serverConfig?: ParsedServerConfig): AccountStore | undefined {
|
|
54
|
+
if (serverConfig === undefined || serverConfig.saslAccounts.length === 0) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return new InMemoryAccountStore(serverConfig.saslAccounts);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolves the SASL account store for a deployment, applying the
|
|
62
|
+
* table-then-config precedence.
|
|
63
|
+
*
|
|
64
|
+
* 1. Scans the DynamoDB `Accounts` table (pre-loading every hashed
|
|
65
|
+
* credential into a {@link DynamoAccountStore}). When the table has
|
|
66
|
+
* one or more rows it is authoritative — the table wins and the
|
|
67
|
+
* config seed is ignored.
|
|
68
|
+
* 2. When the table is empty, falls back to {@link bindAccountStore}
|
|
69
|
+
* (the `SASL_ACCOUNTS` env-var seed) so existing deployments that
|
|
70
|
+
* have not migrated to the table keep working unchanged.
|
|
71
|
+
* 3. Returns `undefined` when neither source has accounts (the default)
|
|
72
|
+
* so `ctx.accounts` stays unset and `AUTHENTICATE PLAIN` → `904`.
|
|
73
|
+
*
|
|
74
|
+
* Scan failures (table not found, network error) are swallowed and
|
|
75
|
+
* treated as an empty table, falling through to the config seed.
|
|
76
|
+
*
|
|
77
|
+
* @param docClient DynamoDB client addressed at the deployment's tables.
|
|
78
|
+
* @param tableName Physical name of the `Accounts` table for this env.
|
|
79
|
+
* @param serverConfig Parsed config (the config-seed fallback source).
|
|
80
|
+
*/
|
|
81
|
+
export async function resolveAccountStore(
|
|
82
|
+
docClient: DynamoDBDocumentClient,
|
|
83
|
+
tableName: string,
|
|
84
|
+
serverConfig?: ParsedServerConfig,
|
|
85
|
+
): Promise<AccountStore | undefined> {
|
|
86
|
+
try {
|
|
87
|
+
const store = await loadDynamoAccountStore(docClient, tableName);
|
|
88
|
+
if (store !== undefined) return store;
|
|
89
|
+
} catch {
|
|
90
|
+
// Table might not exist or be unreachable; fall through to config seed.
|
|
91
|
+
}
|
|
92
|
+
return bindAccountStore(serverConfig);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Writes (or re-seeds) a SASL PLAIN account into the `Accounts` table.
|
|
97
|
+
*
|
|
98
|
+
* The password is hashed via {@link hashAccountCredential} (scrypt,
|
|
99
|
+
* random salt) and stored as a {@link HashedAccountCredential} row —
|
|
100
|
+
* plaintext is never written. A repeat call for the same `username`
|
|
101
|
+
* overwrites the prior row (the PK is `account`), so this is the
|
|
102
|
+
* canonical CRUD/seed entry point for oper and SASL account
|
|
103
|
+
* provisioning.
|
|
104
|
+
*
|
|
105
|
+
* @returns The stored {@link HashedAccountCredential} (for tooling/tests).
|
|
106
|
+
*/
|
|
107
|
+
export async function putAccountCredential(
|
|
108
|
+
docClient: DynamoDBDocumentClient,
|
|
109
|
+
tableName: string,
|
|
110
|
+
username: string,
|
|
111
|
+
password: string,
|
|
112
|
+
): Promise<HashedAccountCredential> {
|
|
113
|
+
const entry = hashAccountCredential(username, password);
|
|
114
|
+
await docClient.send(
|
|
115
|
+
new PutCommand({
|
|
116
|
+
TableName: tableName,
|
|
117
|
+
Item: entry,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
return entry;
|
|
121
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure admission policy for the AWS `$connect` handler.
|
|
3
|
+
*
|
|
4
|
+
* The I/O (counting live rows in DynamoDB) lives in `handlers/connect.ts`;
|
|
5
|
+
* the *decision* — "does this count fit under the caps?" — is a pure
|
|
6
|
+
* function extracted here so it is trivial to unit-test every branch
|
|
7
|
+
* without standing up DynamoDB Local.
|
|
8
|
+
*
|
|
9
|
+
* This is deliberately separate from irc-core's `decideAdmission` /
|
|
10
|
+
* `AdmissionStats`: that helper is an in-memory counter design that
|
|
11
|
+
* assumes a single long-lived process. AWS Lambda invocations are
|
|
12
|
+
* independent processes with no shared memory, so the authoritative
|
|
13
|
+
* counts MUST come from DynamoDB (the shared `Connections` table) and
|
|
14
|
+
* are fed into this pure policy.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Live connection counts gathered from DynamoDB.
|
|
19
|
+
*
|
|
20
|
+
* - `total` is always supplied (a paginated `Scan COUNT` over
|
|
21
|
+
* `Connections`).
|
|
22
|
+
* - `perIp` is optional: counting connections per source IP needs a
|
|
23
|
+
* GSI on `sourceIp` (see `handlers/connect.ts` for the deferred
|
|
24
|
+
* per-IP follow-up). Until that lands, callers omit `perIp` and the
|
|
25
|
+
* per-IP branch of {@link decideConnectAdmission} is skipped.
|
|
26
|
+
*/
|
|
27
|
+
export interface ConnectionCounts {
|
|
28
|
+
readonly total: number;
|
|
29
|
+
readonly perIp?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Effective caps sourced from {@link ParsedServerConfig}. `perIp` is
|
|
34
|
+
* optional because per-IP enforcement is not yet wired on the AWS path.
|
|
35
|
+
*/
|
|
36
|
+
export interface AdmissionLimits {
|
|
37
|
+
readonly maxClients: number;
|
|
38
|
+
readonly maxConnectionsPerIp?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Outcome of an admission decision. Mirrors the {@link ConnectOutcome}
|
|
43
|
+
* shape returned by `handleConnect` so the handler can return the
|
|
44
|
+
* decision verbatim.
|
|
45
|
+
*/
|
|
46
|
+
export type AdmissionOutcome =
|
|
47
|
+
| { readonly admitted: true }
|
|
48
|
+
| { readonly admitted: false; readonly statusCode: number; readonly reason: string };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decides whether a new connection should be admitted given the live
|
|
52
|
+
* counts and the configured caps. Pure: same inputs ⇒ same output, no
|
|
53
|
+
* ambient state.
|
|
54
|
+
*
|
|
55
|
+
* Precedence: the global `maxClients` cap is checked first (it is the
|
|
56
|
+
* cheap, always-available gate), then the optional per-IP cap when both
|
|
57
|
+
* the limit and the `perIp` count are present.
|
|
58
|
+
*/
|
|
59
|
+
export function decideConnectAdmission(
|
|
60
|
+
counts: ConnectionCounts,
|
|
61
|
+
limits: AdmissionLimits,
|
|
62
|
+
): AdmissionOutcome {
|
|
63
|
+
if (counts.total >= limits.maxClients) {
|
|
64
|
+
return { admitted: false, statusCode: 429, reason: 'server full' };
|
|
65
|
+
}
|
|
66
|
+
if (
|
|
67
|
+
limits.maxConnectionsPerIp !== undefined &&
|
|
68
|
+
counts.perIp !== undefined &&
|
|
69
|
+
counts.perIp >= limits.maxConnectionsPerIp
|
|
70
|
+
) {
|
|
71
|
+
return { admitted: false, statusCode: 429, reason: 'too many connections from this IP' };
|
|
72
|
+
}
|
|
73
|
+
return { admitted: true };
|
|
74
|
+
}
|
|
@@ -80,7 +80,8 @@ import type { TablesConfig } from './tables.js';
|
|
|
80
80
|
* is the in-memory `received` array.
|
|
81
81
|
* - `disconnect(reason?)` closes the bound transport. In Lambda this
|
|
82
82
|
* is a no-op — the canonical disconnect path is the APIGW `$disconnect`
|
|
83
|
-
* route, which fires after the socket is already gone.
|
|
83
|
+
* route, which fires after the socket is already gone. See
|
|
84
|
+
* `docs/AWS-Deployment.md` §14.1 for the full platform-limit write-up.
|
|
84
85
|
* - `snapshot()` returns the in-memory ConnectionState the actor is
|
|
85
86
|
* mutating this frame. Critical so `getConnection(boundId)` returns
|
|
86
87
|
* the live view (and dispatch's reducer-observable effects see
|
|
@@ -185,11 +186,12 @@ export class AwsRuntime implements IrcRuntime {
|
|
|
185
186
|
this.handlers.disconnect(reason);
|
|
186
187
|
return;
|
|
187
188
|
}
|
|
188
|
-
// Cross-connection disconnect is a no-op
|
|
189
|
-
//
|
|
189
|
+
// Cross-connection disconnect is a no-op: the Lambda runtime cannot
|
|
190
|
+
// directly close another connection's WebSocket. The canonical
|
|
190
191
|
// disconnect path is the APIGW `$disconnect` route, which fires when
|
|
191
192
|
// the socket is already gone. KICK/PART remove membership rows but
|
|
192
|
-
// do not need to force-close the socket.
|
|
193
|
+
// do not need to force-close the socket. See docs/AWS-Deployment.md
|
|
194
|
+
// §14.1 for the full platform-limit write-up and the mitigations.
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
async sendToNick(
|
|
@@ -627,56 +629,144 @@ export class AwsRuntime implements IrcRuntime {
|
|
|
627
629
|
|
|
628
630
|
/**
|
|
629
631
|
* Tears down a connection: deletes its Connections row, removes every
|
|
630
|
-
* ChannelMembers row owned by it,
|
|
632
|
+
* ChannelMembers row owned by it, releases its nick (if any), and — when
|
|
633
|
+
* a `managementApi` is supplied — broadcasts a `QUIT` line to every peer
|
|
634
|
+
* that shares a channel with the disconnecting connection.
|
|
631
635
|
*
|
|
632
636
|
* Idempotent: every step tolerates a missing row. Safe to call from
|
|
633
|
-
* `$disconnect`, from the `send` GoneException path, and
|
|
634
|
-
*
|
|
637
|
+
* `$disconnect`, from the `send`/`broadcast` `GoneException` path, and
|
|
638
|
+
* from the sweeper Lambda.
|
|
635
639
|
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
640
|
+
* Recursion safety: the Connections row is deleted *before* the QUIT
|
|
641
|
+
* fanout so a recursive `cleanupConnection` triggered by a gone peer
|
|
642
|
+
* during fanout finds the row already gone and returns immediately —
|
|
643
|
+
* no unbounded recursion.
|
|
644
|
+
*
|
|
645
|
+
* `managementApi` is optional because callers that never need fanout
|
|
646
|
+
* (the sweeper Lambda, or a no-op test) may pass `null`.
|
|
638
647
|
*/
|
|
639
648
|
export async function cleanupConnection(
|
|
640
649
|
dynamo: DynamoDBDocumentClient,
|
|
641
650
|
tables: TablesConfig,
|
|
642
651
|
connId: ConnId,
|
|
643
652
|
managementApi: ApiGatewayManagementApi | PostToConnection | null,
|
|
653
|
+
quitMessage = 'Client Quit',
|
|
644
654
|
): Promise<void> {
|
|
645
|
-
void managementApi; // reserved for a future "broadcast QUIT to peers" step
|
|
646
655
|
const connRow = await dynamo.send(
|
|
647
656
|
new GetCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
|
|
648
657
|
);
|
|
649
658
|
const item = connRow.Item as unknown as MarshalledConnection | undefined;
|
|
650
|
-
if (item
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
659
|
+
if (item === undefined) return; // idempotent — already cleaned up
|
|
660
|
+
|
|
661
|
+
// Delete the Connections row FIRST. A recursive cleanup triggered by
|
|
662
|
+
// a gone peer during QUIT fanout reads this same row, finds it gone,
|
|
663
|
+
// and returns — bounding the recursion at one extra hop.
|
|
664
|
+
await dynamo.send(
|
|
665
|
+
new DeleteCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
if (item.nick !== undefined) {
|
|
669
|
+
try {
|
|
670
|
+
await dynamo.send(
|
|
671
|
+
new DeleteCommand({
|
|
672
|
+
TableName: tables.Nicks,
|
|
673
|
+
Key: { nickLower: item.nick.toLowerCase() },
|
|
674
|
+
}),
|
|
675
|
+
);
|
|
676
|
+
} catch {
|
|
677
|
+
// Idempotent — the nick may have been released already.
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Broadcast QUIT to peers in shared channels *before* removing the
|
|
682
|
+
// disconnecting connection's membership rows — the roster query needs
|
|
683
|
+
// those rows to resolve the fanout targets.
|
|
684
|
+
if (item.nick !== undefined && managementApi !== null) {
|
|
685
|
+
await broadcastQuitToChannels(dynamo, tables, connId, item, managementApi, quitMessage);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const joined = item.joinedChannels ?? [];
|
|
689
|
+
for (const chan of joined) {
|
|
690
|
+
try {
|
|
691
|
+
await dynamo.send(
|
|
692
|
+
new DeleteCommand({
|
|
693
|
+
TableName: tables.ChannelMembers,
|
|
694
|
+
Key: { channelName: chan.toLowerCase(), connectionId: connId },
|
|
695
|
+
}),
|
|
696
|
+
);
|
|
697
|
+
} catch {
|
|
698
|
+
// Idempotent — the membership may have been removed already.
|
|
662
699
|
}
|
|
663
|
-
|
|
664
|
-
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Builds the canonical `:nick!user@host QUIT :<reason>` line for the
|
|
705
|
+
* disconnecting connection and fans it out to every *other* member of
|
|
706
|
+
* each channel the connection had joined. A peer whose APIGW socket is
|
|
707
|
+
* already gone raises `GoneException`, which is caught and routed back
|
|
708
|
+
* through {@link cleanupConnection} for lazy cleanup — a partially
|
|
709
|
+
* torn-down roster never crashes the fanout loop.
|
|
710
|
+
*
|
|
711
|
+
* Shared by both the `$disconnect` route and the `send()`/`broadcast()`
|
|
712
|
+
* `GoneException` cleanup path.
|
|
713
|
+
*/
|
|
714
|
+
async function broadcastQuitToChannels(
|
|
715
|
+
dynamo: DynamoDBDocumentClient,
|
|
716
|
+
tables: TablesConfig,
|
|
717
|
+
connId: ConnId,
|
|
718
|
+
item: MarshalledConnection,
|
|
719
|
+
managementApi: ApiGatewayManagementApi | PostToConnection,
|
|
720
|
+
quitMessage: string,
|
|
721
|
+
): Promise<void> {
|
|
722
|
+
const hostmask = buildHostmask(item);
|
|
723
|
+
if (hostmask === undefined) return; // no nick → nothing to attribute the QUIT to
|
|
724
|
+
const line = `:${hostmask} QUIT :${quitMessage}`;
|
|
725
|
+
const joined = item.joinedChannels ?? [];
|
|
726
|
+
for (const chan of joined) {
|
|
727
|
+
const members = await queryChannelMembersRows(dynamo, tables, chan.toLowerCase());
|
|
728
|
+
for (const member of members) {
|
|
729
|
+
if (member.connectionId === connId) continue; // never echo to self
|
|
665
730
|
try {
|
|
666
|
-
await
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
// Idempotent — the membership may have been removed already.
|
|
731
|
+
await managementApi.postToConnection({ ConnectionId: member.connectionId, Data: line });
|
|
732
|
+
} catch (err: unknown) {
|
|
733
|
+
if (err instanceof GoneException || isGone(err)) {
|
|
734
|
+
await cleanupConnection(dynamo, tables, member.connectionId, managementApi, quitMessage);
|
|
735
|
+
} else {
|
|
736
|
+
throw err;
|
|
737
|
+
}
|
|
674
738
|
}
|
|
675
739
|
}
|
|
676
740
|
}
|
|
677
|
-
|
|
678
|
-
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Builds the `nick!user@host` hostmask from a stored connection row,
|
|
745
|
+
* omitting the absent parts. Mirrors {@link hostmaskOf} in irc-core but
|
|
746
|
+
* operates on the marshalled shape (no full unmarshal needed).
|
|
747
|
+
*/
|
|
748
|
+
function buildHostmask(item: MarshalledConnection): string | undefined {
|
|
749
|
+
if (item.nick === undefined) return undefined;
|
|
750
|
+
let out: string = item.nick;
|
|
751
|
+
if (item.user !== undefined) out = `${out}!${item.user}`;
|
|
752
|
+
if (item.host !== undefined) out = `${out}@${item.host}`;
|
|
753
|
+
return out;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Queries the raw `ChannelMembers` rows for one channel (by lowercased key). */
|
|
757
|
+
async function queryChannelMembersRows(
|
|
758
|
+
dynamo: DynamoDBDocumentClient,
|
|
759
|
+
tables: TablesConfig,
|
|
760
|
+
key: string,
|
|
761
|
+
): Promise<MarshalledChannelMember[]> {
|
|
762
|
+
const result = await dynamo.send(
|
|
763
|
+
new QueryCommand({
|
|
764
|
+
TableName: tables.ChannelMembers,
|
|
765
|
+
KeyConditionExpression: 'channelName = :k',
|
|
766
|
+
ExpressionAttributeValues: { ':k': key },
|
|
767
|
+
}),
|
|
679
768
|
);
|
|
769
|
+
return (result.Items ?? []) as unknown as MarshalledChannelMember[];
|
|
680
770
|
}
|
|
681
771
|
|
|
682
772
|
// ---------------------------------------------------------------------------
|