Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/image-builder-bob/pkg/proxy/proxy.go
2506 views
1
// Copyright (c) 2021 Gitpod GmbH. All rights reserved.
2
// Licensed under the GNU Affero General Public License (AGPL).
3
// See License.AGPL.txt in the project root for license information.
4
5
package proxy
6
7
import (
8
"context"
9
"fmt"
10
"io"
11
"net/http"
12
"net/http/httputil"
13
"net/url"
14
"strings"
15
"sync"
16
17
"github.com/containerd/containerd/remotes/docker"
18
"github.com/gitpod-io/gitpod/common-go/log"
19
"github.com/hashicorp/go-retryablehttp"
20
)
21
22
const authKey = "authKey"
23
24
func NewProxy(host *url.URL, aliases map[string]Repo, mirrorAuth func() docker.Authorizer) (*Proxy, error) {
25
if host.Host == "" || host.Scheme == "" {
26
return nil, fmt.Errorf("host Host or Scheme are missing")
27
}
28
for k, v := range aliases {
29
// We need to translate the default hosts for the Docker registry.
30
// If we don't do this, pulling from docker.io will fail.
31
v.Host, _ = docker.DefaultHost(v.Host)
32
aliases[k] = v
33
}
34
return &Proxy{
35
Host: *host,
36
Aliases: aliases,
37
proxies: make(map[string]*httputil.ReverseProxy),
38
mirrorAuth: mirrorAuth,
39
}, nil
40
}
41
42
type Proxy struct {
43
Host url.URL
44
Aliases map[string]Repo
45
46
mu sync.Mutex
47
proxies map[string]*httputil.ReverseProxy
48
mirrorAuth func() docker.Authorizer
49
}
50
51
type Repo struct {
52
Host string
53
Repo string
54
Tag string
55
Auth func() docker.Authorizer
56
}
57
58
func rewriteDockerAPIURL(u *url.URL, fromRepo, toRepo, host, tag string) {
59
var (
60
from = "/v2/" + strings.Trim(fromRepo, "/") + "/"
61
to = "/v2/" + strings.Trim(toRepo, "/") + "/"
62
)
63
u.Path = to + strings.TrimPrefix(strings.TrimPrefix(u.Path, from), "/")
64
65
// we reset the escaped encoding hint, because EscapedPath will produce a valid encoding.
66
u.RawPath = ""
67
68
if tag != "" {
69
// We're forcing the image tag which only affects manifests. No matter what the user
70
// requested we look at, we'll force the tag to the one we're given.
71
segs := strings.Split(u.Path, "/")
72
if len(segs) >= 2 && segs[len(segs)-2] == "manifests" {
73
// We're on the manifest found, hence the last segment must be the reference.
74
// Even if the reference is a digest, we'll just force it to the tag.
75
// This might break some consumers, but we want to use the tag forcing as a means
76
// of excerting control, hence rather break folks than allow unauthorized access.
77
segs[len(segs)-1] = tag
78
u.Path = strings.Join(segs, "/")
79
}
80
}
81
82
u.Host = host
83
}
84
85
// rewriteNonDockerAPIURL is used when a url has to be rewritten but the url
86
// contains a non docker api path
87
func rewriteNonDockerAPIURL(u *url.URL, fromPrefix, toPrefix, host string) {
88
var (
89
from = "/" + strings.Trim(fromPrefix, "/") + "/"
90
to = "/" + strings.Trim(toPrefix, "/") + "/"
91
)
92
if fromPrefix == "" {
93
from = "/"
94
}
95
if toPrefix == "" {
96
to = "/"
97
}
98
u.Path = to + strings.TrimPrefix(strings.TrimPrefix(u.Path, from), "/")
99
100
// we reset the escaped encoding hint, because EscapedPath will produce a valid encoding.
101
u.RawPath = ""
102
103
u.Host = host
104
}
105
106
// ServeHTTP serves the proxy
107
func (proxy *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
108
ctx := r.Context()
109
110
var (
111
repo *Repo
112
alias string
113
)
114
115
// bypass for crane check
116
if r.URL.Path == "/v2/" {
117
w.WriteHeader(http.StatusOK)
118
_, _ = w.Write([]byte("{}"))
119
return
120
}
121
122
for k, v := range proxy.Aliases {
123
// Docker api request
124
if strings.HasPrefix(r.URL.Path, "/v2/"+k+"/") {
125
repo = &v
126
alias = k
127
rewriteDockerAPIURL(r.URL, alias, repo.Repo, repo.Host, repo.Tag)
128
break
129
}
130
// Non-Docker api request
131
if strings.HasPrefix(r.URL.Path, "/"+k+"/") {
132
// We will use the same repo/alias and its credentials but we will set target
133
// repo as empty
134
repo = &v
135
alias = k
136
rewriteNonDockerAPIURL(r.URL, alias, "", repo.Host)
137
break
138
}
139
}
140
141
// get mirror host
142
if host := r.URL.Query().Get("ns"); host != "" && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
143
host, _ = docker.DefaultHost(host)
144
145
r.URL.Host = host
146
r.Host = host
147
148
auth := proxy.mirrorAuth()
149
r = r.WithContext(context.WithValue(ctx, authKey, auth))
150
151
r.RequestURI = ""
152
proxy.mirror(host).ServeHTTP(w, r)
153
return
154
}
155
156
if repo == nil {
157
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
158
return
159
}
160
161
r.Host = r.URL.Host
162
163
auth := repo.Auth()
164
r = r.WithContext(context.WithValue(ctx, authKey, auth))
165
166
err := auth.Authorize(ctx, r)
167
if err != nil {
168
log.WithError(err).Error("cannot authorize request")
169
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
170
return
171
}
172
173
log.WithField("req", r.URL.Path).Info("serving request")
174
175
r.RequestURI = ""
176
proxy.reverse(alias).ServeHTTP(w, r)
177
}
178
179
// reverse produces an authentication-adding reverse proxy for a given repo alias
180
func (proxy *Proxy) reverse(alias string) *httputil.ReverseProxy {
181
proxy.mu.Lock()
182
defer proxy.mu.Unlock()
183
184
if rp, ok := proxy.proxies[alias]; ok {
185
return rp
186
}
187
188
repo, ok := proxy.Aliases[alias]
189
if !ok {
190
// we don't have an alias, hence don't know what to do other than try and proxy.
191
// At this poing things will probably fail.
192
return nil
193
}
194
rp := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "https", Host: repo.Host})
195
196
client := retryablehttp.NewClient()
197
client.RetryMax = 3
198
client.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
199
if err != nil {
200
log.WithError(err).Warn("saw error during CheckRetry")
201
return false, err
202
}
203
auth, ok := ctx.Value(authKey).(docker.Authorizer)
204
if !ok || auth == nil {
205
return false, nil
206
}
207
if resp.StatusCode == http.StatusUnauthorized {
208
// the docker authorizer only refreshes OAuth tokens after two
209
// successive 401 errors for the same URL. Rather than issue the same
210
// request multiple times to tickle the token-refreshing logic, just
211
// provide the same response twice to trick it into refreshing the
212
// cached OAuth token. Call AddResponses() twice, first to invalidate
213
// the existing token (with two responses), second to fetch a new one
214
// (with one response).
215
// TODO: fix after one of these two PRs are merged and available:
216
// https://github.com/containerd/containerd/pull/8735
217
// https://github.com/containerd/containerd/pull/8388
218
err := auth.AddResponses(ctx, []*http.Response{resp, resp})
219
if err != nil {
220
log.WithError(err).WithField("URL", resp.Request.URL.String()).Warn("cannot add responses although response was Unauthorized")
221
return false, nil
222
}
223
224
err = auth.AddResponses(ctx, []*http.Response{resp})
225
if err != nil {
226
log.WithError(err).WithField("URL", resp.Request.URL.String()).Warn("cannot add responses although response was Unauthorized")
227
return false, nil
228
}
229
230
return true, nil
231
}
232
if resp.StatusCode == http.StatusBadRequest {
233
bodyBytes, err := io.ReadAll(resp.Body)
234
if err != nil {
235
log.WithError(err).WithField("URL", resp.Request.URL.String()).Warn("failed to read response body")
236
}
237
238
log.WithField("URL", resp.Request.URL.String()).WithField("Body", string(bodyBytes)).Warn("bad request")
239
return true, nil
240
}
241
242
return false, nil
243
}
244
client.RequestLogHook = func(l retryablehttp.Logger, r *http.Request, i int) {
245
// Total hack: we need a place to modify the request before retrying, and this log
246
// hook seems to be the only place. We need to modify the request, because
247
// maybe we just added the host authorizer in the previous CheckRetry call.
248
//
249
// The ReverseProxy sets the X-Forwarded-For header with the host machine
250
// address. If on a cluster with IPV6 enabled, this will be "::1" (IPV6 equivalent
251
// of "127.0.0.1"). This can have the knock-on effect of receiving an IPV6
252
// URL, e.g. auth.ipv6.docker.com instead of auth.docker.com which may not
253
// exist. By forcing the value to be "127.0.0.1", we ensure consistency
254
// across clusters.
255
//
256
// @link https://golang.org/src/net/http/httputil/reverseproxy.go
257
r.Header.Set("X-Forwarded-For", "127.0.0.1")
258
259
auth, ok := r.Context().Value(authKey).(docker.Authorizer)
260
if !ok || auth == nil {
261
return
262
}
263
_ = auth.Authorize(r.Context(), r)
264
}
265
client.ResponseLogHook = func(l retryablehttp.Logger, r *http.Response) {}
266
267
rp.Transport = &retryablehttp.RoundTripper{
268
Client: client,
269
}
270
rp.ModifyResponse = func(r *http.Response) error {
271
// Some registries return a Location header which we must rewrite to still push
272
// through this proxy.
273
// We support only relative URLs and not absolute URLs.
274
if loc := r.Header.Get("Location"); loc != "" {
275
lurl, err := url.Parse(loc)
276
if err != nil {
277
return err
278
}
279
280
if strings.HasPrefix(loc, "/v2/") {
281
rewriteDockerAPIURL(lurl, repo.Repo, alias, proxy.Host.Host, "")
282
} else {
283
// since this is a non docker api location we
284
// do not need to process the path.
285
// All docker api URLs always start with /v2/. See spec
286
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#endpoints
287
rewriteNonDockerAPIURL(lurl, "", alias, repo.Host)
288
}
289
290
lurl.Host = proxy.Host.Host
291
// force scheme to http assuming this proxy never runs as https
292
lurl.Scheme = proxy.Host.Scheme
293
r.Header.Set("Location", lurl.String())
294
}
295
296
if r.StatusCode == http.StatusBadGateway {
297
// BadGateway makes containerd retry - we don't want that because we retry the upstream
298
// requests internally.
299
r.StatusCode = http.StatusInternalServerError
300
r.Status = http.StatusText(http.StatusInternalServerError)
301
}
302
303
return nil
304
}
305
proxy.proxies[alias] = rp
306
return rp
307
}
308
309
// mirror produces an authentication-adding reverse proxy for given host
310
func (proxy *Proxy) mirror(host string) *httputil.ReverseProxy {
311
proxy.mu.Lock()
312
defer proxy.mu.Unlock()
313
314
if rp, ok := proxy.proxies[host]; ok {
315
return rp
316
}
317
318
rp := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "https", Host: host})
319
320
client := retryablehttp.NewClient()
321
client.RetryMax = 3
322
client.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
323
if err != nil {
324
log.WithError(err).Warn("saw error during CheckRetry")
325
return false, err
326
}
327
auth, ok := ctx.Value(authKey).(docker.Authorizer)
328
if !ok || auth == nil {
329
return false, nil
330
}
331
if resp.StatusCode == http.StatusUnauthorized {
332
// the docker authorizer only refreshes OAuth tokens after two
333
// successive 401 errors for the same URL. Rather than issue the same
334
// request multiple times to tickle the token-refreshing logic, just
335
// provide the same response twice to trick it into refreshing the
336
// cached OAuth token. Call AddResponses() twice, first to invalidate
337
// the existing token (with two responses), second to fetch a new one
338
// (with one response).
339
// TODO: fix after one of these two PRs are merged and available:
340
// https://github.com/containerd/containerd/pull/8735
341
// https://github.com/containerd/containerd/pull/8388
342
err := auth.AddResponses(ctx, []*http.Response{resp, resp})
343
if err != nil {
344
log.WithError(err).WithField("URL", resp.Request.URL.String()).Warn("cannot add responses although response was Unauthorized")
345
return false, nil
346
}
347
348
err = auth.AddResponses(ctx, []*http.Response{resp})
349
if err != nil {
350
log.WithError(err).WithField("URL", resp.Request.URL.String()).Warn("cannot add responses although response was Unauthorized")
351
return false, nil
352
}
353
return true, nil
354
}
355
if resp.StatusCode == http.StatusBadRequest {
356
log.WithField("URL", resp.Request.URL.String()).Warn("bad request")
357
return true, nil
358
}
359
360
return false, nil
361
}
362
client.RequestLogHook = func(l retryablehttp.Logger, r *http.Request, i int) {
363
// Total hack: we need a place to modify the request before retrying, and this log
364
// hook seems to be the only place. We need to modify the request, because
365
// maybe we just added the host authorizer in the previous CheckRetry call.
366
//
367
// The ReverseProxy sets the X-Forwarded-For header with the host machine
368
// address. If on a cluster with IPV6 enabled, this will be "::1" (IPV6 equivalent
369
// of "127.0.0.1"). This can have the knock-on effect of receiving an IPV6
370
// URL, e.g. auth.ipv6.docker.com instead of auth.docker.com which may not
371
// exist. By forcing the value to be "127.0.0.1", we ensure consistency
372
// across clusters.
373
//
374
// @link https://golang.org/src/net/http/httputil/reverseproxy.go
375
r.Header.Set("X-Forwarded-For", "127.0.0.1")
376
377
auth, ok := r.Context().Value(authKey).(docker.Authorizer)
378
if !ok || auth == nil {
379
return
380
}
381
_ = auth.Authorize(r.Context(), r)
382
}
383
client.ResponseLogHook = func(l retryablehttp.Logger, r *http.Response) {}
384
385
rp.Transport = &retryablehttp.RoundTripper{
386
Client: client,
387
}
388
rp.ModifyResponse = func(r *http.Response) error {
389
if r.StatusCode == http.StatusBadGateway {
390
// BadGateway makes containerd retry - we don't want that because we retry the upstream
391
// requests internally.
392
r.StatusCode = http.StatusInternalServerError
393
r.Status = http.StatusText(http.StatusInternalServerError)
394
}
395
396
return nil
397
}
398
proxy.proxies[host] = rp
399
return rp
400
}
401
402