Skip to content

Commit cffb23c

Browse files
committed
[image-builder-bob] Introduced forward-proxy and extended MapAuthorizer
Details - proxy.go: make the core request-handling logic reusable - forward_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 cffb23c

File tree

6 files changed

+338
-31
lines changed

6 files changed

+338
-31
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+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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+
"net/http"
10+
"net/http/httputil"
11+
"net/url"
12+
"strings"
13+
"sync"
14+
15+
"github.com/containerd/containerd/remotes/docker"
16+
"github.com/gitpod-io/gitpod/common-go/log"
17+
)
18+
19+
func NewForwardProxy(authorizer func() docker.Authorizer, scheme string) (*ForwardProxy, error) {
20+
return &ForwardProxy{
21+
authorizer: authorizer,
22+
scheme: scheme,
23+
proxies: make(map[string]*httputil.ReverseProxy),
24+
}, nil
25+
}
26+
27+
// ForwardProxy acts as forward proxy, injecting authentication to requests
28+
// It uses the same docker-specific retry-and-authenticate logic as the reverse/mirror proxy in proxy.go
29+
type ForwardProxy struct {
30+
authorizer func() docker.Authorizer
31+
scheme string
32+
33+
mu sync.Mutex
34+
proxies map[string]*httputil.ReverseProxy
35+
}
36+
37+
// ServeHTTP serves the proxy
38+
func (p *ForwardProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
39+
ctx := r.Context()
40+
41+
// Prepare for forwarding
42+
r.RequestURI = ""
43+
44+
auth := p.authorizer()
45+
r = r.WithContext(context.WithValue(ctx, CONTEXT_KEY_AUTHORIZER, auth)) // auth might be used in the forward proxy below
46+
47+
err := auth.Authorize(ctx, r)
48+
if err != nil {
49+
log.WithError(err).Error("cannot authorize request")
50+
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
51+
return
52+
}
53+
54+
// Construct target URL
55+
targetUrl, err := parseTargetURL(r.Host, p.scheme)
56+
if err != nil {
57+
log.WithError(err).Error("cannot parse host to determine target URL")
58+
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
59+
return
60+
}
61+
p.authenticatingProxy(targetUrl).ServeHTTP(w, r)
62+
}
63+
64+
func parseTargetURL(targetHost string, scheme string) (*url.URL, error) {
65+
// to safely parse the URL, we need to make sure it has a scheme
66+
parts := strings.Split(targetHost, "://")
67+
if len(parts) == 1 {
68+
targetHost = scheme + "://" + parts[0]
69+
} else if len(parts) == 2 {
70+
targetHost = scheme + "://" + parts[1]
71+
} else {
72+
targetHost = scheme + "://" + parts[len(parts)-1]
73+
}
74+
targetUrl, err := url.Parse(targetHost)
75+
if err != nil {
76+
return nil, err
77+
}
78+
79+
return targetUrl, nil
80+
}
81+
82+
func (p *ForwardProxy) authenticatingProxy(targetUrl *url.URL) *httputil.ReverseProxy {
83+
p.mu.Lock()
84+
defer p.mu.Unlock()
85+
86+
if rp, ok := p.proxies[targetUrl.Host]; ok {
87+
return rp
88+
}
89+
rp := createAuthenticatingReverseProxy(targetUrl)
90+
p.proxies[targetUrl.Host] = rp
91+
return rp
92+
}

0 commit comments

Comments
 (0)