v2.31.0-dev8 ec8c5b9

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	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 != "" && gordonhttp.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// modifyResponse returns a function that adds proxy headers and enforces response size limits.
216func modifyResponse(maxResponseSize int64) func(*http.Response) error {
217	return func(resp *http.Response) error {
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}

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