@synopackageland/cli 0.3.10 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -9
- package/dist/bin.js +1 -2
- package/dist/build-spk/generator.d.ts +0 -3
- package/dist/build-spk/generator.js +135 -287
- package/dist/build-spk/native-lifecycle.js +0 -3
- package/dist/build-spk/native-payload.js +0 -1
- package/dist/cli.js +171 -151
- package/dist/compose/analyze.d.ts +1 -2
- package/dist/compose/analyze.js +5 -48
- package/dist/contract/unsupported-types.d.ts +10 -0
- package/dist/contract/unsupported-types.js +49 -0
- package/dist/contract/validate.js +25 -2
- package/dist/contract/web-launch.js +0 -2
- package/dist/deploy/deploy.d.ts +1 -4
- package/dist/deploy/deploy.js +45 -156
- package/dist/deploy/homes-values.js +1 -1
- package/dist/deploy/plan.d.ts +18 -0
- package/dist/deploy/plan.js +54 -0
- package/dist/deploy/spk-version.d.ts +2 -14
- package/dist/deploy/spk-version.js +25 -111
- package/dist/dev.d.ts +1 -3
- package/dist/dev.js +14 -28
- package/dist/error-registry.js +810 -862
- package/dist/host/dsm-client.js +12 -272
- package/dist/host/dsm-port-profile.d.ts +0 -1
- package/dist/host/dsm-port-profile.js +0 -2
- package/dist/host/types.d.ts +1 -83
- package/dist/index.d.ts +0 -9
- package/dist/index.js +0 -6
- package/dist/info.d.ts +0 -1
- package/dist/info.js +3 -10
- package/dist/init.d.ts +11 -1
- package/dist/init.js +112 -4
- package/dist/log.js +2 -3
- package/dist/native/validate.js +0 -1
- package/dist/standalone/binary.d.ts +9 -0
- package/dist/standalone/binary.js +67 -0
- package/dist/standalone/build.d.ts +25 -0
- package/dist/standalone/build.js +108 -0
- package/dist/standalone/dsm-window.d.ts +26 -0
- package/dist/standalone/dsm-window.js +251 -0
- package/dist/standalone/helper.d.ts +31 -0
- package/dist/standalone/helper.js +104 -0
- package/dist/standalone/i18n.d.ts +28 -0
- package/dist/standalone/i18n.js +161 -0
- package/dist/standalone/inspect-spk.d.ts +21 -0
- package/dist/standalone/inspect-spk.js +160 -0
- package/dist/standalone/manifest.d.ts +71 -0
- package/dist/standalone/manifest.js +276 -0
- package/dist/standalone/root-messages.d.ts +7 -0
- package/dist/standalone/root-messages.js +250 -0
- package/dist/standalone/root-ops.d.ts +42 -0
- package/dist/standalone/root-ops.js +101 -0
- package/dist/standalone/root-window.d.ts +35 -0
- package/dist/standalone/root-window.js +290 -0
- package/dist/standalone/spk.d.ts +22 -0
- package/dist/standalone/spk.js +205 -0
- package/dist/templates/binary/synology-app.yaml +7 -0
- package/dist/templates/hello-files/compose.yaml +0 -1
- package/dist/templates/hello-web/compose.yaml +13 -10
- package/dist/templates/hello-web/html/app.js +1 -23
- package/dist/templates/hello-web/html/index.html +1 -3
- package/dist/templates/hello-web-page/compose.yaml +13 -10
- package/dist/templates/hello-web-page/html/app.js +1 -23
- package/dist/templates/hello-web-page/html/index.html +1 -3
- package/dist/templates/native-go/README.md +268 -0
- package/dist/templates/native-go/backend/cmd/app/main.go +49 -0
- package/dist/templates/native-go/backend/cmd/helper/main.go +38 -0
- package/dist/templates/native-go/backend/go.mod +3 -0
- package/dist/templates/native-go/backend/internal/api/errors.go +33 -0
- package/dist/templates/native-go/backend/internal/api/registry.go +94 -0
- package/dist/templates/native-go/backend/internal/api/registry_test.go +329 -0
- package/dist/templates/native-go/backend/internal/api/server.go +155 -0
- package/dist/templates/native-go/backend/internal/app/root_none.go +9 -0
- package/dist/templates/native-go/backend/internal/app/routes.go +127 -0
- package/dist/templates/native-go/backend/internal/app/routes_test.go +167 -0
- package/dist/templates/native-go/backend/internal/dsm/auth.go +118 -0
- package/dist/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
- package/dist/templates/native-go/backend/internal/helper/control.go +219 -0
- package/dist/templates/native-go/backend/internal/helper/control_test.go +242 -0
- package/dist/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
- package/dist/templates/native-go/backend/internal/helper/peer_other.go +7 -0
- package/dist/templates/native-go/backend/internal/helper/process_linux.go +167 -0
- package/dist/templates/native-go/backend/internal/helper/process_other.go +5 -0
- package/dist/templates/native-go/backend/internal/helper/server_linux.go +83 -0
- package/dist/templates/native-go/backend/internal/helper/status.go +271 -0
- package/dist/templates/native-go/backend/internal/helper/status_test.go +205 -0
- package/dist/templates/native-go/backend/internal/helper/supervision.go +84 -0
- package/dist/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
- package/dist/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
- package/dist/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
- package/dist/templates/native-go/dsm-package.yaml +58 -0
- package/dist/templates/native-go/gitignore +1 -0
- package/dist/templates/native-go/i18n/glossary.json +662 -0
- package/dist/templates/native-go/i18n/messages/de.json +28 -0
- package/dist/templates/native-go/i18n/messages/en.json +28 -0
- package/dist/templates/native-go/i18n/messages/es.json +28 -0
- package/dist/templates/native-go/i18n/messages/fr.json +28 -0
- package/dist/templates/native-go/i18n/messages/it.json +28 -0
- package/dist/templates/native-go/i18n/messages/ja.json +28 -0
- package/dist/templates/native-go/i18n/messages/ko.json +28 -0
- package/dist/templates/native-go/i18n/messages/zh-CN.json +28 -0
- package/dist/templates/native-go/i18n/messages/zh-TW.json +28 -0
- package/dist/templates/native-go/ui/app.css +35 -0
- package/dist/templates/native-go/ui/app.js +94 -0
- package/dist/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
- package/dist/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
- package/dist/templates/native-go-better-auth/node/app.mjs +72 -0
- package/dist/templates/native-go-better-auth/node/build.mjs +2 -0
- package/dist/templates/native-go-better-auth/node/package-lock.json +862 -0
- package/dist/templates/native-go-better-auth/node/package.json +1 -0
- package/dist/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
- package/dist/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
- package/dist/templates/native-go-integration/backend/internal/app/personal.go +203 -0
- package/dist/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
- package/dist/templates/native-go-root/backend/internal/app/root.go +107 -0
- package/dist/templates/native-go-root/backend/internal/app/root_test.go +124 -0
- package/dist/templates/native-go-root/root/whoami.sh +6 -0
- package/dist/templates/native-go-vue/README.md +110 -0
- package/dist/templates/native-go-vue/frontend/DESIGN.md +78 -0
- package/dist/templates/native-go-vue/frontend/package-lock.json +2507 -0
- package/dist/templates/native-go-vue/frontend/package.json +6 -0
- package/dist/templates/native-go-vue/frontend/src/App.vue +132 -0
- package/dist/templates/native-go-vue/frontend/src/context.ts +17 -0
- package/dist/templates/native-go-vue/frontend/src/main.ts +26 -0
- package/dist/templates/native-go-vue/frontend/src/style.css +14 -0
- package/dist/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
- package/dist/templates/native-go-vue/frontend/tsconfig.json +1 -0
- package/dist/templates/native-go-vue/frontend/vite.config.ts +8 -0
- package/dist/templates/native-go-vue/gitignore +2 -0
- package/dist/types.d.ts +1 -1
- package/dist/validate.js +11 -7
- package/package.json +3 -7
- package/schema/synology-app.schema.json +36 -37
- package/skills/SKILL.md +4 -48
- package/skills/reference/create.md +11 -83
- package/skills/reference/deploy.md +7 -78
- package/skills/reference/diagnose.md +4 -101
- package/skills/reference/files.md +5 -41
- package/skills/reference/identity.md +3 -29
- package/skills/reference/wrap.md +0 -2
- package/templates/binary/synology-app.yaml +7 -0
- package/templates/hello-files/compose.yaml +0 -1
- package/templates/hello-web/compose.yaml +13 -10
- package/templates/hello-web/html/app.js +1 -23
- package/templates/hello-web/html/index.html +1 -3
- package/templates/hello-web-page/compose.yaml +13 -10
- package/templates/hello-web-page/html/app.js +1 -23
- package/templates/hello-web-page/html/index.html +1 -3
- package/templates/native-go/README.md +268 -0
- package/templates/native-go/backend/cmd/app/main.go +49 -0
- package/templates/native-go/backend/cmd/helper/main.go +38 -0
- package/templates/native-go/backend/go.mod +3 -0
- package/templates/native-go/backend/internal/api/errors.go +33 -0
- package/templates/native-go/backend/internal/api/registry.go +94 -0
- package/templates/native-go/backend/internal/api/registry_test.go +329 -0
- package/templates/native-go/backend/internal/api/server.go +155 -0
- package/templates/native-go/backend/internal/app/root_none.go +9 -0
- package/templates/native-go/backend/internal/app/routes.go +127 -0
- package/templates/native-go/backend/internal/app/routes_test.go +167 -0
- package/templates/native-go/backend/internal/dsm/auth.go +118 -0
- package/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
- package/templates/native-go/backend/internal/helper/control.go +219 -0
- package/templates/native-go/backend/internal/helper/control_test.go +242 -0
- package/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
- package/templates/native-go/backend/internal/helper/peer_other.go +7 -0
- package/templates/native-go/backend/internal/helper/process_linux.go +167 -0
- package/templates/native-go/backend/internal/helper/process_other.go +5 -0
- package/templates/native-go/backend/internal/helper/server_linux.go +83 -0
- package/templates/native-go/backend/internal/helper/status.go +271 -0
- package/templates/native-go/backend/internal/helper/status_test.go +205 -0
- package/templates/native-go/backend/internal/helper/supervision.go +84 -0
- package/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
- package/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
- package/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
- package/templates/native-go/dsm-package.yaml +58 -0
- package/templates/native-go/gitignore +1 -0
- package/templates/native-go/i18n/glossary.json +662 -0
- package/templates/native-go/i18n/messages/de.json +28 -0
- package/templates/native-go/i18n/messages/en.json +28 -0
- package/templates/native-go/i18n/messages/es.json +28 -0
- package/templates/native-go/i18n/messages/fr.json +28 -0
- package/templates/native-go/i18n/messages/it.json +28 -0
- package/templates/native-go/i18n/messages/ja.json +28 -0
- package/templates/native-go/i18n/messages/ko.json +28 -0
- package/templates/native-go/i18n/messages/zh-CN.json +28 -0
- package/templates/native-go/i18n/messages/zh-TW.json +28 -0
- package/templates/native-go/ui/app.css +35 -0
- package/templates/native-go/ui/app.js +94 -0
- package/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
- package/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
- package/templates/native-go-better-auth/node/app.mjs +72 -0
- package/templates/native-go-better-auth/node/build.mjs +2 -0
- package/templates/native-go-better-auth/node/package-lock.json +862 -0
- package/templates/native-go-better-auth/node/package.json +1 -0
- package/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
- package/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
- package/templates/native-go-integration/backend/internal/app/personal.go +203 -0
- package/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
- package/templates/native-go-root/backend/internal/app/root.go +107 -0
- package/templates/native-go-root/backend/internal/app/root_test.go +124 -0
- package/templates/native-go-root/root/whoami.sh +6 -0
- package/templates/native-go-vue/README.md +110 -0
- package/templates/native-go-vue/frontend/DESIGN.md +78 -0
- package/templates/native-go-vue/frontend/package-lock.json +2507 -0
- package/templates/native-go-vue/frontend/package.json +6 -0
- package/templates/native-go-vue/frontend/src/App.vue +132 -0
- package/templates/native-go-vue/frontend/src/context.ts +17 -0
- package/templates/native-go-vue/frontend/src/main.ts +26 -0
- package/templates/native-go-vue/frontend/src/style.css +14 -0
- package/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
- package/templates/native-go-vue/frontend/tsconfig.json +1 -0
- package/templates/native-go-vue/frontend/vite.config.ts +8 -0
- package/templates/native-go-vue/gitignore +2 -0
- package/dist/attestation/app-package.d.ts +0 -86
- package/dist/attestation/app-package.js +0 -206
- package/dist/attestation/install-on-host.d.ts +0 -14
- package/dist/attestation/install-on-host.js +0 -52
- package/dist/attestation/keyring.d.ts +0 -21
- package/dist/attestation/keyring.js +0 -168
- package/dist/attestation/platform-service.d.ts +0 -70
- package/dist/attestation/platform-service.js +0 -250
- package/dist/build-spk/broker-glue.d.ts +0 -16
- package/dist/build-spk/broker-glue.js +0 -40
- package/dist/deploy/catalog-publish.d.ts +0 -3
- package/dist/deploy/catalog-publish.js +0 -7
- package/dist/doctor.d.ts +0 -22
- package/dist/doctor.js +0 -822
- package/dist/error-collector.d.ts +0 -2
- package/dist/error-collector.js +0 -46
- package/dist/host/broker-paths.d.ts +0 -1
- package/dist/host/broker-paths.js +0 -1
- package/dist/host/broker-upgrade-preflight.d.ts +0 -61
- package/dist/host/broker-upgrade-preflight.js +0 -156
- package/dist/host/digests.d.ts +0 -5
- package/dist/host/digests.js +0 -63
- package/dist/host/fake-nas.d.ts +0 -49
- package/dist/host/fake-nas.js +0 -193
- package/dist/mock-broker/bootstrap.d.ts +0 -2
- package/dist/mock-broker/bootstrap.js +0 -8
- package/dist/mock-broker/files.d.ts +0 -60
- package/dist/mock-broker/files.js +0 -408
- package/dist/mock-broker/oidc.d.ts +0 -31
- package/dist/mock-broker/oidc.js +0 -161
- package/dist/mock-broker/path.d.ts +0 -9
- package/dist/mock-broker/path.js +0 -50
- package/dist/mock-broker/server.d.ts +0 -19
- package/dist/mock-broker/server.js +0 -350
- package/dist/publish/build-ledger.d.ts +0 -22
- package/dist/publish/build-ledger.js +0 -26
- package/dist/publish/community-path.d.ts +0 -6
- package/dist/publish/community-path.js +0 -41
- package/dist/publish/curated-release.d.ts +0 -78
- package/dist/publish/curated-release.js +0 -883
- package/dist/publish/file-build-ledger.d.ts +0 -18
- package/dist/publish/file-build-ledger.js +0 -268
- package/dist/publish/github-pr.d.ts +0 -25
- package/dist/publish/github-pr.js +0 -85
- package/dist/publish/github-repo.d.ts +0 -8
- package/dist/publish/github-repo.js +0 -21
- package/dist/publish/platform-service-release.d.ts +0 -60
- package/dist/publish/platform-service-release.js +0 -333
- package/dist/publish/preflight.d.ts +0 -24
- package/dist/publish/preflight.js +0 -138
- package/dist/publish/publish.d.ts +0 -27
- package/dist/publish/publish.js +0 -136
- package/dist/publish/sync-definition.d.ts +0 -4
- package/dist/publish/sync-definition.js +0 -48
- package/dist/release-bin.d.ts +0 -2
- package/dist/release-bin.js +0 -23
- package/dist/templates/hello-web/html/sdk.js +0 -68
- package/dist/templates/hello-web-page/html/sdk.js +0 -68
- package/dist/templates/native/synology-app.yaml +0 -7
- package/dist/test-host.d.ts +0 -15
- package/dist/test-host.js +0 -37
- package/templates/hello-web/html/sdk.js +0 -68
- package/templates/hello-web-page/html/sdk.js +0 -68
- package/templates/native/synology-app.yaml +0 -7
- /package/dist/templates/{native → binary}/native.yaml +0 -0
- /package/templates/{native → binary}/native.yaml +0 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
package api
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"io"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/http/httptest"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// fakeAuth returns a fixed outcome and counts how often it was consulted, so
|
|
15
|
+
// tests can prove each request authenticates at most once.
|
|
16
|
+
type fakeAuth struct {
|
|
17
|
+
user string
|
|
18
|
+
admin bool
|
|
19
|
+
err error
|
|
20
|
+
calls int
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func (f *fakeAuth) Authenticate(*http.Request) (Identity, error) {
|
|
24
|
+
f.calls++
|
|
25
|
+
if f.err != nil {
|
|
26
|
+
return Identity{}, f.err
|
|
27
|
+
}
|
|
28
|
+
return Identity{User: f.user, Admin: f.admin}, nil
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func post(t *testing.T, srv http.Handler, body string, mutate ...func(*http.Request)) (*httptest.ResponseRecorder, envelope) {
|
|
32
|
+
t.Helper()
|
|
33
|
+
req := httptest.NewRequest(http.MethodPost, "http://nas.local/webman/3rdparty/App/cgi/api.cgi", strings.NewReader(body))
|
|
34
|
+
req.Header.Set("Content-Type", "application/json")
|
|
35
|
+
for _, m := range mutate {
|
|
36
|
+
m(req)
|
|
37
|
+
}
|
|
38
|
+
rec := httptest.NewRecorder()
|
|
39
|
+
srv.ServeHTTP(rec, req)
|
|
40
|
+
var env envelope
|
|
41
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
|
42
|
+
t.Fatalf("response is not JSON: %q", rec.Body.String())
|
|
43
|
+
}
|
|
44
|
+
return rec, env
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type envelope struct {
|
|
48
|
+
Success bool `json:"success"`
|
|
49
|
+
Data json.RawMessage `json:"data"`
|
|
50
|
+
Error *struct {
|
|
51
|
+
Code string `json:"code"`
|
|
52
|
+
} `json:"error"`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func countingRoutes(counter *int) []Route {
|
|
56
|
+
h := func(_ context.Context, c Caller, _ json.RawMessage) (any, error) {
|
|
57
|
+
*counter++
|
|
58
|
+
return map[string]string{"user": c.User()}, nil
|
|
59
|
+
}
|
|
60
|
+
return []Route{
|
|
61
|
+
User("demo.user", h),
|
|
62
|
+
Admin("demo.admin", h),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func newTestServer(t *testing.T, auth Authenticator, routes ...Route) *Server {
|
|
67
|
+
t.Helper()
|
|
68
|
+
srv, err := NewServer(auth, routes...)
|
|
69
|
+
if err != nil {
|
|
70
|
+
t.Fatal(err)
|
|
71
|
+
}
|
|
72
|
+
return srv
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestUserRouteRunsForAuthenticatedUserWithVerifiedCaller(t *testing.T) {
|
|
76
|
+
var executed int
|
|
77
|
+
auth := &fakeAuth{user: "alice"}
|
|
78
|
+
srv := newTestServer(t, auth, countingRoutes(&executed)...)
|
|
79
|
+
|
|
80
|
+
rec, env := post(t, srv, `{"method":"demo.user"}`)
|
|
81
|
+
|
|
82
|
+
if rec.Code != http.StatusOK || !env.Success {
|
|
83
|
+
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
|
84
|
+
}
|
|
85
|
+
if string(env.Data) != `{"user":"alice"}` {
|
|
86
|
+
t.Fatalf("data = %s", env.Data)
|
|
87
|
+
}
|
|
88
|
+
if executed != 1 || auth.calls != 1 {
|
|
89
|
+
t.Fatalf("executed=%d auth=%d, want 1/1", executed, auth.calls)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func TestEveryProtectedRouteRefusesMissingInvalidAndUnavailableAuth(t *testing.T) {
|
|
94
|
+
cases := []struct {
|
|
95
|
+
name string
|
|
96
|
+
err error
|
|
97
|
+
code string
|
|
98
|
+
}{
|
|
99
|
+
{"missing session", ErrNoSession, "session_required"},
|
|
100
|
+
{"invalid session", ErrNoSession, "session_required"},
|
|
101
|
+
{"auth unavailable", errors.New("authenticate.cgi crashed"), "auth_unavailable"},
|
|
102
|
+
}
|
|
103
|
+
for _, method := range []string{"demo.user", "demo.admin"} {
|
|
104
|
+
for _, tc := range cases {
|
|
105
|
+
t.Run(method+"/"+tc.name, func(t *testing.T) {
|
|
106
|
+
var executed int
|
|
107
|
+
auth := &fakeAuth{err: tc.err}
|
|
108
|
+
srv := newTestServer(t, auth, countingRoutes(&executed)...)
|
|
109
|
+
|
|
110
|
+
rec, env := post(t, srv, `{"method":"`+method+`"}`)
|
|
111
|
+
|
|
112
|
+
if rec.Code != http.StatusOK || env.Success || env.Error.Code != tc.code {
|
|
113
|
+
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
|
114
|
+
}
|
|
115
|
+
if executed != 0 || auth.calls != 1 {
|
|
116
|
+
t.Fatalf("executed=%d auth=%d, want 0/1", executed, auth.calls)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func TestAdminRouteRefusesNonAdminWithoutExecuting(t *testing.T) {
|
|
124
|
+
var executed int
|
|
125
|
+
srv := newTestServer(t, &fakeAuth{user: "bob"}, countingRoutes(&executed)...)
|
|
126
|
+
|
|
127
|
+
rec, env := post(t, srv, `{"method":"demo.admin"}`)
|
|
128
|
+
|
|
129
|
+
if rec.Code != http.StatusOK || env.Error.Code != "admin_required" || executed != 0 {
|
|
130
|
+
t.Fatalf("got %d %s executed=%d", rec.Code, rec.Body.String(), executed)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func TestAdminRouteRunsForAdmin(t *testing.T) {
|
|
135
|
+
var executed int
|
|
136
|
+
srv := newTestServer(t, &fakeAuth{user: "root-admin", admin: true}, countingRoutes(&executed)...)
|
|
137
|
+
|
|
138
|
+
rec, _ := post(t, srv, `{"method":"demo.admin"}`)
|
|
139
|
+
|
|
140
|
+
if rec.Code != http.StatusOK || executed != 1 {
|
|
141
|
+
t.Fatalf("got %d executed=%d", rec.Code, executed)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
func TestUnknownMethodNeedsSessionThenIsNotFound(t *testing.T) {
|
|
146
|
+
var executed int
|
|
147
|
+
srv := newTestServer(t, &fakeAuth{err: ErrNoSession}, countingRoutes(&executed)...)
|
|
148
|
+
_, env := post(t, srv, `{"method":"demo.missing"}`)
|
|
149
|
+
if env.Error == nil || env.Error.Code != "session_required" {
|
|
150
|
+
t.Fatalf("anonymous unknown method: got %+v, want session_required (no route probing)", env.Error)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
srv = newTestServer(t, &fakeAuth{user: "alice", admin: true}, countingRoutes(&executed)...)
|
|
154
|
+
rec, env := post(t, srv, `{"method":"demo.missing"}`)
|
|
155
|
+
if rec.Code != http.StatusOK || env.Error.Code != "unknown_method" || executed != 0 {
|
|
156
|
+
t.Fatalf("got %d %s executed=%d", rec.Code, rec.Body.String(), executed)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func TestPublicRouteIsExplicitAndSkipsAuthentication(t *testing.T) {
|
|
161
|
+
var executed int
|
|
162
|
+
auth := &fakeAuth{err: ErrNoSession}
|
|
163
|
+
srv := newTestServer(t, auth, Public("demo.ping", func(_ context.Context, c Caller, _ json.RawMessage) (any, error) {
|
|
164
|
+
executed++
|
|
165
|
+
if c.Authenticated() {
|
|
166
|
+
t.Error("public handler received an authenticated caller")
|
|
167
|
+
}
|
|
168
|
+
return "pong", nil
|
|
169
|
+
}))
|
|
170
|
+
|
|
171
|
+
rec, _ := post(t, srv, `{"method":"demo.ping"}`)
|
|
172
|
+
|
|
173
|
+
if rec.Code != http.StatusOK || executed != 1 || auth.calls != 0 {
|
|
174
|
+
t.Fatalf("got %d executed=%d auth=%d", rec.Code, executed, auth.calls)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func TestTransportGuardsRunBeforeAuthenticationAndDispatch(t *testing.T) {
|
|
179
|
+
cases := []struct {
|
|
180
|
+
name string
|
|
181
|
+
body string
|
|
182
|
+
mutate func(*http.Request)
|
|
183
|
+
code string
|
|
184
|
+
}{
|
|
185
|
+
{"GET", `{"method":"demo.user"}`, func(r *http.Request) { r.Method = http.MethodGet }, "method_not_allowed"},
|
|
186
|
+
{"form post", `{"method":"demo.user"}`, func(r *http.Request) { r.Header.Set("Content-Type", "text/plain") }, "json_required"},
|
|
187
|
+
{"cross-site fetch", `{"method":"demo.user"}`, func(r *http.Request) { r.Header.Set("Sec-Fetch-Site", "cross-site") }, "cross_origin"},
|
|
188
|
+
{"foreign origin", `{"method":"demo.user"}`, func(r *http.Request) { r.Header.Set("Origin", "https://evil.example") }, "cross_origin"},
|
|
189
|
+
{"oversized body", `{"method":"demo.user","params":"` + strings.Repeat("x", MaxRequestBytes) + `"}`, nil, "request_too_large"},
|
|
190
|
+
{"malformed JSON", `{"method":`, nil, "bad_request"},
|
|
191
|
+
{"unknown field", `{"method":"demo.user","user":"admin"}`, nil, "bad_request"},
|
|
192
|
+
{"trailing data", `{"method":"demo.user"}{}`, nil, "bad_request"},
|
|
193
|
+
}
|
|
194
|
+
for _, tc := range cases {
|
|
195
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
196
|
+
var executed int
|
|
197
|
+
auth := &fakeAuth{user: "alice", admin: true}
|
|
198
|
+
srv := newTestServer(t, auth, countingRoutes(&executed)...)
|
|
199
|
+
mutate := []func(*http.Request){}
|
|
200
|
+
if tc.mutate != nil {
|
|
201
|
+
mutate = append(mutate, tc.mutate)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
rec, env := post(t, srv, tc.body, mutate...)
|
|
205
|
+
|
|
206
|
+
if rec.Code != http.StatusOK || env.Error == nil || env.Error.Code != tc.code {
|
|
207
|
+
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
|
208
|
+
}
|
|
209
|
+
if executed != 0 || auth.calls != 0 {
|
|
210
|
+
t.Fatalf("executed=%d auth=%d, want 0/0", executed, auth.calls)
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// countingReader records how much of the request body the server consumed.
|
|
217
|
+
type countingReader struct {
|
|
218
|
+
r io.Reader
|
|
219
|
+
read int
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func (c *countingReader) Read(p []byte) (int, error) {
|
|
223
|
+
n, err := c.r.Read(p)
|
|
224
|
+
c.read += n
|
|
225
|
+
return n, err
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// DSM's CGI gateway cuts the reply short when a CGI exits without reading
|
|
229
|
+
// its stdin, so every refusal still consumes the request body.
|
|
230
|
+
func TestRefusalsConsumeTheWholeRequestBody(t *testing.T) {
|
|
231
|
+
cases := map[string]func(*http.Request){
|
|
232
|
+
"form post": func(r *http.Request) { r.Header.Set("Content-Type", "text/plain") },
|
|
233
|
+
"cross-site": func(r *http.Request) { r.Header.Set("Sec-Fetch-Site", "cross-site") },
|
|
234
|
+
"GET": func(r *http.Request) { r.Method = http.MethodGet },
|
|
235
|
+
"oversized": func(*http.Request) {},
|
|
236
|
+
}
|
|
237
|
+
for name, mutate := range cases {
|
|
238
|
+
t.Run(name, func(t *testing.T) {
|
|
239
|
+
var executed int
|
|
240
|
+
srv := newTestServer(t, &fakeAuth{user: "alice"}, countingRoutes(&executed)...)
|
|
241
|
+
body := `{"method":"demo.user"}`
|
|
242
|
+
if name == "oversized" {
|
|
243
|
+
body = `{"method":"demo.user","params":"` + strings.Repeat("x", 2*MaxRequestBytes) + `"}`
|
|
244
|
+
}
|
|
245
|
+
reader := &countingReader{r: strings.NewReader(body)}
|
|
246
|
+
req := httptest.NewRequest(http.MethodPost, "http://nas.local/api.cgi", reader)
|
|
247
|
+
req.Header.Set("Content-Type", "application/json")
|
|
248
|
+
mutate(req)
|
|
249
|
+
srv.ServeHTTP(httptest.NewRecorder(), req)
|
|
250
|
+
if reader.read != len(body) {
|
|
251
|
+
t.Fatalf("read %d of %d body bytes", reader.read, len(body))
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
func TestSameOriginRequestIsAccepted(t *testing.T) {
|
|
258
|
+
var executed int
|
|
259
|
+
srv := newTestServer(t, &fakeAuth{user: "alice"}, countingRoutes(&executed)...)
|
|
260
|
+
rec, _ := post(t, srv, `{"method":"demo.user"}`, func(r *http.Request) {
|
|
261
|
+
r.Header.Set("Origin", "http://nas.local")
|
|
262
|
+
r.Header.Set("Sec-Fetch-Site", "same-origin")
|
|
263
|
+
})
|
|
264
|
+
if rec.Code != http.StatusOK {
|
|
265
|
+
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
func TestResponsesAreUncachedJSON(t *testing.T) {
|
|
270
|
+
var executed int
|
|
271
|
+
srv := newTestServer(t, &fakeAuth{err: ErrNoSession}, countingRoutes(&executed)...)
|
|
272
|
+
rec, _ := post(t, srv, `{"method":"demo.user"}`)
|
|
273
|
+
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
|
274
|
+
t.Errorf("Cache-Control = %q", got)
|
|
275
|
+
}
|
|
276
|
+
if got := rec.Header().Get("Content-Type"); got != "application/json" {
|
|
277
|
+
t.Errorf("Content-Type = %q", got)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func TestHandlerErrorsKeepTheirCodeButInternalErrorsAreHidden(t *testing.T) {
|
|
282
|
+
srv := newTestServer(t, &fakeAuth{user: "alice"},
|
|
283
|
+
User("demo.invalid", func(context.Context, Caller, json.RawMessage) (any, error) {
|
|
284
|
+
return nil, BadRequest("text must not be empty")
|
|
285
|
+
}),
|
|
286
|
+
User("demo.crash", func(context.Context, Caller, json.RawMessage) (any, error) {
|
|
287
|
+
return nil, errors.New("disk path /secret/xyz failed")
|
|
288
|
+
}),
|
|
289
|
+
User("demo.panic", func(context.Context, Caller, json.RawMessage) (any, error) {
|
|
290
|
+
panic("boom")
|
|
291
|
+
}),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
rec, env := post(t, srv, `{"method":"demo.invalid"}`)
|
|
295
|
+
if rec.Code != http.StatusOK || env.Error.Code != "bad_request" || !strings.Contains(rec.Body.String(), "text must not be empty") {
|
|
296
|
+
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
|
|
297
|
+
}
|
|
298
|
+
for _, method := range []string{"demo.crash", "demo.panic"} {
|
|
299
|
+
rec, env = post(t, srv, `{"method":"`+method+`"}`)
|
|
300
|
+
if rec.Code != http.StatusOK || env.Error.Code != "internal" || strings.Contains(rec.Body.String(), "secret") || strings.Contains(rec.Body.String(), "boom") {
|
|
301
|
+
t.Fatalf("%s: got %d %s", method, rec.Code, rec.Body.String())
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
func TestRegistryRefusesUnusableRoutes(t *testing.T) {
|
|
307
|
+
h := func(context.Context, Caller, json.RawMessage) (any, error) { return nil, nil }
|
|
308
|
+
cases := map[string][]Route{
|
|
309
|
+
"zero route (no policy)": {{}},
|
|
310
|
+
"nil handler": {User("demo.nil", nil)},
|
|
311
|
+
"bad method name": {User("Demo Method", h)},
|
|
312
|
+
"duplicate method": {User("demo.x", h), Admin("demo.x", h)},
|
|
313
|
+
}
|
|
314
|
+
for name, routes := range cases {
|
|
315
|
+
if _, err := NewServer(&fakeAuth{}, routes...); err == nil {
|
|
316
|
+
t.Errorf("%s: NewServer accepted it", name)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if _, err := NewServer(nil, User("demo.x", h)); err == nil {
|
|
320
|
+
t.Error("NewServer accepted a nil authenticator")
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
func TestZeroCallerIsNotAuthenticated(t *testing.T) {
|
|
325
|
+
var c Caller
|
|
326
|
+
if c.Authenticated() || c.Admin() || c.User() != "" {
|
|
327
|
+
t.Fatal("a Caller built outside the server must carry no authority")
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
package api
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"io"
|
|
8
|
+
"log"
|
|
9
|
+
"mime"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/url"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// MaxRequestBytes bounds the request body the CGI reads (CONTENT_LENGTH is
|
|
15
|
+
// caller-controlled).
|
|
16
|
+
const MaxRequestBytes = 1 << 20
|
|
17
|
+
|
|
18
|
+
// ErrNoSession means the request carries no valid DSM session.
|
|
19
|
+
var ErrNoSession = errors.New("no valid DSM session")
|
|
20
|
+
|
|
21
|
+
// Authenticator verifies the DSM session behind a request.
|
|
22
|
+
type Authenticator interface {
|
|
23
|
+
Authenticate(r *http.Request) (Identity, error)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Server is the http.Handler behind the package CGI.
|
|
27
|
+
type Server struct {
|
|
28
|
+
auth Authenticator
|
|
29
|
+
routes map[string]Route
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// NewServer refuses an empty authenticator and any route that is not usable,
|
|
33
|
+
// so a mistake fails at startup instead of opening a route.
|
|
34
|
+
func NewServer(auth Authenticator, routes ...Route) (*Server, error) {
|
|
35
|
+
if auth == nil {
|
|
36
|
+
return nil, errors.New("api.NewServer needs an Authenticator")
|
|
37
|
+
}
|
|
38
|
+
registry, err := buildRegistry(routes)
|
|
39
|
+
if err != nil {
|
|
40
|
+
return nil, err
|
|
41
|
+
}
|
|
42
|
+
return &Server{auth: auth, routes: registry}, nil
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type request struct {
|
|
46
|
+
Method string `json:"method"`
|
|
47
|
+
Params json.RawMessage `json:"params"`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
51
|
+
// DSM's CGI gateway cuts the reply short if the CGI exits with request
|
|
52
|
+
// bytes unread, so drain whatever the handling below did not consume.
|
|
53
|
+
defer io.Copy(io.Discard, r.Body)
|
|
54
|
+
defer func() {
|
|
55
|
+
if p := recover(); p != nil {
|
|
56
|
+
log.Printf("handler panic: %v", p)
|
|
57
|
+
writeError(w, internalError)
|
|
58
|
+
}
|
|
59
|
+
}()
|
|
60
|
+
req, apiErr := readRequest(w, r)
|
|
61
|
+
if apiErr != nil {
|
|
62
|
+
writeError(w, apiErr)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
caller, route, apiErr := s.authorize(r, req.Method)
|
|
66
|
+
if apiErr != nil {
|
|
67
|
+
writeError(w, apiErr)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
data, err := route.handler(r.Context(), caller, req.Params)
|
|
71
|
+
if err != nil {
|
|
72
|
+
writeError(w, asError(err))
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
writeJSON(w, http.StatusOK, map[string]any{"success": true, "data": data})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// authorize is the single access decision. Public routes skip authentication;
|
|
79
|
+
// everything else, including unknown methods, authenticates exactly once
|
|
80
|
+
// first, so anonymous callers cannot probe which methods exist.
|
|
81
|
+
func (s *Server) authorize(r *http.Request, method string) (Caller, Route, *Error) {
|
|
82
|
+
route, known := s.routes[method]
|
|
83
|
+
if known && route.policy == policyPublic {
|
|
84
|
+
return Caller{}, route, nil
|
|
85
|
+
}
|
|
86
|
+
identity, err := s.auth.Authenticate(r)
|
|
87
|
+
if errors.Is(err, ErrNoSession) {
|
|
88
|
+
return Caller{}, Route{}, &Error{"session_required", "sign in to DSM again"}
|
|
89
|
+
}
|
|
90
|
+
if err != nil {
|
|
91
|
+
log.Printf("authentication unavailable: %v", err)
|
|
92
|
+
return Caller{}, Route{}, &Error{"auth_unavailable", "DSM authentication is unavailable"}
|
|
93
|
+
}
|
|
94
|
+
if !known {
|
|
95
|
+
return Caller{}, Route{}, &Error{"unknown_method", "unknown method"}
|
|
96
|
+
}
|
|
97
|
+
caller := Caller{identity: identity, authenticated: true}
|
|
98
|
+
if route.policy == policyAdmin && !caller.Admin() {
|
|
99
|
+
return Caller{}, Route{}, &Error{"admin_required", "this action needs a DSM administrator"}
|
|
100
|
+
}
|
|
101
|
+
return caller, route, nil
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func readRequest(w http.ResponseWriter, r *http.Request) (request, *Error) {
|
|
105
|
+
var req request
|
|
106
|
+
if r.Method != http.MethodPost {
|
|
107
|
+
return req, &Error{"method_not_allowed", "use POST"}
|
|
108
|
+
}
|
|
109
|
+
if mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mediaType != "application/json" {
|
|
110
|
+
return req, &Error{"json_required", "send Content-Type: application/json"}
|
|
111
|
+
}
|
|
112
|
+
if !sameOrigin(r) {
|
|
113
|
+
return req, &Error{"cross_origin", "cross-origin request refused"}
|
|
114
|
+
}
|
|
115
|
+
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MaxRequestBytes))
|
|
116
|
+
if err != nil {
|
|
117
|
+
return req, &Error{"request_too_large", "request body too large"}
|
|
118
|
+
}
|
|
119
|
+
dec := json.NewDecoder(bytes.NewReader(body))
|
|
120
|
+
dec.DisallowUnknownFields()
|
|
121
|
+
if err := dec.Decode(&req); err != nil || dec.More() || req.Method == "" {
|
|
122
|
+
return req, BadRequest(`body must be {"method": "...", "params": {...}}`)
|
|
123
|
+
}
|
|
124
|
+
return req, nil
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// sameOrigin refuses browser requests another site initiated. The JSON
|
|
128
|
+
// content type already forces a CORS preflight, which DSM never answers; these
|
|
129
|
+
// checks add an explicit refusal when the browser reports the origin.
|
|
130
|
+
func sameOrigin(r *http.Request) bool {
|
|
131
|
+
if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
if origin := r.Header.Get("Origin"); origin != "" {
|
|
135
|
+
u, err := url.Parse(origin)
|
|
136
|
+
return err == nil && u.Host == r.Host
|
|
137
|
+
}
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func writeJSON(w http.ResponseWriter, status int, body any) {
|
|
142
|
+
h := w.Header()
|
|
143
|
+
h.Set("Content-Type", "application/json")
|
|
144
|
+
h.Set("Cache-Control", "no-store")
|
|
145
|
+
h.Set("X-Content-Type-Options", "nosniff")
|
|
146
|
+
w.WriteHeader(status)
|
|
147
|
+
_ = json.NewEncoder(w).Encode(body)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func writeError(w http.ResponseWriter, e *Error) {
|
|
151
|
+
writeJSON(w, http.StatusOK, map[string]any{
|
|
152
|
+
"success": false,
|
|
153
|
+
"error": map[string]string{"code": e.Code, "message": e.Message},
|
|
154
|
+
})
|
|
155
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import "dsmapp/internal/api"
|
|
4
|
+
|
|
5
|
+
// rootRoutes is empty: this package declares privilege: none and never runs
|
|
6
|
+
// anything as root. `synopkgland init -t native-go --privilege root` creates
|
|
7
|
+
// root.go instead, which reads the results of administrator-approved root
|
|
8
|
+
// operations.
|
|
9
|
+
func (Config) rootRoutes() []api.Route { return nil }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Package app holds this package's business operations. Add a route here and
|
|
2
|
+
// pick its policy: api.User (any signed-in DSM user), api.Admin (DSM
|
|
3
|
+
// administrators only) or, rarely, api.Public (no sign-in at all).
|
|
4
|
+
package app
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"bytes"
|
|
8
|
+
"context"
|
|
9
|
+
"encoding/json"
|
|
10
|
+
"errors"
|
|
11
|
+
"io/fs"
|
|
12
|
+
"os"
|
|
13
|
+
"path/filepath"
|
|
14
|
+
"runtime"
|
|
15
|
+
"time"
|
|
16
|
+
"unicode/utf8"
|
|
17
|
+
|
|
18
|
+
"dsmapp/internal/api"
|
|
19
|
+
"dsmapp/internal/helper"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// Config is fixed at build time and startup, never taken from a request.
|
|
23
|
+
type Config struct {
|
|
24
|
+
PackageID string
|
|
25
|
+
Version string
|
|
26
|
+
HelperInventory helper.Inventory
|
|
27
|
+
// DataDir is the package's persistent, package-owned directory
|
|
28
|
+
// (/var/packages/<package>/var). It survives upgrades.
|
|
29
|
+
DataDir string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func Routes(cfg Config) []api.Route {
|
|
33
|
+
routes := []api.Route{
|
|
34
|
+
api.User("integration.status", cfg.integrationStatus),
|
|
35
|
+
api.User("app.status", cfg.status),
|
|
36
|
+
api.User("greeting.get", cfg.getGreeting),
|
|
37
|
+
api.Admin("greeting.set", cfg.setGreeting),
|
|
38
|
+
}
|
|
39
|
+
return append(routes, cfg.rootRoutes()...)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func (cfg Config) integrationStatus(ctx context.Context, _ api.Caller, params json.RawMessage) (any, error) {
|
|
43
|
+
if err := decodeParams(params, &struct{}{}); err != nil {
|
|
44
|
+
return nil, err
|
|
45
|
+
}
|
|
46
|
+
return helper.NewClient(cfg.HelperInventory).Status(ctx), nil
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func (cfg Config) status(_ context.Context, caller api.Caller, _ json.RawMessage) (any, error) {
|
|
50
|
+
return map[string]any{
|
|
51
|
+
"package": cfg.PackageID,
|
|
52
|
+
"version": cfg.Version,
|
|
53
|
+
"user": caller.User(),
|
|
54
|
+
"admin": caller.Admin(),
|
|
55
|
+
"process": map[string]int{"uid": os.Getuid(), "gid": os.Getgid()},
|
|
56
|
+
"go_version": runtime.Version(),
|
|
57
|
+
"platform": runtime.GOOS + "/" + runtime.GOARCH,
|
|
58
|
+
"time": time.Now().UTC().Format(time.RFC3339),
|
|
59
|
+
}, nil
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type greeting struct {
|
|
63
|
+
Text string `json:"text"`
|
|
64
|
+
UpdatedBy string `json:"updated_by,omitempty"`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func (cfg Config) greetingPath() string { return filepath.Join(cfg.DataDir, "greeting.json") }
|
|
68
|
+
|
|
69
|
+
func (cfg Config) getGreeting(context.Context, api.Caller, json.RawMessage) (any, error) {
|
|
70
|
+
data, err := os.ReadFile(cfg.greetingPath())
|
|
71
|
+
if errors.Is(err, fs.ErrNotExist) {
|
|
72
|
+
return greeting{Text: "Hello"}, nil
|
|
73
|
+
}
|
|
74
|
+
if err != nil {
|
|
75
|
+
return nil, err
|
|
76
|
+
}
|
|
77
|
+
var g greeting
|
|
78
|
+
return g, json.Unmarshal(data, &g)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func (cfg Config) setGreeting(_ context.Context, caller api.Caller, params json.RawMessage) (any, error) {
|
|
82
|
+
var in struct {
|
|
83
|
+
Text string `json:"text"`
|
|
84
|
+
}
|
|
85
|
+
if err := decodeParams(params, &in); err != nil {
|
|
86
|
+
return nil, err
|
|
87
|
+
}
|
|
88
|
+
if n := utf8.RuneCountInString(in.Text); n < 1 || n > 200 {
|
|
89
|
+
return nil, api.BadRequest("text must be 1 to 200 characters")
|
|
90
|
+
}
|
|
91
|
+
g := greeting{Text: in.Text, UpdatedBy: caller.User()}
|
|
92
|
+
data, err := json.Marshal(g)
|
|
93
|
+
if err != nil {
|
|
94
|
+
return nil, err
|
|
95
|
+
}
|
|
96
|
+
return g, writeFileAtomic(cfg.greetingPath(), data)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// decodeParams decodes params strictly: unknown fields are an error.
|
|
100
|
+
func decodeParams(params json.RawMessage, into any) error {
|
|
101
|
+
if len(params) == 0 {
|
|
102
|
+
params = json.RawMessage(`{}`)
|
|
103
|
+
}
|
|
104
|
+
dec := json.NewDecoder(bytes.NewReader(params))
|
|
105
|
+
dec.DisallowUnknownFields()
|
|
106
|
+
if err := dec.Decode(into); err != nil {
|
|
107
|
+
return api.BadRequest("invalid params: " + err.Error())
|
|
108
|
+
}
|
|
109
|
+
return nil
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// writeFileAtomic replaces path in one rename, so readers never see half a file.
|
|
113
|
+
func writeFileAtomic(path string, data []byte) error {
|
|
114
|
+
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
|
|
115
|
+
if err != nil {
|
|
116
|
+
return err
|
|
117
|
+
}
|
|
118
|
+
defer os.Remove(tmp.Name())
|
|
119
|
+
if _, err := tmp.Write(data); err != nil {
|
|
120
|
+
tmp.Close()
|
|
121
|
+
return err
|
|
122
|
+
}
|
|
123
|
+
if err := tmp.Close(); err != nil {
|
|
124
|
+
return err
|
|
125
|
+
}
|
|
126
|
+
return os.Rename(tmp.Name(), path)
|
|
127
|
+
}
|