@synopackageland/cli 0.3.9 → 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 +57 -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/port-preflight.d.ts +21 -0
- package/dist/deploy/port-preflight.js +42 -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 -857
- package/dist/host/dsm-client.js +23 -263
- package/dist/host/dsm-port-profile.d.ts +47 -0
- package/dist/host/dsm-port-profile.js +206 -0
- package/dist/host/port-info-lookup.d.ts +9 -0
- package/dist/host/port-info-lookup.js +87 -0
- package/dist/host/port-info.d.ts +33 -0
- package/dist/host/port-info.js +66 -0
- package/dist/host/types.d.ts +6 -78
- 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 -721
- 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 -41
- package/dist/host/fake-nas.js +0 -174
- 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,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
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"net/http"
|
|
6
|
+
"net/http/httptest"
|
|
7
|
+
"os"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
|
|
11
|
+
"dsmapp/internal/api"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type stubAuth struct {
|
|
15
|
+
identity api.Identity
|
|
16
|
+
err error
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func (s stubAuth) Authenticate(*http.Request) (api.Identity, error) { return s.identity, s.err }
|
|
20
|
+
|
|
21
|
+
func testConfig(t *testing.T) Config {
|
|
22
|
+
return Config{PackageID: "HelloGo", Version: "0.1.0-0001", DataDir: t.TempDir()}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// call returns the envelope's error code ("" on success) and the envelope.
|
|
26
|
+
func call(t *testing.T, cfg Config, auth api.Authenticator, method, params string) (string, map[string]any) {
|
|
27
|
+
t.Helper()
|
|
28
|
+
srv, err := api.NewServer(auth, Routes(cfg)...)
|
|
29
|
+
if err != nil {
|
|
30
|
+
t.Fatal(err)
|
|
31
|
+
}
|
|
32
|
+
body := `{"method":"` + method + `","params":` + params + `}`
|
|
33
|
+
req := httptest.NewRequest(http.MethodPost, "/api.cgi", strings.NewReader(body))
|
|
34
|
+
req.Header.Set("Content-Type", "application/json")
|
|
35
|
+
rec := httptest.NewRecorder()
|
|
36
|
+
srv.ServeHTTP(rec, req)
|
|
37
|
+
var out map[string]any
|
|
38
|
+
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
39
|
+
t.Fatalf("not JSON: %s", rec.Body.String())
|
|
40
|
+
}
|
|
41
|
+
if rec.Code != http.StatusOK {
|
|
42
|
+
t.Fatalf("HTTP %d: DSM replaces non-200 CGI bodies", rec.Code)
|
|
43
|
+
}
|
|
44
|
+
code := ""
|
|
45
|
+
if e, ok := out["error"].(map[string]any); ok {
|
|
46
|
+
code, _ = e["code"].(string)
|
|
47
|
+
}
|
|
48
|
+
return code, out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
var (
|
|
52
|
+
alice = stubAuth{identity: api.Identity{User: "alice", Admin: true}}
|
|
53
|
+
bob = stubAuth{identity: api.Identity{User: "bob"}}
|
|
54
|
+
nobody = stubAuth{err: api.ErrNoSession}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// sampleParams holds valid params for every route, so the denial test below
|
|
58
|
+
// proves the refusal came from the policy and not from bad input.
|
|
59
|
+
var sampleParams = map[string]string{
|
|
60
|
+
"integration.status": `{}`,
|
|
61
|
+
"app.status": `{}`,
|
|
62
|
+
"greeting.get": `{}`,
|
|
63
|
+
"greeting.set": `{"text":"Hi"}`,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func TestIntegrationStatusIsReadableByOrdinaryUsers(t *testing.T) {
|
|
67
|
+
code, out := call(t, testConfig(t), bob, "integration.status", `{}`)
|
|
68
|
+
if code != "" || out["data"].(map[string]any)["state"] != "not-enabled" {
|
|
69
|
+
t.Fatalf("%s: %v", code, out)
|
|
70
|
+
}
|
|
71
|
+
code, _ = call(t, testConfig(t), bob, "integration.status", `{"state":"ready"}`)
|
|
72
|
+
if code != "bad_request" {
|
|
73
|
+
t.Fatal("browser cannot supply readiness")
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func TestEveryRouteHasSampleParamsAndNoneIsPublic(t *testing.T) {
|
|
78
|
+
routes := Routes(testConfig(t))
|
|
79
|
+
if len(routes) != len(sampleParams) {
|
|
80
|
+
t.Fatalf("%d routes but %d sample params: add the new route to sampleParams", len(routes), len(sampleParams))
|
|
81
|
+
}
|
|
82
|
+
for _, r := range routes {
|
|
83
|
+
if _, ok := sampleParams[r.Method()]; !ok {
|
|
84
|
+
t.Errorf("route %s has no sample params", r.Method())
|
|
85
|
+
}
|
|
86
|
+
if r.Public() {
|
|
87
|
+
t.Errorf("route %s is public; this template ships none", r.Method())
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func TestEveryRouteRefusesCallersWithoutASessionAndChangesNothing(t *testing.T) {
|
|
93
|
+
for _, r := range Routes(testConfig(t)) {
|
|
94
|
+
cfg := testConfig(t)
|
|
95
|
+
code, _ := call(t, cfg, nobody, r.Method(), sampleParams[r.Method()])
|
|
96
|
+
if code != "session_required" {
|
|
97
|
+
t.Errorf("%s: %q, want session_required", r.Method(), code)
|
|
98
|
+
}
|
|
99
|
+
assertDataDirEmpty(t, cfg)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func TestEveryAdminRouteRefusesOrdinaryUsersAndChangesNothing(t *testing.T) {
|
|
104
|
+
for _, r := range Routes(testConfig(t)) {
|
|
105
|
+
if !r.AdminOnly() {
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
cfg := testConfig(t)
|
|
109
|
+
code, _ := call(t, cfg, bob, r.Method(), sampleParams[r.Method()])
|
|
110
|
+
if code != "admin_required" {
|
|
111
|
+
t.Errorf("%s: %q, want admin_required", r.Method(), code)
|
|
112
|
+
}
|
|
113
|
+
assertDataDirEmpty(t, cfg)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func TestEveryRouteSucceedsForAnAdmin(t *testing.T) {
|
|
118
|
+
for _, r := range Routes(testConfig(t)) {
|
|
119
|
+
code, out := call(t, testConfig(t), alice, r.Method(), sampleParams[r.Method()])
|
|
120
|
+
if code != "" {
|
|
121
|
+
t.Errorf("%s: %q %v", r.Method(), code, out)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func TestStatusReportsPackageAndCaller(t *testing.T) {
|
|
127
|
+
code, out := call(t, testConfig(t), bob, "app.status", `{}`)
|
|
128
|
+
data := out["data"].(map[string]any)
|
|
129
|
+
if code != "" || data["package"] != "HelloGo" || data["version"] != "0.1.0-0001" || data["user"] != "bob" || data["admin"] != false {
|
|
130
|
+
t.Fatalf("got %q %v", code, out)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func TestGreetingDefaultsAndAdminCanChangeIt(t *testing.T) {
|
|
135
|
+
cfg := testConfig(t)
|
|
136
|
+
_, out := call(t, cfg, bob, "greeting.get", `{}`)
|
|
137
|
+
if got := out["data"].(map[string]any)["text"]; got != "Hello" {
|
|
138
|
+
t.Fatalf("default greeting = %v", got)
|
|
139
|
+
}
|
|
140
|
+
if code, out := call(t, cfg, alice, "greeting.set", `{"text":"Hi from DSM"}`); code != "" {
|
|
141
|
+
t.Fatalf("set: %q %v", code, out)
|
|
142
|
+
}
|
|
143
|
+
_, out = call(t, cfg, bob, "greeting.get", `{}`)
|
|
144
|
+
data := out["data"].(map[string]any)
|
|
145
|
+
if data["text"] != "Hi from DSM" || data["updated_by"] != "alice" {
|
|
146
|
+
t.Fatalf("after set: %v", data)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func TestGreetingSetValidatesText(t *testing.T) {
|
|
151
|
+
for _, params := range []string{`{}`, `{"text":""}`, `{"text":"` + strings.Repeat("x", 201) + `"}`, `{"text":7}`, `{"text":"ok","extra":1}`} {
|
|
152
|
+
cfg := testConfig(t)
|
|
153
|
+
code, _ := call(t, cfg, alice, "greeting.set", params)
|
|
154
|
+
if code != "bad_request" {
|
|
155
|
+
t.Errorf("%s: %q, want bad_request", params, code)
|
|
156
|
+
}
|
|
157
|
+
assertDataDirEmpty(t, cfg)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func assertDataDirEmpty(t *testing.T, cfg Config) {
|
|
162
|
+
t.Helper()
|
|
163
|
+
entries, err := os.ReadDir(cfg.DataDir)
|
|
164
|
+
if err != nil || len(entries) != 0 {
|
|
165
|
+
t.Fatalf("data dir changed: %v %v", entries, err)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Package dsm verifies the DSM login behind a package CGI request.
|
|
2
|
+
//
|
|
3
|
+
// DSM runs a package's CGI as the package account and forwards the browser's
|
|
4
|
+
// cookies. This package hands only the session carriers (cookie, SynoToken,
|
|
5
|
+
// client address) to DSM's own authenticate.cgi, which prints the signed-in
|
|
6
|
+
// user name, then reads administrators membership from DSM's group file. It
|
|
7
|
+
// never parses the session cookie or trusts a user name from the request.
|
|
8
|
+
package dsm
|
|
9
|
+
|
|
10
|
+
import (
|
|
11
|
+
"bufio"
|
|
12
|
+
"context"
|
|
13
|
+
"errors"
|
|
14
|
+
"fmt"
|
|
15
|
+
"net"
|
|
16
|
+
"net/http"
|
|
17
|
+
"net/url"
|
|
18
|
+
"os"
|
|
19
|
+
"os/exec"
|
|
20
|
+
"regexp"
|
|
21
|
+
"slices"
|
|
22
|
+
"strings"
|
|
23
|
+
"time"
|
|
24
|
+
|
|
25
|
+
"dsmapp/internal/api"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
// AuthenticateCGI is DSM's session check for third-party CGIs.
|
|
29
|
+
const AuthenticateCGI = "/usr/syno/synoman/webman/modules/authenticate.cgi"
|
|
30
|
+
|
|
31
|
+
// Authenticator implements api.Authenticator against DSM.
|
|
32
|
+
type Authenticator struct {
|
|
33
|
+
GroupFile string
|
|
34
|
+
AdminGroup string
|
|
35
|
+
Timeout time.Duration
|
|
36
|
+
run func(ctx context.Context, path string, env []string) ([]byte, error)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func NewAuthenticator() *Authenticator {
|
|
40
|
+
return &Authenticator{
|
|
41
|
+
GroupFile: "/etc/group",
|
|
42
|
+
AdminGroup: "administrators",
|
|
43
|
+
Timeout: 10 * time.Second,
|
|
44
|
+
run: runCGI,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
var userName = regexp.MustCompile(`^[A-Za-z0-9._@\\-]{1,64}$`)
|
|
49
|
+
|
|
50
|
+
func (a *Authenticator) Authenticate(r *http.Request) (api.Identity, error) {
|
|
51
|
+
cookie := r.Header.Get("Cookie")
|
|
52
|
+
if cookie == "" {
|
|
53
|
+
return api.Identity{}, api.ErrNoSession
|
|
54
|
+
}
|
|
55
|
+
ctx, cancel := context.WithTimeout(r.Context(), a.Timeout)
|
|
56
|
+
defer cancel()
|
|
57
|
+
out, err := a.run(ctx, AuthenticateCGI, sessionEnv(r, cookie))
|
|
58
|
+
var exit interface{ ExitCode() int }
|
|
59
|
+
if errors.As(err, &exit) {
|
|
60
|
+
return api.Identity{}, api.ErrNoSession
|
|
61
|
+
}
|
|
62
|
+
if err != nil {
|
|
63
|
+
return api.Identity{}, fmt.Errorf("run %s: %w", AuthenticateCGI, err)
|
|
64
|
+
}
|
|
65
|
+
user := strings.TrimSpace(string(out))
|
|
66
|
+
if !userName.MatchString(user) {
|
|
67
|
+
return api.Identity{}, api.ErrNoSession
|
|
68
|
+
}
|
|
69
|
+
admin, err := a.isMember(user)
|
|
70
|
+
if err != nil {
|
|
71
|
+
return api.Identity{}, err
|
|
72
|
+
}
|
|
73
|
+
return api.Identity{User: user, Admin: admin}, nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// sessionEnv is the complete environment authenticate.cgi sees: nothing from
|
|
77
|
+
// this process and no request header besides the session carriers.
|
|
78
|
+
func sessionEnv(r *http.Request, cookie string) []string {
|
|
79
|
+
token := r.Header.Get("X-SYNO-TOKEN")
|
|
80
|
+
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
81
|
+
if err != nil {
|
|
82
|
+
host = r.RemoteAddr
|
|
83
|
+
}
|
|
84
|
+
return []string{
|
|
85
|
+
"REQUEST_METHOD=GET",
|
|
86
|
+
"HTTP_COOKIE=" + cookie,
|
|
87
|
+
"HTTP_X_SYNO_TOKEN=" + token,
|
|
88
|
+
"QUERY_STRING=SynoToken=" + url.QueryEscape(token),
|
|
89
|
+
"REMOTE_ADDR=" + host,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func runCGI(ctx context.Context, path string, env []string) ([]byte, error) {
|
|
94
|
+
cmd := exec.CommandContext(ctx, path)
|
|
95
|
+
cmd.Env = env
|
|
96
|
+
return cmd.Output()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// isMember reads the group file fresh on every request, so removing someone
|
|
100
|
+
// from administrators takes effect immediately.
|
|
101
|
+
func (a *Authenticator) isMember(user string) (bool, error) {
|
|
102
|
+
f, err := os.Open(a.GroupFile)
|
|
103
|
+
if err != nil {
|
|
104
|
+
return false, fmt.Errorf("read groups: %w", err)
|
|
105
|
+
}
|
|
106
|
+
defer f.Close()
|
|
107
|
+
scanner := bufio.NewScanner(f)
|
|
108
|
+
for scanner.Scan() {
|
|
109
|
+
fields := strings.Split(scanner.Text(), ":")
|
|
110
|
+
if len(fields) == 4 && fields[0] == a.AdminGroup {
|
|
111
|
+
return slices.Contains(strings.Split(fields[3], ","), user), nil
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if err := scanner.Err(); err != nil {
|
|
115
|
+
return false, fmt.Errorf("read groups: %w", err)
|
|
116
|
+
}
|
|
117
|
+
return false, nil
|
|
118
|
+
}
|