serverless-ircd 0.9.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/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 +305 -0
- package/README.md +134 -41
- 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 +12 -9
- 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 +21 -17
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- 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 +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- 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 +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- package/packages/aws-adapter/package.json +1 -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 +3 -6
- 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/config-loader.test.ts +66 -0
- package/packages/aws-adapter/tests/connect.test.ts +124 -1
- 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/handlers.test.ts +117 -11
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- 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 +0 -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 +266 -109
- 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 +95 -54
- 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 +16 -19
- package/packages/irc-core/src/commands/memoserv.ts +1 -1
- package/packages/irc-core/src/commands/nickserv.ts +122 -11
- package/packages/irc-core/src/commands/registration.ts +2 -2
- package/packages/irc-core/src/commands/sasl.ts +22 -31
- 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/types.ts +38 -10
- package/packages/irc-core/tests/account-migration.test.ts +133 -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 +237 -1
- package/packages/irc-core/tests/commands/oper.test.ts +15 -0
- package/packages/irc-core/tests/commands/registration.test.ts +140 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
- 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 +0 -14
- package/packages/irc-server/tests/actor.test.ts +19 -47
- package/packages/irc-test-support/package.json +1 -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/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/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
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* End-to-end SASL PLAIN through ConnectionDO with D1-backed
|
|
2
|
+
* End-to-end SASL PLAIN through ConnectionDO with D1-backed services.
|
|
3
3
|
*
|
|
4
|
-
* Verifies the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - D1 empty → the `SASL_ACCOUNTS` env-var seed still works (fallback).
|
|
4
|
+
* Verifies the services-store credential path: an account seeded into the
|
|
5
|
+
* `nickserv_accounts` table (via `ServicesStore.registerNick`) verifies
|
|
6
|
+
* end-to-end through `AUTHENTICATE PLAIN`.
|
|
8
7
|
*
|
|
9
8
|
* Each test file gets its own isolated D1 database from miniflare, so the
|
|
10
9
|
* table state set up here does not leak into `connection-do.test.ts`.
|
|
@@ -12,7 +11,7 @@
|
|
|
12
11
|
|
|
13
12
|
import { env } from 'cloudflare:test';
|
|
14
13
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
15
|
-
import {
|
|
14
|
+
import { CREATE_SERVICES_TABLES_SQL } from '../src/d1-services-store';
|
|
16
15
|
|
|
17
16
|
declare global {
|
|
18
17
|
namespace Cloudflare {
|
|
@@ -107,16 +106,56 @@ async function authenticatePlain(
|
|
|
107
106
|
client.send(`AUTHENTICATE ${plainB64(authcid, password)}\r\n`);
|
|
108
107
|
}
|
|
109
108
|
|
|
110
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Seeds a NickServ registration directly into the D1 services table by
|
|
111
|
+
* driving a first connection through `PRIVMSG NickServ :REGISTER`. Returns
|
|
112
|
+
* once the registration NOTICE is observed.
|
|
113
|
+
*/
|
|
114
|
+
async function seedViaNickservRegister(
|
|
115
|
+
connId: string,
|
|
116
|
+
nick: string,
|
|
117
|
+
password: string,
|
|
118
|
+
email: string,
|
|
119
|
+
): Promise<IrcWsClient> {
|
|
120
|
+
const client = await connect(connId);
|
|
121
|
+
client.send(`NICK ${nick}\r\n`);
|
|
122
|
+
client.send(`USER ${nick} 0 * :${nick}\r\n`);
|
|
123
|
+
await client.waitForMessage((l) => l.includes(' 001 '));
|
|
124
|
+
client.send(`PRIVMSG NickServ :REGISTER ${password} ${email}\r\n`);
|
|
125
|
+
await client.waitForMessage((l) => l.includes('NickServ') && l.includes('registered'));
|
|
126
|
+
return client;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('ConnectionDO — SASL PLAIN with D1-backed services', () => {
|
|
111
130
|
beforeEach(async () => {
|
|
112
|
-
|
|
131
|
+
for (const sql of CREATE_SERVICES_TABLES_SQL) {
|
|
132
|
+
await env.ACCOUNTS_DB.exec(sql);
|
|
133
|
+
}
|
|
113
134
|
});
|
|
114
135
|
afterEach(async () => {
|
|
115
|
-
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS
|
|
136
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
|
|
137
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_channels');
|
|
138
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_access');
|
|
139
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_levels');
|
|
140
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS hostserv_vhosts');
|
|
141
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS hostserv_pending_vhosts');
|
|
142
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS memoserv_memos');
|
|
143
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS read_markers');
|
|
144
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS operserv_akills');
|
|
145
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS operserv_jupes');
|
|
116
146
|
});
|
|
117
147
|
|
|
118
|
-
it('completes SASL PLAIN with 900/903 for a
|
|
119
|
-
|
|
148
|
+
it('completes SASL PLAIN with 900/903 for a NickServ-registered account', async () => {
|
|
149
|
+
// Seed alice via NickServ REGISTER on a first connection.
|
|
150
|
+
const seeder = await seedViaNickservRegister(
|
|
151
|
+
'conn-d1-sasl-seed',
|
|
152
|
+
'd1-alice',
|
|
153
|
+
'd1-s3cret',
|
|
154
|
+
'd1@example.com',
|
|
155
|
+
);
|
|
156
|
+
await seeder.close();
|
|
157
|
+
|
|
158
|
+
// A second connection speaking SASL PLAIN against the registered nick.
|
|
120
159
|
const client = await connect('conn-d1-sasl-1');
|
|
121
160
|
await authenticatePlain(client, 'd1-alice', 'd1-s3cret');
|
|
122
161
|
await client.waitForMessage((l) => l.includes(' 900 '));
|
|
@@ -125,42 +164,27 @@ describe('ConnectionDO — SASL PLAIN with D1-backed accounts', () => {
|
|
|
125
164
|
await client.close();
|
|
126
165
|
});
|
|
127
166
|
|
|
128
|
-
it('rejects SASL PLAIN with 904 for a wrong password
|
|
129
|
-
await
|
|
167
|
+
it('rejects SASL PLAIN with 904 for a wrong password', async () => {
|
|
168
|
+
const seeder = await seedViaNickservRegister(
|
|
169
|
+
'conn-d1-sasl-seed2',
|
|
170
|
+
'd1-bob',
|
|
171
|
+
'd1-s3cret',
|
|
172
|
+
'd1@example.com',
|
|
173
|
+
);
|
|
174
|
+
await seeder.close();
|
|
175
|
+
|
|
130
176
|
const client = await connect('conn-d1-sasl-2');
|
|
131
|
-
await authenticatePlain(client, 'd1-
|
|
177
|
+
await authenticatePlain(client, 'd1-bob', 'wrong');
|
|
132
178
|
const fail = await client.waitForMessage((l) => l.includes(' 904 '));
|
|
133
179
|
expect(fail).toContain(' 904 ');
|
|
134
180
|
await client.close();
|
|
135
181
|
});
|
|
136
182
|
|
|
137
|
-
it('
|
|
138
|
-
// SASL_ACCOUNTS env seeds "cf-sasl:s3cret" but D1 has no such row.
|
|
139
|
-
await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
|
|
183
|
+
it('rejects SASL PLAIN with 904 for an unknown nick', async () => {
|
|
140
184
|
const client = await connect('conn-d1-sasl-3');
|
|
141
|
-
|
|
142
|
-
await authenticatePlain(client, 'cf-sasl', 's3cret');
|
|
185
|
+
await authenticatePlain(client, 'nobody', 'whatever');
|
|
143
186
|
const fail = await client.waitForMessage((l) => l.includes(' 904 '));
|
|
144
187
|
expect(fail).toContain(' 904 ');
|
|
145
188
|
await client.close();
|
|
146
189
|
});
|
|
147
190
|
});
|
|
148
|
-
|
|
149
|
-
describe('ConnectionDO — SASL PLAIN in-memory fallback (D1 empty)', () => {
|
|
150
|
-
// The table exists but has zero rows → resolver falls back to SASL_ACCOUNTS.
|
|
151
|
-
beforeEach(async () => {
|
|
152
|
-
await env.ACCOUNTS_DB.exec(CREATE_ACCOUNTS_TABLE_SQL);
|
|
153
|
-
});
|
|
154
|
-
afterEach(async () => {
|
|
155
|
-
await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('falls back to SASL_ACCOUNTS seed when D1 is empty', async () => {
|
|
159
|
-
const client = await connect('conn-d1-sasl-fallback');
|
|
160
|
-
await authenticatePlain(client, 'cf-sasl', 's3cret');
|
|
161
|
-
await client.waitForMessage((l) => l.includes(' 900 '));
|
|
162
|
-
const success = await client.waitForMessage((l) => l.includes(' 903 '));
|
|
163
|
-
expect(success).toContain(' 903 ');
|
|
164
|
-
await client.close();
|
|
165
|
-
});
|
|
166
|
-
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
CREATE_SERVICES_TABLES_SQL,
|
|
16
16
|
D1ServicesStore,
|
|
17
17
|
loadD1ServicesStore,
|
|
18
|
+
safeSelect,
|
|
18
19
|
} from '../src/d1-services-store';
|
|
19
20
|
|
|
20
21
|
declare global {
|
|
@@ -194,6 +195,18 @@ describe('D1ServicesStore — NickServ round-trip across reload', () => {
|
|
|
194
195
|
expect(reloaded?.getNickEnforce('alice')).toBe('kill');
|
|
195
196
|
});
|
|
196
197
|
|
|
198
|
+
it('persists SET PASSWORD and round-trips the new credential', async () => {
|
|
199
|
+
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
200
|
+
store?.registerNick('alice', 'hunter2', 'a@x');
|
|
201
|
+
store?.setNickPassword('alice', 'newpassword');
|
|
202
|
+
await store?.flush();
|
|
203
|
+
|
|
204
|
+
const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
205
|
+
// The new password verifies against the reloaded store; the old one no longer does.
|
|
206
|
+
expect(reloaded?.verifyNick('alice', 'newpassword')).toEqual({ ok: true, account: 'alice' });
|
|
207
|
+
expect(reloaded?.verifyNick('alice', 'hunter2').ok).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
|
|
197
210
|
it('drops a NickServ registration after flush + reload', async () => {
|
|
198
211
|
const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
|
|
199
212
|
store?.registerNick('alice', 'pw', 'a@x');
|
|
@@ -524,14 +537,18 @@ describe('D1ServicesStore — tolerance', () => {
|
|
|
524
537
|
expect(snap.jupes).toEqual([]);
|
|
525
538
|
});
|
|
526
539
|
|
|
527
|
-
it('skips rows with NULL fields in the all-TEXT tables (vhosts / read_markers)', async () => {
|
|
528
|
-
// Recreate the
|
|
540
|
+
it('skips rows with NULL fields in the all-TEXT tables (vhosts / pending_vhosts / read_markers)', async () => {
|
|
541
|
+
// Recreate the all-TEXT tables without NOT NULL so a NULL field can
|
|
529
542
|
// survive — exercises the typeof-string defensive skip for the loaders
|
|
530
543
|
// whose INTEGER-corruption path is unreachable under SQLite affinity.
|
|
531
544
|
await env.ACCOUNTS_DB.exec('DROP TABLE hostserv_vhosts');
|
|
532
545
|
await env.ACCOUNTS_DB.exec(
|
|
533
546
|
'CREATE TABLE hostserv_vhosts (account_key TEXT PRIMARY KEY, account TEXT, vhost TEXT)',
|
|
534
547
|
);
|
|
548
|
+
await env.ACCOUNTS_DB.exec('DROP TABLE hostserv_pending_vhosts');
|
|
549
|
+
await env.ACCOUNTS_DB.exec(
|
|
550
|
+
'CREATE TABLE hostserv_pending_vhosts (account_key TEXT PRIMARY KEY, account TEXT, vhost TEXT)',
|
|
551
|
+
);
|
|
535
552
|
await env.ACCOUNTS_DB.exec('DROP TABLE read_markers');
|
|
536
553
|
await env.ACCOUNTS_DB.exec(
|
|
537
554
|
'CREATE TABLE read_markers (account_key TEXT NOT NULL, channel_key TEXT NOT NULL, account TEXT, channel TEXT, msgid TEXT)',
|
|
@@ -541,6 +558,11 @@ describe('D1ServicesStore — tolerance', () => {
|
|
|
541
558
|
)
|
|
542
559
|
.bind('bad#v', null, 'cloak')
|
|
543
560
|
.run();
|
|
561
|
+
await env.ACCOUNTS_DB.prepare(
|
|
562
|
+
'INSERT INTO hostserv_pending_vhosts (account_key, account, vhost) VALUES (?, ?, ?)',
|
|
563
|
+
)
|
|
564
|
+
.bind('bad#pv', 'someone', null)
|
|
565
|
+
.run();
|
|
544
566
|
await env.ACCOUNTS_DB.prepare(
|
|
545
567
|
'INSERT INTO read_markers (account_key, channel_key, account, channel, msgid) VALUES (?, ?, ?, ?, ?)',
|
|
546
568
|
)
|
|
@@ -552,6 +574,7 @@ describe('D1ServicesStore — tolerance', () => {
|
|
|
552
574
|
if (!store) throw new Error('services store failed to load');
|
|
553
575
|
const snap = store.snapshot();
|
|
554
576
|
expect(snap.vhosts).toEqual([]);
|
|
577
|
+
expect(snap.pendingVhosts).toEqual([]);
|
|
555
578
|
expect(snap.readMarkers).toEqual([]);
|
|
556
579
|
});
|
|
557
580
|
|
|
@@ -566,6 +589,34 @@ describe('D1ServicesStore — tolerance', () => {
|
|
|
566
589
|
});
|
|
567
590
|
});
|
|
568
591
|
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// safeSelect — defensive catch block (real D1 bindings don't throw on
|
|
594
|
+
// existing-table queries; the catch arm is unreachable through integration).
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
describe('safeSelect — defensive catch block', () => {
|
|
598
|
+
it('returns [] and logs when the underlying D1 query rejects', async () => {
|
|
599
|
+
// A misbehaving binding (transient unavailability, schema drift) would
|
|
600
|
+
// otherwise brick NickServ for the whole connection (the snapshot is
|
|
601
|
+
// cached for life). The catch arm logs to wrangler tail and degrades
|
|
602
|
+
// to an empty result.
|
|
603
|
+
const stub = {
|
|
604
|
+
prepare(): { all(): Promise<never> } {
|
|
605
|
+
return {
|
|
606
|
+
all: async () => {
|
|
607
|
+
throw new Error('simulated D1 outage');
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
const result = await safeSelect<{ x: unknown }>(
|
|
613
|
+
stub as unknown as Parameters<typeof safeSelect>[0],
|
|
614
|
+
'SELECT * FROM nicks',
|
|
615
|
+
);
|
|
616
|
+
expect(result).toEqual([]);
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
569
620
|
function emptySnapshot(): ServicesSnapshot {
|
|
570
621
|
return {
|
|
571
622
|
nicks: [],
|
|
@@ -306,4 +306,29 @@ describe('deserialize — migrate integration', () => {
|
|
|
306
306
|
expect(state.userModes.wallops).toBe(true);
|
|
307
307
|
expect(state.caps).toEqual(new Set(['server-time']));
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
it('coalesces an explicit `secure: null` to false via the ?? fallback', () => {
|
|
311
|
+
// `migrate` only backfills `secure` when the key is absent (=== undefined);
|
|
312
|
+
// an explicit null survives and the `?? false` arm in `deserialize` must
|
|
313
|
+
// catch it. Mirrors a hand-edited storage row where someone wrote null.
|
|
314
|
+
const rec = {
|
|
315
|
+
version: 1,
|
|
316
|
+
id: 'c',
|
|
317
|
+
registration: 'registered' as const,
|
|
318
|
+
capNegotiating: false,
|
|
319
|
+
secure: null,
|
|
320
|
+
caps: [],
|
|
321
|
+
joinedChannels: [],
|
|
322
|
+
userModes: {
|
|
323
|
+
invisible: false,
|
|
324
|
+
oper: false,
|
|
325
|
+
wallops: false,
|
|
326
|
+
serverNotices: false,
|
|
327
|
+
tls: false,
|
|
328
|
+
},
|
|
329
|
+
lastSeen: 0,
|
|
330
|
+
connectedSince: 0,
|
|
331
|
+
};
|
|
332
|
+
expect(deserialize(rec).secure).toBe(false);
|
|
333
|
+
});
|
|
309
334
|
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the one-shot `accounts` → `nickserv_accounts` /
|
|
3
|
+
* `Services` migration (`tools/migrate-accounts-to-services.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Lives in `irc-core` so both adapter test suites can import the helpers
|
|
6
|
+
* without a cross-adapter dependency. The CLI entry point (wrangler /
|
|
7
|
+
* DynamoDB IO) lives in the tools script; these helpers are side-effect-
|
|
8
|
+
* free and fully unit-testable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { caseFold } from './case-fold.js';
|
|
12
|
+
import { hashAccountCredential } from './credential-hashing.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* DDL for the `nickserv_accounts` table (the migration target). Inlined
|
|
16
|
+
* rather than imported from `@serverless-ircd/cf-adapter` so the helpers
|
|
17
|
+
* are testable from any package. Kept in sync with
|
|
18
|
+
* `cf-adapter/src/d1-services-store.ts`.
|
|
19
|
+
*/
|
|
20
|
+
export const MIGRATION_NICKSERV_ACCOUNTS_SQL =
|
|
21
|
+
"CREATE TABLE IF NOT EXISTS nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, cert_subjects TEXT NOT NULL DEFAULT '[]', algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)";
|
|
22
|
+
|
|
23
|
+
/** Parsed `SASL_ACCOUNTS` env var entry. */
|
|
24
|
+
export interface ParsedSaslAccount {
|
|
25
|
+
username: string;
|
|
26
|
+
password: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A raw legacy `accounts` row read from D1 or DynamoDB. */
|
|
30
|
+
export interface LegacyAccountRow {
|
|
31
|
+
account: string;
|
|
32
|
+
algorithm: string;
|
|
33
|
+
salt: string;
|
|
34
|
+
hash: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parses the `SASL_ACCOUNTS` env var into credential entries. */
|
|
38
|
+
export function parseSaslAccountsForMigration(raw: string | undefined): ParsedSaslAccount[] {
|
|
39
|
+
if (raw === undefined || raw.length === 0) return [];
|
|
40
|
+
const out: ParsedSaslAccount[] = [];
|
|
41
|
+
for (const line of raw.split('\n')) {
|
|
42
|
+
const trimmed = line.trim();
|
|
43
|
+
if (trimmed.length === 0) continue;
|
|
44
|
+
const sep = trimmed.indexOf(':');
|
|
45
|
+
if (sep <= 0) continue;
|
|
46
|
+
const username = trimmed.slice(0, sep);
|
|
47
|
+
const password = trimmed.slice(sep + 1);
|
|
48
|
+
if (username.length === 0 || password.length === 0) continue;
|
|
49
|
+
out.push({ username, password });
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Builds the set of `INSERT OR IGNORE` SQL statements that backfill the
|
|
56
|
+
* legacy `accounts` rows plus the `SASL_ACCOUNTS` env-seed entries into
|
|
57
|
+
* `nickserv_accounts`. Pure (no IO).
|
|
58
|
+
*
|
|
59
|
+
* - Legacy rows are copied verbatim (the pre-hashed scrypt credential is
|
|
60
|
+
* preserved; the plaintext is NOT re-hashed). The services row carries
|
|
61
|
+
* `cert_subjects = '[]'`, email `''`, enforce `'none'`, `created_at 0`.
|
|
62
|
+
* - `SASL_ACCOUNTS` env-seed entries are hashed fresh (the env var carries
|
|
63
|
+
* plaintext passwords). Only entries whose username is NOT already
|
|
64
|
+
* present in the legacy rows are emitted (legacy rows win over the env
|
|
65
|
+
* seed).
|
|
66
|
+
* - `INSERT OR IGNORE` guarantees idempotency: an existing
|
|
67
|
+
* `nickserv_accounts` row (registered via NickServ or a prior seed) is
|
|
68
|
+
* never clobbered.
|
|
69
|
+
*
|
|
70
|
+
* @returns The array of executable SQL statements (CREATE TABLE + INSERTs).
|
|
71
|
+
*/
|
|
72
|
+
export function buildMigrationSql(
|
|
73
|
+
legacyRows: ReadonlyArray<LegacyAccountRow>,
|
|
74
|
+
saslAccountsRaw: string | undefined,
|
|
75
|
+
): string[] {
|
|
76
|
+
const statements: string[] = [MIGRATION_NICKSERV_ACCOUNTS_SQL];
|
|
77
|
+
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
|
|
80
|
+
for (const row of legacyRows) {
|
|
81
|
+
const nickKey = caseFold('rfc1459', row.account);
|
|
82
|
+
seen.add(nickKey);
|
|
83
|
+
statements.push(buildInsertOrIgnore(row.account, row));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const acct of parseSaslAccountsForMigration(saslAccountsRaw)) {
|
|
87
|
+
const nickKey = caseFold('rfc1459', acct.username);
|
|
88
|
+
if (seen.has(nickKey)) continue;
|
|
89
|
+
seen.add(nickKey);
|
|
90
|
+
const cred = hashAccountCredential(acct.username, acct.password);
|
|
91
|
+
statements.push(buildInsertOrIgnore(acct.username, cred));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return statements;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildInsertOrIgnore(
|
|
98
|
+
username: string,
|
|
99
|
+
cred: { algorithm: string; salt: string; hash: string },
|
|
100
|
+
): string {
|
|
101
|
+
const nickKey = caseFold('rfc1459', username);
|
|
102
|
+
return `INSERT OR IGNORE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, algorithm, salt, hash) VALUES ('${sqlEscape(nickKey)}', '${sqlEscape(username)}', '${sqlEscape(username)}', '', 0, 'none', '[]', '${sqlEscape(cred.algorithm)}', '${sqlEscape(cred.salt)}', '${sqlEscape(cred.hash)}')`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Doubles single quotes for safe embedding in a D1 SQL string literal. */
|
|
106
|
+
function sqlEscape(value: string): string {
|
|
107
|
+
return value.replace(/'/g, "''");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Builds the DynamoDB `PutItem` input for a legacy row, targeting the
|
|
112
|
+
* `Services` table's `NICK:<fold>` / `#` item. Pure.
|
|
113
|
+
*
|
|
114
|
+
* The caller wraps this in a conditional PutItem
|
|
115
|
+
* (`ConditionExpression: attribute_not_exists(pk)`) so an existing nick
|
|
116
|
+
* registration is never clobbered.
|
|
117
|
+
*/
|
|
118
|
+
export function buildServicesPutItem(
|
|
119
|
+
row: { account: string; algorithm: string; salt: string; hash: string },
|
|
120
|
+
tableName: string,
|
|
121
|
+
): { TableName: string; Item: Record<string, unknown> } {
|
|
122
|
+
const fold = caseFold('rfc1459', row.account);
|
|
123
|
+
return {
|
|
124
|
+
TableName: tableName,
|
|
125
|
+
Item: {
|
|
126
|
+
pk: `NICK:${fold}`,
|
|
127
|
+
sk: '#',
|
|
128
|
+
type: 'nick',
|
|
129
|
+
nick: row.account,
|
|
130
|
+
account: row.account,
|
|
131
|
+
email: '',
|
|
132
|
+
createdAt: 0,
|
|
133
|
+
enforce: 'none',
|
|
134
|
+
certSubjects: [],
|
|
135
|
+
algorithm: row.algorithm,
|
|
136
|
+
salt: row.salt,
|
|
137
|
+
hash: row.hash,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
*
|
|
14
14
|
* {@link passBasedAccountAuth} is the legacy IRC convention of carrying
|
|
15
15
|
* NickServ credentials in `PASS <nick>:<password>` at registration; it
|
|
16
|
-
* resolves at registration completion via the
|
|
17
|
-
* {@link
|
|
16
|
+
* resolves at registration completion via the bound
|
|
17
|
+
* {@link ServicesStore} (`services.verifyNick` — the same single
|
|
18
|
+
* credential home NickServ `REGISTER`, SASL PLAIN, and NickServ
|
|
19
|
+
* `IDENTIFY` consult) and, on success, delegates to
|
|
18
20
|
* {@link applyAccountSuccess}.
|
|
19
21
|
*/
|
|
20
22
|
|
|
@@ -129,17 +131,20 @@ export function passBasedAccountAuth(state: ConnectionState, ctx: Ctx): EffectTy
|
|
|
129
131
|
* in `passReducer` (reading the `PASS` param directly), so the two entry
|
|
130
132
|
* points share one verify+success path and never diverge.
|
|
131
133
|
*
|
|
132
|
-
*
|
|
134
|
+
* Credentials are verified against the bound {@link ServicesStore} via
|
|
135
|
+
* `services.verifyNick` — the same single credential home NickServ
|
|
136
|
+
* `REGISTER`, SASL PLAIN, and NickServ `IDENTIFY` consult. Returns the
|
|
137
|
+
* account-login success effects (`900`/`903`/`+r`/read-marker
|
|
133
138
|
* seeding/away replay/memo delivery/`account-notify`), or `[]` for every
|
|
134
139
|
* other outcome:
|
|
135
140
|
* - already identified (`state.account` set, e.g. via SASL) → no-op;
|
|
136
141
|
* - no `<nick>:<password>` form (bare value or undefined) → no-op;
|
|
137
142
|
* - payload nick does not match `state.nick` (when set) → no-op;
|
|
138
|
-
* - no {@link
|
|
143
|
+
* - no {@link ServicesStore} bound → no-op;
|
|
139
144
|
* - verify failure (unknown nick or wrong password) → no-op.
|
|
140
145
|
*
|
|
141
146
|
* A failed verify is followed by a verify against a fixed dummy entry so
|
|
142
|
-
* the failure path performs the same
|
|
147
|
+
* the failure path performs the same scrypt work whether the payload
|
|
143
148
|
* nick was unknown or the password was wrong — the observable outcome
|
|
144
149
|
* (no numerics, no disconnect, no state change) is identical for both
|
|
145
150
|
* cases, giving no information to a remote attacker.
|
|
@@ -153,24 +158,16 @@ export function attemptPassAccountAuth(
|
|
|
153
158
|
const parsed = parsePassAccountAttempt(attempt);
|
|
154
159
|
if (parsed === null) return [];
|
|
155
160
|
if (state.nick !== undefined && state.nick !== parsed.nick) return [];
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
158
|
-
const result =
|
|
159
|
-
kind: 'PLAIN',
|
|
160
|
-
username: parsed.nick,
|
|
161
|
-
password: parsed.password,
|
|
162
|
-
});
|
|
161
|
+
const services = ctx.services;
|
|
162
|
+
if (services === undefined) return [];
|
|
163
|
+
const result = services.verifyNick(parsed.nick, parsed.password);
|
|
163
164
|
if (result.ok) {
|
|
164
165
|
return applyAccountSuccess(state, ctx, result.account);
|
|
165
166
|
}
|
|
166
167
|
// Equalise timing: perform a verify against a fixed dummy entry so a
|
|
167
|
-
// failed PASS-auth always costs one extra
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
kind: 'PLAIN',
|
|
171
|
-
username: PASS_AUTH_DUMMY_NICK,
|
|
172
|
-
password: PASS_AUTH_DUMMY_PASSWORD,
|
|
173
|
-
});
|
|
168
|
+
// failed PASS-auth always costs one extra scrypt verify regardless of
|
|
169
|
+
// whether the payload nick was unknown or the password was wrong.
|
|
170
|
+
services.verifyNick(PASS_AUTH_DUMMY_NICK, PASS_AUTH_DUMMY_PASSWORD);
|
|
174
171
|
return [];
|
|
175
172
|
}
|
|
176
173
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*
|
|
20
20
|
* The reducer is a pure `Reducer<ConnectionState>`: all side effects are
|
|
21
21
|
* emitted as {@link Effect}s. The services store is synchronous (mirrors
|
|
22
|
-
* the {@link
|
|
22
|
+
* the {@link MessageStore} pattern).
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { caseFold } from '../case-fold.js';
|