Skip to content

Commit cb12598

Browse files
committed
HTTP proxy support for SSH in GitOps
1 parent 85633fc commit cb12598

5 files changed

Lines changed: 1196 additions & 1 deletion

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ require (
4747
go.uber.org/mock v0.6.0
4848
go.uber.org/zap v1.27.1
4949
golang.org/x/crypto v0.48.0
50+
golang.org/x/net v0.50.0
5051
golang.org/x/sync v0.20.0
5152
gonum.org/v1/gonum v0.17.0
5253
gotest.tools v2.2.0+incompatible
@@ -191,7 +192,6 @@ require (
191192
go.yaml.in/yaml/v2 v2.4.3 // indirect
192193
go.yaml.in/yaml/v3 v3.0.4 // indirect
193194
golang.org/x/mod v0.33.0 // indirect
194-
golang.org/x/net v0.50.0 // indirect
195195
golang.org/x/oauth2 v0.35.0 // indirect
196196
golang.org/x/sys v0.41.0 // indirect
197197
golang.org/x/term v0.40.0 // indirect

internal/cmd/cli/gitcloner/cloner.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/rancher/fleet/internal/cmd/cli/gitcloner/submodule"
1818
fleetgithub "github.com/rancher/fleet/internal/github"
1919
fleetssh "github.com/rancher/fleet/internal/ssh"
20+
fleetgit "github.com/rancher/fleet/pkg/git"
2021
giturls "github.com/rancher/fleet/pkg/git-urls"
2122
)
2223

@@ -91,6 +92,7 @@ func cloneBranch(opts *GitCloner, auth transport.AuthMethod, caBundle []byte) er
9192
ReferenceName: plumbing.ReferenceName(opts.Branch),
9293
RecurseSubmodules: git.NoRecurseSubmodules,
9394
Tags: git.NoTags,
95+
ProxyOptions: fleetgit.ProxyOptsFromEnvironment(opts.Repo),
9496
})
9597

