@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,49 @@
|
|
|
1
|
+
// Command app is the package backend. DSM runs it as a CGI (ui/cgi/api.cgi in
|
|
2
|
+
// the package) under the package account, once per request.
|
|
3
|
+
package main
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"fmt"
|
|
8
|
+
"log"
|
|
9
|
+
"net/http/cgi"
|
|
10
|
+
"os"
|
|
11
|
+
|
|
12
|
+
"dsmapp/internal/api"
|
|
13
|
+
"dsmapp/internal/app"
|
|
14
|
+
"dsmapp/internal/dsm"
|
|
15
|
+
"dsmapp/internal/helper"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// Set by `synopkgland build-spk` through -ldflags -X; never read from a request.
|
|
19
|
+
var (
|
|
20
|
+
packageID = "dev"
|
|
21
|
+
version = "0.0.0"
|
|
22
|
+
helperInventory = ""
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
func main() {
|
|
26
|
+
log.SetFlags(0)
|
|
27
|
+
log.SetPrefix(packageID + ": ")
|
|
28
|
+
if os.Getenv("GATEWAY_INTERFACE") == "" {
|
|
29
|
+
fmt.Fprintf(os.Stderr, "%s %s: this program runs as the package's DSM CGI; open the app from the DSM main menu.\n", packageID, version)
|
|
30
|
+
os.Exit(2)
|
|
31
|
+
}
|
|
32
|
+
cfg := app.Config{
|
|
33
|
+
PackageID: packageID,
|
|
34
|
+
Version: version,
|
|
35
|
+
DataDir: "/var/packages/" + packageID + "/var",
|
|
36
|
+
}
|
|
37
|
+
if helperInventory != "" {
|
|
38
|
+
if json.Unmarshal([]byte(helperInventory), &cfg.HelperInventory) != nil || cfg.HelperInventory.Package != packageID || cfg.HelperInventory.Version != version {
|
|
39
|
+
cfg.HelperInventory = helper.Inventory{Package: packageID}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
srv, err := api.NewServer(dsm.NewAuthenticator(), app.Routes(cfg)...)
|
|
43
|
+
if err != nil {
|
|
44
|
+
log.Fatal(err)
|
|
45
|
+
}
|
|
46
|
+
if err := cgi.Serve(srv); err != nil {
|
|
47
|
+
log.Fatal(err)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Built only when dsm-package.yaml explicitly opts into integration.
|
|
2
|
+
package main
|
|
3
|
+
|
|
4
|
+
import (
|
|
5
|
+
"context"
|
|
6
|
+
"dsmapp/internal/helper"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"fmt"
|
|
9
|
+
"os"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
var helperInventory string
|
|
13
|
+
|
|
14
|
+
func main() {
|
|
15
|
+
var i helper.Inventory
|
|
16
|
+
if len(os.Args) != 2 || json.Unmarshal([]byte(helperInventory), &i) != nil {
|
|
17
|
+
os.Exit(2)
|
|
18
|
+
}
|
|
19
|
+
if os.Args[1] == "check-stopped" || os.Args[1] == "diagnose" {
|
|
20
|
+
c, err := helper.LocalClient(i)
|
|
21
|
+
if err != nil {
|
|
22
|
+
os.Exit(1)
|
|
23
|
+
}
|
|
24
|
+
if os.Args[1] == "diagnose" {
|
|
25
|
+
json.NewEncoder(os.Stdout).Encode(c.Status(context.Background()))
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
if c.CheckStopped(context.Background()) != nil {
|
|
29
|
+
os.Exit(1)
|
|
30
|
+
}
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
if err := helper.RunLinux(i, os.Args[1]); err != nil {
|
|
34
|
+
fmt.Fprintln(os.Stderr, "helper lifecycle refused")
|
|
35
|
+
os.Exit(1)
|
|
36
|
+
}
|
|
37
|
+
fmt.Println(`{"success":true}`)
|
|
38
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package api
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"log"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// Error is an API error the browser may see. Any other error a handler
|
|
9
|
+
// returns is logged and reported only as "internal". Errors travel in the
|
|
10
|
+
// JSON envelope with HTTP 200, as DSM's own WebAPI does: DSM's web server
|
|
11
|
+
// replaces the body of a CGI's 403, 404 and 5xx replies with its own page.
|
|
12
|
+
type Error struct {
|
|
13
|
+
Code string
|
|
14
|
+
Message string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func (e *Error) Error() string { return e.Code + ": " + e.Message }
|
|
18
|
+
|
|
19
|
+
// BadRequest reports invalid params.
|
|
20
|
+
func BadRequest(message string) *Error {
|
|
21
|
+
return &Error{"bad_request", message}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
var internalError = &Error{"internal", "internal error"}
|
|
25
|
+
|
|
26
|
+
func asError(err error) *Error {
|
|
27
|
+
var apiErr *Error
|
|
28
|
+
if errors.As(err, &apiErr) {
|
|
29
|
+
return apiErr
|
|
30
|
+
}
|
|
31
|
+
log.Printf("handler error: %v", err)
|
|
32
|
+
return internalError
|
|
33
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Package api is the package's one HTTP entry point. Every request passes the
|
|
2
|
+
// same transport guards and, unless its route was explicitly declared Public,
|
|
3
|
+
// one DSM authentication before any handler runs. The policy is part of the
|
|
4
|
+
// route itself: a Route can only be made through User, Admin or Public, so a
|
|
5
|
+
// handler cannot be registered without saying who may call it.
|
|
6
|
+
package api
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"context"
|
|
10
|
+
"encoding/json"
|
|
11
|
+
"errors"
|
|
12
|
+
"fmt"
|
|
13
|
+
"regexp"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
type policy int
|
|
17
|
+
|
|
18
|
+
const (
|
|
19
|
+
policyUnset policy = iota
|
|
20
|
+
policyUser
|
|
21
|
+
policyAdmin
|
|
22
|
+
policyPublic
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// Handler runs one business operation. It receives the caller the server
|
|
26
|
+
// verified and the raw `params` object; it decodes params itself.
|
|
27
|
+
type Handler func(ctx context.Context, caller Caller, params json.RawMessage) (any, error)
|
|
28
|
+
|
|
29
|
+
// Route binds a method name to its handler and access policy. Its fields are
|
|
30
|
+
// private: build routes with User, Admin or Public.
|
|
31
|
+
type Route struct {
|
|
32
|
+
method string
|
|
33
|
+
policy policy
|
|
34
|
+
handler Handler
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// User declares an operation any signed-in DSM user may call.
|
|
38
|
+
func User(method string, h Handler) Route { return Route{method, policyUser, h} }
|
|
39
|
+
|
|
40
|
+
// Admin declares an operation only members of DSM's administrators group may call.
|
|
41
|
+
func Admin(method string, h Handler) Route { return Route{method, policyAdmin, h} }
|
|
42
|
+
|
|
43
|
+
// Public declares an operation that runs WITHOUT a DSM session. Use it only for
|
|
44
|
+
// data that is safe to show anyone who can reach the NAS's DSM port.
|
|
45
|
+
func Public(method string, h Handler) Route { return Route{method, policyPublic, h} }
|
|
46
|
+
|
|
47
|
+
// Identity is what an Authenticator established about the requester.
|
|
48
|
+
type Identity struct {
|
|
49
|
+
User string
|
|
50
|
+
Admin bool
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Caller is the verified requester handed to handlers. Only the server sets
|
|
54
|
+
// its fields, so a handler cannot fabricate an authenticated caller; the zero
|
|
55
|
+
// value carries no authority.
|
|
56
|
+
type Caller struct {
|
|
57
|
+
identity Identity
|
|
58
|
+
authenticated bool
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func (c Caller) Authenticated() bool { return c.authenticated }
|
|
62
|
+
func (c Caller) User() string { return c.identity.User }
|
|
63
|
+
func (c Caller) Admin() bool { return c.authenticated && c.identity.Admin }
|
|
64
|
+
|
|
65
|
+
var methodName = regexp.MustCompile(`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$`)
|
|
66
|
+
|
|
67
|
+
func buildRegistry(routes []Route) (map[string]Route, error) {
|
|
68
|
+
registry := make(map[string]Route, len(routes))
|
|
69
|
+
for _, r := range routes {
|
|
70
|
+
if r.policy == policyUnset {
|
|
71
|
+
return nil, errors.New("route without access policy: build it with api.User, api.Admin or api.Public")
|
|
72
|
+
}
|
|
73
|
+
if !methodName.MatchString(r.method) {
|
|
74
|
+
return nil, fmt.Errorf("route %q: method must look like \"area.action\" (lowercase)", r.method)
|
|
75
|
+
}
|
|
76
|
+
if r.handler == nil {
|
|
77
|
+
return nil, fmt.Errorf("route %q has no handler", r.method)
|
|
78
|
+
}
|
|
79
|
+
if _, dup := registry[r.method]; dup {
|
|
80
|
+
return nil, fmt.Errorf("route %q is registered twice", r.method)
|
|
81
|
+
}
|
|
82
|
+
registry[r.method] = r
|
|
83
|
+
}
|
|
84
|
+
return registry, nil
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Method is the route's method name, e.g. "greeting.set".
|
|
88
|
+
func (r Route) Method() string { return r.method }
|
|
89
|
+
|
|
90
|
+
// Public reports whether the route was declared with Public.
|
|
91
|
+
func (r Route) Public() bool { return r.policy == policyPublic }
|
|
92
|
+
|
|
93
|
+
// AdminOnly reports whether the route was declared with Admin.
|
|
94
|
+
func (r Route) AdminOnly() bool { return r.policy == policyAdmin }
|
|
@@ -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
|
+
}
|