Skip to content

Commit cc7a0d9

Browse files
committed
[image-builder-bob] Introduced transparent-proxy and extended MapAuthorizer
Details - proxy.go: make the core request-handling logic reusable - transparent_proxy.go: re-used the proxy logic to setup a simple forwarding proxy without any mappings - auth.go: fixed multiple bugs, added tests and introduced handling of GITPOD_IMAGE_AUTH format Tool: gitpod/catfood.gitpod.cloud
1 parent 3702504 commit cc7a0d9

File tree

6 files changed

+303
-27
lines changed

6 files changed

+303
-27
lines changed

components/image-builder-bob/BUILD.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@ packages:
1616
- ["go", "mod", "tidy"]
1717
config:
1818
packaging: app
19+
- name: lib
20+
type: go
21+
deps:
22+
- components/common-go:lib
23+
srcs:
24+
- "cmd/*.go"
25+
- "pkg/**/*.go"
26+
- "main.go"
27+
- "go.mod"
28+
- "go.sum"
29+
config:
30+
packaging: library
31+
dontTest: false
1932
- name: runc-facade
2033
type: go
2134
srcs:

components/image-builder-bob/cmd/proxy.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ var proxyCmd = &cobra.Command{
3737
}
3838
authA, err := proxy.NewAuthorizerFromEnvVar(proxyOpts.AdditionalAuth)
3939
if err != nil {
40-
log.WithError(err).WithField("auth", proxyOpts.Auth).Fatal("cannot unmarshal auth")
40+
log.WithError(err).WithField("additionalAuth", proxyOpts.AdditionalAuth).Fatal("cannot unmarshal additionalAuth")
4141
}
4242
authP = authP.AddIfNotExists(authA)
4343

components/image-builder-bob/pkg/proxy/auth.go

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,27 @@ func (a MapAuthorizer) Authorize(host string) (user, pass string, err error) {
5252
}).Info("authorizing registry access")
5353
}()
5454

