@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.
Files changed (286) hide show
  1. package/README.md +20 -9
  2. package/dist/bin.js +1 -2
  3. package/dist/build-spk/generator.d.ts +0 -3
  4. package/dist/build-spk/generator.js +135 -287
  5. package/dist/build-spk/native-lifecycle.js +0 -3
  6. package/dist/build-spk/native-payload.js +0 -1
  7. package/dist/cli.js +171 -151
  8. package/dist/compose/analyze.d.ts +1 -2
  9. package/dist/compose/analyze.js +5 -48
  10. package/dist/contract/unsupported-types.d.ts +10 -0
  11. package/dist/contract/unsupported-types.js +49 -0
  12. package/dist/contract/validate.js +25 -2
  13. package/dist/contract/web-launch.js +0 -2
  14. package/dist/deploy/deploy.d.ts +1 -4
  15. package/dist/deploy/deploy.js +57 -156
  16. package/dist/deploy/homes-values.js +1 -1
  17. package/dist/deploy/plan.d.ts +18 -0
  18. package/dist/deploy/plan.js +54 -0
  19. package/dist/deploy/port-preflight.d.ts +21 -0
  20. package/dist/deploy/port-preflight.js +42 -0
  21. package/dist/deploy/spk-version.d.ts +2 -14
  22. package/dist/deploy/spk-version.js +25 -111
  23. package/dist/dev.d.ts +1 -3
  24. package/dist/dev.js +14 -28
  25. package/dist/error-registry.js +810 -857
  26. package/dist/host/dsm-client.js +23 -263
  27. package/dist/host/dsm-port-profile.d.ts +47 -0
  28. package/dist/host/dsm-port-profile.js +206 -0
  29. package/dist/host/port-info-lookup.d.ts +9 -0
  30. package/dist/host/port-info-lookup.js +87 -0
  31. package/dist/host/port-info.d.ts +33 -0
  32. package/dist/host/port-info.js +66 -0
  33. package/dist/host/types.d.ts +6 -78
  34. package/dist/index.d.ts +0 -9
  35. package/dist/index.js +0 -6
  36. package/dist/info.d.ts +0 -1
  37. package/dist/info.js +3 -10
  38. package/dist/init.d.ts +11 -1
  39. package/dist/init.js +112 -4
  40. package/dist/log.js +2 -3
  41. package/dist/native/validate.js +0 -1
  42. package/dist/standalone/binary.d.ts +9 -0
  43. package/dist/standalone/binary.js +67 -0
  44. package/dist/standalone/build.d.ts +25 -0
  45. package/dist/standalone/build.js +108 -0
  46. package/dist/standalone/dsm-window.d.ts +26 -0
  47. package/dist/standalone/dsm-window.js +251 -0
  48. package/dist/standalone/helper.d.ts +31 -0
  49. package/dist/standalone/helper.js +104 -0
  50. package/dist/standalone/i18n.d.ts +28 -0
  51. package/dist/standalone/i18n.js +161 -0
  52. package/dist/standalone/inspect-spk.d.ts +21 -0
  53. package/dist/standalone/inspect-spk.js +160 -0
  54. package/dist/standalone/manifest.d.ts +71 -0
  55. package/dist/standalone/manifest.js +276 -0
  56. package/dist/standalone/root-messages.d.ts +7 -0
  57. package/dist/standalone/root-messages.js +250 -0
  58. package/dist/standalone/root-ops.d.ts +42 -0
  59. package/dist/standalone/root-ops.js +101 -0
  60. package/dist/standalone/root-window.d.ts +35 -0
  61. package/dist/standalone/root-window.js +290 -0
  62. package/dist/standalone/spk.d.ts +22 -0
  63. package/dist/standalone/spk.js +205 -0
  64. package/dist/templates/binary/synology-app.yaml +7 -0
  65. package/dist/templates/hello-files/compose.yaml +0 -1
  66. package/dist/templates/hello-web/compose.yaml +13 -10
  67. package/dist/templates/hello-web/html/app.js +1 -23
  68. package/dist/templates/hello-web/html/index.html +1 -3
  69. package/dist/templates/hello-web-page/compose.yaml +13 -10
  70. package/dist/templates/hello-web-page/html/app.js +1 -23
  71. package/dist/templates/hello-web-page/html/index.html +1 -3
  72. package/dist/templates/native-go/README.md +268 -0
  73. package/dist/templates/native-go/backend/cmd/app/main.go +49 -0
  74. package/dist/templates/native-go/backend/cmd/helper/main.go +38 -0
  75. package/dist/templates/native-go/backend/go.mod +3 -0
  76. package/dist/templates/native-go/backend/internal/api/errors.go +33 -0
  77. package/dist/templates/native-go/backend/internal/api/registry.go +94 -0
  78. package/dist/templates/native-go/backend/internal/api/registry_test.go +329 -0
  79. package/dist/templates/native-go/backend/internal/api/server.go +155 -0
  80. package/dist/templates/native-go/backend/internal/app/root_none.go +9 -0
  81. package/dist/templates/native-go/backend/internal/app/routes.go +127 -0
  82. package/dist/templates/native-go/backend/internal/app/routes_test.go +167 -0
  83. package/dist/templates/native-go/backend/internal/dsm/auth.go +118 -0
  84. package/dist/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
  85. package/dist/templates/native-go/backend/internal/helper/control.go +219 -0
  86. package/dist/templates/native-go/backend/internal/helper/control_test.go +242 -0
  87. package/dist/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
  88. package/dist/templates/native-go/backend/internal/helper/peer_other.go +7 -0
  89. package/dist/templates/native-go/backend/internal/helper/process_linux.go +167 -0
  90. package/dist/templates/native-go/backend/internal/helper/process_other.go +5 -0
  91. package/dist/templates/native-go/backend/internal/helper/server_linux.go +83 -0
  92. package/dist/templates/native-go/backend/internal/helper/status.go +271 -0
  93. package/dist/templates/native-go/backend/internal/helper/status_test.go +205 -0
  94. package/dist/templates/native-go/backend/internal/helper/supervision.go +84 -0
  95. package/dist/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
  96. package/dist/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
  97. package/dist/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
  98. package/dist/templates/native-go/dsm-package.yaml +58 -0
  99. package/dist/templates/native-go/gitignore +1 -0
  100. package/dist/templates/native-go/i18n/glossary.json +662 -0
  101. package/dist/templates/native-go/i18n/messages/de.json +28 -0
  102. package/dist/templates/native-go/i18n/messages/en.json +28 -0
  103. package/dist/templates/native-go/i18n/messages/es.json +28 -0
  104. package/dist/templates/native-go/i18n/messages/fr.json +28 -0
  105. package/dist/templates/native-go/i18n/messages/it.json +28 -0
  106. package/dist/templates/native-go/i18n/messages/ja.json +28 -0
  107. package/dist/templates/native-go/i18n/messages/ko.json +28 -0
  108. package/dist/templates/native-go/i18n/messages/zh-CN.json +28 -0
  109. package/dist/templates/native-go/i18n/messages/zh-TW.json +28 -0
  110. package/dist/templates/native-go/ui/app.css +35 -0
  111. package/dist/templates/native-go/ui/app.js +94 -0
  112. package/dist/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
  113. package/dist/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
  114. package/dist/templates/native-go-better-auth/node/app.mjs +72 -0
  115. package/dist/templates/native-go-better-auth/node/build.mjs +2 -0
  116. package/dist/templates/native-go-better-auth/node/package-lock.json +862 -0
  117. package/dist/templates/native-go-better-auth/node/package.json +1 -0
  118. package/dist/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
  119. package/dist/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
  120. package/dist/templates/native-go-integration/backend/internal/app/personal.go +203 -0
  121. package/dist/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
  122. package/dist/templates/native-go-root/backend/internal/app/root.go +107 -0
  123. package/dist/templates/native-go-root/backend/internal/app/root_test.go +124 -0
  124. package/dist/templates/native-go-root/root/whoami.sh +6 -0
  125. package/dist/templates/native-go-vue/README.md +110 -0
  126. package/dist/templates/native-go-vue/frontend/DESIGN.md +78 -0
  127. package/dist/templates/native-go-vue/frontend/package-lock.json +2507 -0
  128. package/dist/templates/native-go-vue/frontend/package.json +6 -0
  129. package/dist/templates/native-go-vue/frontend/src/App.vue +132 -0
  130. package/dist/templates/native-go-vue/frontend/src/context.ts +17 -0
  131. package/dist/templates/native-go-vue/frontend/src/main.ts +26 -0
  132. package/dist/templates/native-go-vue/frontend/src/style.css +14 -0
  133. package/dist/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
  134. package/dist/templates/native-go-vue/frontend/tsconfig.json +1 -0
  135. package/dist/templates/native-go-vue/frontend/vite.config.ts +8 -0
  136. package/dist/templates/native-go-vue/gitignore +2 -0
  137. package/dist/types.d.ts +1 -1
  138. package/dist/validate.js +11 -7
  139. package/package.json +3 -7
  140. package/schema/synology-app.schema.json +36 -37
  141. package/skills/SKILL.md +4 -48
  142. package/skills/reference/create.md +11 -83
  143. package/skills/reference/deploy.md +7 -78
  144. package/skills/reference/diagnose.md +4 -101
  145. package/skills/reference/files.md +5 -41
  146. package/skills/reference/identity.md +3 -29
  147. package/skills/reference/wrap.md +0 -2
  148. package/templates/binary/synology-app.yaml +7 -0
  149. package/templates/hello-files/compose.yaml +0 -1
  150. package/templates/hello-web/compose.yaml +13 -10
  151. package/templates/hello-web/html/app.js +1 -23
  152. package/templates/hello-web/html/index.html +1 -3
  153. package/templates/hello-web-page/compose.yaml +13 -10
  154. package/templates/hello-web-page/html/app.js +1 -23
  155. package/templates/hello-web-page/html/index.html +1 -3
  156. package/templates/native-go/README.md +268 -0
  157. package/templates/native-go/backend/cmd/app/main.go +49 -0
  158. package/templates/native-go/backend/cmd/helper/main.go +38 -0
  159. package/templates/native-go/backend/go.mod +3 -0
  160. package/templates/native-go/backend/internal/api/errors.go +33 -0
  161. package/templates/native-go/backend/internal/api/registry.go +94 -0
  162. package/templates/native-go/backend/internal/api/registry_test.go +329 -0
  163. package/templates/native-go/backend/internal/api/server.go +155 -0
  164. package/templates/native-go/backend/internal/app/root_none.go +9 -0
  165. package/templates/native-go/backend/internal/app/routes.go +127 -0
  166. package/templates/native-go/backend/internal/app/routes_test.go +167 -0
  167. package/templates/native-go/backend/internal/dsm/auth.go +118 -0
  168. package/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
  169. package/templates/native-go/backend/internal/helper/control.go +219 -0
  170. package/templates/native-go/backend/internal/helper/control_test.go +242 -0
  171. package/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
  172. package/templates/native-go/backend/internal/helper/peer_other.go +7 -0
  173. package/templates/native-go/backend/internal/helper/process_linux.go +167 -0
  174. package/templates/native-go/backend/internal/helper/process_other.go +5 -0
  175. package/templates/native-go/backend/internal/helper/server_linux.go +83 -0
  176. package/templates/native-go/backend/internal/helper/status.go +271 -0
  177. package/templates/native-go/backend/internal/helper/status_test.go +205 -0
  178. package/templates/native-go/backend/internal/helper/supervision.go +84 -0
  179. package/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
  180. package/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
  181. package/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
  182. package/templates/native-go/dsm-package.yaml +58 -0
  183. package/templates/native-go/gitignore +1 -0
  184. package/templates/native-go/i18n/glossary.json +662 -0
  185. package/templates/native-go/i18n/messages/de.json +28 -0
  186. package/templates/native-go/i18n/messages/en.json +28 -0
  187. package/templates/native-go/i18n/messages/es.json +28 -0
  188. package/templates/native-go/i18n/messages/fr.json +28 -0
  189. package/templates/native-go/i18n/messages/it.json +28 -0
  190. package/templates/native-go/i18n/messages/ja.json +28 -0
  191. package/templates/native-go/i18n/messages/ko.json +28 -0
  192. package/templates/native-go/i18n/messages/zh-CN.json +28 -0
  193. package/templates/native-go/i18n/messages/zh-TW.json +28 -0
  194. package/templates/native-go/ui/app.css +35 -0
  195. package/templates/native-go/ui/app.js +94 -0
  196. package/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
  197. package/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
  198. package/templates/native-go-better-auth/node/app.mjs +72 -0
  199. package/templates/native-go-better-auth/node/build.mjs +2 -0
  200. package/templates/native-go-better-auth/node/package-lock.json +862 -0
  201. package/templates/native-go-better-auth/node/package.json +1 -0
  202. package/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
  203. package/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
  204. package/templates/native-go-integration/backend/internal/app/personal.go +203 -0
  205. package/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
  206. package/templates/native-go-root/backend/internal/app/root.go +107 -0
  207. package/templates/native-go-root/backend/internal/app/root_test.go +124 -0
  208. package/templates/native-go-root/root/whoami.sh +6 -0
  209. package/templates/native-go-vue/README.md +110 -0
  210. package/templates/native-go-vue/frontend/DESIGN.md +78 -0
  211. package/templates/native-go-vue/frontend/package-lock.json +2507 -0
  212. package/templates/native-go-vue/frontend/package.json +6 -0
  213. package/templates/native-go-vue/frontend/src/App.vue +132 -0
  214. package/templates/native-go-vue/frontend/src/context.ts +17 -0
  215. package/templates/native-go-vue/frontend/src/main.ts +26 -0
  216. package/templates/native-go-vue/frontend/src/style.css +14 -0
  217. package/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
  218. package/templates/native-go-vue/frontend/tsconfig.json +1 -0
  219. package/templates/native-go-vue/frontend/vite.config.ts +8 -0
  220. package/templates/native-go-vue/gitignore +2 -0
  221. package/dist/attestation/app-package.d.ts +0 -86
  222. package/dist/attestation/app-package.js +0 -206
  223. package/dist/attestation/install-on-host.d.ts +0 -14
  224. package/dist/attestation/install-on-host.js +0 -52
  225. package/dist/attestation/keyring.d.ts +0 -21
  226. package/dist/attestation/keyring.js +0 -168
  227. package/dist/attestation/platform-service.d.ts +0 -70
  228. package/dist/attestation/platform-service.js +0 -250
  229. package/dist/build-spk/broker-glue.d.ts +0 -16
  230. package/dist/build-spk/broker-glue.js +0 -40
  231. package/dist/deploy/catalog-publish.d.ts +0 -3
  232. package/dist/deploy/catalog-publish.js +0 -7
  233. package/dist/doctor.d.ts +0 -22
  234. package/dist/doctor.js +0 -721
  235. package/dist/error-collector.d.ts +0 -2
  236. package/dist/error-collector.js +0 -46
  237. package/dist/host/broker-paths.d.ts +0 -1
  238. package/dist/host/broker-paths.js +0 -1
  239. package/dist/host/broker-upgrade-preflight.d.ts +0 -61
  240. package/dist/host/broker-upgrade-preflight.js +0 -156
  241. package/dist/host/digests.d.ts +0 -5
  242. package/dist/host/digests.js +0 -63
  243. package/dist/host/fake-nas.d.ts +0 -41
  244. package/dist/host/fake-nas.js +0 -174
  245. package/dist/mock-broker/bootstrap.d.ts +0 -2
  246. package/dist/mock-broker/bootstrap.js +0 -8
  247. package/dist/mock-broker/files.d.ts +0 -60
  248. package/dist/mock-broker/files.js +0 -408
  249. package/dist/mock-broker/oidc.d.ts +0 -31
  250. package/dist/mock-broker/oidc.js +0 -161
  251. package/dist/mock-broker/path.d.ts +0 -9
  252. package/dist/mock-broker/path.js +0 -50
  253. package/dist/mock-broker/server.d.ts +0 -19
  254. package/dist/mock-broker/server.js +0 -350
  255. package/dist/publish/build-ledger.d.ts +0 -22
  256. package/dist/publish/build-ledger.js +0 -26
  257. package/dist/publish/community-path.d.ts +0 -6
  258. package/dist/publish/community-path.js +0 -41
  259. package/dist/publish/curated-release.d.ts +0 -78
  260. package/dist/publish/curated-release.js +0 -883
  261. package/dist/publish/file-build-ledger.d.ts +0 -18
  262. package/dist/publish/file-build-ledger.js +0 -268
  263. package/dist/publish/github-pr.d.ts +0 -25
  264. package/dist/publish/github-pr.js +0 -85
  265. package/dist/publish/github-repo.d.ts +0 -8
  266. package/dist/publish/github-repo.js +0 -21
  267. package/dist/publish/platform-service-release.d.ts +0 -60
  268. package/dist/publish/platform-service-release.js +0 -333
  269. package/dist/publish/preflight.d.ts +0 -24
  270. package/dist/publish/preflight.js +0 -138
  271. package/dist/publish/publish.d.ts +0 -27
  272. package/dist/publish/publish.js +0 -136
  273. package/dist/publish/sync-definition.d.ts +0 -4
  274. package/dist/publish/sync-definition.js +0 -48
  275. package/dist/release-bin.d.ts +0 -2
  276. package/dist/release-bin.js +0 -23
  277. package/dist/templates/hello-web/html/sdk.js +0 -68
  278. package/dist/templates/hello-web-page/html/sdk.js +0 -68
  279. package/dist/templates/native/synology-app.yaml +0 -7
  280. package/dist/test-host.d.ts +0 -15
  281. package/dist/test-host.js +0 -37
  282. package/templates/hello-web/html/sdk.js +0 -68
  283. package/templates/hello-web-page/html/sdk.js +0 -68
  284. package/templates/native/synology-app.yaml +0 -7
  285. /package/dist/templates/{native → binary}/native.yaml +0 -0
  286. /package/templates/{native → binary}/native.yaml +0 -0
