@synopackageland/cli 0.3.5 → 0.3.7
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/build-spk/generator.js +87 -5
- package/dist/build-spk/resource-worker.js +16 -31
- package/dist/compose/analyze.js +5 -4
- package/dist/deploy/deploy.js +3 -1
- package/dist/doctor.js +81 -6
- package/dist/host/dsm-client.js +52 -1
- package/dist/host/fake-nas.d.ts +4 -2
- package/dist/host/fake-nas.js +14 -1
- package/dist/host/types.d.ts +17 -1
- package/package.json +1 -1
- package/skills/reference/deploy.md +1 -1
|
@@ -20,9 +20,23 @@ import { renderNativePrivilege, renderNativeStartStopStatus, renderNativePostuni
|
|
|
20
20
|
import { renderHeadlessResourceMarker, renderResourceConfig, renderResourceWorkerStartStopStatus, } from "./resource-worker.js";
|
|
21
21
|
const FILE_MODE = 0o644;
|
|
22
22
|
const SCRIPT_MODE = 0o755;
|
|
23
|
+
function hasInterpolation(value) {
|
|
24
|
+
if (typeof value === "string") {
|
|
25
|
+
return /\$\{([^}]+)\}/.test(value);
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return value.some(hasInterpolation);
|
|
29
|
+
}
|
|
30
|
+
if (value && typeof value === "object") {
|
|
31
|
+
return Object.values(value).some(hasInterpolation);
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
23
35
|
const INSTALL_UIFILE = "install_uifile";
|
|
24
36
|
const UPGRADE_UIFILE = "upgrade_uifile";
|
|
25
37
|
const UNINSTALL_UIFILE = "uninstall_uifile";
|
|
38
|
+
const HEADLESS_PROBE_VOLUME = "./.synopkgland:/run/synopkgland:ro";
|
|
39
|
+
const HEADLESS_PROBE_README = "Reserved bind target for package-user headless status probing.\n";
|
|
26
40
|
export const EMBEDDED_ATTESTATION_RELATIVE_PATH = ".synopkgland/attestation.jwt";
|
|
27
41
|
const SCRIPT_NAMES = [
|
|
28
42
|
"preinst",
|
|
@@ -171,11 +185,11 @@ export async function buildSpk(options) {
|
|
|
171
185
|
const resourceProjectDir = join(staging, "package", "compose");
|
|
172
186
|
mkdirSync(resourceProjectDir, { recursive: true });
|
|
173
187
|
const dsmUserParam = collectInstallParameters(contract).find((p) => p.param.type === "dsm_user");
|
|
174
|
-
if (dsmUserParam) {
|
|
188
|
+
if (dsmUserParam || rwEligibility?.headless === true || hasOidcCompat(contract)) {
|
|
175
189
|
const rawYaml = readFileSync(options.project.composePath, "utf8");
|
|
176
190
|
const doc = parseDocument(rawYaml);
|
|
177
191
|
const services = doc.get("services");
|
|
178
|
-
if (services && typeof services === "object" && typeof services.toJSON === "function") {
|
|
192
|
+
if (dsmUserParam && services && typeof services === "object" && typeof services.toJSON === "function") {
|
|
179
193
|
const serviceNames = Object.keys(services.toJSON());
|
|
180
194
|
for (const sName of serviceNames) {
|
|
181
195
|
const userVal = new Scalar(`\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}`);
|
|
@@ -183,15 +197,53 @@ export async function buildSpk(options) {
|
|
|
183
197
|
doc.setIn(["services", sName, "user"], userVal);
|
|
184
198
|
}
|
|
185
199
|
}
|
|
200
|
+
if (hasOidcCompat(contract) && services && typeof services === "object" && typeof services.toJSON === "function") {
|
|
201
|
+
const rawServices = services.toJSON();
|
|
202
|
+
for (const [sName, sValue] of Object.entries(rawServices)) {
|
|
203
|
+
if (sValue && typeof sValue === "object" && hasInterpolation(sValue)) {
|
|
204
|
+
const str = JSON.stringify(sValue);
|
|
205
|
+
if (str.includes("SYNOPKGLAND_OIDC_ISSUER")) {
|
|
206
|
+
const serviceDoc = doc.getIn(["services", sName]);
|
|
207
|
+
const existingExtraHosts = serviceDoc?.get?.("extra_hosts");
|
|
208
|
+
const hostGatewayMapping = "${SYNOPKGLAND_OIDC_ISSUER_HOST}:host-gateway";
|
|
209
|
+
if (!existingExtraHosts) {
|
|
210
|
+
doc.setIn(["services", sName, "extra_hosts"], [hostGatewayMapping]);
|
|
211
|
+
}
|
|
212
|
+
else if (Array.isArray(existingExtraHosts.toJSON?.())) {
|
|
213
|
+
const arr = existingExtraHosts.toJSON();
|
|
214
|
+
if (!arr.includes(hostGatewayMapping)) {
|
|
215
|
+
doc.setIn(["services", sName, "extra_hosts"], [...arr, hostGatewayMapping]);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (rwEligibility?.headless === true) {
|
|
223
|
+
injectHeadlessProbeVolume(doc);
|
|
224
|
+
}
|
|
186
225
|
let composeContent = doc.toString();
|
|
187
|
-
|
|
188
|
-
|
|
226
|
+
if (dsmUserParam) {
|
|
227
|
+
const escaped = `\\$\\{${dsmUserParam.name}_UID\\}:\\$\\{${dsmUserParam.name}_GID\\}`;
|
|
228
|
+
composeContent = composeContent.replace(new RegExp(`^([ \\t]*)user:\\s*"${escaped}"`, "gm"), `$1user: "\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}"\n$1# synopkgland:group_add ${dsmUserParam.name}`);
|
|
229
|
+
}
|
|
189
230
|
writeFileSync(join(resourceProjectDir, "docker-compose.yml"), composeContent, "utf8");
|
|
190
231
|
}
|
|
191
232
|
else {
|
|
192
233
|
copyFileSync(options.project.composePath, join(resourceProjectDir, "docker-compose.yml"));
|
|
193
234
|
}
|
|
194
235
|
chmodSync(join(resourceProjectDir, "docker-compose.yml"), FILE_MODE);
|
|
236
|
+
if (rwEligibility?.headless === true) {
|
|
237
|
+
const probePayloadDir = join(resourceProjectDir, ".synopkgland");
|
|
238
|
+
mkdirSync(probePayloadDir, { recursive: true });
|
|
239
|
+
writeFileSync(join(probePayloadDir, "README"), HEADLESS_PROBE_README, "utf8");
|
|
240
|
+
chmodSync(join(probePayloadDir, "README"), FILE_MODE);
|
|
241
|
+
}
|
|
242
|
+
const routeMarkerDir = join(staging, "package", ".synopkgland");
|
|
243
|
+
mkdirSync(routeMarkerDir, { recursive: true });
|
|
244
|
+
const routeMarker = join(routeMarkerDir, "route");
|
|
245
|
+
writeFileSync(routeMarker, "package-only", "utf8");
|
|
246
|
+
chmodSync(routeMarker, FILE_MODE);
|
|
195
247
|
}
|
|
196
248
|
const htmlDir = join(options.project.root, "html");
|
|
197
249
|
if (existsSync(htmlDir)) {
|
|
@@ -382,6 +434,23 @@ function composeUsesOnlyAppSpace(compose) {
|
|
|
382
434
|
const sources = volumeSources(compose);
|
|
383
435
|
return sources.every((source) => source.startsWith("${SYNOPKG_PKGVAR}") || source.startsWith("${SYNO_APP_SECRETS_DIR}"));
|
|
384
436
|
}
|
|
437
|
+
function injectHeadlessProbeVolume(doc) {
|
|
438
|
+
const compose = (doc.toJSON() ?? {});
|
|
439
|
+
const serviceName = Object.keys(compose.services ?? {})[0];
|
|
440
|
+
if (!serviceName) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const existingVolumes = compose.services?.[serviceName]?.volumes;
|
|
444
|
+
const volumes = existingVolumes == null
|
|
445
|
+
? []
|
|
446
|
+
: Array.isArray(existingVolumes)
|
|
447
|
+
? [...existingVolumes]
|
|
448
|
+
: [existingVolumes];
|
|
449
|
+
if (!volumes.some((volume) => volume === HEADLESS_PROBE_VOLUME)) {
|
|
450
|
+
volumes.push(HEADLESS_PROBE_VOLUME);
|
|
451
|
+
}
|
|
452
|
+
doc.setIn(["services", serviceName, "volumes"], volumes);
|
|
453
|
+
}
|
|
385
454
|
const PRIVILEGE_MAX_WARNING = "此 App 的容器有高權限,接近這台 NAS 的 root、幾乎整台機器。是否安裝由你這位管理員決定。";
|
|
386
455
|
function renderInstallWizard(contract, compose) {
|
|
387
456
|
const items = [];
|
|
@@ -653,6 +722,14 @@ function renderDynamicPreinstMaterializer(contract) {
|
|
|
653
722
|
if (dsmUserParam) {
|
|
654
723
|
lines.push("resolve_dsm_user() {", " user_name=\"$1\"", " case \"$user_name\" in", " */*|*..*|*[[:cntrl:]]*) die \"invalid user name $user_name\" ;;", " '') die \"user name is empty\" ;;", " esac", " u_uid=\"$(id -u -- \"$user_name\" 2>/dev/null || true)\"", " case \"$u_uid\" in", " ''|*[!0-9]*) die \"resolved uid for $user_name is not a valid integer\" ;;", " 0) die \"resolved uid for $user_name cannot be 0 (root)\" ;;", " esac", " u_gid=\"$(id -g -- \"$user_name\" 2>/dev/null || true)\"", " case \"$u_gid\" in", " ''|*[!0-9]*) die \"resolved gid for $user_name is not a valid integer\" ;;", " esac", " # id -G returns all group ids including primary", " raw_groups=\"$(id -G -- \"$user_name\" 2>/dev/null || true)\"", " # filter out primary gid, deduplicate, keep numeric only", " supp_groups=\"\"", " for g in $raw_groups; do", " case \"$g\" in", " ''|*[!0-9]*) continue ;;", " esac", " if [ \"$g\" != \"$u_gid\" ]; then", " already=0", " for sg in $supp_groups; do", " if [ \"$sg\" = \"$g\" ]; then", " already=1", " break", " fi", " done", " if [ \"$already\" -eq 0 ]; then", " supp_groups=\"${supp_groups}${supp_groups:+ }$g\"", " fi", " fi", " done", " printf '%s %s %s' \"$u_uid\" \"$u_gid\" \"$supp_groups\"", "}", "rewrite_group_add() {", " p_name=\"$1\"", " g_list=\"$2\"", " compose_file=\"$3\"", " [ -f \"$compose_file\" ] || die \"compose file missing at $compose_file\"", " [ ! -L \"$compose_file\" ] || die \"compose file is a symlink\"", " tmp_compose=\"$(mktemp \"${compose_file}.tmp.XXXXXX\")\"", " awk -v param=\"$p_name\" -v g_list=\"$g_list\" '", " BEGIN {", " n = split(g_list, g_arr, \" \")", " }", " {", " regex = \"^[ \\t]*# synopkgland:group_add \" param \"$\"", " if ($0 ~ regex) {", " match($0, /^[ \\t]*/)", " indent = substr($0, RSTART, RLENGTH)", " if (n > 0) {", " print indent \"group_add:\"", " for (i = 1; i <= n; i++) {", " print indent \" - ${\" param \"_GROUP_\" i \"}\"", " }", " }", " } else {", " print $0", " }", " }", " ' \"$compose_file\" > \"$tmp_compose\" || die \"failed to rewrite group_add in $compose_file\"", " mv \"$tmp_compose\" \"$compose_file\" || die \"failed to replace $compose_file\"", "}");
|
|
655
724
|
}
|
|
725
|
+
if (hasOidcCompat(contract)) {
|
|
726
|
+
const identity = contract.integration?.identity;
|
|
727
|
+
const redirectPath = identity?.oidc_compat?.redirect_path ?? "";
|
|
728
|
+
const adminProtocol = contract.adminprotocol ?? "http";
|
|
729
|
+
const adminPort = Number(contract.adminport ?? 0);
|
|
730
|
+
const escapedRedirectPath = redirectPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
731
|
+
lines.push("oidc_register() {", " # DSM compose does not create missing bind sources: the secrets dir must exist even when compat is off.", " (umask 077 && mkdir -p \"$PROJECT/secrets\" && chmod 700 \"$PROJECT/secrets\") || die \"failed to create $PROJECT/secrets\"", " OIDC_ISSUER=\"\"", " OIDC_ISSUER_HOST=\"oidc.invalid\"", " OIDC_CLIENT_ID=\"\"", " OIDC_CLIENT_SECRET=\"\"", " identity_file=\"${SYNOPKGLAND_IDENTITY_FILE:-/var/packages/SynoPkgLandService/var/identity.json}\"", " if [ ! -r \"$identity_file\" ]; then", " identity_file=\"/volume1/@appdata/SynoPkgLandService/identity.json\"", " fi", " if [ ! -r \"$identity_file\" ]; then", " # Identity endpoint not found -> Broker not installed -> skip as 404", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " return 0", " fi", " base_url=\"$(python3 -c 'import json, sys; data=json.load(open(sys.argv[1])); print(data.get(\"baseUrl\", \"\"))' \"$identity_file\" 2>/dev/null || true)\"", " if [ -z \"$base_url\" ]; then", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " return 0", " fi", " attestation_file=\"$T/.synopkgland/attestation.jwt\"", " if [ ! -f \"$attestation_file\" ]; then", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " return 0", " fi", " attestation_token=\"$(cat \"$attestation_file\")\"", " phase=\"install\"", " case \"$STATUS\" in", " UPGRADE) phase=\"upgrade\" ;;", " *) phase=\"install\" ;;", " esac", " req_body=\"$(python3 -c 'import json, sys; print(json.dumps({\"protocolVersion\": 1, \"attestation\": sys.argv[1], \"packageIdentity\": sys.argv[2], \"packageVersion\": sys.argv[3], \"redirectPath\": sys.argv[4], \"adminProtocol\": sys.argv[5], \"adminPort\": int(sys.argv[6]), \"phase\": sys.argv[7]}))' \"$attestation_token\" \"${SYNOPKG_PKGNAME:?}\" \"${SYNOPKG_PKGVER:?}\" \"" + escapedRedirectPath + "\" \"" + String(adminProtocol) + "\" \"" + String(adminPort) + "\" \"$phase\")\"", " resp_file=\"$(mktemp \"$T/oidc_resp.XXXXXX\")\"", " http_code=\"$(curl -s -S --connect-timeout 5 --max-time 30 -o \"$resp_file\" -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data-binary \"$req_body\" \"${base_url}/internal/v1/oidc/register\" 2>/dev/null || true)\"", " case \"$http_code\" in [0-9][0-9][0-9]) ;; *) http_code=\"000\" ;; esac", " if [ \"$http_code\" = \"200\" ]; then", " parsed_res=\"$(python3 -c 'import json, sys; d=json.load(open(sys.argv[1])); print(d.get(\"issuer\", \"\")); print(d.get(\"issuerHost\", \"\")); print(d.get(\"clientId\", \"\")); print(d.get(\"clientSecret\", \"\"))' \"$resp_file\" 2>/dev/null || true)\"", " rm -f \"$resp_file\"", " OIDC_ISSUER=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '1p')\"", " OIDC_ISSUER_HOST=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '2p')\"", " OIDC_CLIENT_ID=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '3p')\"", " OIDC_CLIENT_SECRET=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '4p')\"", " (umask 077 && mkdir -p \"$PROJECT/secrets\" && printf '%s' \"$OIDC_CLIENT_SECRET\" > \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\")", " elif [ \"$http_code\" = \"404\" ] || [ \"$http_code\" = \"000\" ]; then", " rm -f \"$resp_file\"", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " OIDC_ISSUER=\"\"", " OIDC_ISSUER_HOST=\"oidc.invalid\"", " OIDC_CLIENT_ID=\"\"", " else", " rm -f \"$resp_file\"", " die \"OIDC_REGISTER_FAILED $http_code\"", " fi", "}", "oidc_register");
|
|
732
|
+
}
|
|
656
733
|
lines.push('tmp="$(mktemp "$PROJECT/.env.tmp.XXXXXX")"', 'chmod 600 "$tmp"', '[ "$(stat -c %a "$tmp")" = "600" ] || die "temporary env mode is not 0600"', '[ "$(stat -c %u "$tmp")" = "$(id -u)" ] || die "temporary env owner is not the package user"', "trap 'rm -f \"$tmp\"' EXIT HUP INT TERM", "{");
|
|
657
734
|
const valueVars = [];
|
|
658
735
|
for (const { name, param } of scalarParams) {
|
|
@@ -681,7 +758,12 @@ function renderDynamicPreinstMaterializer(contract) {
|
|
|
681
758
|
lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawUserVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
|
|
682
759
|
`${rawUserVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", ` die "missing required dsm user parameter ${name}"`, " fi", ` resolved_user_info="$(resolve_dsm_user "$${rawUserVar}")"`, " set -- $resolved_user_info", ` ${name}_UID="$1"`, ` ${name}_GID="$2"`, " shift 2 || true", ` ${name}_GROUPS="$*"`, ` rewrite_group_add "${name}" "$${name}_GROUPS" "$PROJECT/docker-compose.yml"`);
|
|
683
760
|
}
|
|
684
|
-
|
|
761
|
+
if (hasOidcCompat(contract)) {
|
|
762
|
+
lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "${SYNOPKG_PKGDEST_VOL:?}/@appstore/${SYNOPKG_PKGNAME:?}/compose/secrets"', ' append_env "SYNOPKGLAND_OIDC_ISSUER" "$OIDC_ISSUER"', ' append_env "SYNOPKGLAND_OIDC_ISSUER_HOST" "$OIDC_ISSUER_HOST"', ' append_env "SYNOPKGLAND_OIDC_CLIENT_ID" "$OIDC_CLIENT_ID"');
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
|
|
766
|
+
}
|
|
685
767
|
for (const { name, valueVar, type } of valueVars) {
|
|
686
768
|
lines.push(` append_env "${name}" "$${valueVar}" "${type}"`);
|
|
687
769
|
}
|
|
@@ -79,44 +79,29 @@ export function renderResourceConfig(contract, eligibility) {
|
|
|
79
79
|
*/
|
|
80
80
|
export function renderResourceWorkerStartStopStatus(packageId, adminport, headless = false) {
|
|
81
81
|
if (headless) {
|
|
82
|
-
const projectName = composeProjectName(packageId);
|
|
83
82
|
return `#!/bin/sh
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
PROJECT_NAME="${projectName}"
|
|
83
|
+
SYNOPKG_PKGNAME="\${SYNOPKG_PKGNAME:-${packageId}}"
|
|
84
|
+
SYNOPKGLAND_PROC_ROOT="\${SYNOPKGLAND_PROC_ROOT:-/proc}"
|
|
87
85
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
86
|
+
probe_project_process() {
|
|
87
|
+
for p in "$SYNOPKGLAND_PROC_ROOT"/[0-9]*; do
|
|
88
|
+
[ -d "$p" ] || continue
|
|
89
|
+
if grep -q docker "$p/cgroup" 2>/dev/null &&
|
|
90
|
+
grep -q \\
|
|
91
|
+
"@appstore/\${SYNOPKG_PKGNAME}/compose/" \\
|
|
92
|
+
"$p/mountinfo" 2>/dev/null; then
|
|
93
|
+
return 0
|
|
94
|
+
fi
|
|
95
95
|
done
|
|
96
|
-
return
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
marker_managed() {
|
|
100
|
-
[ -f "$MARKER" ] || return 1
|
|
101
|
-
[ ! -L "$MARKER" ] || return 1
|
|
102
|
-
[ "$(cat "$MARKER" 2>/dev/null || true)" = "managed" ]
|
|
96
|
+
return 1
|
|
103
97
|
}
|
|
104
98
|
|
|
105
99
|
case "$1" in
|
|
106
|
-
start|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
exit 0
|
|
111
|
-
fi
|
|
112
|
-
# Package user may not have a usable Docker inspection path. DSM owns the
|
|
113
|
-
# docker-project lifecycle; the postinst marker records that ownership.
|
|
114
|
-
if [ "$probe_status" -eq 2 ] && marker_managed; then
|
|
115
|
-
exit 0
|
|
116
|
-
fi
|
|
117
|
-
exit 3
|
|
100
|
+
start|stop) exit 0 ;;
|
|
101
|
+
status)
|
|
102
|
+
probe_project_process
|
|
103
|
+
exit $?
|
|
118
104
|
;;
|
|
119
|
-
stop) exit 0 ;;
|
|
120
105
|
*) exit 1 ;;
|
|
121
106
|
esac
|
|
122
107
|
`;
|
package/dist/compose/analyze.js
CHANGED
|
@@ -227,10 +227,10 @@ export function classifyComposeRoute(contract, parsed, composeFile = "compose.ya
|
|
|
227
227
|
// Keep the route decision in one place. build-spk must not independently
|
|
228
228
|
// reinterpret an ineligible Compose as D1: only a route with a resolved
|
|
229
229
|
// published adminport or an eligible headless Compose, plus at least one
|
|
230
|
-
// declared install parameter, has a package-only materializer.
|
|
231
|
-
// buildable fallback is Broker.
|
|
230
|
+
// declared install parameter (or oidc_compat declaration), has a package-only materializer.
|
|
231
|
+
// Every other buildable fallback is Broker.
|
|
232
232
|
if ((eligibility.adminport !== undefined || headlessPackageOnly) &&
|
|
233
|
-
collectInstallParameters(contract).length > 0) {
|
|
233
|
+
(collectInstallParameters(contract).length > 0 || hasBrokerManagedOidc(contract))) {
|
|
234
234
|
return { route: "dynamic", eligibility, errors: [] };
|
|
235
235
|
}
|
|
236
236
|
return { route: "broker-installer", eligibility, errors: [] };
|
|
@@ -311,7 +311,7 @@ function collectDynamicRouteError(contract, parsed, composeFile) {
|
|
|
311
311
|
param.type !== "boolean" &&
|
|
312
312
|
param.type !== "shared_folder" &&
|
|
313
313
|
param.type !== "dsm_user"));
|
|
314
|
-
const brokerOidcInterpolationNames =
|
|
314
|
+
const brokerOidcInterpolationNames = hasBrokerManagedOidc(contract)
|
|
315
315
|
? BROKER_OIDC_INTERPOLATION_VARS
|
|
316
316
|
: [];
|
|
317
317
|
const supportedInterpolationNames = new Set([
|
|
@@ -614,6 +614,7 @@ const PLATFORM_BIND_VARS = new Set(["SYNOPKG_PKGVAR", "SYNO_APP_SECRETS_DIR"]);
|
|
|
614
614
|
const BROKER_OIDC_INTERPOLATION_VARS = new Set([
|
|
615
615
|
"SYNOPKGLAND_OIDC_ISSUER",
|
|
616
616
|
"SYNOPKGLAND_OIDC_CLIENT_ID",
|
|
617
|
+
"SYNOPKGLAND_OIDC_ISSUER_HOST",
|
|
617
618
|
]);
|
|
618
619
|
function hasBrokerManagedOidc(contract) {
|
|
619
620
|
if (!contract.integration || typeof contract.integration !== "object") {
|
package/dist/deploy/deploy.js
CHANGED
|
@@ -129,8 +129,9 @@ export async function deployProject(options) {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
|
+
let attestationResult;
|
|
132
133
|
try {
|
|
133
|
-
await options.host.requestAttestation({
|
|
134
|
+
attestationResult = await options.host.requestAttestation({
|
|
134
135
|
packageIdentity: identity.packageId,
|
|
135
136
|
contractDigest,
|
|
136
137
|
bundleDigest,
|
|
@@ -185,6 +186,7 @@ export async function deployProject(options) {
|
|
|
185
186
|
displayname: identity.displayname,
|
|
186
187
|
spkVersion,
|
|
187
188
|
archFamily,
|
|
189
|
+
embeddedAttestationToken: attestationResult.token,
|
|
188
190
|
});
|
|
189
191
|
if (!build.ok || !build.outputPath) {
|
|
190
192
|
return { ok: false, code: "BUILD_SPK_FAILED", message: build.errors.join(", ") };
|
package/dist/doctor.js
CHANGED
|
@@ -6,6 +6,19 @@ 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
|
+
function packageRouteFromDiagnostics(diagnostics) {
|
|
10
|
+
switch (diagnostics?.packageRoute) {
|
|
11
|
+
case "package-only":
|
|
12
|
+
case "broker-installer":
|
|
13
|
+
case "unknown":
|
|
14
|
+
return diagnostics.packageRoute;
|
|
15
|
+
default:
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
// Keep compatibility with the boolean-only response emitted by older
|
|
19
|
+
// Brokers, but never infer broker-installer from missing evidence.
|
|
20
|
+
return diagnostics?.isPackageOnly === true ? "package-only" : "unknown";
|
|
21
|
+
}
|
|
9
22
|
const PROJECT_FOUR_FILES = ["synology-app.yaml", "README.md", "icon.png"];
|
|
10
23
|
function packageFailureSummary(state) {
|
|
11
24
|
const fields = [];
|
|
@@ -199,6 +212,11 @@ export async function runDoctor(options) {
|
|
|
199
212
|
message: "未指定 --host,略過 Trusted App Entry 檢查",
|
|
200
213
|
});
|
|
201
214
|
}
|
|
215
|
+
checks.push({
|
|
216
|
+
id: "host.oidc_origin",
|
|
217
|
+
status: "skip",
|
|
218
|
+
message: "未指定 --host,略過 OIDC origin 檢查",
|
|
219
|
+
});
|
|
202
220
|
}
|
|
203
221
|
else {
|
|
204
222
|
const host = options.host;
|
|
@@ -240,6 +258,11 @@ export async function runDoctor(options) {
|
|
|
240
258
|
status: "skip",
|
|
241
259
|
message: "NAS 無法連線,略過 Attestation Trust Root 檢查",
|
|
242
260
|
});
|
|
261
|
+
checks.push({
|
|
262
|
+
id: "host.oidc_origin",
|
|
263
|
+
status: "skip",
|
|
264
|
+
message: "NAS 無法連線,略過 OIDC origin 檢查",
|
|
265
|
+
});
|
|
243
266
|
if (options.packageId) {
|
|
244
267
|
checks.push({
|
|
245
268
|
id: "host.package_binding",
|
|
@@ -382,6 +405,50 @@ export async function runDoctor(options) {
|
|
|
382
405
|
}
|
|
383
406
|
}
|
|
384
407
|
}
|
|
408
|
+
// Check 2.3a: OIDC-compatible public origin. This is deliberately
|
|
409
|
+
// optional so a pre-A39 Broker remains a usable doctor target.
|
|
410
|
+
if (!isBrokerReady || !host.getOidcCompatSettings) {
|
|
411
|
+
checks.push({
|
|
412
|
+
id: "host.oidc_origin",
|
|
413
|
+
status: "skip",
|
|
414
|
+
message: !isBrokerReady
|
|
415
|
+
? "Broker 未就緒,略過 OIDC origin 檢查"
|
|
416
|
+
: "舊版 Broker 沒有 OIDC origin endpoint,略過",
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
try {
|
|
421
|
+
const oidc = await host.getOidcCompatSettings();
|
|
422
|
+
if (!oidc) {
|
|
423
|
+
checks.push({
|
|
424
|
+
id: "host.oidc_origin",
|
|
425
|
+
status: "skip",
|
|
426
|
+
message: "舊版 Broker 沒有 OIDC origin endpoint,略過",
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
else if (oidc.origin.ready && oidc.origin.hostname) {
|
|
430
|
+
checks.push({
|
|
431
|
+
id: "host.oidc_origin",
|
|
432
|
+
status: "pass",
|
|
433
|
+
message: `${oidc.origin.kind} ${oidc.origin.hostname}`,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
checks.push({
|
|
438
|
+
id: "host.oidc_origin",
|
|
439
|
+
status: "skip",
|
|
440
|
+
message: `未就緒(${oidc.origin.kind}):${oidc.origin.setupHint}`,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
checks.push({
|
|
446
|
+
id: "host.oidc_origin",
|
|
447
|
+
status: "skip",
|
|
448
|
+
message: "OIDC origin endpoint 無法取得,略過",
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
385
452
|
// Check 2.3b: DSM Residue check (A29)
|
|
386
453
|
if (isBrokerReady && host.getDsmDiagnostics) {
|
|
387
454
|
try {
|
|
@@ -472,26 +539,34 @@ export async function runDoctor(options) {
|
|
|
472
539
|
else {
|
|
473
540
|
const info = await host.getAttestationInfo(targetPackageId);
|
|
474
541
|
if (info.readiness === "NOT_INSTALLED") {
|
|
475
|
-
// Check
|
|
476
|
-
|
|
542
|
+
// Check the diagnosed route before treating a missing binding
|
|
543
|
+
// as a Broker-installer failure. Unknown is deliberately
|
|
544
|
+
// non-failing because the Broker may not be able to read the
|
|
545
|
+
// DSM package resource.
|
|
546
|
+
let packageRoute = "unknown";
|
|
477
547
|
if (host.getDsmDiagnostics) {
|
|
478
548
|
try {
|
|
479
549
|
const diag = await host.getDsmDiagnostics(targetPackageId);
|
|
480
|
-
|
|
481
|
-
isPackageOnly = true;
|
|
482
|
-
}
|
|
550
|
+
packageRoute = packageRouteFromDiagnostics(diag);
|
|
483
551
|
}
|
|
484
552
|
catch {
|
|
485
553
|
// ignore
|
|
486
554
|
}
|
|
487
555
|
}
|
|
488
|
-
if (
|
|
556
|
+
if (packageRoute === "package-only") {
|
|
489
557
|
checks.push({
|
|
490
558
|
id: "host.package_binding",
|
|
491
559
|
status: "skip",
|
|
492
560
|
message: "package-only 路線,無 Broker binding(略過)",
|
|
493
561
|
});
|
|
494
562
|
}
|
|
563
|
+
else if (packageRoute === "unknown") {
|
|
564
|
+
checks.push({
|
|
565
|
+
id: "host.package_binding",
|
|
566
|
+
status: "skip",
|
|
567
|
+
message: "Broker 無法判定套件路線,略過 Package Binding 檢查",
|
|
568
|
+
});
|
|
569
|
+
}
|
|
495
570
|
else {
|
|
496
571
|
const errorEntry = getErrorCode("PROJECT_BINDING_NOT_FOUND");
|
|
497
572
|
checks.push({
|
package/dist/host/dsm-client.js
CHANGED
|
@@ -8,6 +8,7 @@ import { lookupSharePathWithSynoagentcli } from "./share-lookup.js";
|
|
|
8
8
|
import { dsmFetch } from "./dsm-http.js";
|
|
9
9
|
import { installPackageFromCatalog, installPackageFromLocalSpk, uninstallPackageFromDsm, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
|
|
10
10
|
import { resolveHostCredentials } from "./credentials.js";
|
|
11
|
+
const OIDC_COMPAT_PATH = "/api/v1/inner-loop/oidc-compat";
|
|
11
12
|
export function createNasHost(options = {}) {
|
|
12
13
|
const credentials = options.credentials ?? resolveHostCredentials(options.host);
|
|
13
14
|
if (!credentials) {
|
|
@@ -160,7 +161,12 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
160
161
|
error.code = body.error?.code;
|
|
161
162
|
throw error;
|
|
162
163
|
}
|
|
163
|
-
|
|
164
|
+
const token = typeof body.token === "string" && body.token.length > 0 ? body.token : undefined;
|
|
165
|
+
return {
|
|
166
|
+
kind: "local",
|
|
167
|
+
attestationDigest: body.attestationDigest ?? "",
|
|
168
|
+
...(token !== undefined ? { token } : {}),
|
|
169
|
+
};
|
|
164
170
|
},
|
|
165
171
|
async ensurePackageSource(origin) {
|
|
166
172
|
ensurePackageSourceFeed(origin);
|
|
@@ -343,6 +349,51 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
343
349
|
return undefined;
|
|
344
350
|
}
|
|
345
351
|
},
|
|
352
|
+
async getOidcCompatSettings() {
|
|
353
|
+
try {
|
|
354
|
+
await ensureLogin();
|
|
355
|
+
const response = await dsmFetch(`${brokerBaseUrl}${OIDC_COMPAT_PATH}`, {
|
|
356
|
+
headers: { cookie: cookieHeader ?? "" },
|
|
357
|
+
});
|
|
358
|
+
const text = await response.text();
|
|
359
|
+
if (response.status === 404 || text.trimStart().startsWith("<")) {
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
if (!response.ok) {
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
const payload = JSON.parse(text);
|
|
366
|
+
const settings = payload.settings;
|
|
367
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
const record = settings;
|
|
371
|
+
const origin = record.origin;
|
|
372
|
+
if (!origin || typeof origin !== "object" || Array.isArray(origin)) {
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
const originRecord = origin;
|
|
376
|
+
if (typeof record.enabled !== "boolean" ||
|
|
377
|
+
typeof originRecord.ready !== "boolean" ||
|
|
378
|
+
typeof originRecord.kind !== "string" ||
|
|
379
|
+
typeof originRecord.setupHint !== "string") {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
enabled: record.enabled,
|
|
384
|
+
origin: {
|
|
385
|
+
ready: originRecord.ready,
|
|
386
|
+
kind: originRecord.kind,
|
|
387
|
+
hostname: typeof originRecord.hostname === "string" ? originRecord.hostname : undefined,
|
|
388
|
+
issuerUrl: typeof originRecord.issuerUrl === "string" ? originRecord.issuerUrl : undefined,
|
|
389
|
+
setupHint: originRecord.setupHint,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
},
|
|
346
397
|
async smokeRequireLogin(input) {
|
|
347
398
|
await ensureLogin();
|
|
348
399
|
const info = await this.getAttestationInfo(input.packageIdentity);
|
package/dist/host/fake-nas.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AttestationKind, HostInstallRecord, NasHost } from "./types.js";
|
|
1
|
+
import type { AttestationKind, HostInstallRecord, HostOidcCompatSettings, NasHost, PackageRoute } from "./types.js";
|
|
2
2
|
export interface FakeNasHostOptions {
|
|
3
3
|
curated?: string[];
|
|
4
4
|
dsmOrigin?: string;
|
|
@@ -8,10 +8,12 @@ export interface FakeNasHostOptions {
|
|
|
8
8
|
installedEnv?: Record<string, Record<string, string>>;
|
|
9
9
|
diagnostics?: {
|
|
10
10
|
dsmResidue?: import("./types.js").DsmResidueInventory;
|
|
11
|
-
packageRoute?:
|
|
11
|
+
packageRoute?: PackageRoute;
|
|
12
12
|
isPackageOnly?: boolean;
|
|
13
13
|
[key: string]: unknown;
|
|
14
14
|
};
|
|
15
|
+
/** Omit to model an older Broker without the OIDC settings endpoint. */
|
|
16
|
+
oidcCompat?: HostOidcCompatSettings;
|
|
15
17
|
}
|
|
16
18
|
export declare function createFakeNasHost(options?: FakeNasHostOptions): NasHost & {
|
|
17
19
|
installs: HostInstallRecord[];
|
package/dist/host/fake-nas.js
CHANGED
|
@@ -49,7 +49,11 @@ export function createFakeNasHost(options = {}) {
|
|
|
49
49
|
}
|
|
50
50
|
const digest = `${input.packageIdentity}:${input.bundleDigest}:${input.contractDigest}`;
|
|
51
51
|
host.attestations.add(input.packageIdentity);
|
|
52
|
-
return {
|
|
52
|
+
return {
|
|
53
|
+
kind: "local",
|
|
54
|
+
attestationDigest: digest,
|
|
55
|
+
token: `fake-local-attestation.${input.packageIdentity}`,
|
|
56
|
+
};
|
|
53
57
|
},
|
|
54
58
|
async ensurePackageSource() { },
|
|
55
59
|
async installFromCatalog(input) {
|
|
@@ -111,6 +115,15 @@ export function createFakeNasHost(options = {}) {
|
|
|
111
115
|
async getDsmDiagnostics() {
|
|
112
116
|
return options.diagnostics;
|
|
113
117
|
},
|
|
118
|
+
async getOidcCompatSettings() {
|
|
119
|
+
if (!options.oidcCompat) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
enabled: options.oidcCompat.enabled,
|
|
124
|
+
origin: { ...options.oidcCompat.origin },
|
|
125
|
+
};
|
|
126
|
+
},
|
|
114
127
|
async smokeRequireLogin() {
|
|
115
128
|
return { ok: true, username: "admin" };
|
|
116
129
|
},
|
package/dist/host/types.d.ts
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import type { InstalledPackageState } from "./catalog-install.js";
|
|
2
2
|
export type AttestationKind = "local" | "app-package" | "none";
|
|
3
|
+
export type PackageRoute = "package-only" | "broker-installer" | "unknown";
|
|
4
|
+
export type OidcOriginKind = "public-ca" | "quickconnect" | "lan-ip" | "self-signed" | "private-ca" | "http";
|
|
5
|
+
export interface HostOidcOriginEvaluation {
|
|
6
|
+
ready: boolean;
|
|
7
|
+
kind: OidcOriginKind;
|
|
8
|
+
hostname?: string;
|
|
9
|
+
issuerUrl?: string;
|
|
10
|
+
setupHint: string;
|
|
11
|
+
}
|
|
12
|
+
export interface HostOidcCompatSettings {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
origin: HostOidcOriginEvaluation;
|
|
15
|
+
}
|
|
3
16
|
export interface DeployIdentity {
|
|
4
17
|
packageId: string;
|
|
5
18
|
displayname: string;
|
|
@@ -8,6 +21,7 @@ export interface DeployIdentity {
|
|
|
8
21
|
export interface AttestationIssueResult {
|
|
9
22
|
kind: "local";
|
|
10
23
|
attestationDigest: string;
|
|
24
|
+
token?: string;
|
|
11
25
|
}
|
|
12
26
|
export interface ResidueItem {
|
|
13
27
|
kind: "port_profile_sc" | "cm_project_config";
|
|
@@ -74,10 +88,12 @@ export interface NasHost {
|
|
|
74
88
|
fetchContainerLogs(packageIdentity: string): Promise<string[]>;
|
|
75
89
|
getDsmDiagnostics?(packageIdentity?: string): Promise<{
|
|
76
90
|
dsmResidue?: DsmResidueInventory;
|
|
77
|
-
packageRoute?:
|
|
91
|
+
packageRoute?: PackageRoute;
|
|
78
92
|
isPackageOnly?: boolean;
|
|
79
93
|
[key: string]: unknown;
|
|
80
94
|
} | undefined>;
|
|
95
|
+
/** Read the optional administrator OIDC compatibility settings endpoint. */
|
|
96
|
+
getOidcCompatSettings?(): Promise<HostOidcCompatSettings | undefined>;
|
|
81
97
|
smokeRequireLogin(input: {
|
|
82
98
|
packageIdentity: string;
|
|
83
99
|
cookieHeader: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 部署(Deploy)
|
|
2
2
|
|
|
3
|
-
Inner Loop:thin SPK、NAS Administrator DSM 授權、Local Attestation、經 Package Center
|
|
3
|
+
Inner Loop:thin SPK、NAS Administrator DSM 授權、Local Attestation、經 Package Center 安裝。Personal deploy 會把 Broker 簽發的 local attestation 內嵌到套件供 preinst 使用。**不要 SSH**,不要把 Compose 貼進 Container Manager 手工裝。
|
|
4
4
|
|
|
5
5
|
## 前置
|
|
6
6
|
|