@synopackageland/cli 0.5.0 → 0.5.3
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/README.md +1 -1
- package/dist/authorization-sdk.d.ts +5 -0
- package/dist/authorization-sdk.js +5 -0
- package/dist/build-spk/delete-app-data.d.ts +1 -1
- package/dist/build-spk/uninstall-wizard.d.ts +1 -1
- package/dist/build-spk/uninstall-wizard.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/error-registry.js +3 -3
- package/dist/host/dsm-port-profile.d.ts +1 -1
- package/dist/host/dsm-port-profile.js +1 -1
- package/dist/init.js +25 -4
- package/dist/standalone/authorization-window.d.ts +2 -0
- package/dist/standalone/authorization-window.js +28 -0
- package/dist/standalone/authorization.d.ts +48 -0
- package/dist/standalone/authorization.js +116 -0
- package/dist/standalone/build.js +18 -6
- package/dist/standalone/dsm-window.js +8 -1
- package/dist/standalone/helper.js +1 -1
- package/dist/standalone/inspect-helper.js +12 -2
- package/dist/standalone/inspect-spk.js +11 -0
- package/dist/standalone/lifecycle-helper.d.ts +1 -1
- package/dist/standalone/lifecycle-helper.js +2 -2
- package/dist/standalone/lifecycle.d.ts +1 -1
- package/dist/standalone/lifecycle.js +3 -3
- package/dist/standalone/root-ops.d.ts +2 -0
- package/dist/standalone/root-ops.js +10 -0
- package/dist/standalone/root-window.js +1 -1
- package/dist/standalone/spk.d.ts +2 -0
- package/dist/standalone/spk.js +14 -0
- package/dist/templates/_authorization/main.go +104 -0
- package/dist/templates/_authorization/main_test.go +35 -0
- package/dist/templates/native-go/README.md +25 -1
- package/dist/templates/native-go/backend/cmd/app/main.go +8 -6
- package/dist/templates/native-go/backend/cmd/app/sdk_main.go +8 -6
- package/dist/templates/native-go/backend/cmd/helper/main.go +18 -1
- package/dist/templates/native-go/backend/internal/app/authorization_none.go +5 -0
- package/dist/templates/native-go/backend/internal/app/routes.go +5 -2
- package/dist/templates/native-go/backend/internal/app/sdk_routes.go +5 -2
- package/dist/templates/native-go/backend/internal/helper/management.go +8 -1
- package/dist/templates/native-go/backend/internal/helper/purge.go +115 -0
- package/dist/templates/native-go/backend/internal/helper/purge_test.go +59 -0
- package/dist/templates/native-go/backend/internal/helper/revoke.go +109 -0
- package/dist/templates/native-go/backend/internal/helper/revoke_test.go +121 -0
- package/dist/templates/native-go/backend/internal/helper/sdk_server_linux.go +11 -2
- package/dist/templates/native-go/backend/internal/helper/server_linux.go +11 -2
- package/dist/templates/native-go/dsm-package.yaml +1 -1
- package/dist/templates/native-go-authorization/backend/internal/app/authorization.go +298 -0
- package/dist/templates/native-go-authorization/backend/internal/app/authorization_test.go +96 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/AuthorizationGate.vue +89 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/de.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/en.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/es.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/fr.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/index.ts +15 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/it.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/ja.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/ko.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/zh-CN.json +36 -0
- package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/zh-TW.json +36 -0
- package/dist/templates/native-go-authorization/frontend/test/authorization-preview.html +14 -0
- package/dist/templates/native-go-authorization/frontend/test/authorization.test.ts +59 -0
- package/dist/templates/native-go-vue/frontend/src/context.ts +3 -0
- package/dist/types.d.ts +1 -1
- package/package.json +5 -2
- package/skills/reference/create.md +2 -2
- package/templates/_authorization/main.go +104 -0
- package/templates/_authorization/main_test.go +35 -0
- package/templates/native-go/README.md +25 -1
- package/templates/native-go/backend/cmd/app/main.go +8 -6
- package/templates/native-go/backend/cmd/app/sdk_main.go +8 -6
- package/templates/native-go/backend/cmd/helper/main.go +18 -1
- package/templates/native-go/backend/internal/app/authorization_none.go +5 -0
- package/templates/native-go/backend/internal/app/routes.go +5 -2
- package/templates/native-go/backend/internal/app/sdk_routes.go +5 -2
- package/templates/native-go/backend/internal/helper/management.go +8 -1
- package/templates/native-go/backend/internal/helper/purge.go +115 -0
- package/templates/native-go/backend/internal/helper/purge_test.go +59 -0
- package/templates/native-go/backend/internal/helper/revoke.go +109 -0
- package/templates/native-go/backend/internal/helper/revoke_test.go +121 -0
- package/templates/native-go/backend/internal/helper/sdk_server_linux.go +11 -2
- package/templates/native-go/backend/internal/helper/server_linux.go +11 -2
- package/templates/native-go/dsm-package.yaml +1 -1
- package/templates/native-go-authorization/backend/internal/app/authorization.go +298 -0
- package/templates/native-go-authorization/backend/internal/app/authorization_test.go +96 -0
- package/templates/native-go-authorization/frontend/src/authorization/AuthorizationGate.vue +89 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/de.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/en.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/es.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/fr.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/index.ts +15 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/it.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/ja.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/ko.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/zh-CN.json +36 -0
- package/templates/native-go-authorization/frontend/src/authorization/i18n/zh-TW.json +36 -0
- package/templates/native-go-authorization/frontend/test/authorization-preview.html +14 -0
- package/templates/native-go-authorization/frontend/test/authorization.test.ts +59 -0
- package/templates/native-go-vue/frontend/src/context.ts +3 -0
- package/dist/templates/hello-web/compose.yaml +0 -24
- package/dist/templates/hello-web/html/app.js +0 -1
- package/dist/templates/hello-web/html/index.html +0 -13
- package/dist/templates/hello-web/synology-app.yaml +0 -16
- package/dist/templates/hello-web-page/compose.yaml +0 -24
- package/dist/templates/hello-web-page/html/app.js +0 -1
- package/dist/templates/hello-web-page/html/index.html +0 -13
- package/dist/templates/hello-web-page/synology-app.yaml +0 -16
- package/templates/hello-web/compose.yaml +0 -24
- package/templates/hello-web/html/app.js +0 -1
- package/templates/hello-web/html/index.html +0 -13
- package/templates/hello-web/synology-app.yaml +0 -16
- package/templates/hello-web-page/compose.yaml +0 -24
- package/templates/hello-web-page/html/app.js +0 -1
- package/templates/hello-web-page/html/index.html +0 -13
- package/templates/hello-web-page/synology-app.yaml +0 -16
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ synopkgland --project ./my-app deploy --dry-run --arch x86_64
|
|
|
15
15
|
synopkgland --project ./my-app deploy --host my-nas --arch x86_64
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Native-go requires Go 1.
|
|
18
|
+
Native-go requires Go 1.25+; binary consumes checked prebuilt archives; container SPKs use DSM resource workers. Root operations require DSM 7.1-42661 or later and per-operation DSM approval. DSM 7.1 keeps the native password dialog but creates root tasks using the admin session without a server-side password token; DSM 7.2+ retains its one-use token. See the native-go template README for the API matrix and completion limits. Remove root tasks before uninstall; changed task text needs fresh approval.
|
|
19
19
|
|
|
20
20
|
`--ui vue --integration go|better-auth` generates a package-account integration
|
|
21
21
|
(`privilege: none`, no helper). The privileged helper needs an explicit
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Public build-time adapter surface for native-layout products. */
|
|
2
|
+
export { ROOT_RUNTIME, type RootTask } from './standalone/root-window.js';
|
|
3
|
+
export { AUTHORIZATION_RUNTIME } from './standalone/authorization-window.js';
|
|
4
|
+
/** Canonical Vue gate and nine catalogs, shipped unchanged in the npm archive. */
|
|
5
|
+
export declare const authorizationTemplateDirectory: URL;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Public build-time adapter surface for native-layout products. */
|
|
2
|
+
export { ROOT_RUNTIME } from './standalone/root-window.js';
|
|
3
|
+
export { AUTHORIZATION_RUNTIME } from './standalone/authorization-window.js';
|
|
4
|
+
/** Canonical Vue gate and nine catalogs, shipped unchanged in the npm archive. */
|
|
5
|
+
export const authorizationTemplateDirectory = new URL('../templates/native-go-authorization/frontend/src/authorization/', import.meta.url);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* DSM keeps `@appdata`, `@apphome` and `@appconf` after an uninstall, so a
|
|
5
5
|
* package that offers deletion has to do it itself. The uninstall wizard's
|
|
6
|
-
* `pkgwizard_remove_additional` box (the key
|
|
6
|
+
* `pkgwizard_remove_additional` box (the key DSM's built-in packages use) is
|
|
7
7
|
* unchecked by default, so an absent variable leaves the data in place. The
|
|
8
8
|
* pre-0.5 key `delete_app_data` and DSM's `wizard_` mirror of either key are
|
|
9
9
|
* still honoured for scripted callers.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** The key
|
|
1
|
+
/** The key DSM's built-in packages use for their "also remove my data" box. */
|
|
2
2
|
export declare const REMOVE_ADDITIONAL_KEY = "pkgwizard_remove_additional";
|
|
3
3
|
export type UninstallWizardKind = "native" | "container";
|
|
4
4
|
/** Options that add a warning without changing the data box. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { renderWizardFiles } from "./wizard-i18n.js";
|
|
2
|
-
/** The key
|
|
2
|
+
/** The key DSM's built-in packages use for their "also remove my data" box. */
|
|
3
3
|
export const REMOVE_ADDITIONAL_KEY = "pkgwizard_remove_additional";
|
|
4
4
|
function list(items) {
|
|
5
5
|
return `<ul class="synopkg-ul-list">${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
|
package/dist/cli.js
CHANGED
|
@@ -247,14 +247,14 @@ export async function runCli(argv) {
|
|
|
247
247
|
.addHelpText("after", `
|
|
248
248
|
模板分兩類:
|
|
249
249
|
獨立 Container / Binary App(synology-app.yaml):
|
|
250
|
-
容器(compose.yaml):hello-
|
|
250
|
+
容器(compose.yaml):hello-files、compose-import
|
|
251
251
|
binary(native.yaml):執行一個預先建置的 binary 服務;可用 adminport 在自己的 port 提供網頁
|
|
252
252
|
Standalone Package(未簽署原生 SPK,含 Go CGI 與 DSM 視窗,dsm-package.yaml):
|
|
253
253
|
native-go:Go CGI backend + DSM 視窗 UI,預設需登入、admin 動作另外宣告。
|
|
254
254
|
--privilege root:另加固定的 root 操作(root/<名稱>.sh),由 DSM 管理員在套件視窗
|
|
255
255
|
以密碼核准後才能執行;預設 none,完全不用 root。
|
|
256
256
|
|
|
257
|
-
快速開始(native-go,需要 Go 1.
|
|
257
|
+
快速開始(native-go,需要 Go 1.25+):
|
|
258
258
|
synopkgland init -t native-go my-app
|
|
259
259
|
cd my-app && $EDITOR backend/internal/app/routes.go frontend/src/App.vue
|
|
260
260
|
synopkgland build-spk # → dist/<Package>-<version>-<arch>.spk
|
package/dist/error-registry.js
CHANGED
|
@@ -651,7 +651,7 @@ export const ERROR_REGISTRY = {
|
|
|
651
651
|
"STANDALONE_BACKGROUND_BUILD_FAILED": {
|
|
652
652
|
"code": "STANDALONE_BACKGROUND_BUILD_FAILED",
|
|
653
653
|
"description": "無法建置套件的背景 worker。",
|
|
654
|
-
"suggestion": "安裝 Go 1.
|
|
654
|
+
"suggestion": "安裝 Go 1.25 以上,確認 backend 含 cmd/background-worker(可用 synopkgland init --background 建立),並檢查 compiler stderr。"
|
|
655
655
|
},
|
|
656
656
|
"STANDALONE_BACKGROUND_ARTIFACT_REQUIRED": {
|
|
657
657
|
"code": "STANDALONE_BACKGROUND_ARTIFACT_REQUIRED",
|
|
@@ -836,7 +836,7 @@ export const ERROR_REGISTRY = {
|
|
|
836
836
|
"STANDALONE_GO_MISSING": {
|
|
837
837
|
"code": "STANDALONE_GO_MISSING",
|
|
838
838
|
"description": "找不到 go 指令。",
|
|
839
|
-
"suggestion": "請安裝 Go 1.
|
|
839
|
+
"suggestion": "請安裝 Go 1.25 以上版本(https://go.dev/dl/),或改用 backend.binary 打包既有執行檔。"
|
|
840
840
|
},
|
|
841
841
|
"STANDALONE_GO_BUILD_FAILED": {
|
|
842
842
|
"code": "STANDALONE_GO_BUILD_FAILED",
|
|
@@ -880,7 +880,7 @@ export const ERROR_REGISTRY = {
|
|
|
880
880
|
},
|
|
881
881
|
"STANDALONE_I18N_GLOSSARY_MISMATCH": {
|
|
882
882
|
"code": "STANDALONE_I18N_GLOSSARY_MISMATCH",
|
|
883
|
-
"description": "訊息沒有使用用語表中的
|
|
883
|
+
"description": "訊息沒有使用用語表中的 DSM 介面用語。",
|
|
884
884
|
"suggestion": "請改用 glossary.json 中該語言的用語,與 DSM 介面保持一致。"
|
|
885
885
|
},
|
|
886
886
|
"DEPLOY_VERSION_INVALID": {
|
|
@@ -36,7 +36,7 @@ export declare function installedPackageIdentitiesFromPayload(payload: unknown):
|
|
|
36
36
|
*
|
|
37
37
|
* The package list is a forward mapping: package identity -> project name.
|
|
38
38
|
* Do not use presumedPackageIdentity here; replacing dashes with dots is
|
|
39
|
-
* lossy for identities such as com.example.
|
|
39
|
+
* lossy for identities such as com.example.container-web.
|
|
40
40
|
*/
|
|
41
41
|
export declare function dsmPortProfileInventoryFromPayloads(portInfoPayload: unknown, packageListPayload: unknown): DsmPortProfileInventory | undefined;
|
|
42
42
|
/**
|
|
@@ -136,7 +136,7 @@ export function installedPackageIdentitiesFromPayload(payload) {
|
|
|
136
136
|
*
|
|
137
137
|
* The package list is a forward mapping: package identity -> project name.
|
|
138
138
|
* Do not use presumedPackageIdentity here; replacing dashes with dots is
|
|
139
|
-
* lossy for identities such as com.example.
|
|
139
|
+
* lossy for identities such as com.example.container-web.
|
|
140
140
|
*/
|
|
141
141
|
export function dsmPortProfileInventoryFromPayloads(portInfoPayload, packageListPayload) {
|
|
142
142
|
const profiles = parseDsmPortInfoPayload(portInfoPayload);
|
package/dist/init.js
CHANGED
|
@@ -4,8 +4,6 @@ import { templatesRoot } from "./template-paths.js";
|
|
|
4
4
|
import { resolveMinDsm, assertContainerMinDsm, DEFAULT_MIN_DSM, isDsmAtLeast } from "./build-spk/dsm-version.js";
|
|
5
5
|
import { readSharedFolder } from "./standalone/shared-folder.js";
|
|
6
6
|
const TEMPLATE_NAMES = [
|
|
7
|
-
"hello-web",
|
|
8
|
-
"hello-web-page",
|
|
9
7
|
"hello-files",
|
|
10
8
|
"compose-import",
|
|
11
9
|
"binary",
|
|
@@ -13,8 +11,6 @@ const TEMPLATE_NAMES = [
|
|
|
13
11
|
];
|
|
14
12
|
/** Templates whose App runs through the DSM docker-project resource worker. */
|
|
15
13
|
const CONTAINER_TEMPLATES = [
|
|
16
|
-
"hello-web",
|
|
17
|
-
"hello-web-page",
|
|
18
14
|
"hello-files",
|
|
19
15
|
"compose-import",
|
|
20
16
|
];
|
|
@@ -103,6 +99,7 @@ export function initFromTemplate(template, targetDir, options = {}) {
|
|
|
103
99
|
const overlays = [
|
|
104
100
|
...(rootExample ? [ROOT_OVERLAY] : []),
|
|
105
101
|
...(ui === "vue" ? ["native-go-vue"] : []),
|
|
102
|
+
...(privilege === "root" ? ["native-go-authorization"] : []),
|
|
106
103
|
...(options.background ? [BACKGROUND_OVERLAY] : []),
|
|
107
104
|
...(options.integration ? ["native-go-integration"] : []),
|
|
108
105
|
...(options.integration === "better-auth" ? ["native-go-better-auth"] : []),
|
|
@@ -128,6 +125,8 @@ export function initFromTemplate(template, targetDir, options = {}) {
|
|
|
128
125
|
}
|
|
129
126
|
if (options.integration === "better-auth")
|
|
130
127
|
cpSync(join(shared, 'notice-overrides'), join(targetDir, 'node/notice-overrides'), { recursive: true });
|
|
128
|
+
if (privilege === "root")
|
|
129
|
+
rmSync(join(targetDir, "backend/internal/app/authorization_none.go"));
|
|
131
130
|
if (rootExample)
|
|
132
131
|
ROOT_REPLACED.forEach(file => rmSync(join(targetDir, file)));
|
|
133
132
|
if (options.background)
|
|
@@ -154,6 +153,28 @@ export function initFromTemplate(template, targetDir, options = {}) {
|
|
|
154
153
|
writeFileSync(manifest, readFileSync(manifest, "utf8").replace(" dir: ui", " dir: frontend/dist\n build: {cwd: frontend, command: [npm, run, build]}"));
|
|
155
154
|
rmSync(join(targetDir, "ui"), { recursive: true });
|
|
156
155
|
}
|
|
156
|
+
if (privilege === "root" && ui === "vue") {
|
|
157
|
+
for (const entry of ["App.vue", "SDKApp.vue"]) {
|
|
158
|
+
const app = join(targetDir, "frontend/src", entry);
|
|
159
|
+
writeFileSync(app, readFileSync(app, "utf8")
|
|
160
|
+
.replace("import AppShell from './shell/AppShell.vue'", "import AuthorizationGate from './authorization/AuthorizationGate.vue'\nimport AppShell from './shell/AppShell.vue'")
|
|
161
|
+
.replace('<AppShell v-model="page"', '<AuthorizationGate :ctx="ctx" @ready="authorizationReady"><AppShell v-model="page"')
|
|
162
|
+
.replace('onMounted(async()=>{\n props.ctx.reopen(()=>{if(!disposed) void refresh()})\n await refresh()', 'async function authorizationReady(){\n await refresh()')
|
|
163
|
+
.replace('})\nonUnmounted', '}\nonMounted(()=>{if(!props.ctx.authorization){props.ctx.reopen(()=>{if(!disposed) void refresh()});void authorizationReady()}})\nonUnmounted')
|
|
164
|
+
.replace('</AppShell>', '</AppShell></AuthorizationGate>'));
|
|
165
|
+
}
|
|
166
|
+
for (const entry of ["main.ts", "sdk-main.ts"]) {
|
|
167
|
+
const main = join(targetDir, "frontend/src", entry);
|
|
168
|
+
writeFileSync(main, readFileSync(main, "utf8").replace('{mount: typeof mount}', '{mount: typeof mount; authorizationUI?: boolean}').replace('{mount}', '{mount, authorizationUI: true}'));
|
|
169
|
+
}
|
|
170
|
+
for (const file of ["apps.test.ts", "nas-ui.test.ts", "shell-ui.test.ts", "shell-sdk.test.ts", "picker-wire.test.ts"]) {
|
|
171
|
+
const test = join(targetDir, "frontend/test", file);
|
|
172
|
+
const source = readFileSync(test, "utf8");
|
|
173
|
+
writeFileSync(test, source + "\n// Test the application surface independently; authorization has its own contract suite.\nvi.mock('../src/authorization/AuthorizationGate.vue',()=>({default:{template:'<slot />'}}))\n");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (ui !== "vue")
|
|
177
|
+
rmSync(join(targetDir, "frontend"), { recursive: true, force: true });
|
|
157
178
|
if (options.background) {
|
|
158
179
|
const manifest = join(targetDir, "dsm-package.yaml");
|
|
159
180
|
writeFileSync(manifest, readFileSync(manifest, "utf8") + "\nbackground:\n worker: sha256-v1\n");
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** Shared host flow. CGI plans are never accepted as completed authorization. */
|
|
2
|
+
export declare const AUTHORIZATION_RUNTIME = "function createAuthorizationAccess(options) {\n var busy = null;\n function fail(code) { var error = new Error(code); error.code = code; return error; }\n function status() { return options.call(\"authorization.status\").then(function (d) {\n if (!d || d.command !== \"authorization\" || d.schema_version !== 1 || [\"ready\",\"authorization_required\",\"not_admin\",\"session_expired\",\"unavailable\"].indexOf(d.state) < 0) throw fail(\"authorization_bad_response\");\n return d;\n }); }\n function plan(method) { return options.call(method).then(function (p) {\n if (!p || p.command !== \"authorization\" || p.schema_version !== 1 || p.phase !== \"plan\" || !Array.isArray(p.operations) || p.operations.length === 0 || p.operations.length > options.tasks.length) throw fail(\"authorization_bad_response\");\n var seen = {};\n p.operations.forEach(function (op) { if (seen[op] || !options.tasks.some(function (t) { return t.op === op; })) throw fail(\"authorization_bad_response\"); seen[op] = true; });\n return p;\n }); }\n function exclusive(fn) { if (busy) return busy; busy = Promise.resolve().then(fn).then(function (s) { busy = null; return s; },function (e) { busy = null; throw e; });return busy; }\n function authorize(ops,run) { return ops.reduce(function (previous,op) { return previous.then(function () {\n return options.root.authorize(op).then(function (state) { if (state !== \"ready\") throw fail(\"root_cancelled\"); return run ? options.root.run(op) : null; });\n }); },Promise.resolve()); }\n return {canManage:function () {return !!options.user && options.user().admin === true;},status:status,request:function () { return exclusive(function () {\n return plan(\"authorization.request\").then(function (p) { return authorize(p.operations,p.run === true); }).then(status,function (e) { if (e.code === \"root_cancelled\") return status();throw e; });\n }); },revoke:function () { return exclusive(function () {\n return plan(\"authorization.revoke\").then(function (p) {\n if (p.withdraw === true && typeof options.withdraw !== \"function\") throw fail(\"authorization_bad_response\");\n var disabled = p.withdraw === true ? options.withdraw() : p.disable === true ? authorize([\"helper_disable\"],true) : Promise.resolve();\n return disabled.then(function () { return p.purge === true ? authorize([\"helper_purge\"],true) : null; }).then(function () { return p.operations.reduce(function (previous,op) {return previous.then(function () {return options.root.remove(op);});},Promise.resolve()); });\n }).then(status,function (e) { if (e.code === \"root_cancelled\") return status();throw e; });\n }); }};\n}";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Shared host flow. CGI plans are never accepted as completed authorization. */
|
|
2
|
+
export const AUTHORIZATION_RUNTIME = `function createAuthorizationAccess(options) {
|
|
3
|
+
var busy = null;
|
|
4
|
+
function fail(code) { var error = new Error(code); error.code = code; return error; }
|
|
5
|
+
function status() { return options.call("authorization.status").then(function (d) {
|
|
6
|
+
if (!d || d.command !== "authorization" || d.schema_version !== 1 || ["ready","authorization_required","not_admin","session_expired","unavailable"].indexOf(d.state) < 0) throw fail("authorization_bad_response");
|
|
7
|
+
return d;
|
|
8
|
+
}); }
|
|
9
|
+
function plan(method) { return options.call(method).then(function (p) {
|
|
10
|
+
if (!p || p.command !== "authorization" || p.schema_version !== 1 || p.phase !== "plan" || !Array.isArray(p.operations) || p.operations.length === 0 || p.operations.length > options.tasks.length) throw fail("authorization_bad_response");
|
|
11
|
+
var seen = {};
|
|
12
|
+
p.operations.forEach(function (op) { if (seen[op] || !options.tasks.some(function (t) { return t.op === op; })) throw fail("authorization_bad_response"); seen[op] = true; });
|
|
13
|
+
return p;
|
|
14
|
+
}); }
|
|
15
|
+
function exclusive(fn) { if (busy) return busy; busy = Promise.resolve().then(fn).then(function (s) { busy = null; return s; },function (e) { busy = null; throw e; });return busy; }
|
|
16
|
+
function authorize(ops,run) { return ops.reduce(function (previous,op) { return previous.then(function () {
|
|
17
|
+
return options.root.authorize(op).then(function (state) { if (state !== "ready") throw fail("root_cancelled"); return run ? options.root.run(op) : null; });
|
|
18
|
+
}); },Promise.resolve()); }
|
|
19
|
+
return {canManage:function () {return !!options.user && options.user().admin === true;},status:status,request:function () { return exclusive(function () {
|
|
20
|
+
return plan("authorization.request").then(function (p) { return authorize(p.operations,p.run === true); }).then(status,function (e) { if (e.code === "root_cancelled") return status();throw e; });
|
|
21
|
+
}); },revoke:function () { return exclusive(function () {
|
|
22
|
+
return plan("authorization.revoke").then(function (p) {
|
|
23
|
+
if (p.withdraw === true && typeof options.withdraw !== "function") throw fail("authorization_bad_response");
|
|
24
|
+
var disabled = p.withdraw === true ? options.withdraw() : p.disable === true ? authorize(["helper_disable"],true) : Promise.resolve();
|
|
25
|
+
return disabled.then(function () { return p.purge === true ? authorize(["helper_purge"],true) : null; }).then(function () { return p.operations.reduce(function (previous,op) {return previous.then(function () {return options.root.remove(op);});},Promise.resolve()); });
|
|
26
|
+
}).then(status,function (e) { if (e.code === "root_cancelled") return status();throw e; });
|
|
27
|
+
}); }};
|
|
28
|
+
}`;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { NativeArchFamily } from '../build-spk/dsm-arch.js';
|
|
2
|
+
import type { CommandRunner } from './build.js';
|
|
3
|
+
import { type RootOp } from './root-ops.js';
|
|
4
|
+
import type { StandaloneManifest } from './manifest.js';
|
|
5
|
+
export interface PinnedFile {
|
|
6
|
+
path: string;
|
|
7
|
+
size: number;
|
|
8
|
+
sha256: string;
|
|
9
|
+
}
|
|
10
|
+
export interface AuthorizationInventory {
|
|
11
|
+
schema: 2;
|
|
12
|
+
package: string;
|
|
13
|
+
version: string;
|
|
14
|
+
arch: string;
|
|
15
|
+
operations: RootOp[];
|
|
16
|
+
files: PinnedFile[];
|
|
17
|
+
digest: string;
|
|
18
|
+
}
|
|
19
|
+
export interface AuthorizationArtifact {
|
|
20
|
+
binary: string;
|
|
21
|
+
inventory: AuthorizationInventory;
|
|
22
|
+
bootstrapSha256: string;
|
|
23
|
+
ops: RootOp[];
|
|
24
|
+
}
|
|
25
|
+
export declare function authorizationInventory(m: {
|
|
26
|
+
package: string;
|
|
27
|
+
spkVersion: string;
|
|
28
|
+
rootOps?: RootOp[];
|
|
29
|
+
}, arch: string, files: PinnedFile[]): AuthorizationInventory;
|
|
30
|
+
export declare function bootstrapOps(m: {
|
|
31
|
+
package: string;
|
|
32
|
+
spkVersion: string;
|
|
33
|
+
rootOps?: RootOp[];
|
|
34
|
+
}, pin: string): RootOp[];
|
|
35
|
+
export declare function authorizationPlan(a: AuthorizationArtifact, m: {
|
|
36
|
+
package: string;
|
|
37
|
+
spkVersion: string;
|
|
38
|
+
}): {
|
|
39
|
+
schema: number;
|
|
40
|
+
package: string;
|
|
41
|
+
version: string;
|
|
42
|
+
bootstrapSha256: string;
|
|
43
|
+
inventory: AuthorizationInventory;
|
|
44
|
+
tasks: import("./root-window.js").RootTask[];
|
|
45
|
+
};
|
|
46
|
+
export declare function buildAuthorization(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): AuthorizationArtifact | undefined;
|
|
47
|
+
/** Archive consistency, not publisher authenticity or device runtime certification. */
|
|
48
|
+
export declare function inspectAuthorization(dir: string, pkg: string, version: string): string[];
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import { renderRootTaskScript } from './root-ops.js';
|
|
5
|
+
import { rootTasks } from './root-window.js';
|
|
6
|
+
import { StandaloneBuildError } from './spk.js';
|
|
7
|
+
import { assertHelperTaskBudget } from './helper.js';
|
|
8
|
+
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
export function authorizationInventory(m, arch, files) {
|
|
10
|
+
const base = { schema: 2, package: m.package, version: m.spkVersion, arch, operations: m.rootOps ?? [], files: [...files].sort((a, b) => a.path.localeCompare(b.path, 'en')) };
|
|
11
|
+
return { ...base, digest: hash(JSON.stringify(base)) };
|
|
12
|
+
}
|
|
13
|
+
export function bootstrapOps(m, pin) {
|
|
14
|
+
if (!/^[a-f0-9]{64}$/.test(pin))
|
|
15
|
+
throw new StandaloneBuildError('AUTHORIZATION_PIN_REQUIRED', 'bootstrap SHA-256 pin required');
|
|
16
|
+
return (m.rootOps ?? []).map(op => ({ name: op.name, protectedOutput: true, exposeOutput: op.protectedOutput !== true, body: `#!/bin/sh
|
|
17
|
+
set -eu
|
|
18
|
+
umask 077
|
|
19
|
+
[ "$(id -u)" = 0 ]
|
|
20
|
+
info=/var/packages/${m.package}/INFO
|
|
21
|
+
[ ! -L "$info" ] && [ "$(stat -c %u "$info")" = 0 ]
|
|
22
|
+
grep -Fqx 'version="${m.spkVersion}"' "$info"
|
|
23
|
+
for p in /var /var/lib; do
|
|
24
|
+
[ ! -L "$p" ] && [ -d "$p" ] && [ "$(stat -c %u "$p")" = 0 ]
|
|
25
|
+
mode=$(stat -c %a "$p"); [ "$((0$mode & 0022))" = 0 ]
|
|
26
|
+
done
|
|
27
|
+
s=/var/lib/synopkgland-auth-${m.package}
|
|
28
|
+
[ ! -L "$s" ]
|
|
29
|
+
if [ ! -e "$s" ]; then mkdir -m 0700 "$s"; fi
|
|
30
|
+
[ -d "$s" ] && [ "$(stat -c %u:%a "$s")" = 0:700 ]
|
|
31
|
+
tmp=$(mktemp "$s/.bootstrap.XXXXXX")
|
|
32
|
+
trap 'rm -f "$tmp"; rmdir "$s" 2>/dev/null || :' EXIT HUP INT TERM
|
|
33
|
+
dd if=/var/packages/${m.package}/target/authorization/bootstrap of="$tmp" bs=65536 count=1025 2>/dev/null
|
|
34
|
+
[ "$(stat -c %s "$tmp")" -le 67108864 ]
|
|
35
|
+
chmod 0700 "$tmp"
|
|
36
|
+
[ "$(sha256sum "$tmp" | cut -d ' ' -f 1)" = '${pin}' ]
|
|
37
|
+
"$tmp" '${op.name}'
|
|
38
|
+
` }));
|
|
39
|
+
}
|
|
40
|
+
export function authorizationPlan(a, m) {
|
|
41
|
+
return { schema: 2, package: m.package, version: m.spkVersion, bootstrapSha256: a.bootstrapSha256, inventory: a.inventory, tasks: rootTasks({ ...m, rootOps: a.ops }) };
|
|
42
|
+
}
|
|
43
|
+
export function buildAuthorization(m, arch, work, run) {
|
|
44
|
+
if (m.privilege !== 'root')
|
|
45
|
+
return undefined;
|
|
46
|
+
if (m.backend.kind !== 'go')
|
|
47
|
+
throw new StandaloneBuildError('AUTHORIZATION_GO_REQUIRED', 'root authorization requires the native-Go CGI template');
|
|
48
|
+
const files = [];
|
|
49
|
+
const add = (path, source) => { const bytes = readFileSync(source); files.push({ path, size: bytes.length, sha256: hash(bytes) }); };
|
|
50
|
+
const walk = (dir) => { for (const name of readdirSync(dir).sort()) {
|
|
51
|
+
const p = join(dir, name), st = lstatSync(p);
|
|
52
|
+
if (st.isDirectory())
|
|
53
|
+
walk(p);
|
|
54
|
+
else if (st.isFile())
|
|
55
|
+
add('ui/app/' + relative(m.ui.dir, p).split('\\').join('/'), p);
|
|
56
|
+
else
|
|
57
|
+
throw new StandaloneBuildError('AUTHORIZATION_PAYLOAD_UNSAFE', 'UI inventory requires regular files');
|
|
58
|
+
} };
|
|
59
|
+
walk(m.ui.dir);
|
|
60
|
+
// Helper bodies already contain the exact helper digest; worker payloads are
|
|
61
|
+
// bound by its compiled registry. UI bytes are additionally checked at launch.
|
|
62
|
+
const inventory = authorizationInventory(m, arch, files);
|
|
63
|
+
const dir = join(work, 'authorization-' + arch);
|
|
64
|
+
mkdirSync(dir);
|
|
65
|
+
const source = readFileSync(new URL('../../templates/_authorization/main.go', import.meta.url), 'utf8');
|
|
66
|
+
writeFileSync(join(dir, 'main.go'), source);
|
|
67
|
+
const binary = join(work, 'bootstrap-' + arch);
|
|
68
|
+
const compiled = JSON.stringify({ ...inventory, operations: inventory.operations.map(op => ({ ...op, body: op.body })) });
|
|
69
|
+
const result = run('go', ['build', '-trimpath', '-buildvcs=false', '-ldflags', `-s -w -buildid= -X 'main.inventoryBase64=${Buffer.from(compiled).toString('base64')}'`, '-o', binary, 'main.go'], { cwd: dir, env: { ...process.env, CGO_ENABLED: '0', GOOS: 'linux', GOARCH: arch === 'x86_64' ? 'amd64' : 'arm64' } });
|
|
70
|
+
if (result.missing || result.status !== 0)
|
|
71
|
+
throw new StandaloneBuildError('AUTHORIZATION_BUILD_FAILED', result.stderr);
|
|
72
|
+
const bootstrapSha256 = hash(readFileSync(binary));
|
|
73
|
+
const ops = bootstrapOps(m, bootstrapSha256);
|
|
74
|
+
ops.forEach(op => assertHelperTaskBudget(renderRootTaskScript(m, op)));
|
|
75
|
+
return { binary, inventory, bootstrapSha256, ops };
|
|
76
|
+
}
|
|
77
|
+
/** Archive consistency, not publisher authenticity or device runtime certification. */
|
|
78
|
+
export function inspectAuthorization(dir, pkg, version) {
|
|
79
|
+
if (!existsSync(join(dir, 'authorization/manifest.json')))
|
|
80
|
+
return ['root authorization manifest missing'];
|
|
81
|
+
try {
|
|
82
|
+
const plan = JSON.parse(readFileSync(join(dir, 'authorization/manifest.json'), 'utf8'));
|
|
83
|
+
if (plan.schema !== 2 || plan.package !== pkg || plan.version !== version || !/^[a-f0-9]{64}$/.test(plan.bootstrapSha256))
|
|
84
|
+
throw Error();
|
|
85
|
+
const bytes = readFileSync(join(dir, 'authorization/bootstrap'));
|
|
86
|
+
if (hash(bytes) !== plan.bootstrapSha256 || !bytes.includes(Buffer.from(Buffer.from(JSON.stringify(plan.inventory)).toString('base64'))))
|
|
87
|
+
throw Error();
|
|
88
|
+
const inventory = authorizationInventory({ package: pkg, spkVersion: version, rootOps: plan.inventory.operations }, plan.inventory.arch, plan.inventory.files);
|
|
89
|
+
if (JSON.stringify(inventory) !== JSON.stringify(plan.inventory))
|
|
90
|
+
throw Error();
|
|
91
|
+
if (JSON.stringify(plan.tasks) !== JSON.stringify(rootTasks({ package: pkg, spkVersion: version, rootOps: bootstrapOps({ package: pkg, spkVersion: version, rootOps: inventory.operations }, plan.bootstrapSha256) })))
|
|
92
|
+
throw Error();
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
for (const f of inventory.files) {
|
|
95
|
+
if (!/^ui\/app\/(?!.*(?:^|\/)\.\.(?:\/|$))[^\x00\\]+$/.test(f.path) || seen.has(f.path))
|
|
96
|
+
throw Error();
|
|
97
|
+
seen.add(f.path);
|
|
98
|
+
const b = readFileSync(join(dir, f.path));
|
|
99
|
+
if (b.length !== f.size || hash(b) !== f.sha256)
|
|
100
|
+
throw Error();
|
|
101
|
+
}
|
|
102
|
+
const cgi = readFileSync(join(dir, 'ui/cgi/api.cgi'));
|
|
103
|
+
if (!cgi.includes(Buffer.from(Buffer.from(JSON.stringify(plan)).toString('base64'))))
|
|
104
|
+
throw Error();
|
|
105
|
+
const windows = readdirSync(join(dir, 'ui')).filter(f => /^window-\d+\.js$/.test(f));
|
|
106
|
+
if (windows.length !== 1)
|
|
107
|
+
throw Error();
|
|
108
|
+
const window = readFileSync(join(dir, 'ui', windows[0]), 'utf8');
|
|
109
|
+
if (!window.includes(JSON.stringify(plan.tasks)))
|
|
110
|
+
throw Error();
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return ['root authorization bootstrap pin, inventory, CGI or task binding invalid'];
|
|
115
|
+
}
|
|
116
|
+
}
|
package/dist/standalone/build.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { resolveMinDsm, DEFAULT_MIN_DSM, isDsmAtLeast } from "../build-spk/dsm-version.js";
|
|
@@ -8,6 +8,7 @@ import { loadStandaloneManifest } from "./manifest.js";
|
|
|
8
8
|
import { assembleStandaloneSpk, StandaloneBuildError } from "./spk.js";
|
|
9
9
|
import { buildHelper } from "./helper.js";
|
|
10
10
|
import { buildDaemonControl } from "./daemon.js";
|
|
11
|
+
import { buildAuthorization, authorizationPlan } from "./authorization.js";
|
|
11
12
|
import { BACKGROUND_WORKER_MAIN } from "./background.js";
|
|
12
13
|
const GOARCH = { x86_64: "amd64", aarch64: "arm64" };
|
|
13
14
|
/** Argv only, never a shell; output streams to the terminal except stderr, which is also kept. */
|
|
@@ -61,16 +62,26 @@ export async function buildStandalone(options) {
|
|
|
61
62
|
if (problem)
|
|
62
63
|
throw new StandaloneBuildError(problem.code, problem.message);
|
|
63
64
|
}
|
|
64
|
-
const
|
|
65
|
+
const operationsManifest = helper ? { ...manifest, rootOps: [...(manifest.rootOps ?? []), ...helper.ops] } : manifest;
|
|
66
|
+
const authorization = buildAuthorization(operationsManifest, arch, work, run);
|
|
67
|
+
if (authorization) {
|
|
68
|
+
const problem = checkCgiBinary(work, authorization.binary, arch);
|
|
69
|
+
if (problem)
|
|
70
|
+
throw new StandaloneBuildError(problem.code, problem.message);
|
|
71
|
+
}
|
|
72
|
+
const cgiPath = backendFor(manifest, arch, work, run, helper, authorization);
|
|
65
73
|
const problem = checkCgiBinary(manifest.backend.kind === "go" ? work : manifest.root, cgiPath, arch);
|
|
66
74
|
if (problem) {
|
|
67
75
|
throw new StandaloneBuildError(problem.code, problem.message);
|
|
68
76
|
}
|
|
77
|
+
if (authorization && !readFileSync(cgiPath).includes(Buffer.from(JSON.stringify(authorizationPlan(authorization, manifest))).toString("base64"))) {
|
|
78
|
+
throw new StandaloneBuildError("AUTHORIZATION_CGI_REQUIRED", "root CGI must contain its compiled authorization plan; regenerate the root template adapter");
|
|
79
|
+
}
|
|
69
80
|
const outputPath = join(outDir, `${manifest.package}-${manifest.spkVersion}-${arch}.spk`);
|
|
70
81
|
const daemonControl = buildDaemonControl(manifest, arch, work, run);
|
|
71
|
-
const builtManifest =
|
|
82
|
+
const builtManifest = authorization ? { ...manifest, rootOps: authorization.ops } : operationsManifest;
|
|
72
83
|
const backgroundWorker = buildBackgroundWorker(manifest, arch, work, run);
|
|
73
|
-
await assembleStandaloneSpk({ manifest: builtManifest, arch, cgiPath, outputPath, helper, daemonControl, backgroundWorker });
|
|
84
|
+
await assembleStandaloneSpk({ manifest: builtManifest, arch, cgiPath, outputPath, helper, daemonControl, backgroundWorker, authorization });
|
|
74
85
|
outputs.push(outputPath);
|
|
75
86
|
}
|
|
76
87
|
return { outputs };
|
|
@@ -93,7 +104,7 @@ function buildUi(manifest, run) {
|
|
|
93
104
|
throw new StandaloneBuildError("STANDALONE_UI_BUILD_FAILED", `UI build(${build.command.join(" ")})失敗。${installHint}\n${result.stderr}`);
|
|
94
105
|
}
|
|
95
106
|
}
|
|
96
|
-
function backendFor(manifest, arch, work, run, helper) {
|
|
107
|
+
function backendFor(manifest, arch, work, run, helper, authorization) {
|
|
97
108
|
const backend = manifest.backend;
|
|
98
109
|
if (backend.kind === "binary") {
|
|
99
110
|
return backend.files[arch];
|
|
@@ -106,6 +117,7 @@ function backendFor(manifest, arch, work, run, helper) {
|
|
|
106
117
|
`-X main.packageID=${manifest.package}`,
|
|
107
118
|
`-X main.version=${manifest.spkVersion}`,
|
|
108
119
|
...(helper ? [`-X 'main.helperInventory=${JSON.stringify(helper.inventory)}'`] : []),
|
|
120
|
+
...(authorization ? [`-X 'main.rootAuthorization=${Buffer.from(JSON.stringify(authorizationPlan(authorization, manifest))).toString('base64')}'`] : []),
|
|
109
121
|
// Integration without a helper: the CGI serves its package-account features itself.
|
|
110
122
|
...(manifest.integration && !helper ? ["-X main.integrationMode=package"] : []),
|
|
111
123
|
...(manifest.fileStation ? [`-X main.fileStation=${manifest.fileStation}`] : []),
|
|
@@ -115,7 +127,7 @@ function backendFor(manifest, arch, work, run, helper) {
|
|
|
115
127
|
env: { ...process.env, CGO_ENABLED: "0", GOOS: "linux", GOARCH: GOARCH[arch] },
|
|
116
128
|
});
|
|
117
129
|
if (result.missing) {
|
|
118
|
-
throw new StandaloneBuildError("STANDALONE_GO_MISSING", "找不到 go 指令。請安裝 Go 1.
|
|
130
|
+
throw new StandaloneBuildError("STANDALONE_GO_MISSING", "找不到 go 指令。請安裝 Go 1.25 以上版本(https://go.dev/dl/),或改用 backend.binary 打包既有執行檔。");
|
|
119
131
|
}
|
|
120
132
|
if (result.status !== 0) {
|
|
121
133
|
throw new StandaloneBuildError("STANDALONE_GO_BUILD_FAILED", `go build(${arch})失敗。\n${result.stderr}`);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AUTHORIZATION_RUNTIME } from "./authorization-window.js";
|
|
1
2
|
import { REOPEN_EVENTS_RUNTIME } from "./reopen-events.js";
|
|
2
3
|
import { DSM_LANGS } from "./i18n.js";
|
|
3
4
|
import { ROOT_MESSAGES } from "./root-messages.js";
|
|
@@ -180,15 +181,17 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
|
|
|
180
181
|
var i18n = ${ns}.createI18n(lang, ${ns}.messages);
|
|
181
182
|
me.hostAccess = ${ns}.createHost({ appWindow: me, locale: i18n.locale });
|
|
182
183
|
root.lang = i18n.locale;
|
|
184
|
+
if (app.authorizationUI && me.body) { var panels = me.body.dom.querySelector(".dsmapp-rootpanels"); if (panels) panels.hidden = true; }
|
|
183
185
|
try {
|
|
184
186
|
me.mounted = app.mount(root, {
|
|
187
|
+
displayName: ${JSON.stringify(manifest.displayname)},
|
|
185
188
|
call: ${ns}.call,
|
|
186
189
|
reopen: me.reopenEvents.subscribe,
|
|
187
190
|
host: me.hostAccess,
|
|
188
191
|
appWindow: me,
|
|
189
192
|
lang: lang,
|
|
190
193
|
locale: i18n.locale,
|
|
191
|
-
t: i18n.t${root ?
|
|
194
|
+
t: i18n.t${root ? `,\n root: access,\n authorization: ${ns}.createAuthorizationAccess({root: access, call: ${ns}.call, tasks: ${ns}.rootTasks, user: session})` : ""}
|
|
192
195
|
}) || {};
|
|
193
196
|
} catch (error) {
|
|
194
197
|
me.hostAccess.dispose();
|
|
@@ -239,6 +242,10 @@ const ROOT_PANEL_CSS = [
|
|
|
239
242
|
function renderRootParts(ns, manifest) {
|
|
240
243
|
return {
|
|
241
244
|
definitions: `${ns}.rootTasks = ${JSON.stringify(rootTasks(manifest))};
|
|
245
|
+
${ns}.createAuthorizationAccess = (function () {
|
|
246
|
+
${AUTHORIZATION_RUNTIME}
|
|
247
|
+
return createAuthorizationAccess;
|
|
248
|
+
})();
|
|
242
249
|
${ns}.rootMessages = ${JSON.stringify(ROOT_MESSAGES)};
|
|
243
250
|
${ns}.createRootAccess = (function () {
|
|
244
251
|
${ROOT_RUNTIME}
|
|
@@ -57,7 +57,7 @@ export function assertHelperTaskBudget(script, metadata = JSON.stringify({ scrip
|
|
|
57
57
|
throw new StandaloneBuildError("HELPER_METADATA_TOO_LARGE", "helper scheduler metadata 超過 8191 bytes 有界限制。");
|
|
58
58
|
}
|
|
59
59
|
export function helperOps(m, inventory) {
|
|
60
|
-
return ["enable", "recover", "teardown", "disable", "repair"].map(action => {
|
|
60
|
+
return ["enable", "recover", "teardown", "disable", "repair", ...(m.privilege === 'root' ? ['purge'] : [])].map(action => {
|
|
61
61
|
const op = { name: `helper_${action}`, protectedOutput: true, body: `#!/bin/sh
|
|
62
62
|
set -eu
|
|
63
63
|
umask 077
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bootstrapOps } from "./authorization.js";
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import * as tar from "tar";
|
|
@@ -18,7 +19,7 @@ export async function inspectHelper(dir, info, findings, daemon = false) {
|
|
|
18
19
|
// Enumerate candidates to reject extras; only the INFO-derived exact path is read.
|
|
19
20
|
if (/^ui\/window-[^/]*\.js$/.test(path))
|
|
20
21
|
windows.push(path);
|
|
21
|
-
if (entry.type !== "File" || !(path.startsWith("integration/workers/") || ["integration/helper", "integration/inventory.json", "integration/workers.json", "ui/cgi/api.cgi", windowPath].includes(path)))
|
|
22
|
+
if (entry.type !== "File" || !(path.startsWith("integration/workers/") || ["authorization/manifest.json", "integration/helper", "integration/inventory.json", "integration/workers.json", "ui/cgi/api.cgi", windowPath].includes(path)))
|
|
22
23
|
return;
|
|
23
24
|
const limit = ["integration/helper", "ui/cgi/api.cgi"].includes(path) ? 64 * 1024 * 1024 : 1024 * 1024;
|
|
24
25
|
if (files.has(path) || entry.size > limit) {
|
|
@@ -75,7 +76,16 @@ export async function inspectHelper(dir, info, findings, daemon = false) {
|
|
|
75
76
|
throw new Error("unregistered worker");
|
|
76
77
|
const manifest = { package: i.package, spkVersion: i.version, privilege: "root", integration: { protocol: i.protocol, workers: {} } };
|
|
77
78
|
const tasks = JSON.parse(/\.rootTasks = (\[[^\n]+\]);/.exec(bytes(windowPath).toString("utf8"))?.[1] ?? "null");
|
|
78
|
-
const
|
|
79
|
+
const originalOps = helperOps(manifest, i);
|
|
80
|
+
let expected = rootTasks({ ...manifest, rootOps: originalOps });
|
|
81
|
+
// Existing CLI 0.5 archives have inline hash-pinned helper tasks. New
|
|
82
|
+
// bootstrap archives carry an additional independently inspected inventory.
|
|
83
|
+
if (files.has("authorization/manifest.json")) {
|
|
84
|
+
const plan = JSON.parse(bytes("authorization/manifest.json").toString("utf8"));
|
|
85
|
+
if (JSON.stringify(plan.inventory.operations.filter((op) => op.name.startsWith("helper_"))) !== JSON.stringify(originalOps))
|
|
86
|
+
throw new Error("bootstrap helper binding mismatch");
|
|
87
|
+
expected = rootTasks({ ...manifest, rootOps: bootstrapOps({ ...manifest, rootOps: originalOps }, plan.bootstrapSha256) });
|
|
88
|
+
}
|
|
79
89
|
if (!Array.isArray(tasks) || JSON.stringify(tasks.filter(t => t.op?.startsWith("helper_"))) !== JSON.stringify(expected))
|
|
80
90
|
throw new Error("approved task mismatch");
|
|
81
91
|
// Daemon lifecycle is verified separately with the complete daemon config.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { inspectAuthorization } from "./authorization.js";
|
|
1
2
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -77,6 +78,16 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
77
78
|
else if (integrationPaths.some(path => path !== "integration/identity/app.mjs")) {
|
|
78
79
|
findings.push("integration 沒有 helper 時只可附 integration/identity/app.mjs");
|
|
79
80
|
}
|
|
81
|
+
if (info.sdk_root_authorization === "2" || [...paths].some(path => path.startsWith("authorization/"))) {
|
|
82
|
+
if (info.sdk_root_authorization !== "2")
|
|
83
|
+
findings.push("root authorization INFO marker missing");
|
|
84
|
+
const authDir = join(dir, "authorization-inspection");
|
|
85
|
+
mkdirSync(authDir);
|
|
86
|
+
await tar.x({ file: join(dir, "package.tgz"), cwd: authDir });
|
|
87
|
+
findings.push(...inspectAuthorization(authDir, info.package, info.version));
|
|
88
|
+
if (payload.find(p => p.path === "authorization/bootstrap")?.mode !== "0755")
|
|
89
|
+
findings.push("authorization bootstrap must be executable");
|
|
90
|
+
}
|
|
80
91
|
const cgiEntry = payload.find((entry) => entry.path === CGI_PAYLOAD_PATH);
|
|
81
92
|
if (kind === "native-go" && !cgiEntry) {
|
|
82
93
|
findings.push(`payload 沒有 ${CGI_PAYLOAD_PATH}`);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Non-privileged management veto shared by the generated package hooks. */
|
|
2
|
-
export declare function renderHelperStoppedCheck(packageId: string): string;
|
|
2
|
+
export declare function renderHelperStoppedCheck(packageId: string, revoke?: boolean): string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Non-privileged management veto shared by the generated package hooks. */
|
|
2
|
-
export function renderHelperStoppedCheck(packageId) {
|
|
3
|
-
return `/var/packages/${packageId}/target/integration/helper check-stopped || {
|
|
2
|
+
export function renderHelperStoppedCheck(packageId, revoke = false) {
|
|
3
|
+
return `${revoke ? `/var/packages/${packageId}/target/integration/helper revoke && ` : ''}/var/packages/${packageId}/target/integration/helper check-stopped || {
|
|
4
4
|
message='Run the approved helper_teardown or helper_disable operation before stopping, upgrading or uninstalling.'
|
|
5
5
|
printf '%s\\n' "$message" >&2
|
|
6
6
|
if [ -n "\${SYNOPKG_TEMP_LOGFILE:-}" ]; then
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { StandaloneManifest } from "./manifest.js";
|
|
2
|
-
/**
|
|
2
|
+
/** Package-UID hooks: root auth withdraws via the trusted socket, then retains the stopped veto. */
|
|
3
3
|
export declare function renderStandaloneScript(name: string, manifest: StandaloneManifest): string;
|
|
@@ -3,7 +3,7 @@ import { renderHelperStoppedCheck } from "./lifecycle-helper.js";
|
|
|
3
3
|
import { renderBackgroundControl } from "./background.js";
|
|
4
4
|
import { renderPackagePostuninst } from "../build-spk/native-lifecycle.js";
|
|
5
5
|
import { renderSharedFolderScript } from "./shared-folder.js";
|
|
6
|
-
/**
|
|
6
|
+
/** Package-UID hooks: root auth withdraws via the trusted socket, then retains the stopped veto. */
|
|
7
7
|
export function renderStandaloneScript(name, manifest) {
|
|
8
8
|
if (name === "postuninst")
|
|
9
9
|
return renderPackagePostuninst(manifest.package);
|
|
@@ -14,7 +14,7 @@ export function renderStandaloneScript(name, manifest) {
|
|
|
14
14
|
if (manifest.background && name === "start-stop-status")
|
|
15
15
|
return renderBackgroundControl(manifest.package);
|
|
16
16
|
if (hasHelper(manifest) && ["preupgrade", "preuninst", "start-stop-status"].includes(name)) {
|
|
17
|
-
const check = renderHelperStoppedCheck(manifest.package);
|
|
17
|
+
const check = renderHelperStoppedCheck(manifest.package, manifest.privilege === 'root');
|
|
18
18
|
if (name !== "start-stop-status")
|
|
19
19
|
return `#!/bin/sh\n${check}`;
|
|
20
20
|
return `#!/bin/sh
|
|
@@ -44,7 +44,7 @@ function renderDaemonScript(name, manifest) {
|
|
|
44
44
|
const pkg = manifest.package;
|
|
45
45
|
const control = `"/var/packages/${pkg}/target/daemon/control"`;
|
|
46
46
|
const paths = `"\${SYNOPKG_PKGDEST:-/var/packages/${pkg}/target}" "\${SYNOPKG_PKGVAR:-/var/packages/${pkg}/var}"`;
|
|
47
|
-
const check = hasHelper(manifest) ? renderHelperStoppedCheck(pkg) : "";
|
|
47
|
+
const check = hasHelper(manifest) ? renderHelperStoppedCheck(pkg, manifest.privilege === 'root') : "";
|
|
48
48
|
if (["preupgrade", "preuninst"].includes(name))
|
|
49
49
|
return `#!/bin/sh\n${check}exec ${control} ${name === "preuninst" && manifest.daemon?.uninstall_args ? "uninstall" : "stop"} ${paths}\n`;
|
|
50
50
|
if (name !== "start-stop-status")
|
|
@@ -11,6 +11,8 @@ export interface RootOp {
|
|
|
11
11
|
body: string;
|
|
12
12
|
/** Internal generator flag for protected helper control output. */
|
|
13
13
|
protectedOutput?: boolean;
|
|
14
|
+
/** Bootstrap wraps public output in a protected directory without hiding it. */
|
|
15
|
+
exposeOutput?: boolean;
|
|
14
16
|
}
|
|
15
17
|
export type RootOpsResult = {
|
|
16
18
|
ok: true;
|