diff --git a/git/config_test.go b/git/config_test.go index efc03ff..4a45df3 100644 --- a/git/config_test.go +++ b/git/config_test.go @@ -1,7 +1,13 @@ package git import ( + "bytes" + "crypto/tls" + "io" + "net/http" + "net/http/httptest" "path/filepath" + "strings" "testing" "github.com/agentuity/proxykit/cache" @@ -35,4 +41,126 @@ func TestDefaultConfig(t *testing.T) { if cfg.MaxReceivePackRequestSize != DefaultMaxReceivePackRequestSize { t.Fatalf("MaxReceivePackRequestSize = %d", cfg.MaxReceivePackRequestSize) } + if cfg.UpstreamScheme != "" { + t.Fatalf("UpstreamScheme = %q, want empty", cfg.UpstreamScheme) + } +} + +func TestConfigValidateUpstreamScheme(t *testing.T) { + for _, scheme := range []string{"", "http", "https"} { + t.Run(scheme, func(t *testing.T) { + cfg := DefaultConfig(t.TempDir()) + cfg.UpstreamScheme = scheme + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } + }) + } + + for _, scheme := range []string{"HTTP", "ftp", "https://"} { + t.Run("invalid_"+scheme, func(t *testing.T) { + cfg := DefaultConfig(t.TempDir()) + cfg.UpstreamScheme = scheme + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() error = nil") + } + if !strings.Contains(err.Error(), "git.UpstreamScheme") { + t.Fatalf("Validate() error = %q", err) + } + }) + } +} + +func TestNewRejectsInvalidUpstreamScheme(t *testing.T) { + cfg := DefaultConfig(t.TempDir()) + cfg.UpstreamScheme = "ftp" + + if _, err := New(cfg); err == nil { + t.Fatal("New() error = nil") + } else if !strings.Contains(err.Error(), "git.UpstreamScheme") { + t.Fatalf("New() error = %q", err) + } +} + +func TestUpstreamSchemeForStreamingAndBufferedRequests(t *testing.T) { + tests := []struct { + name string + configured string + requestUsesTLS bool + want string + }{ + {name: "default plain HTTP", want: "http"}, + {name: "default TLS", requestUsesTLS: true, want: "https"}, + {name: "force HTTP for plain HTTP", configured: "http", want: "http"}, + {name: "force HTTP for TLS", configured: "http", requestUsesTLS: true, want: "http"}, + {name: "force HTTPS for plain HTTP", configured: "https", want: "https"}, + {name: "force HTTPS for TLS", configured: "https", requestUsesTLS: true, want: "https"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, buffered := range []bool{false, true} { + path := "streaming" + if buffered { + path = "buffered" + } + t.Run(path, func(t *testing.T) { + const body = "request body" + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "http://git.example/repo.git/git-upload-pack?service=git-upload-pack", bytes.NewBufferString(body)) + req.RequestURI = req.URL.RequestURI() + if tt.requestUsesTLS { + req.TLS = &tls.ConnectionState{} + } + + var gotURL string + var gotBody string + h := &Handler{ + cfg: Config{UpstreamScheme: tt.configured}, + client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + gotURL = r.URL.String() + requestBody, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read upstream request body: %v", err) + } + gotBody = string(requestBody) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("ok")), + }, nil + })}, + } + + var resp *http.Response + var err error + if buffered { + resp, err = h.doUpstreamRequestWithBody(req, []byte(body)) + } else { + resp, err = h.doUpstreamRequest(req) + } + if err != nil { + t.Fatalf("upstream request error = %v", err) + } + if err := resp.Body.Close(); err != nil { + t.Fatalf("close upstream response body: %v", err) + } + + wantURL := tt.want + "://git.example/repo.git/git-upload-pack?service=git-upload-pack" + if gotURL != wantURL { + t.Fatalf("upstream URL = %q, want %q", gotURL, wantURL) + } + if gotBody != body { + t.Fatalf("upstream body = %q, want %q", gotBody, body) + } + }) + } + }) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) } diff --git a/git/git.go b/git/git.go index 3c323de..5c5a8ea 100644 --- a/git/git.go +++ b/git/git.go @@ -81,6 +81,11 @@ type Config struct { // Default: empty (cache all detected Git hosts). AllowedHosts []string + // UpstreamScheme overrides the scheme used for upstream Git requests. + // Valid values are "http", "https", and empty. When empty, the handler + // uses "https" for TLS requests and "http" for plain HTTP requests. + UpstreamScheme string + // Logger receives cache and proxy diagnostics. A console logger is used when nil. Logger logger.Logger } @@ -182,6 +187,9 @@ func effectivePort(u *url.URL) string { // New creates a new Git proxy Handler. Creates cache directories if they don't exist. // Returns an error if cache initialization fails. func New(cfg Config) (*Handler, error) { + if err := validateUpstreamScheme(cfg.UpstreamScheme); err != nil { + return nil, err + } if cfg.Logger == nil { cfg.Logger = logger.NewConsoleLogger() } @@ -353,6 +361,9 @@ func (cfg *Config) Validate() error { if cfg.MaxPackCacheEntrySize < 0 { return errors.New("git.MaxPackCacheEntrySize must not be negative") } + if err := validateUpstreamScheme(cfg.UpstreamScheme); err != nil { + return err + } for i, host := range cfg.AllowedHosts { if strings.TrimSpace(host) == "" { @@ -366,6 +377,13 @@ func (cfg *Config) Validate() error { return nil } +func validateUpstreamScheme(scheme string) error { + if scheme != "" && scheme != "http" && scheme != "https" { + return fmt.Errorf("git.UpstreamScheme must be empty, http, or https: %q", scheme) + } + return nil +} + // envVarNameRegexp validates environment variable names. var envVarNameRegexp = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) diff --git a/git/handler.go b/git/handler.go index 2b9a799..eafb332 100644 --- a/git/handler.go +++ b/git/handler.go @@ -531,11 +531,7 @@ func (h *Handler) forwardDirect(w http.ResponseWriter, r *http.Request, body []b // doUpstreamRequest creates and sends an upstream request using the original // request's headers (which already have real credentials injected by InjectSecrets). func (h *Handler) doUpstreamRequest(r *http.Request) (*http.Response, error) { - scheme := "https" - if r.TLS == nil { - scheme = "http" - } - targetURL := fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI) + targetURL := fmt.Sprintf("%s://%s%s", h.upstreamScheme(r), r.Host, r.RequestURI) upstreamReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body) if err != nil { @@ -550,11 +546,7 @@ func (h *Handler) doUpstreamRequest(r *http.Request) (*http.Response, error) { // doUpstreamRequestWithBody creates and sends an upstream request using a // pre-buffered body (for upload-pack requests that were buffered for parsing). func (h *Handler) doUpstreamRequestWithBody(r *http.Request, body []byte) (*http.Response, error) { - scheme := "https" - if r.TLS == nil { - scheme = "http" - } - targetURL := fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI) + targetURL := fmt.Sprintf("%s://%s%s", h.upstreamScheme(r), r.Host, r.RequestURI) upstreamReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, bytes.NewReader(body)) if err != nil { @@ -567,6 +559,16 @@ func (h *Handler) doUpstreamRequestWithBody(r *http.Request, body []byte) (*http return h.client.Do(upstreamReq) } +func (h *Handler) upstreamScheme(r *http.Request) string { + if h.cfg.UpstreamScheme != "" { + return h.cfg.UpstreamScheme + } + if r.TLS == nil { + return "http" + } + return "https" +} + // serveUpstreamResponse forwards an upstream response to the client unchanged. func (h *Handler) serveUpstreamResponse(w http.ResponseWriter, resp *http.Response) { defer resp.Body.Close()