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 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
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 originalHost := target.OriginalHost
94 releaseInFlight := h.proxySvc.TrackInFlight(target.ContainerID)
95 defer releaseInFlight()
96
97 transport := h.transportForTarget(target)
98
99 proxy := newReverseProxy(targetURL, originalHost, transport, r, h.trustedNets,
100 func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
101 var maxBytesErr *http.MaxBytesError
102 if errors.As(proxyErr, &maxBytesErr) {
103 log.Warn().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: request body too large")
104 proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
105 return
106 }
107 log.Error().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: connection failed")
108 proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
109 },
110 modifyResponse(maxResponseSize),
111 )
112
113 if target.Protocol == "h2c" {
114 log.Debug().
115 Str("target", targetURL.String()).
116 Str("protocol", "h2c").
117 Str(zerowrap.FieldEntityID, target.ContainerID).
118 Msg("proxying request via h2c")
119 } else {
120 log.Debug().
121 Str("target", targetURL.String()).
122 Str(zerowrap.FieldEntityID, target.ContainerID).
123 Msg("proxying request")
124 }
125
126 proxy.ServeHTTP(w, r)
127}
128
129// forwardToRegistry proxies a request to the local registry server.
130func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, registryPort int) {
131 h.proxySvc.TrackRegistryRequest()
132 defer h.proxySvc.ReleaseRegistryRequest()
133
134 log := zerowrap.FromCtx(r.Context())
135
136 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", registryPort))
137 if err != nil {
138 log.WrapErr(err, "failed to parse registry target URL")
139 proxyError(w, "Internal Server Error", http.StatusInternalServerError)
140 return
141 }
142
143 proxy := &httputil.ReverseProxy{
144 Rewrite: func(pr *httputil.ProxyRequest) {
145 pr.SetURL(targetURL)
146 pr.SetXForwarded()
147 pr.Out.Host = targetURL.Host
148 },
149 Transport: h.registryTransport,
150 ErrorHandler: func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
151 var maxBytesErr *http.MaxBytesError
152 if errors.As(proxyErr, &maxBytesErr) {
153 log.Warn().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error: request body too large")
154 proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
155 return
156 }
157 log.Error().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error")
158 proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
159 },
160 // Strip browser-oriented security headers from upstream registry responses
161 // to avoid conflicts with Gordon's own headers on browser-facing routes.
162 ModifyResponse: func(resp *http.Response) error {
163 resp.Header.Del("X-Content-Type-Options")
164 resp.Header.Del("X-Frame-Options")
165 resp.Header.Del("X-XSS-Protection")
166 resp.Header.Del("Referrer-Policy")
167 resp.Header.Del("Permissions-Policy")
168 resp.Header.Del("Content-Security-Policy")
169 resp.Header.Del("Strict-Transport-Security")
170
171 return nil
172 },
173 }
174
175 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
176 proxy.ServeHTTP(w, r)
177}
178
179// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
180// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
181// to strip the Authorization header when using the default Director. Rewrite processes
182// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
183//
184// originalHost, when non-empty, overrides the Host header sent to the backend.
185// This is used for DNS-pinned targets where the proxy dials an IP but needs to
186// send the original hostname for virtual-hosted upstreams.
187//
188// incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the
189// request originates from a trusted upstream proxy.
190func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, incomingReq *http.Request, trustedNets []*net.IPNet, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy {
191 return &httputil.ReverseProxy{
192 Rewrite: func(pr *httputil.ProxyRequest) {
193 existingProto := pr.In.Header.Get("X-Forwarded-Proto")
194 pr.SetURL(targetURL)
195 pr.SetXForwarded()
196 if originalHost != "" {
197 pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
198 } else {
199 pr.Out.Host = targetURL.Host
200 }
201 // Preserve X-Forwarded-Proto from trusted upstream proxies.
202 // SetXForwarded() unconditionally sets it based on the incoming scheme
203 // (HTTP between proxies), but when a trusted proxy already sent the
204 // real client proto, we must honor it.
205 if existingProto != "" && isTrustedSource(incomingReq, trustedNets) {
206 pr.Out.Header.Set("X-Forwarded-Proto", existingProto)
207 }
208 },
209 Transport: transport,
210 ErrorHandler: errorHandler,
211 ModifyResponse: modifyResp,
212 }
213}
214
215// isTrustedSource reports whether the request's remote address is in trustedNets.
216func isTrustedSource(r *http.Request, trustedNets []*net.IPNet) bool {
217 if len(trustedNets) == 0 {
218 return false
219 }
220 host, _, err := net.SplitHostPort(r.RemoteAddr)
221 if err != nil {
222 // RemoteAddr may be a bare IP without a port (e.g. from Unix sockets
223 // or certain proxy setups). Fall back to using it directly.
224 host = strings.TrimSuffix(strings.TrimPrefix(r.RemoteAddr, "["), "]")
225 }
226 return middleware.IsTrustedProxy(host, trustedNets)
227}
228
229// modifyResponse returns a function that adds proxy headers and enforces response size limits.
230func modifyResponse(maxResponseSize int64) func(*http.Response) error {
231 return func(resp *http.Response) error {
232 if maxResponseSize > 0 {
233 if resp.ContentLength > maxResponseSize {
234 resp.Body.Close()
235 const msg = "Response Too Large"
236 resp.StatusCode = http.StatusBadGateway
237 resp.Body = io.NopCloser(strings.NewReader(msg))
238 resp.ContentLength = int64(len(msg))
239 resp.Header.Set("Content-Type", "text/plain")
240 resp.Header.Set("Cache-Control", "no-store")
241 resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
242 resp.Header.Del("Transfer-Encoding")
243 resp.Header.Del("Content-Encoding")
244 resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
245 return nil
246 }
247 if resp.ContentLength < 0 {
248 resp.Body = &limitedReadCloser{
249 ReadCloser: resp.Body,
250 remaining: maxResponseSize,
251 }
252 }
253 }
254
255 return nil
256 }
257}
258
259// limitedReadCloser wraps an io.ReadCloser with a byte limit.
260type limitedReadCloser struct {
261 io.ReadCloser
262 remaining int64
263 closed bool
264}
265
266func (l *limitedReadCloser) Read(p []byte) (int, error) {
267 if l.remaining <= 0 {
268 if !l.closed {
269 l.closed = true
270 l.ReadCloser.Close()
271 }
272 return 0, fmt.Errorf("response body exceeded maximum size limit (remaining: %d bytes)", l.remaining)
273 }
274 toRead := len(p)
275 if int64(toRead) > l.remaining {
276 toRead = int(l.remaining)
277 }
278 n, err := l.ReadCloser.Read(p[:toRead])
279 // Cap n to the requested slice to guard against misbehaving readers
280 // that return more bytes than the buffer length.
281 if n > toRead {
282 n = toRead
283 }
284 l.remaining -= int64(n)
285 if l.remaining < 0 {
286 l.remaining = 0
287 }
288 return n, err
289}
290
291func (l *limitedReadCloser) Close() error {
292 if l.closed {
293 return nil
294 }
295 l.closed = true
296 return l.ReadCloser.Close()
297}
298
299// proxyError writes an error response with security headers appropriate for
300// proxy-generated error pages.
301func proxyError(w http.ResponseWriter, msg string, code int) {
302 w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
303 w.Header().Set("Cache-Control", "no-store")
304 http.Error(w, msg, code)
305}