@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
|
@@ -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
|
|
57
|
-
//
|
|
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
|
|
57
|
-
//
|
|
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
|
|
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
|
+
}
|