serverless-ircd 0.8.0 → 0.10.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 +4 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +11 -9
- package/.github/workflows/deploy-cf.yml +14 -14
- package/CHANGELOG.md +550 -0
- package/README.md +275 -222
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +82 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +177 -52
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +267 -92
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/package.json +2 -3
- package/apps/cf-tcp-container/src/container-server.ts +33 -10
- package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
- package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
- package/apps/cf-tcp-container/wrangler.toml +1 -10
- package/apps/cf-worker/package.json +3 -4
- package/apps/cf-worker/wrangler.toml +12 -71
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/server.ts +115 -48
- package/apps/local-cli/tests/config-resolution.test.ts +65 -0
- package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
- package/apps/local-cli/tests/rehash.test.ts +147 -0
- package/apps/local-cli/tests/server-helpers.test.ts +63 -0
- package/apps/local-cli/tests/tcp.test.ts +89 -0
- package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +227 -3
- package/apps/web/package.json +3 -2
- package/apps/web/scripts/build.mjs +91 -6
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/src/render-docs.ts +292 -0
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +33 -4
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/apps/web/tests/landing-content.test.ts +103 -0
- package/apps/web/tests/render-docs.test.ts +198 -0
- package/docs/AWS-Adapter-Architecture.md +3 -2
- package/docs/AWS-Deployment.md +670 -96
- package/docs/AWS-TCP-Deployment.md +20 -45
- package/docs/Cloudflare-Deployment-Guide.md +87 -113
- package/docs/Cloudflare-TCP-Deployment.md +25 -49
- package/docs/Release-Process.md +27 -23
- package/docs/Services.md +102 -23
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +15 -1
- package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
- package/packages/aws-adapter/src/config-loader.ts +19 -2
- package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
- package/packages/aws-adapter/src/handlers/connect.ts +26 -0
- package/packages/aws-adapter/src/handlers/default.ts +190 -123
- package/packages/aws-adapter/src/handlers/index.ts +67 -23
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +15 -0
- package/packages/aws-adapter/src/tables.ts +2 -12
- package/packages/aws-adapter/tests/aws-harness.ts +0 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
- package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
- package/packages/aws-adapter/tests/global-setup.ts +28 -1
- package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
- package/packages/aws-adapter/tests/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +59 -8
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/connection-do.ts +284 -115
- package/packages/cf-adapter/src/d1-services-store.ts +63 -26
- package/packages/cf-adapter/src/env.ts +11 -10
- package/packages/cf-adapter/src/index.ts +0 -6
- package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
- package/packages/cf-adapter/tests/serialize.test.ts +25 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/account-migration.ts +140 -0
- package/packages/irc-core/src/commands/account-auth.ts +60 -35
- package/packages/irc-core/src/commands/chanserv.ts +288 -4
- package/packages/irc-core/src/commands/hostserv.ts +38 -3
- package/packages/irc-core/src/commands/index.ts +1 -0
- package/packages/irc-core/src/commands/join.ts +41 -35
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +138 -15
- package/packages/irc-core/src/commands/registration.ts +28 -17
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- package/packages/irc-core/src/commands/service-aliases.ts +52 -0
- package/packages/irc-core/src/commands/topic.ts +23 -10
- package/packages/irc-core/src/config.ts +35 -9
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- package/packages/irc-core/src/index.ts +1 -0
- package/packages/irc-core/src/ports.ts +159 -179
- package/packages/irc-core/src/state/channel.ts +17 -0
- package/packages/irc-core/src/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
- package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
- package/packages/irc-core/tests/commands/join.test.ts +179 -0
- package/packages/irc-core/tests/commands/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +336 -108
- package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
- package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
- package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- package/packages/irc-core/tests/message-store.test.ts +5 -0
- package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
- package/packages/irc-core/tests/ports.test.ts +71 -0
- package/packages/irc-core/tests/services-store.test.ts +204 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +80 -44
- package/packages/irc-server/tests/actor.test.ts +384 -50
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
- package/packages/irc-test-support/src/scenarios.ts +21 -6
- package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
- package/packages/irc-test-support/vitest.config.ts +6 -1
- package/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/src/index.ts +2 -0
- package/tools/ci-hardening/src/validate.ts +57 -0
- package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
- package/tools/ci-hardening/tests/validate.test.ts +42 -0
- package/tools/load-test/package.json +1 -1
- package/tools/load-test/src/client.ts +13 -13
- package/tools/load-test/tests/client.test.ts +258 -2
- package/tools/load-test/tests/config.test.ts +39 -0
- package/tools/load-test/tests/harness.test.ts +21 -0
- package/tools/load-test/tests/metrics.test.ts +7 -0
- package/tools/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
- package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
- package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -161,6 +161,49 @@ describe('config-loader — container-specific knobs', () => {
|
|
|
161
161
|
expect(cfg.server.operCreds).toEqual([{ user: 'admin', password: 'secret' }]);
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
+
it('exercises the OPER_PASSWORD ?? "" default then rejects partial OPER_USER-only creds', () => {
|
|
165
|
+
// The `?? ''` default for the missing OPER_PASSWORD is exercised, but
|
|
166
|
+
// parseServerConfig then rejects the empty password — partial oper
|
|
167
|
+
// creds are a misconfiguration, not a silently-accepted one.
|
|
168
|
+
expect(() =>
|
|
169
|
+
loadContainerConfig({
|
|
170
|
+
SERVER_NAME: 'irc.test',
|
|
171
|
+
NETWORK_NAME: 'TestNet',
|
|
172
|
+
OPER_USER: 'admin',
|
|
173
|
+
}),
|
|
174
|
+
).toThrowError(/password/u);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('exercises the OPER_USER ?? "" default then rejects partial OPER_PASSWORD-only creds', () => {
|
|
178
|
+
expect(() =>
|
|
179
|
+
loadContainerConfig({
|
|
180
|
+
SERVER_NAME: 'irc.test',
|
|
181
|
+
NETWORK_NAME: 'TestNet',
|
|
182
|
+
OPER_PASSWORD: 'secret',
|
|
183
|
+
}),
|
|
184
|
+
).toThrowError(/user/u);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('treats an overflow-numeric CREATED_AT (Infinity) as a verbatim string', () => {
|
|
188
|
+
const cfg = loadContainerConfig({
|
|
189
|
+
SERVER_NAME: 'irc.test',
|
|
190
|
+
NETWORK_NAME: 'TestNet',
|
|
191
|
+
// 400 nines overflows Number.MAX_VALUE → Number() yields Infinity,
|
|
192
|
+
// which is not finite, so parseCreatedAt falls through to the raw string.
|
|
193
|
+
CREATED_AT: '9'.repeat(400),
|
|
194
|
+
});
|
|
195
|
+
expect(cfg.server.createdAt).toBe('9'.repeat(400));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('skips a SASL line whose username is set but password is empty', () => {
|
|
199
|
+
const cfg = loadContainerConfig({
|
|
200
|
+
SERVER_NAME: 'irc.test',
|
|
201
|
+
NETWORK_NAME: 'TestNet',
|
|
202
|
+
SASL_ACCOUNTS: 'alice:\nbob:valid',
|
|
203
|
+
});
|
|
204
|
+
expect(cfg.server.saslAccounts).toEqual([{ username: 'bob', password: 'valid' }]);
|
|
205
|
+
});
|
|
206
|
+
|
|
164
207
|
it('skips malformed SASL account lines', () => {
|
|
165
208
|
const cfg = loadContainerConfig({
|
|
166
209
|
SERVER_NAME: 'irc.test',
|
|
@@ -128,6 +128,58 @@ describe('container-server — lifecycle', () => {
|
|
|
128
128
|
});
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
describe('container-server — option wiring (coverage matrix)', () => {
|
|
132
|
+
// Exercises the `opts.X ?? default` / `opts.X !== undefined ? ... : ...`
|
|
133
|
+
// branches in startContainerServer by starting the server with each optional
|
|
134
|
+
// knob set, so every "option provided" branch is taken at least once.
|
|
135
|
+
|
|
136
|
+
it('binds the default host when `host` is omitted', async () => {
|
|
137
|
+
const srv = await startContainerServer({
|
|
138
|
+
port: 0,
|
|
139
|
+
serverName: 'irc.defaulthost',
|
|
140
|
+
networkName: 'DefaultHostNet',
|
|
141
|
+
});
|
|
142
|
+
expect(srv.host).toBe('0.0.0.0');
|
|
143
|
+
// Connecting via 127.0.0.1 still reaches the 0.0.0.0 listener.
|
|
144
|
+
const c = new TcpClient('127.0.0.1', srv.port);
|
|
145
|
+
await c.opened();
|
|
146
|
+
await c.close();
|
|
147
|
+
await srv.close();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('applies the default admission knobs when only maxConnectionsPerUser is set', async () => {
|
|
151
|
+
// Setting only maxConnectionsPerUser builds the admission config, so
|
|
152
|
+
// maxConnectionsPerIp and perIpConnectionRate fall back to their defaults.
|
|
153
|
+
const srv = await startContainerServer({
|
|
154
|
+
port: 0,
|
|
155
|
+
host: '127.0.0.1',
|
|
156
|
+
serverName: 'irc.admission',
|
|
157
|
+
networkName: 'AdmissionNet',
|
|
158
|
+
maxConnectionsPerUser: 5,
|
|
159
|
+
});
|
|
160
|
+
const c = new TcpClient('127.0.0.1', srv.port);
|
|
161
|
+
await c.opened();
|
|
162
|
+
await c.close();
|
|
163
|
+
await srv.close();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('wires saslAccounts, serverPassword, and cloaking when provided', async () => {
|
|
167
|
+
const srv = await startContainerServer({
|
|
168
|
+
port: 0,
|
|
169
|
+
host: '127.0.0.1',
|
|
170
|
+
serverName: 'irc.options',
|
|
171
|
+
networkName: 'OptionsNet',
|
|
172
|
+
serverPassword: 'secret',
|
|
173
|
+
cloaking: { enabled: true, secret: 'cloak-secret', cloakedSuffix: '.hidden' },
|
|
174
|
+
saslAccounts: [{ username: 'alice', password: 'pw' }],
|
|
175
|
+
});
|
|
176
|
+
const c = new TcpClient('127.0.0.1', srv.port);
|
|
177
|
+
await c.opened();
|
|
178
|
+
await c.close();
|
|
179
|
+
await srv.close();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
131
183
|
describe('container-server — IRC flows over TCP', () => {
|
|
132
184
|
let server: ContainerServer;
|
|
133
185
|
|
|
@@ -329,7 +381,6 @@ describe('container-server — snapshot error resilience', () => {
|
|
|
329
381
|
const path = join(dir, 'corrupt.json');
|
|
330
382
|
const { writeFile } = await import('node:fs/promises');
|
|
331
383
|
await writeFile(path, '{ this is not valid json', 'utf8');
|
|
332
|
-
|
|
333
384
|
const srv = await startContainerServer({
|
|
334
385
|
port: 0,
|
|
335
386
|
host: '127.0.0.1',
|
|
@@ -373,6 +424,61 @@ describe('container-server — snapshot error resilience', () => {
|
|
|
373
424
|
expect(c.socket.destroyed).toBe(true);
|
|
374
425
|
await rm(dir, { recursive: true, force: true });
|
|
375
426
|
});
|
|
427
|
+
|
|
428
|
+
it('close() skips sockets that are already destroyed', async () => {
|
|
429
|
+
// Covers closeServer's `!socket.destroyed` false branch: a server-side
|
|
430
|
+
// socket destroyed in the same tick as close() is still in the sockets
|
|
431
|
+
// set (its 'close' cleanup is async) but already destroyed.
|
|
432
|
+
const srv = await startContainerServer({
|
|
433
|
+
port: 0,
|
|
434
|
+
host: '127.0.0.1',
|
|
435
|
+
serverName: 'irc.skip',
|
|
436
|
+
networkName: 'SkipNet',
|
|
437
|
+
});
|
|
438
|
+
const c = new TcpClient('127.0.0.1', srv.port);
|
|
439
|
+
await c.opened();
|
|
440
|
+
for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
|
|
441
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
442
|
+
}
|
|
443
|
+
const serverSideSocket = [...srv.testSockets][0];
|
|
444
|
+
if (serverSideSocket === undefined) throw new Error('no server-side socket');
|
|
445
|
+
// Destroy the server-side socket synchronously; its 'close' handler
|
|
446
|
+
// (which would remove it from the set) fires on a later tick.
|
|
447
|
+
serverSideSocket.destroy();
|
|
448
|
+
await expect(srv.close()).resolves.toBeUndefined();
|
|
449
|
+
await new Promise<void>((resolve) => c.socket.once('close', () => resolve()));
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('logs and resolves when the final snapshot save fails on close', async () => {
|
|
453
|
+
// Pointing persistencePath at an existing directory makes the boot-time
|
|
454
|
+
// load fail (EISDIR → caught → start fresh) AND the close-time save fail
|
|
455
|
+
// (writeFile on a directory → EISDIR → caught at the closeServer catch).
|
|
456
|
+
const dir = await mkdtemp(join(tmpdir(), 'sirc-finalfail-'));
|
|
457
|
+
const { mkdir } = await import('node:fs/promises');
|
|
458
|
+
const dirPath = join(dir, 'snap-target-dir');
|
|
459
|
+
await mkdir(dirPath);
|
|
460
|
+
|
|
461
|
+
const srv = await startContainerServer({
|
|
462
|
+
port: 0,
|
|
463
|
+
host: '127.0.0.1',
|
|
464
|
+
serverName: 'irc.finalfail',
|
|
465
|
+
networkName: 'FinalFailNet',
|
|
466
|
+
persistencePath: dirPath,
|
|
467
|
+
snapshotIntervalMs: 999_999,
|
|
468
|
+
});
|
|
469
|
+
const c = new TcpClient('127.0.0.1', srv.port);
|
|
470
|
+
await c.opened();
|
|
471
|
+
await c.send('NICK finalFail');
|
|
472
|
+
await c.send('USER finalFail 0 * :FinalFail');
|
|
473
|
+
await c.waitFor((l) => l.startsWith(':irc.finalfail 001 '), 2000);
|
|
474
|
+
// The server destroys live sockets on close; capture the close event first.
|
|
475
|
+
const closed = new Promise<void>((resolve) => c.socket.once('close', () => resolve()));
|
|
476
|
+
// close() must resolve (not reject) even though the final snapshot write
|
|
477
|
+
// throws — the error is logged and swallowed.
|
|
478
|
+
await expect(srv.close()).resolves.toBeUndefined();
|
|
479
|
+
await closed;
|
|
480
|
+
await rm(dir, { recursive: true, force: true });
|
|
481
|
+
});
|
|
376
482
|
});
|
|
377
483
|
|
|
378
484
|
describe('container-server — error paths', () => {
|
|
@@ -407,6 +513,95 @@ describe('container-server — error paths', () => {
|
|
|
407
513
|
await srv.close();
|
|
408
514
|
});
|
|
409
515
|
|
|
516
|
+
it('logs a non-Error rejection from the actor without crashing', async () => {
|
|
517
|
+
// logError's `err instanceof Error ? {...} : err` false branch is only
|
|
518
|
+
// reached when the caught value is NOT an Error instance.
|
|
519
|
+
const srv = await startContainerServer({
|
|
520
|
+
port: 0,
|
|
521
|
+
host: '127.0.0.1',
|
|
522
|
+
serverName: 'irc.nonerr',
|
|
523
|
+
networkName: 'NonErrNet',
|
|
524
|
+
});
|
|
525
|
+
const original = srv.runtime.send.bind(srv.runtime);
|
|
526
|
+
srv.runtime.send = async () => {
|
|
527
|
+
// eslint-disable-next-line no-throw-literal
|
|
528
|
+
throw { code: 'SYNTHETIC', detail: 'not an Error' };
|
|
529
|
+
};
|
|
530
|
+
const client = new TcpClient('127.0.0.1', srv.port);
|
|
531
|
+
await client.opened();
|
|
532
|
+
await client.send('NICK nonErrUser');
|
|
533
|
+
await client.send('USER nonErrUser 0 * :NonErr');
|
|
534
|
+
await client.send('PING :will-throw-non-error');
|
|
535
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
536
|
+
|
|
537
|
+
srv.runtime.send = original;
|
|
538
|
+
const next = new TcpClient('127.0.0.1', srv.port);
|
|
539
|
+
await next.opened();
|
|
540
|
+
await next.send('NICK recover2');
|
|
541
|
+
await next.send('USER recover2 0 * :Recover2');
|
|
542
|
+
await next.waitFor((l) => l.startsWith(':irc.nonerr 001 '), 3000);
|
|
543
|
+
await next.close();
|
|
544
|
+
await client.close();
|
|
545
|
+
await srv.close();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('disconnects a connection without an ERROR notice when no reason is supplied', async () => {
|
|
549
|
+
// Covers the disconnect callback's `reason === undefined` branch: no
|
|
550
|
+
// `ERROR :Closing link` line is sent, the transport is just closed.
|
|
551
|
+
const srv = await startContainerServer({
|
|
552
|
+
port: 0,
|
|
553
|
+
host: '127.0.0.1',
|
|
554
|
+
serverName: 'irc.noreason',
|
|
555
|
+
networkName: 'NoReasonNet',
|
|
556
|
+
});
|
|
557
|
+
const client = new TcpClient('127.0.0.1', srv.port);
|
|
558
|
+
await client.opened();
|
|
559
|
+
await client.send('NICK noReason');
|
|
560
|
+
await client.send('USER noReason 0 * :NoReason');
|
|
561
|
+
await client.waitFor((l) => l.startsWith(':irc.noreason 001 '), 2000);
|
|
562
|
+
|
|
563
|
+
const connId = await srv.runtime.lookupNick('noReason');
|
|
564
|
+
expect(connId).not.toBeNull();
|
|
565
|
+
const closed = new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
|
|
566
|
+
await srv.runtime.disconnect(connId as string);
|
|
567
|
+
await closed;
|
|
568
|
+
// The client must NOT have received an `ERROR :Closing link` line.
|
|
569
|
+
expect(client.received.some((l) => l.startsWith('ERROR :Closing link:'))).toBe(false);
|
|
570
|
+
await srv.close();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('skips writing when the server-side socket is no longer writable', async () => {
|
|
574
|
+
// Covers the sendText callback's `socket.writable` else branch: after the
|
|
575
|
+
// server-side socket is destroyed (writable=false) but before its 'close'
|
|
576
|
+
// handler removes the connection, a runtime send must skip the write.
|
|
577
|
+
const srv = await startContainerServer({
|
|
578
|
+
port: 0,
|
|
579
|
+
host: '127.0.0.1',
|
|
580
|
+
serverName: 'irc.unwritable',
|
|
581
|
+
networkName: 'UnwritableNet',
|
|
582
|
+
});
|
|
583
|
+
const client = new TcpClient('127.0.0.1', srv.port);
|
|
584
|
+
await client.opened();
|
|
585
|
+
await client.send('NICK unwritable');
|
|
586
|
+
await client.send('USER unwritable 0 * :Unwritable');
|
|
587
|
+
await client.waitFor((l) => l.startsWith(':irc.unwritable 001 '), 2000);
|
|
588
|
+
for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
|
|
589
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
590
|
+
}
|
|
591
|
+
const serverSideSocket = [...srv.testSockets][0];
|
|
592
|
+
if (serverSideSocket === undefined) throw new Error('no server-side socket');
|
|
593
|
+
const connId = (await srv.runtime.lookupNick('unwritable')) as string;
|
|
594
|
+
// Destroy the server-side socket: writable flips false synchronously,
|
|
595
|
+
// while the 'close' cleanup (which unregisters the connection) is queued.
|
|
596
|
+
serverSideSocket.destroy();
|
|
597
|
+
// The send resolves without writing because socket.writable is false.
|
|
598
|
+
await expect(
|
|
599
|
+
srv.runtime.send(connId, [{ text: ':irc.unwritable NOTICE unwritable :dropped' }]),
|
|
600
|
+
).resolves.toBeUndefined();
|
|
601
|
+
await srv.close();
|
|
602
|
+
await new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
|
|
603
|
+
});
|
|
604
|
+
|
|
410
605
|
it('logs and recovers from a per-socket error', async () => {
|
|
411
606
|
const srv = await startContainerServer({
|
|
412
607
|
port: 0,
|
|
@@ -463,3 +658,56 @@ describe('container-server — error paths', () => {
|
|
|
463
658
|
await rm(dir, { recursive: true, force: true });
|
|
464
659
|
});
|
|
465
660
|
});
|
|
661
|
+
|
|
662
|
+
describe('container-server — draft/multiline BATCH to a nick target', () => {
|
|
663
|
+
// Driving a nick-target multi-line batch exercises the `batchId` and
|
|
664
|
+
// `nonce` id-factory seams in DEFAULT_ID_FACTORY (container-server lines
|
|
665
|
+
// 96/98), which are only invoked by closeMultilineBatchToNick.
|
|
666
|
+
|
|
667
|
+
/** Negotiates `batch` + `draft/multiline`, then registers the nick. */
|
|
668
|
+
async function registerMultilineCapable(srv: ContainerServer, nick: string): Promise<TcpClient> {
|
|
669
|
+
const c = new TcpClient('127.0.0.1', srv.port);
|
|
670
|
+
await c.opened();
|
|
671
|
+
await c.send('CAP LS 302');
|
|
672
|
+
await c.waitFor((l) => l.startsWith(':irc.ml CAP * LS :'), 2000);
|
|
673
|
+
await c.send('CAP REQ :batch draft/multiline');
|
|
674
|
+
await c.waitFor((l) => l.includes('CAP') && l.includes('ACK'), 2000);
|
|
675
|
+
await c.send('CAP END');
|
|
676
|
+
await c.send(`NICK ${nick}`);
|
|
677
|
+
await c.send(`USER ${nick} 0 * :${nick}`);
|
|
678
|
+
await c.waitFor((l) => l.startsWith(':irc.ml 001 '), 2000);
|
|
679
|
+
return c;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
it('delivers a finalized nick-target batch and exercises batchId/nonce', async () => {
|
|
683
|
+
const srv = await startContainerServer({
|
|
684
|
+
port: 0,
|
|
685
|
+
host: '127.0.0.1',
|
|
686
|
+
serverName: 'irc.ml',
|
|
687
|
+
networkName: 'MLNet',
|
|
688
|
+
});
|
|
689
|
+
const alice = await registerMultilineCapable(srv, 'alice');
|
|
690
|
+
const bob = await registerMultilineCapable(srv, 'bob');
|
|
691
|
+
|
|
692
|
+
await alice.send('BATCH +m1 draft/multiline :bob');
|
|
693
|
+
await alice.send('PRIVMSG bob :line one');
|
|
694
|
+
await alice.send('PRIVMSG bob :line two');
|
|
695
|
+
await alice.send('BATCH -m1');
|
|
696
|
+
|
|
697
|
+
// Bob (also batch+draft/multiline capable) receives the replayed BATCH.
|
|
698
|
+
// The server allocates a fresh UUID batch id via the batchId() seam.
|
|
699
|
+
const open = await bob.waitFor(
|
|
700
|
+
(l) => /^BATCH \+[0-9a-f-]{36} draft\/multiline bob$/u.test(l),
|
|
701
|
+
3000,
|
|
702
|
+
);
|
|
703
|
+
const batchId = open?.match(/^BATCH \+([0-9a-f-]{36})/u)?.[1];
|
|
704
|
+
expect(batchId).toBeDefined();
|
|
705
|
+
await bob.waitFor((l) => l.includes(':alice!') && l.includes('PRIVMSG bob :line one'), 2000);
|
|
706
|
+
await bob.waitFor((l) => l.includes(':alice!') && l.includes('PRIVMSG bob :line two'), 2000);
|
|
707
|
+
await bob.waitFor((l) => l === `BATCH -${batchId}`, 2000);
|
|
708
|
+
|
|
709
|
+
await alice.close();
|
|
710
|
+
await bob.close();
|
|
711
|
+
await srv.close();
|
|
712
|
+
});
|
|
713
|
+
});
|
|
@@ -143,6 +143,15 @@ describe('persistence — saveSnapshot / loadSnapshot file I/O', () => {
|
|
|
143
143
|
expect(loaded).toBeNull();
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
it('re-throws non-ENOENT read errors (e.g. EISDIR when the path is a directory)', async () => {
|
|
147
|
+
// Reading a directory produces EISDIR, which is NOT ENOENT, so
|
|
148
|
+
// loadSnapshot must propagate it instead of treating it as first-boot.
|
|
149
|
+
const { mkdir } = await import('node:fs/promises');
|
|
150
|
+
const subDir = join(dir, 'not-a-file-dir');
|
|
151
|
+
await mkdir(subDir);
|
|
152
|
+
await expect(loadSnapshot(subDir)).rejects.toThrow();
|
|
153
|
+
});
|
|
154
|
+
|
|
146
155
|
it('overwrites a previous snapshot on re-save', async () => {
|
|
147
156
|
const path = join(dir, 'overwrite.json');
|
|
148
157
|
const rt1 = new InMemoryRuntime({ clock: { now: () => 0 } });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
4
4
|
import { type Socket, createConnection } from 'node:net';
|
|
@@ -10,9 +10,21 @@ import {
|
|
|
10
10
|
connect as tlsConnect,
|
|
11
11
|
createServer as tlsCreateServer,
|
|
12
12
|
} from 'node:tls';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
13
14
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
14
15
|
import { type ContainerServer, startContainerServer } from '../src/container-server.js';
|
|
15
16
|
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Maximum wall-clock budget for a single `openssl` invocation. RSA-2048
|
|
21
|
+
* keygen finishes in well under a second on any modern CPU; if it hasn't
|
|
22
|
+
* returned by this point the binary is wedged (or the runner is so starved
|
|
23
|
+
* that the TLS e2e would be flaky anyway), so we skip the suite instead of
|
|
24
|
+
* stalling the test process.
|
|
25
|
+
*/
|
|
26
|
+
const OPENSSL_TIMEOUT_MS = 15_000;
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* Generates a self-signed TLS certificate for the test proxy using the
|
|
18
30
|
* system `openssl` binary. This mirrors what Cloudflare Spectrum does in
|
|
@@ -26,7 +38,12 @@ async function generateSelfSignedCert(dir: string): Promise<{ key: string; cert:
|
|
|
26
38
|
const keyPath = join(dir, 'key.pem');
|
|
27
39
|
const certPath = join(dir, 'cert.pem');
|
|
28
40
|
try {
|
|
29
|
-
execFileSync
|
|
41
|
+
// Bound openssl: the previous `execFileSync` blocked the event loop,
|
|
42
|
+
// which defeated vitest's hook timer and let a stuck/missing binary
|
|
43
|
+
// hang the whole suite. The async call + `timeout` fails fast and
|
|
44
|
+
// yields control back to the runner; beforeAll swallows the throw and
|
|
45
|
+
// skips the suite (each test also guards on `cert === undefined`).
|
|
46
|
+
await execFileAsync(
|
|
30
47
|
'openssl',
|
|
31
48
|
[
|
|
32
49
|
'req',
|
|
@@ -43,10 +60,12 @@ async function generateSelfSignedCert(dir: string): Promise<{ key: string; cert:
|
|
|
43
60
|
'-subj',
|
|
44
61
|
'/CN=localhost',
|
|
45
62
|
],
|
|
46
|
-
{
|
|
63
|
+
{ timeout: OPENSSL_TIMEOUT_MS },
|
|
47
64
|
);
|
|
48
65
|
} catch {
|
|
49
|
-
throw new Error(
|
|
66
|
+
throw new Error(
|
|
67
|
+
`openssl unavailable or did not finish within ${OPENSSL_TIMEOUT_MS}ms — required to generate test TLS cert`,
|
|
68
|
+
);
|
|
50
69
|
}
|
|
51
70
|
const key = await readFile(keyPath, 'utf8');
|
|
52
71
|
const cert = await readFile(certPath, 'utf8');
|
|
@@ -205,7 +224,7 @@ describe('container-server — TLS e2e via in-process proxy (Spectrum analogue)'
|
|
|
205
224
|
originHost: '127.0.0.1',
|
|
206
225
|
originPort: origin.port,
|
|
207
226
|
});
|
|
208
|
-
});
|
|
227
|
+
}, 30_000);
|
|
209
228
|
|
|
210
229
|
afterAll(async () => {
|
|
211
230
|
if (proxy !== undefined) await proxy.close();
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
## - The container image is built from apps/cf-tcp-container/Dockerfile.
|
|
14
14
|
##
|
|
15
15
|
## Deploy:
|
|
16
|
-
## wrangler deploy
|
|
16
|
+
## wrangler deploy
|
|
17
17
|
##
|
|
18
18
|
|
|
19
19
|
name = "sirc-tcp-origin"
|
|
@@ -52,12 +52,3 @@ NETWORK_NAME = "ExampleNet"
|
|
|
52
52
|
MOTD_LINES = "Welcome to the ServerlessIRCd TCP origin."
|
|
53
53
|
TCP_PORT = "6667"
|
|
54
54
|
PERSISTENCE_PATH = "/data/state.json"
|
|
55
|
-
|
|
56
|
-
# --- Staging ---
|
|
57
|
-
[env.staging]
|
|
58
|
-
name = "sirc-tcp-origin-staging"
|
|
59
|
-
|
|
60
|
-
[env.staging.vars]
|
|
61
|
-
SERVER_NAME = "irc-staging.example.com"
|
|
62
|
-
NETWORK_NAME = "StagingNet"
|
|
63
|
-
MOTD_LINES = "Welcome to the staging ServerlessIRCd TCP origin."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/cf-worker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Cloudflare Worker deploy glue: WebSocket edge entry point + DO bindings + wrangler pipeline",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -13,9 +13,8 @@
|
|
|
13
13
|
"test:watch": "vitest",
|
|
14
14
|
"coverage": "vitest run --coverage",
|
|
15
15
|
"dev": "wrangler dev",
|
|
16
|
-
"deploy
|
|
17
|
-
"
|
|
18
|
-
"smoke:staging": "node scripts/smoke.mjs",
|
|
16
|
+
"deploy": "wrangler deploy",
|
|
17
|
+
"smoke": "node scripts/smoke.mjs",
|
|
19
18
|
"clean": "rimraf dist coverage .tsbuildinfo .turbo"
|
|
20
19
|
},
|
|
21
20
|
"dependencies": {
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
## Cloudflare Worker production config for ServerlessIRCd.
|
|
4
4
|
##
|
|
5
5
|
## Deploy:
|
|
6
|
-
## pnpm deploy:cf
|
|
7
|
-
## pnpm deploy:cf:prod # wrangler deploy
|
|
6
|
+
## pnpm deploy:cf # wrangler deploy
|
|
8
7
|
##
|
|
9
8
|
## The `apps/cf-worker` worker is the public edge for the IRC service.
|
|
10
9
|
## It owns the three Durable Object namespaces from the cf-adapter
|
|
@@ -13,10 +12,8 @@
|
|
|
13
12
|
## `src/worker.ts` and PLAN §6.1.
|
|
14
13
|
##
|
|
15
14
|
## Secrets: NEVER put secrets (API tokens, server passwords, account
|
|
16
|
-
## credentials) in this file. Use `wrangler secret put <NAME
|
|
17
|
-
##
|
|
18
|
-
## As of v1 the worker does not consume any secret bindings — server
|
|
19
|
-
## password, SASL `AccountStore`, and oper creds are future work.
|
|
15
|
+
## credentials) in this file. Use `wrangler secret put <NAME>`;
|
|
16
|
+
## they live in the Workers KV secrets store, not the repo.
|
|
20
17
|
|
|
21
18
|
name = "serverless-ircd"
|
|
22
19
|
main = "src/worker.ts"
|
|
@@ -104,55 +101,20 @@ not_found_handling = "none"
|
|
|
104
101
|
run_worker_first = true
|
|
105
102
|
|
|
106
103
|
## ---------------------------------------------------------------------------
|
|
107
|
-
##
|
|
104
|
+
## D1 database — unified services persistence (NickServ / ChanServ / SASL).
|
|
108
105
|
## ---------------------------------------------------------------------------
|
|
109
|
-
##
|
|
110
|
-
##
|
|
111
|
-
##
|
|
112
|
-
|
|
113
|
-
name = "serverless-ircd-staging"
|
|
114
|
-
|
|
115
|
-
[env.staging.observability]
|
|
116
|
-
enabled = true
|
|
117
|
-
head_sampling_rate = 1
|
|
118
|
-
|
|
119
|
-
[env.staging.vars]
|
|
120
|
-
SERVER_NAME = "irc-staging.example.com"
|
|
121
|
-
NETWORK_NAME = "ServerlessIRCd (staging)"
|
|
122
|
-
MOTD_LINES = """
|
|
123
|
-
Welcome to ServerlessIRCd staging.
|
|
124
|
-
This is a deployed Cloudflare Worker.
|
|
125
|
-
This Worker was deployed by CI from the main branch.
|
|
126
|
-
"""
|
|
127
|
-
|
|
128
|
-
# Staging serves the same `apps/web/dist` asset layer. Wrangler requires the
|
|
129
|
-
# per-env assets block so `--env staging` deploys both the Worker and its
|
|
130
|
-
# assets in a single `wrangler deploy --env staging` (the same `pnpm build`
|
|
131
|
-
# populates `dist/` ahead of the deploy step).
|
|
132
|
-
[env.staging.assets]
|
|
133
|
-
directory = "../../apps/web/dist"
|
|
134
|
-
binding = "ASSETS"
|
|
135
|
-
not_found_handling = "none"
|
|
136
|
-
run_worker_first = true
|
|
137
|
-
|
|
138
|
-
## ---------------------------------------------------------------------------
|
|
139
|
-
## D1 database — SASL account persistence.
|
|
140
|
-
## ---------------------------------------------------------------------------
|
|
141
|
-
## The `accounts` table stores scrypt-hashed SASL PLAIN credentials (never
|
|
142
|
-
## plaintext). Loaded at ConnectionDO construction; when empty or unreachable
|
|
143
|
-
## the resolver falls back to the `SASL_ACCOUNTS` env-var seed. Provision with:
|
|
106
|
+
## The `nickserv_accounts` table is the single credential home for SASL
|
|
107
|
+
## PLAIN, SASL EXTERNAL, PASS-auth, and NickServ IDENTIFY. All scrypt-hashed
|
|
108
|
+
## credentials live here; the standalone `accounts` table was dropped.
|
|
109
|
+
## Provision with:
|
|
144
110
|
## wrangler d1 create serverless-ircd-accounts
|
|
145
|
-
##
|
|
146
|
-
##
|
|
147
|
-
##
|
|
111
|
+
## The services tables self-provision on first load (ConnectionDO runs
|
|
112
|
+
## `CREATE TABLE IF NOT EXISTS` for each table). Seed accounts via
|
|
113
|
+
## `tools/seed-cf-accounts.ts`.
|
|
148
114
|
[[d1_databases]]
|
|
149
115
|
binding = "ACCOUNTS_DB"
|
|
150
116
|
database_name = "serverless-ircd-accounts"
|
|
151
|
-
# database_id is filled in by `wrangler d1 create
|
|
152
|
-
|
|
153
|
-
[[env.staging.d1_databases]]
|
|
154
|
-
binding = "ACCOUNTS_DB"
|
|
155
|
-
database_name = "serverless-ircd-accounts-staging"
|
|
117
|
+
# database_id is filled in by `wrangler d1 create`.
|
|
156
118
|
|
|
157
119
|
## ---------------------------------------------------------------------------
|
|
158
120
|
## Durable Object bindings.
|
|
@@ -179,23 +141,6 @@ class_name = "ChannelDO"
|
|
|
179
141
|
name = "CHANNEL_REGISTRY_DO"
|
|
180
142
|
class_name = "ChannelRegistryDO"
|
|
181
143
|
|
|
182
|
-
# Staging environment bindings (wrangler requires per-env blocks).
|
|
183
|
-
[[env.staging.durable_objects.bindings]]
|
|
184
|
-
name = "CONNECTION_DO"
|
|
185
|
-
class_name = "ConnectionDO"
|
|
186
|
-
|
|
187
|
-
[[env.staging.durable_objects.bindings]]
|
|
188
|
-
name = "REGISTRY_DO"
|
|
189
|
-
class_name = "RegistryDO"
|
|
190
|
-
|
|
191
|
-
[[env.staging.durable_objects.bindings]]
|
|
192
|
-
name = "CHANNEL_DO"
|
|
193
|
-
class_name = "ChannelDO"
|
|
194
|
-
|
|
195
|
-
[[env.staging.durable_objects.bindings]]
|
|
196
|
-
name = "CHANNEL_REGISTRY_DO"
|
|
197
|
-
class_name = "ChannelRegistryDO"
|
|
198
|
-
|
|
199
144
|
# ## ---------------------------------------------------------------------------
|
|
200
145
|
# ## Durable Object migrations.
|
|
201
146
|
# ## ---------------------------------------------------------------------------
|
|
@@ -210,10 +155,6 @@ class_name = "ChannelRegistryDO"
|
|
|
210
155
|
# tag = "v1"
|
|
211
156
|
# new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
|
|
212
157
|
|
|
213
|
-
# [[env.staging.migrations]]
|
|
214
|
-
# tag = "v1"
|
|
215
|
-
# new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
|
|
216
|
-
|
|
217
158
|
[exports.ConnectionDO]
|
|
218
159
|
type = "durable-object"
|
|
219
160
|
storage = "sqlite"
|