v2.31.0-dev16 8d0a092

forwarder.go RawHistoryFolder

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}

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