serverless-ircd 0.7.0 → 0.8.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 +3 -3
- package/.gitmodules +1 -1
- package/CHANGELOG.md +273 -29
- package/README.md +155 -55
- 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-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 +1 -0
- package/apps/local-cli/tests/config-loader.test.ts +14 -0
- package/apps/web/landing/index.html +14 -16
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +23 -16
- package/apps/web/src/build-env.ts +1 -1
- package/apps/web/src/config-schema.ts +6 -6
- package/apps/web/tests/build-smoke.test.ts +14 -14
- package/apps/web/tests/config-schema.test.ts +1 -1
- 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 +546 -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 +5 -0
- 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 +15 -0
- 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/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 +127 -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/handlers.test.ts +105 -3
- 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 +112 -2
- 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.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 +172 -0
- package/packages/irc-core/src/commands/chanserv.ts +882 -0
- package/packages/irc-core/src/commands/hostserv.ts +487 -0
- package/packages/irc-core/src/commands/index.ts +12 -0
- package/packages/irc-core/src/commands/join.ts +164 -8
- 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 +390 -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 +61 -6
- package/packages/irc-core/src/commands/sasl.ts +18 -49
- package/packages/irc-core/src/commands/tagmsg.ts +41 -6
- package/packages/irc-core/src/commands/topic.ts +37 -0
- 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 +21 -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 +1668 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
- package/packages/irc-core/tests/commands/join.test.ts +393 -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 +807 -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 +788 -14
- package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
- package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
- 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 +71 -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 +623 -12
- 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 +29 -3
- package/packages/irc-test-support/src/index.ts +1 -0
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
- 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
|
@@ -89,6 +89,14 @@ export interface Env {
|
|
|
89
89
|
* Cloudflare secret (never a plaintext `[vars]` entry) in production.
|
|
90
90
|
*/
|
|
91
91
|
OPER_PASSWORD?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Server-password gate. Treat as a Cloudflare secret (set via
|
|
94
|
+
* `wrangler secret put SERVER_PASSWORD`); the loader threads it into
|
|
95
|
+
* `ServerConfig.serverPassword` so the registration reducer requires
|
|
96
|
+
* `PASS <value>` (or a SASL-identified connection) before
|
|
97
|
+
* `001 RPL_WELCOME`. Undefined / empty disables the gate.
|
|
98
|
+
*/
|
|
99
|
+
SERVER_PASSWORD?: string;
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
/**
|
|
@@ -32,6 +32,11 @@ export {
|
|
|
32
32
|
putAccountCredential,
|
|
33
33
|
resolveAccountStore,
|
|
34
34
|
} from './d1-account-store.js';
|
|
35
|
+
export {
|
|
36
|
+
CREATE_SERVICES_TABLES_SQL,
|
|
37
|
+
D1ServicesStore,
|
|
38
|
+
loadD1ServicesStore,
|
|
39
|
+
} from './d1-services-store.js';
|
|
35
40
|
export {
|
|
36
41
|
PERSISTED_STATE_VERSION,
|
|
37
42
|
STATE_STORAGE_KEY,
|
|
@@ -17,6 +17,12 @@ interface CfEnvLike {
|
|
|
17
17
|
CHANNEL_PREFIXES?: string;
|
|
18
18
|
OPER_USER?: string;
|
|
19
19
|
OPER_PASSWORD?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Server-password gate. Treat as a wrangler secret in production
|
|
22
|
+
* (never a plaintext `[vars]` entry); the loader threads it through
|
|
23
|
+
* to `ServerConfig.serverPassword`.
|
|
24
|
+
*/
|
|
25
|
+
SERVER_PASSWORD?: string;
|
|
20
26
|
MAX_CHANNELS_PER_USER?: string;
|
|
21
27
|
MAX_TARGETS_PER_COMMAND?: string;
|
|
22
28
|
NICK_LEN?: string;
|
|
@@ -76,6 +82,19 @@ describe('loadServerConfigFromCfEnv — valid env', () => {
|
|
|
76
82
|
expect(cfg.operCreds).toEqual([{ user: 'admin', password: 'hunter2' }]);
|
|
77
83
|
});
|
|
78
84
|
|
|
85
|
+
it('threads SERVER_PASSWORD through to serverPassword', () => {
|
|
86
|
+
const cfg = loadServerConfigFromCfEnv({
|
|
87
|
+
...baseEnv(),
|
|
88
|
+
SERVER_PASSWORD: 'hunter2',
|
|
89
|
+
});
|
|
90
|
+
expect(cfg.serverPassword).toBe('hunter2');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('leaves serverPassword undefined when SERVER_PASSWORD is unset', () => {
|
|
94
|
+
const cfg = loadServerConfigFromCfEnv(baseEnv());
|
|
95
|
+
expect(cfg.serverPassword).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
79
98
|
it('parses numeric limit overrides from string env vars', () => {
|
|
80
99
|
const cfg = loadServerConfigFromCfEnv({
|
|
81
100
|
...baseEnv(),
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end NickServ registration persistence across two ConnectionDO
|
|
3
|
+
* instances (simulating a register on one connection, then reconnect).
|
|
4
|
+
*
|
|
5
|
+
* Regression guard for the symptom: "NickServ says I registered, but on
|
|
6
|
+
* reconnect it says the nick is not registered." Each connection is a
|
|
7
|
+
* separate ConnectionDO that hydrates its services store from D1 on first
|
|
8
|
+
* frame and write-behind-flushes on frame exit. This test proves the D1
|
|
9
|
+
* round-trip survives across independent DO instances.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { env } from 'cloudflare:test';
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
|
+
|
|
15
|
+
declare global {
|
|
16
|
+
namespace Cloudflare {
|
|
17
|
+
interface Env {
|
|
18
|
+
CONNECTION_DO: DurableObjectNamespace;
|
|
19
|
+
REGISTRY_DO: DurableObjectNamespace;
|
|
20
|
+
CHANNEL_DO: DurableObjectNamespace;
|
|
21
|
+
ACCOUNTS_DB: D1Database;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class IrcWsClient {
|
|
27
|
+
readonly received: string[] = [];
|
|
28
|
+
readonly ws: WebSocket;
|
|
29
|
+
|
|
30
|
+
constructor(ws: WebSocket) {
|
|
31
|
+
this.ws = ws;
|
|
32
|
+
ws.addEventListener('message', (ev: MessageEvent) => {
|
|
33
|
+
const d = ev.data as string;
|
|
34
|
+
for (const line of d.split('\r\n')) {
|
|
35
|
+
if (line.length > 0) this.received.push(line);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
send(line: string): void {
|
|
41
|
+
this.ws.send(line);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
waitForMessage(predicate: (line: string) => boolean, timeoutMs = 2000): Promise<string> {
|
|
45
|
+
return new Promise<string>((resolve, reject) => {
|
|
46
|
+
const start = Date.now();
|
|
47
|
+
const tick = (): void => {
|
|
48
|
+
const hit = this.received.find(predicate);
|
|
49
|
+
if (hit !== undefined) {
|
|
50
|
+
resolve(hit);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (Date.now() - start > timeoutMs) {
|
|
54
|
+
reject(new Error(`timeout waiting for message; got: ${JSON.stringify(this.received)}`));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
setTimeout(tick, 20);
|
|
58
|
+
};
|
|
59
|
+
tick();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
close(): Promise<void> {
|
|
64
|
+
return new Promise<void>((resolve) => {
|
|
65
|
+
this.ws.addEventListener('close', () => resolve());
|
|
66
|
+
this.ws.close();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function connect(connId: string): Promise<IrcWsClient> {
|
|
72
|
+
const doId = env.CONNECTION_DO.idFromName(connId);
|
|
73
|
+
const stub = env.CONNECTION_DO.get(doId);
|
|
74
|
+
const response = await stub.fetch('https://do/upgrade', {
|
|
75
|
+
headers: { Upgrade: 'websocket' },
|
|
76
|
+
});
|
|
77
|
+
const ws = response.webSocket;
|
|
78
|
+
if (ws === undefined || ws === null) {
|
|
79
|
+
throw new Error('ConnectionDO.fetch did not return a WebSocket');
|
|
80
|
+
}
|
|
81
|
+
ws.accept();
|
|
82
|
+
return new Promise<IrcWsClient>((resolve) => {
|
|
83
|
+
setTimeout(() => resolve(new IrcWsClient(ws as WebSocket)), 0);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('ConnectionDO — NickServ registration persists across connections', () => {
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
|
|
90
|
+
});
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('a registration on one connection is visible after reconnect', async () => {
|
|
96
|
+
// --- Connection A: register the nick. ---
|
|
97
|
+
const a = await connect('conn-nickserv-a');
|
|
98
|
+
a.send('NICK alice\r\n');
|
|
99
|
+
a.send('USER alice 0 * :alice\r\n');
|
|
100
|
+
await a.waitForMessage((l) => l.includes(' 001 '));
|
|
101
|
+
a.send('PRIVMSG NickServ :REGISTER hunter2 alice@example.com\r\n');
|
|
102
|
+
const registered = await a.waitForMessage((l) => l.includes('is now registered.'));
|
|
103
|
+
expect(registered).toContain('is now registered.');
|
|
104
|
+
|
|
105
|
+
// The success NOTICE is emitted during dispatch, BEFORE the finally-block
|
|
106
|
+
// D1 flush. Force the REGISTER frame (incl. its flush) to fully complete
|
|
107
|
+
// by driving a second frame through the same DO and awaiting its reply.
|
|
108
|
+
// DO message events are processed serially, so the PONG guarantees flush.
|
|
109
|
+
a.send('PING :flush-barrier\r\n');
|
|
110
|
+
await a.waitForMessage((l) => l.startsWith('PONG'));
|
|
111
|
+
await a.close();
|
|
112
|
+
|
|
113
|
+
// --- Connection B: a brand-new ConnectionDO that rehydrates from D1. ---
|
|
114
|
+
const b = await connect('conn-nickserv-b');
|
|
115
|
+
b.send('NICK bob\r\n');
|
|
116
|
+
b.send('USER bob 0 * :bob\r\n');
|
|
117
|
+
await b.waitForMessage((l) => l.includes(' 001 '));
|
|
118
|
+
// IDENTIFY against the nick registered on connection A.
|
|
119
|
+
b.send('PRIVMSG NickServ :IDENTIFY alice hunter2\r\n');
|
|
120
|
+
const identified = await b.waitForMessage(
|
|
121
|
+
(l) => l.includes('is not registered.') || l.includes('now identified for nick'),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(identified).toContain('now identified for nick alice');
|
|
125
|
+
expect(identified).not.toContain('is not registered.');
|
|
126
|
+
await b.close();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -94,14 +94,21 @@ class IrcWsClient {
|
|
|
94
94
|
/**
|
|
95
95
|
* Opens a WebSocket to a fresh ConnectionDO instance named `connId`.
|
|
96
96
|
* Each connection gets its own DO instance + isolated storage.
|
|
97
|
+
*
|
|
98
|
+
* `extraHeaders` merges into the upgrade request headers — used to simulate
|
|
99
|
+
* Cloudflare edge headers like `CF-Connecting-IP` that production reads at
|
|
100
|
+
* upgrade time to seed the connection's visible host.
|
|
97
101
|
*/
|
|
98
|
-
async function connect(
|
|
102
|
+
async function connect(
|
|
103
|
+
connId: string,
|
|
104
|
+
extraHeaders?: Record<string, string>,
|
|
105
|
+
): Promise<IrcWsClient> {
|
|
99
106
|
const doId = env.CONNECTION_DO.idFromName(connId);
|
|
100
107
|
const stub = env.CONNECTION_DO.get(doId);
|
|
101
108
|
// The DO's `fetch` returns the websocket-upgraded Response. We strip
|
|
102
109
|
// the `webSocket` field via the standard Workers WebSocket handshake.
|
|
103
110
|
const response = await stub.fetch('https://do/upgrade', {
|
|
104
|
-
headers: { Upgrade: 'websocket' },
|
|
111
|
+
headers: { Upgrade: 'websocket', ...(extraHeaders ?? {}) },
|
|
105
112
|
});
|
|
106
113
|
const ws = response.webSocket;
|
|
107
114
|
if (ws === undefined || ws === null) {
|
|
@@ -339,6 +346,63 @@ describe('ConnectionDO — alarms', () => {
|
|
|
339
346
|
});
|
|
340
347
|
expect(client.isClosed).toBe(true);
|
|
341
348
|
});
|
|
349
|
+
|
|
350
|
+
it('alarm prunes channel rosters and notifies peers when the socket is gone', async () => {
|
|
351
|
+
// Reproduces the ghost-member bug: when a connection drops without a
|
|
352
|
+
// clean close reaching the DO, the idle-sweep alarm is the backstop
|
|
353
|
+
// that must remove the member from every shared channel's roster and
|
|
354
|
+
// fan a QUIT out to peers. Closing the client WS without invoking the
|
|
355
|
+
// close-teardown path leaves the roster populated, so the alarm is the
|
|
356
|
+
// sole cleanup path here.
|
|
357
|
+
const alice = await connect('conn-alarm-ghost-a');
|
|
358
|
+
await register(alice, 'ghost-alice');
|
|
359
|
+
const bob = await connect('conn-alarm-ghost-b');
|
|
360
|
+
await register(bob, 'ghost-bob');
|
|
361
|
+
|
|
362
|
+
alice.send('JOIN #alarm-ghost\r\n');
|
|
363
|
+
await alice.waitForMessage((l) => l.includes(' 366 ') && l.includes('#alarm-ghost'));
|
|
364
|
+
bob.send('JOIN #alarm-ghost\r\n');
|
|
365
|
+
await bob.waitForMessage((l) => l.includes(' 366 ') && l.includes('#alarm-ghost'));
|
|
366
|
+
|
|
367
|
+
const aliceStub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName('conn-alarm-ghost-a'));
|
|
368
|
+
|
|
369
|
+
// Suppress the DO's close teardown so the alarm is the SOLE cleanup
|
|
370
|
+
// path — faithfully simulating a lost close event in production
|
|
371
|
+
// (where the platform drops a hibernated socket without delivering
|
|
372
|
+
// `webSocketClose`). Without this the close handler races in and
|
|
373
|
+
// prunes the roster itself, masking the alarm's behaviour.
|
|
374
|
+
await runInDurableObject(aliceStub, async (instance: unknown) => {
|
|
375
|
+
const doInstance = instance as {
|
|
376
|
+
__suppressCloseTeardownHook?: () => void;
|
|
377
|
+
__backdateLastSeen?: (ms: number) => Promise<void>;
|
|
378
|
+
};
|
|
379
|
+
doInstance.__suppressCloseTeardownHook?.();
|
|
380
|
+
await doInstance.__backdateLastSeen?.(48 * 60 * 60 * 1000);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Drop alice's socket (client side). webSocketClose will fire but is
|
|
384
|
+
// suppressed, so the roster still holds alice when the alarm runs.
|
|
385
|
+
alice.ws.close();
|
|
386
|
+
|
|
387
|
+
await runDurableObjectAlarm(aliceStub);
|
|
388
|
+
|
|
389
|
+
// bob must observe alice's QUIT: only the alarm's synthetic QUIT can
|
|
390
|
+
// deliver it (the close teardown was suppressed).
|
|
391
|
+
const quit = await bob.waitForMessage((l) => l.includes('QUIT') && l.includes('ghost-alice'));
|
|
392
|
+
expect(quit).toContain('ghost-alice');
|
|
393
|
+
|
|
394
|
+
// Flush bob's receive buffer before the NAMES query: the JOIN flow
|
|
395
|
+
// already delivered a 353 (listing alice) that would otherwise mask
|
|
396
|
+
// the fresh NAMES response.
|
|
397
|
+
bob.received.length = 0;
|
|
398
|
+
bob.send('NAMES #alarm-ghost\r\n');
|
|
399
|
+
const names = await bob.waitForMessage(
|
|
400
|
+
(l) => l.includes(' 353 ') && l.includes('#alarm-ghost'),
|
|
401
|
+
);
|
|
402
|
+
expect(names).not.toContain('ghost-alice');
|
|
403
|
+
|
|
404
|
+
bob.ws.close();
|
|
405
|
+
});
|
|
342
406
|
});
|
|
343
407
|
|
|
344
408
|
describe('ConnectionDO — chathistory end-to-end', () => {
|
|
@@ -366,6 +430,90 @@ describe('ConnectionDO — chathistory end-to-end', () => {
|
|
|
366
430
|
});
|
|
367
431
|
});
|
|
368
432
|
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Source host capture (CF-Connecting-IP / X-Real-IP) at WebSocket upgrade
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
describe('ConnectionDO — source host capture at upgrade', () => {
|
|
438
|
+
it('seeds state.host from the CF-Connecting-IP upgrade header', async () => {
|
|
439
|
+
const connId = 'conn-ip-cf';
|
|
440
|
+
const client = await connect(connId, { 'CF-Connecting-IP': '203.0.113.42' });
|
|
441
|
+
await register(client, 'alice');
|
|
442
|
+
|
|
443
|
+
// The visible host is the raw CF-Connecting-IP value (cloaking is off
|
|
444
|
+
// in this env). state.host drives the `@host` segment of every
|
|
445
|
+
// hostmask; the 001 welcome line is the cheapest observable.
|
|
446
|
+
const welcome = await client.waitForMessage((l) => l.includes(' 001 '));
|
|
447
|
+
expect(welcome).toContain('alice!alice@203.0.113.42');
|
|
448
|
+
|
|
449
|
+
const state = await inspectState(connId);
|
|
450
|
+
expect(state?.host).toBe('203.0.113.42');
|
|
451
|
+
|
|
452
|
+
client.ws.close();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('falls back to X-Real-IP when CF-Connecting-IP is absent', async () => {
|
|
456
|
+
const connId = 'conn-ip-xri';
|
|
457
|
+
const client = await connect(connId, { 'X-Real-IP': '198.51.100.7' });
|
|
458
|
+
await register(client, 'bob');
|
|
459
|
+
|
|
460
|
+
const welcome = await client.waitForMessage((l) => l.includes(' 001 '));
|
|
461
|
+
expect(welcome).toContain('bob!bob@198.51.100.7');
|
|
462
|
+
|
|
463
|
+
const state = await inspectState(connId);
|
|
464
|
+
expect(state?.host).toBe('198.51.100.7');
|
|
465
|
+
|
|
466
|
+
client.ws.close();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('prefers CF-Connecting-IP over X-Real-IP when both are present', async () => {
|
|
470
|
+
const connId = 'conn-ip-both';
|
|
471
|
+
const client = await connect(connId, {
|
|
472
|
+
'CF-Connecting-IP': '203.0.113.1',
|
|
473
|
+
'X-Real-IP': '198.51.100.1',
|
|
474
|
+
});
|
|
475
|
+
await register(client, 'carol');
|
|
476
|
+
|
|
477
|
+
const state = await inspectState(connId);
|
|
478
|
+
expect(state?.host).toBe('203.0.113.1');
|
|
479
|
+
|
|
480
|
+
client.ws.close();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('leaves state.host undefined when no source-IP header is present', async () => {
|
|
484
|
+
// Pre-feature behaviour preserved: no header → no host → hostmask omits
|
|
485
|
+
// the `@host` segment entirely (rather than synthesizing `?` or `unknown`).
|
|
486
|
+
const connId = 'conn-ip-none';
|
|
487
|
+
const client = await connect(connId);
|
|
488
|
+
await register(client, 'dave');
|
|
489
|
+
|
|
490
|
+
const welcome = await client.waitForMessage((l) => l.includes(' 001 '));
|
|
491
|
+
// No @host segment: the hostmask ends right after the user segment.
|
|
492
|
+
expect(welcome).toMatch(/dave!dave$/);
|
|
493
|
+
|
|
494
|
+
const state = await inspectState(connId);
|
|
495
|
+
expect(state?.host).toBeUndefined();
|
|
496
|
+
|
|
497
|
+
client.ws.close();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('persists the captured host across simulated hibernation', async () => {
|
|
501
|
+
const connId = 'conn-ip-hib';
|
|
502
|
+
const client = await connect(connId, { 'CF-Connecting-IP': '203.0.113.99' });
|
|
503
|
+
await register(client, 'eve');
|
|
504
|
+
|
|
505
|
+
await forceHibernation(connId);
|
|
506
|
+
// Reload path via a trivial command so loadState re-runs.
|
|
507
|
+
client.send('PING :token-ip\r\n');
|
|
508
|
+
await client.waitForMessage((l) => l.startsWith('PONG'));
|
|
509
|
+
|
|
510
|
+
const after = await inspectState(connId);
|
|
511
|
+
expect(after?.host).toBe('203.0.113.99');
|
|
512
|
+
|
|
513
|
+
client.ws.close();
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
369
517
|
// ---------------------------------------------------------------------------
|
|
370
518
|
// SASL PLAIN end-to-end (AccountStore plumbing)
|
|
371
519
|
// ---------------------------------------------------------------------------
|