v2.31.0 e02f654

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	"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}

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