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/domain"
19)
20
21// newAppTransport creates the shared HTTP/1.1 transport for proxying to application containers.
22func newAppTransport() *http.Transport {
23 return &http.Transport{
24 DialContext: (&net.Dialer{
25 Timeout: 10 * time.Second,
26 KeepAlive: 30 * time.Second,
27 }).DialContext,
28 TLSHandshakeTimeout: 10 * time.Second,
29 ResponseHeaderTimeout: 30 * time.Second,
30 MaxIdleConns: 100,
31 MaxIdleConnsPerHost: 10,
32 IdleConnTimeout: 90 * time.Second,
33 }
34}
35
36// newH2CTransport creates a dedicated cleartext HTTP/2 transport for containers
37// that opt in via the gordon.proxy.protocol=h2c label.
38// Uses Go 1.24+ native UnencryptedHTTP2 protocol support.
39// HTTP/1 is disabled so http:// URLs use HTTP/2 prior-knowledge.
40func newH2CTransport() *http.Transport {
41 var protos http.Protocols
42 protos.SetUnencryptedHTTP2(true)
43 // Do NOT set HTTP1 — this forces HTTP/2 for http:// URLs.
44 return &http.Transport{
45 Protocols: &protos,
46 DialContext: (&net.Dialer{
47 Timeout: 10 * time.Second,
48 KeepAlive: 30 * time.Second,
49 }).DialContext,
50 ResponseHeaderTimeout: 30 * time.Second,
51 MaxIdleConns: 100,
52 MaxIdleConnsPerHost: 10,
53 IdleConnTimeout: 90 * time.Second,
54 }
55}
56
57// newRegistryTransport creates the transport for the registry reverse proxy (localhost loopback).
58// It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy
59// perform blocking operations (image pull, container start, readiness checks).
60func newRegistryTransport() *http.Transport {
61 return &http.Transport{
62 DialContext: (&net.Dialer{
63 Timeout: 10 * time.Second,
64 KeepAlive: 30 * time.Second,
65 }).DialContext,
66 ResponseHeaderTimeout: 3 * time.Minute,
67 MaxIdleConns: 20,
68 MaxIdleConnsPerHost: 10,
69 IdleConnTimeout: 90 * time.Second,
70 }
71}
72
73// transportForTarget selects the appropriate HTTP transport based on the target's protocol.
74func (h *Handler) transportForTarget(target *domain.ProxyTarget) http.RoundTripper {
75 if target.Protocol == "h2c" {
76 return h.h2cTransport
77 }
78 return h.appTransport
79}
80
81// forwardToTarget proxies a request to the resolved target.
82func (h *Handler) forwardToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget, maxResponseSize int64) {
83 log := zerowrap.FromCtx(r.Context())
84
85 targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
86 if err != nil {
87 log.WrapErr(err, "failed to parse target URL")
88 proxyError(w, "Internal Server Error", http.StatusInternalServerError)
89 return
90 }
91
92 originalHost := target.OriginalHost
93 releaseInFlight := h.proxySvc.TrackInFlight(target.ContainerID)
94 defer releaseInFlight()
95
96 transport := h.transportForTarget(target)
97
98 proxy := newReverseProxy(targetURL, originalHost, transport,
99 func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
100 var maxBytesErr *http.MaxBytesError
101 if errors.As(proxyErr, &maxBytesErr) {
102 log.Warn().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: request body too large")
103 proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
104 return
105 }
106 log.Error().Err(proxyErr).Str("target", targetURL.String()).Msg("proxy error: connection failed")
107 proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
108 },
109 modifyResponse(maxResponseSize),
110 )
111
112 if target.Protocol == "h2c" {
113 log.Debug().
114 Str("target", targetURL.String()).
115 Str("protocol", "h2c").
116 Str(zerowrap.FieldEntityID, target.ContainerID).
117 Msg("proxying request via h2c")
118 } else {
119 log.Debug().
120 Str("target", targetURL.String()).
121 Str(zerowrap.FieldEntityID, target.ContainerID).
122 Msg("proxying request")
123 }
124
125 proxy.ServeHTTP(w, r)
126}
127
128// forwardToRegistry proxies a request to the local registry server.
129func (h *Handler) forwardToRegistry(w http.ResponseWriter, r *http.Request, registryPort int) {
130 h.proxySvc.TrackRegistryRequest()
131 defer h.proxySvc.ReleaseRegistryRequest()
132
133 log := zerowrap.FromCtx(r.Context())
134
135 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", registryPort))
136 if err != nil {
137 log.WrapErr(err, "failed to parse registry target URL")
138 proxyError(w, "Internal Server Error", http.StatusInternalServerError)
139 return
140 }
141
142 proxy := &httputil.ReverseProxy{
143 Rewrite: func(pr *httputil.ProxyRequest) {
144 pr.SetURL(targetURL)
145 pr.SetXForwarded()
146 pr.Out.Host = targetURL.Host
147 },
148 Transport: h.registryTransport,
149 ErrorHandler: func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
150 var maxBytesErr *http.MaxBytesError
151 if errors.As(proxyErr, &maxBytesErr) {
152 log.Warn().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error: request body too large")
153 proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
154 return
155 }
156 log.Error().Err(proxyErr).Int("registry_port", registryPort).Msg("registry proxy error")
157 proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
158 },
159 // ModifyResponse adjusts registry responses for Docker client consumption.
160 // Registry API responses are consumed by Docker CLI/daemon, not browsers.
161 // Upstream registry servers may inject browser-oriented security headers
162 // (e.g., HSTS, CSP, X-Frame-Options) that are irrelevant for API clients
163 // and could conflict with Gordon's own security headers on browser-facing
164 // routes. We strip them here to ensure clean, predictable registry responses.
165 ModifyResponse: func(resp *http.Response) error {
166 resp.Header.Set("X-Proxied-By", "Gordon")
167 resp.Header.Set("X-Registry-Backend", "gordon-registry")
168
169 // Remove browser-oriented security headers injected by the upstream
170 // registry. These are unnecessary for Docker client API traffic and
171 // would conflict with headers set on browser-facing proxy routes.
172 resp.Header.Del("X-Content-Type-Options")
173 resp.Header.Del("X-Frame-Options")
174 resp.Header.Del("X-XSS-Protection")
175 resp.Header.Del("Referrer-Policy")
176 resp.Header.Del("Permissions-Policy")
177 resp.Header.Del("Content-Security-Policy")
178 resp.Header.Del("Strict-Transport-Security")
179
180 return nil
181 },
182 }
183
184 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
185 proxy.ServeHTTP(w, r)
186}
187
188// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
189// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
190// to strip the Authorization header when using the default Director. Rewrite processes
191// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
192//
193// originalHost, when non-empty, overrides the Host header sent to the backend.
194// This is used for DNS-pinned targets where the proxy dials an IP but needs to
195// send the original hostname for virtual-hosted upstreams.
196func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy {
197 return &httputil.ReverseProxy{
198 Rewrite: func(pr *httputil.ProxyRequest) {
199 pr.SetURL(targetURL)
200 pr.SetXForwarded()
201 if originalHost != "" {
202 pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
203 } else {
204 pr.Out.Host = targetURL.Host
205 }
206 },
207 Transport: transport,
208 ErrorHandler: errorHandler,
209 ModifyResponse: modifyResp,
210 }
211}
212
213// modifyResponse returns a function that adds proxy headers and enforces response size limits.
214func modifyResponse(maxResponseSize int64) func(*http.Response) error {
215 return func(resp *http.Response) error {
216 resp.Header.Set("X-Proxied-By", "Gordon")
217
218 if maxResponseSize > 0 {
219 if resp.ContentLength > maxResponseSize {
220 resp.Body.Close()
221 const msg = "Response Too Large"
222 resp.StatusCode = http.StatusBadGateway
223 resp.Body = io.NopCloser(strings.NewReader(msg))
224 resp.ContentLength = int64(len(msg))
225 resp.Header.Set("Content-Type", "text/plain")
226 resp.Header.Set("Cache-Control", "no-store")
227 resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
228 resp.Header.Del("Transfer-Encoding")
229 resp.Header.Del("Content-Encoding")
230 resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
231 return nil
232 }
233 if resp.ContentLength < 0 {
234 resp.Body = &limitedReadCloser{
235 ReadCloser: resp.Body,
236 remaining: maxResponseSize,
237 }
238 }
239 }
240
241 return nil
242 }
243}
244
245// limitedReadCloser wraps an io.ReadCloser with a byte limit.
246type limitedReadCloser struct {
247 io.ReadCloser
248 remaining int64
249 closed bool
250}
251
252func (l *limitedReadCloser) Read(p []byte) (int, error) {
253 if l.remaining <= 0 {
254 if !l.closed {
255 l.closed = true
256 l.ReadCloser.Close()
257 }
258 return 0, fmt.Errorf("response body exceeded maximum size limit (remaining: %d bytes)", l.remaining)
259 }
260 toRead := len(p)
261 if int64(toRead) > l.remaining {
262 toRead = int(l.remaining)
263 }
264 n, err := l.ReadCloser.Read(p[:toRead])
265 // Cap n to the requested slice to guard against misbehaving readers
266 // that return more bytes than the buffer length.
267 if n > toRead {
268 n = toRead
269 }
270 l.remaining -= int64(n)
271 if l.remaining < 0 {
272 l.remaining = 0
273 }
274 return n, err
275}
276
277func (l *limitedReadCloser) Close() error {
278 if l.closed {
279 return nil
280 }
281 l.closed = true
282 return l.ReadCloser.Close()
283}
284
285// proxyError writes an error response with security headers appropriate for
286// proxy-generated error pages.
287func proxyError(w http.ResponseWriter, msg string, code int) {
288 w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
289 w.Header().Set("Cache-Control", "no-store")
290 http.Error(w, msg, code)
291}