@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.
Files changed (280) 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 +45 -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/spk-version.d.ts +2 -14
  20. package/dist/deploy/spk-version.js +25 -111
  21. package/dist/dev.d.ts +1 -3
  22. package/dist/dev.js +14 -28
  23. package/dist/error-registry.js +810 -862
  24. package/dist/host/dsm-client.js +12 -272
  25. package/dist/host/dsm-port-profile.d.ts +0 -1
  26. package/dist/host/dsm-port-profile.js +0 -2
  27. package/dist/host/types.d.ts +1 -83
  28. package/dist/index.d.ts +0 -9
  29. package/dist/index.js +0 -6
  30. package/dist/info.d.ts +0 -1
  31. package/dist/info.js +3 -10
  32. package/dist/init.d.ts +11 -1
  33. package/dist/init.js +112 -4
  34. package/dist/log.js +2 -3
  35. package/dist/native/validate.js +0 -1
  36. package/dist/standalone/binary.d.ts +9 -0
  37. package/dist/standalone/binary.js +67 -0
  38. package/dist/standalone/build.d.ts +25 -0
  39. package/dist/standalone/build.js +108 -0
  40. package/dist/standalone/dsm-window.d.ts +26 -0
  41. package/dist/standalone/dsm-window.js +251 -0
  42. package/dist/standalone/helper.d.ts +31 -0
  43. package/dist/standalone/helper.js +104 -0
  44. package/dist/standalone/i18n.d.ts +28 -0
  45. package/dist/standalone/i18n.js +161 -0
  46. package/dist/standalone/inspect-spk.d.ts +21 -0
  47. package/dist/standalone/inspect-spk.js +160 -0
  48. package/dist/standalone/manifest.d.ts +71 -0
  49. package/dist/standalone/manifest.js +276 -0
  50. package/dist/standalone/root-messages.d.ts +7 -0
  51. package/dist/standalone/root-messages.js +250 -0
  52. package/dist/standalone/root-ops.d.ts +42 -0
  53. package/dist/standalone/root-ops.js +101 -0
  54. package/dist/standalone/root-window.d.ts +35 -0
  55. package/dist/standalone/root-window.js +290 -0
  56. package/dist/standalone/spk.d.ts +22 -0
  57. package/dist/standalone/spk.js +205 -0
  58. package/dist/templates/binary/synology-app.yaml +7 -0
  59. package/dist/templates/hello-files/compose.yaml +0 -1
  60. package/dist/templates/hello-web/compose.yaml +13 -10
  61. package/dist/templates/hello-web/html/app.js +1 -23
  62. package/dist/templates/hello-web/html/index.html +1 -3
  63. package/dist/templates/hello-web-page/compose.yaml +13 -10
  64. package/dist/templates/hello-web-page/html/app.js +1 -23
  65. package/dist/templates/hello-web-page/html/index.html +1 -3
  66. package/dist/templates/native-go/README.md +268 -0
  67. package/dist/templates/native-go/backend/cmd/app/main.go +49 -0
  68. package/dist/templates/native-go/backend/cmd/helper/main.go +38 -0
  69. package/dist/templates/native-go/backend/go.mod +3 -0
  70. package/dist/templates/native-go/backend/internal/api/errors.go +33 -0
  71. package/dist/templates/native-go/backend/internal/api/registry.go +94 -0
  72. package/dist/templates/native-go/backend/internal/api/registry_test.go +329 -0
  73. package/dist/templates/native-go/backend/internal/api/server.go +155 -0
  74. package/dist/templates/native-go/backend/internal/app/root_none.go +9 -0
  75. package/dist/templates/native-go/backend/internal/app/routes.go +127 -0
  76. package/dist/templates/native-go/backend/internal/app/routes_test.go +167 -0
  77. package/dist/templates/native-go/backend/internal/dsm/auth.go +118 -0
  78. package/dist/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
  79. package/dist/templates/native-go/backend/internal/helper/control.go +219 -0
  80. package/dist/templates/native-go/backend/internal/helper/control_test.go +242 -0
  81. package/dist/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
  82. package/dist/templates/native-go/backend/internal/helper/peer_other.go +7 -0
  83. package/dist/templates/native-go/backend/internal/helper/process_linux.go +167 -0
  84. package/dist/templates/native-go/backend/internal/helper/process_other.go +5 -0
  85. package/dist/templates/native-go/backend/internal/helper/server_linux.go +83 -0
  86. package/dist/templates/native-go/backend/internal/helper/status.go +271 -0
  87. package/dist/templates/native-go/backend/internal/helper/status_test.go +205 -0
  88. package/dist/templates/native-go/backend/internal/helper/supervision.go +84 -0
  89. package/dist/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
  90. package/dist/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
  91. package/dist/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
  92. package/dist/templates/native-go/dsm-package.yaml +58 -0
  93. package/dist/templates/native-go/gitignore +1 -0
  94. package/dist/templates/native-go/i18n/glossary.json +662 -0
  95. package/dist/templates/native-go/i18n/messages/de.json +28 -0
  96. package/dist/templates/native-go/i18n/messages/en.json +28 -0
  97. package/dist/templates/native-go/i18n/messages/es.json +28 -0
  98. package/dist/templates/native-go/i18n/messages/fr.json +28 -0
  99. package/dist/templates/native-go/i18n/messages/it.json +28 -0
  100. package/dist/templates/native-go/i18n/messages/ja.json +28 -0
  101. package/dist/templates/native-go/i18n/messages/ko.json +28 -0
  102. package/dist/templates/native-go/i18n/messages/zh-CN.json +28 -0
  103. package/dist/templates/native-go/i18n/messages/zh-TW.json +28 -0
  104. package/dist/templates/native-go/ui/app.css +35 -0
  105. package/dist/templates/native-go/ui/app.js +94 -0
  106. package/dist/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
  107. package/dist/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
  108. package/dist/templates/native-go-better-auth/node/app.mjs +72 -0
  109. package/dist/templates/native-go-better-auth/node/build.mjs +2 -0
  110. package/dist/templates/native-go-better-auth/node/package-lock.json +862 -0
  111. package/dist/templates/native-go-better-auth/node/package.json +1 -0
  112. package/dist/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
  113. package/dist/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
  114. package/dist/templates/native-go-integration/backend/internal/app/personal.go +203 -0
  115. package/dist/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
  116. package/dist/templates/native-go-root/backend/internal/app/root.go +107 -0
  117. package/dist/templates/native-go-root/backend/internal/app/root_test.go +124 -0
  118. package/dist/templates/native-go-root/root/whoami.sh +6 -0
  119. package/dist/templates/native-go-vue/README.md +110 -0
  120. package/dist/templates/native-go-vue/frontend/DESIGN.md +78 -0
  121. package/dist/templates/native-go-vue/frontend/package-lock.json +2507 -0
  122. package/dist/templates/native-go-vue/frontend/package.json +6 -0
  123. package/dist/templates/native-go-vue/frontend/src/App.vue +132 -0
  124. package/dist/templates/native-go-vue/frontend/src/context.ts +17 -0
  125. package/dist/templates/native-go-vue/frontend/src/main.ts +26 -0
  126. package/dist/templates/native-go-vue/frontend/src/style.css +14 -0
  127. package/dist/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
  128. package/dist/templates/native-go-vue/frontend/tsconfig.json +1 -0
  129. package/dist/templates/native-go-vue/frontend/vite.config.ts +8 -0
  130. package/dist/templates/native-go-vue/gitignore +2 -0
  131. package/dist/types.d.ts +1 -1
  132. package/dist/validate.js +11 -7
  133. package/package.json +3 -7
  134. package/schema/synology-app.schema.json +36 -37
  135. package/skills/SKILL.md +4 -48
  136. package/skills/reference/create.md +11 -83
  137. package/skills/reference/deploy.md +7 -78
  138. package/skills/reference/diagnose.md +4 -101
  139. package/skills/reference/files.md +5 -41
  140. package/skills/reference/identity.md +3 -29
  141. package/skills/reference/wrap.md +0 -2
  142. package/templates/binary/synology-app.yaml +7 -0
  143. package/templates/hello-files/compose.yaml +0 -1
  144. package/templates/hello-web/compose.yaml +13 -10
  145. package/templates/hello-web/html/app.js +1 -23
  146. package/templates/hello-web/html/index.html +1 -3
  147. package/templates/hello-web-page/compose.yaml +13 -10
  148. package/templates/hello-web-page/html/app.js +1 -23
  149. package/templates/hello-web-page/html/index.html +1 -3
  150. package/templates/native-go/README.md +268 -0
  151. package/templates/native-go/backend/cmd/app/main.go +49 -0
  152. package/templates/native-go/backend/cmd/helper/main.go +38 -0
  153. package/templates/native-go/backend/go.mod +3 -0
  154. package/templates/native-go/backend/internal/api/errors.go +33 -0
  155. package/templates/native-go/backend/internal/api/registry.go +94 -0
  156. package/templates/native-go/backend/internal/api/registry_test.go +329 -0
  157. package/templates/native-go/backend/internal/api/server.go +155 -0
  158. package/templates/native-go/backend/internal/app/root_none.go +9 -0
  159. package/templates/native-go/backend/internal/app/routes.go +127 -0
  160. package/templates/native-go/backend/internal/app/routes_test.go +167 -0
  161. package/templates/native-go/backend/internal/dsm/auth.go +118 -0
  162. package/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
  163. package/templates/native-go/backend/internal/helper/control.go +219 -0
  164. package/templates/native-go/backend/internal/helper/control_test.go +242 -0
  165. package/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
  166. package/templates/native-go/backend/internal/helper/peer_other.go +7 -0
  167. package/templates/native-go/backend/internal/helper/process_linux.go +167 -0
  168. package/templates/native-go/backend/internal/helper/process_other.go +5 -0
  169. package/templates/native-go/backend/internal/helper/server_linux.go +83 -0
  170. package/templates/native-go/backend/internal/helper/status.go +271 -0
  171. package/templates/native-go/backend/internal/helper/status_test.go +205 -0
  172. package/templates/native-go/backend/internal/helper/supervision.go +84 -0
  173. package/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
  174. package/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
  175. package/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
  176. package/templates/native-go/dsm-package.yaml +58 -0
  177. package/templates/native-go/gitignore +1 -0
  178. package/templates/native-go/i18n/glossary.json +662 -0
  179. package/templates/native-go/i18n/messages/de.json +28 -0
  180. package/templates/native-go/i18n/messages/en.json +28 -0
  181. package/templates/native-go/i18n/messages/es.json +28 -0
  182. package/templates/native-go/i18n/messages/fr.json +28 -0
  183. package/templates/native-go/i18n/messages/it.json +28 -0
  184. package/templates/native-go/i18n/messages/ja.json +28 -0
  185. package/templates/native-go/i18n/messages/ko.json +28 -0
  186. package/templates/native-go/i18n/messages/zh-CN.json +28 -0
  187. package/templates/native-go/i18n/messages/zh-TW.json +28 -0
  188. package/templates/native-go/ui/app.css +35 -0
  189. package/templates/native-go/ui/app.js +94 -0
  190. package/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
  191. package/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
  192. package/templates/native-go-better-auth/node/app.mjs +72 -0
  193. package/templates/native-go-better-auth/node/build.mjs +2 -0
  194. package/templates/native-go-better-auth/node/package-lock.json +862 -0
  195. package/templates/native-go-better-auth/node/package.json +1 -0
  196. package/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
  197. package/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
  198. package/templates/native-go-integration/backend/internal/app/personal.go +203 -0
  199. package/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
  200. package/templates/native-go-root/backend/internal/app/root.go +107 -0
  201. package/templates/native-go-root/backend/internal/app/root_test.go +124 -0
  202. package/templates/native-go-root/root/whoami.sh +6 -0
  203. package/templates/native-go-vue/README.md +110 -0
  204. package/templates/native-go-vue/frontend/DESIGN.md +78 -0
  205. package/templates/native-go-vue/frontend/package-lock.json +2507 -0
  206. package/templates/native-go-vue/frontend/package.json +6 -0
  207. package/templates/native-go-vue/frontend/src/App.vue +132 -0
  208. package/templates/native-go-vue/frontend/src/context.ts +17 -0
  209. package/templates/native-go-vue/frontend/src/main.ts +26 -0
  210. package/templates/native-go-vue/frontend/src/style.css +14 -0
  211. package/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
  212. package/templates/native-go-vue/frontend/tsconfig.json +1 -0
  213. package/templates/native-go-vue/frontend/vite.config.ts +8 -0
  214. package/templates/native-go-vue/gitignore +2 -0
  215. package/dist/attestation/app-package.d.ts +0 -86
  216. package/dist/attestation/app-package.js +0 -206
  217. package/dist/attestation/install-on-host.d.ts +0 -14
  218. package/dist/attestation/install-on-host.js +0 -52
  219. package/dist/attestation/keyring.d.ts +0 -21
  220. package/dist/attestation/keyring.js +0 -168
  221. package/dist/attestation/platform-service.d.ts +0 -70
  222. package/dist/attestation/platform-service.js +0 -250
  223. package/dist/build-spk/broker-glue.d.ts +0 -16
  224. package/dist/build-spk/broker-glue.js +0 -40
  225. package/dist/deploy/catalog-publish.d.ts +0 -3
  226. package/dist/deploy/catalog-publish.js +0 -7
  227. package/dist/doctor.d.ts +0 -22
  228. package/dist/doctor.js +0 -822
  229. package/dist/error-collector.d.ts +0 -2
  230. package/dist/error-collector.js +0 -46
  231. package/dist/host/broker-paths.d.ts +0 -1
  232. package/dist/host/broker-paths.js +0 -1
  233. package/dist/host/broker-upgrade-preflight.d.ts +0 -61
  234. package/dist/host/broker-upgrade-preflight.js +0 -156
  235. package/dist/host/digests.d.ts +0 -5
  236. package/dist/host/digests.js +0 -63
  237. package/dist/host/fake-nas.d.ts +0 -49
  238. package/dist/host/fake-nas.js +0 -193
  239. package/dist/mock-broker/bootstrap.d.ts +0 -2
  240. package/dist/mock-broker/bootstrap.js +0 -8
  241. package/dist/mock-broker/files.d.ts +0 -60
  242. package/dist/mock-broker/files.js +0 -408
  243. package/dist/mock-broker/oidc.d.ts +0 -31
  244. package/dist/mock-broker/oidc.js +0 -161
  245. package/dist/mock-broker/path.d.ts +0 -9
  246. package/dist/mock-broker/path.js +0 -50
  247. package/dist/mock-broker/server.d.ts +0 -19
  248. package/dist/mock-broker/server.js +0 -350
  249. package/dist/publish/build-ledger.d.ts +0 -22
  250. package/dist/publish/build-ledger.js +0 -26
  251. package/dist/publish/community-path.d.ts +0 -6
  252. package/dist/publish/community-path.js +0 -41
  253. package/dist/publish/curated-release.d.ts +0 -78
  254. package/dist/publish/curated-release.js +0 -883
  255. package/dist/publish/file-build-ledger.d.ts +0 -18
  256. package/dist/publish/file-build-ledger.js +0 -268
  257. package/dist/publish/github-pr.d.ts +0 -25
  258. package/dist/publish/github-pr.js +0 -85
  259. package/dist/publish/github-repo.d.ts +0 -8
  260. package/dist/publish/github-repo.js +0 -21
  261. package/dist/publish/platform-service-release.d.ts +0 -60
  262. package/dist/publish/platform-service-release.js +0 -333
  263. package/dist/publish/preflight.d.ts +0 -24
  264. package/dist/publish/preflight.js +0 -138
  265. package/dist/publish/publish.d.ts +0 -27
  266. package/dist/publish/publish.js +0 -136
  267. package/dist/publish/sync-definition.d.ts +0 -4
  268. package/dist/publish/sync-definition.js +0 -48
  269. package/dist/release-bin.d.ts +0 -2
  270. package/dist/release-bin.js +0 -23
  271. package/dist/templates/hello-web/html/sdk.js +0 -68
  272. package/dist/templates/hello-web-page/html/sdk.js +0 -68
  273. package/dist/templates/native/synology-app.yaml +0 -7
  274. package/dist/test-host.d.ts +0 -15
  275. package/dist/test-host.js +0 -37
  276. package/templates/hello-web/html/sdk.js +0 -68
  277. package/templates/hello-web-page/html/sdk.js +0 -68
  278. package/templates/native/synology-app.yaml +0 -7
  279. /package/dist/templates/{native → binary}/native.yaml +0 -0
  280. /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
+ }