@synopackageland/cli 0.14.1 → 0.15.1

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 (121) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +33 -18
  3. package/dist/build-spk/generator.d.ts +3 -2
  4. package/dist/build-spk/generator.js +6 -846
  5. package/dist/build-spk/resource-worker.d.ts +2 -14
  6. package/dist/build-spk/resource-worker.js +3 -166
  7. package/dist/cli.js +208 -56
  8. package/dist/compose/analyze.d.ts +3 -61
  9. package/dist/compose/analyze.js +35 -776
  10. package/dist/compose/env.d.ts +5 -3
  11. package/dist/compose/env.js +17 -25
  12. package/dist/compose/runner.js +23 -6
  13. package/dist/contract/unsupported-types.js +1 -1
  14. package/dist/contract/validate.js +2 -2
  15. package/dist/contract/web-launch.d.ts +1 -12
  16. package/dist/contract/web-launch.js +10 -74
  17. package/dist/ctl/cli.d.ts +36 -0
  18. package/dist/ctl/cli.js +218 -0
  19. package/dist/ctl/descriptor.d.ts +81 -0
  20. package/dist/ctl/descriptor.js +93 -0
  21. package/dist/ctl/dsm-auth.d.ts +10 -0
  22. package/dist/ctl/dsm-auth.js +30 -0
  23. package/dist/ctl/errors.d.ts +17 -0
  24. package/dist/ctl/errors.js +32 -0
  25. package/dist/ctl/flows.d.ts +11 -0
  26. package/dist/ctl/flows.js +52 -0
  27. package/dist/ctl/input.d.ts +20 -0
  28. package/dist/ctl/input.js +128 -0
  29. package/dist/ctl/json.d.ts +2 -0
  30. package/dist/ctl/json.js +38 -0
  31. package/dist/ctl/redaction.d.ts +15 -0
  32. package/dist/ctl/redaction.js +198 -0
  33. package/dist/ctl/service.d.ts +38 -0
  34. package/dist/ctl/service.js +156 -0
  35. package/dist/ctl/store.d.ts +51 -0
  36. package/dist/ctl/store.js +93 -0
  37. package/dist/ctl/transfers.d.ts +14 -0
  38. package/dist/ctl/transfers.js +74 -0
  39. package/dist/ctl/transport.d.ts +11 -0
  40. package/dist/ctl/transport.js +79 -0
  41. package/dist/deploy/deploy.d.ts +4 -3
  42. package/dist/deploy/deploy.js +5 -210
  43. package/dist/deploy/plan.js +26 -7
  44. package/dist/dev.js +5 -4
  45. package/dist/e2e/dsm.d.ts +2 -2
  46. package/dist/e2e/dsm.js +10 -6
  47. package/dist/error-registry.js +1020 -834
  48. package/dist/host/share-permission-lookup.d.ts +1 -1
  49. package/dist/host/share-permission.d.ts +1 -1
  50. package/dist/host/share-permission.js +1 -1
  51. package/dist/info.js +120 -28
  52. package/dist/init.js +188 -64
  53. package/dist/inspect.js +21 -6
  54. package/dist/runtime/executor-digests.json +5 -5
  55. package/dist/skill.js +51 -9
  56. package/dist/standalone/api-description-inspect.d.ts +4 -0
  57. package/dist/standalone/api-description-inspect.js +12 -0
  58. package/dist/standalone/api-description.d.ts +7 -0
  59. package/dist/standalone/api-description.js +73 -0
  60. package/dist/standalone/build.d.ts +3 -0
  61. package/dist/standalone/build.js +140 -33
  62. package/dist/standalone/compose-errors.d.ts +3 -0
  63. package/dist/standalone/compose-errors.js +243 -0
  64. package/dist/standalone/compose.d.ts +97 -10
  65. package/dist/standalone/compose.js +306 -75
  66. package/dist/standalone/daemon.js +111 -29
  67. package/dist/standalone/dsm-window.js +39 -8
  68. package/dist/standalone/identity-gateway.d.ts +9 -2
  69. package/dist/standalone/identity-gateway.js +282 -66
  70. package/dist/standalone/inspect-spk.d.ts +12 -0
  71. package/dist/standalone/inspect-spk.js +666 -136
  72. package/dist/standalone/lifecycle.js +124 -42
  73. package/dist/standalone/manifest.d.ts +8 -1
  74. package/dist/standalone/manifest.js +303 -86
  75. package/dist/standalone/resource.js +6 -2
  76. package/dist/standalone/spk.d.ts +4 -1
  77. package/dist/standalone/spk.js +138 -39
  78. package/dist/template-paths.d.ts +0 -2
  79. package/dist/template-paths.js +0 -5
  80. package/dist/test-command.d.ts +1 -1
  81. package/dist/test-command.js +1 -1
  82. package/dist/validate.d.ts +3 -3
  83. package/dist/validate.js +32 -140
  84. package/package.json +5 -3
  85. package/schema/synopkgland-api-v1.schema.json +423 -0
  86. package/skills/SKILL.md +4 -2
  87. package/skills/reference/compose.md +349 -0
  88. package/skills/reference/create.md +9 -18
  89. package/skills/reference/ctl.md +41 -0
  90. package/skills/reference/deploy.md +1 -1
  91. package/skills/reference/files.md +5 -1
  92. package/skills/reference/identity-gateway.md +8 -144
  93. package/skills/reference/wrap.md +10 -39
  94. package/assets/go/daemon/fixture_linux_test.go +0 -15
  95. package/assets/go/daemon/fixture_other_test.go +0 -7
  96. package/assets/go/daemon/go.mod +0 -5
  97. package/assets/go/daemon/main.go +0 -636
  98. package/assets/go/daemon/main_test.go +0 -315
  99. package/assets/go/daemon/network.go +0 -107
  100. package/assets/go/daemon/network_test.go +0 -86
  101. package/assets/go/daemon/process_linux.go +0 -55
  102. package/assets/go/daemon/process_linux_test.go +0 -161
  103. package/assets/go/daemon/process_other.go +0 -14
  104. package/assets/go/daemon/testdata/child.go +0 -44
  105. package/assets/go/gateway/auth.go +0 -101
  106. package/assets/go/gateway/auth_test.go +0 -216
  107. package/assets/go/gateway/cgi.go +0 -133
  108. package/assets/go/gateway/cgi_test.go +0 -156
  109. package/assets/go/gateway/csrf.go +0 -73
  110. package/assets/go/gateway/csrf_test.go +0 -155
  111. package/assets/go/gateway/directory.go +0 -108
  112. package/assets/go/gateway/directory_test.go +0 -202
  113. package/assets/go/gateway/gateway.go +0 -412
  114. package/assets/go/gateway/gateway_test.go +0 -676
  115. package/assets/go/gateway/go.mod +0 -5
  116. package/assets/go/gateway/groups.go +0 -66
  117. package/assets/go/gateway/main.go +0 -45
  118. package/dist/build-spk/gateway.d.ts +0 -31
  119. package/dist/build-spk/gateway.js +0 -60
  120. package/dist/compose/privilege.d.ts +0 -14
  121. package/dist/compose/privilege.js +0 -102
