@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/nas/tls.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import tls from "node:tls";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import { isIP } from "node:net";
|
|
4
|
+
import { CtlError } from "../ctl/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
|
+
(code === "DSM_TLS_UNTRUSTED" || /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", "Check the CA file, NAS hostname and certificate validity, or omit strict TLS", 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
|
+
checkCertificate(socket, { ...options, expectedFingerprint: options.expectedFingerprint });
|
|
60
|
+
resolve({
|
|
61
|
+
trusted: socket.authorized,
|
|
62
|
+
leafFingerprint: fingerprint,
|
|
63
|
+
fingerprint: fingerprint,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
reject(e);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
socket.destroy();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Inspect the certificate and apply NAS policy before releasing this exact socket to HTTP. */
|
|
76
|
+
export function pinnedAgent(origin, trust, timeoutMs) {
|
|
77
|
+
const agent = new https.Agent({ keepAlive: false });
|
|
78
|
+
agent.createConnection = (_options, callback) => {
|
|
79
|
+
let done = false;
|
|
80
|
+
const socket = tls.connect({
|
|
81
|
+
...socketOptions(origin, trust.ca),
|
|
82
|
+
rejectUnauthorized: false,
|
|
83
|
+
});
|
|
84
|
+
const timer = setTimeout(() => socket.destroy(timeoutError()), timeoutMs);
|
|
85
|
+
const finish = (error) => {
|
|
86
|
+
if (done)
|
|
87
|
+
return;
|
|
88
|
+
done = true;
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
if (error) {
|
|
91
|
+
socket.destroy();
|
|
92
|
+
callback?.(error, null);
|
|
93
|
+
}
|
|
94
|
+
else
|
|
95
|
+
callback?.(null, socket);
|
|
96
|
+
};
|
|
97
|
+
socket.once("error", (e) => finish(networkError(e)));
|
|
98
|
+
socket.once("secureConnect", () => {
|
|
99
|
+
try {
|
|
100
|
+
checkCertificate(socket, trust);
|
|
101
|
+
finish();
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
finish(e);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
return undefined;
|
|
108
|
+
};
|
|
109
|
+
return agent;
|
|
110
|
+
}
|
|
111
|
+
/** Runs before HTTP can write headers or a password on this very socket. */
|
|
112
|
+
function checkCertificate(socket, trust) {
|
|
113
|
+
const current = normalizeFingerprint(socket.getPeerCertificate().fingerprint256 ?? '');
|
|
114
|
+
const recorded = trust.readFingerprint?.() ?? trust.fingerprint;
|
|
115
|
+
const old = recorded && normalizeFingerprint(recorded);
|
|
116
|
+
const expected = trust.expectedFingerprint && normalizeFingerprint(trust.expectedFingerprint);
|
|
117
|
+
if (expected && expected !== current)
|
|
118
|
+
throw changed(expected, current, trust.name);
|
|
119
|
+
if (old && old !== current && (trust.strictTls || trust.ca))
|
|
120
|
+
throw changed(old, current, trust.name);
|
|
121
|
+
if ((trust.strictTls || trust.ca) && !socket.authorized)
|
|
122
|
+
throw new CtlError('HOST_TLS_UNTRUSTED', 'NAS certificate failed CA, hostname or validity verification', 'Check the CA file, NAS hostname and certificate validity', 6);
|
|
123
|
+
if (old && old !== current)
|
|
124
|
+
(trust.warn ?? console.error)(`NAS certificate changed: ${old.slice(0, 16)} -> ${current.slice(0, 16)}, now trusting the new one`);
|
|
125
|
+
if (old !== current) {
|
|
126
|
+
trust.record?.(current);
|
|
127
|
+
trust.fingerprint = current;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type HostTls } from './tls.js';
|
|
2
|
+
import { type DsmSession } from './transport.js';
|
|
3
|
+
export declare function localFile(file: unknown): {
|
|
4
|
+
file: string;
|
|
5
|
+
size: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function transferRequest(session: DsmSession, path: string, method: string, params: Record<string, unknown>, options?: {
|
|
8
|
+
file?: {
|
|
9
|
+
path: string;
|
|
10
|
+
parent: string;
|
|
11
|
+
name: string;
|
|
12
|
+
};
|
|
13
|
+
ca?: string;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
trust?: HostTls;
|
|
16
|
+
}): Promise<any>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { networkError, timeoutError } from './tls.js';
|
|
2
|
+
import { createReadStream, statSync } from 'node:fs';
|
|
3
|
+
import { CtlError } from '../ctl/errors.js';
|
|
4
|
+
import { nasHttpRequest, CTL_TIMEOUT_MS } from './transport.js';
|
|
5
|
+
export function localFile(file) { if (typeof file !== 'string' || !file)
|
|
6
|
+
throw new CtlError('CTL_INPUT_FILE', 'A local filename is required.', 'Use --file <path>.'); try {
|
|
7
|
+
const stat = statSync(file);
|
|
8
|
+
if (!stat.isFile() || stat.size > 16 * 1024 ** 3)
|
|
9
|
+
throw Error();
|
|
10
|
+
return { file, size: stat.size };
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new CtlError('CTL_INPUT_FILE', 'Upload must be a readable regular file of at most 16 GiB.', 'Check --file <path>.');
|
|
14
|
+
} }
|
|
15
|
+
export async function transferRequest(session, path, method, params, options = {}) {
|
|
16
|
+
if (!/^\/webman\/3rdparty\/[A-Za-z][A-Za-z0-9_.+-]*\/cgi\/api\.cgi$/.test(path) || !/^([a-z][a-z0-9_]*\.)+[a-z][a-z0-9_]*$/.test(method))
|
|
17
|
+
throw new CtlError('CTL_PATH_INVALID', 'Invalid transfer binding.', 'Refresh the API description.', 4);
|
|
18
|
+
if (options.timeoutMs !== undefined && options.timeoutMs <= 0)
|
|
19
|
+
throw new CtlError('CTL_NETWORK', 'Transfer budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
|
|
20
|
+
const url = new URL(path, session.origin);
|
|
21
|
+
const file = options.file ? localFile(options.file.path) : undefined;
|
|
22
|
+
if (options.file) {
|
|
23
|
+
url.search = new URLSearchParams({ method, parent: options.file.parent, name: options.file.name }).toString();
|
|
24
|
+
}
|
|
25
|
+
const raw = file ? undefined : Buffer.from(JSON.stringify({ method, params }));
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const headers = { 'content-type': file ? 'application/octet-stream' : 'application/json', cookie: session.cookie, 'X-SYNO-TOKEN': session.token, origin: session.origin, 'content-length': String(file?.size ?? raw.length) };
|
|
28
|
+
const req = nasHttpRequest(url, { method: 'POST', headers }, options.timeoutMs ?? CTL_TIMEOUT_MS, { ...options.trust, ca: options.ca ?? options.trust?.ca }, res => {
|
|
29
|
+
const expected = file ? 'application/json' : 'application/x-ndjson';
|
|
30
|
+
if (res.statusCode !== 200 || !res.headers['content-type']?.includes(expected)) {
|
|
31
|
+
res.resume();
|
|
32
|
+
reject(new CtlError('CTL_TRANSFER_FAILED', 'Unexpected transfer response; redirects are refused.', 'Check App transfer support.', 5));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let total = 0;
|
|
37
|
+
res.on('data', (chunk) => { total += chunk.length; if (total > (file ? 1048576 : 8 * 1048576)) {
|
|
38
|
+
req.destroy();
|
|
39
|
+
reject(new CtlError('CTL_FLOW_LIMIT', 'Transfer result exceeds collection limit.', 'Narrow the query.', 5));
|
|
40
|
+
}
|
|
41
|
+
else
|
|
42
|
+
chunks.push(chunk); });
|
|
43
|
+
res.on('error', () => reject(new CtlError('CTL_NETWORK', 'Transfer response interrupted.', 'Check state before retrying writes.', 6)));
|
|
44
|
+
res.on('end', () => { try {
|
|
45
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
46
|
+
if (file) {
|
|
47
|
+
const body = JSON.parse(text);
|
|
48
|
+
if (body.success !== true)
|
|
49
|
+
throw Error();
|
|
50
|
+
resolve(body.data);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const lines = text.split('\n').filter(l => l.trim());
|
|
54
|
+
if (lines.length > 10000)
|
|
55
|
+
throw Error();
|
|
56
|
+
resolve({ items: lines.map(line => JSON.parse(line)) });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
reject(new CtlError('CTL_TRANSFER_FAILED', 'Invalid or failed transfer response.', 'Inspect the App result; do not retry writes blindly.', 5));
|
|
61
|
+
} });
|
|
62
|
+
});
|
|
63
|
+
const wallTimer = setTimeout(() => req.destroy(timeoutError()), options.timeoutMs ?? CTL_TIMEOUT_MS);
|
|
64
|
+
req.once('close', () => clearTimeout(wallTimer));
|
|
65
|
+
req.on('timeout', () => req.destroy(timeoutError()));
|
|
66
|
+
req.on('error', e => reject(networkError(e)));
|
|
67
|
+
if (file) {
|
|
68
|
+
const source = createReadStream(file.file);
|
|
69
|
+
source.on('error', () => req.destroy());
|
|
70
|
+
req.on('close', () => source.destroy());
|
|
71
|
+
source.pipe(req);
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
req.end(raw);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type HostTls } from './tls.js';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
export declare const CTL_TIMEOUT_MS = 62000;
|
|
4
|
+
export declare const APP_COOKIE: unique symbol;
|
|
5
|
+
export interface DsmSession {
|
|
6
|
+
origin: string;
|
|
7
|
+
username: string;
|
|
8
|
+
cookie: string;
|
|
9
|
+
token: string;
|
|
10
|
+
deviceId?: string;
|
|
11
|
+
}
|
|
12
|
+
export type JsonRequest = (path: string, body: URLSearchParams | Record<string, unknown>, session?: DsmSession) => Promise<any>;
|
|
13
|
+
export declare function canonicalOrigin(input: string, tunnel?: boolean): string;
|
|
14
|
+
export declare function createRequest(origin: string, timeoutMs?: number | (() => number), ca?: string, trust?: HostTls): JsonRequest;
|
|
15
|
+
export declare function sessionFetch(url: string, init: RequestInit, session: DsmSession | undefined, ca?: string, trust?: HostTls): Promise<Response>;
|
|
16
|
+
/** The only Node HTTP dispatch for NAS endpoints, including streaming transfers. */
|
|
17
|
+
export declare function nasHttpRequest(url: URL, options: http.RequestOptions, timeoutMs: number, trust: HostTls | undefined, listener: (res: http.IncomingMessage) => void): http.ClientRequest;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { pinnedAgent, networkError, timeoutError } from './tls.js';
|
|
2
|
+
import { strictJson } from '../ctl/json.js';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import { assertSessionActive } from './session.js';
|
|
6
|
+
import { nasOptions } from './options.js';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { CtlError } from '../ctl/errors.js';
|
|
9
|
+
export const CTL_TIMEOUT_MS = 62000;
|
|
10
|
+
export const APP_COOKIE = Symbol("ctlAppCookie");
|
|
11
|
+
export function canonicalOrigin(input, tunnel = false) {
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(input);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new CtlError('CTL_URL_INVALID', 'A NAS origin URL is required.', 'run: synopkgland host add <name> --url https://<nas>:5001');
|
|
18
|
+
}
|
|
19
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || !(url.protocol === 'https:' || (tunnel && url.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(url.hostname))))
|
|
20
|
+
throw new CtlError('CTL_URL_INVALID', 'Use HTTPS, or explicit --ssh-tunnel for a loopback HTTP tunnel.', 'run: synopkgland host add <name> --url https://<nas>:5001');
|
|
21
|
+
return url.origin;
|
|
22
|
+
}
|
|
23
|
+
export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca, trust) {
|
|
24
|
+
return async (path, body, session) => {
|
|
25
|
+
if (!path.startsWith('/') || path.startsWith('//') || path.includes('?') || path.includes('#') || path.includes('\\') || path.includes('%') || path.split('/').some(p => p === '.' || p === '..'))
|
|
26
|
+
throw new CtlError('CTL_PATH_INVALID', 'Unsafe request path.', 'run: synopkgland ctl list --refresh');
|
|
27
|
+
const remaining = typeof timeoutMs === 'function' ? timeoutMs() : timeoutMs;
|
|
28
|
+
if (remaining <= 0)
|
|
29
|
+
throw new CtlError('CTL_NETWORK', 'Request budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
|
|
30
|
+
const url = new URL(path, origin);
|
|
31
|
+
if (url.origin !== origin || (session && session.origin !== origin))
|
|
32
|
+
throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins.', 'run: synopkgland host list', 4);
|
|
33
|
+
const form = body instanceof URLSearchParams;
|
|
34
|
+
const payload = Buffer.from(form ? body.toString() : JSON.stringify(body));
|
|
35
|
+
if (payload.length > 1048576)
|
|
36
|
+
throw new CtlError('CTL_INPUT_TOO_LARGE', 'Request exceeds 1 MiB.', 'Reduce the input.');
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const headers = { 'content-type': form ? 'application/x-www-form-urlencoded' : 'application/json', accept: 'application/json', 'content-length': String(payload.length), origin };
|
|
39
|
+
if (session) {
|
|
40
|
+
headers.cookie = session.cookie;
|
|
41
|
+
headers['X-SYNO-TOKEN'] = session.token;
|
|
42
|
+
}
|
|
43
|
+
const req = nasHttpRequest(url, { method: 'POST', headers }, remaining, trustWithCa(trust, ca), res => {
|
|
44
|
+
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
|
45
|
+
res.resume();
|
|
46
|
+
reject(new CtlError('CTL_HTTP_ERROR', `HTTP ${res.statusCode}; redirects are never followed.`, 'Check the NAS URL and connection.', 6));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!res.headers['content-type']?.includes('application/json')) {
|
|
50
|
+
res.resume();
|
|
51
|
+
reject(new CtlError('CTL_RESPONSE_INVALID', 'NAS returned non-JSON data.', 'Check the endpoint and installed package.', 5));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const chunks = [];
|
|
55
|
+
let length = 0;
|
|
56
|
+
res.on('data', (c) => { length += c.length; if (length > 1048576) {
|
|
57
|
+
res.destroy();
|
|
58
|
+
reject(new CtlError('CTL_RESPONSE_TOO_LARGE', 'Response exceeds 1 MiB.', 'Use a smaller operation.', 5));
|
|
59
|
+
}
|
|
60
|
+
else
|
|
61
|
+
chunks.push(c); });
|
|
62
|
+
res.on('error', () => reject(new CtlError('CTL_NETWORK', 'Response interrupted.', 'Check the NAS connection; state-changing requests are not replayed.', 6)));
|
|
63
|
+
res.on('end', () => {
|
|
64
|
+
try {
|
|
65
|
+
const body = strictJson(Buffer.concat(chunks));
|
|
66
|
+
if (session && path === '/webapi/entry.cgi')
|
|
67
|
+
assertSessionActive(body, trust?.name);
|
|
68
|
+
if (!form && body?.success === true && body.data && path.startsWith('/webman/3rdparty/') && url.protocol === 'https:') {
|
|
69
|
+
const cookies = (res.headers['set-cookie'] ?? []).filter(line => /;\s*Secure(?:;|$)/i.test(line) && /;\s*HttpOnly(?:;|$)/i.test(line) && !/;\s*Domain=/i.test(line));
|
|
70
|
+
const own = path.slice(0, path.lastIndexOf('/cgi/')) + '/';
|
|
71
|
+
const valid = cookies.filter(line => { const match = /;\s*Path=([^;]+)/i.exec(line); return match?.[1] === own; }).map(line => line.split(';')[0]).filter(pair => !pair.startsWith('id='));
|
|
72
|
+
if (valid.length)
|
|
73
|
+
Object.defineProperty(body, APP_COOKIE, { value: valid.join('; '), enumerable: false });
|
|
74
|
+
}
|
|
75
|
+
resolve(body);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error instanceof CtlError) {
|
|
79
|
+
reject(error);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
reject(new CtlError('CTL_RESPONSE_INVALID', 'Invalid JSON response.', 'Check the installed package.', 5));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
const wallTimer = setTimeout(() => req.destroy(timeoutError()), remaining);
|
|
87
|
+
req.once('close', () => clearTimeout(wallTimer));
|
|
88
|
+
req.on('timeout', () => req.destroy(timeoutError()));
|
|
89
|
+
req.on('error', error => reject(networkError(error)));
|
|
90
|
+
req.end(payload);
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export async function sessionFetch(url, init, session, ca, trust) {
|
|
95
|
+
if (session && new URL(url).origin !== session.origin)
|
|
96
|
+
throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins', 'Check the registered NAS origin', 4);
|
|
97
|
+
const headers = new Headers(init.headers);
|
|
98
|
+
if (session) {
|
|
99
|
+
headers.set('cookie', session.cookie);
|
|
100
|
+
headers.set('X-SYNO-TOKEN', session.token);
|
|
101
|
+
}
|
|
102
|
+
const request = new Request(url, { ...init, headers }), bytes = init.body ? Buffer.from(await request.arrayBuffer()) : undefined;
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const target = new URL(url);
|
|
105
|
+
const req = nasHttpRequest(target, { method: request.method, headers: Object.fromEntries(request.headers) }, 62000, trustWithCa(trust, ca), res => {
|
|
106
|
+
const chunks = [];
|
|
107
|
+
let length = 0;
|
|
108
|
+
res.on('data', c => { length += c.length; if (length > 16 * 1024 * 1024)
|
|
109
|
+
res.destroy(new CtlError('CTL_RESPONSE_TOO_LARGE', 'DSM response exceeds 16 MiB', 'Use a narrower operation', 5));
|
|
110
|
+
else
|
|
111
|
+
chunks.push(c); });
|
|
112
|
+
res.on('error', () => reject(new CtlError('DSM_NETWORK', 'DSM response interrupted', 'Check the NAS connection', 6)));
|
|
113
|
+
res.on('end', () => { const headers = new Headers(); for (const [k, v] of Object.entries(res.headers))
|
|
114
|
+
for (const x of Array.isArray(v) ? v : [v])
|
|
115
|
+
if (x !== undefined)
|
|
116
|
+
headers.append(k, x); resolve(new Response([204, 205, 304].includes(res.statusCode ?? 0) || request.method === 'HEAD' ? null : Buffer.concat(chunks), { status: res.statusCode, headers })); });
|
|
117
|
+
});
|
|
118
|
+
const abort = () => req.destroy(request.signal.reason instanceof Error ? request.signal.reason : new Error('Request aborted'));
|
|
119
|
+
if (request.signal.aborted)
|
|
120
|
+
abort();
|
|
121
|
+
else
|
|
122
|
+
request.signal.addEventListener('abort', abort, { once: true });
|
|
123
|
+
req.once('close', () => request.signal.removeEventListener('abort', abort));
|
|
124
|
+
const timer = setTimeout(() => req.destroy(timeoutError()), 62000);
|
|
125
|
+
req.once('close', () => clearTimeout(timer));
|
|
126
|
+
req.on('timeout', () => req.destroy(timeoutError()));
|
|
127
|
+
req.on('error', e => reject(networkError(e)));
|
|
128
|
+
req.end(bytes);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/** The only Node HTTP dispatch for NAS endpoints, including streaming transfers. */
|
|
132
|
+
export function nasHttpRequest(url, options, timeoutMs, trust = {}, listener) {
|
|
133
|
+
const override = nasOptions();
|
|
134
|
+
const policy = { ...trust, get fingerprint() { return trust.fingerprint; }, set fingerprint(value) { trust.fingerprint = value; }, strictTls: trust.strictTls || override.strictTls, ca: override.ca ? readFileSync(override.ca, 'utf8') : trust.ca, warn: trust.warn ?? override.warn };
|
|
135
|
+
const agent = url.protocol === 'https:' ? pinnedAgent(url.origin, policy, timeoutMs) : undefined;
|
|
136
|
+
// Share observations with this request factory, so parallel requests warn once.
|
|
137
|
+
policy.record = (fingerprint) => { trust.record?.(fingerprint); trust.fingerprint = fingerprint; };
|
|
138
|
+
const req = (url.protocol === 'https:' ? https : http).request(url, { ...options, timeout: timeoutMs, ...(agent ? { agent } : {}) }, listener);
|
|
139
|
+
req.once('close', () => agent?.destroy());
|
|
140
|
+
return req;
|
|
141
|
+
}
|
|
142
|
+
function trustWithCa(trust, ca) { const policy = trust ?? {}; if (ca)
|
|
143
|
+
policy.ca = ca; return policy; }
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/cli",
|
|
3
|
-
"version": "0.33.
|
|
3
|
+
"version": "0.33.1",
|
|
4
4
|
"description": "Syno Package Land Developer Kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"synopkgland": "
|
|
7
|
+
"synopkgland": "dist/bin.js"
|
|
8
8
|
},
|
|
9
9
|
"engines": {
|
|
10
10
|
"node": ">=22.12"
|
package/skills/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
|
|
|
5
5
|
|
|
6
6
|
# Independent Synology SPKs
|
|
7
7
|
|
|
8
|
-
CLI 0.
|
|
8
|
+
CLI 0.33.1 installs templates through `~0.31.0`. A templates patch needs only a
|
|
9
9
|
templates release; npm install/update selects a patch in that minor line. The CLI
|
|
10
10
|
uses the locally installed templates and reads their SDK pin for defaults; it
|
|
11
11
|
never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
|
|
@@ -16,6 +16,8 @@ The CLI verifies bytes before packaging and never fetches a manifest at NAS runt
|
|
|
16
16
|
Unsupported formats require a CLI update; invalid manifests fail closed. Existing
|
|
17
17
|
SDK 0.26.0/0.27.0 use the frozen legacy inventory.
|
|
18
18
|
|
|
19
|
+
All CLI NAS requests share one connection path. NAS certificates are accepted by default, including self-signed, wrong-name and expired certificates. A changed certificate warns once, updates its displayed SHA-256 and continues. `host add --strict-tls` stores strict verification; `--strict-tls` or `--ca <file>` on a command opts in for that command. Read [reference/host-install.md](reference/host-install.md) for host trust and error codes.
|
|
20
|
+
|
|
19
21
|
Before releasing an executor App, read [reference/approval-upgrades.md](reference/approval-upgrades.md). Run build/inspect with the previous released SPK as `--baseline` and include the approval difference in release notes. Without a comparable baseline, retention is unknown.
|
|
20
22
|
|
|
21
23
|
Use this guide to initialize, build and inspect own applications or upstream OSS as independent packages. 依已實作能力區分 DSM session 檔案、私有 jobs 與 App 登入;delegate 已實作並隨 CLI 提供固定操作的離線委託(預設 `files.moveFromStaging`);共用 delegate/rootOps executor 的完整裝置生命週期驗收仍待完成,不得宣稱已通過;支援邊界依 delegate guide/support matrix,禁止發明新的身分平台。
|
package/skills/reference/ctl.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Control registered NAS Apps with ctl
|
|
2
2
|
|
|
3
|
+
NAS HTTPS accepts any certificate by default. A changed leaf warns and updates the recorded SHA-256. Use `--strict-tls` or `--ca <file>` to opt into CA, hostname, expiry and change checks; host add stores that policy. See [host login](host-install.md).
|
|
4
|
+
|
|
3
5
|
Use this for operating installed Apps, rather than building/deploying projects.
|
|
4
6
|
|
|
5
7
|
1. `synopkgland host list --json` checks registered machines and sessions.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
Before deployment, register a NAS with `synopkgland host add nas --url https://nas.example:5001`, then renew it with `synopkgland host login nas`. See [host-install.md](host-install.md) for shared sessions, automation, install and uninstall.
|
|
2
2
|
|
|
3
|
+
NAS HTTPS accepts any certificate by default. A changed leaf warns and updates the recorded SHA-256. Use `--strict-tls` or `--ca <file>` to opt into CA, hostname, expiry and change checks; host add stores that policy. See [host login](host-install.md).
|
|
4
|
+
|
|
3
5
|
# Build and deploy
|
|
4
6
|
|
|
5
7
|
1. Run `synopkgland check` and `synopkgland build --arch x86_64` (use aarch64 or armv7 for those targets; armv7 must be listed in `arches`; its jobs, daemon and executor are built but not NAS-verified). deploy detects the NAS arch, except on Marvell CPUs, where it asks for `--arch`.
|
|
@@ -4,7 +4,7 @@ Register once with `synopkgland host add nas --url https://nas.example:5001 --us
|
|
|
4
4
|
The CLI prompts for the password and OTP when DSM requires it. Session cookies,
|
|
5
5
|
SynoToken and remembered-device tokens stay in the OS credential store; host
|
|
6
6
|
metadata and App approvals stay under `~/.synopkgland/ctl`. Passwords are not saved.
|
|
7
|
-
|
|
7
|
+
NAS HTTPS accepts every certificate by default, including self-signed, untrusted CA, wrong-name and expired certificates. Use `--strict-tls` or `--ca <PEM>` to opt into verification.
|
|
8
8
|
|
|
9
9
|
Use `synopkgland host login nas` to renew an expired session. `host list`,
|
|
10
10
|
`host show nas`, `host logout nas`, `host remove nas --yes` and
|
|
@@ -41,15 +41,26 @@ For automation, provide `DSM_USERNAME` and `DSM_PASSWORD` in the process environ
|
|
|
41
41
|
(and `DSM_OTP` if required), with a registered `--host` using that same account.
|
|
42
42
|
These values create an in-memory session and are never written to disk. Without a
|
|
43
43
|
registration, install/deploy/status/logs also accept `DSM_URL` with both credentials.
|
|
44
|
-
Use an HTTPS URL
|
|
44
|
+
Use an HTTPS URL. Supply secrets through your CI secret
|
|
45
45
|
store, never literal command arguments, committed files or command output.
|
|
46
46
|
`host add/login --password-stdin` supports a password from standard input.
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
Every NAS request uses one shared connection module for host lookup, login,
|
|
49
|
+
OS session storage, expiry handling, TLS and HTTP, including ctl transfers,
|
|
50
|
+
install, uninstall, deploy, dev, status, logs and e2e login helpers. Passwords
|
|
51
|
+
are sent only after inspecting the certificate on that exact TLS connection.
|
|
52
|
+
The leaf SHA-256 is recorded for display. An unverified first connection says
|
|
53
|
+
`Trusting the NAS certificate <short fingerprint> (not verified by a trusted CA)`.
|
|
54
|
+
A changed certificate prints one stderr line, updates the recorded fingerprint
|
|
55
|
+
and continues: `NAS certificate changed: <old short> -> <new short>, now trusting the new one`.
|
|
56
|
+
|
|
57
|
+
`host add --strict-tls` stores strict mode in the host profile. `--strict-tls`
|
|
58
|
+
on another command applies to that invocation only. Strict mode checks the CA,
|
|
59
|
+
hostname, expiry and recorded fingerprint: untrusted certificates fail with
|
|
60
|
+
`HOST_TLS_UNTRUSTED`; changed certificates fail with `HOST_CERT_CHANGED`.
|
|
61
|
+
`--ca <PEM>` also opts into real CA and hostname verification and is stored on
|
|
62
|
+
host add, or applies to one command. `host trust <name>` still replaces a recorded
|
|
63
|
+
fingerprint after confirmation. `host add --fingerprint <sha256>` checks an
|
|
64
|
+
explicit expected leaf during registration; it does not turn on strict mode.
|
|
65
|
+
No global TLS override is used. Public package feeds and downloads keep normal
|
|
66
|
+
HTTPS verification. Network errors distinguish DNS, refusal and timeout.
|