55+
// Strip any port from the host if present
56+
host = strings.Split(host, ":")[0]
57+
5558
explicitHostMatcher := func() (authConfig, bool) {
5659
res, ok := a[host]
5760
return res, ok
5861
}
62+
suffixHostMatcher := func() (authConfig, bool) {
63+
var match *authConfig
64+
for k, v := range a {
65+
if strings.HasSuffix(host, k) {
66+
if match == nil || len(k) > len(host) {
67+
match = &v
68+
}
69+
}
70+
}
71+
if match == nil {
72+
return authConfig{}, false
73+
}
74+
return *match, true
75+
}
5976
ecrHostMatcher := func() (authConfig, bool) {
6077
if isECRRegistry(host) {
6178
res, ok := a[DummyECRRegistryDomain]
@@ -71,7 +88,7 @@ func (a MapAuthorizer) Authorize(host string) (user, pass string, err error) {
7188
return authConfig{}, false
7289
}
7390

74-
matchers := []func() (authConfig, bool){explicitHostMatcher, ecrHostMatcher, dockerHubHostMatcher}
91+
matchers := []func() (authConfig, bool){explicitHostMatcher, suffixHostMatcher, ecrHostMatcher, dockerHubHostMatcher}
7592
res, ok := authConfig{}, false
7693
for _, matcher := range matchers {
7794
res, ok = matcher()
@@ -86,12 +103,13 @@ func (a MapAuthorizer) Authorize(host string) (user, pass string, err error) {
86103

87104
user, pass = res.Username, res.Password
88105
if res.Auth != "" {
89-
var auth []byte
90-
auth, err = base64.StdEncoding.DecodeString(res.Auth)
106+
var authBytes []byte
107+
authBytes, err = base64.StdEncoding.DecodeString(res.Auth)
91108
if err != nil {
92109
return
93110
}
94-
segs := strings.Split(string(auth), ":")
111+
auth := strings.TrimSpace(string(authBytes))
112+
segs := strings.Split(auth, ":")
95113
if len(segs) < 2 {
96114
return
97115
}
@@ -145,3 +163,23 @@ func NewAuthorizerFromEnvVar(content string) (auth MapAuthorizer, err error) {
145163
}
146164
return MapAuthorizer(res), nil
147165
}
166+
167+
func NewAuthorizerFromGitpodImageAuth(content string) (auth MapAuthorizer, err error) {
168+
if content == "" {
169+
return nil, nil
170+
}
171+
172+
res := map[string]authConfig{}
173+
hostsCredentials := strings.Split(content, ",")
174+
for _, hostCredentials := range hostsCredentials {
175+
parts := strings.SplitN(hostCredentials, ":", 2)
176+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
177+
log.Debug("Error parsing host credential for authorizer, skipping.")
178+
continue
179+
}
180+
res[parts[0]] = authConfig{
181+
Auth: parts[1],
182+
}
183+
}
184+
return MapAuthorizer(res), nil
185+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) 2025 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+
"testing"
9+
)
10+
11+
func TestAuthorize(t *testing.T) {
12+
type expectation struct {
13+
user string
14+
pass string
15+
err string
16+
}
17+
tests := []struct {
18+
name string
19+
constructor func(string) (MapAuthorizer, error)
20+
input string
21+
testHost string
22+
expected expectation
23+
}{
24+
{
25+
name: "docker auth format - valid credentials",
26+
constructor: NewAuthorizerFromDockerEnvVar,
27+
input: `{"auths": {"registry.example.com": {"auth": "dXNlcjpwYXNz"}}}`, // base64(user:pass)
28+
testHost: "registry.example.com",
29+
expected: expectation{
30+
user: "user",
31+
pass: "pass",
32+
},
33+
},
34+
{
35+
name: "docker auth format - valid credentials - host with port",
36+
constructor: NewAuthorizerFromDockerEnvVar,
37+
input: `{"auths": {"registry.example.com": {"auth": "dXNlcjpwYXNz"}}}`, // base64(user:pass)
38+
testHost: "registry.example.com:443",
39+
expected: expectation{
40+
user: "user",
41+
pass: "pass",
42+
},
43+
},
44+
{
45+
name: "docker auth format - invalid host",
46+
constructor: NewAuthorizerFromDockerEnvVar,
47+
input: `{"auths": {"registry.example.com": {"auth": "dXNlcjpwYXNz"}}}`,
48+
testHost: "wrong.registry.com",
49+
expected: expectation{
50+
user: "",
51+
pass: "",
52+
},
53+
},
54+
{
55+
name: "env var format - valid credentials",
56+
constructor: NewAuthorizerFromEnvVar,
57+
input: `{"registry.example.com": {"auth": "dXNlcjpwYXNz"}}`,
58+
testHost: "registry.example.com",
59+
expected: expectation{
60+
user: "user",
61+
pass: "pass",
62+
},
63+
},
64+
{
65+
name: "env var format - empty input",
66+
constructor: NewAuthorizerFromEnvVar,
67+
input: "",
68+
testHost: "registry.example.com",
69+
expected: expectation{
70+
user: "",
71+
pass: "",
72+
},
73+
},
74+
{
75+
name: "gitpod format - valid credentials",
76+
constructor: NewAuthorizerFromGitpodImageAuth,
77+
input: "registry.example.com:dXNlcjpwYXNz",
78+
testHost: "registry.example.com",
79+
expected: expectation{
80+
user: "user",
81+
pass: "pass",
82+
},
83+
},
84+
{
85+
name: "gitpod format - multiple hosts",
86+
constructor: NewAuthorizerFromGitpodImageAuth,
87+
input: "registry1.example.com:dXNlcjE6cGFzczEK,registry2.example.com:dXNlcjI6cGFzczIK",
88+
testHost: "registry2.example.com",
89+
expected: expectation{
90+
user: "user2",
91+
pass: "pass2",
92+
},
93+
},
94+
{
95+
name: "gitpod format - invalid format",
96+
constructor: NewAuthorizerFromGitpodImageAuth,
97+
input: "invalid:format:with:toomany:colons",
98+
testHost: "registry.example.com",
99+
expected: expectation{
100+
user: "",
101+
pass: "",
102+
},
103+
},
104+
{
105+
name: "gitpod format - empty input",
106+
constructor: NewAuthorizerFromGitpodImageAuth,
107+
input: "",
108+
testHost: "registry.example.com",
109+
expected: expectation{
110+
user: "",
111+
pass: "",
112+
},
113+
},
114+
{
115+
name: "gitpod format - suffix match",
116+
constructor: NewAuthorizerFromDockerEnvVar,
117+
input: `{"auths": {"docker.io": {"auth": "dXNlcjpwYXNz"}}}`,
118+
testHost: "registry.docker.io",
119+
expected: expectation{
120+
user: "user",
121+
pass: "pass",
122+
},
123+
},
124+
}
125+
126+
for _, tt := range tests {
127+
t.Run(tt.name, func(t *testing.T) {
128+
auth, err := tt.constructor(tt.input)
129+
if err != nil {
130+
if tt.expected.err == "" {
131+
t.Errorf("Constructor failed: %s", err)
132+
}
133+
return
134+
}
135+
136+
actualUser, actualPassword, err := auth.Authorize(tt.testHost)
137+
if (err != nil) != (tt.expected.err != "") {
138+
t.Errorf("Authorize() error = %v, wantErr %v", err, tt.expected.err)
139+
return
140+
}
141+
if actualUser != tt.expected.user {
142+
t.Errorf("Authorize() actual user = %v, want %v", actualUser, tt.expected.user)
143+
}
144+
if actualPassword != tt.expected.pass {
145+
t.Errorf("Authorize() actual password = %v, want %v", actualPassword, tt.expected.pass)
146+
}
147+
})
148+
}
149+
}

components/image-builder-bob/pkg/proxy/proxy.go

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import (
1919
"github.com/hashicorp/go-retryablehttp"
2020
)
2121

22-
const authKey = "authKey"
22+
const CONTEXT_KEY_AUTHORIZER = "authKey"
2323

2424
func NewProxy(host *url.URL, aliases map[string]Repo, mirrorAuth func() docker.Authorizer) (*Proxy, error) {
2525
if host.Host == "" || host.Scheme == "" {
@@ -146,7 +146,7 @@ func (proxy *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
146146
r.Host = host
147147

148148
auth := proxy.mirrorAuth()
149-
r = r.WithContext(context.WithValue(ctx, authKey, auth))
149+
r = r.WithContext(context.WithValue(ctx, CONTEXT_KEY_AUTHORIZER, auth))
150150

151151
r.RequestURI = ""
152152
proxy.mirror(host).ServeHTTP(w, r)
@@ -161,7 +161,7 @@ func (proxy *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
161161
r.Host = r.URL.Host
162162

163163
auth := repo.Auth()
164-
r = r.WithContext(context.WithValue(ctx, authKey, auth))
164+
r = r.WithContext(context.WithValue(ctx, CONTEXT_KEY_AUTHORIZER, auth))
165165

166166
err := auth.Authorize(ctx, r)
167167
if err != nil {
@@ -200,7 +200,7 @@ func (proxy *Proxy) reverse(alias string) *httputil.ReverseProxy {
200200
log.WithError(err).Warn("saw error during CheckRetry")
201201
return false, err
202202
}
203-
auth, ok := ctx.Value(authKey).(docker.Authorizer)
203+
auth, ok := ctx.Value(CONTEXT_KEY_AUTHORIZER).(docker.Authorizer)
204204
if !ok || auth == nil {
205205
return false, nil
206206
}
@@ -256,7 +256,7 @@ func (proxy *Proxy) reverse(alias string) *httputil.ReverseProxy {
256256
// @link https://golang.org/src/net/http/httputil/reverseproxy.go
257257
r.Header.Set("X-Forwarded-For", "127.0.0.1")
258258

259-
auth, ok := r.Context().Value(authKey).(docker.Authorizer)
259+
auth, ok := r.Context().Value(CONTEXT_KEY_AUTHORIZER).(docker.Authorizer)
260260
if !ok || auth == nil {
261261
return
262262
}
@@ -315,20 +315,47 @@ func (proxy *Proxy) mirror(host string) *httputil.ReverseProxy {
315315
return rp
316316
}
317317

318+
rp := createAuthenticatingReverseProxy(host)
319+
proxy.proxies[host] = rp
320+
return rp
321+
}
322+
323+
func createAuthenticatingReverseProxy(host string) *httputil.ReverseProxy {
318324
rp := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "https", Host: host})
319325

326+
client := CreateAuthenticatingDockerClient()
327+
rp.Transport = &retryablehttp.RoundTripper{
328+
Client: client,
329+
}
330+
rp.ModifyResponse = func(r *http.Response) error {
331+
if r.StatusCode == http.StatusBadGateway {
332+
// BadGateway makes containerd retry - we don't want that because we retry the upstream
333+
// requests internally.
334+
r.StatusCode = http.StatusInternalServerError
335+
r.Status = http.StatusText(http.StatusInternalServerError)
336+
}
337+
338+
return nil
339+
}
340+
return rp
341+
}
342+
343+
// CreateAuthenticatingDockerClient creates a retryable http client that can authenticate against a docker registry, incl. handling it's idiosyncracies
344+
func CreateAuthenticatingDockerClient() *retryablehttp.Client {
320345
client := retryablehttp.NewClient()
321346
client.RetryMax = 3
322347
client.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
323348
if err != nil {
324349
log.WithError(err).Warn("saw error during CheckRetry")
325350
return false, err
326351
}
327-
auth, ok := ctx.Value(authKey).(docker.Authorizer)
352+
auth, ok := ctx.Value(CONTEXT_KEY_AUTHORIZER).(docker.Authorizer)
328353
if !ok || auth == nil {
354+
log.Warn("no authorizer found in context, won't retry.")
329355
return false, nil
330356
}
331357
if resp.StatusCode == http.StatusUnauthorized {
358+
log.Debug("employing authorizer workaround for 401")
332359
// the docker authorizer only refreshes OAuth tokens after two
333360
// successive 401 errors for the same URL. Rather than issue the same
334361
// request multiple times to tickle the token-refreshing logic, just
@@ -352,6 +379,7 @@ func (proxy *Proxy) mirror(host string) *httputil.ReverseProxy {
352379
}
353380
return true, nil
354381
}
382+
355383
if resp.StatusCode == http.StatusBadRequest {
356384
log.WithField("URL", resp.Request.URL.String()).Warn("bad request")
357385
return true, nil
@@ -374,27 +402,13 @@ func (proxy *Proxy) mirror(host string) *httputil.ReverseProxy {
374402
// @link https://golang.org/src/net/http/httputil/reverseproxy.go
375403
r.Header.Set("X-Forwarded-For", "127.0.0.1")
376404

377-
auth, ok := r.Context().Value(authKey).(docker.Authorizer)
405+
auth, ok := r.Context().Value(CONTEXT_KEY_AUTHORIZER).(docker.Authorizer)
378406
if !ok || auth == nil {
379407
return
380408
}
381409
_ = auth.Authorize(r.Context(), r)
382410
}
383411
client.ResponseLogHook = func(l retryablehttp.Logger, r *http.Response) {}
384412

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
413+
return client
400414
}

0 commit comments

Comments
 (0)