@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.
Files changed (114) hide show
  1. package/README.md +1 -1
  2. package/dist/authorization-sdk.d.ts +5 -0
  3. package/dist/authorization-sdk.js +5 -0
  4. package/dist/build-spk/delete-app-data.d.ts +1 -1
  5. package/dist/build-spk/uninstall-wizard.d.ts +1 -1
  6. package/dist/build-spk/uninstall-wizard.js +1 -1
  7. package/dist/cli.js +2 -2
  8. package/dist/error-registry.js +3 -3
  9. package/dist/host/dsm-port-profile.d.ts +1 -1
  10. package/dist/host/dsm-port-profile.js +1 -1
  11. package/dist/init.js +25 -4
  12. package/dist/standalone/authorization-window.d.ts +2 -0
  13. package/dist/standalone/authorization-window.js +28 -0
  14. package/dist/standalone/authorization.d.ts +48 -0
  15. package/dist/standalone/authorization.js +116 -0
  16. package/dist/standalone/build.js +18 -6
  17. package/dist/standalone/dsm-window.js +8 -1
  18. package/dist/standalone/helper.js +1 -1
  19. package/dist/standalone/inspect-helper.js +12 -2
  20. package/dist/standalone/inspect-spk.js +11 -0
  21. package/dist/standalone/lifecycle-helper.d.ts +1 -1
  22. package/dist/standalone/lifecycle-helper.js +2 -2
  23. package/dist/standalone/lifecycle.d.ts +1 -1
  24. package/dist/standalone/lifecycle.js +3 -3
  25. package/dist/standalone/root-ops.d.ts +2 -0
  26. package/dist/standalone/root-ops.js +10 -0
  27. package/dist/standalone/root-window.js +1 -1
  28. package/dist/standalone/spk.d.ts +2 -0
  29. package/dist/standalone/spk.js +14 -0
  30. package/dist/templates/_authorization/main.go +104 -0
  31. package/dist/templates/_authorization/main_test.go +35 -0
  32. package/dist/templates/native-go/README.md +25 -1
  33. package/dist/templates/native-go/backend/cmd/app/main.go +8 -6
  34. package/dist/templates/native-go/backend/cmd/app/sdk_main.go +8 -6
  35. package/dist/templates/native-go/backend/cmd/helper/main.go +18 -1
  36. package/dist/templates/native-go/backend/internal/app/authorization_none.go +5 -0
  37. package/dist/templates/native-go/backend/internal/app/routes.go +5 -2
  38. package/dist/templates/native-go/backend/internal/app/sdk_routes.go +5 -2
  39. package/dist/templates/native-go/backend/internal/helper/management.go +8 -1
  40. package/dist/templates/native-go/backend/internal/helper/purge.go +115 -0
  41. package/dist/templates/native-go/backend/internal/helper/purge_test.go +59 -0
  42. package/dist/templates/native-go/backend/internal/helper/revoke.go +109 -0
  43. package/dist/templates/native-go/backend/internal/helper/revoke_test.go +121 -0
  44. package/dist/templates/native-go/backend/internal/helper/sdk_server_linux.go +11 -2
  45. package/dist/templates/native-go/backend/internal/helper/server_linux.go +11 -2
  46. package/dist/templates/native-go/dsm-package.yaml +1 -1
  47. package/dist/templates/native-go-authorization/backend/internal/app/authorization.go +298 -0
  48. package/dist/templates/native-go-authorization/backend/internal/app/authorization_test.go +96 -0
  49. package/dist/templates/native-go-authorization/frontend/src/authorization/AuthorizationGate.vue +89 -0
  50. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/de.json +36 -0
  51. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/en.json +36 -0
  52. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/es.json +36 -0
  53. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/fr.json +36 -0
  54. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/index.ts +15 -0
  55. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/it.json +36 -0
  56. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/ja.json +36 -0
  57. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/ko.json +36 -0
  58. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/zh-CN.json +36 -0
  59. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/zh-TW.json +36 -0
  60. package/dist/templates/native-go-authorization/frontend/test/authorization-preview.html +14 -0
  61. package/dist/templates/native-go-authorization/frontend/test/authorization.test.ts +59 -0
  62. package/dist/templates/native-go-vue/frontend/src/context.ts +3 -0
  63. package/dist/types.d.ts +1 -1
  64. package/package.json +5 -2
  65. package/skills/reference/create.md +2 -2
  66. package/templates/_authorization/main.go +104 -0
  67. package/templates/_authorization/main_test.go +35 -0
  68. package/templates/native-go/README.md +25 -1
  69. package/templates/native-go/backend/cmd/app/main.go +8 -6
  70. package/templates/native-go/backend/cmd/app/sdk_main.go +8 -6
  71. package/templates/native-go/backend/cmd/helper/main.go +18 -1
  72. package/templates/native-go/backend/internal/app/authorization_none.go +5 -0
  73. package/templates/native-go/backend/internal/app/routes.go +5 -2
  74. package/templates/native-go/backend/internal/app/sdk_routes.go +5 -2
  75. package/templates/native-go/backend/internal/helper/management.go +8 -1
  76. package/templates/native-go/backend/internal/helper/purge.go +115 -0
  77. package/templates/native-go/backend/internal/helper/purge_test.go +59 -0
  78. package/templates/native-go/backend/internal/helper/revoke.go +109 -0
  79. package/templates/native-go/backend/internal/helper/revoke_test.go +121 -0
  80. package/templates/native-go/backend/internal/helper/sdk_server_linux.go +11 -2
  81. package/templates/native-go/backend/internal/helper/server_linux.go +11 -2
  82. package/templates/native-go/dsm-package.yaml +1 -1
  83. package/templates/native-go-authorization/backend/internal/app/authorization.go +298 -0
  84. package/templates/native-go-authorization/backend/internal/app/authorization_test.go +96 -0
  85. package/templates/native-go-authorization/frontend/src/authorization/AuthorizationGate.vue +89 -0
  86. package/templates/native-go-authorization/frontend/src/authorization/i18n/de.json +36 -0
  87. package/templates/native-go-authorization/frontend/src/authorization/i18n/en.json +36 -0
  88. package/templates/native-go-authorization/frontend/src/authorization/i18n/es.json +36 -0
  89. package/templates/native-go-authorization/frontend/src/authorization/i18n/fr.json +36 -0
  90. package/templates/native-go-authorization/frontend/src/authorization/i18n/index.ts +15 -0
  91. package/templates/native-go-authorization/frontend/src/authorization/i18n/it.json +36 -0
  92. package/templates/native-go-authorization/frontend/src/authorization/i18n/ja.json +36 -0
  93. package/templates/native-go-authorization/frontend/src/authorization/i18n/ko.json +36 -0
  94. package/templates/native-go-authorization/frontend/src/authorization/i18n/zh-CN.json +36 -0
  95. package/templates/native-go-authorization/frontend/src/authorization/i18n/zh-TW.json +36 -0
  96. package/templates/native-go-authorization/frontend/test/authorization-preview.html +14 -0
  97. package/templates/native-go-authorization/frontend/test/authorization.test.ts +59 -0
  98. package/templates/native-go-vue/frontend/src/context.ts +3 -0
  99. package/dist/templates/hello-web/compose.yaml +0 -24
  100. package/dist/templates/hello-web/html/app.js +0 -1
  101. package/dist/templates/hello-web/html/index.html +0 -13
  102. package/dist/templates/hello-web/synology-app.yaml +0 -16
  103. package/dist/templates/hello-web-page/compose.yaml +0 -24
  104. package/dist/templates/hello-web-page/html/app.js +0 -1
  105. package/dist/templates/hello-web-page/html/index.html +0 -13
  106. package/dist/templates/hello-web-page/synology-app.yaml +0 -16
  107. package/templates/hello-web/compose.yaml +0 -24
  108. package/templates/hello-web/html/app.js +0 -1
  109. package/templates/hello-web/html/index.html +0 -13
  110. package/templates/hello-web/synology-app.yaml +0 -16
  111. package/templates/hello-web-page/compose.yaml +0 -24
  112. package/templates/hello-web-page/html/app.js +0 -1
  113. package/templates/hello-web-page/html/index.html +0 -13
  114. package/templates/hello-web-page/synology-app.yaml +0 -16
