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