@synopackageland/cli 0.33.0 → 0.33.2
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 +3 -7
- package/README.md +14 -2
- 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/dist/standalone/dsm-window.d.ts +2 -0
- package/dist/standalone/dsm-window.js +30 -3
- package/package.json +1 -1
- package/skills/SKILL.md +4 -2
- package/skills/reference/create.md +1 -1
- package/skills/reference/ctl.md +2 -0
- package/skills/reference/deploy.md +2 -0
- package/skills/reference/host-install.md +21 -10
- package/skills/reference/portal.md +1 -1
- package/skills/reference/ui-vue.md +1 -1
package/dist/ctl/transfers.js
CHANGED
|
@@ -1,78 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import http from 'node:http';
|
|
4
|
-
import https from 'node:https';
|
|
5
|
-
import { CtlError } from './errors.js';
|
|
6
|
-
import { CTL_TIMEOUT_MS } from './transport.js';
|
|
7
|
-
export function localFile(file) { if (typeof file !== 'string' || !file)
|
|
8
|
-
throw new CtlError('CTL_INPUT_FILE', 'A local filename is required.', 'Use --file <path>.'); try {
|
|
9
|
-
const stat = statSync(file);
|
|
10
|
-
if (!stat.isFile() || stat.size > 16 * 1024 ** 3)
|
|
11
|
-
throw Error();
|
|
12
|
-
return { file, size: stat.size };
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
15
|
-
throw new CtlError('CTL_INPUT_FILE', 'Upload must be a readable regular file of at most 16 GiB.', 'Check --file <path>.');
|
|
16
|
-
} }
|
|
17
|
-
export async function transferRequest(session, path, method, params, options = {}) {
|
|
18
|
-
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))
|
|
19
|
-
throw new CtlError('CTL_PATH_INVALID', 'Invalid transfer binding.', 'Refresh the API description.', 4);
|
|
20
|
-
if (options.timeoutMs !== undefined && options.timeoutMs <= 0)
|
|
21
|
-
throw new CtlError('CTL_NETWORK', 'Transfer budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
|
|
22
|
-
const url = new URL(path, session.origin);
|
|
23
|
-
const file = options.file ? localFile(options.file.path) : undefined;
|
|
24
|
-
if (options.file) {
|
|
25
|
-
url.search = new URLSearchParams({ method, parent: options.file.parent, name: options.file.name }).toString();
|
|
26
|
-
}
|
|
27
|
-
const raw = file ? undefined : Buffer.from(JSON.stringify({ method, params }));
|
|
28
|
-
return new Promise((resolve, reject) => {
|
|
29
|
-
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) };
|
|
30
|
-
const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: options.timeoutMs ?? CTL_TIMEOUT_MS, ...(options.ca ? { ca: options.ca } : {}), ...(url.protocol === 'https:' && options.trust?.fingerprint ? { agent: pinnedAgent(session.origin, options.trust, options.timeoutMs ?? CTL_TIMEOUT_MS) } : {}) }, res => {
|
|
31
|
-
const expected = file ? 'application/json' : 'application/x-ndjson';
|
|
32
|
-
if (res.statusCode !== 200 || !res.headers['content-type']?.includes(expected)) {
|
|
33
|
-
res.resume();
|
|
34
|
-
reject(new CtlError('CTL_TRANSFER_FAILED', 'Unexpected transfer response; redirects are refused.', 'Check App transfer support.', 5));
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
const chunks = [];
|
|
38
|
-
let total = 0;
|
|
39
|
-
res.on('data', (chunk) => { total += chunk.length; if (total > (file ? 1048576 : 8 * 1048576)) {
|
|
40
|
-
req.destroy();
|
|
41
|
-
reject(new CtlError('CTL_FLOW_LIMIT', 'Transfer result exceeds collection limit.', 'Narrow the query.', 5));
|
|
42
|
-
}
|
|
43
|
-
else
|
|
44
|
-
chunks.push(chunk); });
|
|
45
|
-
res.on('error', () => reject(new CtlError('CTL_NETWORK', 'Transfer response interrupted.', 'Check state before retrying writes.', 6)));
|
|
46
|
-
res.on('end', () => { try {
|
|
47
|
-
const text = Buffer.concat(chunks).toString('utf8');
|
|
48
|
-
if (file) {
|
|
49
|
-
const body = JSON.parse(text);
|
|
50
|
-
if (body.success !== true)
|
|
51
|
-
throw Error();
|
|
52
|
-
resolve(body.data);
|
|
53
|
-
}
|
|
54
|
-
else {
|
|
55
|
-
const lines = text.split('\n').filter(l => l.trim());
|
|
56
|
-
if (lines.length > 10000)
|
|
57
|
-
throw Error();
|
|
58
|
-
resolve({ items: lines.map(line => JSON.parse(line)) });
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
reject(new CtlError('CTL_TRANSFER_FAILED', 'Invalid or failed transfer response.', 'Inspect the App result; do not retry writes blindly.', 5));
|
|
63
|
-
} });
|
|
64
|
-
});
|
|
65
|
-
const wallTimer = setTimeout(() => req.destroy(timeoutError()), options.timeoutMs ?? CTL_TIMEOUT_MS);
|
|
66
|
-
req.once('close', () => clearTimeout(wallTimer));
|
|
67
|
-
req.on('timeout', () => req.destroy(timeoutError()));
|
|
68
|
-
req.on('error', e => reject(networkError(e)));
|
|
69
|
-
if (file) {
|
|
70
|
-
const source = createReadStream(file.file);
|
|
71
|
-
source.on('error', () => req.destroy());
|
|
72
|
-
req.on('close', () => source.destroy());
|
|
73
|
-
source.pipe(req);
|
|
74
|
-
}
|
|
75
|
-
else
|
|
76
|
-
req.end(raw);
|
|
77
|
-
});
|
|
78
|
-
}
|
|
1
|
+
// Compatibility export; NAS connection implementation lives in one module.
|
|
2
|
+
export * from "../nas/transfers.js";
|
package/dist/ctl/transport.d.ts
CHANGED
|
@@ -1,14 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export declare const CTL_TIMEOUT_MS = 62000;
|
|
3
|
-
export declare const APP_COOKIE: unique symbol;
|
|
4
|
-
export interface DsmSession {
|
|
5
|
-
origin: string;
|
|
6
|
-
username: string;
|
|
7
|
-
cookie: string;
|
|
8
|
-
token: string;
|
|
9
|
-
deviceId?: string;
|
|
10
|
-
}
|
|
11
|
-
export type JsonRequest = (path: string, body: URLSearchParams | Record<string, unknown>, session?: DsmSession) => Promise<any>;
|
|
12
|
-
export declare function canonicalOrigin(input: string, tunnel?: boolean): string;
|
|
13
|
-
export declare function createRequest(origin: string, timeoutMs?: number | (() => number), ca?: string, trust?: HostTls): JsonRequest;
|
|
14
|
-
export declare function sessionFetch(url: string, init: RequestInit, session: DsmSession, ca?: string, trust?: HostTls): Promise<Response>;
|
|
1
|
+
export * from "../nas/transport.js";
|
package/dist/ctl/transport.js
CHANGED
|
@@ -1,113 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import http from 'node:http';
|
|
4
|
-
import https from 'node:https';
|
|
5
|
-
import { CtlError } from './errors.js';
|
|
6
|
-
export const CTL_TIMEOUT_MS = 62000;
|
|
7
|
-
export const APP_COOKIE = Symbol("ctlAppCookie");
|
|
8
|
-
export function canonicalOrigin(input, tunnel = false) {
|
|
9
|
-
let url;
|
|
10
|
-
try {
|
|
11
|
-
url = new URL(input);
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
14
|
-
throw new CtlError('CTL_URL_INVALID', 'A NAS origin URL is required.', 'run: synopkgland host add <name> --url https://<nas>:5001');
|
|
15
|
-
}
|
|
16
|
-
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))))
|
|
17
|
-
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');
|
|
18
|
-
return url.origin;
|
|
19
|
-
}
|
|
20
|
-
export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca, trust) {
|
|
21
|
-
return async (path, body, session) => {
|
|
22
|
-
if (!path.startsWith('/') || path.startsWith('//') || path.includes('?') || path.includes('#') || path.includes('\\') || path.includes('%') || path.split('/').some(p => p === '.' || p === '..'))
|
|
23
|
-
throw new CtlError('CTL_PATH_INVALID', 'Unsafe request path.', 'run: synopkgland ctl list --refresh');
|
|
24
|
-
const remaining = typeof timeoutMs === 'function' ? timeoutMs() : timeoutMs;
|
|
25
|
-
if (remaining <= 0)
|
|
26
|
-
throw new CtlError('CTL_NETWORK', 'Request budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
|
|
27
|
-
const url = new URL(path, origin);
|
|
28
|
-
if (url.origin !== origin || (session && session.origin !== origin))
|
|
29
|
-
throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins.', 'run: synopkgland host list', 4);
|
|
30
|
-
const form = body instanceof URLSearchParams;
|
|
31
|
-
const payload = Buffer.from(form ? body.toString() : JSON.stringify(body));
|
|
32
|
-
if (payload.length > 1048576)
|
|
33
|
-
throw new CtlError('CTL_INPUT_TOO_LARGE', 'Request exceeds 1 MiB.', 'Reduce the input.');
|
|
34
|
-
return new Promise((resolve, reject) => {
|
|
35
|
-
const headers = { 'content-type': form ? 'application/x-www-form-urlencoded' : 'application/json', accept: 'application/json', 'content-length': String(payload.length), origin };
|
|
36
|
-
if (session) {
|
|
37
|
-
headers.cookie = session.cookie;
|
|
38
|
-
headers['X-SYNO-TOKEN'] = session.token;
|
|
39
|
-
}
|
|
40
|
-
const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: remaining, ...(ca ? { ca } : {}), ...(url.protocol === 'https:' && trust?.fingerprint ? { agent: pinnedAgent(origin, trust, remaining) } : {}) }, res => {
|
|
41
|
-
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
|
42
|
-
res.resume();
|
|
43
|
-
reject(new CtlError('CTL_HTTP_ERROR', `HTTP ${res.statusCode}; redirects are never followed.`, 'Check the NAS URL and connection.', 6));
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
if (!res.headers['content-type']?.includes('application/json')) {
|
|
47
|
-
res.resume();
|
|
48
|
-
reject(new CtlError('CTL_RESPONSE_INVALID', 'NAS returned non-JSON data.', 'Check the endpoint and installed package.', 5));
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
const chunks = [];
|
|
52
|
-
let length = 0;
|
|
53
|
-
res.on('data', (c) => { length += c.length; if (length > 1048576) {
|
|
54
|
-
res.destroy();
|
|
55
|
-
reject(new CtlError('CTL_RESPONSE_TOO_LARGE', 'Response exceeds 1 MiB.', 'Use a smaller operation.', 5));
|
|
56
|
-
}
|
|
57
|
-
else
|
|
58
|
-
chunks.push(c); });
|
|
59
|
-
res.on('error', () => reject(new CtlError('CTL_NETWORK', 'Response interrupted.', 'Check the NAS connection; state-changing requests are not replayed.', 6)));
|
|
60
|
-
res.on('end', () => {
|
|
61
|
-
try {
|
|
62
|
-
const body = strictJson(Buffer.concat(chunks));
|
|
63
|
-
if (!form && body?.success === true && body.data && path.startsWith('/webman/3rdparty/') && url.protocol === 'https:') {
|
|
64
|
-
const cookies = (res.headers['set-cookie'] ?? []).filter(line => /;\s*Secure(?:;|$)/i.test(line) && /;\s*HttpOnly(?:;|$)/i.test(line) && !/;\s*Domain=/i.test(line));
|
|
65
|
-
const own = path.slice(0, path.lastIndexOf('/cgi/')) + '/';
|
|
66
|
-
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='));
|
|
67
|
-
if (valid.length)
|
|
68
|
-
Object.defineProperty(body, APP_COOKIE, { value: valid.join('; '), enumerable: false });
|
|
69
|
-
}
|
|
70
|
-
resolve(body);
|
|
71
|
-
}
|
|
72
|
-
catch {
|
|
73
|
-
reject(new CtlError('CTL_RESPONSE_INVALID', 'Invalid JSON response.', 'Check the installed package.', 5));
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
const wallTimer = setTimeout(() => req.destroy(timeoutError()), remaining);
|
|
78
|
-
req.once('close', () => clearTimeout(wallTimer));
|
|
79
|
-
req.on('timeout', () => req.destroy(timeoutError()));
|
|
80
|
-
req.on('error', error => reject(networkError(error)));
|
|
81
|
-
req.end(payload);
|
|
82
|
-
});
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
export async function sessionFetch(url, init, session, ca, trust) {
|
|
86
|
-
if (new URL(url).origin !== session.origin)
|
|
87
|
-
throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins', 'Check the registered NAS origin', 4);
|
|
88
|
-
const headers = new Headers(init.headers);
|
|
89
|
-
headers.set('cookie', session.cookie);
|
|
90
|
-
headers.set('X-SYNO-TOKEN', session.token);
|
|
91
|
-
const request = new Request(url, { ...init, headers }), bytes = init.body ? Buffer.from(await request.arrayBuffer()) : undefined;
|
|
92
|
-
return new Promise((resolve, reject) => {
|
|
93
|
-
const target = new URL(url);
|
|
94
|
-
const req = (target.protocol === 'https:' ? https : http).request(target, { method: request.method, headers: Object.fromEntries(request.headers), ca, timeout: 62000, ...(trust?.fingerprint && target.protocol === 'https:' ? { agent: pinnedAgent(target.origin, trust, 62000) } : {}) }, res => {
|
|
95
|
-
const chunks = [];
|
|
96
|
-
let length = 0;
|
|
97
|
-
res.on('data', c => { length += c.length; if (length > 16 * 1024 * 1024)
|
|
98
|
-
res.destroy(new CtlError('CTL_RESPONSE_TOO_LARGE', 'DSM response exceeds 16 MiB', 'Use a narrower operation', 5));
|
|
99
|
-
else
|
|
100
|
-
chunks.push(c); });
|
|
101
|
-
res.on('error', () => reject(new CtlError('DSM_NETWORK', 'DSM response interrupted', 'Check the NAS connection', 6)));
|
|
102
|
-
res.on('end', () => { const headers = new Headers(); for (const [k, v] of Object.entries(res.headers))
|
|
103
|
-
for (const x of Array.isArray(v) ? v : [v])
|
|
104
|
-
if (x !== undefined)
|
|
105
|
-
headers.append(k, x); resolve(new Response(Buffer.concat(chunks), { status: res.statusCode, headers })); });
|
|
106
|
-
});
|
|
107
|
-
const timer = setTimeout(() => req.destroy(timeoutError()), 62000);
|
|
108
|
-
req.once('close', () => clearTimeout(timer));
|
|
109
|
-
req.on('timeout', () => req.destroy(timeoutError()));
|
|
110
|
-
req.on('error', e => reject(networkError(e)));
|
|
111
|
-
req.end(bytes);
|
|
112
|
-
});
|
|
113
|
-
}
|
|
1
|
+
// Compatibility export; NAS connection implementation lives in one module.
|
|
2
|
+
export * from "../nas/transport.js";
|
package/dist/e2e/dsm.d.ts
CHANGED
|
@@ -129,10 +129,10 @@ export declare function dismissNotifications(page?: Page, env?: Env): Promise<nu
|
|
|
129
129
|
/**
|
|
130
130
|
* Whether the signed-in desktop's session is usable: it holds a SynoToken
|
|
131
131
|
* (a restored `stay_login` cookie alone yields none, and the app's CGIs then
|
|
132
|
-
* answer 119) and DSM's login.cgi, asked
|
|
132
|
+
* answer 119) and DSM's login.cgi, asked through the shared NAS transport, still accepts the
|
|
133
133
|
* session. Any failure to ask counts as not live.
|
|
134
134
|
*/
|
|
135
|
-
export declare function dsmSessionLive(page: Pick<SessionPage, "evaluate"
|
|
135
|
+
export declare function dsmSessionLive(page: Pick<SessionPage, "evaluate">, check?: (token: string) => Promise<boolean>, env?: Env): Promise<boolean>;
|
|
136
136
|
/**
|
|
137
137
|
* `app.open(path)`, once more after net::ERR_ABORTED: about one first
|
|
138
138
|
* navigation in ten was aborted as DSM's desktop navigated on its own, and the
|
package/dist/e2e/dsm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// DSM helpers for package e2e suites on upstream `e2e` + `@e2e-dev/web`.
|
|
2
|
-
//
|
|
2
|
+
// Import through @synopackageland/cli/e2e to share the CLI NAS connection path.
|
|
3
3
|
// It signs in once per run (a saved, private profile is restored when still valid),
|
|
4
4
|
// opens DSM app windows and Application Portal pages (accepting their native
|
|
5
5
|
// dialogs when given the `browser` fixture), dismisses DSM notifications,
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
// e2e output, which is made private (directories 700, files 600).
|
|
10
10
|
//
|
|
11
11
|
// node tests/e2e/support/dsm.ts tunnel -- npx e2e run
|
|
12
|
+
import { loginDsm } from "../nas/auth.js";
|
|
13
|
+
import { sessionFetch } from "../nas/transport.js";
|
|
14
|
+
import { canonicalOrigin } from "../nas/transport.js";
|
|
12
15
|
import { spawn } from "node:child_process";
|
|
13
16
|
import { randomUUID } from "node:crypto";
|
|
14
17
|
import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -389,16 +392,25 @@ export async function dismissNotifications(page = dsmPage(), env = process.env)
|
|
|
389
392
|
/**
|
|
390
393
|
* Whether the signed-in desktop's session is usable: it holds a SynoToken
|
|
391
394
|
* (a restored `stay_login` cookie alone yields none, and the app's CGIs then
|
|
392
|
-
* answer 119) and DSM's login.cgi, asked
|
|
395
|
+
* answer 119) and DSM's login.cgi, asked through the shared NAS transport, still accepts the
|
|
393
396
|
* session. Any failure to ask counts as not live.
|
|
394
397
|
*/
|
|
395
|
-
export async function dsmSessionLive(page) {
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
398
|
+
export async function dsmSessionLive(page, check, env = process.env) {
|
|
399
|
+
const token = await page.evaluate(() => window.SYNO?.SDS?.Session?.SynoToken).catch(() => undefined);
|
|
400
|
+
if (typeof token !== 'string' || !token)
|
|
401
|
+
return false;
|
|
402
|
+
try {
|
|
403
|
+
if (check)
|
|
404
|
+
return await check(token);
|
|
405
|
+
const state = await surface().context().storageState();
|
|
406
|
+
const cookie = state.cookies.filter(c => sessionCookieNames.includes(c.name)).map(c => `${c.name}=${c.value}`).join('; ');
|
|
407
|
+
const origin = canonicalOrigin(dsmUrl(env), true);
|
|
408
|
+
const response = await sessionFetch(origin + '/webman/login.cgi', {}, { origin, username: 'e2e', cookie, token });
|
|
409
|
+
return (await response.json())?.success === true;
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
402
414
|
}
|
|
403
415
|
function accountFile(user, env) {
|
|
404
416
|
const key = `DSM_E2E_ACCOUNT_${user.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
|
|
@@ -447,8 +459,8 @@ export async function openUntilLoaded(app, path, { timeoutMs = 30_000, pauseMs =
|
|
|
447
459
|
}
|
|
448
460
|
}
|
|
449
461
|
}
|
|
450
|
-
async function restoredSessionLive(page) {
|
|
451
|
-
return await desktopOrLogin(page) === "desktop" && await dsmSessionLive(page);
|
|
462
|
+
async function restoredSessionLive(page, env) {
|
|
463
|
+
return await desktopOrLogin(page) === "desktop" && await dsmSessionLive(page, undefined, env);
|
|
452
464
|
}
|
|
453
465
|
/**
|
|
454
466
|
* Restores the saved profile of `user`; signs in with the account file only
|
|
@@ -466,18 +478,22 @@ export async function signIn(app, user = "admin", env = process.env) {
|
|
|
466
478
|
await openPath(app, "/");
|
|
467
479
|
const page = dsmPage();
|
|
468
480
|
let outcome = "restored";
|
|
469
|
-
if (!profile || !await restoredSessionLive(page)) {
|
|
481
|
+
if (!profile || !await restoredSessionLive(page, env)) {
|
|
470
482
|
// A refused session still shows the desktop; dropping its cookies shows the login form.
|
|
471
483
|
if (profile) {
|
|
472
484
|
await context.clearCookies();
|
|
473
485
|
await openUntilLoaded(app, "/");
|
|
474
486
|
}
|
|
475
487
|
const account = readAccount(accountFile(user, env), user);
|
|
476
|
-
await
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
await
|
|
480
|
-
await
|
|
488
|
+
const session = await loginDsm(canonicalOrigin(url, true), account.username, account.password, {
|
|
489
|
+
remember: false, otp: async () => env.DSM_OTP ?? '',
|
|
490
|
+
});
|
|
491
|
+
const state = await context.storageState();
|
|
492
|
+
await context.setStorageState({ ...state, cookies: [...state.cookies, {
|
|
493
|
+
name: 'id', value: session.cookie.slice(3), domain: new URL(url).hostname, path: '/',
|
|
494
|
+
httpOnly: true, secure: new URL(url).protocol === 'https:', sameSite: 'Lax', expires: -1,
|
|
495
|
+
}] });
|
|
496
|
+
await openUntilLoaded(app, '/');
|
|
481
497
|
outcome = "signed-in";
|
|
482
498
|
}
|
|
483
499
|
await waitForDesktop(page);
|
package/dist/error-registry.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { COMPOSE_ERRORS } from "./standalone/compose-errors.js";
|
|
2
2
|
export const ERROR_REGISTRY = {
|
|
3
3
|
...COMPOSE_ERRORS,
|
|
4
|
-
HOST_CERT_CHANGED: { "code": "HOST_CERT_CHANGED", "description": "
|
|
5
|
-
HOST_TLS_UNTRUSTED: { "code": "HOST_TLS_UNTRUSTED", "description": "
|
|
4
|
+
HOST_CERT_CHANGED: { "code": "HOST_CERT_CHANGED", "description": "Strict TLS: the NAS certificate differs from the recorded SHA-256", "suggestion": "Verify old/new fingerprints, then run synopkgland host trust <name>" },
|
|
5
|
+
HOST_TLS_UNTRUSTED: { "code": "HOST_TLS_UNTRUSTED", "description": "Strict TLS: the NAS certificate failed CA, hostname or validity verification", "suggestion": "Check the CA file, NAS hostname and certificate validity, or omit strict TLS" },
|
|
6
6
|
HOST_DNS_FAILED: { "code": "HOST_DNS_FAILED", "description": "The NAS hostname could not be resolved", "suggestion": "Check the registered hostname and DNS" },
|
|
7
7
|
HOST_CONNECTION_REFUSED: { "code": "HOST_CONNECTION_REFUSED", "description": "The NAS refused the connection", "suggestion": "Check the DSM port and NAS availability" },
|
|
8
8
|
HOST_TIMEOUT: { "code": "HOST_TIMEOUT", "description": "The NAS request timed out", "suggestion": "Check connection and firewall; inspect state before retrying writes" },
|
|
@@ -1000,8 +1000,8 @@ export const ERROR_REGISTRY = {
|
|
|
1000
1000
|
},
|
|
1001
1001
|
DSM_TLS_UNTRUSTED: {
|
|
1002
1002
|
code: "DSM_TLS_UNTRUSTED",
|
|
1003
|
-
description: "
|
|
1004
|
-
suggestion: "
|
|
1003
|
+
description: "Strict TLS: the DSM certificate failed CA, hostname or validity verification",
|
|
1004
|
+
suggestion: "Check the --ca PEM file, NAS hostname and certificate validity; NAS certificates are accepted by default without --strict-tls or --ca",
|
|
1005
1005
|
},
|
|
1006
1006
|
STANDALONE_ICON_INVALID: {
|
|
1007
1007
|
code: "STANDALONE_ICON_INVALID",
|
|
@@ -1,9 +1 @@
|
|
|
1
|
-
|
|
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>;
|
|
1
|
+
export * from "../nas/connection.js";
|
package/dist/host/connection.js
CHANGED
|
@@ -1,49 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { resolveHostCredentials } from "./credentials.js";
|
|
4
|
-
import { registerDsmSession } from "./dsm-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);
|
|
9
|
-
const request = createRequest(session.origin, 62000, credentials.ca, {
|
|
10
|
-
fingerprint: credentials.fingerprint,
|
|
11
|
-
name: credentials.hostName,
|
|
12
|
-
});
|
|
13
|
-
return {
|
|
14
|
-
name: credentials.hostName,
|
|
15
|
-
session,
|
|
16
|
-
request,
|
|
17
|
-
call: async (api, method, version, params = {}) => {
|
|
18
|
-
const body = await request("/webapi/entry.cgi", new URLSearchParams({
|
|
19
|
-
api,
|
|
20
|
-
method,
|
|
21
|
-
version: String(version),
|
|
22
|
-
...params,
|
|
23
|
-
}), session);
|
|
24
|
-
const code = Number(body?.error?.code);
|
|
25
|
-
if (body?.success !== true)
|
|
26
|
-
throw new CtlError([106, 107, 119].includes(code)
|
|
27
|
-
? "CTL_NOT_AUTHENTICATED"
|
|
28
|
-
: code === 105
|
|
29
|
-
? "NAS_ADMIN_REQUIRED"
|
|
30
|
-
: "DSM_API_FAILED", `DSM ${api}/${method} failed (${code || "invalid response"})`, [106, 107, 119].includes(code)
|
|
31
|
-
? `run: synopkgland host login ${credentials.hostName}`
|
|
32
|
-
: code === 105
|
|
33
|
-
? "Log in with a DSM administrator account"
|
|
34
|
-
: "Check DSM permissions and Package Center", 2);
|
|
35
|
-
return body.data;
|
|
36
|
-
},
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
export async function requireAdministrator(nas) {
|
|
40
|
-
const data = await nas.call("SYNO.Core.Group.Member", "list", 1, {
|
|
41
|
-
group: "administrators",
|
|
42
|
-
offset: "0",
|
|
43
|
-
limit: "-1",
|
|
44
|
-
});
|
|
45
|
-
const members = data?.users;
|
|
46
|
-
if (!Array.isArray(members) ||
|
|
47
|
-
!members.some((u) => u.name === nas.session.username))
|
|
48
|
-
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);
|
|
49
|
-
}
|
|
1
|
+
// Compatibility export; NAS connection implementation lives in one module.
|
|
2
|
+
export * from "../nas/connection.js";
|
|
@@ -1,4 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import type { HostCredentials } from "./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;
|
|
1
|
+
export * from "../nas/credentials.js";
|
package/dist/host/credentials.js
CHANGED
|
@@ -1,32 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { canonicalOrigin } from "../ctl/transport.js";
|
|
4
|
-
import { loginDsm } from "../ctl/dsm-auth.js";
|
|
5
|
-
/** Host metadata is public; session carriers are loaded only from secure storage. */
|
|
6
|
-
export function resolveHostCredentials(name, store = new CtlStore()) {
|
|
7
|
-
if (!name &&
|
|
8
|
-
!store.list().length &&
|
|
9
|
-
process.env.DSM_URL &&
|
|
10
|
-
process.env.DSM_USERNAME &&
|
|
11
|
-
process.env.DSM_PASSWORD) {
|
|
12
|
-
const origin = canonicalOrigin(process.env.DSM_URL);
|
|
13
|
-
return {
|
|
14
|
-
dsmOrigin: origin,
|
|
15
|
-
username: process.env.DSM_USERNAME,
|
|
16
|
-
hostName: "env",
|
|
17
|
-
loadSession: () => loginDsm(origin, process.env.DSM_USERNAME, process.env.DSM_PASSWORD, {
|
|
18
|
-
remember: false,
|
|
19
|
-
otp: async () => process.env.DSM_OTP ?? "",
|
|
20
|
-
}),
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
const host = store.select(name);
|
|
24
|
-
return {
|
|
25
|
-
dsmOrigin: host.origin,
|
|
26
|
-
username: host.username,
|
|
27
|
-
hostName: host.name,
|
|
28
|
-
fingerprint: host.fingerprint,
|
|
29
|
-
ca: host.ca ? readFileSync(host.ca, "utf8") : undefined,
|
|
30
|
-
loadSession: () => store.session(host),
|
|
31
|
-
};
|
|
32
|
-
}
|
|
1
|
+
// Compatibility export; NAS connection implementation lives in one module.
|
|
2
|
+
export * from "../nas/credentials.js";
|
package/dist/host/dsm-client.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { sessionForCredentials } from "../nas/credentials.js";
|
|
1
2
|
import { CtlError } from '../ctl/errors.js';
|
|
2
3
|
import { listShareNames, lookupSharePath, } from "./share-lookup.js";
|
|
3
4
|
import { createDsmApiCall, fetchSharePermissions, fetchUserGroups, } from "./share-permission-lookup.js";
|
|
4
5
|
import { fetchPortInfo } from "./port-info-lookup.js";
|
|
5
|
-
import { dsmFetch
|
|
6
|
+
import { dsmFetch } from "./dsm-http.js";
|
|
6
7
|
import { versionUnsupported } from "../runtime/dsm-version.js";
|
|
7
8
|
import { fetchDsmPortProfileInventory } from "./dsm-port-profile.js";
|
|
8
9
|
import { installPackageFromLocalSpk, fetchInstalledPackageState, } from "./catalog-install.js";
|
|
@@ -56,34 +57,10 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
56
57
|
const dsmOrigin = credentials.dsmOrigin.replace(/\/$/, "");
|
|
57
58
|
let cookieHeader = credentials.cookieHeader;
|
|
58
59
|
const ensureLogin = async () => {
|
|
59
|
-
if (
|
|
60
|
-
const session = await credentials
|
|
60
|
+
if (!cookieHeader) {
|
|
61
|
+
const session = await sessionForCredentials(credentials);
|
|
61
62
|
cookieHeader = session.cookie;
|
|
62
|
-
registerDsmSession(session, credentials.hostName ?? profileName ?? 'env', credentials.ca, credentials.fingerprint);
|
|
63
63
|
}
|
|
64
|
-
if (cookieHeader) {
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
const query = new URLSearchParams({
|
|
68
|
-
api: "SYNO.API.Auth",
|
|
69
|
-
version: "3",
|
|
70
|
-
method: "login",
|
|
71
|
-
account: credentials.username,
|
|
72
|
-
passwd: credentials.password ?? "",
|
|
73
|
-
session: "FileStation",
|
|
74
|
-
format: "cookie",
|
|
75
|
-
});
|
|
76
|
-
const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi`, {
|
|
77
|
-
method: "POST",
|
|
78
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
79
|
-
body: query,
|
|
80
|
-
});
|
|
81
|
-
const login = (await response.json());
|
|
82
|
-
if (!response.ok || login.success !== true)
|
|
83
|
-
throw new Error("DSM login failed");
|
|
84
|
-
cookieHeader = setCookiePairs(response.headers).join("; ");
|
|
85
|
-
if (!cookieHeader)
|
|
86
|
-
throw new Error("DSM login returned no session cookie");
|
|
87
64
|
};
|
|
88
65
|
/**
|
|
89
66
|
* One read-only call of the App's own CGI as the administrator. identity:
|
package/dist/host/dsm-http.d.ts
CHANGED
|
@@ -1,3 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function registerDsmSession(session: DsmSession, name: string, ca?: string, fingerprint?: string): void;
|
|
3
|
-
export declare function dsmFetch(url: string, init?: RequestInit): Promise<Response>;
|
|
1
|
+
export { dsmFetch, registerDsmSession } from '../nas/http.js';
|