@synopackageland/cli 0.41.12 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +27 -12
- package/dist/ctl/cli.d.ts +5 -0
- package/dist/ctl/cli.js +12 -5
- package/dist/ctl/descriptor.js +2 -1
- package/dist/ctl/input.d.ts +5 -1
- package/dist/ctl/input.js +28 -14
- package/dist/ctl/pattern.d.ts +12 -0
- package/dist/ctl/pattern.js +10 -0
- package/dist/ctl/redaction.js +21 -2
- package/dist/ctl/service.d.ts +17 -5
- package/dist/ctl/service.js +33 -22
- package/dist/e2e/app-open.js +5 -1
- package/dist/error-registry.js +2 -2
- package/dist/host/package-commands.d.ts +15 -0
- package/dist/host/package-commands.js +15 -4
- package/dist/runtime/sdk-source.d.ts +2 -0
- package/dist/runtime/sdk-source.js +2 -0
- package/dist/spk/archive-inventory.d.ts +9 -0
- package/dist/spk/archive-inventory.js +65 -0
- package/dist/spk/check-payload.js +9 -0
- package/dist/spk/rules.d.ts +5 -0
- package/dist/spk/rules.js +1 -0
- package/dist/spk/validate-spk.js +12 -0
- package/dist/standalone/compose.js +4 -4
- package/dist/standalone/daemon-ingress.d.ts +3 -2
- package/dist/standalone/daemon-ingress.js +12 -9
- package/dist/standalone/daemon-restart.d.ts +1 -1
- package/dist/standalone/daemon-restart.js +3 -3
- package/dist/standalone/daemon.d.ts +2 -2
- package/dist/standalone/daemon.js +5 -5
- package/dist/standalone/inspect-bridge.d.ts +3 -1
- package/dist/standalone/inspect-bridge.js +6 -3
- package/dist/standalone/inspect-spk.js +27 -7
- package/dist/standalone/install-settings.js +2 -2
- package/dist/standalone/manifest.js +1 -1
- package/dist/standalone/native-layout.js +18 -1
- package/dist/template-paths.d.ts +0 -4
- package/dist/template-paths.js +0 -11
- package/docs/ctl-external.md +16 -0
- package/package.json +3 -2
- package/skills/SKILL.md +1 -1
- package/skills/reference/ctl.md +24 -0
- package/skills/reference/daemon-mode.md +5 -0
- package/skills/reference/install.md +1 -1
|
@@ -122,6 +122,8 @@ export async function installPackage(spec, options = {}, connection) {
|
|
|
122
122
|
if (installed.has(item.package) &&
|
|
123
123
|
compareVersions(item.version, installed.get(item.package)) < 0)
|
|
124
124
|
throw fail("PACKAGE_DOWNGRADE_REFUSED", `Refusing downgrade of ${item.package}`, "Choose the installed version or a newer release");
|
|
125
|
+
const changes = plan.filter((item) => !installed.has(item.package) ||
|
|
126
|
+
compareVersions(item.version, installed.get(item.package)) !== 0);
|
|
125
127
|
const publicPlan = {
|
|
126
128
|
host: nas.name,
|
|
127
129
|
target,
|
|
@@ -130,21 +132,30 @@ export async function installPackage(spec, options = {}, connection) {
|
|
|
130
132
|
package: item.package,
|
|
131
133
|
version: item.version,
|
|
132
134
|
size: item.size,
|
|
133
|
-
action: installed.has(item.package)
|
|
135
|
+
action: !installed.has(item.package)
|
|
136
|
+
? "install"
|
|
137
|
+
: compareVersions(item.version, installed.get(item.package)) === 0
|
|
138
|
+
? "none"
|
|
139
|
+
: "upgrade",
|
|
134
140
|
...(item.package === root.package && report
|
|
135
141
|
? { signed: report.signed }
|
|
136
142
|
: {}),
|
|
137
143
|
})),
|
|
138
144
|
parameters: Object.keys(options.param ?? {}),
|
|
145
|
+
...(!changes.length
|
|
146
|
+
? { message: `${root.package}@${root.version} is already up to date; no package changes required.` }
|
|
147
|
+
: {}),
|
|
139
148
|
};
|
|
140
149
|
if (options.dryRun)
|
|
141
150
|
return { dryRun: true, ...publicPlan };
|
|
151
|
+
if (!changes.length)
|
|
152
|
+
return publicPlan;
|
|
142
153
|
await requireAdministrator(nas);
|
|
143
|
-
await confirm(`Install ${
|
|
154
|
+
await confirm(`Install ${changes.map((e) => e.package + "@" + e.version).join(", ")}`, options.yes);
|
|
144
155
|
// Download, verify and inspect the entire plan before the first NAS write.
|
|
145
156
|
const files = new Map();
|
|
146
157
|
const images = new Map();
|
|
147
|
-
for (const entry of
|
|
158
|
+
for (const entry of changes) {
|
|
148
159
|
let file = localFile;
|
|
149
160
|
if (!local || entry.package !== root.package) {
|
|
150
161
|
const response = await fetchPackageSource(entry.link);
|
|
@@ -163,7 +174,7 @@ export async function installPackage(spec, options = {}, connection) {
|
|
|
163
174
|
files.set(entry.package, file);
|
|
164
175
|
images.set(entry.package, containerImageReferences(checked));
|
|
165
176
|
}
|
|
166
|
-
for (const entry of
|
|
177
|
+
for (const entry of changes)
|
|
167
178
|
await installPackageFromLocalSpk({
|
|
168
179
|
dsmOrigin: nas.session.origin,
|
|
169
180
|
cookieHeader: nas.session.cookie,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { goPrivateCovers } from './go-env.js';
|
|
2
2
|
import { type ExecutorManifest } from './sdk-executor-manifest.js';
|
|
3
|
+
/** Pinned SDK for CLI-owned controllers and Compose planning; App pins take precedence. */
|
|
4
|
+
export declare const DEFAULT_SDK_VERSION = "0.41.1";
|
|
3
5
|
export declare const SDK_PACKAGE = "@synopackageland/sdk";
|
|
4
6
|
export declare const SDK_MODULE = "github.com/synopackageland/syno-pkg-land-sdk";
|
|
5
7
|
export declare const EXECUTOR_MODULE = "github.com/synopackageland/syno-pkg-land-sdk/executor";
|
|
@@ -4,6 +4,8 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { readExecutorManifest, predatesExecutorManifest } from './sdk-executor-manifest.js';
|
|
7
|
+
/** Pinned SDK for CLI-owned controllers and Compose planning; App pins take precedence. */
|
|
8
|
+
export const DEFAULT_SDK_VERSION = '0.41.1';
|
|
7
9
|
export const SDK_PACKAGE = '@synopackageland/sdk';
|
|
8
10
|
export const SDK_MODULE = 'github.com/synopackageland/syno-pkg-land-sdk';
|
|
9
11
|
export const EXECUTOR_MODULE = `${SDK_MODULE}/executor`;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface ArchiveIssue {
|
|
2
|
+
kind: 'path' | 'type' | 'duplicate' | 'collision' | 'unexpected';
|
|
3
|
+
path: string;
|
|
4
|
+
type: string;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export declare const formatArchiveIssue: (issue: ArchiveIssue) => string;
|
|
8
|
+
/** Scan names and types before extraction can merge or replace archive members. */
|
|
9
|
+
export declare function archiveInventory(file: string, outer?: boolean, allowPayloadLinks?: boolean): Promise<ArchiveIssue[]>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as tar from 'tar';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { KNOWN_TOP_LEVEL } from './dsm-spec.js';
|
|
4
|
+
export const formatArchiveIssue = (issue) => `SPK_ARCHIVE_${issue.kind.toUpperCase()} ${issue.message}`;
|
|
5
|
+
/** Scan names and types before extraction can merge or replace archive members. */
|
|
6
|
+
export async function archiveInventory(file, outer = false, allowPayloadLinks = false) {
|
|
7
|
+
const findings = [];
|
|
8
|
+
const names = new Map();
|
|
9
|
+
const spellings = new Map();
|
|
10
|
+
const scan = (entry) => {
|
|
11
|
+
const raw = entry.path;
|
|
12
|
+
const add = (kind, message) => findings.push({ kind, path: raw, type: entry.type, message });
|
|
13
|
+
const path = raw.replace(/^\.\//, '').replace(/\/$/, '');
|
|
14
|
+
if (!path || path === '.') {
|
|
15
|
+
if (entry.type !== 'Directory')
|
|
16
|
+
add('path', `invalid root entry: ${raw}`);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const parts = path.split('/');
|
|
20
|
+
if (raw.startsWith('/') || path.includes('\\') || parts.some(p => !p || p === '.' || p === '..') || /[\x00-\x1f\x7f]/.test(path))
|
|
21
|
+
add('path', `noncanonical archive path: ${raw}`);
|
|
22
|
+
if (!['File', 'Directory', ...(allowPayloadLinks ? ['SymbolicLink', 'Link'] : [])].includes(entry.type))
|
|
23
|
+
add('type', `unexpected ${entry.type} entry: ${raw}`);
|
|
24
|
+
const previous = names.get(path);
|
|
25
|
+
if (previous)
|
|
26
|
+
add('duplicate', `repeated archive path: ${raw}`);
|
|
27
|
+
names.set(path, { path, type: entry.type });
|
|
28
|
+
// Include implicit parent directories: Bin/a and bin/b collide too.
|
|
29
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
30
|
+
const prefix = parts.slice(0, i).join('/');
|
|
31
|
+
const folded = prefix.normalize('NFC').toLowerCase();
|
|
32
|
+
const spelling = spellings.get(folded);
|
|
33
|
+
if (spelling !== undefined && spelling !== prefix)
|
|
34
|
+
add('collision', `case or Unicode alias: ${spelling} and ${prefix}`);
|
|
35
|
+
spellings.set(folded, prefix);
|
|
36
|
+
}
|
|
37
|
+
if (outer && !KNOWN_TOP_LEVEL.includes(parts[0]))
|
|
38
|
+
add('unexpected', `unknown SPK metadata entry: ${raw}`);
|
|
39
|
+
else if (outer) {
|
|
40
|
+
const directory = ['scripts', 'conf', 'WIZARD_UIFILES'].includes(parts[0]);
|
|
41
|
+
if (!directory && (parts.length > 1 || entry.type === 'Directory') ||
|
|
42
|
+
directory && parts.length === 1 && entry.type === 'File')
|
|
43
|
+
add('type', `SPK metadata has the wrong file or directory role: ${raw}`);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const parser = new tar.Parser({ strict: true, onReadEntry(entry) { scan(entry); entry.resume(); } });
|
|
47
|
+
// tar.t silently drops unknown type flags; they are still archive members.
|
|
48
|
+
parser.on('ignoredEntry', scan);
|
|
49
|
+
const input = createReadStream(file);
|
|
50
|
+
await new Promise((resolve, reject) => {
|
|
51
|
+
input.on('error', reject);
|
|
52
|
+
parser.on('error', error => { input.destroy(); reject(error); });
|
|
53
|
+
parser.on('end', resolve);
|
|
54
|
+
input.pipe(parser);
|
|
55
|
+
});
|
|
56
|
+
for (const [path] of names) {
|
|
57
|
+
const parts = path.split('/');
|
|
58
|
+
for (let i = 1; i < parts.length; i++) {
|
|
59
|
+
const parent = names.get(parts.slice(0, i).join('/'));
|
|
60
|
+
if (parent && parent.type !== 'Directory')
|
|
61
|
+
findings.push({ kind: 'collision', path, type: parent.type, message: `file used as a directory: ${parent.path}` });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
@@ -2,6 +2,7 @@ import { openSync, readSync, closeSync } from "node:fs";
|
|
|
2
2
|
import { posix } from "node:path";
|
|
3
3
|
import * as tar from "tar";
|
|
4
4
|
import { ARCH_ELF_MACHINE } from "./dsm-spec.js";
|
|
5
|
+
import { archiveInventory, formatArchiveIssue } from './archive-inventory.js';
|
|
5
6
|
const XZ_MAGIC = Buffer.from([0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]);
|
|
6
7
|
const ELF_MAGIC = Buffer.from("\x7fELF", "latin1");
|
|
7
8
|
const MAX_TEXT = 1024 * 1024;
|
|
@@ -145,6 +146,14 @@ export async function checkPayload(payloadPath, info, findings) {
|
|
|
145
146
|
return;
|
|
146
147
|
}
|
|
147
148
|
const dirs = uiDirs(info.dsmuidir);
|
|
149
|
+
try {
|
|
150
|
+
for (const issue of await archiveInventory(payloadPath, false, true)) {
|
|
151
|
+
if (issue.kind === 'path' && (issue.path.startsWith('/') || posix.normalize(issue.path).split('/')[0] === '..'))
|
|
152
|
+
continue;
|
|
153
|
+
findings.add('SPK_ARCHIVE_INVALID', formatArchiveIssue(issue), at(issue.path));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch { /* scanPayload reports unreadable archives */ }
|
|
148
157
|
const configs = new Set(dirs.map((dir) => posix.join(dir, "config")));
|
|
149
158
|
const files = await scanPayload(payloadPath, (path) => configs.has(path) || (/\/texts\/[^/]+\/strings$/.test(path) && dirs.some((dir) => path.startsWith(`${dir}/`))), findings);
|
|
150
159
|
if (!files)
|
package/dist/spk/rules.d.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface SpkRule {
|
|
|
11
11
|
source: string;
|
|
12
12
|
}
|
|
13
13
|
export declare const SPK_RULES: {
|
|
14
|
+
readonly SPK_ARCHIVE_INVALID: {
|
|
15
|
+
readonly severity: "error";
|
|
16
|
+
readonly description: "An archive has noncanonical or colliding names, repeated entries, or unsupported entry types.";
|
|
17
|
+
readonly source: "Archive inventory";
|
|
18
|
+
};
|
|
14
19
|
readonly SPK_NOT_TAR: {
|
|
15
20
|
readonly severity: "error";
|
|
16
21
|
readonly description: "The file is not a readable tar archive; an SPK is an uncompressed tar.";
|
package/dist/spk/rules.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const GUIDE = "DSM 7 Developer Guide";
|
|
2
2
|
export const SPK_RULES = {
|
|
3
|
+
SPK_ARCHIVE_INVALID: { severity: "error", description: "An archive has noncanonical or colliding names, repeated entries, or unsupported entry types.", source: "Archive inventory" },
|
|
3
4
|
SPK_NOT_TAR: { severity: "error", description: "The file is not a readable tar archive; an SPK is an uncompressed tar.", source: `${GUIDE}: Package Structure` },
|
|
4
5
|
SPK_ENTRY_UNSAFE: { severity: "error", description: "A path inside the SPK escapes the archive root (an absolute path or ..), or is a link.", source: `${GUIDE}: Package Structure` },
|
|
5
6
|
SPK_ENTRY_UNKNOWN: { severity: "warning", description: "The SPK top level has a file or directory the guide does not list (file names are case-sensitive).", source: `${GUIDE}: Package Structure` },
|
package/dist/spk/validate-spk.js
CHANGED
|
@@ -8,6 +8,7 @@ import { checkMetadata } from "./check-metadata.js";
|
|
|
8
8
|
import { checkPayload } from "./check-payload.js";
|
|
9
9
|
import { KNOWN_TOP_LEVEL } from "./dsm-spec.js";
|
|
10
10
|
import { Findings } from "./findings.js";
|
|
11
|
+
import { archiveInventory, formatArchiveIssue } from './archive-inventory.js';
|
|
11
12
|
/** Outer files other than package.tgz are metadata; anything larger is not read. */
|
|
12
13
|
const MAX_OUTER_FILE = 16 * 1024 * 1024;
|
|
13
14
|
const TOP_LEVEL = new Set(KNOWN_TOP_LEVEL);
|
|
@@ -17,6 +18,17 @@ export async function validateSpk(spkPath) {
|
|
|
17
18
|
const dir = mkdtempSync(join(tmpdir(), "syno-validate-spk-"));
|
|
18
19
|
let info = {};
|
|
19
20
|
try {
|
|
21
|
+
try {
|
|
22
|
+
for (const issue of await archiveInventory(spkPath, true)) {
|
|
23
|
+
// The existing outer rules already report escaping names and links.
|
|
24
|
+
if (issue.kind === 'unexpected' || issue.kind === 'type' && ['SymbolicLink', 'Link'].includes(issue.type))
|
|
25
|
+
continue;
|
|
26
|
+
if (issue.kind === 'path' && (issue.path.startsWith('/') || issue.path.split('/').includes('..')))
|
|
27
|
+
continue;
|
|
28
|
+
findings.add('SPK_ARCHIVE_INVALID', formatArchiveIssue(issue), issue.path);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch { /* readOuter reports unreadable archives */ }
|
|
20
32
|
const outer = await readOuter(spkPath, dir, findings);
|
|
21
33
|
if (outer) {
|
|
22
34
|
checkOuterEntries(outer.entries, findings);
|
|
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { parse } from "yaml";
|
|
12
|
-
import {
|
|
12
|
+
import { DEFAULT_SDK_VERSION } from "../runtime/sdk-source.js";
|
|
13
13
|
import { goChildEnv } from "../runtime/go-env.js";
|
|
14
14
|
import { sdkSource } from "../runtime/sdk-source.js";
|
|
15
15
|
import { readDaemon } from "./daemon.js";
|
|
@@ -106,10 +106,10 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
|
|
|
106
106
|
return invalid("compose.host_udp allows at most 32 distinct ports 1024-65535 with lowercase purpose labels of 1-64 characters.", "COMPOSE_HOST_UDP_INVALID");
|
|
107
107
|
hostUdp = [...value.host_udp].map(l => ({ port: l.port, purpose: l.purpose })).sort((a, b) => a.port - b.port);
|
|
108
108
|
}
|
|
109
|
-
const sdk = typeof value.sdk === "string" ? value.sdk : (context.sdk ??
|
|
109
|
+
const sdk = typeof value.sdk === "string" ? value.sdk : (context.sdk ?? DEFAULT_SDK_VERSION);
|
|
110
110
|
let targets = {};
|
|
111
111
|
if (value.helpers !== undefined) {
|
|
112
|
-
const d = readDaemon({ exec: "_sso/gateway", targets: value.helpers }, root, arches, fail);
|
|
112
|
+
const d = readDaemon({ exec: "_sso/gateway", targets: value.helpers }, root, arches, fail, context.package);
|
|
113
113
|
if (!d)
|
|
114
114
|
return;
|
|
115
115
|
targets = d.targets;
|
|
@@ -309,7 +309,7 @@ export function lockComposeImages(m, arch, resolveImages, imageProofs) {
|
|
|
309
309
|
return { ...m, compose };
|
|
310
310
|
}
|
|
311
311
|
export function composeInputs(root, file, envFile) {
|
|
312
|
-
const result = spawnSync(planner(
|
|
312
|
+
const result = spawnSync(planner(DEFAULT_SDK_VERSION), [
|
|
313
313
|
"sources",
|
|
314
314
|
"--root",
|
|
315
315
|
root,
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { StandaloneError } from "./manifest.js";
|
|
2
2
|
/** `daemon.ingress`: one path in DSM's own server blocks, proxied to the daemon. */
|
|
3
3
|
export interface DaemonIngress {
|
|
4
|
-
/**
|
|
4
|
+
/** `/synopkgland/<package>/`, with the exact manifest package ID. */
|
|
5
5
|
path: string;
|
|
6
6
|
/** nginx proxy_read_timeout and proxy_send_timeout in seconds. */
|
|
7
7
|
timeout: number;
|
|
8
8
|
}
|
|
9
9
|
export declare const DAEMON_INGRESS_CONFIG = "daemon/ingress-nginx.conf";
|
|
10
|
-
export declare
|
|
10
|
+
export declare const daemonIngressPath: (packageId: string) => string;
|
|
11
|
+
export declare function readDaemonIngress(value: unknown, packageId: string | undefined, fail: (error: StandaloneError) => void): DaemonIngress | undefined;
|
|
11
12
|
/** DSM before 7.2 rejects an unsigned web-config, the worker this path uses. */
|
|
12
13
|
export declare function daemonIngressMinDsmProblem(osMinVer: string | undefined): string | undefined;
|
|
13
14
|
export declare const daemonIngressSocket: (pkg: string) => string;
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { isDsmAtLeast, PORTAL_MIN_DSM } from "../build-spk/dsm-version.js";
|
|
2
|
-
import { RESERVED_PORTAL_ALIASES } from "../portal/alias.js";
|
|
3
2
|
export const DAEMON_INGRESS_CONFIG = "daemon/ingress-nginx.conf";
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
export function readDaemonIngress(value, fail) {
|
|
3
|
+
export const daemonIngressPath = (packageId) => `/synopkgland/${packageId}/`;
|
|
4
|
+
export function readDaemonIngress(value, packageId, fail) {
|
|
7
5
|
if (value === undefined)
|
|
8
6
|
return undefined;
|
|
9
7
|
const v = value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
@@ -11,14 +9,14 @@ export function readDaemonIngress(value, fail) {
|
|
|
11
9
|
if (!v ||
|
|
12
10
|
Object.keys(v).some((k) => k !== "path" && k !== "timeout") ||
|
|
13
11
|
typeof v.path !== "string" ||
|
|
14
|
-
!
|
|
15
|
-
|
|
12
|
+
!packageId || !/^[A-Za-z][A-Za-z0-9]{1,63}$/.test(packageId) ||
|
|
13
|
+
v.path !== daemonIngressPath(packageId) ||
|
|
16
14
|
!Number.isInteger(timeout) ||
|
|
17
15
|
timeout < 1 ||
|
|
18
16
|
timeout > 3600) {
|
|
19
17
|
fail({
|
|
20
18
|
code: "STANDALONE_DAEMON_INGRESS_INVALID",
|
|
21
|
-
message: `daemon.ingress
|
|
19
|
+
message: `daemon.ingress.path must be exactly /synopkgland/<package>/ with the manifest package ID (case-sensitive), and timeout must be 1–3600 seconds`,
|
|
22
20
|
});
|
|
23
21
|
return undefined;
|
|
24
22
|
}
|
|
@@ -31,9 +29,14 @@ export function daemonIngressMinDsmProblem(osMinVer) {
|
|
|
31
29
|
return `daemon.ingress needs os_min_ver ${PORTAL_MIN_DSM} or later; os_min_ver is "${osMinVer ?? ""}". DSM 7.1 rejects the unsigned web-config worker that adds the path to DSM's nginx. Raise os_min_ver or remove daemon.ingress.`;
|
|
32
30
|
}
|
|
33
31
|
export const daemonIngressSocket = (pkg) => `/tmp/synopkgland-ingress-${pkg}.sock`;
|
|
32
|
+
function checkedIngressPath(pkg, ingress) {
|
|
33
|
+
if (!readDaemonIngress(ingress, pkg, () => { }))
|
|
34
|
+
throw new Error('Invalid daemon ingress: use /synopkgland/<package>/ with the exact package ID');
|
|
35
|
+
return daemonIngressPath(pkg);
|
|
36
|
+
}
|
|
34
37
|
/** What the daemon learns: where to listen and which path prefix it serves. */
|
|
35
38
|
export function ingressEnv(pkg, ingress) {
|
|
36
|
-
return { SYNOPKGLAND_INGRESS_PATH: ingress
|
|
39
|
+
return { SYNOPKGLAND_INGRESS_PATH: checkedIngressPath(pkg, ingress), SYNOPKGLAND_INGRESS_SOCKET: daemonIngressSocket(pkg) };
|
|
37
40
|
}
|
|
38
41
|
export function daemonIngressResource() {
|
|
39
42
|
return { "nginx-static-config": { enable: [{ type: "dsm", relpath: DAEMON_INGRESS_CONFIG }] } };
|
|
@@ -43,7 +46,7 @@ export function daemonIngressResource() {
|
|
|
43
46
|
* unbuffered; nginx replaces any client X-Real-IP with the peer address.
|
|
44
47
|
*/
|
|
45
48
|
export function renderDaemonIngressConfig(pkg, ingress) {
|
|
46
|
-
return `location ^~ ${ingress
|
|
49
|
+
return `location ^~ ${checkedIngressPath(pkg, ingress)} {
|
|
47
50
|
proxy_pass http://unix:${daemonIngressSocket(pkg)}:;
|
|
48
51
|
proxy_http_version 1.1;
|
|
49
52
|
proxy_set_header Host $http_host;
|
|
@@ -7,7 +7,7 @@ export interface DaemonRestart {
|
|
|
7
7
|
max_attempts: number;
|
|
8
8
|
window_seconds: number;
|
|
9
9
|
}
|
|
10
|
-
/** Normalize
|
|
10
|
+
/** Normalize explicit manifest policies; omission uses the SDK controller default. */
|
|
11
11
|
export declare function readDaemonRestart(value: unknown, fail: (error: StandaloneError) => void): DaemonRestart | undefined;
|
|
12
12
|
export declare function daemonRestartSdkSupported(sdk: string): boolean;
|
|
13
13
|
export declare function daemonRestartSdkProblem(sdk: string): string | undefined;
|
|
@@ -7,7 +7,7 @@ let validator;
|
|
|
7
7
|
function restartValidator() {
|
|
8
8
|
return validator ??= new Ajv().compile(JSON.parse(readFileSync(new URL("../../schema/daemon-restart.v1.schema.json", import.meta.url), "utf8")));
|
|
9
9
|
}
|
|
10
|
-
/** Normalize
|
|
10
|
+
/** Normalize explicit manifest policies; omission uses the SDK controller default. */
|
|
11
11
|
export function readDaemonRestart(value, fail) {
|
|
12
12
|
if (value === undefined)
|
|
13
13
|
return undefined;
|
|
@@ -35,7 +35,7 @@ export function daemonRestartSdkSupported(sdk) {
|
|
|
35
35
|
}
|
|
36
36
|
export function daemonRestartSdkProblem(sdk) {
|
|
37
37
|
if (!daemonRestartSdkSupported(sdk))
|
|
38
|
-
return `daemon.restart requires SDK ${DAEMON_RESTART_MIN_SDK} or later; selected SDK ${sdk}. Upgrade the App's SDK pin, identity_gateway.sdk or the
|
|
38
|
+
return `daemon.restart requires SDK ${DAEMON_RESTART_MIN_SDK} or later; selected SDK ${sdk}. Upgrade the App's SDK pin, identity_gateway.sdk or the CLI supplying the default controller SDK before rebuilding.`;
|
|
39
39
|
return undefined;
|
|
40
40
|
}
|
|
41
41
|
/** Development overrides must contain the decoder as well as the restart code. */
|
|
@@ -50,6 +50,6 @@ export function requireDaemonRestartSource(dir, sdk, policy) {
|
|
|
50
50
|
catch { /* Fail below before compiling or packaging a mismatched runtime. */ }
|
|
51
51
|
const problem = daemonRestartSdkProblem(sdk);
|
|
52
52
|
if (problem || !/\bRestart\s+\*restartPolicy\s+`json:"restart,omitempty"`/.test(main) ||
|
|
53
|
-
!/^func normalizeRestart\(/m.test(restart) || !/^type
|
|
53
|
+
!/^func normalizeRestart\(/m.test(restart) || !/^type restartPolicy struct\s*\{/m.test(restart))
|
|
54
54
|
throw new StandaloneBuildError("STANDALONE_DAEMON_RESTART_SDK_REQUIRED", problem ?? `Selected SDK ${sdk} source does not support daemon.restart; use matching SDK ${DAEMON_RESTART_MIN_SDK} or later sources, including for a local development override.`);
|
|
55
55
|
}
|
|
@@ -5,7 +5,7 @@ import { type DaemonRestart } from "./daemon-restart.js";
|
|
|
5
5
|
import type { CommandRunner } from "./build.js";
|
|
6
6
|
import type { StandaloneError, StandaloneManifest } from "./manifest.js";
|
|
7
7
|
export interface Daemon {
|
|
8
|
-
/** Optional bounded recovery;
|
|
8
|
+
/** Optional bounded recovery; omission uses the SDK controller default. SDK 0.26.0 or later. */
|
|
9
9
|
restart?: DaemonRestart;
|
|
10
10
|
/** Fixed foreground executable relative to daemon/payload; empty for a backend daemon. */
|
|
11
11
|
exec: string;
|
|
@@ -53,7 +53,7 @@ export declare const token: (v: unknown) => v is string;
|
|
|
53
53
|
export declare const envKey: (k: string) => boolean;
|
|
54
54
|
/** A relative path to a fixed executable, without `.` or `..` parts. */
|
|
55
55
|
export declare const relativeExec: (v: unknown) => v is string;
|
|
56
|
-
export declare function readDaemon(value: unknown, root: string, arches: NativeArchFamily[], fail: (error: StandaloneError) => void): Daemon | undefined;
|
|
56
|
+
export declare function readDaemon(value: unknown, root: string, arches: NativeArchFamily[], fail: (error: StandaloneError) => void, packageId: string | undefined): Daemon | undefined;
|
|
57
57
|
export declare function daemonSdkVersion(manifest: Pick<StandaloneManifest, "backend" | "identityGateway">): string;
|
|
58
58
|
export declare function daemonConfig(manifest: StandaloneManifest): string;
|
|
59
59
|
/**
|
|
@@ -5,7 +5,7 @@ import { chmodSync, existsSync, copyFileSync, lstatSync, mkdirSync, readdirSync,
|
|
|
5
5
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
6
|
import { PINNED_GO_HINT, pinnedGoBuildEnv, pinnedGoProblem, } from "../build-spk/go-toolchain.js";
|
|
7
7
|
import { sdkSource, appSdkVersion } from "../runtime/sdk-source.js";
|
|
8
|
-
import {
|
|
8
|
+
import { DEFAULT_SDK_VERSION } from "../runtime/sdk-source.js";
|
|
9
9
|
import { checkCgiBinary } from "./binary.js";
|
|
10
10
|
import { NODE_PACKAGES } from "./bridge-backend.js";
|
|
11
11
|
import { readDaemonIngress, ingressEnv } from "./daemon-ingress.js";
|
|
@@ -65,7 +65,7 @@ export const relativeExec = (v) => typeof v === "string" &&
|
|
|
65
65
|
!v.split("/").some((p) => p === "." || p === "..");
|
|
66
66
|
/** A Node entry such as `server.mjs` or `dist/main.js`, inside the payload. */
|
|
67
67
|
const relativeEntry = (v) => typeof v === "string" && relativeExec(`_/${v}`);
|
|
68
|
-
export function readDaemon(value, root, arches, fail) {
|
|
68
|
+
export function readDaemon(value, root, arches, fail, packageId) {
|
|
69
69
|
if (value === undefined)
|
|
70
70
|
return undefined;
|
|
71
71
|
const invalid = () => {
|
|
@@ -126,10 +126,10 @@ export function readDaemon(value, root, arches, fail) {
|
|
|
126
126
|
const node = readDaemonNode(value.node, fail);
|
|
127
127
|
if (value.node !== undefined && !node)
|
|
128
128
|
return undefined;
|
|
129
|
-
const ingress = readDaemonIngress(value.ingress, fail);
|
|
129
|
+
const ingress = readDaemonIngress(value.ingress, packageId, fail);
|
|
130
130
|
if (value.ingress !== undefined && !ingress)
|
|
131
131
|
return undefined;
|
|
132
|
-
if (ingress && Object.keys(env).some((key) => key in ingressEnv(
|
|
132
|
+
if (ingress && Object.keys(env).some((key) => key in ingressEnv(packageId, ingress)))
|
|
133
133
|
return invalid();
|
|
134
134
|
if (backend && node && Object.keys(env).some((key) => key in backendNodeEnv()))
|
|
135
135
|
return invalid();
|
|
@@ -172,7 +172,7 @@ function readDaemonNode(value, fail) {
|
|
|
172
172
|
export function daemonSdkVersion(manifest) {
|
|
173
173
|
return manifest.identityGateway?.sdk ??
|
|
174
174
|
(manifest.backend.kind === "go" ? appSdkVersion(manifest.backend) : undefined) ??
|
|
175
|
-
|
|
175
|
+
DEFAULT_SDK_VERSION;
|
|
176
176
|
}
|
|
177
177
|
export function daemonConfig(manifest) {
|
|
178
178
|
const { exec, args, uninstall_args, env, timeouts, restart } = manifest.compose
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { EntryMode } from './manifest.js';
|
|
1
2
|
type Payload = Array<{
|
|
2
3
|
path: string;
|
|
3
4
|
mode: string;
|
|
4
5
|
}>;
|
|
5
6
|
export interface BridgeJson {
|
|
7
|
+
access?: EntryMode;
|
|
6
8
|
runtime?: string;
|
|
7
9
|
entry?: string;
|
|
8
10
|
methods?: string[];
|
|
@@ -22,7 +24,7 @@ export interface BridgeJson {
|
|
|
22
24
|
* permissions.json by digest, the App lives in target/app (never under ui/),
|
|
23
25
|
* and its entry, SDK and dependencies are in the package.
|
|
24
26
|
*/
|
|
25
|
-
export declare function checkBridgeApp(dir: string, backend: string, info: Record<string, string>, payload: Payload, findings: string[]): Promise<BridgeJson | undefined>;
|
|
27
|
+
export declare function checkBridgeApp(dir: string, backend: string, info: Record<string, string>, payload: Payload, findings: string[], entry?: EntryMode): Promise<BridgeJson | undefined>;
|
|
26
28
|
/** root and delegate reach the package's executor; delegate pins the one this SPK ships. */
|
|
27
29
|
export declare function checkBridgeExecutor(bridge: BridgeJson, executor: {
|
|
28
30
|
executorDigest: string;
|
|
@@ -7,6 +7,7 @@ import { ELF_MACHINE } from "./binary.js";
|
|
|
7
7
|
import { NODE_SDK_PACKAGE } from "./bridge-app.js";
|
|
8
8
|
import { BRIDGE_JSON, bridgeManifestProblems, PYTHON_VENDOR_DIR } from "./bridge-json.js";
|
|
9
9
|
import { CGI_PAYLOAD_PATH } from "./dsm-window.js";
|
|
10
|
+
import { parseApiPermissions } from './api-permissions.js';
|
|
10
11
|
/** Files that belong in target/app only: DSM serves everything under ui/ to the browser. */
|
|
11
12
|
const APP_ONLY = [`/${BRIDGE_JSON}`, "/permissions.json", `/${PYTHON_VENDOR_DIR}/synopkgland_app/`, `/node_modules/${NODE_SDK_PACKAGE}/`];
|
|
12
13
|
/**
|
|
@@ -14,7 +15,7 @@ const APP_ONLY = [`/${BRIDGE_JSON}`, "/permissions.json", `/${PYTHON_VENDOR_DIR}
|
|
|
14
15
|
* permissions.json by digest, the App lives in target/app (never under ui/),
|
|
15
16
|
* and its entry, SDK and dependencies are in the package.
|
|
16
17
|
*/
|
|
17
|
-
export async function checkBridgeApp(dir, backend, info, payload, findings) {
|
|
18
|
+
export async function checkBridgeApp(dir, backend, info, payload, findings, entry) {
|
|
18
19
|
const paths = new Set(payload.map(entry => entry.path));
|
|
19
20
|
for (const path of payload.map(entry => `/${entry.path}`).filter(path => path.startsWith("/ui/") && APP_ONLY.some(name => path.includes(name)))) {
|
|
20
21
|
findings.push(`BRIDGE_APP_IN_UI ${path.slice(1)} is an App file and must not be placed in ui/ (DSM serves it directly to the browser)`);
|
|
@@ -31,6 +32,8 @@ export async function checkBridgeApp(dir, backend, info, payload, findings) {
|
|
|
31
32
|
return undefined;
|
|
32
33
|
}
|
|
33
34
|
const bridge = JSON.parse(text);
|
|
35
|
+
if (bridge.access !== entry)
|
|
36
|
+
findings.push('BRIDGE_ACCESS_MISMATCH app/bridge.json access differs from sdk-package.json entry');
|
|
34
37
|
if (bridge.runtime !== backend)
|
|
35
38
|
findings.push(`BRIDGE_MANIFEST_INVALID the runtime "${bridge.runtime}" in app/${BRIDGE_JSON} differs from the backend "${backend}" in sdk-package.json`);
|
|
36
39
|
checkEntry(app, bridge, info, payload, findings);
|
|
@@ -82,10 +85,10 @@ function checkEntry(app, bridge, info, payload, findings) {
|
|
|
82
85
|
function checkPermissions(bytes, bridge, findings) {
|
|
83
86
|
let methods;
|
|
84
87
|
try {
|
|
85
|
-
methods = Object.keys(
|
|
88
|
+
methods = Object.keys(parseApiPermissions(bytes).methods);
|
|
86
89
|
}
|
|
87
90
|
catch {
|
|
88
|
-
findings.push("BRIDGE_PERMISSIONS app/permissions.json
|
|
91
|
+
findings.push("BRIDGE_PERMISSIONS app/permissions.json must be a valid bounded method policy with default administrators");
|
|
89
92
|
return;
|
|
90
93
|
}
|
|
91
94
|
const unlisted = methods.filter(method => !bridge.methods?.includes(method));
|
|
@@ -14,6 +14,7 @@ import { readComposeApp, composeSource, dsmComposeProblem, COMPOSE_READINESS_LIM
|
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { inspectNativeLayout } from "./native-layout.js";
|
|
16
16
|
import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
|
|
17
|
+
import { archiveInventory, formatArchiveIssue } from '../spk/archive-inventory.js';
|
|
17
18
|
import { inspectExecutor, inspectExecutorAnchor, } from "../runtime/executor-inspect.js";
|
|
18
19
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
19
20
|
import { sdkScriptOf, unwrapAppScripts } from "./app-scripts.js";
|
|
@@ -65,8 +66,15 @@ export async function inspectStandaloneSpk(spkPath) {
|
|
|
65
66
|
export async function inspectSpk(spkPath, expectedKind) {
|
|
66
67
|
const dir = mkdtempSync(join(tmpdir(), "syno-inspect-spk-"));
|
|
67
68
|
try {
|
|
69
|
+
const findings = (await archiveInventory(spkPath, true)).map(formatArchiveIssue);
|
|
70
|
+
if (findings.length)
|
|
71
|
+
return { signed: false, payload: [], findings, capabilities: [] };
|
|
68
72
|
await tar.x({ file: spkPath, cwd: dir });
|
|
69
|
-
|
|
73
|
+
if (existsSync(join(dir, 'package.tgz'))) {
|
|
74
|
+
findings.push(...(await archiveInventory(join(dir, 'package.tgz'))).map(formatArchiveIssue));
|
|
75
|
+
if (findings.length)
|
|
76
|
+
return { signed: existsSync(join(dir, 'syno_signature.asc')), payload: [], findings, capabilities: [] };
|
|
77
|
+
}
|
|
70
78
|
if (!expectedKind && existsSync(join(dir, "native-layout.json"))) {
|
|
71
79
|
let dependencies;
|
|
72
80
|
try {
|
|
@@ -240,7 +248,7 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
240
248
|
}
|
|
241
249
|
}
|
|
242
250
|
const bridge = compiledKind && kind !== "native-go"
|
|
243
|
-
? await checkBridgeApp(dir, metadata.backend, info, payload, findings)
|
|
251
|
+
? await checkBridgeApp(dir, metadata.backend, info, payload, findings, metadata.entry)
|
|
244
252
|
: undefined;
|
|
245
253
|
if (dependencies && bridge?.node?.package && !dependencies.packages.some(d => d.name === bridge.node.package))
|
|
246
254
|
findings.push("DEPENDENCIES_INVALID build metadata dependencies omit the Node backend package.");
|
|
@@ -996,7 +1004,7 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
|
|
|
996
1004
|
timeouts: config.timeouts,
|
|
997
1005
|
restart: config.restart,
|
|
998
1006
|
targets: { x86_64: "payload" },
|
|
999
|
-
}, dir, ["x86_64"], () => { });
|
|
1007
|
+
}, dir, ["x86_64"], () => { }, info.package);
|
|
1000
1008
|
if (!daemon)
|
|
1001
1009
|
throw new Error("invalid config");
|
|
1002
1010
|
// The packaged config already carries the launcher argv; only the launcher itself is checked here.
|
|
@@ -1280,7 +1288,7 @@ async function recoverNativeGateway(dir, value, info, findings) {
|
|
|
1280
1288
|
function recoverDaemonNode(value, findings) {
|
|
1281
1289
|
if (value === undefined)
|
|
1282
1290
|
return undefined;
|
|
1283
|
-
const daemon = readDaemon({ exec: "x", targets: {}, node: value }, tmpdir(), [], () => { });
|
|
1291
|
+
const daemon = readDaemon({ exec: "x", targets: {}, node: value }, tmpdir(), [], () => { }, undefined);
|
|
1284
1292
|
if (!daemon?.node || !isDeepStrictEqual(daemon.node, value)) {
|
|
1285
1293
|
findings.push("DAEMON_NODE_INVALID sdk-package.json daemon_node is not a Node.js package declaration build writes");
|
|
1286
1294
|
return undefined;
|
|
@@ -1291,7 +1299,7 @@ async function recoverDaemonIngress(dir, value, info, paths, findings) {
|
|
|
1291
1299
|
if (value === undefined)
|
|
1292
1300
|
return undefined;
|
|
1293
1301
|
try {
|
|
1294
|
-
const ingress = readDaemonIngress(value, () => { });
|
|
1302
|
+
const ingress = readDaemonIngress(value, info.package, () => { });
|
|
1295
1303
|
if (!ingress || !isDeepStrictEqual(ingress, value))
|
|
1296
1304
|
throw new Error("invalid ingress record");
|
|
1297
1305
|
const minDsm = daemonIngressMinDsmProblem(info.os_min_ver);
|
|
@@ -1369,10 +1377,18 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, c
|
|
|
1369
1377
|
findings.push("conf/privilege is not JSON");
|
|
1370
1378
|
return;
|
|
1371
1379
|
}
|
|
1380
|
+
if (!privilege || typeof privilege !== 'object' || Array.isArray(privilege)) {
|
|
1381
|
+
findings.push('conf/privilege must be a JSON object');
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1372
1384
|
const defaults = privilege.defaults;
|
|
1373
1385
|
if (defaults?.["run-as"] !== "package") {
|
|
1374
1386
|
findings.push('conf/privilege defaults.run-as must be "package"');
|
|
1375
1387
|
}
|
|
1388
|
+
if (Object.keys(privilege).some(key => key !== 'defaults') || !defaults ||
|
|
1389
|
+
Object.keys(defaults).some(key => key !== 'run-as')) {
|
|
1390
|
+
findings.push('conf/privilege must contain only defaults.run-as: package');
|
|
1391
|
+
}
|
|
1376
1392
|
for (const key of ["tool", "ctrl-script", "executable", "join-groupname"]) {
|
|
1377
1393
|
if (key in privilege) {
|
|
1378
1394
|
findings.push(`conf/privilege requires ${key} (root or extra group privileges)`);
|
|
@@ -1624,8 +1640,12 @@ function checkScripts(dir, findings) {
|
|
|
1624
1640
|
for (const name of readdirSync(path))
|
|
1625
1641
|
scan(`${relative}/${name}`);
|
|
1626
1642
|
}
|
|
1627
|
-
else
|
|
1628
|
-
|
|
1643
|
+
else {
|
|
1644
|
+
if (!(lstatSync(path).mode & 0o111))
|
|
1645
|
+
findings.push(`${relative} is not executable`);
|
|
1646
|
+
if (ROOT_SCRIPT_HINTS.some(pattern => pattern.test(readFileSync(path, "utf8")))) {
|
|
1647
|
+
findings.push(`${relative} tries to gain root (sudo, chown root or setuid)`);
|
|
1648
|
+
}
|
|
1629
1649
|
}
|
|
1630
1650
|
};
|
|
1631
1651
|
if (existsSync(scriptsDir))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { sdkSource } from '../runtime/sdk-source.js';
|
|
4
|
-
import {
|
|
4
|
+
import { DEFAULT_SDK_VERSION } from '../runtime/sdk-source.js';
|
|
5
5
|
import { goChildEnv } from '../runtime/go-env.js';
|
|
6
6
|
import { pinnedGoBuildEnv, pinnedGoProblem } from '../build-spk/go-toolchain.js';
|
|
7
7
|
import { checkCgiBinary } from './binary.js';
|
|
@@ -49,7 +49,7 @@ export function renderSettingsHook(name, m) {
|
|
|
49
49
|
export async function buildInstallSettings(m, arch, work, run) {
|
|
50
50
|
if (!m.installSettings && !m.installPorts)
|
|
51
51
|
return;
|
|
52
|
-
const sdk = m.compose?.sdk ?? m.identityGateway?.sdk ??
|
|
52
|
+
const sdk = m.compose?.sdk ?? m.identityGateway?.sdk ?? DEFAULT_SDK_VERSION, source = sdkSource(sdk);
|
|
53
53
|
if (!existsSync(join(source.goDir, 'installsettings/cmd/control/main.go')))
|
|
54
54
|
throw new StandaloneBuildError('SDK_INSTALL_SETTINGS_UNSUPPORTED', 'Use an SDK release with native install.settings support.');
|
|
55
55
|
if (m.installPorts && !existsSync(join(source.goDir, 'installsettings/ports.go')))
|
|
@@ -250,7 +250,7 @@ function readManifest(raw, c) {
|
|
|
250
250
|
const timeoutOnly = isRecord(raw.daemon) && Object.keys(raw.daemon).length === 1 && 'timeouts' in raw.daemon;
|
|
251
251
|
const lifecycleTimeouts = timeoutOnly ? readLifecycleTimeouts(raw.daemon.timeouts, (e) => c.fail(e.code, e.message)) : undefined;
|
|
252
252
|
const daemon = arches &&
|
|
253
|
-
readDaemon(timeoutOnly ? undefined : raw.daemon, c.root, arches, (e) => c.fail(e.code, e.message));
|
|
253
|
+
readDaemon(timeoutOnly ? undefined : raw.daemon, c.root, arches, (e) => c.fail(e.code, e.message), packageId);
|
|
254
254
|
let dependencies;
|
|
255
255
|
try {
|
|
256
256
|
const explicit = readPackageDependencies(raw.dependencies);
|