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
|
@@ -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",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Socket } from 'node:net';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dedicated coverage for the `server.close` error-rejection branch in
|
|
6
|
+
* forwarder close() (forwarder.ts:135): `server.close((err) => err ?
|
|
7
|
+
* reject(err) : resolve())`. Under normal operation the callback never
|
|
8
|
+
* surfaces an error, so the truthy branch is unreachable without a stub.
|
|
9
|
+
*
|
|
10
|
+
* This file mocks `node:net` so the forwarder's TCP listener gets a server
|
|
11
|
+
* whose `close` invokes its callback with a synthetic Error. The mock is
|
|
12
|
+
* file-scoped (vi.mock), leaving the real-socket integration suite in
|
|
13
|
+
* forwarder.test.ts untouched.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
vi.mock('node:net', async (importActual) => {
|
|
17
|
+
const actual = await importActual<typeof import('node:net')>();
|
|
18
|
+
const wrappedCreateServer = (connectionListener?: (socket: Socket) => void) => {
|
|
19
|
+
const server = actual.createServer(connectionListener);
|
|
20
|
+
const realClose = server.close.bind(server);
|
|
21
|
+
// Drive the real shutdown so the listener is actually torn down, but
|
|
22
|
+
// surface a synthetic error to the callback to exercise the
|
|
23
|
+
// error-rejection branch.
|
|
24
|
+
server.close = ((cb?: (err?: Error | null) => void) => {
|
|
25
|
+
realClose(() => cb?.(new Error('synthetic server.close failure')));
|
|
26
|
+
return server;
|
|
27
|
+
}) as unknown as typeof server.close;
|
|
28
|
+
return server;
|
|
29
|
+
};
|
|
30
|
+
return { ...actual, createServer: wrappedCreateServer as unknown as typeof actual.createServer };
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const { startForwarder } = await import('../src/forwarder');
|
|
34
|
+
|
|
35
|
+
describe('forwarder close() — server.close error branch', () => {
|
|
36
|
+
it('rejects when server.close surfaces an error to its callback', async () => {
|
|
37
|
+
const fwd = await startForwarder({ listenPort: 0, targetUrl: 'ws://127.0.0.1:1/' });
|
|
38
|
+
await expect(fwd.close()).rejects.toThrow('synthetic server.close failure');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import type { Socket } from 'node:net';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Coverage for two defensive branches in forwarder.ts that cannot be reached
|
|
7
|
+
* through real sockets:
|
|
8
|
+
* - line 148: `server.address()` always returns an AddressInfo after the
|
|
9
|
+
* 'listening' event fires, so the `opts.listenPort` ternary fallback is
|
|
10
|
+
* never taken in practice.
|
|
11
|
+
* - line 184: a connected server-side socket always carries remoteAddress/
|
|
12
|
+
* remotePort, so the `?? '?'` remote-label fallbacks are never taken.
|
|
13
|
+
*
|
|
14
|
+
* Both are exercised here by mocking `node:net` so the forwarder's TCP server
|
|
15
|
+
* reports a null address and its connection listener is captured, allowing a
|
|
16
|
+
* synthetic address-less socket to be fed in. The mock is file-scoped
|
|
17
|
+
* (vi.mock); the real-socket integration suite in forwarder.test.ts is
|
|
18
|
+
* untouched.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const state = vi.hoisted(() => ({
|
|
22
|
+
captured: undefined as ((socket: Socket) => void) | undefined,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('node:net', async (importActual) => {
|
|
26
|
+
const actual = await importActual<typeof import('node:net')>();
|
|
27
|
+
const wrappedCreateServer = (connectionListener?: (socket: Socket) => void) => {
|
|
28
|
+
state.captured = connectionListener;
|
|
29
|
+
const server = actual.createServer(connectionListener);
|
|
30
|
+
// Force the defensive AddressInfo fallback (forwarder.ts:148).
|
|
31
|
+
server.address = (() => null) as typeof server.address;
|
|
32
|
+
return server;
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
...actual,
|
|
36
|
+
createServer: wrappedCreateServer as unknown as typeof actual.createServer,
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const { startForwarder } = await import('../src/forwarder');
|
|
41
|
+
|
|
42
|
+
const openForwarders: { close(): Promise<void> }[] = [];
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
for (const f of openForwarders.splice(0)) {
|
|
45
|
+
await f.close().catch(() => undefined);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('forwarder — defensive branches unreachable via real sockets', () => {
|
|
50
|
+
it('falls back to opts.listenPort when server.address() yields no AddressInfo', async () => {
|
|
51
|
+
const fwd = await startForwarder({ listenPort: 0, targetUrl: 'ws://127.0.0.1:1/' });
|
|
52
|
+
openForwarders.push(fwd);
|
|
53
|
+
// server.address() returns null → the ternary takes its false branch and
|
|
54
|
+
// the reported port is the requested opts.listenPort (0), not a real
|
|
55
|
+
// ephemeral port.
|
|
56
|
+
expect(fwd.listenPort).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('builds the remote label with "?" placeholders for an address-less socket', async () => {
|
|
60
|
+
const fwd = await startForwarder({ listenPort: 0, targetUrl: 'ws://127.0.0.1:1/' });
|
|
61
|
+
openForwarders.push(fwd);
|
|
62
|
+
|
|
63
|
+
// Synthesize an incoming connection on a socket with no remote address/
|
|
64
|
+
// port; bridgeConnection must build the `?:?` label (forwarder.ts:184)
|
|
65
|
+
// and register the bridge without throwing.
|
|
66
|
+
const fakeSocket = new EventEmitter();
|
|
67
|
+
Object.assign(fakeSocket, {
|
|
68
|
+
remoteAddress: undefined,
|
|
69
|
+
remotePort: undefined,
|
|
70
|
+
destroy: () => undefined,
|
|
71
|
+
});
|
|
72
|
+
const listener = state.captured;
|
|
73
|
+
if (listener === undefined) throw new Error('connection listener was not captured');
|
|
74
|
+
listener(fakeSocket as unknown as Socket);
|
|
75
|
+
|
|
76
|
+
expect([...fwd.testSockets]).toContain(fakeSocket);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -735,3 +735,54 @@ describe('forwarder — logging', () => {
|
|
|
735
735
|
infoSpy.mockRestore();
|
|
736
736
|
});
|
|
737
737
|
});
|
|
738
|
+
|
|
739
|
+
describe('forwarder — empty-line drop (TCP→WS guard)', () => {
|
|
740
|
+
it('drops a bare-CRLF empty line and forwards only the next non-empty line', async () => {
|
|
741
|
+
const target = await startTarget();
|
|
742
|
+
openServers.push(target);
|
|
743
|
+
const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
|
|
744
|
+
openServers.push(fwd);
|
|
745
|
+
|
|
746
|
+
const client = new TcpClient(fwd.listenPort);
|
|
747
|
+
openClients.push(client);
|
|
748
|
+
await client.opened();
|
|
749
|
+
|
|
750
|
+
// A bare CRLF yields an empty line from the LineScanner; the
|
|
751
|
+
// `line.length > 0` guard drops it, so only the subsequent real line is
|
|
752
|
+
// bridged as a single WS frame.
|
|
753
|
+
client.sendRaw('\r\n');
|
|
754
|
+
client.send('PING :after-empty');
|
|
755
|
+
|
|
756
|
+
const ws = await target.waitForConnection();
|
|
757
|
+
await new Promise<void>((res) => ws.once('message', () => res()));
|
|
758
|
+
expect(target.frames).toEqual(['PING :after-empty']);
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
describe('forwarder — post-teardown error suppression', () => {
|
|
763
|
+
it('suppresses a tcp socket error arriving after the bridge has torn down', async () => {
|
|
764
|
+
const target = await startTarget();
|
|
765
|
+
openServers.push(target);
|
|
766
|
+
const fwd = await startForwarder({ listenPort: 0, targetUrl: `ws://127.0.0.1:${target.port}` });
|
|
767
|
+
openServers.push(fwd);
|
|
768
|
+
|
|
769
|
+
const client = new TcpClient(fwd.listenPort);
|
|
770
|
+
openClients.push(client);
|
|
771
|
+
await client.opened();
|
|
772
|
+
await target.waitForConnection();
|
|
773
|
+
|
|
774
|
+
const serverSide = [...fwd.testSockets][0];
|
|
775
|
+
if (serverSide === undefined) throw new Error('no server-side socket');
|
|
776
|
+
|
|
777
|
+
// Tearing the bridge down arms the `bridgeClosed` guard; a tcp error
|
|
778
|
+
// arriving afterwards (e.g. an RST echoing our own destroy()) is
|
|
779
|
+
// expected and must be silently suppressed.
|
|
780
|
+
await fwd.close();
|
|
781
|
+
|
|
782
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
783
|
+
serverSide.emit('error', new Error('post-teardown noise'));
|
|
784
|
+
await new Promise((r) => setImmediate(r));
|
|
785
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
786
|
+
errSpy.mockRestore();
|
|
787
|
+
});
|
|
788
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { ConsoleLogger, LogLevel, parseLogLevel } from '../src/logger';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -134,3 +134,33 @@ describe('ConsoleLogger — output shape', () => {
|
|
|
134
134
|
expect(JSON.parse(lines.error[0] as string).msg).toBe('e');
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
|
+
|
|
138
|
+
describe('ConsoleLogger — global console sinks', () => {
|
|
139
|
+
it('routes a warn record to the global console.warn sink when no sinks are injected', () => {
|
|
140
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
141
|
+
const log = new ConsoleLogger(undefined, LogLevel.Warn);
|
|
142
|
+
log.warn('global sink warn', { k: 'v' });
|
|
143
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
144
|
+
const rec = JSON.parse(String(warnSpy.mock.calls[0]?.[0] ?? '{}'));
|
|
145
|
+
expect(rec.msg).toBe('global sink warn');
|
|
146
|
+
warnSpy.mockRestore();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('falls back to no-op drop-sinks when globalThis.console is undefined', () => {
|
|
150
|
+
const original = globalThis.console;
|
|
151
|
+
(globalThis as { console?: Console | undefined }).console = undefined;
|
|
152
|
+
try {
|
|
153
|
+
// globalConsoleSinks() takes the `console === undefined` branch and
|
|
154
|
+
// returns drop-sinks; every level must swallow without throwing.
|
|
155
|
+
const log = new ConsoleLogger(undefined, LogLevel.Debug);
|
|
156
|
+
expect(() => {
|
|
157
|
+
log.debug('d');
|
|
158
|
+
log.info('i');
|
|
159
|
+
log.warn('w');
|
|
160
|
+
log.error('e');
|
|
161
|
+
}).not.toThrow();
|
|
162
|
+
} finally {
|
|
163
|
+
(globalThis as { console?: Console | undefined }).console = original;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -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
|
-
}
|