@synopackageland/cli 0.33.0 → 0.33.1
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 +6 -7
- package/README.md +10 -0
- package/dist/cli.js +7 -0
- package/dist/ctl/cli.js +27 -17
- package/dist/ctl/dsm-auth.d.ts +1 -10
- package/dist/ctl/dsm-auth.js +2 -30
- package/dist/ctl/input.js +1 -1
- package/dist/ctl/service.js +3 -5
- package/dist/ctl/store.d.ts +1 -55
- package/dist/ctl/store.js +2 -121
- package/dist/ctl/tls.d.ts +1 -22
- package/dist/ctl/tls.js +2 -118
- package/dist/ctl/transfers.d.ts +1 -16
- package/dist/ctl/transfers.js +2 -78
- package/dist/ctl/transport.d.ts +1 -14
- package/dist/ctl/transport.js +2 -113
- package/dist/e2e/dsm.d.ts +2 -2
- package/dist/e2e/dsm.js +33 -17
- package/dist/error-registry.js +4 -4
- package/dist/host/connection.d.ts +1 -9
- package/dist/host/connection.js +2 -49
- package/dist/host/credentials.d.ts +1 -4
- package/dist/host/credentials.js +2 -32
- package/dist/host/dsm-client.js +4 -27
- package/dist/host/dsm-http.d.ts +1 -3
- package/dist/host/dsm-http.js +2 -144
- package/dist/host/package-commands.js +3 -3
- package/dist/host/types.d.ts +1 -0
- package/dist/nas/auth.d.ts +10 -0
- package/dist/nas/auth.js +30 -0
- package/dist/nas/connection.d.ts +9 -0
- package/dist/nas/connection.js +46 -0
- package/dist/nas/credentials.d.ts +6 -0
- package/dist/nas/credentials.js +22 -0
- package/dist/nas/http.d.ts +5 -0
- package/dist/nas/http.js +25 -0
- package/dist/nas/options.d.ts +13 -0
- package/dist/nas/options.js +33 -0
- package/dist/nas/session.d.ts +2 -0
- package/dist/nas/session.js +6 -0
- package/dist/nas/store.d.ts +61 -0
- package/dist/nas/store.js +144 -0
- package/dist/nas/tls.d.ts +25 -0
- package/dist/nas/tls.js +129 -0
- package/dist/nas/transfers.d.ts +16 -0
- package/dist/nas/transfers.js +76 -0
- package/dist/nas/transport.d.ts +17 -0
- package/dist/nas/transport.js +143 -0
- package/dist/package-source.d.ts +2 -0
- package/dist/package-source.js +4 -0
- package/package.json +2 -2
- package/skills/SKILL.md +3 -1
- package/skills/reference/ctl.md +2 -0
- package/skills/reference/deploy.md +2 -0
- package/skills/reference/host-install.md +21 -10
package/dist/host/dsm-http.js
CHANGED
|
@@ -1,144 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const sessions = new Map();
|
|
4
|
-
export function registerDsmSession(session, name, ca, fingerprint) { sessions.set(session.origin + '\0' + session.cookie, { session, name, ca, fingerprint }); }
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
6
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { tmpdir } from "node:os";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
function errorCode(error) {
|
|
10
|
-
return error.cause?.code
|
|
11
|
-
?? error.code;
|
|
12
|
-
}
|
|
13
|
-
function isHostUnreachable(error) {
|
|
14
|
-
const code = errorCode(error);
|
|
15
|
-
return code === "EHOSTUNREACH" || code === "ENETUNREACH";
|
|
16
|
-
}
|
|
17
|
-
const UNTRUSTED_CERTIFICATE = new Set([
|
|
18
|
-
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
19
|
-
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
20
|
-
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
21
|
-
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
22
|
-
"CERT_HAS_EXPIRED",
|
|
23
|
-
"ERR_TLS_CERT_ALTNAME_INVALID",
|
|
24
|
-
]);
|
|
25
|
-
/** Node reports an untrusted certificate only as "fetch failed"; say what to do. Verification stays on. */
|
|
26
|
-
function untrustedCertificate(url, code) {
|
|
27
|
-
const origin = new URL(url).origin;
|
|
28
|
-
return Object.assign(new Error(`DSM at ${origin} presented a TLS certificate this computer does not trust (${code}). ` +
|
|
29
|
-
`Export the NAS certificate or its CA as PEM and run with NODE_EXTRA_CA_CERTS=<that file>, ` +
|
|
30
|
-
`or give DSM a certificate whose name matches the host profile. Certificate checks stay on.`), { code: "DSM_TLS_UNTRUSTED" });
|
|
31
|
-
}
|
|
32
|
-
function headerRecord(headers) {
|
|
33
|
-
if (!headers) {
|
|
34
|
-
return {};
|
|
35
|
-
}
|
|
36
|
-
if (headers instanceof Headers) {
|
|
37
|
-
return Object.fromEntries(headers.entries());
|
|
38
|
-
}
|
|
39
|
-
if (Array.isArray(headers)) {
|
|
40
|
-
return Object.fromEntries(headers);
|
|
41
|
-
}
|
|
42
|
-
return { ...headers };
|
|
43
|
-
}
|
|
44
|
-
export async function dsmFetch(url, init = {}) {
|
|
45
|
-
try {
|
|
46
|
-
for (const [key, value] of Object.entries(headerRecord(init.headers)))
|
|
47
|
-
if (/[\r\n\0]/.test(key + value))
|
|
48
|
-
throw new Error('Invalid header value');
|
|
49
|
-
const headers = new Headers(init.headers);
|
|
50
|
-
const binding = sessions.get(new URL(url).origin + '\0' + (headers.get('cookie') ?? ''));
|
|
51
|
-
if (binding)
|
|
52
|
-
headers.set('X-SYNO-TOKEN', binding.session.token);
|
|
53
|
-
const request = { ...init, headers, redirect: 'error', signal: init.signal ?? AbortSignal.timeout(62000) };
|
|
54
|
-
const response = binding ? await sessionFetch(url, request, binding.session, binding.ca, { fingerprint: binding.fingerprint, name: binding.name }) : await fetch(url, request);
|
|
55
|
-
if (binding && new URL(url).pathname === '/webapi/entry.cgi') {
|
|
56
|
-
const body = await response.clone().json().catch(() => null);
|
|
57
|
-
if (body?.success === false && [106, 107, 119].includes(Number(body.error?.code)))
|
|
58
|
-
throw new CtlError('CTL_NOT_AUTHENTICATED', 'DSM session expired', `run: synopkgland host login ${binding.name}`, 2);
|
|
59
|
-
}
|
|
60
|
-
return response;
|
|
61
|
-
}
|
|
62
|
-
catch (error) {
|
|
63
|
-
const code = errorCode(error);
|
|
64
|
-
if (code && UNTRUSTED_CERTIFICATE.has(code)) {
|
|
65
|
-
throw untrustedCertificate(url, code);
|
|
66
|
-
}
|
|
67
|
-
if (error instanceof CtlError)
|
|
68
|
-
throw error;
|
|
69
|
-
if (!isHostUnreachable(error)) {
|
|
70
|
-
throw error;
|
|
71
|
-
}
|
|
72
|
-
return curlFetch(url, init);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
function curlConfigString(value) {
|
|
76
|
-
const escapes = { "\\": "\\\\", '"': '\\"', "\t": "\\t", "\n": "\\n", "\r": "\\r", "\v": "\\v" };
|
|
77
|
-
return `"${value.replace(/[\\"\t\n\r\v]/g, (c) => escapes[c])}"`;
|
|
78
|
-
}
|
|
79
|
-
async function curlFetch(url, init) {
|
|
80
|
-
const staging = mkdtempSync(join(tmpdir(), "synopkgland-dsm-http-"));
|
|
81
|
-
const headerFile = join(staging, "headers");
|
|
82
|
-
const bodyFile = join(staging, "body");
|
|
83
|
-
try {
|
|
84
|
-
const configFile = join(staging, "config");
|
|
85
|
-
const args = ["-sS", "-D", headerFile, "-o", bodyFile, "-w", "%{http_code}", "-X", init.method ?? "GET", "-K", configFile];
|
|
86
|
-
// Headers and form text go through a private config file, not argv, and
|
|
87
|
-
// form text is sent literally (curl -F would read "@file" and "<file").
|
|
88
|
-
const config = [];
|
|
89
|
-
const headers = headerRecord(init.headers);
|
|
90
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
91
|
-
if (name.toLowerCase() === "content-type" && init.body instanceof FormData) {
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
if (/[\r\n\0]/.test(name + value)) {
|
|
95
|
-
throw new Error(`Invalid header value: ${name}`);
|
|
96
|
-
}
|
|
97
|
-
config.push(`header = ${curlConfigString(`${name}: ${value}`)}`);
|
|
98
|
-
}
|
|
99
|
-
let stdin;
|
|
100
|
-
if (init.body instanceof FormData) {
|
|
101
|
-
for (const [name, value] of init.body.entries()) {
|
|
102
|
-
if (typeof value === "string") {
|
|
103
|
-
if (/[=\0]/.test(name) || value.includes("\0")) {
|
|
104
|
-
throw new Error(`Invalid form field: ${name}`);
|
|
105
|
-
}
|
|
106
|
-
config.push(`form-string = ${curlConfigString(`${name}=${value}`)}`);
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
const filename = "name" in value && typeof value.name === "string" ? value.name : "upload.bin";
|
|
110
|
-
const partPath = join(staging, filename);
|
|
111
|
-
writeFileSync(partPath, Buffer.from(await value.arrayBuffer()));
|
|
112
|
-
config.push(`form = ${curlConfigString(`${name}=@${partPath};filename=${filename}`)}`);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
else if (typeof init.body === "string") {
|
|
116
|
-
stdin = Buffer.from(init.body);
|
|
117
|
-
args.push("--data-binary", "@-");
|
|
118
|
-
}
|
|
119
|
-
else if (init.body) {
|
|
120
|
-
stdin = Buffer.from(await new Response(init.body).arrayBuffer());
|
|
121
|
-
args.push("--data-binary", "@-");
|
|
122
|
-
}
|
|
123
|
-
writeFileSync(configFile, config.map((line) => line + "\n").join(""), { mode: 0o600 });
|
|
124
|
-
args.push(url);
|
|
125
|
-
const result = spawnSync("curl", args, { encoding: "utf8", input: stdin });
|
|
126
|
-
if (result.status !== 0) {
|
|
127
|
-
throw new Error(result.stderr || `curl exit ${result.status}`);
|
|
128
|
-
}
|
|
129
|
-
const status = Number((result.stdout ?? "").trim().slice(-3));
|
|
130
|
-
const rawHeaders = readFileSync(headerFile, "utf8");
|
|
131
|
-
const body = readFileSync(bodyFile);
|
|
132
|
-
const responseHeaders = new Headers();
|
|
133
|
-
for (const line of rawHeaders.split(/\r?\n/)) {
|
|
134
|
-
const idx = line.indexOf(":");
|
|
135
|
-
if (idx > 0) {
|
|
136
|
-
responseHeaders.append(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return new Response(body, { status: Number.isInteger(status) ? status : 502, headers: responseHeaders });
|
|
140
|
-
}
|
|
141
|
-
finally {
|
|
142
|
-
rmSync(staging, { recursive: true, force: true });
|
|
143
|
-
}
|
|
144
|
-
}
|
|
1
|
+
// Compatibility export; every DSM request uses the shared NAS connection module.
|
|
2
|
+
export { dsmFetch, registerDsmSession } from '../nas/http.js';
|
|
@@ -5,7 +5,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
5
5
|
import { CtlError } from "../ctl/errors.js";
|
|
6
6
|
import { inspectAnySpk } from "../spk/inspect.js";
|
|
7
7
|
import { connectNas, requireAdministrator, } from "./connection.js";
|
|
8
|
-
import {
|
|
8
|
+
import { fetchPackageSource } from "../package-source.js";
|
|
9
9
|
import { fetchInstalledPackageState, installPackageFromLocalSpk, uninstallPackageFromDsm, } from "./catalog-install.js";
|
|
10
10
|
import { assertCompatible, sourceUrl, selectEntry, resolveInstallPlan, verifyPins, compareVersions, } from "./install-plan.js";
|
|
11
11
|
export const DEFAULT_PACKAGE_SOURCE = "https://packages.synopkgland.com";
|
|
@@ -38,7 +38,7 @@ export async function nasTarget(nas) {
|
|
|
38
38
|
return { arch, version: `${version}-${build}`, major, minor, build };
|
|
39
39
|
}
|
|
40
40
|
export async function readFeed(source, target) {
|
|
41
|
-
const response = await
|
|
41
|
+
const response = await fetchPackageSource(sourceUrl(source, target).href);
|
|
42
42
|
if (!response.ok)
|
|
43
43
|
throw fail("PACKAGE_SOURCE_FAILED", `Package source returned HTTP ${response.status}`);
|
|
44
44
|
const body = (await response.json());
|
|
@@ -145,7 +145,7 @@ export async function installPackage(spec, options = {}, connection) {
|
|
|
145
145
|
for (const entry of plan) {
|
|
146
146
|
let file = localFile;
|
|
147
147
|
if (!local || entry.package !== root.package) {
|
|
148
|
-
const response = await
|
|
148
|
+
const response = await fetchPackageSource(entry.link);
|
|
149
149
|
if (!response.ok)
|
|
150
150
|
throw fail("PACKAGE_DOWNLOAD_FAILED", `SPK download returned HTTP ${response.status}`);
|
|
151
151
|
const bytes = Buffer.from(await response.arrayBuffer());
|
package/dist/host/types.d.ts
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type DsmSession, type JsonRequest } from './transport.js';
|
|
2
|
+
export interface LoginOptions {
|
|
3
|
+
remember?: boolean;
|
|
4
|
+
deviceId?: string;
|
|
5
|
+
otp: () => Promise<string>;
|
|
6
|
+
request?: JsonRequest;
|
|
7
|
+
ca?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function loginDsm(origin: string, username: string, password: string, options: LoginOptions): Promise<DsmSession>;
|
|
10
|
+
export declare function logoutDsm(session: DsmSession, request?: JsonRequest): Promise<void>;
|
package/dist/nas/auth.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { CtlError } from '../ctl/errors.js';
|
|
2
|
+
import { createRequest } from './transport.js';
|
|
3
|
+
export async function loginDsm(origin, username, password, options) {
|
|
4
|
+
const request = options.request ?? createRequest(origin, 30000, options.ca);
|
|
5
|
+
const form = new URLSearchParams({ api: 'SYNO.API.Auth', version: '7', method: 'login', account: username, passwd: password, session: 'DSM', format: 'cookie', enable_syno_token: 'yes', enable_device_token: options.remember === false ? 'no' : 'yes', device_name: 'synopkgland', device_id: options.deviceId ?? '' });
|
|
6
|
+
let result = await request('/webapi/entry.cgi', form);
|
|
7
|
+
if (result?.error?.code === 402) {
|
|
8
|
+
form.delete('session');
|
|
9
|
+
result = await request('/webapi/entry.cgi', form);
|
|
10
|
+
}
|
|
11
|
+
if (result?.error?.code === 403) {
|
|
12
|
+
const otp = await options.otp();
|
|
13
|
+
if (!/^\d{6}$/.test(otp))
|
|
14
|
+
throw new CtlError('CTL_OTP_INVALID', 'OTP must be six digits.', 'run: synopkgland host login <name>', 2);
|
|
15
|
+
form.set('otp_code', otp);
|
|
16
|
+
result = await request('/webapi/entry.cgi', form);
|
|
17
|
+
}
|
|
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 host login <name>', 2);
|
|
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
|
+
throw new CtlError('CTL_AUTH_FAILED', 'DSM returned invalid session carriers.', 'Check the DSM endpoint.', 2);
|
|
22
|
+
const returnedDevice = result.data.device_id || result.data.did || options.deviceId;
|
|
23
|
+
const deviceId = options.remember === false ? undefined : typeof returnedDevice === 'string' && returnedDevice.length <= 4096 && !/[\r\n\x00]/.test(returnedDevice) ? returnedDevice : undefined;
|
|
24
|
+
return { origin, username, cookie: `id=${result.data.sid}`, token: result.data.synotoken, ...(deviceId ? { deviceId } : {}) };
|
|
25
|
+
}
|
|
26
|
+
export async function logoutDsm(session, request = createRequest(session.origin)) {
|
|
27
|
+
const body = await request('/webapi/entry.cgi', new URLSearchParams({ api: 'SYNO.API.Auth', version: '7', method: 'logout', SynoToken: session.token }), session);
|
|
28
|
+
if (body?.success !== true)
|
|
29
|
+
throw new CtlError('CTL_LOGOUT_FAILED', 'DSM did not confirm logout.', 'Retry logout or revoke the NAS session.', 2);
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type DsmSession, type JsonRequest } from "./transport.js";
|
|
2
|
+
export interface NasConnection {
|
|
3
|
+
name: string;
|
|
4
|
+
session: DsmSession;
|
|
5
|
+
request: JsonRequest;
|
|
6
|
+
call: (api: string, method: string, version: number, params?: Record<string, string>) => Promise<any>;
|
|
7
|
+
}
|
|
8
|
+
export declare function connectNas(name?: string): Promise<NasConnection>;
|
|
9
|
+
export declare function requireAdministrator(nas: NasConnection): Promise<void>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { CtlError } from "../ctl/errors.js";
|
|
2
|
+
import { createRequest, } from "./transport.js";
|
|
3
|
+
import { resolveHostCredentials } from "./credentials.js";
|
|
4
|
+
import { registerDsmSession } from "./http.js";
|
|
5
|
+
export async function connectNas(name) {
|
|
6
|
+
const credentials = resolveHostCredentials(name);
|
|
7
|
+
const session = await credentials.loadSession();
|
|
8
|
+
registerDsmSession(session, credentials.hostName, credentials.ca, credentials.fingerprint, credentials.tls);
|
|
9
|
+
const request = createRequest(session.origin, 62000, credentials.ca, credentials.tls);
|
|
10
|
+
return {
|
|
11
|
+
name: credentials.hostName,
|
|
12
|
+
session,
|
|
13
|
+
request,
|
|
14
|
+
call: async (api, method, version, params = {}) => {
|
|
15
|
+
const body = await request("/webapi/entry.cgi", new URLSearchParams({
|
|
16
|
+
api,
|
|
17
|
+
method,
|
|
18
|
+
version: String(version),
|
|
19
|
+
...params,
|
|
20
|
+
}), session);
|
|
21
|
+
const code = Number(body?.error?.code);
|
|
22
|
+
if (body?.success !== true)
|
|
23
|
+
throw new CtlError([106, 107, 119].includes(code)
|
|
24
|
+
? "CTL_NOT_AUTHENTICATED"
|
|
25
|
+
: code === 105
|
|
26
|
+
? "NAS_ADMIN_REQUIRED"
|
|
27
|
+
: "DSM_API_FAILED", `DSM ${api}/${method} failed (${code || "invalid response"})`, [106, 107, 119].includes(code)
|
|
28
|
+
? `run: synopkgland host login ${credentials.hostName}`
|
|
29
|
+
: code === 105
|
|
30
|
+
? "Log in with a DSM administrator account"
|
|
31
|
+
: "Check DSM permissions and Package Center", 2);
|
|
32
|
+
return body.data;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export async function requireAdministrator(nas) {
|
|
37
|
+
const data = await nas.call("SYNO.Core.Group.Member", "list", 1, {
|
|
38
|
+
group: "administrators",
|
|
39
|
+
offset: "0",
|
|
40
|
+
limit: "-1",
|
|
41
|
+
});
|
|
42
|
+
const members = data?.users;
|
|
43
|
+
if (!Array.isArray(members) ||
|
|
44
|
+
!members.some((u) => u.name === nas.session.username))
|
|
45
|
+
throw new CtlError("NAS_ADMIN_REQUIRED", "Package installation and removal require a DSM administrator account", "Register and log in with a DSM administrator account", 4);
|
|
46
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { CtlStore } from "./store.js";
|
|
2
|
+
import type { HostCredentials } from "../host/types.js";
|
|
3
|
+
/** Host metadata is public; session carriers are loaded only from secure storage. */
|
|
4
|
+
export declare function resolveHostCredentials(name?: string, store?: CtlStore): HostCredentials | null;
|
|
5
|
+
/** Legacy embedded callers supply credentials here too; there is only one DSM login implementation. */
|
|
6
|
+
export declare function sessionForCredentials(credentials: HostCredentials): Promise<import("./transport.js").DsmSession>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { registerDsmSession } from "./http.js";
|
|
2
|
+
import { CtlStore } from "./store.js";
|
|
3
|
+
import { loginDsm } from "./auth.js";
|
|
4
|
+
/** Host metadata is public; session carriers are loaded only from secure storage. */
|
|
5
|
+
export function resolveHostCredentials(name, store = new CtlStore()) {
|
|
6
|
+
const host = store.select(name);
|
|
7
|
+
return {
|
|
8
|
+
dsmOrigin: host.origin,
|
|
9
|
+
username: host.username,
|
|
10
|
+
hostName: host.name,
|
|
11
|
+
fingerprint: host.fingerprint,
|
|
12
|
+
ca: store.trust(host).ca,
|
|
13
|
+
tls: store.trust(host),
|
|
14
|
+
loadSession: async () => { const session = await store.session(host); registerDsmSession(session, host.name, undefined, undefined, store.trust(host)); return session; },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Legacy embedded callers supply credentials here too; there is only one DSM login implementation. */
|
|
18
|
+
export async function sessionForCredentials(credentials) {
|
|
19
|
+
const session = credentials.loadSession ? await credentials.loadSession() : await loginDsm(credentials.dsmOrigin, credentials.username, credentials.password ?? '', { remember: false, otp: async () => process.env.DSM_OTP ?? '' });
|
|
20
|
+
registerDsmSession(session, credentials.hostName ?? 'env', credentials.ca, credentials.fingerprint, credentials.tls);
|
|
21
|
+
return session;
|
|
22
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type DsmSession } from './transport.js';
|
|
2
|
+
import type { HostTls } from './tls.js';
|
|
3
|
+
export declare function registerDsmSession(session: DsmSession, name: string, ca?: string, fingerprint?: string, policy?: HostTls): void;
|
|
4
|
+
/** All DSM fetch callers, including session-only automation, use the NAS socket policy. */
|
|
5
|
+
export declare function dsmFetch(url: string, init?: RequestInit): Promise<Response>;
|
package/dist/nas/http.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { sessionFetch } from './transport.js';
|
|
2
|
+
import { assertSessionActive } from './session.js';
|
|
3
|
+
import { CtlError } from '../ctl/errors.js';
|
|
4
|
+
const bindings = new Map();
|
|
5
|
+
const policies = new Map();
|
|
6
|
+
export function registerDsmSession(session, name, ca, fingerprint, policy) {
|
|
7
|
+
const trust = policy ?? { name, ca, fingerprint };
|
|
8
|
+
bindings.set(session.origin + '\0' + session.cookie, { session, name, trust });
|
|
9
|
+
policies.set(session.origin, trust);
|
|
10
|
+
}
|
|
11
|
+
/** All DSM fetch callers, including session-only automation, use the NAS socket policy. */
|
|
12
|
+
export async function dsmFetch(url, init = {}) {
|
|
13
|
+
const headers = new Headers(init.headers), origin = new URL(url).origin;
|
|
14
|
+
const cookie = headers.get('cookie') ?? '';
|
|
15
|
+
// App cookies can accompany a DSM cookie; keep its token and TLS policy.
|
|
16
|
+
const binding = [...bindings.values()].find(b => b.session.origin === origin && cookie.split(/;\s*/).includes(b.session.cookie));
|
|
17
|
+
const response = await sessionFetch(url, { ...init, headers }, binding ? { ...binding.session, cookie } : undefined, undefined, binding?.trust ?? policies.get(origin));
|
|
18
|
+
if (response.status >= 300 && response.status < 400)
|
|
19
|
+
throw new CtlError('CTL_HTTP_ERROR', `HTTP ${response.status}; redirects are never followed`, 'Check the NAS URL', 6);
|
|
20
|
+
if (binding && new URL(url).pathname === '/webapi/entry.cgi') {
|
|
21
|
+
const body = await response.clone().json().catch(() => null);
|
|
22
|
+
assertSessionActive(body, binding.name);
|
|
23
|
+
}
|
|
24
|
+
return response;
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface NasOptions {
|
|
2
|
+
strictTls?: boolean;
|
|
3
|
+
ca?: string;
|
|
4
|
+
url?: string;
|
|
5
|
+
warn?: (line: string) => void;
|
|
6
|
+
}
|
|
7
|
+
export declare const nasOptions: () => NasOptions;
|
|
8
|
+
export declare function withNasOptions<T>(options: NasOptions, run: () => T): T;
|
|
9
|
+
/** Shared flag extraction for ctl and ordinary commands; overrides last for this invocation only. */
|
|
10
|
+
export declare function nasFlags(argv: string[], allowUrl?: boolean): {
|
|
11
|
+
args: string[];
|
|
12
|
+
options: NasOptions;
|
|
13
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { CtlError } from '../ctl/errors.js';
|
|
3
|
+
const context = new AsyncLocalStorage();
|
|
4
|
+
export const nasOptions = () => context.getStore() ?? {};
|
|
5
|
+
export function withNasOptions(options, run) {
|
|
6
|
+
return context.run({ ...nasOptions(), ...options }, run);
|
|
7
|
+
}
|
|
8
|
+
/** Shared flag extraction for ctl and ordinary commands; overrides last for this invocation only. */
|
|
9
|
+
export function nasFlags(argv, allowUrl = false) {
|
|
10
|
+
const args = [...argv], options = {};
|
|
11
|
+
for (let i = 0; i < args.length; i++) {
|
|
12
|
+
const flag = args[i];
|
|
13
|
+
if (flag === '--strict-tls') {
|
|
14
|
+
options.strictTls = true;
|
|
15
|
+
args.splice(i--, 1);
|
|
16
|
+
}
|
|
17
|
+
else if (allowUrl && (flag === '--url' || flag.startsWith('--url=')) && args[0] !== 'host' && args[2] !== 'host') {
|
|
18
|
+
const value = flag.includes('=') ? flag.slice(6) : args[i + 1];
|
|
19
|
+
if (!value || value.startsWith('--'))
|
|
20
|
+
throw new CtlError('CTL_ARGUMENT_INVALID', '--url requires a NAS origin', 'Use --url https://<nas>:5001');
|
|
21
|
+
options.url = value;
|
|
22
|
+
args.splice(i--, flag.includes('=') ? 1 : 2);
|
|
23
|
+
}
|
|
24
|
+
else if (flag === '--ca' || flag.startsWith('--ca=')) {
|
|
25
|
+
const value = flag.includes('=') ? flag.slice(5) : args[i + 1];
|
|
26
|
+
if (!value || value.startsWith('--'))
|
|
27
|
+
throw new CtlError('CTL_ARGUMENT_INVALID', '--ca requires a PEM file', 'Use --ca <file>');
|
|
28
|
+
options.ca = value;
|
|
29
|
+
args.splice(i--, flag.includes('=') ? 1 : 2);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { args, options };
|
|
33
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { CtlError } from '../ctl/errors.js';
|
|
2
|
+
/** Passwords are never persisted. Expired sessions require login; writes are never replayed. */
|
|
3
|
+
export function assertSessionActive(body, name = '<name>') {
|
|
4
|
+
if (body?.success === false && [106, 107, 119].includes(Number(body.error?.code)))
|
|
5
|
+
throw new CtlError('CTL_NOT_AUTHENTICATED', 'DSM session expired', `run: synopkgland host login ${name}`, 2);
|
|
6
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { DsmSession } from './transport.js';
|
|
2
|
+
import type { HostTls } from './tls.js';
|
|
3
|
+
export interface Approval {
|
|
4
|
+
version: string;
|
|
5
|
+
hash?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface Host {
|
|
8
|
+
name: string;
|
|
9
|
+
origin: string;
|
|
10
|
+
username: string;
|
|
11
|
+
fingerprint?: string;
|
|
12
|
+
strictTls?: boolean;
|
|
13
|
+
ephemeral?: boolean;
|
|
14
|
+
tunnel?: boolean;
|
|
15
|
+
ca?: string;
|
|
16
|
+
lastUsed?: string;
|
|
17
|
+
approved: Record<string, Approval>;
|
|
18
|
+
}
|
|
19
|
+
export interface CredentialStore {
|
|
20
|
+
get(key: string): Promise<DsmSession | null>;
|
|
21
|
+
set(key: string, value: DsmSession): Promise<void>;
|
|
22
|
+
delete(key: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export declare class OsCredentialStore implements CredentialStore {
|
|
25
|
+
private entry;
|
|
26
|
+
get(key: string): Promise<DsmSession | null>;
|
|
27
|
+
set(key: string, value: DsmSession): Promise<void>;
|
|
28
|
+
delete(key: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export declare class CtlStore {
|
|
31
|
+
root: string;
|
|
32
|
+
credentials: CredentialStore;
|
|
33
|
+
constructor(root?: string, credentials?: CredentialStore);
|
|
34
|
+
private safe;
|
|
35
|
+
private file;
|
|
36
|
+
key(host: Host): string;
|
|
37
|
+
defaultName(): string | undefined;
|
|
38
|
+
setDefault(name: string): void;
|
|
39
|
+
automation(): Host | undefined;
|
|
40
|
+
targets(): Host[];
|
|
41
|
+
select(name?: string): Host;
|
|
42
|
+
list(): Host[];
|
|
43
|
+
get(name: string): Host;
|
|
44
|
+
save(host: Host): void;
|
|
45
|
+
add(host: Host, session: DsmSession): Promise<void>;
|
|
46
|
+
trust(host: Host): HostTls;
|
|
47
|
+
session(host: Host): Promise<DsmSession>;
|
|
48
|
+
cacheRoot(host: Host): string;
|
|
49
|
+
cacheFile(host: Host, pkg: string, version: string): string;
|
|
50
|
+
cache(host: Host, pkg: string, version: string): any | null;
|
|
51
|
+
putCache(host: Host, pkg: string, version: string, description: any): void;
|
|
52
|
+
putFailure(host: Host, pkg: string, version: string, failure: unknown): void;
|
|
53
|
+
revoke(name: string, pkg: string): {
|
|
54
|
+
revoked: string;
|
|
55
|
+
cacheCleared: boolean;
|
|
56
|
+
};
|
|
57
|
+
remove(name: string): Promise<{
|
|
58
|
+
removed: string;
|
|
59
|
+
deleted: string[];
|
|
60
|
+
}>;
|
|
61
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, rmSync, existsSync, chmodSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
5
|
+
import { CtlError, choose } from '../ctl/errors.js';
|
|
6
|
+
import { loginDsm } from './auth.js';
|
|
7
|
+
import { createRequest, canonicalOrigin } from './transport.js';
|
|
8
|
+
import { nasOptions } from './options.js';
|
|
9
|
+
import { publicDescriptor } from '../ctl/redaction.js';
|
|
10
|
+
export class OsCredentialStore {
|
|
11
|
+
async entry(key) { try {
|
|
12
|
+
const { AsyncEntry } = await import('@napi-rs/keyring');
|
|
13
|
+
return new AsyncEntry('synopkgland.ctl', key, { linux: { store: 'secret-service' } });
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new CtlError('CTL_CREDENTIAL_STORE', 'OS secure credential store unavailable.', 'Unlock the OS credential store and retry.', 2);
|
|
17
|
+
} }
|
|
18
|
+
async get(key) { try {
|
|
19
|
+
const raw = await (await this.entry(key)).getPassword();
|
|
20
|
+
return raw ? JSON.parse(raw) : null;
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
if (e instanceof CtlError)
|
|
24
|
+
throw e;
|
|
25
|
+
throw new CtlError('CTL_CREDENTIAL_STORE', 'Cannot read OS credentials.', 'Unlock the OS credential store and retry.', 2);
|
|
26
|
+
} }
|
|
27
|
+
async set(key, value) { try {
|
|
28
|
+
await (await this.entry(key)).setPassword(JSON.stringify(value));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new CtlError('CTL_CREDENTIAL_STORE', 'Cannot save session in OS secure storage.', 'Unlock the OS credential store and retry.', 2);
|
|
32
|
+
} }
|
|
33
|
+
async delete(key) { try {
|
|
34
|
+
await (await this.entry(key)).deleteCredential();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new CtlError('CTL_CREDENTIAL_STORE', 'Cannot remove OS credentials.', 'Unlock the OS credential store and retry removal.', 2);
|
|
38
|
+
} }
|
|
39
|
+
}
|
|
40
|
+
export class CtlStore {
|
|
41
|
+
root;
|
|
42
|
+
credentials;
|
|
43
|
+
constructor(root = process.env.SYNOPKGLAND_CTL_DIR ?? join(homedir(), '.synopkgland', 'ctl'), credentials = new OsCredentialStore()) {
|
|
44
|
+
this.root = root;
|
|
45
|
+
this.credentials = credentials;
|
|
46
|
+
}
|
|
47
|
+
safe(name) { if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name))
|
|
48
|
+
throw new CtlError('CTL_HOST_INVALID', 'Invalid host name.', 'Use letters, digits, dots, dashes or underscores.'); return name; }
|
|
49
|
+
file(name) { return join(this.root, 'hosts', this.safe(name) + '.json'); }
|
|
50
|
+
key(host) { return createHash('sha256').update(host.name + '\0' + host.origin + '\0' + host.username).digest('hex'); }
|
|
51
|
+
defaultName() { try {
|
|
52
|
+
return JSON.parse(readFileSync(join(this.root, 'default.json'), 'utf8')).name;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
} }
|
|
57
|
+
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); }
|
|
58
|
+
automation() {
|
|
59
|
+
const url = nasOptions().url ?? process.env.DSM_URL;
|
|
60
|
+
if (url && process.env.DSM_USERNAME && process.env.DSM_PASSWORD)
|
|
61
|
+
return { name: 'env', origin: canonicalOrigin(url), username: process.env.DSM_USERNAME, approved: {}, ephemeral: true };
|
|
62
|
+
}
|
|
63
|
+
targets() { const registered = this.list(); const ephemeral = this.automation(); return ephemeral && (nasOptions().url || !registered.length) ? [ephemeral] : registered; }
|
|
64
|
+
select(name) { if (!name) {
|
|
65
|
+
const ephemeral = this.automation();
|
|
66
|
+
if (ephemeral && (nasOptions().url || !this.list().length))
|
|
67
|
+
return ephemeral;
|
|
68
|
+
} if (name)
|
|
69
|
+
return this.get(name); const hosts = this.list(); const selected = this.defaultName(); if (selected)
|
|
70
|
+
return this.get(selected); if (hosts.length === 1)
|
|
71
|
+
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); }
|
|
72
|
+
list() { const dir = join(this.root, 'hosts'); if (!existsSync(dir))
|
|
73
|
+
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)); }
|
|
74
|
+
get(name) { this.safe(name); const host = this.list().find(h => h.name === name); if (!host)
|
|
75
|
+
return choose(name, this.list().map(h => h.name), 'host'); return host; }
|
|
76
|
+
save(host) { if (host.ephemeral)
|
|
77
|
+
return; 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); }
|
|
78
|
+
async add(host, session) { if (this.list().some(h => h.name === host.name))
|
|
79
|
+
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 {
|
|
80
|
+
this.save(host);
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
await this.credentials.delete(this.key(host));
|
|
84
|
+
throw e;
|
|
85
|
+
} }
|
|
86
|
+
trust(host) {
|
|
87
|
+
const override = nasOptions();
|
|
88
|
+
return { name: host.name, fingerprint: host.fingerprint, strictTls: host.strictTls || override.strictTls, ca: (override.ca ?? host.ca) ? readFileSync((override.ca ?? host.ca), 'utf8') : undefined,
|
|
89
|
+
readFingerprint: () => { try {
|
|
90
|
+
return JSON.parse(readFileSync(this.file(host.name), 'utf8')).fingerprint;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return host.fingerprint;
|
|
94
|
+
} },
|
|
95
|
+
record: (fingerprint) => { host.fingerprint = fingerprint; this.save(host); }, warn: override.warn };
|
|
96
|
+
}
|
|
97
|
+
async session(host) {
|
|
98
|
+
let session;
|
|
99
|
+
if (process.env.DSM_USERNAME && process.env.DSM_PASSWORD) {
|
|
100
|
+
if (process.env.DSM_USERNAME !== host.username)
|
|
101
|
+
throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Automation account differs from the registered host.', 'Use the registered account or a separately named host.', 2);
|
|
102
|
+
session = await loginDsm(host.origin, host.username, process.env.DSM_PASSWORD, { remember: false, otp: async () => process.env.DSM_OTP ?? '', request: createRequest(host.origin, 30000, undefined, this.trust(host)) });
|
|
103
|
+
}
|
|
104
|
+
else
|
|
105
|
+
session = await this.credentials.get(this.key(host));
|
|
106
|
+
if (!session?.cookie || !session.token)
|
|
107
|
+
throw new CtlError('CTL_NOT_AUTHENTICATED', `Host ${host.name} is not logged in.`, `run: synopkgland host login ${host.name}`, 2);
|
|
108
|
+
if (/[\r\n\0]/.test(session.cookie + session.token))
|
|
109
|
+
throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Stored session carriers are invalid', `run: synopkgland host login ${host.name}`, 2);
|
|
110
|
+
if (session.origin !== host.origin || session.username !== host.username)
|
|
111
|
+
throw new CtlError('CTL_CREDENTIAL_MISMATCH', 'Stored credentials do not match host.', 'Run synopkgland host login again.', 2);
|
|
112
|
+
return session;
|
|
113
|
+
}
|
|
114
|
+
cacheRoot(host) { return join(this.root, 'cache', this.key(host)); }
|
|
115
|
+
cacheFile(host, pkg, version) { return join(this.cacheRoot(host), createHash('sha256').update(pkg + '\0' + version).digest('hex') + '.json'); }
|
|
116
|
+
cache(host, pkg, version) {
|
|
117
|
+
try {
|
|
118
|
+
const entry = JSON.parse(readFileSync(this.cacheFile(host, pkg, version), 'utf8'));
|
|
119
|
+
if (entry.description && !entry.redacted) {
|
|
120
|
+
const safe = publicDescriptor(entry.description);
|
|
121
|
+
if (JSON.stringify(safe) !== JSON.stringify(entry.description)) {
|
|
122
|
+
this.putCache(host, pkg, version, entry.description);
|
|
123
|
+
return { ...entry, description: safe, redacted: true };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return entry;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
putCache(host, pkg, version, description) {
|
|
133
|
+
const safe = publicDescriptor(description);
|
|
134
|
+
const redacted = JSON.stringify(safe) !== JSON.stringify(description);
|
|
135
|
+
mkdirSync(this.cacheRoot(host), { recursive: true, mode: 0o700 });
|
|
136
|
+
const file = this.cacheFile(host, pkg, version), tmp = file + '.' + randomUUID();
|
|
137
|
+
writeFileSync(tmp, JSON.stringify({ hash: description.sha256, description: safe, ...(redacted ? { redacted: true } : {}) }), { mode: 0o600 });
|
|
138
|
+
renameSync(tmp, file);
|
|
139
|
+
}
|
|
140
|
+
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); }
|
|
141
|
+
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 }; }
|
|
142
|
+
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)
|
|
143
|
+
rmSync(join(this.root, 'default.json'), { force: true }); return { removed: name, deleted: ['session', 'SynoToken', 'device token', 'App approvals', 'hash pins', 'list cache', 'host registration'] }; }
|
|
144
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import https from "node:https";
|
|
2
|
+
import { CtlError } from "../ctl/errors.js";
|
|
3
|
+
export interface HostTls {
|
|
4
|
+
fingerprint?: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
ca?: string;
|
|
7
|
+
strictTls?: boolean;
|
|
8
|
+
expectedFingerprint?: string;
|
|
9
|
+
readFingerprint?: () => string | undefined;
|
|
10
|
+
record?: (fingerprint: string) => void;
|
|
11
|
+
warn?: (line: string) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare function normalizeFingerprint(value: string): string;
|
|
14
|
+
export declare function networkError(error: unknown): CtlError;
|
|
15
|
+
export declare function timeoutError(): CtlError;
|
|
16
|
+
/** TLS-only handshake. No HTTP headers, password or session is sent during discovery. */
|
|
17
|
+
export declare function probeCertificate(origin: string, options?: HostTls & {
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
}): Promise<{
|
|
20
|
+
fingerprint?: string;
|
|
21
|
+
leafFingerprint?: string;
|
|
22
|
+
trusted: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
/** Inspect the certificate and apply NAS policy before releasing this exact socket to HTTP. */
|
|
25
|
+
export declare function pinnedAgent(origin: string, trust: HostTls, timeoutMs: number): https.Agent;
|