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