v2.13.0 7748089

service.go RawHistoryFolder

1// Package proxy implements the reverse proxy use case.
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	"os"
14	"strconv"
15	"strings"
16	"sync"
17	"sync/atomic"
18	"time"
19
20	"github.com/bnema/zerowrap"
21
22	"github.com/bnema/gordon/internal/boundaries/in"
23	"github.com/bnema/gordon/internal/boundaries/out"
24	"github.com/bnema/gordon/internal/domain"
25)
26
27// proxyTransport is a shared HTTP transport with proper timeouts.
28// This prevents resource exhaustion from slow backends or network issues.
29var proxyTransport = &http.Transport{
30	DialContext: (&net.Dialer{
31		Timeout:   10 * time.Second,
32		KeepAlive: 30 * time.Second,
33	}).DialContext,
34	TLSHandshakeTimeout:   10 * time.Second,
35	ResponseHeaderTimeout: 30 * time.Second,
36	MaxIdleConns:          100,
37	MaxIdleConnsPerHost:   10,
38	IdleConnTimeout:       90 * time.Second,
39}
40
41// Config holds configuration needed by the proxy service.
42type Config struct {
43	RegistryDomain     string
44	RegistryPort       int
45	MaxBodySize        int64 // Maximum request body size in bytes (0 = no limit)
46	MaxResponseSize    int64 // Maximum response body size in bytes (0 = no limit)
47	MaxConcurrentConns int   // Maximum concurrent proxy connections (0 = no limit)
48}
49
50// Service implements the ProxyService interface.
51type Service struct {
52	runtime      out.ContainerRuntime
53	containerSvc in.ContainerService
54	configSvc    in.ConfigService
55	config       Config
56	targets      map[string]*domain.ProxyTarget
57	mu           sync.RWMutex
58	activeConns  atomic.Int64 // Atomic counter for concurrent connection limiting
59}
60
61// NewService creates a new proxy service.
62func NewService(
63	runtime out.ContainerRuntime,
64	containerSvc in.ContainerService,
65	configSvc in.ConfigService,
66	config Config,
67) *Service {
68	return &Service{
69		runtime:      runtime,
70		containerSvc: containerSvc,
71		configSvc:    configSvc,
72		config:       config,
73		targets:      make(map[string]*domain.ProxyTarget),
74	}
75}
76
77// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
78func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
79	// Copy config under RLock to avoid data race with UpdateConfig
80	s.mu.RLock()
81	cfg := s.config
82	s.mu.RUnlock()
83
84	// SECURITY: Limit concurrent connections to prevent resource exhaustion.
85	// Uses an atomic counter so config reloads don't create a race between
86	// an old and new semaphore — the counter is shared across reloads.
87	if cfg.MaxConcurrentConns > 0 {
88		current := s.activeConns.Add(1)
89		if current > int64(cfg.MaxConcurrentConns) {
90			s.activeConns.Add(-1)
91			proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
92			return
93		}
94		defer s.activeConns.Add(-1)
95	}
96
97	// SECURITY: Limit request body size to prevent resource exhaustion.
98	if cfg.MaxBodySize > 0 {
99		r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
100	}
101
102	// Enrich request context with fields for downstream logging
103	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
104		zerowrap.FieldLayer:    "adapter",
105		zerowrap.FieldAdapter:  "http",
106		zerowrap.FieldMethod:   r.Method,
107		zerowrap.FieldPath:     r.URL.Path,
108		zerowrap.FieldHost:     r.Host,
109		zerowrap.FieldClientIP: r.RemoteAddr,
110	})
111	r = r.WithContext(ctx)
112	log := zerowrap.FromCtx(ctx)
113
114	// Check if this is the registry domain
115	if cfg.RegistryDomain != "" && r.Host == cfg.RegistryDomain {
116		log.Info().Msg("routing request to registry")
117		s.proxyToRegistry(w, r)
118		return
119	}
120
121	// Get target for this domain
122	log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
123	target, err := s.GetTarget(ctx, r.Host)
124	if err != nil {
125		log.Warn().Err(err).Msg("no route found for domain")
126		proxyError(w, "404 page not found", http.StatusNotFound)
127		return
128	}
129
130	log.Debug().
131		Str("host", target.Host).
132		Int("port", target.Port).
133		Str("container_id", target.ContainerID).
134		Msg("resolved proxy target")
135
136	s.proxyToTarget(w, r, target)
137}
138
139// GetTarget returns the proxy target for a given domain.
140func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) {
141	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
142		zerowrap.FieldLayer:   "usecase",
143		zerowrap.FieldUseCase: "GetTarget",
144		"domain":              domainName,
145	})
146	log := zerowrap.FromCtx(ctx)
147
148	// Check cache first
149	s.mu.RLock()
150	if target, exists := s.targets[domainName]; exists {
151		s.mu.RUnlock()
152		log.Debug().
153			Str("host", target.Host).
154			Int("port", target.Port).
155			Str("container_id", target.ContainerID).
156			Msg("using cached proxy target")
157		return target, nil
158	}
159	s.mu.RUnlock()
160
161	// Check if this is an external route
162	externalRoutes := s.configSvc.GetExternalRoutes()
163	if targetAddr, ok := externalRoutes[domainName]; ok {
164		host, portStr, err := net.SplitHostPort(targetAddr)
165		if err != nil {
166			return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
167		}
168		port, err := strconv.Atoi(portStr)
169		if err != nil {
170			return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
171		}
172
173		// SECURITY: Resolve DNS and validate that the target is not an internal/blocked
174		// network. We use the resolved IP as the proxy target to prevent DNS rebinding
175		// (TOCTOU) attacks where a hostname resolves to a public IP during validation
176		// but to a private IP when the proxy connects.
177		resolvedIP, err := ResolveAndValidateHost(host)
178		if err != nil {
179			log.Warn().
180				Err(err).
181				Str("host", host).
182				Str("domain", domainName).
183				Msg("SSRF protection: blocked external route to internal network")
184			return nil, err
185		}
186
187		// Preserve the original hostname for the Host header so virtual-hosted
188		// upstreams work correctly. The resolved IP is used for dialing only.
189		var originalHost string
190		if resolvedIP != host {
191			originalHost = host
192		}
193
194		target := &domain.ProxyTarget{
195			Host:         resolvedIP,
196			Port:         port,
197			ContainerID:  "", // Not a container
198			Scheme:       "http",
199			OriginalHost: originalHost,
200		}
201
202		// Cache external route target
203		s.mu.Lock()
204		s.targets[domainName] = target
205		s.mu.Unlock()
206
207		log.Debug().
208			Str("host", host).
209			Int("port", port).
210			Msg("using external route target")
211		return target, nil
212	}
213
214	// Get container for this domain
215	container, exists := s.containerSvc.Get(ctx, domainName)
216	if !exists {
217		log.Debug().Msg("container not found for domain")
218		return nil, domain.ErrNoTargetAvailable
219	}
220	log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
221
222	// Build target based on runtime mode
223	var target *domain.ProxyTarget
224
225	if s.isRunningInContainer() {
226		// Gordon is in a container - use container network
227		containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
228		if err != nil {
229			return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
230		}
231		target = &domain.ProxyTarget{
232			Host:        containerIP,
233			Port:        containerPort,
234			ContainerID: container.ID,
235			Scheme:      "http",
236		}
237	} else {
238		// Gordon is on the host - use host port mapping
239		routes := s.configSvc.GetRoutes(ctx)
240		var route *domain.Route
241		for _, r := range routes {
242			if r.Domain == domainName {
243				route = &r
244				break
245			}
246		}
247
248		if route == nil {
249			return nil, domain.ErrRouteNotFound
250		}
251
252		// Determine target port: check for label first, then fall back to first exposed port
253		// Use container.Image (the actual running image) instead of route.Image (config shorthand)
254		targetPort, err := s.getProxyPort(ctx, container.Image)
255		if err != nil {
256			return nil, log.WrapErr(err, "failed to determine proxy port")
257		}
258
259		hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
260		if err != nil {
261			return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
262		}
263
264		target = &domain.ProxyTarget{
265			Host:        "localhost",
266			Port:        hostPort,
267			ContainerID: container.ID,
268			Scheme:      "http",
269		}
270	}
271
272	// Cache the target
273	s.mu.Lock()
274	s.targets[domainName] = target
275	s.mu.Unlock()
276
277	return target, nil
278}
279
280// RegisterTarget registers a new proxy target for a domain.
281func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
282	s.mu.Lock()
283	defer s.mu.Unlock()
284
285	s.targets[domainName] = target
286	return nil
287}
288
289// UnregisterTarget removes a proxy target for a domain.
290func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
291	s.mu.Lock()
292	defer s.mu.Unlock()
293
294	delete(s.targets, domainName)
295	return nil
296}
297
298// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
299// This is used during zero-downtime deployments to switch traffic to a new container.
300func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
301	s.mu.Lock()
302	defer s.mu.Unlock()
303
304	delete(s.targets, domainName)
305}
306
307// RefreshTargets refreshes all proxy targets from container state.
308func (s *Service) RefreshTargets(ctx context.Context) error {
309	s.mu.Lock()
310	s.targets = make(map[string]*domain.ProxyTarget)
311	s.mu.Unlock()
312
313	log := zerowrap.FromCtx(ctx)
314	log.Debug().Msg("proxy targets cache cleared")
315	return nil
316}
317
318// UpdateConfig updates the service configuration.
319// Connection limiting uses an atomic counter checked against config, so
320// changing MaxConcurrentConns takes effect immediately without any race.
321func (s *Service) UpdateConfig(config Config) {
322	s.mu.Lock()
323	s.config = config
324	s.mu.Unlock()
325}
326
327// modifyResponse returns a function that modifies backend responses.
328// It adds proxy headers and enforces the response size limit if configured.
329func (s *Service) modifyResponse(cfg Config) func(*http.Response) error {
330	return func(resp *http.Response) error {
331		resp.Header.Set("X-Proxied-By", "Gordon")
332
333		// SECURITY: Enforce response size limit to prevent memory exhaustion
334		// from malicious or buggy backends streaming unbounded data.
335		if cfg.MaxResponseSize > 0 {
336			if resp.ContentLength > cfg.MaxResponseSize {
337				resp.Body.Close()
338				const msg = "Response Too Large"
339				resp.StatusCode = http.StatusBadGateway
340				resp.Body = io.NopCloser(strings.NewReader(msg))
341				resp.ContentLength = int64(len(msg))
342				resp.Header.Set("Content-Type", "text/plain")
343				resp.Header.Set("Cache-Control", "no-store")
344				resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
345				// Ensure framing/encoding headers match the substituted body
346				resp.Header.Del("Transfer-Encoding")
347				resp.Header.Del("Content-Encoding")
348				resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
349				return nil
350			}
351			// For chunked/streaming responses where Content-Length is unknown,
352			// wrap with LimitReader to enforce the cap.
353			if resp.ContentLength < 0 {
354				resp.Body = &limitedReadCloser{
355					ReadCloser: resp.Body,
356					remaining:  cfg.MaxResponseSize,
357				}
358			}
359		}
360
361		return nil
362	}
363}
364
365// limitedReadCloser wraps an io.ReadCloser with a byte limit.
366// When the limit is exceeded, subsequent reads return an error and the
367// underlying body is closed exactly once to release upstream resources.
368type limitedReadCloser struct {
369	io.ReadCloser
370	remaining int64
371	closed    bool
372}
373
374func (l *limitedReadCloser) Read(p []byte) (int, error) {
375	if l.remaining <= 0 {
376		// Close the underlying body to release upstream connection/resources
377		if !l.closed {
378			l.closed = true
379			l.ReadCloser.Close()
380		}
381		return 0, fmt.Errorf("response body exceeded maximum size limit")
382	}
383	if int64(len(p)) > l.remaining {
384		p = p[:l.remaining]
385	}
386	n, err := l.ReadCloser.Read(p)
387	l.remaining -= int64(n)
388	return n, err
389}
390
391func (l *limitedReadCloser) Close() error {
392	if l.closed {
393		return nil
394	}
395	l.closed = true
396	return l.ReadCloser.Close()
397}
398
399// proxyError writes an error response with security headers appropriate for
400// proxy-generated error pages. CSP is set here (not in global middleware)
401// because Gordon proxies arbitrary web apps and a blanket CSP would break them.
402func proxyError(w http.ResponseWriter, msg string, code int) {
403	w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
404	w.Header().Set("Cache-Control", "no-store")
405	http.Error(w, msg, code)
406}
407
408// Helper methods
409
410// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
411// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
412// to strip the Authorization header when using the default Director. Rewrite processes
413// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
414//
415// originalHost, when non-empty, overrides the Host header sent to the backend.
416// This is used for DNS-pinned targets where the proxy dials an IP but needs to
417// send the original hostname for virtual-hosted upstreams.
418func newReverseProxy(targetURL *url.URL, originalHost string, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
419	return &httputil.ReverseProxy{
420		Rewrite: func(pr *httputil.ProxyRequest) {
421			pr.SetURL(targetURL)
422			// SECURITY: Let SetXForwarded() set X-Forwarded-Proto based on the actual
423			// connection state (r.TLS). We do NOT preserve the original X-Forwarded-Proto
424			// from the incoming request because we cannot verify it came from a trusted
425			// proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection
426			// to trick backends into thinking the connection is secure.
427			pr.SetXForwarded()
428			if originalHost != "" {
429				// DNS pinning: dial the resolved IP but send the original hostname
430				// so virtual-hosted upstreams route the request correctly.
431				pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
432			} else {
433				pr.Out.Host = targetURL.Host
434			}
435		},
436		Transport:      proxyTransport,
437		ErrorHandler:   errorHandler,
438		ModifyResponse: modifyResponse,
439	}
440}
441
442func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
443	s.mu.RLock()
444	cfg := s.config
445	s.mu.RUnlock()
446
447	log := zerowrap.FromCtx(r.Context())
448
449	// Use net.JoinHostPort for IPv6-safe URL construction (brackets are added automatically)
450	targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
451	if err != nil {
452		log.WrapErr(err, "failed to parse target URL")
453		proxyError(w, "Internal Server Error", http.StatusInternalServerError)
454		return
455	}
456
457	log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
458
459	// If DNS pinning resolved hostname to IP, preserve the original hostname
460	// for the Host header so virtual-hosted upstreams work correctly.
461	originalHost := target.OriginalHost
462
463	proxy := newReverseProxy(targetURL, originalHost,
464		func(w http.ResponseWriter, _ *http.Request, err error) {
465			var maxBytesErr *http.MaxBytesError
466			if errors.As(err, &maxBytesErr) {
467				log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
468				proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
469				return
470			}
471			log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
472			proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
473		},
474		s.modifyResponse(cfg),
475	)
476
477	log.Debug().
478		Str("target", targetURL.String()).
479		Str(zerowrap.FieldEntityID, target.ContainerID).
480		Msg("proxying request")
481
482	proxy.ServeHTTP(w, r)
483}
484
485// proxyToRegistry forwards requests to the local registry HTTP server.
486// SECURITY: This uses http://localhost:{port} which is safe because the registry
487// runs on the same host and traffic never leaves the loopback interface. If Gordon
488// ever supports running the registry on a separate host, this must use TLS with
489// certificate verification.
490func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
491	// Copy config under RLock to avoid data race with UpdateConfig
492	s.mu.RLock()
493	cfg := s.config
494	s.mu.RUnlock()
495
496	log := zerowrap.FromCtx(r.Context())
497
498	targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
499	if err != nil {
500		log.WrapErr(err, "failed to parse registry target URL")
501		proxyError(w, "Internal Server Error", http.StatusInternalServerError)
502		return
503	}
504
505	proxy := newReverseProxy(targetURL, "",
506		func(w http.ResponseWriter, _ *http.Request, err error) {
507			var maxBytesErr *http.MaxBytesError
508			if errors.As(err, &maxBytesErr) {
509				log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
510				proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
511				return
512			}
513			log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
514			proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
515		},
516		func(resp *http.Response) error {
517			resp.Header.Set("X-Proxied-By", "Gordon")
518			resp.Header.Set("X-Registry-Backend", "gordon-registry")
519
520			// Remove security headers from registry response to prevent duplicates.
521			// The proxy middleware already adds these headers, so the registry's
522			// copies would create duplicates in the final response.
523			resp.Header.Del("X-Content-Type-Options")
524			resp.Header.Del("X-Frame-Options")
525			resp.Header.Del("X-XSS-Protection")
526			resp.Header.Del("Referrer-Policy")
527			resp.Header.Del("Permissions-Policy")
528			resp.Header.Del("Content-Security-Policy")
529			resp.Header.Del("Strict-Transport-Security")
530
531			return nil
532		},
533	)
534
535	log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
536
537	proxy.ServeHTTP(w, r)
538}
539
540func (s *Service) isRunningInContainer() bool {
541	// Check for /.dockerenv
542	if _, err := os.Stat("/.dockerenv"); err == nil {
543		return true
544	}
545
546	// Check cgroup for container indicators
547	if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
548		content := string(data)
549		if strings.Contains(content, "docker") ||
550			strings.Contains(content, "containerd") ||
551			strings.Contains(content, "podman") {
552			return true
553		}
554	}
555
556	// NOTE: Hostname length check (12 or 64 chars) was removed because it produced
557	// false positives on hosts with short hostnames (e.g., "web-server-1" = 12 chars),
558	// which would cause the proxy to use container network IPs instead of host port mappings.
559
560	// Check environment variables
561	if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
562		os.Getenv("DOCKER_CONTAINER") != "" ||
563		os.Getenv("container") != "" {
564		return true
565	}
566
567	return false
568}
569
570// getProxyPort determines the port to proxy to for an image.
571// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
572func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
573	log := zerowrap.FromCtx(ctx)
574
575	log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
576
577	// Check for explicit port label
578	labels, err := s.runtime.GetImageLabels(ctx, imageRef)
579	if err != nil {
580		log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
581	} else if portStr, ok := labels[domain.LabelProxyPort]; ok {
582		port, err := strconv.Atoi(portStr)
583		if err == nil && port > 0 {
584			log.Debug().Int("port", port).Msg("using proxy port from image label")
585			return port, nil
586		}
587		log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
588	}
589
590	// Fall back to first exposed port
591	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
592	if err != nil {
593		return 0, err
594	}
595	if len(exposedPorts) == 0 {
596		return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
597	}
598
599	log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
600	return exposedPorts[0], nil
601}

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