@synopackageland/cli 0.3.9 → 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/deploy/deploy.js +12 -0
- package/dist/deploy/port-preflight.d.ts +21 -0
- package/dist/deploy/port-preflight.js +42 -0
- package/dist/doctor.js +101 -0
- package/dist/error-registry.js +5 -0
- package/dist/host/dsm-client.js +20 -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 +8 -0
- package/dist/host/fake-nas.js +19 -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/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/deploy/deploy.js
CHANGED
|
@@ -13,6 +13,8 @@ import { runSharePermissionPreflight } from "./share-permission-preflight.js";
|
|
|
13
13
|
import { homesParameterNames, resolveHomesExtraValues } from "./homes-values.js";
|
|
14
14
|
import { resolveInstallParameterExtraValues } from "./param-values.js";
|
|
15
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";
|
|
16
18
|
const CURATED_DEPLOY_REFUSED_MESSAGE = "此 NAS 已持有 Curated App Package Attestation。請改用 --dev,或使用 --replace-curated 明確降級。";
|
|
17
19
|
const POSIX_ALL_SHARES_LOOKUP_FAILED = "DEPLOY_POSIX_ALL_SHARES_LOOKUP_FAILED";
|
|
18
20
|
function brokerCapabilitiesFromContract(project) {
|
|
@@ -143,6 +145,16 @@ export async function deployProject(options) {
|
|
|
143
145
|
if (!sharePermission.ok) {
|
|
144
146
|
return { ok: false, code: sharePermission.code, message: sharePermission.message };
|
|
145
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
|
+
}
|
|
146
158
|
const extraValues = {
|
|
147
159
|
...paramResolved.extraValues,
|
|
148
160
|
...homesResolved.extraValues,
|
|
@@ -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
|
+
}
|
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
|
@@ -504,6 +504,11 @@ export const ERROR_REGISTRY = {
|
|
|
504
504
|
description: "deploy 的安裝前檢查發現選定的 Container Run-as 使用者對選定的 Shared Folder 沒有權限(或被明確拒絕)。容器會以該使用者掛載該 Share,安裝會成功但 runtime 讀寫會拿到 Permission denied。",
|
|
505
505
|
suggestion: "請在 DSM 控制台把該 Shared Folder 的權限授予該使用者(或其群組),或改選別的使用者/Share,再重新部署。此檢查只在 deploy 進行;Package Center 安裝精靈不經過這裡,ACL 仍是管理員的前提責任。",
|
|
506
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
|
+
},
|
|
507
512
|
CURATED_DEPLOY_REFUSED: {
|
|
508
513
|
code: "CURATED_DEPLOY_REFUSED",
|
|
509
514
|
description: "目標 NAS 已持有 Curated App Package Attestation,禁止隨意覆蓋部署。",
|
package/dist/host/dsm-client.js
CHANGED
|
@@ -6,7 +6,9 @@ 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
8
|
import { createSynoagentcliApiCall, fetchSharePermissions, fetchUserGroups, } from "./share-permission-lookup.js";
|
|
9
|
+
import { fetchPortInfo } from "./port-info-lookup.js";
|
|
9
10
|
import { dsmFetch } from "./dsm-http.js";
|
|
11
|
+
import { fetchDsmPortProfileInventory } from "./dsm-port-profile.js";
|
|
10
12
|
import { installPackageFromCatalog, installPackageFromLocalSpk, uninstallPackageFromDsm, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
|
|
11
13
|
import { resolveHostCredentials } from "./credentials.js";
|
|
12
14
|
const OIDC_COMPAT_PATH = "/api/v1/inner-loop/oidc-compat";
|
|
@@ -241,6 +243,15 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
241
243
|
async listUserGroups(userName) {
|
|
242
244
|
return fetchUserGroups(userName, createSynoagentcliApiCall(profileName));
|
|
243
245
|
},
|
|
246
|
+
async listPortInfo() {
|
|
247
|
+
try {
|
|
248
|
+
await ensureLogin();
|
|
249
|
+
return fetchPortInfo(dsmOrigin, cookieHeader ?? "");
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
},
|
|
244
255
|
async resolveHomesPath() {
|
|
245
256
|
try {
|
|
246
257
|
const result = synoagentcli(["dsm", "control-panel", "user-group", "advanced", "get", "--section", "home"], profileName);
|
|
@@ -359,6 +370,15 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
359
370
|
return undefined;
|
|
360
371
|
}
|
|
361
372
|
},
|
|
373
|
+
async getDsmPortProfileInventory() {
|
|
374
|
+
try {
|
|
375
|
+
await ensureLogin();
|
|
376
|
+
return await fetchDsmPortProfileInventory(dsmOrigin, cookieHeader ?? "");
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
},
|
|
362
382
|
async getOidcCompatSettings() {
|
|
363
383
|
try {
|
|
364
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>;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { composeProjectName } from "../build-spk/resource-worker.js";
|
|
3
|
+
import { dsmFetch } from "./dsm-http.js";
|
|
4
|
+
/** The precision caveat for the administrator-only fallback. */
|
|
5
|
+
export const DSM_PORT_PROFILE_PRECISION_WARNING = "API 版未讀取 conf/resource;若有 App 自訂 Compose project 名可能誤報";
|
|
6
|
+
/**
|
|
7
|
+
* Parses the Docker .sc filename using the same rules as the Broker's
|
|
8
|
+
* parsePortProfileFilename(). In particular, projectName is everything before
|
|
9
|
+
* the final two dash-separated container segments.
|
|
10
|
+
*/
|
|
11
|
+
export function parsePortProfileFilename(filename) {
|
|
12
|
+
const base = basename(filename);
|
|
13
|
+
if (!base.startsWith("docker_") || !base.endsWith(".sc")) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const containerName = base.slice("docker_".length, base.length - ".sc".length);
|
|
17
|
+
if (!containerName) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
// Keep this split aligned with packages/broker/src/diagnostics/dsm-residue.ts:
|
|
21
|
+
// remove the final -<service>-<replica> pair, not an identity-specific guess.
|
|
22
|
+
const lastDash = containerName.lastIndexOf("-");
|
|
23
|
+
if (lastDash <= 0) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
const secondLastDash = containerName.lastIndexOf("-", lastDash - 1);
|
|
27
|
+
if (secondLastDash <= 0) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const projectName = containerName.slice(0, secondLastDash);
|
|
31
|
+
const presumedPackageIdentity = projectName.replace(/-/g, ".");
|
|
32
|
+
return {
|
|
33
|
+
containerName,
|
|
34
|
+
projectName,
|
|
35
|
+
presumedPackageIdentity,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Parse one DSM dst_port value, including the documented range form. */
|
|
39
|
+
export function parseDsmPortRange(value) {
|
|
40
|
+
const match = /^([0-9]{1,5})(?:-([0-9]{1,5}))?$/.exec(value.trim());
|
|
41
|
+
if (!match) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const start = Number(match[1]);
|
|
45
|
+
const end = Number(match[2] ?? match[1]);
|
|
46
|
+
if (!Number.isInteger(start) ||
|
|
47
|
+
!Number.isInteger(end) ||
|
|
48
|
+
start < 1 ||
|
|
49
|
+
end > 65535 ||
|
|
50
|
+
start > end) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return { start, end };
|
|
54
|
+
}
|
|
55
|
+
function parseDsmPortRanges(value) {
|
|
56
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const ranges = [];
|
|
60
|
+
for (const entry of value) {
|
|
61
|
+
if (typeof entry !== "string") {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const range = parseDsmPortRange(entry);
|
|
65
|
+
if (!range) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
ranges.push(range);
|
|
69
|
+
}
|
|
70
|
+
return ranges;
|
|
71
|
+
}
|
|
72
|
+
function record(value) {
|
|
73
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
74
|
+
? value
|
|
75
|
+
: undefined;
|
|
76
|
+
}
|
|
77
|
+
function nonEmptyString(value) {
|
|
78
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
79
|
+
}
|
|
80
|
+
function parseDockerPortInfo(value) {
|
|
81
|
+
const entry = record(value);
|
|
82
|
+
if (!entry) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
// PortInfo also contains ordinary DSM service rows. Only the documented
|
|
86
|
+
// Docker(TCP) rows correspond to docker_<container>.sc profiles.
|
|
87
|
+
if (entry.protocol !== "tcp" || entry.name !== "Docker(TCP)") {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const portId = nonEmptyString(entry.port_id);
|
|
91
|
+
if (!portId?.endsWith("_tcp")) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
const containerName = portId.slice(0, -"_tcp".length);
|
|
95
|
+
if (!containerName || entry.desc !== `Docker ${containerName}`) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const parsed = parsePortProfileFilename(`docker_${containerName}.sc`);
|
|
99
|
+
const dstPorts = parseDsmPortRanges(entry.dst_port);
|
|
100
|
+
if (!parsed || !dstPorts) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
portId,
|
|
105
|
+
containerName: parsed.containerName,
|
|
106
|
+
projectName: parsed.projectName,
|
|
107
|
+
presumedPackageIdentity: parsed.presumedPackageIdentity,
|
|
108
|
+
dstPorts,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Parse a successful SYNO.Core.Service.PortInfo/load response. */
|
|
112
|
+
export function parseDsmPortInfoPayload(payload) {
|
|
113
|
+
const root = record(payload);
|
|
114
|
+
const data = record(root?.data);
|
|
115
|
+
if (root?.success !== true || !data || !Array.isArray(data.port_info)) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
return data.port_info.flatMap((entry) => {
|
|
119
|
+
const profile = parseDockerPortInfo(entry);
|
|
120
|
+
return profile ? [profile] : [];
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** Parse the installed identity set from a successful DSM package list. */
|
|
124
|
+
export function installedPackageIdentitiesFromPayload(payload) {
|
|
125
|
+
const root = record(payload);
|
|
126
|
+
const data = record(root?.data);
|
|
127
|
+
if (root?.success !== true || !data || !Array.isArray(data.packages)) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
return [
|
|
131
|
+
...new Set(data.packages
|
|
132
|
+
.map((entry) => record(entry)?.id)
|
|
133
|
+
.filter((id) => typeof id === "string" && id.length > 0)),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Build the API-only inventory from the two independent DSM responses.
|
|
138
|
+
*
|
|
139
|
+
* The package list is a forward mapping: package identity -> project name.
|
|
140
|
+
* Do not use presumedPackageIdentity here; replacing dashes with dots is
|
|
141
|
+
* lossy for identities such as com.example.hello-web.
|
|
142
|
+
*/
|
|
143
|
+
export function dsmPortProfileInventoryFromPayloads(portInfoPayload, packageListPayload) {
|
|
144
|
+
const profiles = parseDsmPortInfoPayload(portInfoPayload);
|
|
145
|
+
if (!profiles) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
// An empty PortInfo list is a safe, successful "nothing to report" result;
|
|
149
|
+
// there is no reason to turn an unavailable package list into an orphan.
|
|
150
|
+
if (profiles.length === 0) {
|
|
151
|
+
return { profiles, orphaned: [] };
|
|
152
|
+
}
|
|
153
|
+
const installedPackages = installedPackageIdentitiesFromPayload(packageListPayload);
|
|
154
|
+
// An empty or unavailable package list cannot prove that any profile is an
|
|
155
|
+
// orphan. Fail closed instead of classifying every visible Docker row.
|
|
156
|
+
if (!installedPackages || installedPackages.length === 0) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
const declaredProjectNames = new Set(installedPackages.map(composeProjectName));
|
|
160
|
+
const orphaned = profiles.filter((profile) => !declaredProjectNames.has(profile.projectName));
|
|
161
|
+
return { profiles, orphaned };
|
|
162
|
+
}
|
|
163
|
+
async function fetchJson(dsmOrigin, cookieHeader, params) {
|
|
164
|
+
const query = new URLSearchParams(params);
|
|
165
|
+
const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?${query.toString()}`, {
|
|
166
|
+
headers: { cookie: cookieHeader },
|
|
167
|
+
});
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
return await response.json();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Read PortInfo.load and the administrator-visible installed package list.
|
|
180
|
+
* Any transport, HTTP, JSON, or DSM API failure becomes undefined so callers
|
|
181
|
+
* can skip without inventing residue.
|
|
182
|
+
*/
|
|
183
|
+
export async function fetchDsmPortProfileInventory(dsmOrigin, cookieHeader) {
|
|
184
|
+
try {
|
|
185
|
+
const portInfoPayload = await fetchJson(dsmOrigin, cookieHeader, {
|
|
186
|
+
api: "SYNO.Core.Service.PortInfo",
|
|
187
|
+
version: "1",
|
|
188
|
+
method: "load",
|
|
189
|
+
});
|
|
190
|
+
const profiles = parseDsmPortInfoPayload(portInfoPayload);
|
|
191
|
+
if (!profiles) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
if (profiles.length === 0) {
|
|
195
|
+
return { profiles, orphaned: [] };
|
|
196
|
+
}
|
|
197
|
+
const packageListPayload = await fetchJson(dsmOrigin, cookieHeader, {
|
|
198
|
+
api: "SYNO.Core.Package",
|
|
199
|
+
version: "2",
|
|
200
|
+
method: "list",
|
|
201
|
+
additional: JSON.stringify(["status"]),
|
|
202
|
+
});
|
|
203
|
+
return dsmPortProfileInventoryFromPayloads(portInfoPayload, packageListPayload);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
package/dist/host/fake-nas.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { SharePermissionRow } from "./share-permission.js";
|
|
2
|
+
import type { PortInfoEntry } from "./port-info.js";
|
|
2
3
|
import type { AttestationKind, HostInstallRecord, HostOidcCompatSettings, NasHost, PackageRoute } from "./types.js";
|
|
4
|
+
import type { DsmPortProfileInventory } from "./dsm-port-profile.js";
|
|
3
5
|
export type SharePermissionRowInput = {
|
|
4
6
|
name: string;
|
|
5
7
|
} & Partial<Omit<SharePermissionRow, "name">>;
|
|
@@ -21,6 +23,10 @@ export interface FakeNasHostOptions {
|
|
|
21
23
|
}>;
|
|
22
24
|
/** Group membership per DSM user, for share-permission resolution. */
|
|
23
25
|
userGroups?: Record<string, string[]>;
|
|
26
|
+
/** SYNO.Core.Service.PortInfo rows; omit to model a host without the capability. */
|
|
27
|
+
portInfo?: PortInfoEntry[];
|
|
28
|
+
/** Model a host whose PortInfo capability is present but the API failed. */
|
|
29
|
+
portInfoFailure?: boolean;
|
|
24
30
|
installedEnv?: Record<string, Record<string, string>>;
|
|
25
31
|
diagnostics?: {
|
|
26
32
|
dsmResidue?: import("./types.js").DsmResidueInventory;
|
|
@@ -28,6 +34,8 @@ export interface FakeNasHostOptions {
|
|
|
28
34
|
isPackageOnly?: boolean;
|
|
29
35
|
[key: string]: unknown;
|
|
30
36
|
};
|
|
37
|
+
/** Administrator-only DSM PortInfo inventory, when the fake should expose it. */
|
|
38
|
+
portProfileInventory?: DsmPortProfileInventory;
|
|
31
39
|
/** Omit to model an older Broker without the OIDC settings endpoint. */
|
|
32
40
|
oidcCompat?: HostOidcCompatSettings;
|
|
33
41
|
}
|
package/dist/host/fake-nas.js
CHANGED
|
@@ -63,6 +63,22 @@ export function createFakeNasHost(options = {}) {
|
|
|
63
63
|
},
|
|
64
64
|
}
|
|
65
65
|
: {}),
|
|
66
|
+
...(options.portInfo !== undefined || options.portInfoFailure
|
|
67
|
+
? {
|
|
68
|
+
async listPortInfo() {
|
|
69
|
+
if (options.portInfoFailure) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return options.portInfo?.map((entry) => ({
|
|
73
|
+
...entry,
|
|
74
|
+
dst_port: [...entry.dst_port],
|
|
75
|
+
...(entry.src_port === undefined
|
|
76
|
+
? {}
|
|
77
|
+
: { src_port: entry.src_port === null ? null : [...entry.src_port] }),
|
|
78
|
+
}));
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
: {}),
|
|
66
82
|
async resolveHomesPath() {
|
|
67
83
|
return options.homesPath;
|
|
68
84
|
},
|
|
@@ -154,6 +170,9 @@ export function createFakeNasHost(options = {}) {
|
|
|
154
170
|
async getDsmDiagnostics() {
|
|
155
171
|
return options.diagnostics;
|
|
156
172
|
},
|
|
173
|
+
async getDsmPortProfileInventory() {
|
|
174
|
+
return options.portProfileInventory;
|
|
175
|
+
},
|
|
157
176
|
async getOidcCompatSettings() {
|
|
158
177
|
if (!options.oidcCompat) {
|
|
159
178
|
return undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PortInfoEntry } from "./port-info.js";
|
|
2
|
+
export type PortInfoHttpRequest = (url: string, init?: RequestInit) => Promise<Response>;
|
|
3
|
+
/**
|
|
4
|
+
* Parses the native DSM response, not a synoagentcli operation envelope.
|
|
5
|
+
* `undefined` means "cannot tell" and deliberately allows deployment to
|
|
6
|
+
* continue. An empty `port_info` list has the same fail-open meaning.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parsePortInfo(payload: unknown): PortInfoEntry[] | undefined;
|
|
9
|
+
export declare function fetchPortInfo(dsmOrigin: string, cookieHeader: string, request?: PortInfoHttpRequest): Promise<PortInfoEntry[] | undefined>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { dsmFetch } from "./dsm-http.js";
|
|
2
|
+
const PORT_INFO_API = "SYNO.Core.Service.PortInfo";
|
|
3
|
+
function asRecord(value) {
|
|
4
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
function validPortRange(value) {
|
|
10
|
+
const match = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(value.trim());
|
|
11
|
+
if (!match) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const start = Number(match[1]);
|
|
15
|
+
const end = Number(match[2] ?? match[1]);
|
|
16
|
+
return (Number.isSafeInteger(start) &&
|
|
17
|
+
Number.isSafeInteger(end) &&
|
|
18
|
+
start >= 1 &&
|
|
19
|
+
start <= end &&
|
|
20
|
+
end <= 65535);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Parses the native DSM response, not a synoagentcli operation envelope.
|
|
24
|
+
* `undefined` means "cannot tell" and deliberately allows deployment to
|
|
25
|
+
* continue. An empty `port_info` list has the same fail-open meaning.
|
|
26
|
+
*/
|
|
27
|
+
export function parsePortInfo(payload) {
|
|
28
|
+
const outer = asRecord(payload);
|
|
29
|
+
const data = asRecord(outer?.data);
|
|
30
|
+
const items = data?.port_info;
|
|
31
|
+
if (outer?.success !== true || !Array.isArray(items) || items.length === 0) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const entries = [];
|
|
35
|
+
for (const item of items) {
|
|
36
|
+
const record = asRecord(item);
|
|
37
|
+
if (!record) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const desc = record?.desc;
|
|
41
|
+
const dstPort = record?.dst_port;
|
|
42
|
+
const name = record?.name;
|
|
43
|
+
const portId = record?.port_id;
|
|
44
|
+
const protocol = record?.protocol;
|
|
45
|
+
if (typeof desc !== "string" ||
|
|
46
|
+
typeof name !== "string" ||
|
|
47
|
+
typeof portId !== "string" ||
|
|
48
|
+
typeof protocol !== "string" ||
|
|
49
|
+
!Array.isArray(dstPort) ||
|
|
50
|
+
dstPort.length === 0 ||
|
|
51
|
+
!dstPort.every((value) => typeof value === "string" && validPortRange(value))) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const srcPort = record.src_port;
|
|
55
|
+
if (srcPort !== undefined &&
|
|
56
|
+
srcPort !== null &&
|
|
57
|
+
(!Array.isArray(srcPort) || !srcPort.every((value) => typeof value === "string"))) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
entries.push({
|
|
61
|
+
desc,
|
|
62
|
+
dst_port: [...dstPort],
|
|
63
|
+
name,
|
|
64
|
+
port_id: portId,
|
|
65
|
+
protocol,
|
|
66
|
+
...(srcPort === undefined ? {} : { src_port: srcPort === null ? null : [...srcPort] }),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return entries;
|
|
70
|
+
}
|
|
71
|
+
export async function fetchPortInfo(dsmOrigin, cookieHeader, request = dsmFetch) {
|
|
72
|
+
try {
|
|
73
|
+
const query = new URLSearchParams({
|
|
74
|
+
api: PORT_INFO_API,
|
|
75
|
+
version: "1",
|
|
76
|
+
method: "load",
|
|
77
|
+
});
|
|
78
|
+
const response = await request(`${dsmOrigin.replace(/\/$/, "")}/webapi/entry.cgi?${query.toString()}`, { headers: { cookie: cookieHeader } });
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return parsePortInfo(await response.json());
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** One row returned by SYNO.Core.Service.PortInfo/load. */
|
|
2
|
+
export interface PortInfoEntry {
|
|
3
|
+
desc: string;
|
|
4
|
+
dst_port: readonly string[];
|
|
5
|
+
name: string;
|
|
6
|
+
port_id: string;
|
|
7
|
+
protocol: string;
|
|
8
|
+
src_port?: readonly string[] | null;
|
|
9
|
+
}
|
|
10
|
+
export type DsmPortInfoEntry = PortInfoEntry;
|
|
11
|
+
export type PortInfoRow = PortInfoEntry;
|
|
12
|
+
export interface PortConflict {
|
|
13
|
+
port: number;
|
|
14
|
+
services: readonly string[];
|
|
15
|
+
occupants: readonly PortInfoEntry[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Returns the Docker container name represented by a DSM port row, when the
|
|
19
|
+
* row has Docker's known `port_id` or `desc` shape. Other DSM services are
|
|
20
|
+
* intentionally returned as undefined so they remain conflicts.
|
|
21
|
+
*/
|
|
22
|
+
export declare function dockerContainerName(entry: PortInfoEntry): string | undefined;
|
|
23
|
+
export declare function formatPortOccupant(entry: PortInfoEntry): string;
|
|
24
|
+
/**
|
|
25
|
+
* Finds occupied published ports, ignoring only containers that are one of
|
|
26
|
+
* the Compose containers for this exact package identity. A same-identity
|
|
27
|
+
* occupant is the existing install being upgraded or reinstalled.
|
|
28
|
+
*/
|
|
29
|
+
export declare function findPortConflicts(input: {
|
|
30
|
+
publishedPorts: ReadonlyMap<number, readonly string[]>;
|
|
31
|
+
packageIdentity: string;
|
|
32
|
+
portInfo: readonly PortInfoEntry[];
|
|
33
|
+
}): PortConflict[];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { composeProjectName } from "../build-spk/resource-worker.js";
|
|
2
|
+
function parsePortRange(value) {
|
|
3
|
+
const match = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(value.trim());
|
|
4
|
+
if (!match) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
const start = Number(match[1]);
|
|
8
|
+
const end = Number(match[2] ?? match[1]);
|
|
9
|
+
if (!Number.isSafeInteger(start) ||
|
|
10
|
+
!Number.isSafeInteger(end) ||
|
|
11
|
+
start < 1 ||
|
|
12
|
+
end > 65535 ||
|
|
13
|
+
start > end) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
return { start, end };
|
|
17
|
+
}
|
|
18
|
+
function escapeRegExp(value) {
|
|
19
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
20
|
+
}
|
|
21
|
+
function entryCoversPort(entry, port) {
|
|
22
|
+
return entry.dst_port.some((value) => {
|
|
23
|
+
const range = parsePortRange(value);
|
|
24
|
+
return range !== undefined && range.start <= port && port <= range.end;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Returns the Docker container name represented by a DSM port row, when the
|
|
29
|
+
* row has Docker's known `port_id` or `desc` shape. Other DSM services are
|
|
30
|
+
* intentionally returned as undefined so they remain conflicts.
|
|
31
|
+
*/
|
|
32
|
+
export function dockerContainerName(entry) {
|
|
33
|
+
const portIdMatch = /^(.*)_tcp$/i.exec(entry.port_id.trim());
|
|
34
|
+
if (portIdMatch?.[1]) {
|
|
35
|
+
return portIdMatch[1];
|
|
36
|
+
}
|
|
37
|
+
const descMatch = /^Docker\s+(.+)$/i.exec(entry.desc.trim());
|
|
38
|
+
return descMatch?.[1]?.trim() || undefined;
|
|
39
|
+
}
|
|
40
|
+
export function formatPortOccupant(entry) {
|
|
41
|
+
return dockerContainerName(entry) ?? (entry.desc.trim() || entry.port_id.trim() || entry.name.trim());
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Finds occupied published ports, ignoring only containers that are one of
|
|
45
|
+
* the Compose containers for this exact package identity. A same-identity
|
|
46
|
+
* occupant is the existing install being upgraded or reinstalled.
|
|
47
|
+
*/
|
|
48
|
+
export function findPortConflicts(input) {
|
|
49
|
+
const conflicts = [];
|
|
50
|
+
const projectName = composeProjectName(input.packageIdentity);
|
|
51
|
+
for (const [port, services] of input.publishedPorts) {
|
|
52
|
+
// Compose names a container <project>-<service>-<replica>. Match the
|
|
53
|
+
// replica as a number rather than assuming -1: a scaled service would
|
|
54
|
+
// otherwise look like a foreign occupant and refuse this package's own
|
|
55
|
+
// redeploy. The service name is still matched exactly, so a different
|
|
56
|
+
// package whose project name merely shares a prefix stays a conflict.
|
|
57
|
+
const ownContainer = (containerName) => services.some((service) => new RegExp(`^${escapeRegExp(`${projectName}-${service}-`)}\\d+$`).test(containerName));
|
|
58
|
+
const occupants = input.portInfo.filter((entry) => entry.protocol.trim().toLowerCase() === "tcp" &&
|
|
59
|
+
entryCoversPort(entry, port) &&
|
|
60
|
+
!ownContainer(dockerContainerName(entry) ?? ""));
|
|
61
|
+
if (occupants.length > 0) {
|
|
62
|
+
conflicts.push({ port, services, occupants });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return conflicts;
|
|
66
|
+
}
|
package/dist/host/types.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { SharePermissionListing } from "../deploy/share-permission-preflight.js";
|
|
2
2
|
import type { InstalledPackageState } from "./catalog-install.js";
|
|
3
|
+
import type { PortInfoEntry } from "./port-info.js";
|
|
4
|
+
import type { DsmPortProfileInventory } from "./dsm-port-profile.js";
|
|
3
5
|
export type AttestationKind = "local" | "app-package" | "none";
|
|
4
6
|
export type PackageRoute = "package-only" | "broker-installer" | "unknown";
|
|
5
7
|
export type OidcOriginKind = "public-ca" | "quickconnect" | "lan-ip" | "self-signed" | "private-ca" | "http";
|
|
@@ -88,6 +90,8 @@ export interface NasHost {
|
|
|
88
90
|
listSharePermissions?(logicalName: string): Promise<SharePermissionListing | undefined>;
|
|
89
91
|
/** Group names a DSM user belongs to, for resolving share permission. */
|
|
90
92
|
listUserGroups?(userName: string): Promise<string[] | undefined>;
|
|
93
|
+
/** Read SYNO.Core.Service.PortInfo/load using an administrator session. */
|
|
94
|
+
listPortInfo?(): Promise<PortInfoEntry[] | undefined>;
|
|
91
95
|
/** Resolve the NAS-specific user-home root when DSM User Home is enabled. */
|
|
92
96
|
resolveHomesPath?(): Promise<string | undefined>;
|
|
93
97
|
getInstalledProjectEnv?(packageIdentity: string): Promise<Record<string, string> | undefined>;
|
|
@@ -103,6 +107,12 @@ export interface NasHost {
|
|
|
103
107
|
isPackageOnly?: boolean;
|
|
104
108
|
[key: string]: unknown;
|
|
105
109
|
} | undefined>;
|
|
110
|
+
/**
|
|
111
|
+
* Read DSM PortInfo plus the administrator-visible installed package list.
|
|
112
|
+
* This path does not require SynoPkgLandService; undefined means the host
|
|
113
|
+
* could not answer and must be treated as unknown by diagnostics.
|
|
114
|
+
*/
|
|
115
|
+
getDsmPortProfileInventory?(): Promise<DsmPortProfileInventory | undefined>;
|
|
106
116
|
/** Read the optional administrator OIDC compatibility settings endpoint. */
|
|
107
117
|
getOidcCompatSettings?(): Promise<HostOidcCompatSettings | undefined>;
|
|
108
118
|
smokeRequireLogin(input: {
|