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
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Migration script: backfills rows from the legacy `accounts` / `Accounts`
|
|
4
|
+
* table into the unified `nickserv_accounts` / `Services` table BEFORE the
|
|
5
|
+
* build that drops the legacy table is deployed.
|
|
6
|
+
*
|
|
7
|
+
* The migration is:
|
|
8
|
+
* - **One-shot**: run once per environment (staging then prod), then the
|
|
9
|
+
* new build lands.
|
|
10
|
+
* - **Idempotent**: re-running is a no-op — `INSERT OR IGNORE` (D1) /
|
|
11
|
+
* conditional PutItem (DynamoDB) never clobbers an existing services
|
|
12
|
+
* row.
|
|
13
|
+
* - **Never-overwrite**: a nick already registered in the services table
|
|
14
|
+
* (via NickServ REGISTER or a prior seed) wins; the legacy row's
|
|
15
|
+
* credential is NOT copied over it.
|
|
16
|
+
* - **Cert-subject carry-through**: the legacy `accounts` / `Accounts`
|
|
17
|
+
* table has no `cert_subjects` column. The script reads the still-
|
|
18
|
+
* deployed `SASL_ACCOUNTS` env var at run time; any account declared
|
|
19
|
+
* there is registered (if not already present) so the env-seed
|
|
20
|
+
* survives the table drop. The env-var format (`username:password`)
|
|
21
|
+
* does NOT carry cert subjects, so `cert_subjects` defaults to `'[]'`
|
|
22
|
+
* for every migrated row — matching today's effective behavior (SASL
|
|
23
|
+
* EXTERNAL only worked against the static `InMemoryAccountStore`, never
|
|
24
|
+
* against the hashed `accounts`/`Accounts` table).
|
|
25
|
+
*
|
|
26
|
+
* Usage (Cloudflare D1):
|
|
27
|
+
* node --import tsx tools/migrate-accounts-to-services.ts \
|
|
28
|
+
* --platform cf \
|
|
29
|
+
* --database serverless-ircd-accounts \
|
|
30
|
+
* --env staging \
|
|
31
|
+
* --remote
|
|
32
|
+
*
|
|
33
|
+
* Usage (AWS DynamoDB):
|
|
34
|
+
* node --import tsx tools/migrate-accounts-to-services.ts \
|
|
35
|
+
* --platform aws \
|
|
36
|
+
* --accounts-table StagingAccounts \
|
|
37
|
+
* --services-table StagingServices \
|
|
38
|
+
* --region us-east-1
|
|
39
|
+
*
|
|
40
|
+
* The `SASL_ACCOUNTS` env var is read at run time in both modes so any
|
|
41
|
+
* operator-declared seed accounts are carried into the services table.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Pure helpers (re-exported from irc-core for testability)
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
export {
|
|
49
|
+
type LegacyAccountRow,
|
|
50
|
+
type ParsedSaslAccount,
|
|
51
|
+
buildMigrationSql,
|
|
52
|
+
buildServicesPutItem,
|
|
53
|
+
parseSaslAccountsForMigration,
|
|
54
|
+
} from '@serverless-ircd/irc-core';
|
|
55
|
+
import { caseFold } from '@serverless-ircd/irc-core';
|
|
56
|
+
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// CLI entry point (only runs when executed directly via `node --import tsx`)
|
|
59
|
+
// ============================================================================
|
|
60
|
+
|
|
61
|
+
async function main(): Promise<void> {
|
|
62
|
+
const { execFileSync } = await import('node:child_process');
|
|
63
|
+
const { parseArgs } = await import('node:util');
|
|
64
|
+
const { values } = parseArgs({
|
|
65
|
+
options: {
|
|
66
|
+
platform: { type: 'string' },
|
|
67
|
+
database: { type: 'string', default: undefined },
|
|
68
|
+
env: { type: 'string', default: undefined },
|
|
69
|
+
remote: { type: 'boolean', default: false },
|
|
70
|
+
'accounts-table': { type: 'string', default: undefined },
|
|
71
|
+
'services-table': { type: 'string', default: undefined },
|
|
72
|
+
region: { type: 'string', default: undefined },
|
|
73
|
+
endpoint: { type: 'string', default: undefined },
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const SASL_ACCOUNTS_RAW = process.env.SASL_ACCOUNTS;
|
|
78
|
+
|
|
79
|
+
if (values.platform === 'cf') {
|
|
80
|
+
await runCfMigration(values, SASL_ACCOUNTS_RAW, execFileSync);
|
|
81
|
+
} else if (values.platform === 'aws') {
|
|
82
|
+
await runAwsMigration(values, SASL_ACCOUNTS_RAW);
|
|
83
|
+
} else {
|
|
84
|
+
console.error(
|
|
85
|
+
'Usage: migrate-accounts-to-services --platform cf|aws [platform-specific flags]',
|
|
86
|
+
);
|
|
87
|
+
console.error(' cf: --database <d1-name> [--env staging] [--remote]');
|
|
88
|
+
console.error(
|
|
89
|
+
' aws: --accounts-table <name> --services-table <name> [--region <r>] [--endpoint <url>]',
|
|
90
|
+
);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type CfValues = {
|
|
96
|
+
database?: string;
|
|
97
|
+
env?: string;
|
|
98
|
+
remote?: boolean;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
async function runCfMigration(
|
|
102
|
+
values: CfValues,
|
|
103
|
+
saslAccountsRaw: string | undefined,
|
|
104
|
+
execFileSync: typeof import('node:child_process').execFileSync,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
if (values.database === undefined) {
|
|
107
|
+
console.error('CF migration requires --database <d1-name>');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
const selectArgs = [
|
|
111
|
+
'd1',
|
|
112
|
+
'execute',
|
|
113
|
+
values.database,
|
|
114
|
+
'--command',
|
|
115
|
+
'SELECT account, algorithm, salt, hash FROM accounts',
|
|
116
|
+
'--json',
|
|
117
|
+
];
|
|
118
|
+
if (values.env !== undefined) selectArgs.push('--env', values.env);
|
|
119
|
+
if (values.remote) selectArgs.push('--remote');
|
|
120
|
+
|
|
121
|
+
let legacyRows: LegacyAccountRow[] = [];
|
|
122
|
+
try {
|
|
123
|
+
const output = execFileSync('npx', ['wrangler', ...selectArgs], {
|
|
124
|
+
encoding: 'utf-8',
|
|
125
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
126
|
+
});
|
|
127
|
+
const parsed = JSON.parse(output) as Array<{ results?: Array<Record<string, unknown>> }>;
|
|
128
|
+
legacyRows = parsed
|
|
129
|
+
.flatMap((r) => r.results ?? [])
|
|
130
|
+
.filter(
|
|
131
|
+
(r): r is Record<string, string> =>
|
|
132
|
+
typeof r.account === 'string' &&
|
|
133
|
+
typeof r.algorithm === 'string' &&
|
|
134
|
+
typeof r.salt === 'string' &&
|
|
135
|
+
typeof r.hash === 'string',
|
|
136
|
+
)
|
|
137
|
+
.map((r) => ({
|
|
138
|
+
account: r.account,
|
|
139
|
+
algorithm: r.algorithm,
|
|
140
|
+
salt: r.salt,
|
|
141
|
+
hash: r.hash,
|
|
142
|
+
}));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error(`Could not read legacy accounts table: ${String(err)}`);
|
|
145
|
+
console.error('Continuing with SASL_ACCOUNTS env-seed carry-through only.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const statements = buildMigrationSql(legacyRows, saslAccountsRaw);
|
|
149
|
+
for (const sql of statements) {
|
|
150
|
+
const execArgs = ['d1', 'execute', values.database, '--command', sql];
|
|
151
|
+
if (values.env !== undefined) execArgs.push('--env', values.env);
|
|
152
|
+
if (values.remote) execArgs.push('--remote');
|
|
153
|
+
execFileSync('npx', ['wrangler', ...execArgs], { stdio: 'inherit' });
|
|
154
|
+
}
|
|
155
|
+
console.log(
|
|
156
|
+
`CF migration complete: ${statements.length - 1} statement(s) executed ` +
|
|
157
|
+
`(${legacyRows.length} legacy rows + env-seed carry-through).`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type AwsValues = {
|
|
162
|
+
'accounts-table'?: string;
|
|
163
|
+
'services-table'?: string;
|
|
164
|
+
region?: string;
|
|
165
|
+
endpoint?: string;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
async function runAwsMigration(
|
|
169
|
+
values: AwsValues,
|
|
170
|
+
saslAccountsRaw: string | undefined,
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
if (values['accounts-table'] === undefined || values['services-table'] === undefined) {
|
|
173
|
+
console.error('AWS migration requires --accounts-table and --services-table');
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
const { PutCommand, ScanCommand } = await import('@aws-sdk/lib-dynamodb');
|
|
177
|
+
const { createDynamoDocumentClient, loadDynamoServicesStore } = await import(
|
|
178
|
+
'@serverless-ircd/aws-adapter'
|
|
179
|
+
);
|
|
180
|
+
const { SystemClock } = await import('@serverless-ircd/irc-core');
|
|
181
|
+
|
|
182
|
+
const clientOpts: Record<string, string> = {};
|
|
183
|
+
if (values.region !== undefined) clientOpts.region = values.region;
|
|
184
|
+
if (values.endpoint !== undefined) clientOpts.endpoint = values.endpoint;
|
|
185
|
+
const docClient = createDynamoDocumentClient(clientOpts);
|
|
186
|
+
|
|
187
|
+
const legacyRows: LegacyAccountRow[] = [];
|
|
188
|
+
try {
|
|
189
|
+
let exclusiveStartKey: Record<string, unknown> | undefined;
|
|
190
|
+
do {
|
|
191
|
+
const result = await docClient.send(
|
|
192
|
+
new ScanCommand({
|
|
193
|
+
TableName: values['accounts-table'],
|
|
194
|
+
ExclusiveStartKey: exclusiveStartKey,
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
for (const item of result.Items ?? []) {
|
|
198
|
+
if (
|
|
199
|
+
typeof item.account === 'string' &&
|
|
200
|
+
typeof item.algorithm === 'string' &&
|
|
201
|
+
typeof item.salt === 'string' &&
|
|
202
|
+
typeof item.hash === 'string'
|
|
203
|
+
) {
|
|
204
|
+
legacyRows.push({
|
|
205
|
+
account: item.account,
|
|
206
|
+
algorithm: item.algorithm,
|
|
207
|
+
salt: item.salt,
|
|
208
|
+
hash: item.hash,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
exclusiveStartKey = result.LastEvaluatedKey;
|
|
213
|
+
} while (exclusiveStartKey !== undefined);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.error(`Could not scan legacy Accounts table: ${String(err)}`);
|
|
216
|
+
console.error('Continuing with SASL_ACCOUNTS env-seed carry-through only.');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const seen = new Set<string>();
|
|
220
|
+
let legacyPutCount = 0;
|
|
221
|
+
for (const row of legacyRows) {
|
|
222
|
+
const nickKey = caseFold('rfc1459', row.account);
|
|
223
|
+
seen.add(nickKey);
|
|
224
|
+
const input = buildServicesPutItem(row, values['services-table']);
|
|
225
|
+
try {
|
|
226
|
+
await docClient.send(
|
|
227
|
+
new PutCommand({
|
|
228
|
+
...input,
|
|
229
|
+
ConditionExpression: 'attribute_not_exists(pk)',
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
legacyPutCount++;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
const msg = String(err);
|
|
235
|
+
if (msg.includes('ConditionalCheckFailed')) {
|
|
236
|
+
console.log(`Skipped ${row.account} (already registered in services).`);
|
|
237
|
+
} else {
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const store = await loadDynamoServicesStore(docClient, values['services-table'], SystemClock);
|
|
244
|
+
if (store === undefined) {
|
|
245
|
+
console.error(`Could not load services store from ${values['services-table']}`);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let envSeedCount = 0;
|
|
250
|
+
for (const acct of parseSaslAccountsForMigration(saslAccountsRaw)) {
|
|
251
|
+
const nickKey = caseFold('rfc1459', acct.username);
|
|
252
|
+
if (seen.has(nickKey)) continue;
|
|
253
|
+
seen.add(nickKey);
|
|
254
|
+
const result = store.registerNick(acct.username, acct.password, '');
|
|
255
|
+
if (result.ok) {
|
|
256
|
+
envSeedCount++;
|
|
257
|
+
console.log(`Registered ${acct.username} from SASL_ACCOUNTS env seed.`);
|
|
258
|
+
} else {
|
|
259
|
+
console.log(`Skipped ${acct.username} (already registered in services).`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
await store.flush();
|
|
263
|
+
|
|
264
|
+
console.log(
|
|
265
|
+
`AWS migration complete: ${legacyRows.length} legacy rows scanned ` +
|
|
266
|
+
`(${legacyPutCount} PutItem'd), ${envSeedCount} env-seed accounts registered.`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
void main();
|
package/tools/package.json
CHANGED
|
@@ -1,25 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Seed SASL PLAIN accounts into the DynamoDB `
|
|
3
|
+
* Seed SASL PLAIN accounts into the DynamoDB `Services` table (the unified
|
|
4
|
+
* services credential home).
|
|
5
|
+
*
|
|
6
|
+
* Repointed from the legacy `Accounts` table to the `Services` table —
|
|
7
|
+
* `Services` (`NICK:<fold>` partition) is the single credential home for
|
|
8
|
+
* SASL PLAIN, SASL EXTERNAL, PASS-auth, and NickServ IDENTIFY. Seeded
|
|
9
|
+
* accounts verify via `services.verifyNick` end-to-end.
|
|
4
10
|
*
|
|
5
11
|
* Usage:
|
|
6
12
|
* node --import tsx tools/seed-aws-accounts.ts \
|
|
7
|
-
* --table
|
|
13
|
+
* --table StagingServices \
|
|
8
14
|
* --endpoint http://localhost:8000 \
|
|
9
15
|
* --accounts alice:s3cret bob:password2
|
|
10
16
|
*
|
|
11
17
|
* Or read from a file (newline-delimited `username:password`):
|
|
12
18
|
* node --import tsx tools/seed-aws-accounts.ts \
|
|
13
|
-
* --table
|
|
19
|
+
* --table StagingServices \
|
|
14
20
|
* --file accounts.txt
|
|
15
21
|
*
|
|
16
|
-
* The script hashes each password with scrypt (never writes plaintext)
|
|
17
|
-
*
|
|
22
|
+
* The script hashes each password with scrypt (never writes plaintext) and
|
|
23
|
+
* writes via a conditional PutItem that never clobbers an existing nick
|
|
24
|
+
* registration (a NickServ-registered nick whose password was set by the
|
|
25
|
+
* user wins over the seed).
|
|
18
26
|
*/
|
|
19
27
|
|
|
20
28
|
import { readFileSync } from 'node:fs';
|
|
21
29
|
import { parseArgs } from 'node:util';
|
|
22
|
-
import { createDynamoDocumentClient,
|
|
30
|
+
import { createDynamoDocumentClient, loadDynamoServicesStore } from '@serverless-ircd/aws-adapter';
|
|
31
|
+
import { SystemClock } from '@serverless-ircd/irc-core';
|
|
23
32
|
|
|
24
33
|
const { values } = parseArgs({
|
|
25
34
|
options: {
|
|
@@ -33,7 +42,7 @@ const { values } = parseArgs({
|
|
|
33
42
|
|
|
34
43
|
if (values.table === undefined) {
|
|
35
44
|
console.error(
|
|
36
|
-
'Usage: seed-aws-accounts --table <
|
|
45
|
+
'Usage: seed-aws-accounts --table <ServicesTableName> [--endpoint <url>] [--accounts user:pass ...] [--file <path>]',
|
|
37
46
|
);
|
|
38
47
|
process.exit(1);
|
|
39
48
|
}
|
|
@@ -71,9 +80,25 @@ if (pairs.length === 0) {
|
|
|
71
80
|
process.exit(1);
|
|
72
81
|
}
|
|
73
82
|
|
|
83
|
+
// Load the services store against the target table, then register each
|
|
84
|
+
// account. `registerNick` is idempotent: it returns `{ ok: false }` when
|
|
85
|
+
// the nick already exists and does NOT overwrite the credential.
|
|
86
|
+
const store = await loadDynamoServicesStore(docClient, values.table, SystemClock);
|
|
87
|
+
if (store === undefined) {
|
|
88
|
+
console.error(`Could not load services store from table ${values.table}.`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
for (const { username, password } of pairs) {
|
|
75
|
-
const
|
|
76
|
-
|
|
93
|
+
const result = store.registerNick(username, password, '');
|
|
94
|
+
if (result.ok) {
|
|
95
|
+
console.log(`Seeded ${username}`);
|
|
96
|
+
} else {
|
|
97
|
+
console.log(`Skipped ${username} (already registered)`);
|
|
98
|
+
}
|
|
77
99
|
}
|
|
78
100
|
|
|
79
|
-
|
|
101
|
+
// Flush the write-behind queue so the registrations land in DynamoDB.
|
|
102
|
+
await store.flush();
|
|
103
|
+
|
|
104
|
+
console.log(`Done: ${pairs.length} account(s) processed against ${values.table}.`);
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Seed SASL PLAIN accounts into a Cloudflare D1 `
|
|
3
|
+
* Seed SASL PLAIN accounts into a Cloudflare D1 `nickserv_accounts` table.
|
|
4
|
+
*
|
|
5
|
+
* Repointed from the legacy `accounts` table to the unified services table
|
|
6
|
+
* — `nickserv_accounts` is the single credential home for SASL PLAIN, SASL
|
|
7
|
+
* EXTERNAL, PASS-auth, and NickServ IDENTIFY. Seeded accounts verify via
|
|
8
|
+
* `services.verifyNick` end-to-end.
|
|
4
9
|
*
|
|
5
10
|
* Usage:
|
|
6
11
|
* node --import tsx tools/seed-cf-accounts.ts \
|
|
@@ -17,9 +22,10 @@
|
|
|
17
22
|
* --file accounts.txt
|
|
18
23
|
*
|
|
19
24
|
* The script hashes each password with scrypt (never writes plaintext) and
|
|
20
|
-
* executes the resulting `INSERT OR
|
|
21
|
-
*
|
|
22
|
-
*
|
|
25
|
+
* executes the resulting `INSERT OR IGNORE` via `wrangler d1 execute`.
|
|
26
|
+
* `INSERT OR IGNORE` is idempotent: re-running with the same username does
|
|
27
|
+
* NOT overwrite an existing row (a NickServ-registered nick whose password
|
|
28
|
+
* was set by the user wins over the seed).
|
|
23
29
|
*
|
|
24
30
|
* Flags:
|
|
25
31
|
* --database D1 database name (as declared in wrangler.toml). Required.
|
|
@@ -33,10 +39,12 @@
|
|
|
33
39
|
import { execFileSync } from 'node:child_process';
|
|
34
40
|
import { readFileSync } from 'node:fs';
|
|
35
41
|
import { parseArgs } from 'node:util';
|
|
42
|
+
import { CREATE_NICKSERV_ACCOUNTS_SQL } from '@serverless-ircd/cf-adapter';
|
|
36
43
|
import {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
type HashedAccountCredential,
|
|
45
|
+
caseFold,
|
|
46
|
+
hashAccountCredential,
|
|
47
|
+
} from '@serverless-ircd/irc-core';
|
|
40
48
|
|
|
41
49
|
const { values } = parseArgs({
|
|
42
50
|
options: {
|
|
@@ -96,10 +104,35 @@ function runD1Execute(sql: string): void {
|
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
// Ensure the table exists (idempotent).
|
|
99
|
-
runD1Execute(
|
|
107
|
+
runD1Execute(CREATE_NICKSERV_ACCOUNTS_SQL);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Builds a self-contained `INSERT OR IGNORE` SQL statement that writes a
|
|
111
|
+
* scrypt-hashed `nickserv_accounts` row.
|
|
112
|
+
*
|
|
113
|
+
* `INSERT OR IGNORE` (not `INSERT OR REPLACE`) ensures the seed NEVER
|
|
114
|
+
* clobbers an existing registration — a NickServ-registered nick or a
|
|
115
|
+
* prior seed's password wins. The row carries an empty email, the default
|
|
116
|
+
* `'none'` enforcement policy, `cert_subjects = '[]'`, and the scrypt
|
|
117
|
+
* credential derived from the plaintext password.
|
|
118
|
+
*/
|
|
119
|
+
export function buildNickServPutStatement(
|
|
120
|
+
username: string,
|
|
121
|
+
password: string,
|
|
122
|
+
): { entry: HashedAccountCredential; sql: string } {
|
|
123
|
+
const entry = hashAccountCredential(username, password);
|
|
124
|
+
const nickKey = caseFold('rfc1459', username);
|
|
125
|
+
const sql = `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(entry.algorithm)}', '${sqlEscape(entry.salt)}', '${sqlEscape(entry.hash)}')`;
|
|
126
|
+
return { entry, sql };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Doubles single quotes for safe embedding in a D1 SQL string literal. */
|
|
130
|
+
function sqlEscape(value: string): string {
|
|
131
|
+
return value.replace(/'/g, "''");
|
|
132
|
+
}
|
|
100
133
|
|
|
101
134
|
for (const { username, password } of pairs) {
|
|
102
|
-
const { entry, sql } =
|
|
135
|
+
const { entry, sql } = buildNickServPutStatement(username, password);
|
|
103
136
|
runD1Execute(sql);
|
|
104
137
|
console.log(`Seeded ${username} (algorithm=${entry.algorithm})`);
|
|
105
138
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverless-ircd/tcp-ws-forwarder",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Local TCP to ws/wss forwarder so standard IRC clients can reach the WebSocket-only ServerlessIRCd transport",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SASL account store construction + resolution for the AWS adapter.
|
|
3
|
-
*
|
|
4
|
-
* Centralises `AccountStore` creation so the actor-construction site
|
|
5
|
-
* (`handleDefault`) binds a single helper rather than constructing the
|
|
6
|
-
* store inline. Two sources of credentials are reconciled here:
|
|
7
|
-
*
|
|
8
|
-
* 1. The DynamoDB `Accounts` table (authoritative when populated) —
|
|
9
|
-
* loaded at cold start into a {@link DynamoAccountStore} by
|
|
10
|
-
* {@link resolveAccountStore}. Credentials are stored as scrypt
|
|
11
|
-
* hashes, never plaintext.
|
|
12
|
-
* 2. The parsed server config (`saslAccounts`, sourced from the
|
|
13
|
-
* `SASL_ACCOUNTS` env var) — the legacy/config fallback used only
|
|
14
|
-
* when the table is empty, preserving the config-seed behaviour for
|
|
15
|
-
* deployments that have not migrated to the table.
|
|
16
|
-
*
|
|
17
|
-
* The resolved store is scoped to the Lambda execution context: it
|
|
18
|
-
* persists across warm invocations (via the memoised `HandlerDeps`) but
|
|
19
|
-
* is rebuilt on cold start, picking up account changes made since the
|
|
20
|
-
* last cold start.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
24
|
-
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
25
|
-
import {
|
|
26
|
-
type AccountStore,
|
|
27
|
-
InMemoryAccountStore,
|
|
28
|
-
type ParsedServerConfig,
|
|
29
|
-
} from '@serverless-ircd/irc-core';
|
|
30
|
-
import {
|
|
31
|
-
DynamoAccountStore,
|
|
32
|
-
type HashedAccountCredential,
|
|
33
|
-
hashAccountCredential,
|
|
34
|
-
loadDynamoAccountStore,
|
|
35
|
-
} from './dynamo-account-store.js';
|
|
36
|
-
|
|
37
|
-
// Re-export so callers can import everything from one module.
|
|
38
|
-
export {
|
|
39
|
-
DynamoAccountStore,
|
|
40
|
-
type HashedAccountCredential,
|
|
41
|
-
hashAccountCredential,
|
|
42
|
-
loadDynamoAccountStore,
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Constructs the config-seeded in-memory account store (the legacy
|
|
47
|
-
* fallback). Returns `undefined` when no accounts are configured so the
|
|
48
|
-
* actor's `ctx.accounts` stays unset (preserving the no-store behaviour:
|
|
49
|
-
* `AUTHENTICATE PLAIN` → `904`).
|
|
50
|
-
*
|
|
51
|
-
* @param serverConfig Parsed config carrying the `saslAccounts` seed list.
|
|
52
|
-
*/
|
|
53
|
-
export function bindAccountStore(serverConfig?: ParsedServerConfig): AccountStore | undefined {
|
|
54
|
-
if (serverConfig === undefined || serverConfig.saslAccounts.length === 0) {
|
|
55
|
-
return undefined;
|
|
56
|
-
}
|
|
57
|
-
return new InMemoryAccountStore(serverConfig.saslAccounts);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Resolves the SASL account store for a deployment, applying the
|
|
62
|
-
* table-then-config precedence.
|
|
63
|
-
*
|
|
64
|
-
* 1. Scans the DynamoDB `Accounts` table (pre-loading every hashed
|
|
65
|
-
* credential into a {@link DynamoAccountStore}). When the table has
|
|
66
|
-
* one or more rows it is authoritative — the table wins and the
|
|
67
|
-
* config seed is ignored.
|
|
68
|
-
* 2. When the table is empty, falls back to {@link bindAccountStore}
|
|
69
|
-
* (the `SASL_ACCOUNTS` env-var seed) so existing deployments that
|
|
70
|
-
* have not migrated to the table keep working unchanged.
|
|
71
|
-
* 3. Returns `undefined` when neither source has accounts (the default)
|
|
72
|
-
* so `ctx.accounts` stays unset and `AUTHENTICATE PLAIN` → `904`.
|
|
73
|
-
*
|
|
74
|
-
* Scan failures (table not found, network error) are swallowed and
|
|
75
|
-
* treated as an empty table, falling through to the config seed.
|
|
76
|
-
*
|
|
77
|
-
* @param docClient DynamoDB client addressed at the deployment's tables.
|
|
78
|
-
* @param tableName Physical name of the `Accounts` table for this env.
|
|
79
|
-
* @param serverConfig Parsed config (the config-seed fallback source).
|
|
80
|
-
*/
|
|
81
|
-
export async function resolveAccountStore(
|
|
82
|
-
docClient: DynamoDBDocumentClient,
|
|
83
|
-
tableName: string,
|
|
84
|
-
serverConfig?: ParsedServerConfig,
|
|
85
|
-
): Promise<AccountStore | undefined> {
|
|
86
|
-
try {
|
|
87
|
-
const store = await loadDynamoAccountStore(docClient, tableName);
|
|
88
|
-
if (store !== undefined) return store;
|
|
89
|
-
} catch {
|
|
90
|
-
// Table might not exist or be unreachable; fall through to config seed.
|
|
91
|
-
}
|
|
92
|
-
return bindAccountStore(serverConfig);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Writes (or re-seeds) a SASL PLAIN account into the `Accounts` table.
|
|
97
|
-
*
|
|
98
|
-
* The password is hashed via {@link hashAccountCredential} (scrypt,
|
|
99
|
-
* random salt) and stored as a {@link HashedAccountCredential} row —
|
|
100
|
-
* plaintext is never written. A repeat call for the same `username`
|
|
101
|
-
* overwrites the prior row (the PK is `account`), so this is the
|
|
102
|
-
* canonical CRUD/seed entry point for oper and SASL account
|
|
103
|
-
* provisioning.
|
|
104
|
-
*
|
|
105
|
-
* @returns The stored {@link HashedAccountCredential} (for tooling/tests).
|
|
106
|
-
*/
|
|
107
|
-
export async function putAccountCredential(
|
|
108
|
-
docClient: DynamoDBDocumentClient,
|
|
109
|
-
tableName: string,
|
|
110
|
-
username: string,
|
|
111
|
-
password: string,
|
|
112
|
-
): Promise<HashedAccountCredential> {
|
|
113
|
-
const entry = hashAccountCredential(username, password);
|
|
114
|
-
await docClient.send(
|
|
115
|
-
new PutCommand({
|
|
116
|
-
TableName: tableName,
|
|
117
|
-
Item: entry,
|
|
118
|
-
}),
|
|
119
|
-
);
|
|
120
|
-
return entry;
|
|
121
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DynamoDB-backed {@link AccountStore} primitives for the AWS adapter.
|
|
3
|
-
*
|
|
4
|
-
* The `Accounts` DynamoDB table stores SASL PLAIN credentials as
|
|
5
|
-
* **scrypt hashes** (never plaintext). Each row is a flat
|
|
6
|
-
* {@link HashedAccountCredential} (`{ account, algorithm, salt, hash }`).
|
|
7
|
-
*
|
|
8
|
-
* Two-phase construction keeps the `AccountStore` port synchronous:
|
|
9
|
-
* 1. {@link loadDynamoAccountStore} (async) scans the table at Lambda
|
|
10
|
-
* cold start and returns a fully-populated `DynamoAccountStore`.
|
|
11
|
-
* 2. `DynamoAccountStore(entries)` (sync constructor) takes the
|
|
12
|
-
* pre-loaded entries. The class itself has zero DynamoDB
|
|
13
|
-
* coupling — it works equally well with entries from any source.
|
|
14
|
-
*
|
|
15
|
-
* This mirrors the pre-load pattern documented in
|
|
16
|
-
* `irc-core/src/ports.ts` and used by `MotdProvider` / `MessageStore`:
|
|
17
|
-
* adapters that need an async backend pre-load the data at boot into a
|
|
18
|
-
* synchronously-readable store.
|
|
19
|
-
*
|
|
20
|
-
* The scrypt hashing helpers (`hashAccountCredential` /
|
|
21
|
-
* `verifyHashedPassword` + the `HashedAccountCredential` type) live in
|
|
22
|
-
* `irc-core` and are shared with the CF adapter's `D1AccountStore`. They
|
|
23
|
-
* are re-exported below so existing call sites importing from this module
|
|
24
|
-
* keep working.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import type { ScanCommandOutput } from '@aws-sdk/client-dynamodb';
|
|
28
|
-
import { ScanCommand } from '@aws-sdk/lib-dynamodb';
|
|
29
|
-
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
|
|
30
|
-
import {
|
|
31
|
-
type HashedAccountCredential,
|
|
32
|
-
HashedAccountStore,
|
|
33
|
-
hashAccountCredential,
|
|
34
|
-
verifyHashedPassword,
|
|
35
|
-
} from '@serverless-ircd/irc-core';
|
|
36
|
-
|
|
37
|
-
// Re-export so callers importing these from `@serverless-ircd/aws-adapter`
|
|
38
|
-
// (the historical home of the hashing helpers) keep compiling.
|
|
39
|
-
export { type HashedAccountCredential, hashAccountCredential, verifyHashedPassword };
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Synchronous {@link AccountStore} backed by pre-loaded hashed credentials.
|
|
43
|
-
*
|
|
44
|
-
* Now a re-export of the shared `HashedAccountStore` from `irc-core` —
|
|
45
|
-
* both adapters use one store implementation. The AWS-specific surface
|
|
46
|
-
* (`loadDynamoAccountStore`) lives below; it diverges from CF only at the
|
|
47
|
-
* scan boundary.
|
|
48
|
-
*/
|
|
49
|
-
export { HashedAccountStore as DynamoAccountStore };
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Scans the `Accounts` table and returns a fully-populated
|
|
53
|
-
* {@link DynamoAccountStore}, or `undefined` when the table has no rows.
|
|
54
|
-
*
|
|
55
|
-
* Handles pagination (`LastEvaluatedKey`) so tables larger than the 1 MB
|
|
56
|
-
* scan page are fully loaded. Rows missing required attributes are
|
|
57
|
-
* silently skipped.
|
|
58
|
-
*/
|
|
59
|
-
export async function loadDynamoAccountStore(
|
|
60
|
-
docClient: DynamoDBDocumentClient,
|
|
61
|
-
tableName: string,
|
|
62
|
-
): Promise<HashedAccountStore | undefined> {
|
|
63
|
-
const entries: HashedAccountCredential[] = [];
|
|
64
|
-
let exclusiveStartKey: Record<string, unknown> | undefined = undefined;
|
|
65
|
-
do {
|
|
66
|
-
const result: ScanCommandOutput = await docClient.send(
|
|
67
|
-
new ScanCommand({
|
|
68
|
-
TableName: tableName,
|
|
69
|
-
ExclusiveStartKey: exclusiveStartKey,
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
for (const item of result.Items ?? []) {
|
|
73
|
-
const account = typeof item.account === 'string' ? item.account : undefined;
|
|
74
|
-
const algorithm = typeof item.algorithm === 'string' ? item.algorithm : undefined;
|
|
75
|
-
const salt = typeof item.salt === 'string' ? item.salt : undefined;
|
|
76
|
-
const hash = typeof item.hash === 'string' ? item.hash : undefined;
|
|
77
|
-
if (
|
|
78
|
-
account !== undefined &&
|
|
79
|
-
algorithm !== undefined &&
|
|
80
|
-
salt !== undefined &&
|
|
81
|
-
hash !== undefined
|
|
82
|
-
) {
|
|
83
|
-
entries.push({
|
|
84
|
-
account,
|
|
85
|
-
algorithm: algorithm as HashedAccountCredential['algorithm'],
|
|
86
|
-
salt,
|
|
87
|
-
hash,
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
exclusiveStartKey = result.LastEvaluatedKey;
|
|
92
|
-
} while (exclusiveStartKey !== undefined);
|
|
93
|
-
if (entries.length === 0) return undefined;
|
|
94
|
-
return new HashedAccountStore(entries);
|
|
95
|
-
}
|