@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,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
+ }
@@ -0,0 +1,242 @@
1
+ package helper
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net"
7
+ "os"
8
+ "path/filepath"
9
+ "testing"
10
+ "time"
11
+ )
12
+
13
+ func staleSocket(t *testing.T, p string) {
14
+ l, err := net.ListenUnix("unix", &net.UnixAddr{Name: p, Net: "unix"})
15
+ if err != nil {
16
+ t.Fatal(err)
17
+ }
18
+ l.SetUnlinkOnClose(false)
19
+ l.Close()
20
+ }
21
+
22
+ type fakeProcesses struct {
23
+ current Process
24
+ stopped, started int
25
+ stopErr error
26
+ }
27
+
28
+ func (f *fakeProcesses) Stop(want Process) error {
29
+ if f.stopErr != nil {
30
+ return f.stopErr
31
+ }
32
+ if want != f.current {
33
+ return errTrust
34
+ }
35
+ f.stopped++
36
+ return nil
37
+ }
38
+ func (f *fakeProcesses) Start(Inventory) error { f.started++; return nil }
39
+
40
+ func controlFixture(t *testing.T) (Controller, *fakeProcesses) {
41
+ c := fixture(t)
42
+ p := Process{Inventory: c.Expected, Mode: "supervised", PID: 1234, Boot: "fixture-boot", Start: "100", Device: 1, Inode: 2}
43
+ f := &fakeProcesses{current: p}
44
+ ctl := Controller{Client: c, Processes: f}
45
+ record(t, c, Record{Inventory: c.Expected, Enabled: true})
46
+ if err := writeProcess(c.RunDir, p); err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ return ctl, f
50
+ }
51
+
52
+ func TestTeardownAndRecoveryVerifyOwnedProcess(t *testing.T) {
53
+ for _, kind := range []string{"pid", "boot", "start", "inode", "digest", "package"} {
54
+ t.Run(kind, func(t *testing.T) {
55
+ c, f := controlFixture(t)
56
+ switch kind {
57
+ case "pid":
58
+ f.current.PID++
59
+ case "boot":
60
+ f.current.Boot = "new-boot"
61
+ case "start":
62
+ f.current.Start = "reused"
63
+ case "inode":
64
+ f.current.Inode++
65
+ case "digest":
66
+ f.current.Inventory.PayloadDigest = "foreign"
67
+ case "package":
68
+ f.current.Inventory.Package = "Other"
69
+ }
70
+ if c.Control(context.Background(), "recover") == nil || f.stopped != 0 || f.started != 0 {
71
+ t.Fatal("foreign process touched")
72
+ }
73
+ if _, err := os.Stat(filepath.Join(c.RunDir, "process.json")); err != nil {
74
+ t.Fatal("failure evidence removed")
75
+ }
76
+ })
77
+ }
78
+ c, f := controlFixture(t)
79
+ if err := c.Control(context.Background(), "teardown"); err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ if f.stopped != 1 || f.started != 0 {
83
+ t.Fatal(f)
84
+ }
85
+ if _, err := os.Stat(filepath.Join(c.RunDir, "process.json")); !os.IsNotExist(err) {
86
+ t.Fatal(err)
87
+ }
88
+ if err := c.Control(context.Background(), "recover"); err != nil || f.started != 1 {
89
+ t.Fatalf("%v %+v", err, f)
90
+ }
91
+ }
92
+
93
+ func TestDisableIsDurableAndPrecedesFailedCleanup(t *testing.T) {
94
+ c, f := controlFixture(t)
95
+ f.stopErr = errors.New("fixture stop failed")
96
+ if c.Control(context.Background(), "disable") == nil {
97
+ t.Fatal("cleanup falsely succeeded")
98
+ }
99
+ r, err := readRecord(c.StateDir, c.Owner)
100
+ if err != nil || r.Enabled || !r.Residual {
101
+ t.Fatalf("%+v %v", r, err)
102
+ }
103
+ s := c.Status(context.Background())
104
+ if s.State != "not-enabled" || s.Cleanup != "required" {
105
+ t.Fatal(s)
106
+ }
107
+ if c.Control(context.Background(), "recover") == nil || f.started != 0 {
108
+ t.Fatal("old recovery resurrected disabled integration")
109
+ }
110
+ f.stopErr = nil
111
+ if err := c.Control(context.Background(), "disable"); err != nil {
112
+ t.Fatal(err)
113
+ }
114
+ r, _ = readRecord(c.StateDir, c.Owner)
115
+ if r.Enabled || r.Residual {
116
+ t.Fatal(r)
117
+ }
118
+ if err := c.Control(context.Background(), "enable"); err != nil || f.started != 1 {
119
+ t.Fatalf("%v %+v", err, f)
120
+ }
121
+ }
122
+
123
+ func TestMissingApprovalAndUnknownActionCannotStart(t *testing.T) {
124
+ c, f := controlFixture(t)
125
+ os.Remove(filepath.Join(c.StateDir, "approval.json"))
126
+ if c.Control(context.Background(), "recover") == nil {
127
+ t.Fatal("recover without approval")
128
+ }
129
+ if c.Control(context.Background(), "arbitrary-command") == nil || f.started != 0 {
130
+ t.Fatal("arbitrary action accepted")
131
+ }
132
+ }
133
+
134
+ func TestSocketResidueCannotUnlinkForeignFilesOrLiveListeners(t *testing.T) {
135
+ for _, kind := range []string{"file", "symlink", "live", "stale"} {
136
+ t.Run(kind, func(t *testing.T) {
137
+ c, f := controlFixture(t)
138
+ os.Remove(filepath.Join(c.RunDir, "process.json"))
139
+ p := filepath.Join(c.RunDir, "helper.sock")
140
+ switch kind {
141
+ case "file":
142
+ os.WriteFile(p, []byte("foreign"), 0600)
143
+ case "symlink":
144
+ os.Symlink("foreign", p)
145
+ case "live":
146
+ live(t, c.Client, c.Expected, nil)
147
+ case "stale":
148
+ staleSocket(t, p)
149
+ }
150
+ err := c.Control(context.Background(), "teardown")
151
+ if kind == "stale" {
152
+ if err != nil {
153
+ t.Fatal(err)
154
+ }
155
+ if _, err := os.Lstat(p); !os.IsNotExist(err) {
156
+ t.Fatal("stale socket not removed")
157
+ }
158
+ } else {
159
+ if err == nil {
160
+ t.Fatal("foreign residue accepted")
161
+ }
162
+ if _, err := os.Lstat(p); err != nil {
163
+ t.Fatal("foreign residue removed")
164
+ }
165
+ }
166
+ if f.stopped != 0 {
167
+ t.Fatal("unrecorded process signalled")
168
+ }
169
+ })
170
+ }
171
+ }
172
+
173
+ func TestLifecycleGateBlocksResidueAndAllowsCleanStoppedState(t *testing.T) {
174
+ c, _ := controlFixture(t)
175
+ if c.CheckStopped(context.Background()) == nil {
176
+ t.Fatal("process residue accepted")
177
+ }
178
+ if err := c.Control(context.Background(), "teardown"); err != nil {
179
+ t.Fatal(err)
180
+ }
181
+ if err := c.CheckStopped(context.Background()); err != nil {
182
+ t.Fatal(err)
183
+ }
184
+ record(t, c.Client, Record{Inventory: c.Expected, Enabled: false, Residual: true})
185
+ if c.CheckStopped(context.Background()) == nil {
186
+ t.Fatal("failed disable accepted")
187
+ }
188
+ }
189
+
190
+ func TestStartupProbesShareOneDeadline(t *testing.T) {
191
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
192
+ defer cancel()
193
+ err := waitHealthy(ctx, func(probeCtx context.Context) bool {
194
+ if probeCtx != ctx {
195
+ t.Fatal("probe got an independent deadline")
196
+ }
197
+ <-probeCtx.Done()
198
+ return false
199
+ })
200
+ if !errors.Is(err, context.DeadlineExceeded) {
201
+ t.Fatal(err)
202
+ }
203
+ if err := waitHealthy(context.Background(), func(context.Context) bool { return true }); err != nil {
204
+ t.Fatal(err)
205
+ }
206
+ }
207
+
208
+ func TestSupervisorSocketResidueAlsoBlocksLifecycle(t *testing.T) {
209
+ c, _ := controlFixture(t)
210
+ os.Remove(filepath.Join(c.RunDir, "process.json"))
211
+ p := filepath.Join(c.RunDir, "supervisor.sock")
212
+ l, err := net.ListenUnix("unix", &net.UnixAddr{Name: p, Net: "unix"})
213
+ if err != nil {
214
+ t.Fatal(err)
215
+ }
216
+ defer l.Close()
217
+ if c.CheckStopped(context.Background()) == nil {
218
+ t.Fatal("unidentified supervisor accepted")
219
+ }
220
+ if c.Control(context.Background(), "teardown") == nil {
221
+ t.Fatal("live supervisor ignored")
222
+ }
223
+ }
224
+
225
+ func TestOnlySupervisedProcessRecordsAreReadable(t *testing.T) {
226
+ for _, mode := range []string{"", "supervised", "unknown"} {
227
+ c, f := controlFixture(t)
228
+ p := f.current
229
+ p.Mode = mode
230
+ if err := writeProcess(c.RunDir, p); err != nil {
231
+ t.Fatal(err)
232
+ }
233
+ got, err := readProcess(c.RunDir, c.Owner)
234
+ if mode != "supervised" {
235
+ if err == nil {
236
+ t.Fatal("unsupervised process mode accepted")
237
+ }
238
+ } else if err != nil || got != p {
239
+ t.Fatalf("mode %q: %+v %v", mode, got, err)
240
+ }
241
+ }
242
+ }
@@ -0,0 +1,44 @@
1
+ package helper
2
+
3
+ import (
4
+ "net"
5
+ "syscall"
6
+ )
7
+
8
+ func peerCredentials(conn *net.UnixConn) (*syscall.Ucred, error) {
9
+ raw, err := conn.SyscallConn()
10
+ if err != nil {
11
+ return nil, err
12
+ }
13
+ var cred *syscall.Ucred
14
+ var sockErr error
15
+ err = raw.Control(func(fd uintptr) {
16
+ cred, sockErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
17
+ })
18
+ if err != nil {
19
+ return nil, err
20
+ }
21
+ if sockErr != nil {
22
+ return nil, sockErr
23
+ }
24
+ return cred, nil
25
+ }
26
+
27
+ func peerUID(conn *net.UnixConn) (uint32, error) {
28
+ cred, err := peerCredentials(conn)
29
+ if err != nil {
30
+ return 0, err
31
+ }
32
+ return cred.Uid, nil
33
+ }
34
+
35
+ func RootPeer(conn *net.UnixConn) error {
36
+ uid, err := peerUID(conn)
37
+ if err != nil {
38
+ return err
39
+ }
40
+ if uid != 0 {
41
+ return errTrust
42
+ }
43
+ return nil
44
+ }
@@ -0,0 +1,7 @@
1
+ //go:build !linux
2
+
3
+ package helper
4
+
5
+ import "net"
6
+
7
+ func RootPeer(*net.UnixConn) error { return errTrust }