9698
if err != nil {
@@ -120,6 +122,7 @@ func cloneRevision(opts *GitCloner, auth transport.AuthMethod, caBundle []byte)
120122
CABundle: caBundle,
121123
RecurseSubmodules: git.NoRecurseSubmodules,
122124
Tags: git.NoTags,
125+
ProxyOptions: fleetgit.ProxyOptsFromEnvironment(opts.Repo),
123126
})
124127
if err != nil {
125128
return fmt.Errorf("failed to clone repo from revision %s: %w", repo(opts), err)

pkg/git/proxy.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package git
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"crypto/tls"
7+
"encoding/base64"
8+
"fmt"
9+
"net"
10+
"net/http"
11+
"net/url"
12+
"strings"
13+
14+
"github.com/go-git/go-git/v5/plumbing/transport"
15+
"golang.org/x/net/http/httpproxy"
16+
"golang.org/x/net/proxy"
17+
)
18+
19+
func init() {
20+
proxy.RegisterDialerType("http", newHTTPConnectDialer)
21+
proxy.RegisterDialerType("https", newHTTPConnectDialer)
22+
}
23+
24+
// ProxyOptsFromEnvironment reads the standard HTTP_PROXY / HTTPS_PROXY /
25+
// NO_PROXY environment variables and returns a transport.ProxyOptions value
26+
// ready to be embedded in go-git CloneOptions or ListOptions.
27+
//
28+
// Why this is necessary: go-git's HTTP transport uses http.DefaultTransport,
29+
// which already honors HTTP_PROXY / HTTPS_PROXY natively. However, go-git's
30+
// SSH transport only routes through a proxy when ProxyOptions.URL is non-empty
31+
// — it never reads the proxy env vars itself. Without wiring ProxyOptions the
32+
// registered httpConnectDialer would never be invoked for SSH repos.
33+
//
34+
// Proxy selection and NO_PROXY matching are delegated to
35+
// golang.org/x/net/http/httpproxy, which follows the same rules as net/http.
36+
// SSH and scp-style repos are looked up as https:// because SSH traffic is
37+
// tunnelled through a CONNECT proxy the same way HTTPS is. Both HTTP_PROXY
38+
// and HTTPS_PROXY work; HTTPS_PROXY is checked first for SSH URLs.
39+
func ProxyOptsFromEnvironment(repoURL string) transport.ProxyOptions {
40+
if repoURL == "" {
41+
return transport.ProxyOptions{}
42+
}
43+
44+
proxyFn := httpproxy.FromEnvironment().ProxyFunc()
45+
46+
// HTTP/HTTPS URLs are passed directly. SSH and scp-style URLs are looked
47+
// up as https:// so that HTTPS_PROXY is preferred, with HTTP_PROXY as
48+
// fallback (SSH traffic tunnels through CONNECT like HTTPS does).
49+
var proxyURL *url.URL
50+
if u, err := url.Parse(repoURL); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
51+
// proxyFn error means the configured proxy URL is malformed; treat as
52+
// no proxy (proxyURL stays nil and we return empty opts below).
53+
proxyURL, _ = proxyFn(u)
54+
} else {
55+
// SSH/scp-style: we have no scheme to pass directly, so synthesize
56+
// lookup URLs. Try https first (HTTPS_PROXY) then http (HTTP_PROXY).
57+
// NO_PROXY is checked per-host by proxyFn regardless of scheme.
58+
// Only fall back to HTTP_PROXY when the https lookup returns (nil, nil)
59+
// — i.e. HTTPS_PROXY is simply not set. An error means HTTPS_PROXY is
60+
// set but malformed, so we stop rather than silently use a different proxy.
61+
host := hostFromRepoURL(repoURL)
62+
var err error
63+
proxyURL, err = proxyFn(&url.URL{Scheme: "https", Host: host})
64+
if proxyURL == nil && err == nil {
65+
proxyURL, _ = proxyFn(&url.URL{Scheme: "http", Host: host})
66+
}
67+
}
68+
69+
if proxyURL == nil {
70+
return transport.ProxyOptions{}
71+
}
72+
73+
opts := transport.ProxyOptions{URL: proxyURL.String()}
74+
if proxyURL.User != nil {
75+
opts.Username = proxyURL.User.Username()
76+
opts.Password, _ = proxyURL.User.Password()
77+
}
78+
return opts
79+
}
80+
81+
// hostFromRepoURL extracts the hostname from a git repository URL.
82+
// It handles ssh:// and scp-style (git@host:path) URLs.
83+
func hostFromRepoURL(repoURL string) string {
84+
if u, err := url.Parse(repoURL); err == nil && u.Host != "" {
85+
return u.Hostname()
86+
}
87+
// scp-style: git@github.com:org/repo.git
88+
if _, after, ok := strings.Cut(repoURL, "@"); ok {
89+
rest := after
90+
if before, _, ok := strings.Cut(rest, ":"); ok {
91+
return before
92+
}
93+
}
94+
return repoURL
95+
}
96+
97+
// httpConnectDialer tunnels a TCP connection through an HTTP proxy using the
98+
// CONNECT method, as understood by Squid and most other HTTP forward proxies.
99+
// It implements both proxy.Dialer and proxy.ContextDialer so that go-git's SSH
100+
// transport, which calls proxy.FromURL and then asserts proxy.ContextDialer,
101+
// works transparently with HTTP_PROXY / HTTPS_PROXY environment variables.
102+
type httpConnectDialer struct {
103+
proxyURL *url.URL
104+
forward proxy.ContextDialer
105+
tlsConfig *tls.Config // nil means use system defaults; only consulted when proxyURL.Scheme == "https"
106+
}
107+
108+
// newHTTPConnectDialer is the factory registered with proxy.RegisterDialerType.
109+
// The forward argument is typed as proxy.Dialer by the library's API; we
110+
// require it to also implement proxy.ContextDialer. In practice the only
111+
// caller is go-git, which always passes proxy.Direct — a type that implements
112+
// both interfaces.
113+
func newHTTPConnectDialer(proxyURL *url.URL, forward proxy.Dialer) (proxy.Dialer, error) {
114+
if forward == nil {
115+
forward = proxy.Direct
116+
}
117+
fwd, ok := forward.(proxy.ContextDialer)
118+
if !ok {
119+
return nil, fmt.Errorf("http connect proxy: forward dialer %T does not implement proxy.ContextDialer", forward)
120+
}
121+
return &httpConnectDialer{
122+
proxyURL: proxyURL,
123+
forward: fwd,
124+
}, nil
125+
}
126+
127+
// Dial implements proxy.Dialer.
128+
func (d *httpConnectDialer) Dial(network, addr string) (net.Conn, error) {
129+
return d.DialContext(context.Background(), network, addr)
130+
}
131+
132+
// DialContext implements proxy.ContextDialer.
133+
// It opens a connection to the proxy and issues an HTTP CONNECT request to
134+
// tunnel to addr. On success it returns the raw conn ready for use by the
135+
// SSH handshake.
136+
func (d *httpConnectDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
137+
proxyAddr := d.proxyURL.Host
138+
if d.proxyURL.Port() == "" {
139+
switch d.proxyURL.Scheme {
140+
case "https":
141+
proxyAddr = net.JoinHostPort(d.proxyURL.Hostname(), "443")
142+
default:
143+
proxyAddr = net.JoinHostPort(d.proxyURL.Hostname(), "3128")
144+
}
145+
}
146+
147+
conn, err := d.forward.DialContext(ctx, "tcp", proxyAddr)
148+
if err != nil {
149+
return nil, fmt.Errorf("http connect proxy: dial proxy %s: %w", proxyAddr, err)
150+
}
151+
152+
// For https:// proxy URLs, upgrade the plain TCP connection to TLS before
153+
// sending the CONNECT request. This protects the Proxy-Authorization header
154+
// and the CONNECT line itself from eavesdropping on the path to the proxy.
155+
if d.proxyURL.Scheme == "https" {
156+
tlsCfg := d.tlsConfig
157+
if tlsCfg == nil {
158+
tlsCfg = &tls.Config{ServerName: d.proxyURL.Hostname()}
159+
} else {
160+
tlsCfg = tlsCfg.Clone()
161+
if tlsCfg.ServerName == "" {
162+
tlsCfg.ServerName = d.proxyURL.Hostname()
163+
}
164+
}
165+
tlsConn := tls.Client(conn, tlsCfg)
166+
// HandshakeContext honors ctx cancellation/deadline internally.
167+
if err := tlsConn.HandshakeContext(ctx); err != nil {
168+
conn.Close()
169+
if ctxErr := ctx.Err(); ctxErr != nil {
170+
return nil, fmt.Errorf("http connect proxy: TLS handshake with proxy %s: %w", proxyAddr, ctxErr)
171+
}
172+
return nil, fmt.Errorf("http connect proxy: TLS handshake with proxy %s: %w", proxyAddr, err)
173+
}
174+
conn = tlsConn
175+
}
176+
177+
// Neither req.Write(conn) nor http.ReadResponse observe ctx on their own,
178+
// so close the connection from a goroutine if ctx is cancelled or times
179+
// out. Closing the conn unblocks any in-progress Write/Read immediately.
180+
// The goroutine is started after the TLS block so it captures the final
181+
// value of conn (plain or TLS) without a data race.
182+
done := make(chan struct{})
183+
defer close(done)
184+
go func() {
185+
select {
186+
case <-ctx.Done():
187+
conn.Close()
188+
case <-done:
189+
}
190+
}()
191+
192+
// Send the CONNECT request over the connection.
193+
req := &http.Request{
194+
Method: http.MethodConnect,
195+
URL: &url.URL{Opaque: addr},
196+
Host: addr,
197+
Header: make(http.Header),
198+
}
199+
req.Header.Set("User-Agent", "git/fleet")
200+
201+
if user := d.proxyURL.User; user != nil {
202+
username := user.Username()
203+
password, _ := user.Password()
204+
creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
205+
req.Header.Set("Proxy-Authorization", "Basic "+creds)
206+
}
207+
208+
if err := req.Write(conn); err != nil {
209+
conn.Close()
210+
if ctxErr := ctx.Err(); ctxErr != nil {
211+
return nil, fmt.Errorf("http connect proxy: write CONNECT request: %w", ctxErr)
212+
}
213+
return nil, fmt.Errorf("http connect proxy: write CONNECT request: %w", err)
214+
}
215+
216+
resp, err := http.ReadResponse(bufio.NewReader(conn), req)
217+
if err != nil {
218+
conn.Close()
219+
if ctxErr := ctx.Err(); ctxErr != nil {
220+
return nil, fmt.Errorf("http connect proxy: read CONNECT response: %w", ctxErr)
221+
}
222+
return nil, fmt.Errorf("http connect proxy: read CONNECT response: %w", err)
223+
}
224+
resp.Body.Close()
225+
226+
if resp.StatusCode != http.StatusOK {
227+
conn.Close()
228+
return nil, fmt.Errorf("http connect proxy: CONNECT to %s via %s failed with status %d %s",
229+
addr, proxyAddr, resp.StatusCode, resp.Status)
230+
}
231+
232+
return conn, nil
233+
}

0 commit comments

Comments
 (0)