@synopackageland/cli 0.3.8 → 0.3.10
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/dist/cli.js +3 -0
- package/dist/deploy/deploy.d.ts +2 -0
- package/dist/deploy/deploy.js +32 -0
- package/dist/deploy/port-preflight.d.ts +21 -0
- package/dist/deploy/port-preflight.js +42 -0
- package/dist/deploy/share-permission-preflight.d.ts +37 -0
- package/dist/deploy/share-permission-preflight.js +81 -0
- package/dist/doctor.js +101 -0
- package/dist/error-registry.js +10 -0
- package/dist/host/dsm-client.js +27 -0
- package/dist/host/dsm-port-profile.d.ts +48 -0
- package/dist/host/dsm-port-profile.js +208 -0
- package/dist/host/fake-nas.d.ts +23 -0
- package/dist/host/fake-nas.js +50 -0
- package/dist/host/port-info-lookup.d.ts +9 -0
- package/dist/host/port-info-lookup.js +87 -0
- package/dist/host/port-info.d.ts +33 -0
- package/dist/host/port-info.js +66 -0
- package/dist/host/share-permission-lookup.d.ts +23 -0
- package/dist/host/share-permission-lookup.js +149 -0
- package/dist/host/share-permission.d.ts +43 -0
- package/dist/host/share-permission.js +59 -0
- package/dist/host/types.d.ts +19 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -274,6 +274,9 @@ export async function runCli(argv) {
|
|
|
274
274
|
console.error(`[${result.code ?? "DEPLOY_FAILED"}] ${result.message ?? "部署失敗。"}`);
|
|
275
275
|
process.exit(1);
|
|
276
276
|
}
|
|
277
|
+
for (const note of result.notes ?? []) {
|
|
278
|
+
console.warn(`[注意] ${note}`);
|
|
279
|
+
}
|
|
277
280
|
console.log(`已部署 ${result.packageId}`);
|
|
278
281
|
if (result.openUrl) {
|
|
279
282
|
console.log(`Open URL:${result.openUrl}`);
|
package/dist/deploy/deploy.d.ts
CHANGED
|
@@ -24,5 +24,7 @@ export interface DeployProjectResult {
|
|
|
24
24
|
spkPath?: string;
|
|
25
25
|
openUrl?: string;
|
|
26
26
|
catalogVersion?: string;
|
|
27
|
+
/** Non-blocking pre-flight remarks, e.g. a read-only Shared Folder grant. */
|
|
28
|
+
notes?: string[];
|
|
27
29
|
}
|
|
28
30
|
export declare function deployProject(options: DeployProjectOptions): Promise<DeployProjectResult>;
|
package/dist/deploy/deploy.js
CHANGED
|
@@ -9,9 +9,12 @@ import { resolveDeployIdentity, PACKAGE_IDENTITY_MAX_LENGTH } from "./identity.j
|
|
|
9
9
|
import { resolveDeploySpkVersion } from "./spk-version.js";
|
|
10
10
|
import { resolveSharedFolderExtraValues, sharedFolderParameterNames } from "./share-values.js";
|
|
11
11
|
import { resolveDsmUserExtraValues, dsmUserParameterNames } from "./run-as-values.js";
|
|
12
|
+
import { runSharePermissionPreflight } from "./share-permission-preflight.js";
|
|
12
13
|
import { homesParameterNames, resolveHomesExtraValues } from "./homes-values.js";
|
|
13
14
|
import { resolveInstallParameterExtraValues } from "./param-values.js";
|
|
14
15
|
import { CURATED_BUILD_OFFSET } from "@synopackageland/package-source/index-manifest";
|
|
16
|
+
import { loadCompose } from "../compose/analyze.js";
|
|
17
|
+
import { runPortPreflight } from "./port-preflight.js";
|
|
15
18
|
const CURATED_DEPLOY_REFUSED_MESSAGE = "此 NAS 已持有 Curated App Package Attestation。請改用 --dev,或使用 --replace-curated 明確降級。";
|
|
16
19
|
const POSIX_ALL_SHARES_LOOKUP_FAILED = "DEPLOY_POSIX_ALL_SHARES_LOOKUP_FAILED";
|
|
17
20
|
function brokerCapabilitiesFromContract(project) {
|
|
@@ -124,6 +127,34 @@ export async function deployProject(options) {
|
|
|
124
127
|
if (!runAsResolved.ok) {
|
|
125
128
|
return { ok: false, code: runAsResolved.code, message: runAsResolved.message };
|
|
126
129
|
}
|
|
130
|
+
// A42 pre-flight: preinst runs as the package user and can never read ACL, so
|
|
131
|
+
// the only place that can catch "selected user cannot reach selected Share" is
|
|
132
|
+
// here, where we still hold an administrator session. Refuses before install.
|
|
133
|
+
const sharePermission = await runSharePermissionPreflight({
|
|
134
|
+
shares: Object.fromEntries(sharedFolderParameterNames(contract)
|
|
135
|
+
.map((key) => [key, (options.shareValues ?? {})[key] ?? ""])
|
|
136
|
+
.filter(([, value]) => value.trim() !== "")),
|
|
137
|
+
runAsUsers: runAsResolved.extraValues,
|
|
138
|
+
listSharePermissions: options.host.listSharePermissions
|
|
139
|
+
? (name) => options.host.listSharePermissions(name)
|
|
140
|
+
: undefined,
|
|
141
|
+
listUserGroups: options.host.listUserGroups
|
|
142
|
+
? (name) => options.host.listUserGroups(name)
|
|
143
|
+
: undefined,
|
|
144
|
+
});
|
|
145
|
+
if (!sharePermission.ok) {
|
|
146
|
+
return { ok: false, code: sharePermission.code, message: sharePermission.message };
|
|
147
|
+
}
|
|
148
|
+
const portPreflight = await runPortPreflight({
|
|
149
|
+
compose: loadCompose(options.project.composePath)?.compose,
|
|
150
|
+
packageIdentity: identity.packageId,
|
|
151
|
+
listPortInfo: options.host.listPortInfo
|
|
152
|
+
? () => options.host.listPortInfo()
|
|
153
|
+
: undefined,
|
|
154
|
+
});
|
|
155
|
+
if (!portPreflight.ok) {
|
|
156
|
+
return { ok: false, code: portPreflight.code, message: portPreflight.message };
|
|
157
|
+
}
|
|
127
158
|
const extraValues = {
|
|
128
159
|
...paramResolved.extraValues,
|
|
129
160
|
...homesResolved.extraValues,
|
|
@@ -273,5 +304,6 @@ export async function deployProject(options) {
|
|
|
273
304
|
spkPath: build.outputPath,
|
|
274
305
|
openUrl: info.openUrl ?? appOriginFromContract(options.host.dsmOrigin, contract),
|
|
275
306
|
catalogVersion,
|
|
307
|
+
notes: sharePermission.notes.length > 0 ? sharePermission.notes : undefined,
|
|
276
308
|
};
|
|
277
309
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ComposeDocument } from "../types.js";
|
|
2
|
+
import { type PortInfoEntry } from "../host/port-info.js";
|
|
3
|
+
export declare const PORT_ALREADY_IN_USE = "PORT_ALREADY_IN_USE";
|
|
4
|
+
export interface PortPreflightInput {
|
|
5
|
+
compose?: ComposeDocument;
|
|
6
|
+
packageIdentity: string;
|
|
7
|
+
listPortInfo?: () => Promise<PortInfoEntry[] | undefined>;
|
|
8
|
+
}
|
|
9
|
+
export type PortPreflightResult = {
|
|
10
|
+
ok: true;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
code: typeof PORT_ALREADY_IN_USE;
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Checks Compose's published TCP host ports against DSM's administrator-only
|
|
18
|
+
* PortInfo listing. Missing capability, transport failure, malformed data,
|
|
19
|
+
* and an empty listing all mean "cannot tell" and therefore pass through.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runPortPreflight(input: PortPreflightInput): Promise<PortPreflightResult>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { collectPublishedTcpPorts } from "../compose/analyze.js";
|
|
2
|
+
import { findPortConflicts, formatPortOccupant, } from "../host/port-info.js";
|
|
3
|
+
export const PORT_ALREADY_IN_USE = "PORT_ALREADY_IN_USE";
|
|
4
|
+
/**
|
|
5
|
+
* Checks Compose's published TCP host ports against DSM's administrator-only
|
|
6
|
+
* PortInfo listing. Missing capability, transport failure, malformed data,
|
|
7
|
+
* and an empty listing all mean "cannot tell" and therefore pass through.
|
|
8
|
+
*/
|
|
9
|
+
export async function runPortPreflight(input) {
|
|
10
|
+
const publishedPorts = input.compose
|
|
11
|
+
? collectPublishedTcpPorts(input.compose)
|
|
12
|
+
: new Map();
|
|
13
|
+
if (publishedPorts.size === 0 || !input.listPortInfo) {
|
|
14
|
+
return { ok: true };
|
|
15
|
+
}
|
|
16
|
+
let portInfo;
|
|
17
|
+
try {
|
|
18
|
+
portInfo = await input.listPortInfo();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
portInfo = undefined;
|
|
22
|
+
}
|
|
23
|
+
if (!portInfo || portInfo.length === 0) {
|
|
24
|
+
return { ok: true };
|
|
25
|
+
}
|
|
26
|
+
const conflicts = findPortConflicts({
|
|
27
|
+
publishedPorts,
|
|
28
|
+
packageIdentity: input.packageIdentity,
|
|
29
|
+
portInfo,
|
|
30
|
+
});
|
|
31
|
+
const conflict = conflicts[0];
|
|
32
|
+
if (!conflict) {
|
|
33
|
+
return { ok: true };
|
|
34
|
+
}
|
|
35
|
+
const occupants = conflict.occupants.map(formatPortOccupant).join("、");
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
code: PORT_ALREADY_IN_USE,
|
|
39
|
+
message: `Published port ${conflict.port} 已被 ${occupants} 佔用(Compose service:${conflict.services.join("、")})。` +
|
|
40
|
+
"請改用未被其他服務使用的 host port;同一 Package Identity 的既有容器會在升級/重裝時放行。",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type SharePermissionRow } from "../host/share-permission.js";
|
|
2
|
+
export declare const SHARE_RUN_AS_NO_ACCESS = "SHARE_RUN_AS_NO_ACCESS";
|
|
3
|
+
export interface SharePermissionListing {
|
|
4
|
+
users: SharePermissionRow[];
|
|
5
|
+
groups: SharePermissionRow[];
|
|
6
|
+
}
|
|
7
|
+
export interface SharePermissionPreflightInput {
|
|
8
|
+
/** Logical Share names to check, keyed by the contract parameter name. */
|
|
9
|
+
shares: Record<string, string>;
|
|
10
|
+
/** Selected DSM user logical names, keyed by the contract parameter name. */
|
|
11
|
+
runAsUsers: Record<string, string>;
|
|
12
|
+
listSharePermissions?: (shareName: string) => Promise<SharePermissionListing | undefined>;
|
|
13
|
+
listUserGroups?: (userName: string) => Promise<string[] | undefined>;
|
|
14
|
+
}
|
|
15
|
+
export type SharePermissionPreflightResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
notes: string[];
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
code: string;
|
|
21
|
+
message: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Ask the NAS, with the administrator session `deploy` already holds, whether
|
|
25
|
+
* every selected Run-as user can reach every selected Shared Folder.
|
|
26
|
+
*
|
|
27
|
+
* Deliberate boundaries:
|
|
28
|
+
* - This is a pre-flight, not enforcement. Package Center installs do not come
|
|
29
|
+
* through here, and share permission can change after the install.
|
|
30
|
+
* - "Cannot tell" never becomes a refusal: a host without the capability, an
|
|
31
|
+
* API that failed, an empty listing, or an advanced/Windows ACL (`is_custom`,
|
|
32
|
+
* readable only by root-only synoacltool) all pass through.
|
|
33
|
+
* - `posix_all_shares` is out of scope on purpose. It means "mount every Share,
|
|
34
|
+
* read-only" -- gating it on permission would refuse almost every install,
|
|
35
|
+
* and the read-only bind already limits the damage.
|
|
36
|
+
*/
|
|
37
|
+
export declare function runSharePermissionPreflight(input: SharePermissionPreflightInput): Promise<SharePermissionPreflightResult>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { evaluateShareAccess } from "../host/share-permission.js";
|
|
2
|
+
export const SHARE_RUN_AS_NO_ACCESS = "SHARE_RUN_AS_NO_ACCESS";
|
|
3
|
+
/**
|
|
4
|
+
* Ask the NAS, with the administrator session `deploy` already holds, whether
|
|
5
|
+
* every selected Run-as user can reach every selected Shared Folder.
|
|
6
|
+
*
|
|
7
|
+
* Deliberate boundaries:
|
|
8
|
+
* - This is a pre-flight, not enforcement. Package Center installs do not come
|
|
9
|
+
* through here, and share permission can change after the install.
|
|
10
|
+
* - "Cannot tell" never becomes a refusal: a host without the capability, an
|
|
11
|
+
* API that failed, an empty listing, or an advanced/Windows ACL (`is_custom`,
|
|
12
|
+
* readable only by root-only synoacltool) all pass through.
|
|
13
|
+
* - `posix_all_shares` is out of scope on purpose. It means "mount every Share,
|
|
14
|
+
* read-only" -- gating it on permission would refuse almost every install,
|
|
15
|
+
* and the read-only bind already limits the damage.
|
|
16
|
+
*/
|
|
17
|
+
export async function runSharePermissionPreflight(input) {
|
|
18
|
+
const shareEntries = Object.entries(input.shares).filter(([, name]) => name.trim() !== "");
|
|
19
|
+
const userEntries = Object.entries(input.runAsUsers).filter(([, name]) => name.trim() !== "");
|
|
20
|
+
if (shareEntries.length === 0 || userEntries.length === 0) {
|
|
21
|
+
return { ok: true, notes: [] };
|
|
22
|
+
}
|
|
23
|
+
if (!input.listSharePermissions || !input.listUserGroups) {
|
|
24
|
+
return { ok: true, notes: [] };
|
|
25
|
+
}
|
|
26
|
+
const notes = [];
|
|
27
|
+
const groupCache = new Map();
|
|
28
|
+
const listingCache = new Map();
|
|
29
|
+
for (const [shareKey, shareName] of shareEntries) {
|
|
30
|
+
let listing = listingCache.get(shareName);
|
|
31
|
+
if (!listingCache.has(shareName)) {
|
|
32
|
+
try {
|
|
33
|
+
listing = await input.listSharePermissions(shareName);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
listing = undefined;
|
|
37
|
+
}
|
|
38
|
+
listingCache.set(shareName, listing);
|
|
39
|
+
}
|
|
40
|
+
if (!listing) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const [userKey, userName] of userEntries) {
|
|
44
|
+
let groups = groupCache.get(userName);
|
|
45
|
+
if (!groupCache.has(userName)) {
|
|
46
|
+
try {
|
|
47
|
+
groups = await input.listUserGroups(userName);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
groups = undefined;
|
|
51
|
+
}
|
|
52
|
+
groupCache.set(userName, groups);
|
|
53
|
+
}
|
|
54
|
+
if (!groups) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const verdict = evaluateShareAccess({
|
|
58
|
+
userName,
|
|
59
|
+
userGroups: groups,
|
|
60
|
+
userRows: listing.users,
|
|
61
|
+
groupRows: listing.groups,
|
|
62
|
+
});
|
|
63
|
+
if (verdict.access === "denied" || verdict.access === "none") {
|
|
64
|
+
const cause = verdict.access === "denied"
|
|
65
|
+
? `被「${verdict.deniedBy}」明確拒絕`
|
|
66
|
+
: "在使用者本身與其所屬群組都沒有任何授權";
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
code: SHARE_RUN_AS_NO_ACCESS,
|
|
70
|
+
message: `Container Run-as「${userKey}=${userName}」對 Shared Folder「${shareKey}=${shareName}」${cause}。` +
|
|
71
|
+
"容器會以該使用者的身分掛載這個 Share,安裝雖然會成功,但讀寫會在 runtime 拿到 Permission denied。" +
|
|
72
|
+
`請先在 DSM 控制台把 ${shareName} 的權限給 ${userName},或改選另一個使用者/Share。`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (verdict.access === "read") {
|
|
76
|
+
notes.push(`Run-as「${userName}」對 Shared Folder「${shareName}」只有唯讀權限(來源 ${verdict.grantedBy})。若 App 需要寫入會在 runtime 失敗。`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, notes };
|
|
81
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -6,6 +6,7 @@ import { loadContract } from "./contract/load.js";
|
|
|
6
6
|
import { getErrorCode } from "./error-registry.js";
|
|
7
7
|
import { dsmFetch } from "./host/dsm-http.js";
|
|
8
8
|
import { appOriginFromContract } from "./host/open-url.js";
|
|
9
|
+
import { DSM_PORT_PROFILE_PRECISION_WARNING, } from "./host/dsm-port-profile.js";
|
|
9
10
|
function packageRouteFromDiagnostics(diagnostics) {
|
|
10
11
|
switch (diagnostics?.packageRoute) {
|
|
11
12
|
case "package-only":
|
|
@@ -67,6 +68,98 @@ function packageFailureSuggestion(state) {
|
|
|
67
68
|
: "DSM API 的 worker_msg 實測為空或未提供;";
|
|
68
69
|
return `${diagnosis} ${workerMessage}worker 內部完整訊息只存在 NAS 的 /var/log/synoplugin.log(需 root),doctor 不會假裝取得。`;
|
|
69
70
|
}
|
|
71
|
+
function formatDsmPortProfile(profile) {
|
|
72
|
+
const ports = profile.dstPorts
|
|
73
|
+
.map((range) => range.start === range.end ? String(range.start) : `${range.start}-${range.end}`)
|
|
74
|
+
.join(",");
|
|
75
|
+
return `${profile.containerName} (project ${profile.projectName}, port ${ports})`;
|
|
76
|
+
}
|
|
77
|
+
function formatDsmPortProfiles(profiles) {
|
|
78
|
+
return profiles.map(formatDsmPortProfile).join(";");
|
|
79
|
+
}
|
|
80
|
+
function brokerPortProfileNames(residue) {
|
|
81
|
+
return new Set((residue?.items ?? [])
|
|
82
|
+
.filter((item) => item.kind === "port_profile_sc")
|
|
83
|
+
.map((item) => item.name));
|
|
84
|
+
}
|
|
85
|
+
async function appendDsmPortProfileCheck(checks, host, options) {
|
|
86
|
+
// The API fallback is authoritative only when the Broker is absent. An
|
|
87
|
+
// installed-but-unready Broker has no trustworthy residue conclusion, so do
|
|
88
|
+
// not silently substitute a lower-precision result for it.
|
|
89
|
+
if (!options.brokerReady && options.brokerInstalled !== false) {
|
|
90
|
+
checks.push({
|
|
91
|
+
id: "host.dsm_port_profile",
|
|
92
|
+
status: "skip",
|
|
93
|
+
message: "Broker 未就緒,略過 DSM PortInfo port profile 盤點",
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!host.getDsmPortProfileInventory) {
|
|
98
|
+
checks.push({
|
|
99
|
+
id: "host.dsm_port_profile",
|
|
100
|
+
status: "skip",
|
|
101
|
+
message: "DSM PortInfo API 能力不可用,略過 port profile 盤點",
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let inventory;
|
|
106
|
+
try {
|
|
107
|
+
inventory = await host.getDsmPortProfileInventory();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
inventory = undefined;
|
|
111
|
+
}
|
|
112
|
+
if (!inventory) {
|
|
113
|
+
checks.push({
|
|
114
|
+
id: "host.dsm_port_profile",
|
|
115
|
+
status: "skip",
|
|
116
|
+
message: "DSM PortInfo API 或已安裝套件清單無法取得,略過 port profile 盤點",
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (options.brokerReady) {
|
|
121
|
+
const apiNames = new Set(inventory.orphaned.map((profile) => profile.containerName));
|
|
122
|
+
const brokerNames = brokerPortProfileNames(options.brokerResidue);
|
|
123
|
+
const agrees = options.brokerResidue !== undefined &&
|
|
124
|
+
apiNames.size === brokerNames.size &&
|
|
125
|
+
[...apiNames].every((name) => brokerNames.has(name));
|
|
126
|
+
if (agrees) {
|
|
127
|
+
checks.push({
|
|
128
|
+
id: "host.dsm_port_profile",
|
|
129
|
+
status: "pass",
|
|
130
|
+
message: `DSM PortInfo API 交叉檢查與 Broker 的 port profile 結果一致(${apiNames.size} 筆;Broker 判定優先)`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const apiDetail = inventory.orphaned.length > 0
|
|
135
|
+
? `API 候選 ${inventory.orphaned.length} 筆:${formatDsmPortProfiles(inventory.orphaned)}`
|
|
136
|
+
: "API 未發現候選";
|
|
137
|
+
checks.push({
|
|
138
|
+
id: "host.dsm_port_profile",
|
|
139
|
+
status: "skip",
|
|
140
|
+
message: `DSM PortInfo API 交叉檢查${apiDetail};Broker 判定優先,未覆蓋 host.dsm_residue(${DSM_PORT_PROFILE_PRECISION_WARNING})`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// No Broker: this is the primary result, but explicitly expose the one
|
|
146
|
+
// precision gap that the administrator session cannot close.
|
|
147
|
+
if (inventory.orphaned.length > 0) {
|
|
148
|
+
checks.push({
|
|
149
|
+
id: "host.dsm_port_profile",
|
|
150
|
+
status: "warn",
|
|
151
|
+
message: `發現 ${inventory.orphaned.length} 筆 DSM port profile 疑似孤兒(${DSM_PORT_PROFILE_PRECISION_WARNING}):${formatDsmPortProfiles(inventory.orphaned)}`,
|
|
152
|
+
suggestion: "這是未經 conf/resource 交叉比對的 API 候選;請先人工確認 Compose project 名,再由管理員清理。",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
checks.push({
|
|
157
|
+
id: "host.dsm_port_profile",
|
|
158
|
+
status: "pass",
|
|
159
|
+
message: `無 DSM port profile 孤兒候選(${DSM_PORT_PROFILE_PRECISION_WARNING})`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
70
163
|
export async function runDoctor(options) {
|
|
71
164
|
const checks = [];
|
|
72
165
|
// --- 1. Local checks (Always run) ---
|
|
@@ -450,11 +543,13 @@ export async function runDoctor(options) {
|
|
|
450
543
|
}
|
|
451
544
|
}
|
|
452
545
|
// Check 2.3b: DSM Residue check (A29)
|
|
546
|
+
let brokerResidue;
|
|
453
547
|
if (isBrokerReady && host.getDsmDiagnostics) {
|
|
454
548
|
try {
|
|
455
549
|
const diag = await host.getDsmDiagnostics("probe");
|
|
456
550
|
if (diag && diag.dsmResidue) {
|
|
457
551
|
const residue = diag.dsmResidue;
|
|
552
|
+
brokerResidue = residue;
|
|
458
553
|
if (residue.items && residue.items.length > 0) {
|
|
459
554
|
const details = residue.items
|
|
460
555
|
.map((item) => `[${item.kind}] ${item.name} (${item.path}):${item.reason}`)
|
|
@@ -479,6 +574,12 @@ export async function runDoctor(options) {
|
|
|
479
574
|
// If query fails or Broker is old, omit / skip silently to be compatible
|
|
480
575
|
}
|
|
481
576
|
}
|
|
577
|
+
// Check 2.3c: administrator-only DSM PortInfo fallback / cross-check.
|
|
578
|
+
await appendDsmPortProfileCheck(checks, host, {
|
|
579
|
+
brokerInstalled,
|
|
580
|
+
brokerReady: isBrokerReady,
|
|
581
|
+
brokerResidue,
|
|
582
|
+
});
|
|
482
583
|
// Check 2.4: If packageId is provided (or derived)
|
|
483
584
|
const targetPackageId = options.packageId ?? (options.project ? resolveDeployIdentity(options.project, { dev: options.dev }).packageId : undefined);
|
|
484
585
|
if (targetPackageId) {
|
package/dist/error-registry.js
CHANGED
|
@@ -499,6 +499,16 @@ export const ERROR_REGISTRY = {
|
|
|
499
499
|
description: "dsm_user 參數必須提供 DSM 使用者邏輯名稱,禁止使用數字 UID。",
|
|
500
500
|
suggestion: "請在 --run-as 指定 DSM 使用者帳號名稱(例如 --run-as RUN_AS=alice)。",
|
|
501
501
|
},
|
|
502
|
+
SHARE_RUN_AS_NO_ACCESS: {
|
|
503
|
+
code: "SHARE_RUN_AS_NO_ACCESS",
|
|
504
|
+
description: "deploy 的安裝前檢查發現選定的 Container Run-as 使用者對選定的 Shared Folder 沒有權限(或被明確拒絕)。容器會以該使用者掛載該 Share,安裝會成功但 runtime 讀寫會拿到 Permission denied。",
|
|
505
|
+
suggestion: "請在 DSM 控制台把該 Shared Folder 的權限授予該使用者(或其群組),或改選別的使用者/Share,再重新部署。此檢查只在 deploy 進行;Package Center 安裝精靈不經過這裡,ACL 仍是管理員的前提責任。",
|
|
506
|
+
},
|
|
507
|
+
PORT_ALREADY_IN_USE: {
|
|
508
|
+
code: "PORT_ALREADY_IN_USE",
|
|
509
|
+
description: "deploy 的安裝前檢查發現 Compose published TCP port 已被其他服務或容器佔用。",
|
|
510
|
+
suggestion: "請更換 synology-app.yaml/compose.yaml 的 published host port;同一 Package Identity 的既有容器會在升級或重裝時放行。",
|
|
511
|
+
},
|
|
502
512
|
CURATED_DEPLOY_REFUSED: {
|
|
503
513
|
code: "CURATED_DEPLOY_REFUSED",
|
|
504
514
|
description: "目標 NAS 已持有 Curated App Package Attestation,禁止隨意覆蓋部署。",
|
package/dist/host/dsm-client.js
CHANGED
|
@@ -5,7 +5,10 @@ import { join } from "node:path";
|
|
|
5
5
|
import { IDENTITY_CGI_PATH } from "./broker-paths.js";
|
|
6
6
|
import { packageSourceOrigin } from "../deploy/catalog-publish.js";
|
|
7
7
|
import { listShareNamesWithSynoagentcli, lookupSharePathWithSynoagentcli } from "./share-lookup.js";
|
|
8
|
+
import { createSynoagentcliApiCall, fetchSharePermissions, fetchUserGroups, } from "./share-permission-lookup.js";
|
|
9
|
+
import { fetchPortInfo } from "./port-info-lookup.js";
|
|
8
10
|
import { dsmFetch } from "./dsm-http.js";
|
|
11
|
+
import { fetchDsmPortProfileInventory } from "./dsm-port-profile.js";
|
|
9
12
|
import { installPackageFromCatalog, installPackageFromLocalSpk, uninstallPackageFromDsm, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
|
|
10
13
|
import { resolveHostCredentials } from "./credentials.js";
|
|
11
14
|
const OIDC_COMPAT_PATH = "/api/v1/inner-loop/oidc-compat";
|
|
@@ -234,6 +237,21 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
234
237
|
async listShareNames() {
|
|
235
238
|
return listShareNamesWithSynoagentcli(profileName);
|
|
236
239
|
},
|
|
240
|
+
async listSharePermissions(logicalName) {
|
|
241
|
+
return fetchSharePermissions(logicalName, createSynoagentcliApiCall(profileName));
|
|
242
|
+
},
|
|
243
|
+
async listUserGroups(userName) {
|
|
244
|
+
return fetchUserGroups(userName, createSynoagentcliApiCall(profileName));
|
|
245
|
+
},
|
|
246
|
+
async listPortInfo() {
|
|
247
|
+
try {
|
|
248
|
+
await ensureLogin();
|
|
249
|
+
return fetchPortInfo(dsmOrigin, cookieHeader ?? "");
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
},
|
|
237
255
|
async resolveHomesPath() {
|
|
238
256
|
try {
|
|
239
257
|
const result = synoagentcli(["dsm", "control-panel", "user-group", "advanced", "get", "--section", "home"], profileName);
|
|
@@ -352,6 +370,15 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
352
370
|
return undefined;
|
|
353
371
|
}
|
|
354
372
|
},
|
|
373
|
+
async getDsmPortProfileInventory() {
|
|
374
|
+
try {
|
|
375
|
+
await ensureLogin();
|
|
376
|
+
return await fetchDsmPortProfileInventory(dsmOrigin, cookieHeader ?? "");
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
},
|
|
355
382
|
async getOidcCompatSettings() {
|
|
356
383
|
try {
|
|
357
384
|
await ensureLogin();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** The precision caveat for the administrator-only fallback. */
|
|
2
|
+
export declare const DSM_PORT_PROFILE_PRECISION_WARNING = "API \u7248\u672A\u8B80\u53D6 conf/resource\uFF1B\u82E5\u6709 App \u81EA\u8A02 Compose project \u540D\u53EF\u80FD\u8AA4\u5831";
|
|
3
|
+
export interface DsmPortRange {
|
|
4
|
+
start: number;
|
|
5
|
+
end: number;
|
|
6
|
+
}
|
|
7
|
+
export interface DsmPortProfile {
|
|
8
|
+
portId: string;
|
|
9
|
+
containerName: string;
|
|
10
|
+
projectName: string;
|
|
11
|
+
/** Kept for diagnostics only; the API orphan test must use projectName. */
|
|
12
|
+
presumedPackageIdentity: string;
|
|
13
|
+
dstPorts: DsmPortRange[];
|
|
14
|
+
}
|
|
15
|
+
export interface DsmPortProfileInventory {
|
|
16
|
+
profiles: DsmPortProfile[];
|
|
17
|
+
orphaned: DsmPortProfile[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parses the Docker .sc filename using the same rules as the Broker's
|
|
21
|
+
* parsePortProfileFilename(). In particular, projectName is everything before
|
|
22
|
+
* the final two dash-separated container segments.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parsePortProfileFilename(filename: string): {
|
|
25
|
+
containerName: string;
|
|
26
|
+
projectName: string;
|
|
27
|
+
presumedPackageIdentity: string;
|
|
28
|
+
} | undefined;
|
|
29
|
+
/** Parse one DSM dst_port value, including the documented range form. */
|
|
30
|
+
export declare function parseDsmPortRange(value: string): DsmPortRange | undefined;
|
|
31
|
+
/** Parse a successful SYNO.Core.Service.PortInfo/load response. */
|
|
32
|
+
export declare function parseDsmPortInfoPayload(payload: unknown): DsmPortProfile[] | undefined;
|
|
33
|
+
/** Parse the installed identity set from a successful DSM package list. */
|
|
34
|
+
export declare function installedPackageIdentitiesFromPayload(payload: unknown): string[] | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Build the API-only inventory from the two independent DSM responses.
|
|
37
|
+
*
|
|
38
|
+
* The package list is a forward mapping: package identity -> project name.
|
|
39
|
+
* Do not use presumedPackageIdentity here; replacing dashes with dots is
|
|
40
|
+
* lossy for identities such as com.example.hello-web.
|
|
41
|
+
*/
|
|
42
|
+
export declare function dsmPortProfileInventoryFromPayloads(portInfoPayload: unknown, packageListPayload: unknown): DsmPortProfileInventory | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Read PortInfo.load and the administrator-visible installed package list.
|
|
45
|
+
* Any transport, HTTP, JSON, or DSM API failure becomes undefined so callers
|
|
46
|
+
* can skip without inventing residue.
|
|
47
|
+
*/
|
|
48
|
+
export declare function fetchDsmPortProfileInventory(dsmOrigin: string, cookieHeader: string): Promise<DsmPortProfileInventory | undefined>;
|