@synopackageland/cli 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +26 -16
- package/dist/build-spk/generator.d.ts +3 -2
- package/dist/build-spk/generator.js +6 -846
- package/dist/build-spk/resource-worker.d.ts +2 -14
- package/dist/build-spk/resource-worker.js +3 -166
- package/dist/cli.js +208 -56
- package/dist/compose/analyze.d.ts +3 -61
- package/dist/compose/analyze.js +35 -776
- package/dist/compose/env.d.ts +5 -3
- package/dist/compose/env.js +17 -25
- package/dist/compose/runner.js +23 -6
- package/dist/contract/web-launch.d.ts +1 -12
- package/dist/contract/web-launch.js +10 -74
- package/dist/ctl/cli.d.ts +36 -0
- package/dist/ctl/cli.js +218 -0
- package/dist/ctl/descriptor.d.ts +81 -0
- package/dist/ctl/descriptor.js +93 -0
- package/dist/ctl/dsm-auth.d.ts +10 -0
- package/dist/ctl/dsm-auth.js +30 -0
- package/dist/ctl/errors.d.ts +17 -0
- package/dist/ctl/errors.js +32 -0
- package/dist/ctl/flows.d.ts +11 -0
- package/dist/ctl/flows.js +52 -0
- package/dist/ctl/input.d.ts +20 -0
- package/dist/ctl/input.js +128 -0
- package/dist/ctl/json.d.ts +2 -0
- package/dist/ctl/json.js +38 -0
- package/dist/ctl/redaction.d.ts +15 -0
- package/dist/ctl/redaction.js +198 -0
- package/dist/ctl/service.d.ts +38 -0
- package/dist/ctl/service.js +156 -0
- package/dist/ctl/store.d.ts +51 -0
- package/dist/ctl/store.js +93 -0
- package/dist/ctl/transfers.d.ts +14 -0
- package/dist/ctl/transfers.js +74 -0
- package/dist/ctl/transport.d.ts +11 -0
- package/dist/ctl/transport.js +79 -0
- package/dist/deploy/deploy.d.ts +4 -3
- package/dist/deploy/deploy.js +5 -210
- package/dist/deploy/plan.js +26 -7
- package/dist/dev.js +5 -4
- package/dist/error-registry.js +1020 -834
- package/dist/info.js +120 -28
- package/dist/init.js +188 -64
- package/dist/inspect.js +21 -6
- package/dist/runtime/executor-digests.json +1 -12
- package/dist/skill.js +51 -9
- package/dist/standalone/api-description-inspect.d.ts +4 -0
- package/dist/standalone/api-description-inspect.js +12 -0
- package/dist/standalone/api-description.d.ts +7 -0
- package/dist/standalone/api-description.js +73 -0
- package/dist/standalone/build.d.ts +3 -0
- package/dist/standalone/build.js +140 -33
- package/dist/standalone/compose-errors.d.ts +3 -0
- package/dist/standalone/compose-errors.js +243 -0
- package/dist/standalone/compose.d.ts +97 -10
- package/dist/standalone/compose.js +306 -75
- package/dist/standalone/daemon.js +111 -29
- package/dist/standalone/dsm-window.js +39 -8
- package/dist/standalone/identity-gateway.d.ts +9 -2
- package/dist/standalone/identity-gateway.js +282 -66
- package/dist/standalone/inspect-spk.d.ts +12 -0
- package/dist/standalone/inspect-spk.js +666 -136
- package/dist/standalone/lifecycle.js +124 -42
- package/dist/standalone/manifest.d.ts +8 -1
- package/dist/standalone/manifest.js +303 -86
- package/dist/standalone/resource.js +6 -2
- package/dist/standalone/spk.d.ts +4 -1
- package/dist/standalone/spk.js +138 -39
- package/dist/template-paths.d.ts +0 -2
- package/dist/template-paths.js +0 -5
- package/dist/test-command.d.ts +1 -1
- package/dist/test-command.js +1 -1
- package/dist/validate.d.ts +3 -3
- package/dist/validate.js +32 -140
- package/package.json +5 -3
- package/schema/synopkgland-api-v1.schema.json +423 -0
- package/skills/SKILL.md +3 -1
- package/skills/reference/compose.md +349 -0
- package/skills/reference/create.md +8 -17
- package/skills/reference/ctl.md +41 -0
- package/skills/reference/deploy.md +1 -1
- package/skills/reference/files.md +4 -0
- package/skills/reference/identity-gateway.md +8 -144
- package/skills/reference/wrap.md +10 -39
- package/assets/go/daemon/fixture_linux_test.go +0 -15
- package/assets/go/daemon/fixture_other_test.go +0 -7
- package/assets/go/daemon/go.mod +0 -5
- package/assets/go/daemon/main.go +0 -636
- package/assets/go/daemon/main_test.go +0 -315
- package/assets/go/daemon/network.go +0 -107
- package/assets/go/daemon/network_test.go +0 -86
- package/assets/go/daemon/process_linux.go +0 -55
- package/assets/go/daemon/process_linux_test.go +0 -161
- package/assets/go/daemon/process_other.go +0 -14
- package/assets/go/daemon/testdata/child.go +0 -44
- package/assets/go/gateway/auth.go +0 -101
- package/assets/go/gateway/auth_test.go +0 -216
- package/assets/go/gateway/cgi.go +0 -133
- package/assets/go/gateway/cgi_test.go +0 -156
- package/assets/go/gateway/csrf.go +0 -73
- package/assets/go/gateway/csrf_test.go +0 -155
- package/assets/go/gateway/directory.go +0 -108
- package/assets/go/gateway/directory_test.go +0 -202
- package/assets/go/gateway/gateway.go +0 -412
- package/assets/go/gateway/gateway_test.go +0 -676
- package/assets/go/gateway/go.mod +0 -5
- package/assets/go/gateway/groups.go +0 -66
- package/assets/go/gateway/main.go +0 -45
- package/dist/build-spk/gateway.d.ts +0 -31
- package/dist/build-spk/gateway.js +0 -60
- package/dist/compose/privilege.d.ts +0 -14
- package/dist/compose/privilege.js +0 -102
|
@@ -1,412 +0,0 @@
|
|
|
1
|
-
package main
|
|
2
|
-
|
|
3
|
-
// The gateway is the only door to a package's container. DSM runs it as the
|
|
4
|
-
// package account's CGI behind the DSM web server, so a request reaches the
|
|
5
|
-
// container only after DSM's own session check, the access rule of the
|
|
6
|
-
// package, and (for state-changing methods) a SynoToken.
|
|
7
|
-
|
|
8
|
-
import (
|
|
9
|
-
"context"
|
|
10
|
-
"errors"
|
|
11
|
-
"fmt"
|
|
12
|
-
"io"
|
|
13
|
-
"net"
|
|
14
|
-
"net/http"
|
|
15
|
-
"net/http/httputil"
|
|
16
|
-
"net/url"
|
|
17
|
-
"regexp"
|
|
18
|
-
"slices"
|
|
19
|
-
"strconv"
|
|
20
|
-
"strings"
|
|
21
|
-
"time"
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
const (
|
|
25
|
-
accessUsers = "users"
|
|
26
|
-
accessAdmins = "admins"
|
|
27
|
-
accessGroups = "groups"
|
|
28
|
-
|
|
29
|
-
adminGroup = "administrators"
|
|
30
|
-
|
|
31
|
-
// gatewayScript is the CGI file name; what follows it in the URL path is the
|
|
32
|
-
// path inside the container.
|
|
33
|
-
gatewayScript = "/gateway.cgi"
|
|
34
|
-
|
|
35
|
-
// keepErrorBodyHeader is the request header with which an app asks that its own error replies reach it
|
|
36
|
-
// whole: see keepErrorBody.
|
|
37
|
-
keepErrorBodyHeader = "X-Synopkgland-Keep-Error-Body"
|
|
38
|
-
|
|
39
|
-
defaultMaxBodyBytes = 64 << 20
|
|
40
|
-
defaultResponseTimeout = 60 * time.Second
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
type config struct {
|
|
44
|
-
// Upstream is the container's published loopback address, host:port.
|
|
45
|
-
Upstream string `json:"upstream"`
|
|
46
|
-
// Access is users (any signed-in DSM user), admins or groups.
|
|
47
|
-
Access string `json:"access"`
|
|
48
|
-
Groups []string `json:"groups,omitempty"`
|
|
49
|
-
|
|
50
|
-
MaxBodyBytes int64 `json:"maxBodyBytes,omitempty"`
|
|
51
|
-
ResponseTimeoutMillis int `json:"responseTimeoutMillis,omitempty"`
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
var groupName = regexp.MustCompile(`^(?:[A-Za-z0-9._-]{1,64}|[A-Za-z0-9._-]{1,64}\\[A-Za-z0-9._-](?:[A-Za-z0-9._ -]{0,126}[A-Za-z0-9._-])?|[A-Za-z0-9._-](?:[A-Za-z0-9._ -]{0,126}[A-Za-z0-9._-])?@[A-Za-z0-9._-]{1,64})$`)
|
|
55
|
-
|
|
56
|
-
func (c config) validate() error {
|
|
57
|
-
host, port, err := net.SplitHostPort(c.Upstream)
|
|
58
|
-
if err != nil || (host != "127.0.0.1" && host != "::1") {
|
|
59
|
-
return fmt.Errorf("upstream %q must be a loopback host:port", c.Upstream)
|
|
60
|
-
}
|
|
61
|
-
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
|
|
62
|
-
return fmt.Errorf("upstream port %q is invalid", port)
|
|
63
|
-
}
|
|
64
|
-
switch c.Access {
|
|
65
|
-
case accessUsers, accessAdmins:
|
|
66
|
-
case accessGroups:
|
|
67
|
-
if len(c.Groups) == 0 {
|
|
68
|
-
return errors.New("access groups needs at least one group")
|
|
69
|
-
}
|
|
70
|
-
for _, g := range c.Groups {
|
|
71
|
-
if !groupName.MatchString(g) {
|
|
72
|
-
return fmt.Errorf("group %q is invalid", g)
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
default:
|
|
76
|
-
return fmt.Errorf("access %q must be users, admins or groups", c.Access)
|
|
77
|
-
}
|
|
78
|
-
return nil
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
type gateway struct {
|
|
82
|
-
cfg config
|
|
83
|
-
auth authenticator
|
|
84
|
-
proxy *httputil.ReverseProxy
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// newGateway refuses an unusable configuration or a missing authenticator, so
|
|
88
|
-
// a mistake fails at startup instead of opening the door.
|
|
89
|
-
func newGateway(cfg config, auth authenticator) (*gateway, error) {
|
|
90
|
-
if auth == nil {
|
|
91
|
-
return nil, errors.New("newGateway needs an authenticator")
|
|
92
|
-
}
|
|
93
|
-
if err := cfg.validate(); err != nil {
|
|
94
|
-
return nil, err
|
|
95
|
-
}
|
|
96
|
-
if cfg.MaxBodyBytes <= 0 {
|
|
97
|
-
cfg.MaxBodyBytes = defaultMaxBodyBytes
|
|
98
|
-
}
|
|
99
|
-
timeout := defaultResponseTimeout
|
|
100
|
-
if cfg.ResponseTimeoutMillis > 0 {
|
|
101
|
-
timeout = time.Duration(cfg.ResponseTimeoutMillis) * time.Millisecond
|
|
102
|
-
}
|
|
103
|
-
g := &gateway{cfg: cfg, auth: auth}
|
|
104
|
-
g.proxy = &httputil.ReverseProxy{
|
|
105
|
-
Rewrite: g.rewrite,
|
|
106
|
-
ModifyResponse: g.modifyResponse,
|
|
107
|
-
ErrorHandler: g.proxyError,
|
|
108
|
-
Transport: &http.Transport{
|
|
109
|
-
Proxy: nil,
|
|
110
|
-
DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext,
|
|
111
|
-
ResponseHeaderTimeout: timeout,
|
|
112
|
-
DisableKeepAlives: true,
|
|
113
|
-
},
|
|
114
|
-
}
|
|
115
|
-
return g, nil
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
type ctxKey struct{}
|
|
119
|
-
|
|
120
|
-
// gatewayRequest is what ServeHTTP worked out before handing over to the proxy.
|
|
121
|
-
type gatewayRequest struct {
|
|
122
|
-
prefix string // escaped URL path up to and including the CGI file name
|
|
123
|
-
rest string // escaped path inside the container, starting with "/"
|
|
124
|
-
id identity
|
|
125
|
-
// keepErrorBody: the app asked for DSM-safe statuses on the container's own error replies.
|
|
126
|
-
keepErrorBody bool
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
130
|
-
// DSM's CGI gateway cuts the reply short if the CGI exits with request
|
|
131
|
-
// bytes unread, so drain whatever the handling below did not consume.
|
|
132
|
-
defer io.Copy(io.Discard, io.LimitReader(r.Body, g.cfg.MaxBodyBytes))
|
|
133
|
-
|
|
134
|
-
prefix, rest, ok := splitGatewayPath(r.URL.EscapedPath())
|
|
135
|
-
if !ok {
|
|
136
|
-
reply(w, http.StatusNotFound, "not_found", "Not a gateway path.")
|
|
137
|
-
return
|
|
138
|
-
}
|
|
139
|
-
id, err := g.auth.Authenticate(r)
|
|
140
|
-
switch {
|
|
141
|
-
case errors.Is(err, errNoSession):
|
|
142
|
-
reply(w, http.StatusUnauthorized, "session_required", "Sign in to DSM to open this app.")
|
|
143
|
-
return
|
|
144
|
-
case err != nil:
|
|
145
|
-
reply(w, http.StatusInternalServerError, "auth_unavailable", "DSM could not verify the session.")
|
|
146
|
-
return
|
|
147
|
-
}
|
|
148
|
-
if !g.permits(id) {
|
|
149
|
-
reply(w, http.StatusForbidden, "forbidden", "Your DSM account is not allowed to open this app.")
|
|
150
|
-
return
|
|
151
|
-
}
|
|
152
|
-
if !safeMethod(r.Method) {
|
|
153
|
-
if !wellFormedToken(r.Header.Get("X-SYNO-TOKEN")) {
|
|
154
|
-
reply(w, http.StatusForbidden, "token_required", "State-changing requests need the DSM SynoToken.")
|
|
155
|
-
return
|
|
156
|
-
}
|
|
157
|
-
if !sameOrigin(r) {
|
|
158
|
-
reply(w, http.StatusForbidden, "cross_site_blocked", "State-changing requests must come from this DSM's own pages.")
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
if isUpgrade(r) {
|
|
163
|
-
switch {
|
|
164
|
-
case !isWebSocket(r):
|
|
165
|
-
reply(w, http.StatusNotImplemented, "upgrade_unsupported", "Only WebSocket upgrades pass through the DSM gateway.")
|
|
166
|
-
return
|
|
167
|
-
case !sameOrigin(r):
|
|
168
|
-
// A browser cannot add the SynoToken header to a WebSocket handshake, so the same-origin evidence
|
|
169
|
-
// (the rule state-changing requests use) is what keeps another site from opening a socket with the user's DSM cookie.
|
|
170
|
-
reply(w, http.StatusForbidden, "cross_site_blocked", "The WebSocket must be opened from this DSM's own pages.")
|
|
171
|
-
return
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
if rest == "" {
|
|
175
|
-
// Relative links in the app only resolve under a trailing slash.
|
|
176
|
-
location := strings.TrimPrefix(gatewayScript, "/") + "/"
|
|
177
|
-
if r.URL.RawQuery != "" {
|
|
178
|
-
location += "?" + r.URL.RawQuery
|
|
179
|
-
}
|
|
180
|
-
w.Header().Set("Location", location)
|
|
181
|
-
reply(w, http.StatusPermanentRedirect, "redirect", "Open the app with a trailing slash.")
|
|
182
|
-
return
|
|
183
|
-
}
|
|
184
|
-
if !safeContainerPath(rest) {
|
|
185
|
-
reply(w, http.StatusBadRequest, "bad_path", "The path is not allowed.")
|
|
186
|
-
return
|
|
187
|
-
}
|
|
188
|
-
r.Body = http.MaxBytesReader(w, r.Body, g.cfg.MaxBodyBytes)
|
|
189
|
-
g.proxy.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, gatewayRequest{prefix: prefix, rest: rest, id: id, keepErrorBody: r.Header.Get(keepErrorBodyHeader) == "1"})))
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
func (g *gateway) permits(id identity) bool {
|
|
193
|
-
switch g.cfg.Access {
|
|
194
|
-
case accessUsers:
|
|
195
|
-
return true
|
|
196
|
-
case accessAdmins:
|
|
197
|
-
return slices.Contains(id.Groups, adminGroup)
|
|
198
|
-
default:
|
|
199
|
-
return slices.ContainsFunc(id.Groups, func(group string) bool { return slices.Contains(g.cfg.Groups, group) })
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
func splitGatewayPath(escaped string) (prefix, rest string, ok bool) {
|
|
204
|
-
i := strings.Index(escaped, gatewayScript)
|
|
205
|
-
if i < 0 {
|
|
206
|
-
return "", "", false
|
|
207
|
-
}
|
|
208
|
-
end := i + len(gatewayScript)
|
|
209
|
-
rest = escaped[end:]
|
|
210
|
-
if rest != "" && rest[0] != '/' {
|
|
211
|
-
return "", "", false
|
|
212
|
-
}
|
|
213
|
-
return escaped[:end], rest, true
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// safeContainerPath keeps the request inside the container's own URL space:
|
|
217
|
-
// no dot segments, no authority-looking "//", no NUL, no backslash.
|
|
218
|
-
func safeContainerPath(escaped string) bool {
|
|
219
|
-
decoded, err := url.PathUnescape(escaped)
|
|
220
|
-
if err != nil || strings.HasPrefix(decoded, "//") || strings.ContainsAny(decoded, "\x00\\") {
|
|
221
|
-
return false
|
|
222
|
-
}
|
|
223
|
-
return !slices.Contains(strings.Split(decoded, "/"), "..") && !slices.Contains(strings.Split(decoded, "/"), ".")
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
func safeMethod(method string) bool {
|
|
227
|
-
return method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
func isUpgrade(r *http.Request) bool {
|
|
231
|
-
if r.Header.Get("Upgrade") != "" {
|
|
232
|
-
return true
|
|
233
|
-
}
|
|
234
|
-
for _, v := range r.Header.Values("Connection") {
|
|
235
|
-
for _, token := range strings.Split(v, ",") {
|
|
236
|
-
if strings.EqualFold(strings.TrimSpace(token), "upgrade") {
|
|
237
|
-
return true
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
return false
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
func isWebSocket(r *http.Request) bool {
|
|
245
|
-
return strings.EqualFold(strings.TrimSpace(r.Header.Get("Upgrade")), "websocket")
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Inbound headers that carry or imitate identity, session or proxy state. The
|
|
249
|
-
// container trusts only the X-Syno-User and X-Syno-Admin the gateway sets
|
|
250
|
-
// after DSM verified the session, so the client's own copies never get through.
|
|
251
|
-
var droppedExact = map[string]bool{"Cookie": true, "Remote-User": true, "Remote-Groups": true, "Remote-Email": true, "Remote-Name": true, "Forwarded": true}
|
|
252
|
-
|
|
253
|
-
func dropsHeader(name string) bool {
|
|
254
|
-
canonical := http.CanonicalHeaderKey(name)
|
|
255
|
-
if droppedExact[canonical] {
|
|
256
|
-
return true
|
|
257
|
-
}
|
|
258
|
-
for _, prefix := range []string{"X-Syno-", "X-Forwarded-", "X-Remote-", "X-Auth", "X-Real-", "Remote-"} {
|
|
259
|
-
if strings.HasPrefix(canonical, prefix) {
|
|
260
|
-
return true
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
return false
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
func (g *gateway) rewrite(pr *httputil.ProxyRequest) {
|
|
267
|
-
gr := pr.In.Context().Value(ctxKey{}).(gatewayRequest)
|
|
268
|
-
decoded, _ := url.PathUnescape(gr.rest)
|
|
269
|
-
pr.Out.URL = &url.URL{Scheme: "http", Host: g.cfg.Upstream, Path: decoded, RawPath: gr.rest, RawQuery: withoutSynoToken(pr.In.URL.RawQuery)}
|
|
270
|
-
pr.Out.Host = g.cfg.Upstream
|
|
271
|
-
if isUpgrade(pr.In) {
|
|
272
|
-
// The Origin was checked against this host; the container's own same-origin check needs to see it too.
|
|
273
|
-
pr.Out.Host = pr.In.Host
|
|
274
|
-
}
|
|
275
|
-
for name := range pr.Out.Header {
|
|
276
|
-
if dropsHeader(name) {
|
|
277
|
-
pr.Out.Header.Del(name)
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
pr.Out.Header.Set("X-Syno-User", gr.id.User)
|
|
281
|
-
pr.Out.Header.Set("X-Syno-Admin", strconv.FormatBool(slices.Contains(gr.id.Groups, adminGroup)))
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// withoutSynoToken keeps the CSRF token out of the container's logs.
|
|
285
|
-
func withoutSynoToken(rawQuery string) string {
|
|
286
|
-
if rawQuery == "" {
|
|
287
|
-
return ""
|
|
288
|
-
}
|
|
289
|
-
var kept []string
|
|
290
|
-
for _, pair := range strings.Split(rawQuery, "&") {
|
|
291
|
-
key, _, _ := strings.Cut(pair, "=")
|
|
292
|
-
if k, err := url.QueryUnescape(key); err == nil && k == "SynoToken" {
|
|
293
|
-
continue
|
|
294
|
-
}
|
|
295
|
-
kept = append(kept, pair)
|
|
296
|
-
}
|
|
297
|
-
return strings.Join(kept, "&")
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
func (g *gateway) modifyResponse(resp *http.Response) error {
|
|
301
|
-
gr := resp.Request.Context().Value(ctxKey{}).(gatewayRequest)
|
|
302
|
-
// The container shares DSM's origin; it must not be able to set or clear
|
|
303
|
-
// DSM's own cookies.
|
|
304
|
-
resp.Header.Del("Set-Cookie")
|
|
305
|
-
if resp.StatusCode != http.StatusSwitchingProtocols {
|
|
306
|
-
resp.Header.Set("Cache-Control", privateCacheControl(resp.Header.Get("Cache-Control")))
|
|
307
|
-
resp.Header.Add("Vary", "Cookie")
|
|
308
|
-
}
|
|
309
|
-
if location := resp.Header.Get("Location"); location != "" {
|
|
310
|
-
resp.Header.Set("Location", g.rewriteLocation(location, gr.prefix))
|
|
311
|
-
}
|
|
312
|
-
if gr.keepErrorBody {
|
|
313
|
-
keepErrorBody(resp)
|
|
314
|
-
}
|
|
315
|
-
return nil
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// keepErrorBody swaps a container status that DSM would replace for its stand-in, so the body and headers
|
|
319
|
-
// the container sent reach the app. Opt-in per request: the app then reads X-Synopkgland-Status for the
|
|
320
|
-
// status the container really sent. Without it the container's own status goes out untouched.
|
|
321
|
-
func keepErrorBody(resp *http.Response) {
|
|
322
|
-
if safe := dsmSafeStatus(resp.StatusCode); safe != resp.StatusCode {
|
|
323
|
-
resp.Header.Set("X-Synopkgland-Status", strconv.Itoa(resp.StatusCode))
|
|
324
|
-
resp.StatusCode, resp.Status = safe, strconv.Itoa(safe)+" "+http.StatusText(safe)
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
func privateCacheControl(value string) string {
|
|
329
|
-
kept := []string{"private"}
|
|
330
|
-
for _, directive := range strings.Split(value, ",") {
|
|
331
|
-
directive = strings.TrimSpace(directive)
|
|
332
|
-
name, _, _ := strings.Cut(strings.ToLower(directive), "=")
|
|
333
|
-
if directive != "" && name != "public" && name != "private" && name != "s-maxage" {
|
|
334
|
-
kept = append(kept, directive)
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
return strings.Join(kept, ", ")
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// rewriteLocation points root-relative and container-absolute redirects back
|
|
341
|
-
// under the gateway path; relative and foreign locations pass through.
|
|
342
|
-
func (g *gateway) rewriteLocation(location, prefix string) string {
|
|
343
|
-
u, err := url.Parse(location)
|
|
344
|
-
if err != nil {
|
|
345
|
-
return location
|
|
346
|
-
}
|
|
347
|
-
switch {
|
|
348
|
-
case u.Host == g.cfg.Upstream && (u.Scheme == "http" || u.Scheme == "https"):
|
|
349
|
-
u.Scheme, u.Host = "", ""
|
|
350
|
-
case u.Scheme != "" || u.Host != "":
|
|
351
|
-
return location
|
|
352
|
-
}
|
|
353
|
-
if !strings.HasPrefix(u.EscapedPath(), "/") {
|
|
354
|
-
return location
|
|
355
|
-
}
|
|
356
|
-
u.Path, u.RawPath = "", prefix+u.EscapedPath()
|
|
357
|
-
u.Path, _ = url.PathUnescape(u.RawPath)
|
|
358
|
-
return u.String()
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
func (g *gateway) proxyError(w http.ResponseWriter, r *http.Request, err error) {
|
|
362
|
-
var tooLarge *http.MaxBytesError
|
|
363
|
-
var netErr net.Error
|
|
364
|
-
var opErr *net.OpError
|
|
365
|
-
switch {
|
|
366
|
-
case errors.As(err, &tooLarge):
|
|
367
|
-
reply(w, http.StatusRequestEntityTooLarge, "body_too_large", "The request body is too large.")
|
|
368
|
-
case errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()):
|
|
369
|
-
reply(w, http.StatusGatewayTimeout, "upstream_timeout", "The app did not answer in time.")
|
|
370
|
-
case errors.As(err, &opErr) && opErr.Op == "dial":
|
|
371
|
-
w.Header().Set("Retry-After", "5")
|
|
372
|
-
reply(w, http.StatusServiceUnavailable, "upstream_unavailable", "The app is not running. Start the package in Package Center and try again.")
|
|
373
|
-
default:
|
|
374
|
-
reply(w, http.StatusBadGateway, "bad_gateway", "The app returned an invalid response.")
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// DSM's nginx runs `scgi_intercept_errors on` with `error_page 403 404 500 502 503 504`, so a CGI reply with
|
|
379
|
-
// one of these statuses loses its body and headers to DSM's generic error page (measured on DSM 7.4-90069;
|
|
380
|
-
// every other status, 4xx and 5xx alike, passes with body and headers intact). Each replaced status has a
|
|
381
|
-
// stand-in the app can read; the status the reply would have had travels in X-Synopkgland-Status.
|
|
382
|
-
var dsmReplacedStatus = map[int]int{
|
|
383
|
-
http.StatusForbidden: http.StatusUnauthorized,
|
|
384
|
-
http.StatusNotFound: http.StatusGone,
|
|
385
|
-
http.StatusInternalServerError: 520, // unregistered (Cloudflare "unknown error"); clients treat it as 500
|
|
386
|
-
http.StatusBadGateway: 520,
|
|
387
|
-
http.StatusServiceUnavailable: 521, // unregistered (Cloudflare "web server is down")
|
|
388
|
-
http.StatusGatewayTimeout: 524, // unregistered (Cloudflare "a timeout occurred")
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
func dsmSafeStatus(status int) int {
|
|
392
|
-
if safe, replaced := dsmReplacedStatus[status]; replaced {
|
|
393
|
-
return safe
|
|
394
|
-
}
|
|
395
|
-
return status
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// reply answers with one of the gateway's own errors. `intended` is the status the error means; the status
|
|
399
|
-
// sent is the one DSM lets through, and X-Synopkgland-Error carries the machine-readable code.
|
|
400
|
-
func reply(w http.ResponseWriter, intended int, code, message string) {
|
|
401
|
-
status := dsmSafeStatus(intended)
|
|
402
|
-
h := w.Header()
|
|
403
|
-
h.Set("Content-Type", "text/plain; charset=utf-8")
|
|
404
|
-
h.Set("Cache-Control", "no-store")
|
|
405
|
-
h.Set("X-Content-Type-Options", "nosniff")
|
|
406
|
-
h.Set("X-Synopkgland-Error", code)
|
|
407
|
-
if status != intended {
|
|
408
|
-
h.Set("X-Synopkgland-Status", strconv.Itoa(intended))
|
|
409
|
-
}
|
|
410
|
-
w.WriteHeader(status)
|
|
411
|
-
fmt.Fprintf(w, "%s\n%s\n", code, message)
|
|
412
|
-
}
|