v2.30.0-pre4 15d9fe3

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	"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			// Remove browser-oriented security headers injected by the upstream
167			// registry. These are unnecessary for Docker client API traffic and
168			// would conflict with headers set on browser-facing proxy routes.
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
185// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
186// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
187// to strip the Authorization header when using the default Director. Rewrite processes
188// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
189//
190// originalHost, when non-empty, overrides the Host header sent to the backend.
191// This is used for DNS-pinned targets where the proxy dials an IP but needs to
192// send the original hostname for virtual-hosted upstreams.
193func newReverseProxy(targetURL *url.URL, originalHost string, transport http.RoundTripper, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResp func(*http.Response) error) *httputil.ReverseProxy {
194	return &httputil.ReverseProxy{
195		Rewrite: func(pr *httputil.ProxyRequest) {
196			pr.SetURL(targetURL)
197			pr.SetXForwarded()
198			if originalHost != "" {
199				pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
200			} else {
201				pr.Out.Host = targetURL.Host
202			}
203		},
204		Transport:      transport,
205		ErrorHandler:   errorHandler,
206		ModifyResponse: modifyResp,
207	}
208}
209
210// modifyResponse returns a function that adds proxy headers and enforces response size limits.
211func modifyResponse(maxResponseSize int64) func(*http.Response) error {
212	return func(resp *http.Response) error {
213		if maxResponseSize > 0 {
214			if resp.ContentLength > maxResponseSize {
215				resp.Body.Close()
216				const msg = "Response Too Large"
217				resp.StatusCode = http.StatusBadGateway
218				resp.Body = io.NopCloser(strings.NewReader(msg))
219				resp.ContentLength = int64(len(msg))
220				resp.Header.Set("Content-Type", "text/plain")
221				resp.Header.Set("Cache-Control", "no-store")
222				resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
223				resp.Header.Del("Transfer-Encoding")
224				resp.Header.Del("Content-Encoding")
225				resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
226				return nil
227			}
228			if resp.ContentLength < 0 {
229				resp.Body = &limitedReadCloser{
230					ReadCloser: resp.Body,
231					remaining:  maxResponseSize,
232				}
233			}
234		}
235
236		return nil
237	}
238}
239
240// limitedReadCloser wraps an io.ReadCloser with a byte limit.
241type limitedReadCloser struct {
242	io.ReadCloser
243	remaining int64
244	closed    bool
245}
246
247func (l *limitedReadCloser) Read(p []byte) (int, error) {
248	if l.remaining <= 0 {
249		if !l.closed {
250			l.closed = true
251			l.ReadCloser.Close()
252		}
253		return 0, fmt.Errorf("response body exceeded maximum size limit (remaining: %d bytes)", l.remaining)
254	}
255	toRead := len(p)
256	if int64(toRead) > l.remaining {
257		toRead = int(l.remaining)
258	}
259	n, err := l.ReadCloser.Read(p[:toRead])
260	// Cap n to the requested slice to guard against misbehaving readers
261	// that return more bytes than the buffer length.
262	if n > toRead {
263		n = toRead
264	}
265	l.remaining -= int64(n)
266	if l.remaining < 0 {
267		l.remaining = 0
268	}
269	return n, err
270}
271
272func (l *limitedReadCloser) Close() error {
273	if l.closed {
274		return nil
275	}
276	l.closed = true
277	return l.ReadCloser.Close()
278}
279
280// proxyError writes an error response with security headers appropriate for
281// proxy-generated error pages.
282func proxyError(w http.ResponseWriter, msg string, code int) {
283	w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
284	w.Header().Set("Cache-Control", "no-store")
285	http.Error(w, msg, code)
286}

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 8

Tags 106

↑↓ moveenter openesc close