1// Package proxy implements the HTTP adapter for the reverse proxy.
2package proxy
3
4import (
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "net"
10 "net/http"
11 "net/http/httputil"
12 "net/url"
13 "slices"
14 "strconv"
15 "strings"
16 "time"
17
18 "github.com/bnema/zerowrap"
19
20 gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
21 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
22 "github.com/bnema/gordon/internal/domain"
23)
24
25// newAppTransport creates the shared HTTP/1.1 transport for proxying to application containers.
26func newAppTransport() *http.Transport {
27 return &http.Transport{
28 DialContext: (&net.Dialer{
29 Timeout: 10 * time.Second,
30 KeepAlive: 30 * time.Second,
31 }).DialContext,
32 TLSHandshakeTimeout: 10 * time.Second,
33 ResponseHeaderTimeout: 30 * time.Second,
34 MaxIdleConns: 100,
35 MaxIdleConnsPerHost: 10,
36 IdleConnTimeout: 90 * time.Second,
37 }
38}
39
40// newH2CTransport creates a dedicated cleartext HTTP/2 transport for containers
41// that opt in via the gordon.proxy.protocol=h2c label.
42// Uses Go 1.24+ native UnencryptedHTTP2 protocol support.
43// HTTP/1 is disabled so http:// URLs use HTTP/2 prior-knowledge.
44func newH2CTransport() *http.Transport {
45 var protos http.Protocols
46 protos.SetUnencryptedHTTP2(true)
47 // Do NOT set HTTP1 — this forces HTTP/2 for http:// URLs.
48 return &http.Transport{
49 Protocols: &protos,
50 DialContext: (&net.Dialer{
51 Timeout: 10 * time.Second,
52 KeepAlive: 30 * time.Second,
53 }).DialContext,
54 ResponseHeaderTimeout: 30 * time.Second,
55 MaxIdleConns: 100,
56 MaxIdleConnsPerHost: 10,
57 IdleConnTimeout: 90 * time.Second,
58 }
59}
60
61// newRegistryTransport creates the transport for the registry reverse proxy (localhost loopback).
62// It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy
63// perform blocking operations (image pull, container start, readiness checks).
64func newRegistryTransport() *http.Transport {
65 return &http.Transport{
66 DialContext: (&net.Dialer{
67 Timeout: 10 * time.Second,
68 KeepAlive: 30 * time.Second,
69 }).DialContext,
70 ResponseHeaderTimeout: 3 * time.Minute,
71 MaxIdleConns: 20,
72 MaxIdleConnsPerHost: 10,
73 IdleConnTimeout: 90 * time.Second,
74 }
75}
76
77// transportForTarget selects the appropriate HTTP transport based on the target's protocol.
78func (h *Handler) transportForTarget(target *domain.ProxyTarget) http.RoundTripper {
79 if target.Protocol == "h2c" {
80 return h.h2cTransport
81 }
82 return h.appTransport
83}
84
85// forwardToTarget proxies a request to the resolved target.
86func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget, maxResponseSize int64) {
87 log := zerowrap.FromCtx(r.Context())
88
89 targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
90 if err != nil {
91 log.WrapErr(err, "failed to parse target URL")
92 proxyError(w, "Internal Server Error", http.StatusInternalServerError)
93 return
94 }
95
96 releaseInFlight := h.proxySvc.TrackInFlight(target.ContainerID)
97 defer releaseInFlight()
98
99 transport := h.transportForTarget(target)
100
101 proxy := newReverseProxy(reverseProxyOptions{
102 targetURL: targetURL,
103 hostHeader: targetHostHeader(target, targetURL),
104 forwardedHost: target.RouteHost,
105 transport: transport,
106 incomingReq: r,
107 trustedNets: h.trustedNets,
108 errorHandler: newProxyErrorHandler(
109 log.WithField("target", targetURL.String()),
110 proxyErrorHandlerConfig{
111 requestTooLargeLog: "proxy error: request body too large",
112 clientDisconnectLog: "proxy request canceled by client",
113 upstreamErrorLog: "proxy error: connection failed",
114 upstreamErrorBody: "Service Unavailable",
115 },
116 ),
117 modifyResponse: modifyResponse(maxResponseSize),
118 })
119
120 if target.Protocol == "h2c" {
121 log.Debug().
122 Str("target", targetURL.String()).
123 Str("protocol", "h2c").
124 Str(zerowrap.FieldEntityID, target.ContainerID).
125 Msg("proxying request via h2c")
126 } else {
127 log.Debug().
128 Str("target", targetURL.String()).
129 Str(zerowrap.FieldEntityID, target.ContainerID).
130 Msg("proxying request")
131 }
132
133 proxy.ServeHTTP(w, r)
134}
135
136// forwardToRegistry proxies a request to the local registry server.
137func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, registryPort int) {
138 h.proxySvc.TrackRegistryRequest()
139 defer h.proxySvc.ReleaseRegistryRequest()
140
141 log := zerowrap.FromCtx(r.Context())
142
143 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", registryPort))
144 if err != nil {
145 log.WrapErr(err, "failed to parse registry target URL")
146 proxyError(w, "Internal Server Error", http.StatusInternalServerError)
147 return
148 }
149
150 proxy := &httputil.ReverseProxy{
151 Rewrite: func(pr *httputil.ProxyRequest) {
152 pr.SetURL(targetURL)
153 pr.SetXForwarded()
154 pr.Out.Host = targetURL.Host
155 },
156 Transport: h.registryTransport,
157 ErrorHandler: newProxyErrorHandler(
158 log.WithField("registry_port", registryPort),
159 proxyErrorHandlerConfig{
160 requestTooLargeLog: "registry proxy error: request body too large",
161 clientDisconnectLog: "registry proxy request canceled by client",
162 upstreamErrorLog: "registry proxy error",
163 upstreamErrorBody: "Registry Unavailable",
164 },
165 ),
166 // Strip browser-oriented security headers from upstream registry responses
167 // to avoid conflicts with Gordon's own headers on browser-facing routes.
168 ModifyResponse: func(resp *http.Response) error {
169 resp.Header.Del("X-Content-Type-Options")
170 resp.Header.Del("X-Frame-Options")
171 resp.Header.Del("X-XSS-Protection")
172 resp.Header.Del("Referrer-Policy")
173 resp.Header.Del("Permissions-Policy")
174 resp.Header.Del("Content-Security-Policy")
175 resp.Header.Del("Strict-Transport-Security")
176
177 return nil
178 },
179 }
180
181 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
182 proxy.ServeHTTP(w, r)
183}
184
185type proxyErrorHandlerConfig struct {
186 requestTooLargeLog string
187 clientDisconnectLog string
188 upstreamErrorLog string
189 upstreamErrorBody string
190}
191
192func newProxyErrorHandler(log zerowrap.Logger, cfg proxyErrorHandlerConfig) func(http.ResponseWriter, *http.Request, error) {
193 return func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
194 switch {
195 case isRequestBodyTooLargeError(proxyErr):
196 log.Warn().Err(proxyErr).Msg(cfg.requestTooLargeLog)
197 proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
198 case isClientDisconnectError(proxyErr):
199 log.Debug().Err(proxyErr).Msg(cfg.clientDisconnectLog)
200 clientClosedRequest(w)
201 default:
202 log.Error().Err(proxyErr).Msg(cfg.upstreamErrorLog)
203 proxyError(w, cfg.upstreamErrorBody, http.StatusServiceUnavailable)
204 }
205 }
206}
207
208func isRequestBodyTooLargeError(err error) bool {
209 _, ok := errors.AsType[*http.MaxBytesError](err)
210 return ok
211}
212
213type reverseProxyOptions struct {
214 targetURL *url.URL
215 hostHeader string
216 forwardedHost string
217 transport http.RoundTripper
218 incomingReq *http.Request
219 trustedNets []*net.IPNet
220 errorHandler func(http.ResponseWriter, *http.Request, error)
221 modifyResponse func(*http.Response) error
222}
223
224func targetHostHeader(target *domain.ProxyTarget, targetURL *url.URL) string {
225 if target.RouteHost != "" {
226 return target.RouteHost
227 }
228 if target.OriginalHost != "" {
229 return net.JoinHostPort(target.OriginalHost, targetURL.Port())
230 }
231 return targetURL.Host
232}
233
234// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
235// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
236// to strip the Authorization header when using the default Director. Rewrite processes
237// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
238//
239// incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the
240// request originates from a trusted upstream proxy, and to resolve the client IP
241// sent upstream in X-Real-IP and X-Forwarded-For.
242func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy {
243 return &httputil.ReverseProxy{
244 Rewrite: func(pr *httputil.ProxyRequest) {
245 existingProto := pr.In.Header.Get("X-Forwarded-Proto")
246 pr.SetURL(opts.targetURL)
247 pr.SetXForwarded()
248 setClientIPHeaders(pr, opts.trustedNets)
249 if opts.hostHeader != "" {
250 pr.Out.Host = opts.hostHeader
251 }
252 if opts.forwardedHost != "" {
253 pr.Out.Header.Set("X-Forwarded-Host", opts.forwardedHost)
254 }
255 // Preserve X-Forwarded-Proto from trusted upstream proxies.
256 // SetXForwarded() unconditionally sets it based on the incoming scheme
257 // (HTTP between proxies), but when a trusted proxy already sent the
258 // real client proto, we must honor it.
259 if existingProto != "" && gordonhttp.IsTrustedSource(opts.incomingReq, opts.trustedNets) {
260 pr.Out.Header.Set("X-Forwarded-Proto", existingProto)
261 }
262 },
263 Transport: opts.transport,
264 ErrorHandler: opts.errorHandler,
265 ModifyResponse: opts.modifyResponse,
266 }
267}
268
269// clientIPHeaders are the headers carrying a client IP that Gordon rewrites
270// before proxying to a container, in canonical form.
271var clientIPHeaders = []string{"X-Real-Ip", "X-Forwarded-For", "Cf-Connecting-Ip"}
272
273// setClientIPHeaders replaces client-supplied IP headers with the client IP
274// resolved by the same trusted-proxy rules Gordon uses for its own logs and
275// rate limits, so upstream apps can always trust X-Real-IP and X-Forwarded-For.
276// Cf-Connecting-Ip is only kept when it is the IP resolved from a trusted
277// Cloudflare peer.
278func setClientIPHeaders(pr *httputil.ProxyRequest, trustedNets []*net.IPNet) {
279 cfIP := pr.Out.Header.Get("Cf-Connecting-Ip")
280 deleteClientIPHeaders(pr.Out.Header)
281
282 clientIP := middleware.GetClientIP(pr.In, trustedNets)
283 parsed := net.ParseIP(clientIP)
284 if parsed == nil {
285 // No usable peer address (e.g. a Unix socket): send no client IP at all.
286 return
287 }
288 pr.Out.Header.Set("X-Real-IP", clientIP)
289 pr.Out.Header.Set("X-Forwarded-For", clientIP)
290 if middleware.IsTrustedCloudflarePeer(pr.In, trustedNets) && parsed.Equal(net.ParseIP(strings.TrimSpace(cfIP))) {
291 pr.Out.Header.Set("Cf-Connecting-Ip", clientIP)
292 }
293}
294
295// deleteClientIPHeaders removes every client IP header, including spellings
296// with underscores (X_Real_IP). Go keeps those under a separate key, but
297// CGI-style servers map both "-" and "_" to the same HTTP_X_REAL_IP variable,
298// so a client could otherwise override the value set by Gordon.
299func deleteClientIPHeaders(h http.Header) {
300 for key := range h {
301 normalized := http.CanonicalHeaderKey(strings.ReplaceAll(key, "_", "-"))
302 if slices.Contains(clientIPHeaders, normalized) {
303 delete(h, key)
304 }
305 }
306}
307
308// modifyResponse returns a function that adds proxy headers and enforces response size limits.
309func modifyResponse(maxResponseSize int64) func(*http.Response) error {
310 return func(resp *http.Response) error {
311 if maxResponseSize > 0 {
312 if resp.ContentLength > maxResponseSize {
313 resp.Body.Close()
314 const msg = "Response Too Large"
315 resp.StatusCode = http.StatusBadGateway
316 resp.Body = io.NopCloser(strings.NewReader(msg))
317 resp.ContentLength = int64(len(msg))
318 resp.Header.Set("Content-Type", "text/plain")
319 resp.Header.Set("Cache-Control", "no-store")
320 resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
321 resp.Header.Del("Transfer-Encoding")
322 resp.Header.Del("Content-Encoding")
323 resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
324 return nil
325 }
326 if resp.ContentLength < 0 {
327 resp.Body = &limitedReadCloser{
328 ReadCloser: resp.Body,
329 remaining: maxResponseSize,
330 }
331 }
332 }
333
334 return nil
335 }
336}
337
338// limitedReadCloser wraps an io.ReadCloser with a byte limit.
339type limitedReadCloser struct {
340 io.ReadCloser
341 remaining int64
342 closed bool
343}
344
345func (l *limitedReadCloser) Read(p []byte) (int, error) {
346 if l.remaining <= 0 {
347 if !l.closed {
348 l.closed = true
349 l.ReadCloser.Close()
350 }
351 return 0, fmt.Errorf("response body exceeded maximum size limit (remaining: %d bytes)", l.remaining)
352 }
353 toRead := len(p)
354 if int64(toRead) > l.remaining {
355 toRead = int(l.remaining)
356 }
357 n, err := l.ReadCloser.Read(p[:toRead])
358 // Cap n to the requested slice to guard against misbehaving readers
359 // that return more bytes than the buffer length.
360 if n > toRead {
361 n = toRead
362 }
363 l.remaining -= int64(n)
364 if l.remaining < 0 {
365 l.remaining = 0
366 }
367 return n, err
368}
369
370func (l *limitedReadCloser) Close() error {
371 if l.closed {
372 return nil
373 }
374 l.closed = true
375 return l.ReadCloser.Close()
376}
377
378const statusClientClosedRequest = 499
379
380func isClientDisconnectError(err error) bool {
381 return errors.Is(err, context.Canceled) || errors.Is(err, http.ErrAbortHandler)
382}
383
384func clientClosedRequest(w http.ResponseWriter) {
385 setProxyGeneratedResponseHeaders(w)
386 w.WriteHeader(statusClientClosedRequest)
387}
388
389// proxyError writes an error response with security headers appropriate for
390// proxy-generated error pages.
391func proxyError(w http.ResponseWriter, msg string, code int) {
392 setProxyGeneratedResponseHeaders(w)
393 http.Error(w, msg, code)
394}
395
396func setProxyGeneratedResponseHeaders(w http.ResponseWriter) {
397 w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
398 w.Header().Set("Cache-Control", "no-store")
399}