serverless-ircd 0.2.0 → 0.3.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 +84 -0
- package/README.md +28 -19
- 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 +5 -1
- package/apps/cf-worker/wrangler.toml +41 -17
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +1 -0
- package/apps/local-cli/src/main.ts +1 -1
- package/apps/local-cli/src/server.ts +28 -2
- package/apps/local-cli/tests/e2e.test.ts +52 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +93 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/AWS-Adapter-Architecture.md +6 -4
- package/docs/AWS-Deployment.md +86 -7
- package/docs/Cloudflare-Deployment-Guide.md +5 -5
- package/docs/Home.md +11 -1
- package/docs/Release-Process.md +9 -6
- package/package.json +14 -15
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +121 -0
- package/packages/aws-adapter/src/aws-runtime.ts +118 -30
- package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
- package/packages/aws-adapter/src/config-loader.ts +30 -0
- package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
- package/packages/aws-adapter/src/handlers/default.ts +8 -0
- package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
- package/packages/aws-adapter/src/handlers/index.ts +51 -6
- package/packages/aws-adapter/src/index.ts +7 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
- package/packages/aws-adapter/tests/account-store.test.ts +280 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +55 -0
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
- package/packages/aws-adapter/tests/handlers.test.ts +46 -0
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +42 -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 +29 -0
- package/packages/cf-adapter/src/connection-do.ts +85 -0
- package/packages/cf-adapter/src/env.ts +27 -1
- package/packages/cf-adapter/src/index.ts +2 -1
- package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
- package/packages/cf-adapter/tests/worker/main.ts +2 -1
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
- package/packages/cf-adapter/wrangler.test.toml +10 -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 +3 -2
- package/packages/irc-core/src/case-fold.ts +64 -0
- package/packages/irc-core/src/commands/index.ts +2 -0
- package/packages/irc-core/src/commands/invite.ts +6 -9
- 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/registration.ts +8 -7
- package/packages/irc-core/src/commands/tagmsg.ts +205 -0
- package/packages/irc-core/src/config.ts +26 -3
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +68 -4
- package/packages/irc-core/src/types.ts +22 -0
- package/packages/irc-core/tests/account-store.test.ts +88 -0
- package/packages/irc-core/tests/case-fold.test.ts +80 -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/registration.test.ts +106 -14
- package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -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/config.test.ts +75 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +24 -0
- package/packages/irc-server/src/routing.ts +9 -0
- package/packages/irc-server/tests/actor.test.ts +220 -1
- package/packages/irc-server/tests/routing.test.ts +3 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
- package/pnpm-workspace.yaml +1 -0
- package/tools/seed-aws-accounts.ts +80 -0
- 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/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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ChannelRegistryDO` — Cloudflare Durable Object that tracks the set
|
|
3
|
+
* of all channel names.
|
|
4
|
+
*
|
|
5
|
+
* A single DO instance (keyed by the literal name `channels`) maintains
|
|
6
|
+
* the complete channel-name set so {@link CfRuntime.listChannels} can
|
|
7
|
+
* enumerate every channel without scanning each per-channel DO
|
|
8
|
+
* individually.
|
|
9
|
+
*
|
|
10
|
+
* Storage layout:
|
|
11
|
+
* - One key per channel: `chan:<lower>` → `1`
|
|
12
|
+
*
|
|
13
|
+
* Per-channel keys keep storage proportional to the live channel count
|
|
14
|
+
* and let individual register/unregister calls stay O(1).
|
|
15
|
+
*
|
|
16
|
+
* RPC surface — implements {@link ChannelRegistryRpc}. The ChannelDO
|
|
17
|
+
* calls `register` / `unregister` whenever its membership transitions
|
|
18
|
+
* between empty and non-empty; CfRuntime calls `list` for LIST.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
22
|
+
import type { ChannelRegistryRpc } from './env.js';
|
|
23
|
+
|
|
24
|
+
/** Storage key prefix for per-channel entries. */
|
|
25
|
+
const CHAN_KEY_PREFIX = 'chan:';
|
|
26
|
+
|
|
27
|
+
/** Returns the storage key for `nameLower`. */
|
|
28
|
+
function chanKey(nameLower: string): string {
|
|
29
|
+
return `${CHAN_KEY_PREFIX}${nameLower}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Channel registry authority.
|
|
34
|
+
*
|
|
35
|
+
* The class is exported as a binding target in `wrangler.toml`; the
|
|
36
|
+
* Workers runtime instantiates it with `(ctx, env)`.
|
|
37
|
+
*/
|
|
38
|
+
export class ChannelRegistryDO extends DurableObject implements ChannelRegistryRpc {
|
|
39
|
+
/** Adds `name` to the registry (idempotent). */
|
|
40
|
+
async register(name: string): Promise<void> {
|
|
41
|
+
await this.ctx.storage.put(chanKey(name.toLowerCase()), 1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Removes `name` from the registry (idempotent). */
|
|
45
|
+
async unregister(name: string): Promise<void> {
|
|
46
|
+
await this.ctx.storage.delete(chanKey(name.toLowerCase()));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Returns all registered channel names (lowercased). */
|
|
50
|
+
async list(): Promise<string[]> {
|
|
51
|
+
const entries = await this.ctx.storage.list({ prefix: CHAN_KEY_PREFIX });
|
|
52
|
+
const names: string[] = [];
|
|
53
|
+
for (const key of entries.keys()) {
|
|
54
|
+
names.push(key.slice(CHAN_KEY_PREFIX.length));
|
|
55
|
+
}
|
|
56
|
+
return names;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -34,6 +34,12 @@ export interface CfConfigEnv {
|
|
|
34
34
|
TOPIC_LEN?: string;
|
|
35
35
|
MAX_LIST_ENTRIES?: string;
|
|
36
36
|
QUIT_MESSAGE?: string;
|
|
37
|
+
/**
|
|
38
|
+
* SASL PLAIN accounts. Newline-delimited `username:password` pairs.
|
|
39
|
+
* Parsed into the `saslAccounts` config field so the config-driven
|
|
40
|
+
* path (vs the connection-do's direct env read) stays consistent.
|
|
41
|
+
*/
|
|
42
|
+
SASL_ACCOUNTS?: string;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
/**
|
|
@@ -90,9 +96,32 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
|
|
|
90
96
|
if (env.QUIT_MESSAGE !== undefined) {
|
|
91
97
|
input.quitMessage = env.QUIT_MESSAGE;
|
|
92
98
|
}
|
|
99
|
+
if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
|
|
100
|
+
input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
|
|
101
|
+
}
|
|
93
102
|
return input;
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Parses a newline-delimited `username:password` string into the config's
|
|
107
|
+
* `saslAccounts` shape. Malformed entries are skipped so a trailing
|
|
108
|
+
* newline or partial edit does not break boot.
|
|
109
|
+
*/
|
|
110
|
+
function parseSaslAccountsLines(raw: string): Array<{ username: string; password: string }> {
|
|
111
|
+
const out: Array<{ username: string; password: string }> = [];
|
|
112
|
+
for (const line of raw.split('\n')) {
|
|
113
|
+
const trimmed = line.trim();
|
|
114
|
+
if (trimmed.length === 0) continue;
|
|
115
|
+
const sep = trimmed.indexOf(':');
|
|
116
|
+
if (sep <= 0) continue;
|
|
117
|
+
const username = trimmed.slice(0, sep);
|
|
118
|
+
const password = trimmed.slice(sep + 1);
|
|
119
|
+
if (username.length === 0 || password.length === 0) continue;
|
|
120
|
+
out.push({ username, password });
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
96
125
|
/**
|
|
97
126
|
* Loads and validates the server config from a Cloudflare Workers env.
|
|
98
127
|
*
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import { DurableObject } from 'cloudflare:workers';
|
|
31
31
|
import {
|
|
32
|
+
type AccountStore,
|
|
32
33
|
type ChanName,
|
|
33
34
|
type ChannelState,
|
|
34
35
|
type Clock,
|
|
@@ -36,12 +37,14 @@ import {
|
|
|
36
37
|
type ConnectionState,
|
|
37
38
|
ConsoleLogger,
|
|
38
39
|
type IdFactory,
|
|
40
|
+
InMemoryAccountStore,
|
|
39
41
|
InMemoryMessageStore,
|
|
40
42
|
LogLevel,
|
|
41
43
|
type Logger,
|
|
42
44
|
type MessageStore,
|
|
43
45
|
type MotdProvider,
|
|
44
46
|
type RawLine,
|
|
47
|
+
type SaslAccountCredential,
|
|
45
48
|
type ServerConfig,
|
|
46
49
|
SystemClock,
|
|
47
50
|
UuidIdFactory,
|
|
@@ -55,6 +58,7 @@ import { makeCfRuntime } from './cf-runtime.js';
|
|
|
55
58
|
import type { CfConnectionHandlers } from './cf-runtime.js';
|
|
56
59
|
import type { Env } from './env.js';
|
|
57
60
|
import { PERSISTED_STATE_VERSION, STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
|
|
61
|
+
import type { PersistedConnectionState } from './serialize.js';
|
|
58
62
|
|
|
59
63
|
/** Default PING cadence (ms) — tuned for typical IRC client behavior. */
|
|
60
64
|
export const DEFAULT_PING_INTERVAL_MS = 60_000;
|
|
@@ -104,6 +108,13 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
104
108
|
* follow-up. Lazily constructed so the first frame seeds it.
|
|
105
109
|
*/
|
|
106
110
|
private messages: MessageStore | undefined;
|
|
111
|
+
/**
|
|
112
|
+
* In-worker SASL account store seeded from the `SASL_ACCOUNTS` env var.
|
|
113
|
+
* Lazily constructed so the first frame that needs it parses the env once.
|
|
114
|
+
* A Durable-Object / D1-backed persistent variant is a documented
|
|
115
|
+
* follow-up; swapping it in means replacing {@link accountStore}.
|
|
116
|
+
*/
|
|
117
|
+
private accounts: AccountStore | undefined;
|
|
107
118
|
private readonly clock: Clock = SystemClock;
|
|
108
119
|
private readonly ids: IdFactory = new UuidIdFactory();
|
|
109
120
|
private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
|
|
@@ -311,6 +322,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
311
322
|
// Bound `connectionId` so every record the actor emits is filterable
|
|
312
323
|
// per-connection in the Workers dashboard.
|
|
313
324
|
const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
|
|
325
|
+
const accounts = this.accountStore();
|
|
314
326
|
return new ConnectionActor({
|
|
315
327
|
state,
|
|
316
328
|
runtime,
|
|
@@ -320,6 +332,7 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
320
332
|
ids: this.ids,
|
|
321
333
|
motd,
|
|
322
334
|
messages: this.messageStore(),
|
|
335
|
+
...(accounts !== undefined ? { accounts } : {}),
|
|
323
336
|
logger,
|
|
324
337
|
});
|
|
325
338
|
}
|
|
@@ -384,6 +397,21 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
384
397
|
return this.messages;
|
|
385
398
|
}
|
|
386
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Lazily constructs the in-worker SASL account store from the
|
|
402
|
+
* `SASL_ACCOUNTS` env var. Returns `undefined` when no accounts are
|
|
403
|
+
* configured so the actor's `ctx.accounts` stays unset (preserving the
|
|
404
|
+
* no-store behaviour: `AUTHENTICATE PLAIN` → `904`). A Durable-Object /
|
|
405
|
+
* D1-backed persistent variant is a documented follow-up.
|
|
406
|
+
*/
|
|
407
|
+
private accountStore(): AccountStore | undefined {
|
|
408
|
+
if (this.accounts !== undefined) return this.accounts;
|
|
409
|
+
const creds = parseSaslAccountsEnv(this.env.SASL_ACCOUNTS);
|
|
410
|
+
if (creds.length === 0) return undefined;
|
|
411
|
+
this.accounts = new InMemoryAccountStore(creds);
|
|
412
|
+
return this.accounts;
|
|
413
|
+
}
|
|
414
|
+
|
|
387
415
|
// -------------------------------------------------------------------------
|
|
388
416
|
// Test hooks (only invoked from `runInDurableObject` in tests)
|
|
389
417
|
// -------------------------------------------------------------------------
|
|
@@ -470,6 +498,38 @@ export class ConnectionDO extends DurableObject<Env> {
|
|
|
470
498
|
const state = await this.loadState();
|
|
471
499
|
return toSnapshot(state);
|
|
472
500
|
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Cross-connection RPC: returns the full connection state as a
|
|
504
|
+
* JSON-friendly DTO ({@link PersistedConnectionState}). Called by
|
|
505
|
+
* another connection's {@link CfRuntime} when a `GetConnection`
|
|
506
|
+
* lookup targets this connection (e.g. WHOIS). The DTO survives DO
|
|
507
|
+
* RPC because `Set`/`Map` fields are pre-flattened to arrays by
|
|
508
|
+
* {@link serialize}; the caller rehydrates via {@link deserialize}.
|
|
509
|
+
*/
|
|
510
|
+
public async getConnState(): Promise<PersistedConnectionState | null> {
|
|
511
|
+
const state = await this.loadState();
|
|
512
|
+
return serialize(state);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Cross-connection RPC: tears down this connection. Reuses the same
|
|
517
|
+
* QUIT fan-out / nick-release / WebSocket-close path as the normal
|
|
518
|
+
* `webSocketClose` handler so KICK and operator-driven disconnect
|
|
519
|
+
* produce identical side effects. If no live socket is attached the
|
|
520
|
+
* no-socket teardown path still releases the nick.
|
|
521
|
+
*/
|
|
522
|
+
public async disconnect(reason?: string): Promise<void> {
|
|
523
|
+
void reason;
|
|
524
|
+
const sockets = this.ctx.getWebSockets();
|
|
525
|
+
const ws = sockets[0];
|
|
526
|
+
if (ws === undefined) {
|
|
527
|
+
const state = await this.loadState();
|
|
528
|
+
await this.tearDownNoSocket(state);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
await this.tearDown(ws as WebSocket);
|
|
532
|
+
}
|
|
473
533
|
}
|
|
474
534
|
|
|
475
535
|
/** Default MOTD when no `MOTD_LINES` env is supplied. */
|
|
@@ -574,3 +634,28 @@ class PassthroughChannelAccess {
|
|
|
574
634
|
// in production today. (Removed from the ConnectionDO path because the
|
|
575
635
|
// authoritative delta application lives in the ChannelDO stub today.)
|
|
576
636
|
export { applyChannelDelta, PERSISTED_STATE_VERSION };
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Parses the `SASL_ACCOUNTS` env var into credential entries.
|
|
640
|
+
*
|
|
641
|
+
* Format: newline-delimited `username:password` pairs (mirroring the
|
|
642
|
+
* `MOTD_LINES` convention). Blank lines and malformed entries (missing
|
|
643
|
+
* colon or empty username/password) are skipped so a trailing newline or a
|
|
644
|
+
* partial edit does not break boot. Exported so the parsing boundary is
|
|
645
|
+
* unit-testable in isolation.
|
|
646
|
+
*/
|
|
647
|
+
export function parseSaslAccountsEnv(raw: string | undefined): SaslAccountCredential[] {
|
|
648
|
+
if (raw === undefined || raw.length === 0) return [];
|
|
649
|
+
const out: SaslAccountCredential[] = [];
|
|
650
|
+
for (const line of raw.split('\n')) {
|
|
651
|
+
const trimmed = line.trim();
|
|
652
|
+
if (trimmed.length === 0) continue;
|
|
653
|
+
const sep = trimmed.indexOf(':');
|
|
654
|
+
if (sep <= 0) continue; // missing colon or empty username
|
|
655
|
+
const username = trimmed.slice(0, sep);
|
|
656
|
+
const password = trimmed.slice(sep + 1);
|
|
657
|
+
if (username.length === 0 || password.length === 0) continue;
|
|
658
|
+
out.push({ username, password });
|
|
659
|
+
}
|
|
660
|
+
return out;
|
|
661
|
+
}
|
|
@@ -31,13 +31,28 @@ export interface Env {
|
|
|
31
31
|
REGISTRY_DO: DurableObjectNamespace;
|
|
32
32
|
/**
|
|
33
33
|
* Channel authority, keyed by lowercased channel name. RPC: broadcast /
|
|
34
|
-
* applyChannelDelta / getChannelSnapshot. Stubbed now;
|
|
34
|
+
* applyChannelDelta / getChannelSnapshot / listMembers. Stubbed now;
|
|
35
|
+
* real in 035.
|
|
35
36
|
*/
|
|
36
37
|
CHANNEL_DO: DurableObjectNamespace;
|
|
38
|
+
/**
|
|
39
|
+
* Channel registry authority. A single DO instance tracks the set of
|
|
40
|
+
* all channel names so {@link CfRuntime.listChannels} can enumerate
|
|
41
|
+
* them without scanning every per-channel DO. RPC: register /
|
|
42
|
+
* unregister / list.
|
|
43
|
+
*/
|
|
44
|
+
CHANNEL_REGISTRY_DO: DurableObjectNamespace;
|
|
37
45
|
/** Server-level config knobs (name, MOTD lines, limits). */
|
|
38
46
|
SERVER_NAME: string;
|
|
39
47
|
NETWORK_NAME: string;
|
|
40
48
|
MOTD_LINES: string;
|
|
49
|
+
/**
|
|
50
|
+
* SASL PLAIN accounts seeded into the in-worker `AccountStore`.
|
|
51
|
+
* Newline-delimited `username:password` pairs; the connection-do parses
|
|
52
|
+
* these into an `InMemoryAccountStore` so `AUTHENTICATE PLAIN` works
|
|
53
|
+
* end-to-end. Empty/undefined disables SASL account verification.
|
|
54
|
+
*/
|
|
55
|
+
SASL_ACCOUNTS?: string;
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
/**
|
|
@@ -64,4 +79,15 @@ export interface ChannelRpc {
|
|
|
64
79
|
broadcast(lines: unknown[], except?: string): Promise<void>;
|
|
65
80
|
applyChannelDelta(delta: unknown): Promise<void>;
|
|
66
81
|
getChannelSnapshot(): Promise<unknown>;
|
|
82
|
+
listMembers(): Promise<string[]>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* RPC contract for the channel registry DO. Tracks the set of all
|
|
87
|
+
* channel names so {@link CfRuntime.listChannels} can enumerate them.
|
|
88
|
+
*/
|
|
89
|
+
export interface ChannelRegistryRpc {
|
|
90
|
+
register(name: string): Promise<void>;
|
|
91
|
+
unregister(name: string): Promise<void>;
|
|
92
|
+
list(): Promise<string[]>;
|
|
67
93
|
}
|
|
@@ -19,6 +19,7 @@ export {
|
|
|
19
19
|
} from './connection-do.js';
|
|
20
20
|
export { RegistryDO } from './registry-do.js';
|
|
21
21
|
export { ChannelDO } from './channel-do.js';
|
|
22
|
+
export { ChannelRegistryDO } from './channel-registry-do.js';
|
|
22
23
|
export type { DeliverResult, ConnectionDeliveryRpc } from './channel-do.js';
|
|
23
24
|
export {
|
|
24
25
|
DEFAULT_REGISTRY_SHARDS,
|
|
@@ -27,7 +28,7 @@ export {
|
|
|
27
28
|
} from './sharding.js';
|
|
28
29
|
export { makeCfRuntime } from './cf-runtime.js';
|
|
29
30
|
export type { CfConnectionHandlers } from './cf-runtime.js';
|
|
30
|
-
export type { ChannelRpc, Env, RegistryRpc } from './env.js';
|
|
31
|
+
export type { ChannelRpc, ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
|
|
31
32
|
export {
|
|
32
33
|
PERSISTED_STATE_VERSION,
|
|
33
34
|
STATE_STORAGE_KEY,
|
|
@@ -44,6 +44,7 @@ declare global {
|
|
|
44
44
|
CHANNEL_DO: DurableObjectNamespace;
|
|
45
45
|
CHANNEL_DO_REAL: DurableObjectNamespace;
|
|
46
46
|
CHANNEL_DO_STUB: DurableObjectNamespace;
|
|
47
|
+
CHANNEL_REGISTRY_DO: DurableObjectNamespace;
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
}
|
|
@@ -62,6 +63,7 @@ function envWithRealDOs(): Env {
|
|
|
62
63
|
CONNECTION_DO: env.CONNECTION_DO,
|
|
63
64
|
REGISTRY_DO: env.REGISTRY_DO_REAL,
|
|
64
65
|
CHANNEL_DO: env.CHANNEL_DO_REAL,
|
|
66
|
+
CHANNEL_REGISTRY_DO: env.CHANNEL_REGISTRY_DO,
|
|
65
67
|
SERVER_NAME: 'irc.example.com',
|
|
66
68
|
NETWORK_NAME: 'ExampleNet',
|
|
67
69
|
MOTD_LINES: '',
|
|
@@ -78,6 +80,7 @@ function envWithChannelStub(): Env {
|
|
|
78
80
|
CONNECTION_DO: env.CONNECTION_DO,
|
|
79
81
|
REGISTRY_DO: env.REGISTRY_DO_REAL,
|
|
80
82
|
CHANNEL_DO: env.CHANNEL_DO_STUB,
|
|
83
|
+
CHANNEL_REGISTRY_DO: env.CHANNEL_REGISTRY_DO,
|
|
81
84
|
SERVER_NAME: 'irc.example.com',
|
|
82
85
|
NETWORK_NAME: 'ExampleNet',
|
|
83
86
|
MOTD_LINES: '',
|
|
@@ -484,6 +487,94 @@ describe('CfRuntime — cross-connection RPC via ConnectionDO', () => {
|
|
|
484
487
|
});
|
|
485
488
|
});
|
|
486
489
|
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Tests — cross-connection getConnection / getChannelConnections / listChannels / disconnect
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
describe('CfRuntime — cross-connection lookups and disconnect', () => {
|
|
495
|
+
it('getConnection(remoteConnId) returns the remote connection state via RPC', async () => {
|
|
496
|
+
const targetName = 'conn-get-target';
|
|
497
|
+
const ws = await openConnection(targetName);
|
|
498
|
+
await new Promise<void>((r) => setTimeout(r, 20));
|
|
499
|
+
ws.send('NICK carol\r\n');
|
|
500
|
+
ws.send('USER carol 0 * :Carol\r\n');
|
|
501
|
+
await new Promise<void>((r) => setTimeout(r, 100));
|
|
502
|
+
|
|
503
|
+
const targetHexId = await getConnectionHexId(targetName);
|
|
504
|
+
const realEnv = envWithRealDOs();
|
|
505
|
+
const rt = new CfRuntime(realEnv, 'conn-get-caller', recordingHandlers());
|
|
506
|
+
const conn = await rt.getConnection(targetHexId);
|
|
507
|
+
expect(conn).not.toBeNull();
|
|
508
|
+
expect(conn?.nick).toBe('carol');
|
|
509
|
+
expect(conn?.id).toBe(targetHexId);
|
|
510
|
+
|
|
511
|
+
ws.close();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('getChannelConnections(chan) returns the ConnectionStates of all members', async () => {
|
|
515
|
+
const memberName = 'conn-who-target';
|
|
516
|
+
const ws = await openConnection(memberName);
|
|
517
|
+
await new Promise<void>((r) => setTimeout(r, 20));
|
|
518
|
+
ws.send('NICK dave\r\n');
|
|
519
|
+
ws.send('USER dave 0 * :Dave\r\n');
|
|
520
|
+
await new Promise<void>((r) => setTimeout(r, 100));
|
|
521
|
+
|
|
522
|
+
const memberHexId = await getConnectionHexId(memberName);
|
|
523
|
+
const realEnv = envWithRealDOs();
|
|
524
|
+
const rt = new CfRuntime(realEnv, 'conn-who-caller', recordingHandlers());
|
|
525
|
+
|
|
526
|
+
await rt.applyChannelDelta('#whochan', {
|
|
527
|
+
memberships: [{ type: 'add' as const, conn: memberHexId, nick: 'dave' }],
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const conns = await rt.getChannelConnections('#whochan');
|
|
531
|
+
expect(conns.size).toBe(1);
|
|
532
|
+
expect(conns.get(memberHexId)?.nick).toBe('dave');
|
|
533
|
+
|
|
534
|
+
ws.close();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('listChannels returns channels registered in the registry', async () => {
|
|
538
|
+
const realEnv = envWithRealDOs();
|
|
539
|
+
const rt = new CfRuntime(realEnv, 'conn-list', recordingHandlers());
|
|
540
|
+
|
|
541
|
+
await rt.applyChannelDelta('#listchan', {
|
|
542
|
+
memberships: [{ type: 'add' as const, conn: 'conn-list', nick: 'eve' }],
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
const channels = await rt.listChannels();
|
|
546
|
+
expect(channels.length).toBeGreaterThan(0);
|
|
547
|
+
expect(channels.some((c) => c.nameLower === '#listchan')).toBe(true);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('listChannels returns empty array when no channels exist', async () => {
|
|
551
|
+
const realEnv = envWithRealDOs();
|
|
552
|
+
const rt = new CfRuntime(realEnv, 'conn-list-empty', recordingHandlers());
|
|
553
|
+
const channels = await rt.listChannels();
|
|
554
|
+
expect(channels).toEqual([]);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('disconnect(remoteConn) closes the target connection', async () => {
|
|
558
|
+
const targetName = 'conn-disc-target';
|
|
559
|
+
const ws = await openConnection(targetName);
|
|
560
|
+
await new Promise<void>((r) => setTimeout(r, 20));
|
|
561
|
+
|
|
562
|
+
const targetHexId = await getConnectionHexId(targetName);
|
|
563
|
+
const realEnv = envWithRealDOs();
|
|
564
|
+
const rt = new CfRuntime(realEnv, 'conn-disc-caller', recordingHandlers());
|
|
565
|
+
|
|
566
|
+
const closed = new Promise<void>((resolve) => {
|
|
567
|
+
ws.addEventListener('close', () => resolve());
|
|
568
|
+
setTimeout(resolve, 2000);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
await rt.disconnect(targetHexId, 'Kicked');
|
|
572
|
+
await closed;
|
|
573
|
+
|
|
574
|
+
expect([WebSocket.CLOSING, WebSocket.CLOSED]).toContain(ws.readyState);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
487
578
|
// ---------------------------------------------------------------------------
|
|
488
579
|
// Tests — implements IrcRuntime
|
|
489
580
|
// ---------------------------------------------------------------------------
|
|
@@ -366,6 +366,46 @@ describe('ConnectionDO — chathistory end-to-end', () => {
|
|
|
366
366
|
});
|
|
367
367
|
});
|
|
368
368
|
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// SASL PLAIN end-to-end (AccountStore plumbing)
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
describe('ConnectionDO — SASL PLAIN end-to-end', () => {
|
|
374
|
+
/** base64(`\0authcid\0password`) — the PLAIN payload format. */
|
|
375
|
+
function plainB64(authcid: string, password: string): string {
|
|
376
|
+
// workerd has no `Buffer`; use btoa on a Latin1-encoded string.
|
|
377
|
+
const raw = `\0${authcid}\0${password}`;
|
|
378
|
+
let latin1 = '';
|
|
379
|
+
for (let i = 0; i < raw.length; i++) latin1 += String.fromCharCode(raw.charCodeAt(i));
|
|
380
|
+
return (globalThis as unknown as { btoa: (s: string) => string }).btoa(latin1);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
it('completes SASL PLAIN with 900/903 for valid seeded creds', async () => {
|
|
384
|
+
const client = await connect('conn-sasl-1');
|
|
385
|
+
client.send('CAP REQ :sasl\r\n');
|
|
386
|
+
await client.waitForMessage((l) => / CAP \S+ ACK :sasl/.test(l));
|
|
387
|
+
client.send('AUTHENTICATE PLAIN\r\n');
|
|
388
|
+
await client.waitForMessage((l) => l === 'AUTHENTICATE +');
|
|
389
|
+
client.send(`AUTHENTICATE ${plainB64('cf-sasl', 's3cret')}\r\n`);
|
|
390
|
+
await client.waitForMessage((l) => l.includes(' 900 '));
|
|
391
|
+
const success = await client.waitForMessage((l) => l.includes(' 903 '));
|
|
392
|
+
expect(success).toContain(' 903 ');
|
|
393
|
+
client.ws.close();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('rejects SASL PLAIN with 904 for invalid creds', async () => {
|
|
397
|
+
const client = await connect('conn-sasl-2');
|
|
398
|
+
client.send('CAP REQ :sasl\r\n');
|
|
399
|
+
await client.waitForMessage((l) => / CAP \S+ ACK :sasl/.test(l));
|
|
400
|
+
client.send('AUTHENTICATE PLAIN\r\n');
|
|
401
|
+
await client.waitForMessage((l) => l === 'AUTHENTICATE +');
|
|
402
|
+
client.send(`AUTHENTICATE ${plainB64('cf-sasl', 'wrong')}\r\n`);
|
|
403
|
+
const fail = await client.waitForMessage((l) => l.includes(' 904 '));
|
|
404
|
+
expect(fail).toContain(' 904 ');
|
|
405
|
+
client.ws.close();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
369
409
|
// ---------------------------------------------------------------------------
|
|
370
410
|
// End of file
|
|
371
411
|
// ---------------------------------------------------------------------------
|
|
@@ -19,12 +19,13 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { ChannelDO } from '../../src/channel-do';
|
|
22
|
+
import { ChannelRegistryDO } from '../../src/channel-registry-do';
|
|
22
23
|
import { ConnectionDO } from '../../src/connection-do';
|
|
23
24
|
import { RegistryDO } from '../../src/registry-do';
|
|
24
25
|
import { RecordingChannelDO } from './stubs/channel-stub';
|
|
25
26
|
import { RecordingRegistryDO } from './stubs/registry-stub';
|
|
26
27
|
|
|
27
|
-
export { ChannelDO, ConnectionDO, RegistryDO, RecordingRegistryDO, RecordingChannelDO };
|
|
28
|
+
export { ChannelDO, ChannelRegistryDO, ConnectionDO, RegistryDO, RecordingRegistryDO, RecordingChannelDO };
|
|
28
29
|
|
|
29
30
|
export default {
|
|
30
31
|
async fetch(): Promise<Response> {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { DurableObject } from 'cloudflare:workers';
|
|
13
13
|
|
|
14
14
|
export interface RecordedChannelCall {
|
|
15
|
-
method: 'broadcast' | 'applyChannelDelta' | 'getChannelSnapshot';
|
|
15
|
+
method: 'broadcast' | 'applyChannelDelta' | 'getChannelSnapshot' | 'listMembers';
|
|
16
16
|
args: unknown[];
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -20,6 +20,7 @@ interface ChannelRpc {
|
|
|
20
20
|
broadcast(lines: unknown[], except?: string): Promise<void>;
|
|
21
21
|
applyChannelDelta(delta: unknown): Promise<void>;
|
|
22
22
|
getChannelSnapshot(): Promise<unknown>;
|
|
23
|
+
listMembers(): Promise<string[]>;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const LOG_KEY = 'channel-call-log';
|
|
@@ -47,4 +48,9 @@ export class RecordingChannelDO extends DurableObject implements ChannelRpc {
|
|
|
47
48
|
await this.append({ method: 'getChannelSnapshot', args: [] });
|
|
48
49
|
return null;
|
|
49
50
|
}
|
|
51
|
+
|
|
52
|
+
async listMembers(): Promise<string[]> {
|
|
53
|
+
await this.append({ method: 'listMembers', args: [] });
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
50
56
|
}
|
|
@@ -9,6 +9,10 @@ compatibility_flags = ["nodejs_compat"]
|
|
|
9
9
|
# body text in every runtime factory.
|
|
10
10
|
[vars]
|
|
11
11
|
MOTD_LINES = "Welcome to the ServerlessIRCd integration-test harness.\nAll scenarios run parametrized over the IrcRuntime factory matrix."
|
|
12
|
+
# SASL PLAIN accounts seeded into the in-worker AccountStore. Newline-
|
|
13
|
+
# delimited `username:password` pairs; the connection-do parses these into
|
|
14
|
+
# an InMemoryAccountStore so AUTHENTICATE PLAIN works end-to-end in tests.
|
|
15
|
+
SASL_ACCOUNTS = "cf-sasl:s3cret"
|
|
12
16
|
|
|
13
17
|
# Production bindings: REGISTRY_DO and CHANNEL_DO point at the real DO
|
|
14
18
|
# classes so CfRuntime, ConnectionDO, and the integration suite all
|
|
@@ -25,6 +29,11 @@ class_name = "RegistryDO"
|
|
|
25
29
|
name = "CHANNEL_DO"
|
|
26
30
|
class_name = "ChannelDO"
|
|
27
31
|
|
|
32
|
+
# Channel registry — single DO tracking all channel names for LIST.
|
|
33
|
+
[[durable_objects.bindings]]
|
|
34
|
+
name = "CHANNEL_REGISTRY_DO"
|
|
35
|
+
class_name = "ChannelRegistryDO"
|
|
36
|
+
|
|
28
37
|
# Recording stubs (kept under separate names for any test that wants to
|
|
29
38
|
# assert "this exact RPC was emitted" rather than the end-to-end effect).
|
|
30
39
|
[[durable_objects.bindings]]
|
|
@@ -47,4 +56,4 @@ class_name = "ChannelDO"
|
|
|
47
56
|
|
|
48
57
|
[[migrations]]
|
|
49
58
|
tag = "v1"
|
|
50
|
-
new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "RecordingRegistryDO", "RecordingChannelDO"]
|
|
59
|
+
new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "RecordingRegistryDO", "RecordingChannelDO"]
|