// Package proxy implements the HTTP adapter for the reverse proxy. package proxy import ( "context" "errors" "fmt" "io" "net" "net/http" "net/http/httputil" "net/url" "slices" "strconv" "strings" "time" "github.com/bnema/zerowrap" gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper" "github.com/bnema/gordon/internal/adapters/in/http/middleware" "github.com/bnema/gordon/internal/domain" ) // newAppTransport creates the shared HTTP/1.1 transport for proxying to application containers. func newAppTransport() *http.Transport { return &http.Transport{ DialContext: (&net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 30 * time.Second, MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, } } // newH2CTransport creates a dedicated cleartext HTTP/2 transport for containers // that opt in via the gordon.proxy.protocol=h2c label. // Uses Go 1.24+ native UnencryptedHTTP2 protocol support. // HTTP/1 is disabled so http:// URLs use HTTP/2 prior-knowledge. func newH2CTransport() *http.Transport { var protos http.Protocols protos.SetUnencryptedHTTP2(true) // Do NOT set HTTP1 — this forces HTTP/2 for http:// URLs. return &http.Transport{ Protocols: &protos, DialContext: (&net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, ResponseHeaderTimeout: 30 * time.Second, MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, } } // newRegistryTransport creates the transport for the registry reverse proxy (localhost loopback). // It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy // perform blocking operations (image pull, container start, readiness checks). func newRegistryTransport() *http.Transport { return &http.Transport{ DialContext: (&net.Dialer{ Timeout: 10 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, ResponseHeaderTimeout: 3 * time.Minute, MaxIdleConns: 20, MaxIdleConnsPerHost: 10, IdleConnTimeout: 90 * time.Second, } } // transportForTarget selects the appropriate HTTP transport based on the target's protocol. func (h *Handler) transportForTarget(target *domain.ProxyTarget) http.RoundTripper { if target.Protocol == "h2c" { return h.h2cTransport } return h.appTransport } // forwardToTarget proxies a request to the resolved target. func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget, maxResponseSize int64) { log := zerowrap.FromCtx(r.Context()) targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port)))) if err != nil { log.WrapErr(err, "failed to parse target URL") proxyError(w, "Internal Server Error", http.StatusInternalServerError) return } releaseInFlight := h.proxySvc.TrackInFlight(target.ContainerID) defer releaseInFlight() transport := h.transportForTarget(target) proxy := newReverseProxy(reverseProxyOptions{ targetURL: targetURL, hostHeader: targetHostHeader(target, targetURL), forwardedHost: target.RouteHost, transport: transport, incomingReq: r, trustedNets: h.trustedNets, errorHandler: newProxyErrorHandler( log.WithField("target", targetURL.String()), proxyErrorHandlerConfig{ requestTooLargeLog: "proxy error: request body too large", clientDisconnectLog: "proxy request canceled by client", upstreamErrorLog: "proxy error: connection failed", upstreamErrorBody: "Service Unavailable", }, ), modifyResponse: modifyResponse(maxResponseSize), }) if target.Protocol == "h2c" { log.Debug(). Str("target", targetURL.String()). Str("protocol", "h2c"). Str(zerowrap.FieldEntityID, target.ContainerID). Msg("proxying request via h2c") } else { log.Debug(). Str("target", targetURL.String()). Str(zerowrap.FieldEntityID, target.ContainerID). Msg("proxying request") } proxy.ServeHTTP(w, r) } // forwardToRegistry proxies a request to the local registry server. func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, registryPort int) { h.proxySvc.TrackRegistryRequest() defer h.proxySvc.ReleaseRegistryRequest() log := zerowrap.FromCtx(r.Context()) targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", registryPort)) if err != nil { log.WrapErr(err, "failed to parse registry target URL") proxyError(w, "Internal Server Error", http.StatusInternalServerError) return } proxy := &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(targetURL) pr.SetXForwarded() pr.Out.Host = targetURL.Host }, Transport: h.registryTransport, ErrorHandler: newProxyErrorHandler( log.WithField("registry_port", registryPort), proxyErrorHandlerConfig{ requestTooLargeLog: "registry proxy error: request body too large", clientDisconnectLog: "registry proxy request canceled by client", upstreamErrorLog: "registry proxy error", upstreamErrorBody: "Registry Unavailable", }, ), // Strip browser-oriented security headers from upstream registry responses // to avoid conflicts with Gordon's own headers on browser-facing routes. ModifyResponse: func(resp *http.Response) error { resp.Header.Del("X-Content-Type-Options") resp.Header.Del("X-Frame-Options") resp.Header.Del("X-XSS-Protection") resp.Header.Del("Referrer-Policy") resp.Header.Del("Permissions-Policy") resp.Header.Del("Content-Security-Policy") resp.Header.Del("Strict-Transport-Security") return nil }, } log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry") proxy.ServeHTTP(w, r) } type proxyErrorHandlerConfig struct { requestTooLargeLog string clientDisconnectLog string upstreamErrorLog string upstreamErrorBody string } func newProxyErrorHandler(log zerowrap.Logger, cfg proxyErrorHandlerConfig) func(http.ResponseWriter, *http.Request, error) { return func(w http.ResponseWriter, _ *http.Request, proxyErr error) { switch { case isRequestBodyTooLargeError(proxyErr): log.Warn().Err(proxyErr).Msg(cfg.requestTooLargeLog) proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) case isClientDisconnectError(proxyErr): log.Debug().Err(proxyErr).Msg(cfg.clientDisconnectLog) clientClosedRequest(w) default: log.Error().Err(proxyErr).Msg(cfg.upstreamErrorLog) proxyError(w, cfg.upstreamErrorBody, http.StatusServiceUnavailable) } } } func isRequestBodyTooLargeError(err error) bool { _, ok := errors.AsType[*http.MaxBytesError](err) return ok } type reverseProxyOptions struct { targetURL *url.URL hostHeader string forwardedHost string transport http.RoundTripper incomingReq *http.Request trustedNets []*net.IPNet errorHandler func(http.ResponseWriter, *http.Request, error) modifyResponse func(*http.Response) error } func targetHostHeader(target *domain.ProxyTarget, targetURL *url.URL) string { if target.RouteHost != "" { return target.RouteHost } if target.OriginalHost != "" { return net.JoinHostPort(target.OriginalHost, targetURL.Port()) } return targetURL.Host } // newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent // hop-by-hop header attacks. A malicious client could send "Connection: Authorization" // to strip the Authorization header when using the default Director. Rewrite processes // headers after hop-by-hop removal, ensuring headers like Authorization are preserved. // // incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the // request originates from a trusted upstream proxy, and to resolve the client IP // sent upstream in X-Real-IP and X-Forwarded-For. func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy { return &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { existingProto := pr.In.Header.Get("X-Forwarded-Proto") pr.SetURL(opts.targetURL) pr.SetXForwarded() setClientIPHeaders(pr, opts.trustedNets) if opts.hostHeader != "" { pr.Out.Host = opts.hostHeader } if opts.forwardedHost != "" { pr.Out.Header.Set("X-Forwarded-Host", opts.forwardedHost) } // Preserve X-Forwarded-Proto from trusted upstream proxies. // SetXForwarded() unconditionally sets it based on the incoming scheme // (HTTP between proxies), but when a trusted proxy already sent the // real client proto, we must honor it. if existingProto != "" && gordonhttp.IsTrustedSource(opts.incomingReq, opts.trustedNets) { pr.Out.Header.Set("X-Forwarded-Proto", existingProto) } }, Transport: opts.transport, ErrorHandler: opts.errorHandler, ModifyResponse: opts.modifyResponse, } } // clientIPHeaders are the headers carrying a client IP that Gordon rewrites // before proxying to a container, in canonical form. var clientIPHeaders = []string{"X-Real-Ip", "X-Forwarded-For", "Cf-Connecting-Ip"} // setClientIPHeaders replaces client-supplied IP headers with the client IP // resolved by the same trusted-proxy rules Gordon uses for its own logs and // rate limits, so upstream apps can always trust X-Real-IP and X-Forwarded-For. // Cf-Connecting-Ip is only kept when it is the IP resolved from a trusted // Cloudflare peer. func setClientIPHeaders(pr *httputil.ProxyRequest, trustedNets []*net.IPNet) { cfIP := pr.Out.Header.Get("Cf-Connecting-Ip") deleteClientIPHeaders(pr.Out.Header) clientIP := middleware.GetClientIP(pr.In, trustedNets) parsed := net.ParseIP(clientIP) if parsed == nil { // No usable peer address (e.g. a Unix socket): send no client IP at all. return } pr.Out.Header.Set("X-Real-IP", clientIP) pr.Out.Header.Set("X-Forwarded-For", clientIP) if middleware.IsTrustedCloudflarePeer(pr.In, trustedNets) && parsed.Equal(net.ParseIP(strings.TrimSpace(cfIP))) { pr.Out.Header.Set("Cf-Connecting-Ip", clientIP) } } // deleteClientIPHeaders removes every client IP header, including spellings // with underscores (X_Real_IP). Go keeps those under a separate key, but // CGI-style servers map both "-" and "_" to the same HTTP_X_REAL_IP variable, // so a client could otherwise override the value set by Gordon. func deleteClientIPHeaders(h http.Header) { for key := range h { normalized := http.CanonicalHeaderKey(strings.ReplaceAll(key, "_", "-")) if slices.Contains(clientIPHeaders, normalized) { delete(h, key) } } } // modifyResponse returns a function that adds proxy headers and enforces response size limits. func modifyResponse(maxResponseSize int64) func(*http.Response) error { return func(resp *http.Response) error { if maxResponseSize > 0 { if resp.ContentLength > maxResponseSize { resp.Body.Close() const msg = "Response Too Large" resp.StatusCode = http.StatusBadGateway resp.Body = io.NopCloser(strings.NewReader(msg)) resp.ContentLength = int64(len(msg)) resp.Header.Set("Content-Type", "text/plain") resp.Header.Set("Cache-Control", "no-store") resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") resp.Header.Del("Transfer-Encoding") resp.Header.Del("Content-Encoding") resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10)) return nil } if resp.ContentLength < 0 { resp.Body = &limitedReadCloser{ ReadCloser: resp.Body, remaining: maxResponseSize, } } } return nil } } // limitedReadCloser wraps an io.ReadCloser with a byte limit. type limitedReadCloser struct { io.ReadCloser remaining int64 closed bool } func (l *limitedReadCloser) Read(p []byte) (int, error) { if l.remaining <= 0 { if !l.closed { l.closed = true l.ReadCloser.Close() } return 0, fmt.Errorf("response body exceeded maximum size limit (remaining: %d bytes)", l.remaining) } toRead := len(p) if int64(toRead) > l.remaining { toRead = int(l.remaining) } n, err := l.ReadCloser.Read(p[:toRead]) // Cap n to the requested slice to guard against misbehaving readers // that return more bytes than the buffer length. if n > toRead { n = toRead } l.remaining -= int64(n) if l.remaining < 0 { l.remaining = 0 } return n, err } func (l *limitedReadCloser) Close() error { if l.closed { return nil } l.closed = true return l.ReadCloser.Close() } const statusClientClosedRequest = 499 func isClientDisconnectError(err error) bool { return errors.Is(err, context.Canceled) || errors.Is(err, http.ErrAbortHandler) } func clientClosedRequest(w http.ResponseWriter) { setProxyGeneratedResponseHeaders(w) w.WriteHeader(statusClientClosedRequest) } // proxyError writes an error response with security headers appropriate for // proxy-generated error pages. func proxyError(w http.ResponseWriter, msg string, code int) { setProxyGeneratedResponseHeaders(w) http.Error(w, msg, code) } func setProxyGeneratedResponseHeaders(w http.ResponseWriter) { w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") w.Header().Set("Cache-Control", "no-store") }