@@ -0,0 +1,109 @@
1
+ package helper
2
+
3
+ import (
4
+ "context"
5
+ "crypto/rand"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "errors"
9
+ "io"
10
+ "io/fs"
11
+ "net"
12
+ "os"
13
+ "path/filepath"
14
+ "syscall"
15
+ "time"
16
+ )
17
+
18
+ // Called by the already approved root child after SO_PEERCRED authorization.
19
+ // This endpoint can only withdraw the exact installed build's authority. It
20
+ // cannot start a process, select a path, signal a PID, or replace an inventory.
21
+ func packageRevoke(c Client, req Request) error {
22
+ if req.Op != "revoke" || req.Inventory != c.Expected || !c.Expected.Valid() || !digestPattern.MatchString(req.Nonce) {
23
+ return errTrust
24
+ }
25
+ if err := protected(c.StateDir, c.Owner, true); err != nil {
26
+ return err
27
+ }
28
+ f, err := os.OpenFile(filepath.Join(c.StateDir, "control.lock"), os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600)
29
+ if err != nil {
30
+ return err
31
+ }
32
+ defer f.Close()
33
+ if err := protected(f.Name(), c.Owner, false); err != nil {
34
+ return err
35
+ }
36
+ if syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil {
37
+ return errBusy
38
+ }
39
+ defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
40
+ return policyUpdate(c, func(r *Record) error {
41
+ if r.Inventory != c.Expected || r.Lifecycle == nil || r.Lifecycle.Phase != "running" || !r.Enabled {
42
+ return errTrust
43
+ }
44
+ r.Enabled = false
45
+ r.Residual = true
46
+ r.Lifecycle.Phase = "fenced"
47
+ r.Lifecycle.Completion = ""
48
+ r.Lifecycle.Reason = "package-revoke"
49
+ return nil
50
+ })
51
+ }
52
+
53
+ // Revoke runs as the package UID. A socket acknowledgement proves only durable
54
+ // withdrawal; success additionally requires the owning supervisor's sealed
55
+ // terminal publication and absent runtime. No missing-socket shortcut.
56
+ func (c Client) Revoke(ctx context.Context) error {
57
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
58
+ defer cancel()
59
+ r, err := readRecord(c.StateDir, c.Owner)
60
+ if errors.Is(err, fs.ErrNotExist) {
61
+ return c.CheckStopped(ctx)
62
+ }
63
+ // The new build may manage the old disabled terminal publication without
64
+ // acquiring its authority or mutating it. This keeps uninstall possible
65
+ // before the user has approved the upgraded build.
66
+ if err == nil && !r.Enabled && c.managementStopped(r) {
67
+ return nil
68
+ }
69
+ if err != nil || r.Inventory != c.Expected {
70
+ return errTrust
71
+ }
72
+ if !r.Enabled {
73
+ if c.managementStopped(r) {
74
+ return nil
75
+ }
76
+ return errTrust
77
+ }
78
+ if r.Lifecycle == nil || r.Lifecycle.Phase != "running" {
79
+ return errTrust
80
+ }
81
+ dialer := net.Dialer{Timeout: time.Second}
82
+ conn, err := dialer.DialContext(ctx, "unix", filepath.Join(c.RunDir, "helper.sock"))
83
+ if err != nil {
84
+ return err
85
+ }
86
+ defer conn.Close()
87
+ if c.Peer == nil || c.Peer(conn.(*net.UnixConn)) != nil {
88
+ return errTrust
89
+ }
90
+ conn.SetDeadline(time.Now().Add(time.Second))
91
+ var nonce [32]byte
92
+ if _, err := rand.Read(nonce[:]); err != nil {
93
+ return err
94
+ }
95
+ req := Request{Inventory: c.Expected, Op: "revoke", Nonce: hex.EncodeToString(nonce[:])}
96
+ if err := json.NewEncoder(conn).Encode(req); err != nil {
97
+ return err
98
+ }
99
+ var res Response
100
+ d := json.NewDecoder(io.LimitReader(conn, 4097))
101
+ d.DisallowUnknownFields()
102
+ if d.Decode(&res) != nil || res.Inventory != c.Expected || res.Nonce != req.Nonce || !res.OK || res.Error != "" {
103
+ return errTrust
104
+ }
105
+ return waitHealthy(ctx, func(context.Context) bool {
106
+ r, err := readRecord(c.StateDir, c.Owner)
107
+ return err == nil && r.Inventory == c.Expected && !r.Enabled && c.managementStopped(r)
108
+ })
109
+ }
@@ -0,0 +1,121 @@
1
+ package helper
2
+
3
+ import (
4
+ "context"
5
+ "net"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "syscall"
10
+ "testing"
11
+ "time"
12
+ )
13
+
14
+ func TestPackageRevokeDurablyDisablesExactInventory(t *testing.T) {
15
+ c, _ := controlFixture(t)
16
+ r := Record{Inventory: c.Expected, Enabled: true, Lifecycle: testLifecycle("running")}
17
+ record(t, c.Client, r)
18
+ if err := packageRevoke(c.Client, Request{Inventory: c.Expected, Op: "revoke", Nonce: strings.Repeat("a", 64)}); err != nil {
19
+ t.Fatal(err)
20
+ }
21
+ got, err := readRecord(c.StateDir, c.Owner)
22
+ if err != nil || got.Enabled || !got.Residual || got.Lifecycle.Phase != "fenced" {
23
+ t.Fatalf("not durably revoked: %+v %v", got, err)
24
+ }
25
+ if c.CheckStopped(context.Background()) == nil {
26
+ t.Fatal("revocation acknowledgement accepted as completion")
27
+ }
28
+ if err := c.Control(context.Background(), "recover"); err == nil {
29
+ t.Fatal("revoked approval recovered")
30
+ }
31
+ }
32
+
33
+ func TestRevokeAcknowledgementCannotCertifyQuiescence(t *testing.T) {
34
+ c, _ := controlFixture(t)
35
+ live(t, c.Client, c.Expected, func(req Request) Response { return Response{Inventory: c.Expected, Nonce: req.Nonce, OK: true} })
36
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
37
+ defer cancel()
38
+ if c.Revoke(ctx) == nil {
39
+ t.Fatal("socket acknowledgement certified live process")
40
+ }
41
+ }
42
+
43
+ func TestRevokeRejectsUntrustedPeerAndWrongNonce(t *testing.T) {
44
+ for _, which := range []string{"peer", "nonce"} {
45
+ t.Run(which, func(t *testing.T) {
46
+ c, _ := controlFixture(t)
47
+ live(t, c.Client, c.Expected, func(req Request) Response { return Response{Inventory: c.Expected, Nonce: "wrong", OK: true} })
48
+ if which == "peer" {
49
+ c.Peer = func(*net.UnixConn) error { return errTrust }
50
+ }
51
+ if c.Revoke(context.Background()) == nil {
52
+ t.Fatal("untrusted response accepted")
53
+ }
54
+ })
55
+ }
56
+ }
57
+
58
+ func TestRevokeNewBuildConsumesOldDisabledTerminalWithoutMutation(t *testing.T) {
59
+ c, f := controlFixture(t)
60
+ l := testLifecycle("fenced")
61
+ if err := createLifetime(c.Client, l); err != nil {
62
+ t.Fatal(err)
63
+ }
64
+ record(t, c.Client, Record{Schema: 2, Inventory: c.Expected, Enabled: false, Residual: true, Lifecycle: l})
65
+ if err := cleanRuntime(c.Client, true); err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ if err := completeRecord(c.Client, l.Epoch, f.current); err != nil {
69
+ t.Fatal(err)
70
+ }
71
+ before, _ := os.ReadFile(filepath.Join(c.StateDir, "approval.json"))
72
+ c.Expected.Version = "1.0.0-0002"
73
+ if err := c.Revoke(context.Background()); err != nil {
74
+ t.Fatal(err)
75
+ }
76
+ after, _ := os.ReadFile(filepath.Join(c.StateDir, "approval.json"))
77
+ if string(before) != string(after) {
78
+ t.Fatal("new build rewrote old publication")
79
+ }
80
+ if err := os.Remove(filepath.Join(c.StateDir, "completion-"+l.Epoch+".json")); err != nil {
81
+ t.Fatal(err)
82
+ }
83
+ if c.Revoke(context.Background()) == nil {
84
+ t.Fatal("missing terminal proof accepted")
85
+ }
86
+ }
87
+
88
+ func TestPackageRevokeRejectsWrongBuildInvalidFrameAndConcurrentEnable(t *testing.T) {
89
+ for _, kind := range []string{"build", "nonce", "operation", "busy"} {
90
+ t.Run(kind, func(t *testing.T) {
91
+ c, _ := controlFixture(t)
92
+ r := Record{Inventory: c.Expected, Enabled: true, Lifecycle: testLifecycle("running")}
93
+ record(t, c.Client, r)
94
+ req := Request{Inventory: c.Expected, Op: "revoke", Nonce: strings.Repeat("a", 64)}
95
+ switch kind {
96
+ case "build":
97
+ req.Inventory.Version = "0.1.0-0002"
98
+ case "nonce":
99
+ req.Nonce = ""
100
+ case "operation":
101
+ req.Op = "enable"
102
+ case "busy":
103
+ f, err := os.OpenFile(filepath.Join(c.StateDir, "control.lock"), os.O_CREATE|os.O_RDWR, 0600)
104
+ if err != nil {
105
+ t.Fatal(err)
106
+ }
107
+ defer f.Close()
108
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
109
+ t.Fatal(err)
110
+ }
111
+ }
112
+ if packageRevoke(c.Client, req) == nil {
113
+ t.Fatal("unsafe revoke accepted")
114
+ }
115
+ got, _ := readRecord(c.StateDir, c.Owner)
116
+ if !got.Enabled || got.Residual || got.Lifecycle.Phase != "running" {
117
+ t.Fatal("rejected request changed approval")
118
+ }
119
+ })
120
+ }
121
+ }
@@ -53,8 +53,8 @@ func serveLinux(c Client) error {
53
53
  if err := os.Chmod(socket, 0660); err != nil {
54
54
  return err
55
55
  }
56
- // Bound concurrency and each frame's size/time. No privileged mutations are
57
- // accepted over this socket; lifecycle control remains fixed admin tasks.
56
+ // Bound concurrency and each frame. The authenticated package UID may only
57
+ // withdraw authority; activation remains an exact approved root task.
58
58
  slots := make(chan struct{}, 8)
59
59
  for {
60
60
  conn, err := l.AcceptUnix()
@@ -105,6 +105,15 @@ func serveLinux(c Client) error {
105
105
  if health.Decode(&req) != nil {
106
106
  return
107
107
  }
108
+ if req.Op == "revoke" {
109
+ err := packageRevoke(c, req)
110
+ res := Response{Inventory: c.Expected, Nonce: req.Nonce, OK: err == nil}
111
+ if err != nil {
112
+ res.Error = "revoke-refused"
113
+ }
114
+ json.NewEncoder(conn).Encode(res)
115
+ return
116
+ }
108
117
  r, err := readRecord(c.StateDir, 0)
109
118
  readiness := "fenced"
110
119
  if err == nil && r.Enabled && r.Inventory == c.Expected && r.Lifecycle != nil && r.Lifecycle.Phase == "running" {
@@ -53,8 +53,8 @@ func serveLinux(c Client) error {
53
53
  if err := os.Chmod(socket, 0660); err != nil {
54
54
  return err
55
55
  }
56
- // Bound concurrency and each frame's size/time. No privileged mutations are
57
- // accepted over this socket; lifecycle control remains fixed admin tasks.
56
+ // Bound concurrency and each frame. The authenticated package UID may only
57
+ // withdraw authority; activation remains an exact approved root task.
58
58
  slots := make(chan struct{}, 8)
59
59
  for {
60
60
  conn, err := l.AcceptUnix()
@@ -80,6 +80,15 @@ func serveLinux(c Client) error {
80
80
  if d.Decode(&req) != nil {
81
81
  return
82
82
  }
83
+ if req.Op == "revoke" {
84
+ err := packageRevoke(c, req)
85
+ res := Response{Inventory: c.Expected, Nonce: req.Nonce, OK: err == nil}
86
+ if err != nil {
87
+ res.Error = "revoke-refused"
88
+ }
89
+ json.NewEncoder(conn).Encode(res)
90
+ return
91
+ }
83
92
  r, err := readRecord(c.StateDir, 0)
84
93
  readiness := "fenced"
85
94
  if err == nil && r.Enabled && r.Inventory == c.Expected && r.Lifecycle != nil && r.Lifecycle.Phase == "running" {
@@ -35,7 +35,7 @@ ui:
35
35
  # UI languages: i18n/messages/<locale>.json for en, zh-TW, zh-CN, ja, ko, de,
36
36
  # fr, es and it (DSM's other languages fall back to English). build-spk checks
37
37
  # that every language has the en.json keys and placeholders, and that the keys
38
- # listed in the glossary use Synology's own DSM wording.
38
+ # listed in the glossary use DSM's own wording.
39
39
  i18n:
40
40
  messages: i18n/messages
41
41
  glossary: i18n/glossary.json
@@ -0,0 +1,298 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "crypto/sha256"
6
+ "dsmapp/internal/api"
7
+ "encoding/base64"
8
+ "encoding/hex"
9
+ "encoding/json"
10
+ "errors"
11
+ "io"
12
+ "net"
13
+ "net/http"
14
+ "net/url"
15
+ "os"
16
+ "strconv"
17
+ "strings"
18
+ "time"
19
+ )
20
+
21
+ // Compiled into the CGI. Package-owned JSON is never the source of trust.
22
+ type authorizationTask struct {
23
+ Op string `json:"op"`
24
+ Name string `json:"name"`
25
+ Header string `json:"header"`
26
+ Script string `json:"script"`
27
+ Output bool `json:"output"`
28
+ }
29
+ type authorizationBuild struct {
30
+ Schema int `json:"schema"`
31
+ Package string `json:"package"`
32
+ Version string `json:"version"`
33
+ Pin string `json:"bootstrapSha256"`
34
+ Inventory struct {
35
+ Files []struct {
36
+ Path string `json:"path"`
37
+ Size int64 `json:"size"`
38
+ Hash string `json:"sha256"`
39
+ } `json:"files"`
40
+ } `json:"inventory"`
41
+ Tasks []authorizationTask `json:"tasks"`
42
+ }
43
+
44
+ func (cfg Config) authorizationRoutes() []api.Route {
45
+ if cfg.RootAuthorization == "" {
46
+ return nil
47
+ }
48
+ return []api.Route{api.UserRequest("authorization.status", cfg.authorizationStatus), api.Admin("authorization.request", cfg.authorizationRequest), api.Admin("authorization.revoke", cfg.authorizationRevoke)}
49
+ }
50
+ func authorizationDocument(state string, reason any) any {
51
+ return map[string]any{"command": "authorization", "schema_version": 1, "state": state, "reason": reason}
52
+ }
53
+ func (cfg Config) authorizationPlan() (authorizationBuild, error) {
54
+ var p authorizationBuild
55
+ raw, decode := base64.StdEncoding.DecodeString(cfg.RootAuthorization)
56
+ if decode != nil || json.Unmarshal(raw, &p) != nil || p.Schema != 2 || p.Package != cfg.PackageID || p.Version != cfg.Version || len(p.Tasks) == 0 || len(p.Pin) != 64 {
57
+ return p, errors.New("invalid build authorization")
58
+ }
59
+ if b, e := hex.DecodeString(p.Pin); e != nil || len(b) != 32 {
60
+ return p, errors.New("invalid bootstrap pin")
61
+ }
62
+ return p, nil
63
+ }
64
+ func (cfg Config) authorizationRequest(_ context.Context, _ api.Caller, params json.RawMessage) (any, error) {
65
+ if e := decodeParams(params, &struct{}{}); e != nil {
66
+ return nil, e
67
+ }
68
+ p, e := cfg.authorizationPlan()
69
+ if e != nil {
70
+ return nil, e
71
+ }
72
+ if cfg.authorizationTrusted(p) != nil {
73
+ return nil, &api.Error{Code: "authorization_required", Message: "Build payload cannot be trusted; reinstall this package"}
74
+ }
75
+ ops := []string{}
76
+ for _, t := range p.Tasks {
77
+ if t.Op == "helper_enable" {
78
+ ops = []string{t.Op}
79
+ break
80
+ }
81
+ ops = append(ops, t.Op)
82
+ }
83
+ return map[string]any{"command": "authorization", "schema_version": 1, "phase": "plan", "operations": ops, "run": cfg.HelperInventory.Package != ""}, nil
84
+ }
85
+ func (cfg Config) authorizationRevoke(_ context.Context, _ api.Caller, params json.RawMessage) (any, error) {
86
+ if e := decodeParams(params, &struct{}{}); e != nil {
87
+ return nil, e
88
+ }
89
+ p, e := cfg.authorizationPlan()
90
+ if e != nil {
91
+ return nil, e
92
+ }
93
+ // Root task still pins bootstrap. Corrupt UI must not prevent safe cleanup.
94
+ ops := []string{}
95
+ purge := false
96
+ for _, t := range p.Tasks {
97
+ ops = append(ops, t.Op)
98
+ if cfg.HelperInventory.Package != "" && t.Op == "helper_purge" {
99
+ purge = true
100
+ }
101
+ }
102
+ return map[string]any{"command": "authorization", "schema_version": 1, "phase": "plan", "operations": ops, "disable": cfg.HelperInventory.Package != "", "purge": purge}, nil
103
+ }
104
+ func (cfg Config) authorizationTrusted(p authorizationBuild) error {
105
+ root, e := os.OpenRoot("/var/packages/" + cfg.PackageID + "/target")
106
+ if e != nil {
107
+ return e
108
+ }
109
+ defer root.Close()
110
+ verify := func(path string, size int64, want string) error {
111
+ f, e := root.Open(path)
112
+ if e != nil {
113
+ return e
114
+ }
115
+ defer f.Close()
116
+ st, e := f.Stat()
117
+ if e != nil || !st.Mode().IsRegular() || st.Size() > 64<<20 || size >= 0 && st.Size() != size {
118
+ return errors.New("untrusted payload")
119
+ }
120
+ h := sha256.New()
121
+ _, e = io.Copy(h, io.LimitReader(f, (64<<20)+1))
122
+ if e != nil || hex.EncodeToString(h.Sum(nil)) != want {
123
+ return errors.New("untrusted payload")
124
+ }
125
+ return nil
126
+ }
127
+ if e = verify("authorization/bootstrap", -1, p.Pin); e != nil {
128
+ return e
129
+ }
130
+ for _, f := range p.Inventory.Files {
131
+ if e = verify(f.Path, f.Size, f.Hash); e != nil {
132
+ return e
133
+ }
134
+ }
135
+ return nil
136
+ }
137
+ func (cfg Config) authorizationStatus(ctx context.Context, caller api.Caller, r *http.Request, params json.RawMessage) (any, error) {
138
+ if e := decodeParams(params, &struct{}{}); e != nil {
139
+ return nil, e
140
+ }
141
+ p, e := cfg.authorizationPlan()
142
+ if e != nil {
143
+ return authorizationDocument("unavailable", "service_unavailable"), nil
144
+ }
145
+ if cfg.authorizationTrusted(p) != nil {
146
+ return authorizationDocument("authorization_required", "helper_untrusted"), nil
147
+ }
148
+ if cfg.HelperInventory.Package != "" {
149
+ s := cfg.integrationState(ctx)
150
+ if s.State == "ready" {
151
+ return authorizationDocument("ready", nil), nil
152
+ }
153
+ if !caller.Admin() {
154
+ return authorizationDocument("not_admin", "not_admin"), nil
155
+ }
156
+ switch s.State {
157
+ case "incompatible":
158
+ return authorizationDocument("authorization_required", "grant_incompatible"), nil
159
+ case "pending":
160
+ return authorizationDocument("unavailable", "health_probe_timeout"), nil
161
+ case "failed":
162
+ return authorizationDocument("unavailable", "service_unavailable"), nil
163
+ }
164
+ }
165
+ if !caller.Admin() {
166
+ return authorizationDocument("not_admin", "not_admin"), nil
167
+ }
168
+ state, reason := authorizationScheduler(ctx, r, p.Tasks)
169
+ if cfg.HelperInventory.Package != "" && state == "ready" {
170
+ state = "authorization_required"
171
+ reason = "grant_missing"
172
+ }
173
+ return authorizationDocument(state, reason), nil
174
+ }
175
+
176
+ // Read-only scheduler observations; never accept caller-selected URLs or task IDs.
177
+ // One modern probe; legacy fallback only for explicit missing method (103).
178
+ func authorizationScheduler(ctx context.Context, r *http.Request, tasks []authorizationTask) (string, any) {
179
+ return authorizationSchedulerAt(ctx, r, tasks, "http://127.0.0.1:5000")
180
+ }
181
+ func authorizationSchedulerAt(ctx context.Context, r *http.Request, tasks []authorizationTask, endpoint string) (string, any) {
182
+ tr := &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext, DisableKeepAlives: true, DisableCompression: true, ResponseHeaderTimeout: 5 * time.Second, MaxResponseHeaderBytes: 32 << 10}
183
+ defer tr.CloseIdleConnections()
184
+ client := &http.Client{Transport: tr, Timeout: 8 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
185
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
186
+ defer cancel()
187
+ call := func(method string, version int, params url.Values) (json.RawMessage, int, error) {
188
+ params.Set("api", "SYNO.Core.TaskScheduler")
189
+ params.Set("method", method)
190
+ params.Set("version", strconv.Itoa(version))
191
+ req, e := http.NewRequestWithContext(ctx, "GET", endpoint+"/webapi/entry.cgi?"+params.Encode(), nil)
192
+ if e != nil {
193
+ return nil, 0, e
194
+ }
195
+ cookies := r.CookiesNamed("id")
196
+ if len(cookies) != 1 {
197
+ return nil, 0, errors.New("session")
198
+ }
199
+ req.AddCookie(cookies[0])
200
+ req.Header.Set("X-SYNO-TOKEN", r.Header.Get("X-SYNO-TOKEN"))
201
+ response, e := client.Do(req)
202
+ if e != nil {
203
+ return nil, 0, e
204
+ }
205
+ defer response.Body.Close()
206
+ b, e := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
207
+ if e != nil || len(b) > 1<<20 || response.StatusCode != 200 {
208
+ return nil, 0, errors.New("scheduler unavailable")
209
+ }
210
+ var wire struct {
211
+ Success bool `json:"success"`
212
+ Data json.RawMessage `json:"data"`
213
+ Error struct {
214
+ Code int `json:"code"`
215
+ } `json:"error"`
216
+ }
217
+ if json.Unmarshal(b, &wire) != nil {
218
+ return nil, 0, errors.New("scheduler schema")
219
+ }
220
+ if !wire.Success {
221
+ return nil, wire.Error.Code, errors.New("scheduler refused")
222
+ }
223
+ return wire.Data, 0, nil
224
+ }
225
+ data, code, e := call("list", 3, url.Values{"start": {"0"}, "limit": {"500"}})
226
+ legacy := code == 103
227
+ if legacy {
228
+ data, code, e = call("list", 2, url.Values{"offset": {"0"}, "limit": {"500"}})
229
+ }
230
+ if e != nil {
231
+ if code == 106 || code == 107 || code == 119 {
232
+ return "session_expired", "session_expired"
233
+ }
234
+ return "unavailable", "scheduler_unavailable"
235
+ }
236
+ type item struct {
237
+ ID json.Number `json:"id"`
238
+ Name string `json:"name"`
239
+ Owner string `json:"owner"`
240
+ RealOwner string `json:"real_owner"`
241
+ Enable *bool `json:"enable"`
242
+ Type string `json:"type"`
243
+ Extra struct {
244
+ Script string `json:"script"`
245
+ } `json:"extra"`
246
+ }
247
+ var list struct {
248
+ Tasks []item `json:"tasks"`
249
+ Total *int `json:"total"`
250
+ }
251
+ if json.Unmarshal(data, &list) != nil || list.Total == nil || *list.Total != len(list.Tasks) || len(list.Tasks) > 500 {
252
+ return "unavailable", "scheduler_unavailable"
253
+ }
254
+ for _, expected := range tasks {
255
+ // Helper control tasks are individually approved on demand. Enable is the
256
+ // activation anchor; live helper health remains required separately.
257
+ if strings.HasPrefix(expected.Op, "helper_") && expected.Op != "helper_enable" {
258
+ continue
259
+ }
260
+ var found *item
261
+ for n := range list.Tasks {
262
+ if list.Tasks[n].Name == expected.Name {
263
+ if found != nil {
264
+ return "authorization_required", "maintenance_task_mismatch"
265
+ }
266
+ found = &list.Tasks[n]
267
+ }
268
+ }
269
+ if found == nil {
270
+ return "authorization_required", "grant_missing"
271
+ }
272
+ valid := func(t item) bool {
273
+ return t.Owner == "root" && t.RealOwner == "root" && t.Enable != nil && !*t.Enable && t.Type == "script"
274
+ }
275
+ if !valid(*found) {
276
+ return "authorization_required", "maintenance_task_mismatch"
277
+ }
278
+ version := 4
279
+ if legacy {
280
+ version = 3
281
+ }
282
+ detail, _, e := call("get", version, url.Values{"id": {string(found.ID)}, "real_owner": {"root"}})
283
+ if e != nil {
284
+ return "unavailable", "scheduler_unavailable"
285
+ }
286
+ var full item
287
+ if json.Unmarshal(detail, &full) != nil || full.ID != found.ID || full.Name != expected.Name || !valid(full) {
288
+ return "authorization_required", "maintenance_task_mismatch"
289
+ }
290
+ if full.Extra.Script != expected.Script {
291
+ if strings.HasPrefix(full.Extra.Script, expected.Header) {
292
+ return "authorization_required", "grant_incompatible"
293
+ }
294
+ return "authorization_required", "maintenance_task_mismatch"
295
+ }
296
+ }
297
+ return "ready", nil
298
+ }
@@ -0,0 +1,96 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "dsmapp/internal/api"
6
+ "encoding/json"
7
+ "io"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "slices"
11
+ "testing"
12
+ )
13
+
14
+ func TestAuthorizationFailClosed(t *testing.T) {
15
+ cfg := Config{PackageID: "RootDemo", Version: "0.1.0-0001", RootAuthorization: `{"schema":2}`}
16
+ if _, e := cfg.authorizationPlan(); e == nil {
17
+ t.Fatal("accepted missing pin")
18
+ }
19
+ if _, e := cfg.authorizationRequest(context.Background(), api.Caller{}, json.RawMessage(`{"op":"arbitrary"}`)); e == nil {
20
+ t.Fatal("accepted arbitrary params")
21
+ }
22
+ if len((Config{}).authorizationRoutes()) != 0 {
23
+ t.Fatal("default registered root routes")
24
+ }
25
+ }
26
+ func TestAuthorizationDocuments(t *testing.T) {
27
+ for _, state := range []string{"ready", "authorization_required", "not_admin", "unavailable"} {
28
+ b, e := json.Marshal(authorizationDocument(state, nil))
29
+ if e != nil {
30
+ t.Fatal(e)
31
+ }
32
+ var d map[string]any
33
+ json.Unmarshal(b, &d)
34
+ if d["command"] != "authorization" || d["schema_version"] != float64(1) || d["state"] != state {
35
+ t.Fatal(string(b))
36
+ }
37
+ }
38
+ }
39
+
40
+ func TestSchedulerRejectsChangedTaskAndFallsBackOnlyOn103(t *testing.T) {
41
+ for _, legacy := range []bool{false, true} {
42
+ altered := false
43
+ var versions []string
44
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45
+ q := r.URL.Query()
46
+ versions = append(versions, q.Get("method")+":"+q.Get("version"))
47
+ if q.Get("method") == "list" && q.Get("version") == "3" && legacy {
48
+ io.WriteString(w, `{"success":false,"error":{"code":103}}`)
49
+ return
50
+ }
51
+ script := "approved"
52
+ if altered {
53
+ script = "unapproved"
54
+ }
55
+ item := map[string]any{"id": 1, "name": "RootDemo root: whoami", "owner": "root", "real_owner": "root", "enable": false, "type": "script", "extra": map[string]string{"script": script}}
56
+ var data any = item
57
+ if q.Get("method") == "list" {
58
+ data = map[string]any{"tasks": []any{item}, "total": 1}
59
+ }
60
+ json.NewEncoder(w).Encode(map[string]any{"success": true, "data": data})
61
+ }))
62
+ request := httptest.NewRequest("POST", "/", nil)
63
+ request.AddCookie(&http.Cookie{Name: "id", Value: "fixture-session"})
64
+ tasks := []authorizationTask{{Op: "whoami", Name: "RootDemo root: whoami", Script: "approved", Header: "header"}}
65
+ state, _ := authorizationSchedulerAt(context.Background(), request, tasks, server.URL)
66
+ if state != "ready" {
67
+ t.Fatal(state)
68
+ }
69
+ if legacy && !slices.Contains(versions, "get:3") {
70
+ t.Fatal(versions)
71
+ }
72
+ if !legacy && slices.Contains(versions, "get:3") {
73
+ t.Fatal(versions)
74
+ }
75
+ altered = true
76
+ state, reason := authorizationSchedulerAt(context.Background(), request, tasks, server.URL)
77
+ if state != "authorization_required" || reason != "maintenance_task_mismatch" {
78
+ t.Fatal(state, reason)
79
+ }
80
+ server.Close()
81
+ }
82
+ }
83
+ func TestSchedulerPermissionFailureIsNotLegacyFallback(t *testing.T) {
84
+ calls := 0
85
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
86
+ calls++
87
+ io.WriteString(w, `{"success":false,"error":{"code":105}}`)
88
+ }))
89
+ defer server.Close()
90
+ request := httptest.NewRequest("POST", "/", nil)
91
+ request.AddCookie(&http.Cookie{Name: "id", Value: "fixture-session"})
92
+ state, _ := authorizationSchedulerAt(context.Background(), request, nil, server.URL)
93
+ if state != "unavailable" || calls != 1 {
94
+ t.Fatal(state, calls)
95
+ }
96
+ }