@synopackageland/cli 0.32.0 → 0.33.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/CHANGELOG.md +7 -5
- package/README.md +4 -4
- package/dist/cli.js +33 -12
- package/dist/ctl/cli.d.ts +1 -0
- package/dist/ctl/cli.js +61 -19
- package/dist/ctl/dsm-auth.js +2 -2
- package/dist/ctl/errors.js +1 -1
- package/dist/ctl/input.js +2 -2
- package/dist/ctl/service.js +8 -6
- package/dist/ctl/store.d.ts +4 -0
- package/dist/ctl/store.js +33 -5
- package/dist/ctl/tls.d.ts +22 -0
- package/dist/ctl/tls.js +118 -0
- package/dist/ctl/transfers.d.ts +2 -0
- package/dist/ctl/transfers.js +5 -4
- package/dist/ctl/transport.d.ts +3 -1
- package/dist/ctl/transport.js +38 -8
- package/dist/error-registry.js +9 -11
- package/dist/host/catalog-install.d.ts +0 -10
- package/dist/host/catalog-install.js +24 -140
- package/dist/host/connection.d.ts +9 -0
- package/dist/host/connection.js +49 -0
- package/dist/host/credentials.d.ts +3 -1
- package/dist/host/credentials.js +27 -62
- package/dist/host/dsm-client.d.ts +4 -7
- package/dist/host/dsm-client.js +47 -92
- package/dist/host/dsm-http.d.ts +2 -0
- package/dist/host/dsm-http.js +21 -1
- package/dist/host/dsm-port-profile.js +7 -2
- package/dist/host/install-plan.d.ts +32 -0
- package/dist/host/install-plan.js +157 -0
- package/dist/host/package-commands.d.ts +75 -0
- package/dist/host/package-commands.js +205 -0
- package/dist/host/port-info-lookup.d.ts +1 -1
- package/dist/host/port-info-lookup.js +5 -2
- package/dist/host/share-lookup.d.ts +3 -2
- package/dist/host/share-lookup.js +5 -91
- package/dist/host/share-permission-lookup.d.ts +3 -3
- package/dist/host/share-permission-lookup.js +18 -30
- package/dist/host/types.d.ts +5 -1
- package/dist/init-upstream.js +6 -6
- package/dist/package-metadata.d.ts +8 -1
- package/dist/package-metadata.js +28 -1
- package/dist/skill.js +1 -0
- package/dist/spk/inspect.d.ts +1 -0
- package/dist/spk/inspect.js +2 -1
- package/dist/spk/validate-spk.d.ts +1 -0
- package/dist/spk/validate-spk.js +1 -0
- package/dist/upstream-attribution.js +4 -3
- package/package.json +1 -1
- package/schema/package-metadata.schema.json +32 -3
- package/schema/synology-app.schema.json +32 -3
- package/skills/SKILL.md +3 -1
- package/skills/reference/compose.md +1 -1
- package/skills/reference/ctl.md +4 -4
- package/skills/reference/deploy.md +2 -0
- package/skills/reference/host-install.md +55 -0
- package/skills/reference/upstream-fork.md +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.33.0 (2026-10-27)
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
5
|
+
- Share one secure NAS login across host, ctl, deploy, install, uninstall, status and logs
|
|
6
|
+
- Install or upgrade store packages and local SPKs with dependency plans, compatibility checks and pinned download verification
|
|
7
|
+
- Preview package changes with --dry-run, confirm with --yes and query installed packages without a project directory
|
|
8
|
+
- Trust default NAS certificates on first use, pin the leaf SHA-256 before sending credentials and reject changed certificates until explicitly trusted
|
|
9
|
+
- Distinguish certificate, DNS, connection refusal and timeout errors; support private CAs and expected certificate fingerprints
|
|
10
|
+
- Document interactive and environment-only automation login without saving passwords
|
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ CLI-only releases can differ from the SDK patch. Compose import and SDK defaults
|
|
|
74
74
|
read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
|
|
75
75
|
|
|
76
76
|
`init` and `add` read their templates from the locally installed
|
|
77
|
-
`@synopackageland/templates`. CLI 0.32.
|
|
77
|
+
`@synopackageland/templates`. CLI 0.32.1 depends on `~0.31.0`: npm installation or
|
|
78
78
|
update can select the latest templates patch in the 0.31 minor line. Templates
|
|
79
79
|
patches need only a templates release; update the npm installation to receive them.
|
|
80
80
|
The CLI never fetches templates at runtime, so offline use works after installation;
|
|
@@ -346,11 +346,11 @@ DSM readiness; inspect lists explicit acceptance blockers.
|
|
|
346
346
|
|
|
347
347
|
## Control installed Apps
|
|
348
348
|
|
|
349
|
-
Use `synopkgland
|
|
349
|
+
Use `synopkgland host add nas --url https://nas.example:5001 --username alice`
|
|
350
350
|
to register and authenticate a NAS in OS secure storage. A bare host name uses
|
|
351
351
|
`https://<host>:5001`; provide a full URL for a different scheme or port.
|
|
352
352
|
The CLI loads only `DSM_*` and `SYNOPKGLAND_*` variables from `.env`; set other
|
|
353
|
-
process variables in the shell. `
|
|
353
|
+
process variables in the shell. `host list --json`
|
|
354
354
|
checks sessions; `ctl list --host nas --json` discovers all approved task-level
|
|
355
355
|
commands and their schemas. Run the listed alias/command with typed flags,
|
|
356
356
|
`--help` and `--dry-run`; writes require `--yes`. There is no mutable current
|
|
@@ -382,7 +382,7 @@ not become process exit codes 4 or 5. Attempting to execute an excluded App with
|
|
|
382
382
|
`--refresh` instead returns `ok:false`, `CTL_APP_UNKNOWN` and exit 3 because the
|
|
383
383
|
App is absent from discovery results; no business operation runs.
|
|
384
384
|
|
|
385
|
-
`
|
|
385
|
+
`host list/show` can also succeed while reporting `authenticated:false` and
|
|
386
386
|
exit 0. Check discovery `apps`/`diagnostics` and session fields as well as the exit
|
|
387
387
|
code when deciding whether an App is ready.
|
|
388
388
|
`ctl --timeout <seconds>` applies to discovery, JSON calls, polling and transfer
|
package/dist/cli.js
CHANGED
|
@@ -289,6 +289,11 @@ async function containerBuild(project, arches, options) {
|
|
|
289
289
|
}
|
|
290
290
|
/** Resolves after dispatch; commands may set process.exitCode. Rejections are failures. */
|
|
291
291
|
export async function runCli(argv) {
|
|
292
|
+
if (argv[2] === "host") {
|
|
293
|
+
const { runCtl } = await import("./ctl/cli.js");
|
|
294
|
+
await runCtl(["host", ...argv.slice(3)]);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
292
297
|
if (argv[2] === "ctl") {
|
|
293
298
|
const { runCtl } = await import("./ctl/cli.js");
|
|
294
299
|
await runCtl(argv.slice(3));
|
|
@@ -304,14 +309,24 @@ export async function runCli(argv) {
|
|
|
304
309
|
.option("--project <dir>", "App project root")
|
|
305
310
|
.exitOverride();
|
|
306
311
|
const rootOf = (command) => resolve(process.cwd(), command.parent?.opts().project ?? ".");
|
|
307
|
-
const hostOf = (host) => {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
312
|
+
const hostOf = (host) => createNasHost({ host });
|
|
313
|
+
program.command("host").description("Shared NAS login and host management (host --help)");
|
|
314
|
+
program.command("install <package-or-spk>")
|
|
315
|
+
.description("Install a store package or a local SPK")
|
|
316
|
+
.option("--host <name>", "Registered NAS host")
|
|
317
|
+
.option("--source <url>", "HTTPS package source")
|
|
318
|
+
.option("--param <NAME=value>", "Install wizard value", (v, p) => collectKv(v, p, "--param"))
|
|
319
|
+
.option("--yes", "Confirm installation")
|
|
320
|
+
.option("--dry-run", "Print the installation plan without writing to DSM")
|
|
321
|
+
.option("--json", "Print a single JSON object")
|
|
322
|
+
.action(async (spec, options) => { const { installPackage } = await import('./host/package-commands.js'); const result = await installPackage(spec, options); emit('install', result, JSON.stringify(result, null, 2)); });
|
|
323
|
+
program.command("uninstall <package-id>")
|
|
324
|
+
.description("Remove an installed package")
|
|
325
|
+
.option("--host <name>", "Registered NAS host")
|
|
326
|
+
.option("--yes", "Confirm removal")
|
|
327
|
+
.option("--dry-run", "Print the removal plan without writing to DSM")
|
|
328
|
+
.option("--json", "Print a single JSON object")
|
|
329
|
+
.action(async (id, options) => { const { uninstallPackage } = await import('./host/package-commands.js'); const result = await uninstallPackage(id, options); emit('uninstall', result, JSON.stringify(result, null, 2)); });
|
|
315
330
|
program.command("ctl").description("Control registered NAS Apps using described AI commands (ctl --help)");
|
|
316
331
|
program
|
|
317
332
|
.command("check")
|
|
@@ -473,7 +488,7 @@ export async function runCli(argv) {
|
|
|
473
488
|
const targetDir = resolve(dir);
|
|
474
489
|
const upstream = options.upstreamFork ? upstreamRepository(targetDir, options.upstream) : undefined;
|
|
475
490
|
if (options.upstream && !upstream)
|
|
476
|
-
throw failure("INIT_UPSTREAM_INVALID", "Supply an
|
|
491
|
+
throw failure("INIT_UPSTREAM_INVALID", "Supply an HTTPS repository URL without credentials");
|
|
477
492
|
if (!options.upstreamFork && (options.upstream || options.upstreamMaintainer || options.publisher || options.publisherUrl))
|
|
478
493
|
throw failure("INIT_OPTIONS_UNSUPPORTED", "Attribution options require --upstream-fork");
|
|
479
494
|
initFromTemplate(options.template, targetDir, {
|
|
@@ -608,12 +623,18 @@ export async function runCli(argv) {
|
|
|
608
623
|
emit("deploy", result, `Deployed ${result.packageId}${result.openUrl ? "\nOpen URL: " + result.openUrl : ""}`);
|
|
609
624
|
});
|
|
610
625
|
program
|
|
611
|
-
.command("status")
|
|
626
|
+
.command("status [package-id]")
|
|
612
627
|
.description("DSM package status, Open URL and capability readiness")
|
|
613
628
|
.option("--json", "Print a single JSON object")
|
|
614
629
|
.option("--host <name>", "NAS profile")
|
|
615
630
|
.option("--dev", "Development Package Identity")
|
|
616
|
-
.action(async (options, command) => {
|
|
631
|
+
.action(async (id, options, command) => {
|
|
632
|
+
if (id) {
|
|
633
|
+
const { packageStatus } = await import('./host/package-commands.js');
|
|
634
|
+
const result = await packageStatus(id, options.host);
|
|
635
|
+
emit('status', result, JSON.stringify(result, null, 2));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
617
638
|
const host = hostOf(options.host);
|
|
618
639
|
const root = rootOf(command);
|
|
619
640
|
const standalone = standaloneRoot(root);
|
|
@@ -629,7 +650,7 @@ export async function runCli(argv) {
|
|
|
629
650
|
program
|
|
630
651
|
.command("logs")
|
|
631
652
|
.description("Status diagnosis: DSM package status and masked diagnostics; does not stream application logs")
|
|
632
|
-
.
|
|
653
|
+
.option("--host <name>", "Registered NAS host")
|
|
633
654
|
.option("--dev", "Development Package Identity")
|
|
634
655
|
.action(async (options, command) => {
|
|
635
656
|
const host = hostOf(options.host);
|
package/dist/ctl/cli.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Descriptor } from './descriptor.js';
|
|
|
4
4
|
export interface CtlIO {
|
|
5
5
|
store?: CtlStore;
|
|
6
6
|
out?: (line: string) => void;
|
|
7
|
+
err?: (line: string) => void;
|
|
7
8
|
isTTY?: boolean;
|
|
8
9
|
prompt?: (message: string, secret?: boolean) => Promise<string>;
|
|
9
10
|
requestFor?: (host: Host) => JsonRequest;
|
package/dist/ctl/cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { probeCertificate, normalizeFingerprint } from './tls.js';
|
|
1
2
|
import { readBounded } from './json.js';
|
|
2
3
|
import { readFileSync } from 'node:fs';
|
|
3
4
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -9,7 +10,7 @@ import { selectHosts, selectApp, selectOperation, flagName, RESERVED_FLAGS } fro
|
|
|
9
10
|
import { canonicalOrigin, CTL_TIMEOUT_MS, createRequest } from './transport.js';
|
|
10
11
|
import { loginDsm, logoutDsm } from './dsm-auth.js';
|
|
11
12
|
import { publicOperation } from './redaction.js';
|
|
12
|
-
const USAGE = 'synopkgland
|
|
13
|
+
const USAGE = 'synopkgland host add|list|show|login|logout|remove|revoke|default|trust; ctl list [--host a,b|--all-hosts] [--app pkg] [--refresh]; ctl <alias|package> <command...> [typed flags|--input file|-] [--dry-run] [--yes]';
|
|
13
14
|
async function prompt(message, secret = false) {
|
|
14
15
|
if (!process.stdin.isTTY)
|
|
15
16
|
throw new CtlError('CTL_INTERACTION_REQUIRED', 'Interactive input required.', 'Use --username and --password-stdin for host authentication.', 2);
|
|
@@ -79,7 +80,7 @@ export async function runCtl(argv, io = {}) {
|
|
|
79
80
|
if (args[0] === 'host') {
|
|
80
81
|
const action = args[1], name = args[2];
|
|
81
82
|
if (help || !action) {
|
|
82
|
-
emit({ ok: true, data: { usage: USAGE, actions: ['add', 'list', 'show', 'login', 'logout', 'remove', 'revoke'] } });
|
|
83
|
+
emit({ ok: true, data: { usage: USAGE, actions: ['add', 'list', 'show', 'login', 'logout', 'remove', 'revoke', 'default', 'trust'] } });
|
|
83
84
|
return;
|
|
84
85
|
}
|
|
85
86
|
if (action === 'list') {
|
|
@@ -88,31 +89,55 @@ export async function runCtl(argv, io = {}) {
|
|
|
88
89
|
return;
|
|
89
90
|
}
|
|
90
91
|
if (!name)
|
|
91
|
-
throw new CtlError('CTL_ARGUMENT_INVALID', 'Host name is required.', 'run: synopkgland
|
|
92
|
+
throw new CtlError('CTL_ARGUMENT_INVALID', 'Host name is required.', 'run: synopkgland host list');
|
|
93
|
+
if (action === 'default') {
|
|
94
|
+
store.setDefault(name);
|
|
95
|
+
emit({ ok: true, data: { defaultHost: name } });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
92
98
|
if (action === 'add' || action === 'login') {
|
|
93
99
|
const remaining = args.slice(3);
|
|
94
|
-
const url = take(remaining, '--url', true), usernameOption = take(remaining, '--username', true), passwordStdin = !!take(remaining, '--password-stdin'), tunnel = !!take(remaining, '--ssh-tunnel'), noRemember = !!take(remaining, '--no-remember-device'), ca = take(remaining, '--ca', true);
|
|
100
|
+
const url = take(remaining, '--url', true), usernameOption = take(remaining, '--username', true), passwordStdin = !!take(remaining, '--password-stdin'), tunnel = !!take(remaining, '--ssh-tunnel'), noRemember = !!take(remaining, '--no-remember-device'), ca = take(remaining, '--ca', true), fingerprint = take(remaining, '--fingerprint', true);
|
|
101
|
+
if (ca && fingerprint)
|
|
102
|
+
throw new CtlError('CTL_ARGUMENT_INVALID', 'Use --ca or --fingerprint', 'Choose one certificate trust policy');
|
|
95
103
|
if (remaining.length)
|
|
96
|
-
throw new CtlError('CTL_ARGUMENT_INVALID', 'Unknown host options.', 'run: synopkgland
|
|
104
|
+
throw new CtlError('CTL_ARGUMENT_INVALID', 'Unknown host options.', 'run: synopkgland host --help', 3);
|
|
97
105
|
if (action === 'add' && store.list().some(h => h.name === name))
|
|
98
|
-
throw new CtlError('CTL_HOST_EXISTS', `Host ${name} already registered.`, `run: synopkgland
|
|
106
|
+
throw new CtlError('CTL_HOST_EXISTS', `Host ${name} already registered.`, `run: synopkgland host login ${name}`);
|
|
99
107
|
if (action === 'login' && (url || usernameOption || tunnel || ca))
|
|
100
|
-
throw new CtlError('CTL_ARGUMENT_INVALID', 'Login uses the registered URL/account; add a separately named host to change them.', `run: synopkgland
|
|
108
|
+
throw new CtlError('CTL_ARGUMENT_INVALID', 'Login uses the registered URL/account; add a separately named host to change them.', `run: synopkgland host show ${name}`);
|
|
101
109
|
const old = action === 'login' ? store.get(name) : undefined;
|
|
102
110
|
if (action === 'add' && !url)
|
|
103
|
-
throw new CtlError('CTL_URL_REQUIRED', '--url is required.', `run: synopkgland
|
|
104
|
-
const host = old ?? { name, origin: canonicalOrigin(url, tunnel), username: usernameOption ?? await ask('DSM account: '), approved: {}, ...(tunnel ? { tunnel: true } : {}), ...(ca ? { ca } : {}) };
|
|
111
|
+
throw new CtlError('CTL_URL_REQUIRED', '--url is required.', `run: synopkgland host add ${name} --url https://${name}:5001`);
|
|
112
|
+
const host = old ?? { name, origin: canonicalOrigin(url, tunnel), username: usernameOption ?? process.env.DSM_USERNAME ?? await ask('DSM account: '), approved: {}, ...(tunnel ? { tunnel: true } : {}), ...(ca ? { ca } : {}) };
|
|
113
|
+
if (fingerprint)
|
|
114
|
+
normalizeFingerprint(fingerprint);
|
|
115
|
+
if (old?.fingerprint && fingerprint && normalizeFingerprint(fingerprint) !== old.fingerprint)
|
|
116
|
+
throw new CtlError('HOST_CERT_CHANGED', 'Login cannot replace a certificate pin', `run: synopkgland host trust ${name}`, 6);
|
|
117
|
+
if (!io.requestFor) {
|
|
118
|
+
const checked = await probeCertificate(host.origin, { name, ca: host.ca ? readFileSync(host.ca, 'utf8') : undefined, fingerprint: host.fingerprint ?? fingerprint, timeoutMs });
|
|
119
|
+
if (checked.fingerprint && !host.fingerprint) {
|
|
120
|
+
host.fingerprint = checked.fingerprint;
|
|
121
|
+
store.save(host);
|
|
122
|
+
if (!checked.trusted)
|
|
123
|
+
(io.err ?? console.error)(`Trusting the NAS certificate ${checked.fingerprint.slice(0, 16)} (self-signed)`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else if (fingerprint)
|
|
127
|
+
host.fingerprint = normalizeFingerprint(fingerprint);
|
|
105
128
|
const previous = old ? await store.credentials.get(store.key(host)) : null;
|
|
106
|
-
const secret = passwordStdin ? readBounded(0, 4096).replace(/[\r\n]+$/, '') : await ask('User password: ', true);
|
|
107
|
-
const session = await loginDsm(host.origin, host.username, secret, { remember: !noRemember, deviceId: previous?.deviceId, otp: () => ask('DSM OTP: ', true), request: io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined), ca: host.ca ? readFileSync(host.ca, 'utf8') : undefined });
|
|
129
|
+
const secret = process.env.DSM_PASSWORD ?? (passwordStdin ? readBounded(0, 4096).replace(/[\r\n]+$/, '') : await ask('User password: ', true));
|
|
130
|
+
const session = await loginDsm(host.origin, host.username, secret, { remember: !noRemember, deviceId: previous?.deviceId, otp: () => process.env.DSM_OTP ? Promise.resolve(process.env.DSM_OTP) : ask('DSM OTP: ', true), request: io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined, { fingerprint: host.fingerprint, name: host.name }), ca: host.ca ? readFileSync(host.ca, 'utf8') : undefined });
|
|
108
131
|
try {
|
|
109
|
-
if (old)
|
|
132
|
+
if (old || host.fingerprint) {
|
|
110
133
|
await store.credentials.set(store.key(host), session);
|
|
134
|
+
store.save(host);
|
|
135
|
+
}
|
|
111
136
|
else
|
|
112
137
|
await store.add(host, session);
|
|
113
138
|
}
|
|
114
139
|
catch (e) {
|
|
115
|
-
await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs)).catch(() => { });
|
|
140
|
+
await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined, { fingerprint: host.fingerprint, name: host.name })).catch(() => { });
|
|
116
141
|
throw e;
|
|
117
142
|
}
|
|
118
143
|
host.lastUsed = new Date().toISOString();
|
|
@@ -121,25 +146,42 @@ export async function runCtl(argv, io = {}) {
|
|
|
121
146
|
return;
|
|
122
147
|
}
|
|
123
148
|
const host = store.get(name);
|
|
149
|
+
if (action === 'trust') {
|
|
150
|
+
if (host.ca)
|
|
151
|
+
throw new CtlError('HOST_CA_CONFIGURED', 'This host uses a configured CA', 'Update the trusted CA file instead of replacing it with a leaf pin', 3);
|
|
152
|
+
if (!host.origin.startsWith('https:'))
|
|
153
|
+
throw new CtlError('HOST_TLS_REQUIRED', 'Certificate trust requires HTTPS', 'Use an HTTPS host registration', 3);
|
|
154
|
+
const checked = await probeCertificate(host.origin, { fingerprint: take(args, '--fingerprint', true), name });
|
|
155
|
+
// Get the leaf even when system-trusted, to make an explicit re-pin stable.
|
|
156
|
+
const current = checked.leafFingerprint;
|
|
157
|
+
if (!current)
|
|
158
|
+
throw new CtlError('HOST_TLS_SYSTEM_TRUSTED', 'This NAS certificate is trusted by the system', 'Remove the old pin only after verifying the NAS certificate with --fingerprint', 3);
|
|
159
|
+
if (!yes && (!tty || !/^y(es)?$/i.test(await ask(`Trust NAS certificate SHA-256 ${current} (previous ${host.fingerprint ?? 'none'})? [y/N] `))))
|
|
160
|
+
throw new CtlError('HOST_TRUST_CONFIRM_REQUIRED', 'Certificate replacement requires confirmation', `Verify the certificate, then run: synopkgland host trust ${name}`, 7);
|
|
161
|
+
host.fingerprint = current;
|
|
162
|
+
store.save(host);
|
|
163
|
+
emit({ ok: true, data: { name, fingerprint: current } });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
124
166
|
if (action === 'show') {
|
|
125
167
|
emit({ ok: true, data: { ...host, authenticated: await client(host).valid() } });
|
|
126
168
|
return;
|
|
127
169
|
}
|
|
128
170
|
if (action === 'revoke') {
|
|
129
171
|
if (!args[3])
|
|
130
|
-
throw new CtlError('CTL_ARGUMENT_INVALID', 'Package ID is required.', `run: synopkgland
|
|
172
|
+
throw new CtlError('CTL_ARGUMENT_INVALID', 'Package ID is required.', `run: synopkgland host show ${name}`);
|
|
131
173
|
emit({ ok: true, data: store.revoke(name, args[3]) });
|
|
132
174
|
return;
|
|
133
175
|
}
|
|
134
176
|
if (action === 'logout' || action === 'remove') {
|
|
135
177
|
if (action === 'remove' && !yes && (!tty || !/^y(es)?$/i.test(await ask(`Remove all local data for ${name}? [y/N] `))))
|
|
136
|
-
throw new CtlError('CTL_CONFIRM_REQUIRED', 'Host removal requires --yes.', `run: synopkgland
|
|
178
|
+
throw new CtlError('CTL_CONFIRM_REQUIRED', 'Host removal requires --yes.', `run: synopkgland host remove ${name} --yes`, 7);
|
|
137
179
|
const session = await store.credentials.get(store.key(host));
|
|
138
180
|
let loggedOut = false;
|
|
139
181
|
let logoutError;
|
|
140
182
|
if (session?.cookie) {
|
|
141
183
|
try {
|
|
142
|
-
await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined));
|
|
184
|
+
await logoutDsm(session, io.requestFor?.(host) ?? createRequest(host.origin, timeoutMs, host.ca ? readFileSync(host.ca, 'utf8') : undefined, { fingerprint: host.fingerprint, name: host.name }));
|
|
143
185
|
loggedOut = true;
|
|
144
186
|
}
|
|
145
187
|
catch (error) {
|
|
@@ -151,15 +193,15 @@ export async function runCtl(argv, io = {}) {
|
|
|
151
193
|
return;
|
|
152
194
|
}
|
|
153
195
|
if (logoutError)
|
|
154
|
-
throw new CtlError('CTL_LOGOUT_FAILED', 'DSM logout was not confirmed; local session retained for retry.', `run: synopkgland
|
|
196
|
+
throw new CtlError('CTL_LOGOUT_FAILED', 'DSM logout was not confirmed; local session retained for retry.', `run: synopkgland host logout ${name}`, 6);
|
|
155
197
|
if (session)
|
|
156
198
|
await store.credentials.set(store.key(host), { ...session, cookie: '', token: '' });
|
|
157
199
|
emit({ ok: true, data: { name, loggedOut, registrationRetained: true } });
|
|
158
200
|
return;
|
|
159
201
|
}
|
|
160
|
-
throw new CtlError('CTL_COMMAND_UNKNOWN', `Unknown host action ${action}.`, 'run: synopkgland
|
|
202
|
+
throw new CtlError('CTL_COMMAND_UNKNOWN', `Unknown host action ${action}.`, 'run: synopkgland host --help', 3, ['add', 'list', 'show', 'login', 'logout', 'remove', 'revoke', 'default', 'trust']);
|
|
161
203
|
}
|
|
162
|
-
const names = selectHosts(store.list().map(h => h.name), hostOption, allHosts);
|
|
204
|
+
const names = selectHosts(store.list().map(h => h.name), hostOption ?? (allHosts ? undefined : store.defaultName()), allHosts);
|
|
163
205
|
const inputAt = args.indexOf('--input');
|
|
164
206
|
let commonInput;
|
|
165
207
|
if (inputAt >= 0 && args[inputAt + 1] === '-') {
|
package/dist/ctl/dsm-auth.js
CHANGED
|
@@ -11,12 +11,12 @@ export async function loginDsm(origin, username, password, options) {
|
|
|
11
11
|
if (result?.error?.code === 403) {
|
|
12
12
|
const otp = await options.otp();
|
|
13
13
|
if (!/^\d{6}$/.test(otp))
|
|
14
|
-
throw new CtlError('CTL_OTP_INVALID', 'OTP must be six digits.', 'run: synopkgland
|
|
14
|
+
throw new CtlError('CTL_OTP_INVALID', 'OTP must be six digits.', 'run: synopkgland host login <name>', 2);
|
|
15
15
|
form.set('otp_code', otp);
|
|
16
16
|
result = await request('/webapi/entry.cgi', form);
|
|
17
17
|
}
|
|
18
18
|
if (result?.success !== true || typeof result.data?.sid !== 'string' || !result.data.sid || typeof result.data.synotoken !== 'string' || !result.data.synotoken)
|
|
19
|
-
throw new CtlError('CTL_AUTH_FAILED', `DSM login failed (${Number(result?.error?.code) || 'invalid response'}).`, 'run: synopkgland
|
|
19
|
+
throw new CtlError('CTL_AUTH_FAILED', `DSM login failed (${Number(result?.error?.code) || 'invalid response'}).`, 'run: synopkgland host login <name>', 2);
|
|
20
20
|
if (/[;\r\n\x00]/.test(result.data.sid) || /[\r\n\x00]/.test(result.data.synotoken) || result.data.sid.length > 4096 || result.data.synotoken.length > 4096)
|
|
21
21
|
throw new CtlError('CTL_AUTH_FAILED', 'DSM returned invalid session carriers.', 'Check the DSM endpoint.', 2);
|
|
22
22
|
const returnedDevice = result.data.device_id || result.data.did || options.deviceId;
|
package/dist/ctl/errors.js
CHANGED
|
@@ -28,5 +28,5 @@ export function choose(value, allowed, what) {
|
|
|
28
28
|
row = next;
|
|
29
29
|
} return row[b.length]; };
|
|
30
30
|
const nearest = [...allowed].sort((a, b) => distance(value.toLowerCase(), a.toLowerCase()) - distance(value.toLowerCase(), b.toLowerCase()))[0];
|
|
31
|
-
throw new CtlError(`CTL_${what.toUpperCase()}_UNKNOWN`, `Unknown ${what}: ${value}.${nearest ? ` Did you mean ${nearest}?` : ''}`, nearest ? `run: synopkgland ctl ${what === 'host' ? `host show ${nearest}` : 'list --json'}` : 'run: synopkgland
|
|
31
|
+
throw new CtlError(`CTL_${what.toUpperCase()}_UNKNOWN`, `Unknown ${what}: ${value}.${nearest ? ` Did you mean ${nearest}?` : ''}`, nearest ? `run: synopkgland ctl ${what === 'host' ? `host show ${nearest}` : 'list --json'}` : 'run: synopkgland host add <name> --url https://<nas>:5001', 3, allowed);
|
|
32
32
|
}
|
package/dist/ctl/input.js
CHANGED
|
@@ -4,9 +4,9 @@ import { CtlError, choose } from './errors.js';
|
|
|
4
4
|
import { redactValue } from './redaction.js';
|
|
5
5
|
export function selectHosts(names, selected, all = false) {
|
|
6
6
|
if (selected && all)
|
|
7
|
-
throw new CtlError('CTL_ARGUMENT_INVALID', 'Use --host or --all-hosts, not both.', 'run: synopkgland
|
|
7
|
+
throw new CtlError('CTL_ARGUMENT_INVALID', 'Use --host or --all-hosts, not both.', 'run: synopkgland host list');
|
|
8
8
|
if (!names.length)
|
|
9
|
-
throw new CtlError('CTL_NOT_AUTHENTICATED', 'No hosts registered.', 'run: synopkgland
|
|
9
|
+
throw new CtlError('CTL_NOT_AUTHENTICATED', 'No hosts registered.', 'run: synopkgland host add <name> --url https://<nas>:5001', 2);
|
|
10
10
|
if (all)
|
|
11
11
|
return names;
|
|
12
12
|
if (!selected) {
|
package/dist/ctl/service.js
CHANGED
|
@@ -17,7 +17,7 @@ export class CtlClient {
|
|
|
17
17
|
constructor(store, host, request, timeoutMs = CTL_TIMEOUT_MS) {
|
|
18
18
|
this.store = store;
|
|
19
19
|
this.host = host;
|
|
20
|
-
this.request = request ?? createRequest(host.origin, () => this.remaining(timeoutMs), host.ca ? readFileSync(host.ca, 'utf8') : undefined);
|
|
20
|
+
this.request = request ?? createRequest(host.origin, () => this.remaining(timeoutMs), host.ca ? readFileSync(host.ca, 'utf8') : undefined, { fingerprint: host.fingerprint, name: host.name });
|
|
21
21
|
}
|
|
22
22
|
until;
|
|
23
23
|
remaining(fallback) { return this.until === undefined ? fallback : Math.max(0, this.until - performance.now()); }
|
|
@@ -25,14 +25,16 @@ export class CtlClient {
|
|
|
25
25
|
await this.inventory(await this.store.session(this.host));
|
|
26
26
|
return true;
|
|
27
27
|
}
|
|
28
|
-
catch {
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof CtlError && error.code.startsWith('HOST_'))
|
|
30
|
+
throw error;
|
|
29
31
|
return false;
|
|
30
32
|
} }
|
|
31
33
|
async inventory(session) {
|
|
32
34
|
const response = await this.request('/webapi/entry.cgi', new URLSearchParams({ api: 'SYNO.Core.Package', version: '2', method: 'list', additional: '["status"]', SynoToken: session.token }), session);
|
|
33
35
|
if (response?.success !== true) {
|
|
34
36
|
const code = Number(response?.error?.code);
|
|
35
|
-
throw new CtlError([106, 107, 119].includes(code) ? 'CTL_NOT_AUTHENTICATED' : 'CTL_INVENTORY_DENIED', `DSM Package list failed (${code}).`, `run: synopkgland
|
|
37
|
+
throw new CtlError([106, 107, 119].includes(code) ? 'CTL_NOT_AUTHENTICATED' : 'CTL_INVENTORY_DENIED', `DSM Package list failed (${code}).`, `run: synopkgland host login ${this.host.name}`, [106, 107, 119].includes(code) ? 2 : 4);
|
|
36
38
|
}
|
|
37
39
|
const entries = response.data?.packages;
|
|
38
40
|
if (!Array.isArray(entries) || response.data.total !== entries.length || entries.length > 4096)
|
|
@@ -134,7 +136,7 @@ export class CtlClient {
|
|
|
134
136
|
const boot = await this.request(app.payload.transport.path, { method: 'identity.session', params: { bootstrap: true } }, session);
|
|
135
137
|
// The transport adapter adds only validated, path-scoped App cookies for this call.
|
|
136
138
|
if (boot?.success !== true)
|
|
137
|
-
throw new CtlError('CTL_APP_SESSION', 'App session bootstrap failed; HTTPS is required.', `run: synopkgland
|
|
139
|
+
throw new CtlError('CTL_APP_SESSION', 'App session bootstrap failed; HTTPS is required.', `run: synopkgland host show ${this.host.name}`, 5);
|
|
138
140
|
appSession = this.appCookieSession(session, boot);
|
|
139
141
|
}
|
|
140
142
|
try {
|
|
@@ -150,8 +152,8 @@ export class CtlClient {
|
|
|
150
152
|
};
|
|
151
153
|
const ca = this.host.ca ? readFileSync(this.host.ca, 'utf8') : undefined;
|
|
152
154
|
const result = await runFlow(operation.flow, params, invoke, { wait: options.wait, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS),
|
|
153
|
-
ndjson: (method, input) => transferRequest(appSession, app.payload.transport.path, method, input, { ca, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS) }),
|
|
154
|
-
upload: (method, input, flow) => transferRequest(appSession, app.payload.transport.path, method, {}, { ca, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS), file: { path: String(input[flow.fileParam]), parent: String(input[flow.parentParam]), name: String(input[flow.nameParam]) } }) });
|
|
155
|
+
ndjson: (method, input) => transferRequest(appSession, app.payload.transport.path, method, input, { ca, trust: { fingerprint: this.host.fingerprint, name: this.host.name }, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS) }),
|
|
156
|
+
upload: (method, input, flow) => transferRequest(appSession, app.payload.transport.path, method, {}, { ca, trust: { fingerprint: this.host.fingerprint, name: this.host.name }, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS), file: { path: String(input[flow.fileParam]), parent: String(input[flow.parentParam]), name: String(input[flow.nameParam]) } }) });
|
|
155
157
|
if (operation.resultSchema && !ajv.validate(operation.resultSchema, result))
|
|
156
158
|
throw new CtlError('CTL_RESPONSE_INVALID', 'Result does not match command schema.', 'Check the App version.', 5);
|
|
157
159
|
return output.clean(result, operation.resultSchema);
|
package/dist/ctl/store.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface Host {
|
|
|
7
7
|
name: string;
|
|
8
8
|
origin: string;
|
|
9
9
|
username: string;
|
|
10
|
+
fingerprint?: string;
|
|
10
11
|
tunnel?: boolean;
|
|
11
12
|
ca?: string;
|
|
12
13
|
lastUsed?: string;
|
|
@@ -30,6 +31,9 @@ export declare class CtlStore {
|
|
|
30
31
|
private safe;
|
|
31
32
|
private file;
|
|
32
33
|
key(host: Host): string;
|
|
34
|
+
defaultName(): string | undefined;
|
|
35
|
+
setDefault(name: string): void;
|
|
36
|
+
select(name?: string): Host;
|
|
33
37
|
list(): Host[];
|
|
34
38
|
get(name: string): Host;
|
|
35
39
|
save(host: Host): void;
|
package/dist/ctl/store.js
CHANGED
|
@@ -3,6 +3,8 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { randomUUID, createHash } from 'node:crypto';
|
|
5
5
|
import { CtlError, choose } from './errors.js';
|
|
6
|
+
import { loginDsm } from './dsm-auth.js';
|
|
7
|
+
import { createRequest } from './transport.js';
|
|
6
8
|
import { publicDescriptor } from './redaction.js';
|
|
7
9
|
export class OsCredentialStore {
|
|
8
10
|
async entry(key) { try {
|
|
@@ -45,22 +47,47 @@ export class CtlStore {
|
|
|
45
47
|
throw new CtlError('CTL_HOST_INVALID', 'Invalid host name.', 'Use letters, digits, dots, dashes or underscores.'); return name; }
|
|
46
48
|
file(name) { return join(this.root, 'hosts', this.safe(name) + '.json'); }
|
|
47
49
|
key(host) { return createHash('sha256').update(host.name + '\0' + host.origin + '\0' + host.username).digest('hex'); }
|
|
50
|
+
defaultName() { try {
|
|
51
|
+
return JSON.parse(readFileSync(join(this.root, 'default.json'), 'utf8')).name;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
} }
|
|
56
|
+
setDefault(name) { this.get(name); mkdirSync(this.root, { recursive: true, mode: 0o700 }); const file = join(this.root, 'default.json'), temp = file + '.' + randomUUID(); writeFileSync(temp, JSON.stringify({ name }), { mode: 0o600 }); renameSync(temp, file); }
|
|
57
|
+
select(name) { if (name)
|
|
58
|
+
return this.get(name); const hosts = this.list(); const selected = this.defaultName(); if (selected)
|
|
59
|
+
return this.get(selected); if (hosts.length === 1)
|
|
60
|
+
return hosts[0]; throw new CtlError('CTL_HOST_REQUIRED', hosts.length ? 'Choose a NAS with --host.' : 'No NAS hosts registered.', hosts.length ? 'run: synopkgland host default <name>' : 'run: synopkgland host add <name> --url https://<nas>:5001', 2); }
|
|
48
61
|
list() { const dir = join(this.root, 'hosts'); if (!existsSync(dir))
|
|
49
62
|
return []; return readdirSync(dir).filter(n => n.endsWith('.json')).map(n => JSON.parse(readFileSync(join(dir, n), 'utf8'))).sort((a, b) => a.name.localeCompare(b.name)); }
|
|
50
63
|
get(name) { this.safe(name); const host = this.list().find(h => h.name === name); if (!host)
|
|
51
64
|
return choose(name, this.list().map(h => h.name), 'host'); return host; }
|
|
52
65
|
save(host) { const file = this.file(host.name); mkdirSync(join(this.root, 'hosts'), { recursive: true, mode: 0o700 }); const temp = file + '.' + randomUUID(); writeFileSync(temp, JSON.stringify(host), { mode: 0o600 }); renameSync(temp, file); chmodSync(file, 0o600); }
|
|
53
66
|
async add(host, session) { if (this.list().some(h => h.name === host.name))
|
|
54
|
-
throw new CtlError('CTL_HOST_EXISTS', 'Host name already registered.', `run: synopkgland
|
|
67
|
+
throw new CtlError('CTL_HOST_EXISTS', 'Host name already registered.', `run: synopkgland host login ${host.name}`); await this.credentials.set(this.key(host), session); try {
|
|
55
68
|
this.save(host);
|
|
56
69
|
}
|
|
57
70
|
catch (e) {
|
|
58
71
|
await this.credentials.delete(this.key(host));
|
|
59
72
|
throw e;
|
|
60
73
|
} }
|
|
61
|
-
async session(host) {
|
|
62
|
-
|
|
63
|
-
|
|
74
|
+
async session(host) {
|
|
75
|
+
let session;
|
|
76
|
+
if (process.env.DSM_USERNAME && process.env.DSM_PASSWORD) {
|
|
77
|
+
if (process.env.DSM_USERNAME !== host.username)
|
|
78
|
+
throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Automation account differs from the registered host.', 'Use the registered account or a separately named host.', 2);
|
|
79
|
+
session = await loginDsm(host.origin, host.username, process.env.DSM_PASSWORD, { remember: false, otp: async () => process.env.DSM_OTP ?? '', request: createRequest(host.origin, 30000, host.ca ? readFileSync(host.ca, 'utf8') : undefined, { fingerprint: host.fingerprint, name: host.name }) });
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
session = await this.credentials.get(this.key(host));
|
|
83
|
+
if (!session?.cookie || !session.token)
|
|
84
|
+
throw new CtlError('CTL_NOT_AUTHENTICATED', `Host ${host.name} is not logged in.`, `run: synopkgland host login ${host.name}`, 2);
|
|
85
|
+
if (/[\r\n\0]/.test(session.cookie + session.token))
|
|
86
|
+
throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Stored session carriers are invalid', `run: synopkgland host login ${host.name}`, 2);
|
|
87
|
+
if (session.origin !== host.origin || session.username !== host.username)
|
|
88
|
+
throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Stored credentials do not match host.', 'Run synopkgland host login again.', 2);
|
|
89
|
+
return session;
|
|
90
|
+
}
|
|
64
91
|
cacheRoot(host) { return join(this.root, 'cache', this.key(host)); }
|
|
65
92
|
cacheFile(host, pkg, version) { return join(this.cacheRoot(host), createHash('sha256').update(pkg + '\0' + version).digest('hex') + '.json'); }
|
|
66
93
|
cache(host, pkg, version) {
|
|
@@ -89,5 +116,6 @@ export class CtlStore {
|
|
|
89
116
|
}
|
|
90
117
|
putFailure(host, pkg, version, failure) { mkdirSync(this.cacheRoot(host), { recursive: true, mode: 0o700 }); const file = this.cacheFile(host, pkg, version), tmp = file + '.' + randomUUID(); writeFileSync(tmp, JSON.stringify({ failure }), { mode: 0o600 }); renameSync(tmp, file); }
|
|
91
118
|
revoke(name, pkg) { const host = this.get(name); delete host.approved[pkg]; this.save(host); rmSync(this.cacheRoot(host), { recursive: true, force: true }); return { revoked: pkg, cacheCleared: true }; }
|
|
92
|
-
async remove(name) { const host = this.get(name); await this.credentials.delete(this.key(host)); rmSync(this.cacheRoot(host), { recursive: true, force: true }); rmSync(this.file(name));
|
|
119
|
+
async remove(name) { const host = this.get(name); await this.credentials.delete(this.key(host)); rmSync(this.cacheRoot(host), { recursive: true, force: true }); rmSync(this.file(name)); if (this.defaultName() === name)
|
|
120
|
+
rmSync(join(this.root, 'default.json'), { force: true }); return { removed: name, deleted: ['session', 'SynoToken', 'device token', 'App approvals', 'hash pins', 'list cache', 'host registration'] }; }
|
|
93
121
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import https from "node:https";
|
|
2
|
+
import { CtlError } from "./errors.js";
|
|
3
|
+
export interface HostTls {
|
|
4
|
+
fingerprint?: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function normalizeFingerprint(value: string): string;
|
|
8
|
+
export declare function networkError(error: unknown): CtlError;
|
|
9
|
+
export declare function timeoutError(): CtlError;
|
|
10
|
+
/** TLS-only handshake. No HTTP headers, password or session is sent during discovery. */
|
|
11
|
+
export declare function probeCertificate(origin: string, options?: {
|
|
12
|
+
ca?: string;
|
|
13
|
+
fingerprint?: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
}): Promise<{
|
|
17
|
+
fingerprint?: string;
|
|
18
|
+
leafFingerprint?: string;
|
|
19
|
+
trusted: boolean;
|
|
20
|
+
}>;
|
|
21
|
+
/** Release the socket to HTTPS only after matching the leaf pin on this exact connection. */
|
|
22
|
+
export declare function pinnedAgent(origin: string, trust: HostTls, timeoutMs: number): https.Agent;
|
package/dist/ctl/tls.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import tls from "node:tls";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import { isIP } from "node:net";
|
|
4
|
+
import { CtlError } from "./errors.js";
|
|
5
|
+
export function normalizeFingerprint(value) {
|
|
6
|
+
const normalized = value.replaceAll(":", "").toLowerCase();
|
|
7
|
+
if (!/^[a-f0-9]{64}$/.test(normalized))
|
|
8
|
+
throw new CtlError("HOST_FINGERPRINT_INVALID", "A SHA-256 fingerprint must contain 64 hexadecimal digits", "Use the SHA-256 leaf certificate fingerprint", 3);
|
|
9
|
+
return normalized;
|
|
10
|
+
}
|
|
11
|
+
export function networkError(error) {
|
|
12
|
+
if (error instanceof CtlError)
|
|
13
|
+
return error;
|
|
14
|
+
const code = error?.code;
|
|
15
|
+
if (code === "ENOTFOUND" || code === "EAI_AGAIN")
|
|
16
|
+
return new CtlError("HOST_DNS_FAILED", "NAS hostname could not be resolved", "Check the NAS hostname and DNS", 6);
|
|
17
|
+
if (code === "ECONNREFUSED")
|
|
18
|
+
return new CtlError("HOST_CONNECTION_REFUSED", "NAS refused the connection", "Check the DSM port and NAS availability", 6);
|
|
19
|
+
if (code === "ETIMEDOUT" || code === "ESOCKETTIMEDOUT")
|
|
20
|
+
return new CtlError("HOST_TIMEOUT", "NAS connection timed out", "Check the NAS connection and firewall", 6);
|
|
21
|
+
if (code &&
|
|
22
|
+
/CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/.test(code))
|
|
23
|
+
return new CtlError("HOST_TLS_UNTRUSTED", "NAS TLS certificate is not trusted", "Use synopkgland host login <name>, --ca, or an expected --fingerprint", 6);
|
|
24
|
+
return new CtlError("CTL_NETWORK", "NAS connection failed", "Check the NAS connection; state-changing requests are not replayed", 6);
|
|
25
|
+
}
|
|
26
|
+
export function timeoutError() {
|
|
27
|
+
return new CtlError("HOST_TIMEOUT", "NAS connection timed out", "Check the NAS connection and firewall", 6);
|
|
28
|
+
}
|
|
29
|
+
function changed(old, current, name) {
|
|
30
|
+
return new CtlError("HOST_CERT_CHANGED", `NAS certificate changed: expected SHA-256 ${old}; received ${current}`, `Verify the new certificate, then run: synopkgland host trust ${name ?? "<name>"}`, 6);
|
|
31
|
+
}
|
|
32
|
+
function socketOptions(origin, ca) {
|
|
33
|
+
const url = new URL(origin), host = url.hostname.replace(/^\[|\]$/g, "");
|
|
34
|
+
return {
|
|
35
|
+
host,
|
|
36
|
+
port: Number(url.port || 443),
|
|
37
|
+
...(isIP(host) ? {} : { servername: host }),
|
|
38
|
+
...(ca ? { ca } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** TLS-only handshake. No HTTP headers, password or session is sent during discovery. */
|
|
42
|
+
export function probeCertificate(origin, options = {}) {
|
|
43
|
+
if (new URL(origin).protocol !== "https:")
|
|
44
|
+
return Promise.resolve({ trusted: true });
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const socket = tls.connect({
|
|
47
|
+
...socketOptions(origin, options.ca),
|
|
48
|
+
rejectUnauthorized: false,
|
|
49
|
+
});
|
|
50
|
+
const timer = setTimeout(() => socket.destroy(timeoutError()), options.timeoutMs ?? 30000);
|
|
51
|
+
socket.once("error", (e) => reject(networkError(e)));
|
|
52
|
+
socket.once("close", () => clearTimeout(timer));
|
|
53
|
+
socket.once("secureConnect", () => {
|
|
54
|
+
try {
|
|
55
|
+
const raw = socket.getPeerCertificate().fingerprint256;
|
|
56
|
+
if (!raw)
|
|
57
|
+
throw new CtlError("HOST_TLS_UNTRUSTED", "NAS did not provide a leaf certificate", "Check the NAS HTTPS endpoint", 6);
|
|
58
|
+
const fingerprint = normalizeFingerprint(raw);
|
|
59
|
+
if (options.fingerprint &&
|
|
60
|
+
normalizeFingerprint(options.fingerprint) !== fingerprint)
|
|
61
|
+
throw changed(normalizeFingerprint(options.fingerprint), fingerprint, options.name);
|
|
62
|
+
// A caller-provided CA is an explicit trust policy, never a TOFU fallback.
|
|
63
|
+
if (options.ca && !socket.authorized)
|
|
64
|
+
throw new CtlError("HOST_TLS_UNTRUSTED", "NAS certificate was not verified by the supplied CA", "Check the CA file and NAS hostname", 6);
|
|
65
|
+
resolve({
|
|
66
|
+
trusted: socket.authorized,
|
|
67
|
+
leafFingerprint: fingerprint,
|
|
68
|
+
fingerprint: options.fingerprint || !socket.authorized ? fingerprint : undefined,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
reject(e);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
socket.destroy();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Release the socket to HTTPS only after matching the leaf pin on this exact connection. */
|
|
81
|
+
export function pinnedAgent(origin, trust, timeoutMs) {
|
|
82
|
+
const agent = new https.Agent({ keepAlive: false });
|
|
83
|
+
agent.createConnection = (_options, callback) => {
|
|
84
|
+
let done = false;
|
|
85
|
+
const socket = tls.connect({
|
|
86
|
+
...socketOptions(origin),
|
|
87
|
+
rejectUnauthorized: false,
|
|
88
|
+
});
|
|
89
|
+
const timer = setTimeout(() => socket.destroy(timeoutError()), timeoutMs);
|
|
90
|
+
const finish = (error) => {
|
|
91
|
+
if (done)
|
|
92
|
+
return;
|
|
93
|
+
done = true;
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
if (error) {
|
|
96
|
+
socket.destroy();
|
|
97
|
+
callback?.(error, null);
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
callback?.(null, socket);
|
|
101
|
+
};
|
|
102
|
+
socket.once("error", (e) => finish(networkError(e)));
|
|
103
|
+
socket.once("secureConnect", () => {
|
|
104
|
+
try {
|
|
105
|
+
const current = normalizeFingerprint(socket.getPeerCertificate().fingerprint256 ?? "");
|
|
106
|
+
const old = normalizeFingerprint(trust.fingerprint);
|
|
107
|
+
if (old !== current)
|
|
108
|
+
throw changed(old, current, trust.name);
|
|
109
|
+
finish();
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
finish(e);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
return undefined;
|
|
116
|
+
};
|
|
117
|
+
return agent;
|
|
118
|
+
}
|