@@ -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
+ }
@@ -0,0 +1,139 @@
1
+ package dsm
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "os"
9
+ "path/filepath"
10
+ "slices"
11
+ "strings"
12
+ "testing"
13
+
14
+ "dsmapp/internal/api"
15
+ )
16
+
17
+ const groupFile = `root:x:0:
18
+ users:x:100:
19
+ administrators:x:101:admin,alice
20
+ http:x:1023:
21
+ `
22
+
23
+ type fakeRunner struct {
24
+ out string
25
+ err error
26
+ env []string
27
+ path string
28
+ }
29
+
30
+ func (f *fakeRunner) run(_ context.Context, path string, env []string) ([]byte, error) {
31
+ f.path, f.env = path, env
32
+ return []byte(f.out), f.err
33
+ }
34
+
35
+ func newTestAuth(t *testing.T, runner *fakeRunner) *Authenticator {
36
+ t.Helper()
37
+ groups := filepath.Join(t.TempDir(), "group")
38
+ if err := os.WriteFile(groups, []byte(groupFile), 0o644); err != nil {
39
+ t.Fatal(err)
40
+ }
41
+ a := NewAuthenticator()
42
+ a.GroupFile = groups
43
+ a.run = runner.run
44
+ return a
45
+ }
46
+
47
+ func sessionRequest() *http.Request {
48
+ r := httptest.NewRequest(http.MethodPost, "/webman/3rdparty/App/cgi/api.cgi", nil)
49
+ r.RemoteAddr = "192.0.2.7:51234"
50
+ r.Header.Set("Cookie", "id=abc; did=xyz")
51
+ r.Header.Set("X-SYNO-TOKEN", "tok+en/=")
52
+ r.Header.Set("User-Agent", "should-not-pass")
53
+ return r
54
+ }
55
+
56
+ func TestAdminUserIsResolvedFromAuthenticateCGIAndGroupFile(t *testing.T) {
57
+ runner := &fakeRunner{out: "alice\n"}
58
+ id, err := newTestAuth(t, runner).Authenticate(sessionRequest())
59
+ if err != nil || id.User != "alice" || !id.Admin {
60
+ t.Fatalf("got %+v, %v", id, err)
61
+ }
62
+ if runner.path != AuthenticateCGI {
63
+ t.Errorf("ran %q", runner.path)
64
+ }
65
+ }
66
+
67
+ func TestOrdinaryUserIsNotAdmin(t *testing.T) {
68
+ id, err := newTestAuth(t, &fakeRunner{out: "bob\n"}).Authenticate(sessionRequest())
69
+ if err != nil || id.User != "bob" || id.Admin {
70
+ t.Fatalf("got %+v, %v", id, err)
71
+ }
72
+ }
73
+
74
+ func TestOnlySessionCarriersReachAuthenticateCGI(t *testing.T) {
75
+ runner := &fakeRunner{out: "alice"}
76
+ if _, err := newTestAuth(t, runner).Authenticate(sessionRequest()); err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ want := []string{
80
+ "HTTP_COOKIE=id=abc; did=xyz",
81
+ "HTTP_X_SYNO_TOKEN=tok+en/=",
82
+ "QUERY_STRING=SynoToken=tok%2Ben%2F%3D",
83
+ "REMOTE_ADDR=192.0.2.7",
84
+ "REQUEST_METHOD=GET",
85
+ }
86
+ got := slices.Clone(runner.env)
87
+ slices.Sort(got)
88
+ if !slices.Equal(got, want) {
89
+ t.Fatalf("env = %q\nwant %q", got, want)
90
+ }
91
+ }
92
+
93
+ func TestMissingCookieNeverRunsAuthenticateCGI(t *testing.T) {
94
+ runner := &fakeRunner{out: "alice"}
95
+ r := sessionRequest()
96
+ r.Header.Del("Cookie")
97
+ _, err := newTestAuth(t, runner).Authenticate(r)
98
+ if !errors.Is(err, api.ErrNoSession) || runner.path != "" {
99
+ t.Fatalf("err=%v ran=%q", err, runner.path)
100
+ }
101
+ }
102
+
103
+ func TestRejectedOrMalformedSessionsAreNoSession(t *testing.T) {
104
+ for name, runner := range map[string]*fakeRunner{
105
+ "exit status": {err: &exitError{}},
106
+ "empty output": {out: "\n"},
107
+ "two lines": {out: "alice\nadmin\n"},
108
+ "html output": {out: "<html>login</html>"},
109
+ "overlong name": {out: strings.Repeat("a", 200)},
110
+ "space in name": {out: "alice admin"},
111
+ } {
112
+ t.Run(name, func(t *testing.T) {
113
+ _, err := newTestAuth(t, runner).Authenticate(sessionRequest())
114
+ if !errors.Is(err, api.ErrNoSession) {
115
+ t.Fatalf("err = %v", err)
116
+ }
117
+ })
118
+ }
119
+ }
120
+
121
+ func TestBrokenAuthenticatorIsUnavailableNotNoSession(t *testing.T) {
122
+ _, err := newTestAuth(t, &fakeRunner{err: errors.New("exec: not found")}).Authenticate(sessionRequest())
123
+ if err == nil || errors.Is(err, api.ErrNoSession) {
124
+ t.Fatalf("err = %v", err)
125
+ }
126
+ }
127
+
128
+ func TestUnreadableGroupFileFailsClosed(t *testing.T) {
129
+ a := newTestAuth(t, &fakeRunner{out: "alice"})
130
+ a.GroupFile = filepath.Join(t.TempDir(), "missing")
131
+ if _, err := a.Authenticate(sessionRequest()); err == nil || errors.Is(err, api.ErrNoSession) {
132
+ t.Fatalf("err = %v, want unavailable", err)
133
+ }
134
+ }
135
+
136
+ type exitError struct{}
137
+
138
+ func (*exitError) Error() string { return "exit status 1" }
139
+ func (*exitError) ExitCode() int { return 1 }
@@ -0,0 +1,219 @@
1
+ package helper
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "io"
8
+ "io/fs"
9
+ "net"
10
+ "os"
11
+ "path/filepath"
12
+ "syscall"
13
+ "time"
14
+ )
15
+
16
+ // Process identity rejects reboot/PID reuse and executable replacement.
17
+ // Linux Stop authenticates the owning supervisor socket; only that parent signals its child.
18
+ type Process struct {
19
+ Inventory Inventory `json:"inventory"`
20
+ Mode string `json:"mode,omitempty"` // must be supervised; old unsupervised records fail closed
21
+ PID int `json:"pid"`
22
+ Boot string `json:"boot"`
23
+ Start string `json:"start"`
24
+ Device uint64 `json:"device"`
25
+ Inode uint64 `json:"inode"`
26
+ }
27
+
28
+ type Processes interface {
29
+ Stop(Process) error
30
+ Start(Inventory) error
31
+ }
32
+ type Controller struct {
33
+ Client
34
+ Processes Processes
35
+ }
36
+
37
+ func waitHealthy(ctx context.Context, probe func(context.Context) bool) error {
38
+ for ctx.Err() == nil {
39
+ if probe(ctx) {
40
+ return nil
41
+ }
42
+ timer := time.NewTimer(20 * time.Millisecond)
43
+ select {
44
+ case <-ctx.Done():
45
+ timer.Stop()
46
+ return ctx.Err()
47
+ case <-timer.C:
48
+ }
49
+ }
50
+ return ctx.Err()
51
+ }
52
+
53
+ func writeProtected(dir, name string, value any) error {
54
+ b, err := json.Marshal(value)
55
+ if err != nil {
56
+ return err
57
+ }
58
+ f, err := os.CreateTemp(dir, ".transition-*")
59
+ if err != nil {
60
+ return err
61
+ }
62
+ defer os.Remove(f.Name())
63
+ mode := fs.FileMode(0644)
64
+ if name == "process.json" {
65
+ mode = 0600
66
+ }
67
+ if err := f.Chmod(mode); err != nil {
68
+ f.Close()
69
+ return err
70
+ }
71
+ if _, err := f.Write(b); err != nil {
72
+ f.Close()
73
+ return err
74
+ }
75
+ if err := f.Sync(); err != nil {
76
+ f.Close()
77
+ return err
78
+ }
79
+ if err := f.Close(); err != nil {
80
+ return err
81
+ }
82
+ if err := os.Rename(f.Name(), filepath.Join(dir, name)); err != nil {
83
+ return err
84
+ }
85
+ d, err := os.Open(dir)
86
+ if err != nil {
87
+ return err
88
+ }
89
+ defer d.Close()
90
+ return d.Sync()
91
+ }
92
+
93
+ func writeProcess(dir string, p Process) error { return writeProtected(dir, "process.json", p) }
94
+
95
+ func readProcess(dir string, owner uint32) (Process, error) {
96
+ var p Process
97
+ if err := protected(dir, owner, true); err != nil {
98
+ return p, err
99
+ }
100
+ path := filepath.Join(dir, "process.json")
101
+ if err := protected(path, owner, false); err != nil {
102
+ return p, err
103
+ }
104
+ f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
105
+ if err != nil {
106
+ return p, err
107
+ }
108
+ defer f.Close()
109
+ d := json.NewDecoder(io.LimitReader(f, 4097))
110
+ d.DisallowUnknownFields()
111
+ if d.Decode(&p) != nil || d.Decode(new(any)) != io.EOF || !p.Inventory.Valid() || p.Mode != "supervised" || p.PID <= 1 || p.Boot == "" || p.Start == "" || p.Inode == 0 {
112
+ return p, errTrust
113
+ }
114
+ return p, nil
115
+ }
116
+
117
+ // Control is called only by the verified root executable from a fixed DSM
118
+ // task. Package hooks and CGI handlers never call this function.
119
+ func (c Controller) Control(ctx context.Context, action string) error {
120
+ if ctx.Err() != nil {
121
+ return ctx.Err()
122
+ }
123
+ if action != "enable" && action != "recover" && action != "teardown" && action != "disable" {
124
+ return errors.New("unsupported lifecycle action")
125
+ }
126
+ if !c.Expected.Valid() || c.Processes == nil {
127
+ return errTrust
128
+ }
129
+ if err := protected(c.StateDir, c.Owner, true); err != nil {
130
+ return err
131
+ }
132
+ f, err := os.OpenFile(filepath.Join(c.StateDir, "control.lock"), os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600)
133
+ if err != nil {
134
+ return err
135
+ }
136
+ defer f.Close()
137
+ if err := protected(f.Name(), c.Owner, false); err != nil {
138
+ return err
139
+ }
140
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
141
+ return errors.New("lifecycle busy")
142
+ }
143
+ defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
144
+ r, err := readRecord(c.StateDir, c.Owner)
145
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
146
+ return err
147
+ }
148
+ if action == "recover" && (err != nil || !r.Enabled) {
149
+ return errors.New("explicit enable approval required")
150
+ }
151
+ if err != nil {
152
+ r = Record{Inventory: c.Expected}
153
+ }
154
+ if action == "disable" {
155
+ r.Enabled = false
156
+ }
157
+ // Publish the durable gate first. A crashed/failed disable still refuses
158
+ // requests and exposes required cleanup; never report cleanup as successful.
159
+ r.Residual = true
160
+ if err := writeProtected(c.StateDir, "approval.json", r); err != nil {
161
+ return err
162
+ }
163
+ if err := c.teardown(); err != nil {
164
+ return err
165
+ }
166
+ if action == "enable" || action == "recover" {
167
+ r = Record{Inventory: c.Expected, Enabled: true, Residual: true}
168
+ if err := writeProtected(c.StateDir, "approval.json", r); err != nil {
169
+ return err
170
+ }
171
+ if err := c.Processes.Start(c.Expected); err != nil {
172
+ return err
173
+ }
174
+ }
175
+ r.Residual = false
176
+ return writeProtected(c.StateDir, "approval.json", r)
177
+ }
178
+
179
+ func (c Controller) teardown() error {
180
+ p, err := readProcess(c.RunDir, c.Owner)
181
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
182
+ return err
183
+ }
184
+ if err == nil {
185
+ if p.Inventory.Package != c.Expected.Package {
186
+ return errTrust
187
+ }
188
+ if err := c.Processes.Stop(p); err != nil {
189
+ return err
190
+ }
191
+ }
192
+ for _, name := range []string{"helper.sock", "supervisor.sock"} {
193
+ path := filepath.Join(c.RunDir, name)
194
+ st, e := os.Lstat(path)
195
+ if e == nil {
196
+ s, ok := st.Sys().(*syscall.Stat_t)
197
+ if !ok || s.Uid != c.Owner || st.Mode()&os.ModeSocket == 0 || st.Mode().Perm()&0002 != 0 {
198
+ return errTrust
199
+ }
200
+ conn, err := net.DialTimeout("unix", path, 100*time.Millisecond)
201
+ if err == nil {
202
+ conn.Close()
203
+ return errors.New("unidentified live socket")
204
+ }
205
+ if !errors.Is(err, syscall.ECONNREFUSED) && !errors.Is(err, fs.ErrNotExist) {
206
+ return err
207
+ }
208
+ if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
209
+ return err
210
+ }
211
+ } else if !errors.Is(e, fs.ErrNotExist) {
212
+ return e
213
+ }
214
+ }
215
+ if err == nil {
216
+ return os.Remove(filepath.Join(c.RunDir, "process.json"))
217
+ }
218
+ return nil
219
+ }