v3.3.2 9697f9d

forwarder.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close