@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
|
@@ -68,6 +68,16 @@ function readRootOp(path, file) {
|
|
|
68
68
|
*/
|
|
69
69
|
export function renderRootTaskScript(manifest, op) {
|
|
70
70
|
const body = op.body.endsWith("\n") ? op.body : `${op.body}\n`;
|
|
71
|
+
// Wrapped helper operations expose no output; don't leave a root-owned
|
|
72
|
+
// output directory or files for an operation whose result is task history.
|
|
73
|
+
if (op.protectedOutput && op.exposeOutput === false)
|
|
74
|
+
return `${rootTaskHeader(manifest.package, op.name)}${manifest.spkVersion}.
|
|
75
|
+
set -eu
|
|
76
|
+
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
|
77
|
+
export PATH
|
|
78
|
+
/bin/sh -eu <<'${ROOT_OP_END}'
|
|
79
|
+
${body}${ROOT_OP_END}
|
|
80
|
+
`;
|
|
71
81
|
return `${rootTaskHeader(manifest.package, op.name)}${manifest.spkVersion}.
|
|
72
82
|
# Approved by a DSM administrator; runs only when this task is run.
|
|
73
83
|
set -eu
|
|
@@ -5,7 +5,7 @@ export function rootTasks(manifest) {
|
|
|
5
5
|
name: rootTaskName(manifest.package, op.name),
|
|
6
6
|
header: rootTaskHeader(manifest.package, op.name),
|
|
7
7
|
script: renderRootTaskScript(manifest, op),
|
|
8
|
-
output: op.protectedOutput !== true,
|
|
8
|
+
output: op.exposeOutput ?? op.protectedOutput !== true,
|
|
9
9
|
}));
|
|
10
10
|
}
|
|
11
11
|
/**
|
package/dist/standalone/spk.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type NativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
2
2
|
import type { StandaloneManifest } from "./manifest.js";
|
|
3
3
|
import type { HelperArtifact } from "./helper.js";
|
|
4
|
+
import { type AuthorizationArtifact } from "./authorization.js";
|
|
4
5
|
export declare const LIFECYCLE_SCRIPTS: readonly ["preinst", "postinst", "preuninst", "postuninst", "preupgrade", "postupgrade", "start-stop-status"];
|
|
5
6
|
export declare class StandaloneBuildError extends Error {
|
|
6
7
|
readonly code: string;
|
|
@@ -13,6 +14,7 @@ export interface AssembleOptions {
|
|
|
13
14
|
cgiPath: string;
|
|
14
15
|
outputPath: string;
|
|
15
16
|
helper?: HelperArtifact;
|
|
17
|
+
authorization?: AuthorizationArtifact;
|
|
16
18
|
daemonControl?: string;
|
|
17
19
|
/** The compiled background worker, required exactly when the manifest declares background. */
|
|
18
20
|
backgroundWorker?: string;
|
package/dist/standalone/spk.js
CHANGED
|
@@ -18,6 +18,7 @@ import { hostScriptName, renderHostAdapterScript } from "./host-adapter.js";
|
|
|
18
18
|
import { copyDaemonPayload } from "./daemon.js";
|
|
19
19
|
import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit } from "./background.js";
|
|
20
20
|
import { renderStandaloneResource } from "./resource.js";
|
|
21
|
+
import { authorizationPlan } from "./authorization.js";
|
|
21
22
|
import { renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps } from "./shared-folder.js";
|
|
22
23
|
const FILE_MODE = 0o644;
|
|
23
24
|
const EXEC_MODE = 0o755;
|
|
@@ -47,6 +48,8 @@ export async function assembleStandaloneSpk(options) {
|
|
|
47
48
|
const { manifest } = options;
|
|
48
49
|
const staging = mkdtempSync(join(tmpdir(), "syno-standalone-spk-"));
|
|
49
50
|
try {
|
|
51
|
+
if (manifest.privilege === "root" && !options.authorization)
|
|
52
|
+
throw new StandaloneBuildError("AUTHORIZATION_PIN_REQUIRED", "root package requires pinned authorization bootstrap");
|
|
50
53
|
const uiRoot = join(staging, "package", "ui");
|
|
51
54
|
const appDir = join(uiRoot, "app");
|
|
52
55
|
copyUiTree(manifest.ui.dir, appDir);
|
|
@@ -100,6 +103,15 @@ export async function assembleStandaloneSpk(options) {
|
|
|
100
103
|
copyStandaloneNotices(manifest.root, manifest.ui.dir, join(staging, 'package'), manifest.backend.kind === 'go' || Boolean(manifest.daemon), Boolean(options.helper), manifest.integration?.identity === 'better-auth', manifest.backend.kind === 'go' ? manifest.backend.dir : undefined);
|
|
101
104
|
if (manifest.daemon)
|
|
102
105
|
copyFileSync(new URL('../../templates/_shared/LICENSES/TEMPLATE.txt', import.meta.url), join(staging, 'package', 'LICENSES', 'DAEMON-LICENSE.txt'));
|
|
106
|
+
if (manifest.privilege === "root") {
|
|
107
|
+
if (!options.authorization || !/^[a-f0-9]{64}$/.test(options.authorization.bootstrapSha256))
|
|
108
|
+
throw new StandaloneBuildError("AUTHORIZATION_PIN_REQUIRED", "root package requires pinned authorization bootstrap");
|
|
109
|
+
const authDir = join(staging, "package", "authorization");
|
|
110
|
+
mkdirSync(authDir);
|
|
111
|
+
copyFileSync(options.authorization.binary, join(authDir, "bootstrap"));
|
|
112
|
+
chmodSync(join(authDir, "bootstrap"), EXEC_MODE);
|
|
113
|
+
writeFile(join(authDir, "manifest.json"), JSON.stringify(authorizationPlan(options.authorization, manifest)));
|
|
114
|
+
}
|
|
103
115
|
const packageTgz = join(staging, "package.tgz");
|
|
104
116
|
await createReproducibleTar(join(staging, "package"), packageTgz, { gzip: true });
|
|
105
117
|
rmSync(join(staging, "package"), { recursive: true, force: true });
|
|
@@ -157,6 +169,8 @@ function renderInfo(manifest, arch, checksum) {
|
|
|
157
169
|
["startable", "yes"],
|
|
158
170
|
["checksum", checksum],
|
|
159
171
|
];
|
|
172
|
+
if (manifest.privilege === "root")
|
|
173
|
+
fields.push(["sdk_root_authorization", "2"]);
|
|
160
174
|
if (hasHelper(manifest) || manifest.daemon)
|
|
161
175
|
fields.push(["precheckstartstop", "yes"]);
|
|
162
176
|
if (manifest.integration?.identity === "better-auth")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// CLI-owned root bootstrap: accepts only compiled fixed operations. The native
|
|
2
|
+
// task copies and pins this executable before it can run with root authority.
|
|
3
|
+
package main
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"crypto/sha256"
|
|
7
|
+
"encoding/base64"
|
|
8
|
+
"encoding/hex"
|
|
9
|
+
"encoding/json"
|
|
10
|
+
"errors"
|
|
11
|
+
"fmt"
|
|
12
|
+
"io"
|
|
13
|
+
"os"
|
|
14
|
+
"os/exec"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
var inventoryBase64 string
|
|
18
|
+
|
|
19
|
+
type inventory struct {
|
|
20
|
+
Schema int `json:"schema"`
|
|
21
|
+
Package string `json:"package"`
|
|
22
|
+
Version string `json:"version"`
|
|
23
|
+
Operations []struct {
|
|
24
|
+
Name string `json:"name"`
|
|
25
|
+
Body string `json:"body"`
|
|
26
|
+
} `json:"operations"`
|
|
27
|
+
Files []struct {
|
|
28
|
+
Path string `json:"path"`
|
|
29
|
+
Size int64 `json:"size"`
|
|
30
|
+
Hash string `json:"sha256"`
|
|
31
|
+
} `json:"files"`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func run(args []string) error {
|
|
35
|
+
if os.Geteuid() != 0 || len(args) != 1 {
|
|
36
|
+
return errors.New("root and one fixed operation required")
|
|
37
|
+
}
|
|
38
|
+
var i inventory
|
|
39
|
+
raw, decode := base64.StdEncoding.DecodeString(inventoryBase64)
|
|
40
|
+
if decode != nil || json.Unmarshal(raw, &i) != nil || i.Schema != 2 || len(i.Operations) == 0 {
|
|
41
|
+
return errors.New("invalid compiled inventory")
|
|
42
|
+
}
|
|
43
|
+
var body string
|
|
44
|
+
for _, op := range i.Operations {
|
|
45
|
+
if op.Name == args[0] {
|
|
46
|
+
body = op.Body
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if body == "" {
|
|
50
|
+
return errors.New("unknown operation")
|
|
51
|
+
}
|
|
52
|
+
// Disable/teardown/repair retain an escape path when UI payload is corrupt;
|
|
53
|
+
// they still execute only compiled, hash-pinned cleanup code.
|
|
54
|
+
cleanup := args[0] == "helper_disable" || args[0] == "helper_teardown" || args[0] == "helper_repair" || args[0] == "helper_purge"
|
|
55
|
+
if !cleanup {
|
|
56
|
+
if e := verifyFiles("/var/packages/"+i.Package+"/target", raw); e != nil {
|
|
57
|
+
return e
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
cmd := exec.Command("/bin/sh", "-eu", "-c", body)
|
|
61
|
+
cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin"}
|
|
62
|
+
cmd.Stdout = os.Stdout
|
|
63
|
+
cmd.Stderr = os.Stderr
|
|
64
|
+
return cmd.Run()
|
|
65
|
+
}
|
|
66
|
+
func verifyFiles(dir string, raw []byte) error {
|
|
67
|
+
var i inventory
|
|
68
|
+
if json.Unmarshal(raw, &i) != nil || i.Schema != 2 {
|
|
69
|
+
return errors.New("invalid inventory")
|
|
70
|
+
}
|
|
71
|
+
root, e := os.OpenRoot(dir)
|
|
72
|
+
if e != nil {
|
|
73
|
+
return e
|
|
74
|
+
}
|
|
75
|
+
defer root.Close()
|
|
76
|
+
for _, p := range i.Files {
|
|
77
|
+
if p.Size < 0 || p.Size > 64<<20 {
|
|
78
|
+
return errors.New("invalid size")
|
|
79
|
+
}
|
|
80
|
+
f, e := root.Open(p.Path)
|
|
81
|
+
if e != nil {
|
|
82
|
+
return e
|
|
83
|
+
}
|
|
84
|
+
st, e := f.Stat()
|
|
85
|
+
if e != nil || !st.Mode().IsRegular() || st.Size() != p.Size {
|
|
86
|
+
f.Close()
|
|
87
|
+
return errors.New("untrusted payload")
|
|
88
|
+
}
|
|
89
|
+
h := sha256.New()
|
|
90
|
+
_, e = io.Copy(h, io.LimitReader(f, p.Size+1))
|
|
91
|
+
f.Close()
|
|
92
|
+
if e != nil || hex.EncodeToString(h.Sum(nil)) != p.Hash {
|
|
93
|
+
return errors.New("untrusted payload")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return nil
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func main() {
|
|
100
|
+
if e := run(os.Args[1:]); e != nil {
|
|
101
|
+
fmt.Fprintln(os.Stderr, "authorization bootstrap refused:", e)
|
|
102
|
+
os.Exit(1)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/sha256"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestInventoryRejectsReplacementAndSymlinkEscape(t *testing.T) {
|
|
12
|
+
dir := t.TempDir()
|
|
13
|
+
body := []byte("approved bytes")
|
|
14
|
+
os.WriteFile(filepath.Join(dir, "file"), body, 0600)
|
|
15
|
+
raw := fmt.Sprintf(`{"schema":2,"files":[{"path":"file","size":%d,"sha256":"%x"}]}`, len(body), sha256.Sum256(body))
|
|
16
|
+
if e := verifyFiles(dir, []byte(raw)); e != nil {
|
|
17
|
+
t.Fatal(e)
|
|
18
|
+
}
|
|
19
|
+
os.WriteFile(filepath.Join(dir, "file"), []byte("altered bytes!"), 0600)
|
|
20
|
+
if e := verifyFiles(dir, []byte(raw)); e == nil {
|
|
21
|
+
t.Fatal("accepted replacement")
|
|
22
|
+
}
|
|
23
|
+
os.Remove(filepath.Join(dir, "file"))
|
|
24
|
+
outside := filepath.Join(t.TempDir(), "file")
|
|
25
|
+
os.WriteFile(outside, body, 0600)
|
|
26
|
+
os.Symlink(outside, filepath.Join(dir, "file"))
|
|
27
|
+
if e := verifyFiles(dir, []byte(raw)); e == nil {
|
|
28
|
+
t.Fatal("accepted external symlink")
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
func TestUnknownOperationNeverRuns(t *testing.T) {
|
|
32
|
+
if e := run([]string{"arbitrary"}); e == nil {
|
|
33
|
+
t.Fatal("accepted unknown operation")
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -39,7 +39,7 @@ greeting in the package's data folder.
|
|
|
39
39
|
| `ui/app.js`, `ui/app.css` | The app inside the DSM window | Yes — your UI |
|
|
40
40
|
| `ui/…` | Any other assets; served from `/webman/3rdparty/__PACKAGE__/app/` | Yes |
|
|
41
41
|
| `i18n/messages/<locale>.json` | UI text in nine languages | Yes |
|
|
42
|
-
| `i18n/glossary.json` |
|
|
42
|
+
| `i18n/glossary.json` | DSM's own UI terms, and which messages must use them | Add terms |
|
|
43
43
|
| `backend/internal/app/` | Your API: routes and handlers | Yes — your backend |
|
|
44
44
|
| `backend/internal/api/` | The CGI front door: guards, auth order, route policies | Rarely |
|
|
45
45
|
| `backend/internal/dsm/` | DSM session check and administrators lookup | Rarely |
|
|
@@ -411,3 +411,27 @@ drive the same flow from its own UI.
|
|
|
411
411
|
| Target CPUs | x86_64 and aarch64 DSM models (one SPK each) |
|
|
412
412
|
| DSM | 7.1 or later declared (`os_min_ver`, change it with `init --min-dsm` or `build-spk --min-dsm`). Installed and tested on DSM 7.4 (x86_64). aarch64 SPKs are built the same way but have not been run on a NAS. |
|
|
413
413
|
| Not supported | 32-bit ARM, DSM 6, running anything as root without an administrator's approval |
|
|
414
|
+
|
|
415
|
+
### Shared root authorization (explicit opt-in)
|
|
416
|
+
|
|
417
|
+
`init -t native-go --privilege root` adds `authorization.status`,
|
|
418
|
+
`authorization.request` and `authorization.revoke` to the same package CGI URL.
|
|
419
|
+
The Vue shell includes a centered authorization gate. Request/revoke return fixed
|
|
420
|
+
plans; the host completes DSM PasswordConfirm/scheduler operations and reads status
|
|
421
|
+
back before showing success. No password is sent to the package CGI.
|
|
422
|
+
|
|
423
|
+
Each root SPK contains a SHA-256-pinned `authorization/bootstrap` and a v2 build
|
|
424
|
+
inventory. The disabled native task copies bootstrap into a root-private directory
|
|
425
|
+
and verifies the copied bytes before running a compiled fixed operation. The
|
|
426
|
+
bootstrap verifies UI build hashes; helper control scripts retain their helper pins.
|
|
427
|
+
Changing root code, helper or UI requires rebuilding and fresh authorization.
|
|
428
|
+
`inspect --spk` checks bootstrap/CGI/window/inventory bindings; missing pins fail.
|
|
429
|
+
The default `privilege: none` has no bootstrap or authorization routes, and all
|
|
430
|
+
lifecycle scripts and CGI continue to use the package account.
|
|
431
|
+
|
|
432
|
+
Before upgrading a helper integration, revoke/disable its current grant and verify
|
|
433
|
+
cleanup. The existing pre-upgrade guard refuses a live helper; a new build never
|
|
434
|
+
activates root automatically. Plain UI keeps its per-operation result controls.
|
|
435
|
+
A non-Go prebuilt CGI must implement its own adapter and cannot use this generated
|
|
436
|
+
root authorization contract. Native-layout products such as Reclaim retain their
|
|
437
|
+
own privileged runtime until they explicitly migrate their adapters.
|
|
@@ -22,9 +22,10 @@ import (
|
|
|
22
22
|
|
|
23
23
|
// Set by `synopkgland build-spk` through -ldflags -X; never read from a request.
|
|
24
24
|
var (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
rootAuthorization = ""
|
|
26
|
+
packageID = "dev"
|
|
27
|
+
version = "0.0.0"
|
|
28
|
+
helperInventory = ""
|
|
28
29
|
// "package" when the project has an integration but no privileged helper.
|
|
29
30
|
integrationMode = ""
|
|
30
31
|
// "session" when the project opted into the interactive File Station adapter.
|
|
@@ -39,9 +40,10 @@ func main() {
|
|
|
39
40
|
os.Exit(2)
|
|
40
41
|
}
|
|
41
42
|
cfg := app.Config{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
RootAuthorization: rootAuthorization,
|
|
44
|
+
PackageID: packageID,
|
|
45
|
+
Version: version,
|
|
46
|
+
DataDir: "/var/packages/" + packageID + "/var",
|
|
45
47
|
|
|
46
48
|
PackageIntegration: integrationMode == "package",
|
|
47
49
|
}
|
|
@@ -20,9 +20,10 @@ import (
|
|
|
20
20
|
|
|
21
21
|
// Set by `synopkgland build-spk` through -ldflags -X; never read from a request.
|
|
22
22
|
var (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
rootAuthorization = ""
|
|
24
|
+
packageID = "dev"
|
|
25
|
+
version = "0.0.0"
|
|
26
|
+
helperInventory = ""
|
|
26
27
|
// "session" when the project opted into the interactive File Station adapter.
|
|
27
28
|
fileStation = ""
|
|
28
29
|
)
|
|
@@ -35,9 +36,10 @@ func main() {
|
|
|
35
36
|
os.Exit(2)
|
|
36
37
|
}
|
|
37
38
|
cfg := app.Config{
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
RootAuthorization: rootAuthorization,
|
|
40
|
+
PackageID: packageID,
|
|
41
|
+
Version: version,
|
|
42
|
+
DataDir: "/var/packages/" + packageID + "/var",
|
|
41
43
|
}
|
|
42
44
|
if helperInventory != "" {
|
|
43
45
|
if json.Unmarshal([]byte(helperInventory), &cfg.HelperInventory) != nil || cfg.HelperInventory.Package != packageID || cfg.HelperInventory.Version != version {
|
|
@@ -16,7 +16,7 @@ func main() {
|
|
|
16
16
|
if len(os.Args) != 2 || json.Unmarshal([]byte(helperInventory), &i) != nil {
|
|
17
17
|
os.Exit(2)
|
|
18
18
|
}
|
|
19
|
-
if os.Args[1] == "check-stopped" || os.Args[1] == "diagnose" || os.Args[1] == "diagnose-startup" {
|
|
19
|
+
if os.Args[1] == "revoke" || os.Args[1] == "check-stopped" || os.Args[1] == "diagnose" || os.Args[1] == "diagnose-startup" {
|
|
20
20
|
c, err := helper.LocalClient(i)
|
|
21
21
|
if err != nil {
|
|
22
22
|
os.Exit(1)
|
|
@@ -34,11 +34,28 @@ func main() {
|
|
|
34
34
|
json.NewEncoder(os.Stdout).Encode(c.Status(context.Background()))
|
|
35
35
|
return
|
|
36
36
|
}
|
|
37
|
+
if os.Args[1] == "revoke" {
|
|
38
|
+
if c.Revoke(context.Background()) != nil {
|
|
39
|
+
os.Exit(1)
|
|
40
|
+
}
|
|
41
|
+
return
|
|
42
|
+
}
|
|
37
43
|
if c.CheckStopped(context.Background()) != nil {
|
|
38
44
|
os.Exit(1)
|
|
39
45
|
}
|
|
40
46
|
return
|
|
41
47
|
}
|
|
48
|
+
if os.Args[1] == "purge" {
|
|
49
|
+
if os.Geteuid() != 0 {
|
|
50
|
+
os.Exit(1)
|
|
51
|
+
}
|
|
52
|
+
c, err := helper.LocalClient(i)
|
|
53
|
+
if err != nil || c.Purge() != nil {
|
|
54
|
+
os.Exit(1)
|
|
55
|
+
}
|
|
56
|
+
fmt.Println(`{"success":true}`)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
42
59
|
if err := helper.RunLinux(i, os.Args[1]); err != nil {
|
|
43
60
|
fmt.Fprintln(os.Stderr, "helper lifecycle refused")
|
|
44
61
|
os.Exit(1)
|
|
@@ -27,6 +27,8 @@ import (
|
|
|
27
27
|
|
|
28
28
|
// Config is fixed at build time and startup, never taken from a request.
|
|
29
29
|
type Config struct {
|
|
30
|
+
RootAuthorization string
|
|
31
|
+
|
|
30
32
|
// SessionFiles is the interactive File Station adapter; nil keeps legacy refusal.
|
|
31
33
|
SessionFiles *filestation.Service
|
|
32
34
|
PackageID string
|
|
@@ -45,13 +47,14 @@ type Config struct {
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
func Routes(cfg Config) []api.Route {
|
|
48
|
-
routes :=
|
|
50
|
+
routes := cfg.authorizationRoutes()
|
|
51
|
+
routes = append(routes, []api.Route{
|
|
49
52
|
api.User("integration.status", cfg.integrationStatus),
|
|
50
53
|
api.BoundedRead(cfg.binaryRead),
|
|
51
54
|
api.User("app.status", cfg.status),
|
|
52
55
|
api.User("greeting.get", cfg.getGreeting),
|
|
53
56
|
api.Admin("greeting.set", cfg.setGreeting),
|
|
54
|
-
}
|
|
57
|
+
}...)
|
|
55
58
|
routes = append(routes, cfg.SessionFiles.Routes()...)
|
|
56
59
|
routes = append(routes, cfg.backgroundRoutes()...)
|
|
57
60
|
return append(routes, cfg.rootRoutes()...)
|
|
@@ -25,6 +25,8 @@ import (
|
|
|
25
25
|
|
|
26
26
|
// Config is fixed at build time and startup, never taken from a request.
|
|
27
27
|
type Config struct {
|
|
28
|
+
RootAuthorization string
|
|
29
|
+
|
|
28
30
|
// SessionFiles is the interactive File Station adapter; nil keeps legacy refusal.
|
|
29
31
|
SessionFiles *filestation.Service
|
|
30
32
|
SDKJobs func(context.Context, string, helper.SessionCarriers, json.RawMessage) (json.RawMessage, helper.Code)
|
|
@@ -44,13 +46,14 @@ type Config struct {
|
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
func Routes(cfg Config) []api.Route {
|
|
47
|
-
routes :=
|
|
49
|
+
routes := cfg.authorizationRoutes()
|
|
50
|
+
routes = append(routes, []api.Route{
|
|
48
51
|
api.User("integration.status", cfg.integrationStatus),
|
|
49
52
|
api.BoundedRead(cfg.binaryRead),
|
|
50
53
|
api.User("app.status", cfg.status),
|
|
51
54
|
api.User("greeting.get", cfg.getGreeting),
|
|
52
55
|
api.Admin("greeting.set", cfg.setGreeting),
|
|
53
|
-
}
|
|
56
|
+
}...)
|
|
54
57
|
routes = append(routes, cfg.sdkJobRoutes()...)
|
|
55
58
|
routes = append(routes, cfg.SessionFiles.Routes()...)
|
|
56
59
|
routes = append(routes, cfg.backgroundRoutes()...)
|
|
@@ -16,7 +16,14 @@ import (
|
|
|
16
16
|
// starts/signals a process. Private completion contents stay private to root.
|
|
17
17
|
func (c Client) staleManagementStopped() bool {
|
|
18
18
|
r, err := readRecord(c.StateDir, c.Owner)
|
|
19
|
-
if err != nil || !
|
|
19
|
+
if err != nil || !r.Enabled {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
return c.managementStopped(r)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func (c Client) managementStopped(r Record) bool {
|
|
26
|
+
if !c.Expected.Valid() || r.Inventory.Package != c.Expected.Package || r.Schema != 2 || r.Residual || r.Lifecycle == nil || r.Lifecycle.Phase != "stopped" || r.Lifecycle.Completion == "" || r.Lifecycle.Lease == nil {
|
|
20
27
|
return false
|
|
21
28
|
}
|
|
22
29
|
if !c.canonicalApproval(r) || !c.completionPublished(r) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
package helper
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"io/fs"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"regexp"
|
|
8
|
+
"syscall"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
var terminalFile = regexp.MustCompile(`^(completion-[a-f0-9]{64}\.json|lifetime-[a-f0-9]{64}\.lock)$`)
|
|
12
|
+
var cachedPayload = regexp.MustCompile(`^payload-[a-f0-9]{64}$`)
|
|
13
|
+
|
|
14
|
+
// Purge is a separate fixed, administrator-approved operation. Never expose
|
|
15
|
+
// deletion through the package socket. Unknown files (including data/grants)
|
|
16
|
+
// are retained and cause refusal; only terminal lifecycle metadata is removed.
|
|
17
|
+
func (c Client) Purge() error {
|
|
18
|
+
r, err := readRecord(c.StateDir, c.Owner)
|
|
19
|
+
if err != nil || r.Enabled || r.Inventory != c.Expected || !c.managementStopped(r) {
|
|
20
|
+
return errTrust
|
|
21
|
+
}
|
|
22
|
+
locks := []*os.File{}
|
|
23
|
+
defer func() {
|
|
24
|
+
for _, f := range locks {
|
|
25
|
+
f.Close()
|
|
26
|
+
}
|
|
27
|
+
}()
|
|
28
|
+
lock := func(name string) error {
|
|
29
|
+
f, err := os.OpenFile(filepath.Join(c.StateDir, name), os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600)
|
|
30
|
+
if err != nil {
|
|
31
|
+
return err
|
|
32
|
+
}
|
|
33
|
+
locks = append(locks, f)
|
|
34
|
+
if err := protected(f.Name(), c.Owner, false); err != nil {
|
|
35
|
+
return err
|
|
36
|
+
}
|
|
37
|
+
if syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil {
|
|
38
|
+
return errBusy
|
|
39
|
+
}
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
42
|
+
for _, name := range []string{"control.lock", "policy.lock"} {
|
|
43
|
+
if err := lock(name); err != nil {
|
|
44
|
+
return err
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Recheck after serialization against enable and the terminal publisher.
|
|
48
|
+
r, err = readRecord(c.StateDir, c.Owner)
|
|
49
|
+
if err != nil || r.Enabled || r.Inventory != c.Expected || !c.managementStopped(r) {
|
|
50
|
+
return errTrust
|
|
51
|
+
}
|
|
52
|
+
entries, err := os.ReadDir(c.StateDir)
|
|
53
|
+
if err != nil || len(entries) > 512 {
|
|
54
|
+
return errTrust
|
|
55
|
+
}
|
|
56
|
+
for _, entry := range entries {
|
|
57
|
+
name := entry.Name()
|
|
58
|
+
mode := fs.FileMode(0600)
|
|
59
|
+
switch {
|
|
60
|
+
case name == "approval.json":
|
|
61
|
+
mode = 0644
|
|
62
|
+
case cachedPayload.MatchString(name):
|
|
63
|
+
mode = 0700
|
|
64
|
+
case terminalFile.MatchString(name), name == "control.lock", name == "policy.lock", name == "observer-diagnostic.json":
|
|
65
|
+
default:
|
|
66
|
+
return errTrust
|
|
67
|
+
}
|
|
68
|
+
st, err := os.Lstat(filepath.Join(c.StateDir, name))
|
|
69
|
+
if err != nil {
|
|
70
|
+
return err
|
|
71
|
+
}
|
|
72
|
+
stat, ok := st.Sys().(*syscall.Stat_t)
|
|
73
|
+
if !ok || !st.Mode().IsRegular() || st.Mode().Perm() != mode || stat.Uid != c.Owner || stat.Nlink != 1 {
|
|
74
|
+
return errTrust
|
|
75
|
+
}
|
|
76
|
+
if filepath.Ext(name) == ".lock" && name != "control.lock" && name != "policy.lock" {
|
|
77
|
+
if err := lock(name); err != nil {
|
|
78
|
+
return err
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// No recursive removal. A socket/process/foreign file prevents all deletion.
|
|
83
|
+
if err := protected(c.RunDir, c.Owner, true); err != nil {
|
|
84
|
+
return err
|
|
85
|
+
}
|
|
86
|
+
runtime, err := os.ReadDir(c.RunDir)
|
|
87
|
+
if err != nil || len(runtime) != 0 {
|
|
88
|
+
return errTrust
|
|
89
|
+
}
|
|
90
|
+
// If an I/O failure interrupts cleanup, retain a durable disabled gate and
|
|
91
|
+
// cleanup-required diagnostic. Remove that record only after other files.
|
|
92
|
+
r.Residual = true
|
|
93
|
+
if err := writeProtected(c.StateDir, "approval.json", r); err != nil {
|
|
94
|
+
return err
|
|
95
|
+
}
|
|
96
|
+
for _, entry := range entries {
|
|
97
|
+
if entry.Name() == "approval.json" {
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if err := os.Remove(filepath.Join(c.StateDir, entry.Name())); err != nil {
|
|
101
|
+
return err
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if err := os.Remove(c.RunDir); err != nil {
|
|
105
|
+
return err
|
|
106
|
+
}
|
|
107
|
+
if err := os.Remove(filepath.Join(c.StateDir, "approval.json")); err != nil {
|
|
108
|
+
return err
|
|
109
|
+
}
|
|
110
|
+
if err := os.Remove(c.StateDir); err != nil {
|
|
111
|
+
_ = writeProtected(c.StateDir, "approval.json", r)
|
|
112
|
+
return err
|
|
113
|
+
}
|
|
114
|
+
return nil
|
|
115
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
package helper
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func terminalPurgeFixture(t *testing.T) Client {
|
|
10
|
+
c, f := controlFixture(t)
|
|
11
|
+
l := testLifecycle("fenced")
|
|
12
|
+
if err := createLifetime(c.Client, l); err != nil {
|
|
13
|
+
t.Fatal(err)
|
|
14
|
+
}
|
|
15
|
+
record(t, c.Client, Record{Schema: 2, Inventory: c.Expected, Enabled: false, Residual: true, Lifecycle: l})
|
|
16
|
+
if err := cleanRuntime(c.Client, true); err != nil {
|
|
17
|
+
t.Fatal(err)
|
|
18
|
+
}
|
|
19
|
+
if err := completeRecord(c.Client, l.Epoch, f.current); err != nil {
|
|
20
|
+
t.Fatal(err)
|
|
21
|
+
}
|
|
22
|
+
return c.Client
|
|
23
|
+
}
|
|
24
|
+
func TestPurgeRemovesOnlyVerifiedDisabledTerminalState(t *testing.T) {
|
|
25
|
+
c := terminalPurgeFixture(t)
|
|
26
|
+
if err := c.Purge(); err != nil {
|
|
27
|
+
t.Fatal(err)
|
|
28
|
+
}
|
|
29
|
+
for _, path := range []string{c.StateDir, c.RunDir} {
|
|
30
|
+
if _, err := os.Lstat(path); !os.IsNotExist(err) {
|
|
31
|
+
t.Fatalf("residue %s: %v", path, err)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
func TestPurgeRefusesLiveStateUnknownEntriesAndLinks(t *testing.T) {
|
|
36
|
+
for _, which := range []string{"live", "unknown", "link", "proof"} {
|
|
37
|
+
t.Run(which, func(t *testing.T) {
|
|
38
|
+
c := terminalPurgeFixture(t)
|
|
39
|
+
switch which {
|
|
40
|
+
case "live":
|
|
41
|
+
r, _ := readRecord(c.StateDir, c.Owner)
|
|
42
|
+
r.Enabled = true
|
|
43
|
+
record(t, c, r)
|
|
44
|
+
case "unknown":
|
|
45
|
+
os.WriteFile(filepath.Join(c.StateDir, "user-data"), []byte("retain"), 0600)
|
|
46
|
+
case "link":
|
|
47
|
+
os.Symlink("/etc/passwd", filepath.Join(c.StateDir, "payload-"+c.Expected.PayloadDigest))
|
|
48
|
+
case "proof":
|
|
49
|
+
os.Remove(filepath.Join(c.StateDir, "completion-"+testLifecycle("stopped").Epoch+".json"))
|
|
50
|
+
}
|
|
51
|
+
if c.Purge() == nil {
|
|
52
|
+
t.Fatal("unsafe purge accepted")
|
|
53
|
+
}
|
|
54
|
+
if _, err := os.Stat(filepath.Join(c.StateDir, "approval.json")); err != nil {
|
|
55
|
+
t.Fatal("refusal deleted approval")
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|