@@ -1,676 +0,0 @@
1
- package main
2
-
3
- import (
4
- "bufio"
5
- "errors"
6
- "io"
7
- "net"
8
- "net/http"
9
- "net/http/httptest"
10
- "slices"
11
- "strconv"
12
- "strings"
13
- "sync/atomic"
14
- "testing"
15
- "time"
16
- )
17
-
18
- const base = "/webman/3rdparty/App/cgi/gateway.cgi"
19
-
20
- type fakeAuth struct {
21
- id identity
22
- err error
23
- calls int
24
- }
25
-
26
- func (f *fakeAuth) Authenticate(*http.Request) (identity, error) {
27
- f.calls++
28
- return f.id, f.err
29
- }
30
-
31
- // upstream records what reached the container.
32
- type upstream struct {
33
- *httptest.Server
34
- hits atomic.Int32
35
- request atomic.Pointer[http.Request]
36
- body atomic.Pointer[string]
37
- }
38
-
39
- func newUpstream(t *testing.T, handler http.HandlerFunc) *upstream {
40
- t.Helper()
41
- u := &upstream{}
42
- u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43
- u.hits.Add(1)
44
- u.request.Store(r)
45
- b, _ := io.ReadAll(r.Body)
46
- s := string(b)
47
- u.body.Store(&s)
48
- handler(w, r)
49
- }))
50
- t.Cleanup(u.Close)
51
- return u
52
- }
53
-
54
- func hello(w http.ResponseWriter, r *http.Request) {
55
- w.Header().Set("Content-Type", "text/plain")
56
- io.WriteString(w, "container says hi: "+r.URL.RequestURI())
57
- }
58
-
59
- func newTestGateway(t *testing.T, cfg config, auth authenticator, up *upstream) http.Handler {
60
- t.Helper()
61
- cfg.Upstream = up.Listener.Addr().String()
62
- g, err := newGateway(cfg, auth)
63
- if err != nil {
64
- t.Fatal(err)
65
- }
66
- return g
67
- }
68
-
69
- func do(h http.Handler, method, target string, hdr map[string]string, body string) *httptest.ResponseRecorder {
70
- r := httptest.NewRequest(method, target, strings.NewReader(body))
71
- r.RemoteAddr = "192.0.2.7:51234"
72
- for k, v := range hdr {
73
- r.Header.Set(k, v)
74
- }
75
- w := httptest.NewRecorder()
76
- h.ServeHTTP(w, r)
77
- return w
78
- }
79
-
80
- var signedIn = map[string]string{"Cookie": "id=abc"}
81
-
82
- func TestSignedInUserReachesContainer(t *testing.T) {
83
- up := newUpstream(t, hello)
84
- auth := &fakeAuth{id: identity{User: "alice"}}
85
- w := do(newTestGateway(t, config{Access: "users"}, auth, up), "GET", base+"/index.html?x=1", signedIn, "")
86
- if w.Code != 200 || w.Body.String() != "container says hi: /index.html?x=1" {
87
- t.Fatalf("%d %q", w.Code, w.Body.String())
88
- }
89
- }
90
-
91
- func TestAnonymousRequestIsRejectedAndNeverReachesContainer(t *testing.T) {
92
- up := newUpstream(t, hello)
93
- auth := &fakeAuth{err: errNoSession}
94
- w := do(newTestGateway(t, config{Access: "users"}, auth, up), "GET", base+"/", nil, "")
95
- if w.Code != 401 || up.hits.Load() != 0 {
96
- t.Fatalf("code=%d hits=%d", w.Code, up.hits.Load())
97
- }
98
- if !strings.Contains(w.Body.String(), "session_required") {
99
- t.Errorf("body %q", w.Body.String())
100
- }
101
- }
102
-
103
- func TestExpiredSessionIsRejected(t *testing.T) {
104
- up := newUpstream(t, hello)
105
- auth := &fakeAuth{err: errNoSession}
106
- w := do(newTestGateway(t, config{Access: "users"}, auth, up), "GET", base+"/", signedIn, "")
107
- if w.Code != 401 || up.hits.Load() != 0 {
108
- t.Fatalf("code=%d hits=%d", w.Code, up.hits.Load())
109
- }
110
- }
111
-
112
- func TestAuthenticatorFailureFailsClosed(t *testing.T) {
113
- up := newUpstream(t, hello)
114
- auth := &fakeAuth{err: errors.New("authenticate.cgi missing")}
115
- w := do(newTestGateway(t, config{Access: "users"}, auth, up), "GET", base+"/", signedIn, "")
116
- if w.Code != 520 || up.hits.Load() != 0 {
117
- t.Fatalf("code=%d hits=%d", w.Code, up.hits.Load())
118
- }
119
- if got := w.Header().Get("X-Synopkgland-Error"); got != "auth_unavailable" {
120
- t.Errorf("X-Synopkgland-Error = %q", got)
121
- }
122
- }
123
-
124
- func TestForgedIdentityHeadersDoNotAuthenticate(t *testing.T) {
125
- up := newUpstream(t, hello)
126
- auth := &fakeAuth{err: errNoSession}
127
- forged := map[string]string{
128
- "X-Syno-User": "admin",
129
- "X-Syno-Admin": "true",
130
- "X-Forwarded-User": "admin",
131
- "X-Remote-User": "admin",
132
- "Remote-User": "admin",
133
- "X-Forwarded-For": "127.0.0.1",
134
- "X-Real-IP": "127.0.0.1",
135
- "Authorization": "Basic YWRtaW46YWRtaW4=",
136
- "X-Syno-Token": "bogus",
137
- "Cookie": "id=forged; did=forged",
138
- }
139
- w := do(newTestGateway(t, config{Access: "users"}, auth, up), "GET", base+"/", forged, "")
140
- if w.Code != 401 || up.hits.Load() != 0 {
141
- t.Fatalf("code=%d hits=%d", w.Code, up.hits.Load())
142
- }
143
- }
144
-
145
- func TestContainerNeverSeesClientIdentityOrSessionHeaders(t *testing.T) {
146
- up := newUpstream(t, hello)
147
- auth := &fakeAuth{id: identity{User: "bob"}}
148
- hdr := map[string]string{
149
- "Cookie": "id=abc; did=xyz",
150
- "X-Syno-Token": "tok",
151
- "X-Syno-User": "admin",
152
- "X-Syno-Admin": "true",
153
- "X-Forwarded-User": "admin",
154
- "X-Remote-User": "admin",
155
- "Remote-User": "admin",
156
- "Remote-Groups": "administrators",
157
- "X-Forwarded-For": "10.0.0.9",
158
- "Forwarded": "for=10.0.0.9",
159
- "X-Real-IP": "10.0.0.9",
160
- "X-Auth-Request-User": "admin",
161
- "Accept-Language": "zh-TW",
162
- }
163
- w := do(newTestGateway(t, config{Access: "users"}, auth, up), "GET", base+"/?SynoToken=secret&a=b", hdr, "")
164
- if w.Code != 200 {
165
- t.Fatalf("code=%d", w.Code)
166
- }
167
- got := up.request.Load()
168
- for _, name := range []string{"Cookie", "X-Syno-Token", "X-Forwarded-User", "X-Remote-User", "Remote-User", "Remote-Groups", "X-Forwarded-For", "Forwarded", "X-Real-Ip", "X-Auth-Request-User"} {
169
- if v := got.Header.Get(name); v != "" {
170
- t.Errorf("%s reached the container: %q", name, v)
171
- }
172
- }
173
- if got.Header.Get("X-Syno-User") != "bob" || got.Header.Get("X-Syno-Admin") != "false" {
174
- t.Errorf("identity = %q / %q, want gateway-set bob/false", got.Header.Get("X-Syno-User"), got.Header.Get("X-Syno-Admin"))
175
- }
176
- if got.Header.Get("Accept-Language") != "zh-TW" {
177
- t.Errorf("ordinary header dropped")
178
- }
179
- if strings.Contains(got.URL.RawQuery, "SynoToken") || got.URL.Query().Get("a") != "b" {
180
- t.Errorf("query = %q", got.URL.RawQuery)
181
- }
182
- }
183
-
184
- func TestAdminHeaderIsTrueForAdministrators(t *testing.T) {
185
- up := newUpstream(t, hello)
186
- auth := &fakeAuth{id: identity{User: "alice", Groups: []string{"administrators"}}}
187
- do(newTestGateway(t, config{Access: "users"}, auth, up), "GET", base+"/", signedIn, "")
188
- if got := up.request.Load().Header.Get("X-Syno-Admin"); got != "true" {
189
- t.Errorf("X-Syno-Admin = %q", got)
190
- }
191
- }
192
-
193
- func TestAdminsOnlyRejectsOrdinaryUsers(t *testing.T) {
194
- up := newUpstream(t, hello)
195
- cfg := config{Access: "admins"}
196
- g := newTestGateway(t, cfg, &fakeAuth{id: identity{User: "bob", Groups: []string{"staff"}}}, up)
197
- if w := do(g, "GET", base+"/", signedIn, ""); w.Code != 401 || up.hits.Load() != 0 {
198
- t.Fatalf("ordinary: code=%d hits=%d", w.Code, up.hits.Load())
199
- } else if w.Header().Get("X-Synopkgland-Error") != "forbidden" || w.Header().Get("X-Synopkgland-Status") != "403" {
200
- t.Errorf("headers: %v", w.Header())
201
- }
202
- g = newTestGateway(t, cfg, &fakeAuth{id: identity{User: "alice", Groups: []string{"administrators"}}}, up)
203
- if w := do(g, "GET", base+"/", signedIn, ""); w.Code != 200 {
204
- t.Fatalf("admin: code=%d", w.Code)
205
- }
206
- }
207
-
208
- func TestGroupsRejectUsersOutsideEveryListedGroup(t *testing.T) {
209
- up := newUpstream(t, hello)
210
- cfg := config{Access: "groups", Groups: []string{"staff", "ops"}}
211
- g := newTestGateway(t, cfg, &fakeAuth{id: identity{User: "bob", Groups: []string{"users"}}}, up)
212
- if w := do(g, "GET", base+"/", signedIn, ""); w.Code != 401 || up.hits.Load() != 0 || !strings.Contains(w.Body.String(), "forbidden") {
213
- t.Fatalf("outsider: code=%d hits=%d body=%q", w.Code, up.hits.Load(), w.Body.String())
214
- }
215
- g = newTestGateway(t, cfg, &fakeAuth{id: identity{User: "carol", Groups: []string{"ops"}}}, up)
216
- if w := do(g, "GET", base+"/", signedIn, ""); w.Code != 200 {
217
- t.Fatalf("member: code=%d", w.Code)
218
- }
219
- }
220
-
221
- func TestGroupsAccessWithoutGroupsIsRefusedAtStartup(t *testing.T) {
222
- for _, cfg := range []config{
223
- {Upstream: "127.0.0.1:1", Access: "groups"},
224
- {Upstream: "127.0.0.1:1", Access: "everyone"},
225
- {Upstream: "127.0.0.1:1", Access: ""},
226
- {Upstream: "10.0.0.5:80", Access: "users"},
227
- {Upstream: "localhost:80", Access: "users"},
228
- } {
229
- if _, err := newGateway(cfg, &fakeAuth{}); err == nil {
230
- t.Errorf("config %+v accepted", cfg)
231
- }
232
- }
233
- if _, err := newGateway(config{Upstream: "127.0.0.1:18333", Access: "users"}, nil); err == nil {
234
- t.Error("nil authenticator accepted")
235
- }
236
- }
237
-
238
- func TestStateChangingRequestNeedsSynoToken(t *testing.T) {
239
- up := newUpstream(t, hello)
240
- g := newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up)
241
- for _, method := range []string{"POST", "PUT", "PATCH", "DELETE"} {
242
- if w := do(g, method, base+"/", signedIn, "x"); w.Code != 401 || !strings.Contains(w.Body.String(), "token_required") || w.Header().Get("X-Synopkgland-Error") != "token_required" {
243
- t.Errorf("%s without token: %d %q", method, w.Code, w.Body.String())
244
- }
245
- }
246
- if up.hits.Load() != 0 {
247
- t.Fatalf("hits=%d", up.hits.Load())
248
- }
249
- hdr := browserPost("tok")
250
- if w := do(g, "POST", base+"/save", hdr, "payload"); w.Code != 200 || *up.body.Load() != "payload" {
251
- t.Fatalf("with token: %d body=%q", w.Code, *up.body.Load())
252
- }
253
- }
254
-
255
- func TestQueryTokenDoesNotSatisfyStateChangingRequests(t *testing.T) {
256
- up := newUpstream(t, hello)
257
- g := newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up)
258
- hdr := browserPost("")
259
- delete(hdr, "X-SYNO-TOKEN")
260
- for _, method := range []string{"POST", "PUT", "PATCH", "DELETE"} {
261
- w := do(g, method, base+"/?SynoToken=secret", hdr, "x")
262
- if w.Code != 401 || w.Header().Get("X-Synopkgland-Error") != "token_required" || strings.Contains(w.Body.String(), "secret") {
263
- t.Errorf("%s with query token only: %d %q", method, w.Code, w.Body.String())
264
- }
265
- }
266
- if up.hits.Load() != 0 {
267
- t.Fatalf("hits=%d", up.hits.Load())
268
- }
269
- }
270
-
271
- func TestSafeRequestsNeedNoSynoToken(t *testing.T) {
272
- up := newUpstream(t, hello)
273
- g := newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up)
274
- for _, method := range []string{"GET", "HEAD"} {
275
- if w := do(g, method, base+"/", signedIn, ""); w.Code != 200 {
276
- t.Errorf("%s: %d", method, w.Code)
277
- }
278
- }
279
- }
280
-
281
- func TestOversizedBodyIsRefused(t *testing.T) {
282
- up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) {
283
- if _, err := io.ReadAll(r.Body); err != nil {
284
- w.WriteHeader(413)
285
- }
286
- })
287
- g := newTestGateway(t, config{Access: "users", MaxBodyBytes: 4}, &fakeAuth{id: identity{User: "bob"}}, up)
288
- hdr := browserPost("tok")
289
- if w := do(g, "POST", base+"/", hdr, "0123456789"); w.Code != 413 {
290
- t.Fatalf("code=%d", w.Code)
291
- }
292
- }
293
-
294
- func TestContainerDownIsServiceUnavailable(t *testing.T) {
295
- l, err := net.Listen("tcp", "127.0.0.1:0")
296
- if err != nil {
297
- t.Fatal(err)
298
- }
299
- addr := l.Addr().String()
300
- l.Close()
301
- g, err := newGateway(config{Upstream: addr, Access: "users"}, &fakeAuth{id: identity{User: "bob"}})
302
- if err != nil {
303
- t.Fatal(err)
304
- }
305
- w := do(g, "GET", base+"/", signedIn, "")
306
- if w.Code != 521 || w.Header().Get("Retry-After") == "" || !strings.Contains(w.Body.String(), "upstream_unavailable") {
307
- t.Fatalf("code=%d retry=%q body=%q", w.Code, w.Header().Get("Retry-After"), w.Body.String())
308
- }
309
- if w.Header().Get("X-Synopkgland-Error") != "upstream_unavailable" || w.Header().Get("X-Synopkgland-Status") != "503" {
310
- t.Errorf("headers: %v", w.Header())
311
- }
312
- }
313
-
314
- func TestSlowContainerIsGatewayTimeout(t *testing.T) {
315
- up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) { time.Sleep(300 * time.Millisecond) })
316
- g := newTestGateway(t, config{Access: "users", ResponseTimeoutMillis: 50}, &fakeAuth{id: identity{User: "bob"}}, up)
317
- w := do(g, "GET", base+"/", signedIn, "")
318
- if w.Code != 524 || !strings.Contains(w.Body.String(), "upstream_timeout") || w.Header().Get("X-Synopkgland-Status") != "504" {
319
- t.Fatalf("code=%d body=%q", w.Code, w.Body.String())
320
- }
321
- }
322
-
323
- func TestContainerCannotSetCookiesOnTheDSMOrigin(t *testing.T) {
324
- up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) {
325
- http.SetCookie(w, &http.Cookie{Name: "id", Value: "evil"})
326
- io.WriteString(w, "ok")
327
- })
328
- g := newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up)
329
- w := do(g, "GET", base+"/", signedIn, "")
330
- if w.Code != 200 || w.Header().Get("Set-Cookie") != "" {
331
- t.Fatalf("code=%d set-cookie=%q", w.Code, w.Header().Get("Set-Cookie"))
332
- }
333
- }
334
-
335
- func TestRedirectsStayInsideTheGateway(t *testing.T) {
336
- up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) {
337
- switch r.URL.Path {
338
- case "/abs":
339
- http.Redirect(w, r, "http://"+r.Host+"/next?q=1", http.StatusFound)
340
- case "/root":
341
- http.Redirect(w, r, "/next", http.StatusFound)
342
- case "/dir/rel":
343
- http.Redirect(w, r, "next", http.StatusFound)
344
- case "/rel":
345
- w.Header().Set("Location", "next")
346
- w.WriteHeader(http.StatusFound)
347
- default:
348
- http.Redirect(w, r, "https://example.org/away", http.StatusFound)
349
- }
350
- })
351
- g := newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up)
352
- for path, want := range map[string]string{
353
- "/abs": base + "/next?q=1",
354
- "/root": base + "/next",
355
- "/rel": "next",
356
- "/dir/rel": base + "/dir/next",
357
- "/ext": "https://example.org/away",
358
- } {
359
- w := do(g, "GET", base+path, signedIn, "")
360
- if got := w.Header().Get("Location"); w.Code != 302 || got != want {
361
- t.Errorf("%s: %d Location=%q want %q", path, w.Code, got, want)
362
- }
363
- }
364
- }
365
-
366
- func TestPathsCannotEscapeTheUpstream(t *testing.T) {
367
- up := newUpstream(t, hello)
368
- g := newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up)
369
- for _, target := range []string{
370
- base + "/../../etc/passwd",
371
- base + "/%2e%2e/%2e%2e/x",
372
- base + "//evil.example/x",
373
- base + "/a/../../x",
374
- base + "/x%00y",
375
- } {
376
- if w := do(g, "GET", target, signedIn, ""); w.Code != 400 {
377
- t.Errorf("%s: code=%d", target, w.Code)
378
- }
379
- }
380
- if up.hits.Load() != 0 {
381
- t.Fatalf("hits=%d", up.hits.Load())
382
- }
383
- }
384
-
385
- func TestPathWithoutTrailingSlashRedirectsSoRelativeLinksWork(t *testing.T) {
386
- up := newUpstream(t, hello)
387
- g := newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up)
388
- w := do(g, "GET", base+"?x=1", signedIn, "")
389
- if w.Code != 308 || w.Header().Get("Location") != "gateway.cgi/?x=1" {
390
- t.Fatalf("code=%d location=%q", w.Code, w.Header().Get("Location"))
391
- }
392
- }
393
-
394
- func TestRequestOutsideTheGatewayIsNotFound(t *testing.T) {
395
- up := newUpstream(t, hello)
396
- g := newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up)
397
- if w := do(g, "GET", "/somewhere/else", signedIn, ""); w.Code != 410 || w.Header().Get("X-Synopkgland-Status") != "404" {
398
- t.Fatalf("code=%d headers=%v", w.Code, w.Header())
399
- }
400
- }
401
-
402
- func TestResponsesAreNotSharedCacheable(t *testing.T) {
403
- up := newUpstream(t, hello)
404
- g := newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up)
405
- w := do(g, "GET", base+"/", signedIn, "")
406
- if cc := w.Header().Get("Cache-Control"); !strings.Contains(cc, "private") {
407
- t.Errorf("Cache-Control = %q", cc)
408
- }
409
- }
410
-
411
- // DSM's nginx has `scgi_intercept_errors on` and `error_page 403 404 500 502 503 504`: a CGI reply with one of
412
- // these statuses loses its body and headers to DSM's generic error page. Measured on DSM 7.4-90069.
413
- func TestNoGatewayStatusIsOneThatDSMReplaces(t *testing.T) {
414
- replaced := []int{403, 404, 500, 502, 503, 504}
415
- for _, intended := range replaced {
416
- w := httptest.NewRecorder()
417
- reply(w, intended, "some_code", "message")
418
- if slices.Contains(replaced, w.Code) || w.Code < 400 {
419
- t.Errorf("reply(%d) sent %d, which DSM replaces or is not an error", intended, w.Code)
420
- }
421
- if got := w.Header().Get("X-Synopkgland-Status"); got != strconv.Itoa(intended) {
422
- t.Errorf("reply(%d): X-Synopkgland-Status = %q", intended, got)
423
- }
424
- if w.Header().Get("X-Synopkgland-Error") != "some_code" || !strings.HasPrefix(w.Body.String(), "some_code\n") {
425
- t.Errorf("reply(%d): error code lost: %v %q", intended, w.Header(), w.Body.String())
426
- }
427
- }
428
- }
429
-
430
- func TestStatusesDSMKeepsAreSentAsTheyAre(t *testing.T) {
431
- for _, status := range []int{400, 401, 413, 501, 308} {
432
- w := httptest.NewRecorder()
433
- reply(w, status, "some_code", "message")
434
- if w.Code != status || w.Header().Get("X-Synopkgland-Status") != "" || w.Header().Get("X-Synopkgland-Error") != "some_code" {
435
- t.Errorf("reply(%d): sent %d headers=%v", status, w.Code, w.Header())
436
- }
437
- }
438
- }
439
-
440
- func TestContainerErrorBodiesSurviveDSMWhenTheAppAsks(t *testing.T) {
441
- up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) {
442
- w.Header().Set("Content-Type", "application/json")
443
- w.Header().Set("Retry-After", "7")
444
- w.Header().Set("X-App-Reason", "no-such-note")
445
- w.WriteHeader(map[string]int{"/missing": 404, "/boom": 500, "/busy": 503, "/denied": 401}[r.URL.Path])
446
- io.WriteString(w, `{"error":"x"}`)
447
- })
448
- g := newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up)
449
- keep := map[string]string{"Cookie": "id=abc", "X-Synopkgland-Keep-Error-Body": "1"}
450
- for path, want := range map[string][2]int{"/missing": {404, 410}, "/boom": {500, 520}, "/busy": {503, 521}, "/denied": {401, 401}} {
451
- plain := do(g, "GET", base+path, signedIn, "")
452
- if plain.Code != want[0] || plain.Header().Get("X-Synopkgland-Status") != "" {
453
- t.Errorf("%s without the header: %d %v, want the container's own %d", path, plain.Code, plain.Header(), want[0])
454
- }
455
- w := do(g, "GET", base+path, keep, "")
456
- if w.Code != want[1] || w.Body.String() != `{"error":"x"}` || w.Header().Get("Retry-After") != "7" || w.Header().Get("X-App-Reason") != "no-such-note" {
457
- t.Errorf("%s with the header: %d %q %v", path, w.Code, w.Body.String(), w.Header())
458
- }
459
- if want[0] != want[1] && w.Header().Get("X-Synopkgland-Status") != strconv.Itoa(want[0]) {
460
- t.Errorf("%s: X-Synopkgland-Status = %q", path, w.Header().Get("X-Synopkgland-Status"))
461
- }
462
- }
463
- }
464
-
465
- func TestKeepErrorBodyLeavesSuccessAndRedirectsAlone(t *testing.T) {
466
- up := newUpstream(t, hello)
467
- g := newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up)
468
- w := do(g, "GET", base+"/", map[string]string{"Cookie": "id=abc", "X-Synopkgland-Keep-Error-Body": "1"}, "")
469
- if w.Code != 200 || w.Header().Get("X-Synopkgland-Status") != "" {
470
- t.Fatalf("%d %v", w.Code, w.Header())
471
- }
472
- }
473
-
474
- // Server-Sent Events work through DSM (nginx does not buffer SCGI replies there), provided the gateway hands
475
- // each event on as it arrives instead of holding it until the container finishes.
476
- func TestServerSentEventsAreForwardedAsTheyArrive(t *testing.T) {
477
- release := make(chan struct{})
478
- gone := make(chan struct{})
479
- up := newUpstream(t, func(w http.ResponseWriter, r *http.Request) {
480
- w.Header().Set("Content-Type", "text/event-stream")
481
- io.WriteString(w, "data: one\n\n")
482
- w.(http.Flusher).Flush()
483
- select {
484
- case <-release:
485
- io.WriteString(w, "data: two\n\n")
486
- case <-r.Context().Done():
487
- close(gone)
488
- }
489
- })
490
- srv := httptest.NewServer(newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up))
491
- defer srv.Close()
492
- req, _ := http.NewRequest("GET", srv.URL+base+"/events", nil)
493
- req.Header.Set("Cookie", "id=abc")
494
- resp, err := http.DefaultClient.Do(req)
495
- if err != nil {
496
- t.Fatal(err)
497
- }
498
- line := make(chan string, 1)
499
- go func() {
500
- buf := make([]byte, 64)
501
- n, _ := resp.Body.Read(buf)
502
- line <- string(buf[:n])
503
- }()
504
- select {
505
- case got := <-line:
506
- if got != "data: one\n\n" {
507
- t.Fatalf("first event = %q", got)
508
- }
509
- case <-time.After(2 * time.Second):
510
- t.Fatal("the first event was held back while the container kept the stream open")
511
- }
512
- resp.Body.Close()
513
- select {
514
- case <-gone:
515
- case <-time.After(2 * time.Second):
516
- t.Fatal("the container was not told the client went away")
517
- }
518
- }
519
-
520
- // wsEchoHandler is a container that accepts a WebSocket upgrade and then answers every chunk with "echo:"+chunk.
521
- func wsEchoHandler(w http.ResponseWriter, r *http.Request) {
522
- conn, brw, err := w.(http.Hijacker).Hijack()
523
- if err != nil {
524
- return
525
- }
526
- defer conn.Close()
527
- io.WriteString(brw, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n")
528
- brw.Flush()
529
- buf := make([]byte, 64)
530
- for {
531
- n, err := brw.Read(buf)
532
- if n > 0 {
533
- io.WriteString(conn, "echo:"+string(buf[:n]))
534
- }
535
- if err != nil {
536
- return
537
- }
538
- }
539
- }
540
-
541
- func dialUpgrade(t *testing.T, srv *httptest.Server, headers string) (net.Conn, *bufio.Reader, string) {
542
- t.Helper()
543
- conn, err := net.Dial("tcp", srv.Listener.Addr().String())
544
- if err != nil {
545
- t.Fatal(err)
546
- }
547
- t.Cleanup(func() { conn.Close() })
548
- conn.SetDeadline(time.Now().Add(5 * time.Second))
549
- io.WriteString(conn, "GET "+base+"/ws HTTP/1.1\r\nHost: "+srv.Listener.Addr().String()+"\r\nCookie: id=abc\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n"+headers+"\r\n")
550
- r := bufio.NewReader(conn)
551
- status, err := r.ReadString('\n')
552
- if err != nil {
553
- t.Fatal(err)
554
- }
555
- return conn, r, strings.TrimSpace(status)
556
- }
557
-
558
- func TestWebSocketUpgradeIsProxiedBothWays(t *testing.T) {
559
- up := newUpstream(t, wsEchoHandler)
560
- srv := httptest.NewServer(newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up))
561
- defer srv.Close()
562
- conn, r, status := dialUpgrade(t, srv, "Origin: http://"+srv.Listener.Addr().String()+"\r\n")
563
- if status != "HTTP/1.1 101 Switching Protocols" {
564
- t.Fatalf("status = %q", status)
565
- }
566
- for line, err := r.ReadString('\n'); strings.TrimSpace(line) != ""; line, err = r.ReadString('\n') {
567
- if err != nil {
568
- t.Fatal(err)
569
- }
570
- }
571
- io.WriteString(conn, "hello")
572
- got := make([]byte, len("echo:hello"))
573
- if _, err := io.ReadFull(r, got); err != nil || string(got) != "echo:hello" {
574
- t.Fatalf("echo = %q, %v", got, err)
575
- }
576
- h := up.request.Load().Header
577
- if h.Get("X-Syno-User") != "bob" || h.Get("Cookie") != "" || h.Get("Upgrade") != "websocket" {
578
- t.Errorf("headers at the container: %v", h)
579
- }
580
- if up.request.Load().Host != srv.Listener.Addr().String() {
581
- t.Errorf("Host at the container = %q, want the one the browser used so that same-origin checks pass", up.request.Load().Host)
582
- }
583
- }
584
-
585
- // upgradeRefusal reads the rest of a refused handshake and returns its X-Synopkgland-Error and -Status values.
586
- func upgradeRefusal(t *testing.T, r *bufio.Reader) (code, intended string) {
587
- t.Helper()
588
- for line, err := r.ReadString('\n'); strings.TrimSpace(line) != ""; line, err = r.ReadString('\n') {
589
- if err != nil {
590
- t.Fatal(err)
591
- }
592
- name, value, _ := strings.Cut(strings.TrimSpace(line), ": ")
593
- switch strings.ToLower(name) {
594
- case "x-synopkgland-error":
595
- code = value
596
- case "x-synopkgland-status":
597
- intended = value
598
- }
599
- }
600
- return code, intended
601
- }
602
-
603
- func TestWebSocketFromAnotherOriginIsRefused(t *testing.T) {
604
- up := newUpstream(t, wsEchoHandler)
605
- srv := httptest.NewServer(newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up))
606
- defer srv.Close()
607
- own := srv.Listener.Addr().String()
608
- for name, headers := range map[string]string{
609
- "foreign origin": "Origin: https://evil.example\r\n",
610
- "sub-domain of host": "Origin: http://" + own + ".evil.example\r\n",
611
- "origin null": "Origin: null\r\n",
612
- "fetch cross-site": "Sec-Fetch-Site: cross-site\r\n",
613
- "fetch same-site": "Sec-Fetch-Site: same-site\r\n",
614
- "cross-site, own host": "Sec-Fetch-Site: cross-site\r\nOrigin: http://" + own + "\r\n",
615
- } {
616
- _, r, status := dialUpgrade(t, srv, headers)
617
- code, intended := upgradeRefusal(t, r)
618
- if status != "HTTP/1.1 401 Unauthorized" || code != "cross_site_blocked" || intended != "403" {
619
- t.Errorf("%s: %q %q %q", name, status, code, intended)
620
- }
621
- }
622
- if up.hits.Load() != 0 {
623
- t.Fatalf("hits=%d", up.hits.Load())
624
- }
625
- }
626
-
627
- // Browsers always send Origin on a WebSocket handshake, so a handshake with neither Origin nor
628
- // Sec-Fetch-Site is not from a browser that can be trusted to scope it: the rule is the one state-changing requests use.
629
- func TestWebSocketWithoutAnyOriginEvidenceIsRefused(t *testing.T) {
630
- up := newUpstream(t, wsEchoHandler)
631
- srv := httptest.NewServer(newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up))
632
- defer srv.Close()
633
- _, r, status := dialUpgrade(t, srv, "")
634
- code, _ := upgradeRefusal(t, r)
635
- if status != "HTTP/1.1 401 Unauthorized" || code != "cross_site_blocked" || up.hits.Load() != 0 {
636
- t.Fatalf("%q %q hits=%d", status, code, up.hits.Load())
637
- }
638
- }
639
-
640
- func TestWebSocketWithOneKindOfSameOriginEvidenceIsAllowed(t *testing.T) {
641
- up := newUpstream(t, wsEchoHandler)
642
- srv := httptest.NewServer(newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up))
643
- defer srv.Close()
644
- for name, headers := range map[string]string{
645
- "origin only": "Origin: http://" + srv.Listener.Addr().String() + "\r\n",
646
- "fetch metadata only": "Sec-Fetch-Site: same-origin\r\n",
647
- } {
648
- if _, _, status := dialUpgrade(t, srv, headers); status != "HTTP/1.1 101 Switching Protocols" {
649
- t.Errorf("%s: %q", name, status)
650
- }
651
- }
652
- }
653
-
654
- func TestWebSocketNeedsTheSameAccessAsAnyRequest(t *testing.T) {
655
- up := newUpstream(t, wsEchoHandler)
656
- for name, auth := range map[string]*fakeAuth{"anonymous": {err: errNoSession}, "outsider": {id: identity{User: "bob", Groups: []string{"users"}}}} {
657
- srv := httptest.NewServer(newTestGateway(t, config{Access: "admins"}, auth, up))
658
- _, _, status := dialUpgrade(t, srv, "")
659
- srv.Close()
660
- if status != "HTTP/1.1 401 Unauthorized" {
661
- t.Errorf("%s: %q", name, status)
662
- }
663
- }
664
- if up.hits.Load() != 0 {
665
- t.Fatalf("hits=%d", up.hits.Load())
666
- }
667
- }
668
-
669
- func TestOtherProtocolUpgradesAreNotSupported(t *testing.T) {
670
- up := newUpstream(t, hello)
671
- g := newTestGateway(t, config{Access: "users"}, &fakeAuth{id: identity{User: "bob"}}, up)
672
- w := do(g, "GET", base+"/", map[string]string{"Cookie": "id=abc", "Connection": "Upgrade", "Upgrade": "h2c"}, "")
673
- if w.Code != 501 || up.hits.Load() != 0 || w.Header().Get("X-Synopkgland-Error") != "upgrade_unsupported" {
674
- t.Fatalf("code=%d hits=%d headers=%v", w.Code, up.hits.Load(), w.Header())
675
- }
676
- }
@@ -1,5 +0,0 @@
1
- module synopkgland-gateway
2
-
3
- go 1.24
4
-
5
- toolchain go1.26.9