v2.11.0 dca6537

service.go RawHistoryFolder

1// Package proxy implements the reverse proxy use case.
2package proxy
3
4import (
5	"context"
6	"fmt"
7	"net"
8	"net/http"
9	"net/http/httputil"
10	"net/url"
11	"os"
12	"strconv"
13	"strings"
14	"sync"
15	"time"
16
17	"github.com/bnema/zerowrap"
18
19	"github.com/bnema/gordon/internal/boundaries/in"
20	"github.com/bnema/gordon/internal/boundaries/out"
21	"github.com/bnema/gordon/internal/domain"
22)
23
24// proxyTransport is a shared HTTP transport with proper timeouts.
25// This prevents resource exhaustion from slow backends or network issues.
26var proxyTransport = &http.Transport{
27	DialContext: (&net.Dialer{
28		Timeout:   10 * time.Second,
29		KeepAlive: 30 * time.Second,
30	}).DialContext,
31	TLSHandshakeTimeout:   10 * time.Second,
32	ResponseHeaderTimeout: 30 * time.Second,
33	MaxIdleConns:          100,
34	MaxIdleConnsPerHost:   10,
35	IdleConnTimeout:       90 * time.Second,
36}
37
38// Config holds configuration needed by the proxy service.
39type Config struct {
40	RegistryDomain string
41	RegistryPort   int
42	MaxBodySize    int64 // Maximum request body size in bytes (0 = no limit)
43}
44
45// Service implements the ProxyService interface.
46type Service struct {
47	runtime      out.ContainerRuntime
48	containerSvc in.ContainerService
49	configSvc    in.ConfigService
50	config       Config
51	targets      map[string]*domain.ProxyTarget
52	mu           sync.RWMutex
53}
54
55// NewService creates a new proxy service.
56func NewService(
57	runtime out.ContainerRuntime,
58	containerSvc in.ContainerService,
59	configSvc in.ConfigService,
60	config Config,
61) *Service {
62	return &Service{
63		runtime:      runtime,
64		containerSvc: containerSvc,
65		configSvc:    configSvc,
66		config:       config,
67		targets:      make(map[string]*domain.ProxyTarget),
68	}
69}
70
71// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
72func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
73	// Copy config under RLock to avoid data race with UpdateConfig
74	s.mu.RLock()
75	cfg := s.config
76	s.mu.RUnlock()
77
78	// SECURITY: Limit request body size to prevent resource exhaustion.
79	if cfg.MaxBodySize > 0 {
80		r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
81	}
82
83	// Enrich request context with fields for downstream logging
84	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
85		zerowrap.FieldLayer:    "adapter",
86		zerowrap.FieldAdapter:  "http",
87		zerowrap.FieldMethod:   r.Method,
88		zerowrap.FieldPath:     r.URL.Path,
89		zerowrap.FieldHost:     r.Host,
90		zerowrap.FieldClientIP: r.RemoteAddr,
91	})
92	r = r.WithContext(ctx)
93	log := zerowrap.FromCtx(ctx)
94
95	// Check if this is the registry domain
96	if cfg.RegistryDomain != "" && r.Host == cfg.RegistryDomain {
97		log.Info().Msg("routing request to registry")
98		s.proxyToRegistry(w, r)
99		return
100	}
101
102	// Get target for this domain
103	log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
104	target, err := s.GetTarget(ctx, r.Host)
105	if err != nil {
106		log.Warn().Err(err).Msg("no route found for domain")
107		http.NotFound(w, r)
108		return
109	}
110
111	log.Debug().
112		Str("host", target.Host).
113		Int("port", target.Port).
114		Str("container_id", target.ContainerID).
115		Msg("resolved proxy target")
116
117	s.proxyToTarget(w, r, target)
118}
119
120// GetTarget returns the proxy target for a given domain.
121func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) {
122	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
123		zerowrap.FieldLayer:   "usecase",
124		zerowrap.FieldUseCase: "GetTarget",
125		"domain":              domainName,
126	})
127	log := zerowrap.FromCtx(ctx)
128
129	// Check cache first
130	s.mu.RLock()
131	if target, exists := s.targets[domainName]; exists {
132		s.mu.RUnlock()
133		log.Debug().
134			Str("host", target.Host).
135			Int("port", target.Port).
136			Str("container_id", target.ContainerID).
137			Msg("using cached proxy target")
138		return target, nil
139	}
140	s.mu.RUnlock()
141
142	// Check if this is an external route
143	externalRoutes := s.configSvc.GetExternalRoutes()
144	if targetAddr, ok := externalRoutes[domainName]; ok {
145		host, portStr, err := net.SplitHostPort(targetAddr)
146		if err != nil {
147			return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
148		}
149		port, err := strconv.Atoi(portStr)
150		if err != nil {
151			return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
152		}
153
154		// SECURITY: Validate target is not an internal/blocked network (SSRF protection)
155		if err := ValidateExternalRouteTarget(host); err != nil {
156			log.Warn().
157				Err(err).
158				Str("host", host).
159				Str("domain", domainName).
160				Msg("SSRF protection: blocked external route to internal network")
161			return nil, err
162		}
163
164		target := &domain.ProxyTarget{
165			Host:        host,
166			Port:        port,
167			ContainerID: "", // Not a container
168			Scheme:      "http",
169		}
170
171		// Cache external route target
172		s.mu.Lock()
173		s.targets[domainName] = target
174		s.mu.Unlock()
175
176		log.Debug().
177			Str("host", host).
178			Int("port", port).
179			Msg("using external route target")
180		return target, nil
181	}
182
183	// Get container for this domain
184	container, exists := s.containerSvc.Get(ctx, domainName)
185	if !exists {
186		log.Debug().Msg("container not found for domain")
187		return nil, domain.ErrNoTargetAvailable
188	}
189	log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
190
191	// Build target based on runtime mode
192	var target *domain.ProxyTarget
193
194	if s.isRunningInContainer() {
195		// Gordon is in a container - use container network
196		containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
197		if err != nil {
198			return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
199		}
200		target = &domain.ProxyTarget{
201			Host:        containerIP,
202			Port:        containerPort,
203			ContainerID: container.ID,
204			Scheme:      "http",
205		}
206	} else {
207		// Gordon is on the host - use host port mapping
208		routes := s.configSvc.GetRoutes(ctx)
209		var route *domain.Route
210		for _, r := range routes {
211			if r.Domain == domainName {
212				route = &r
213				break
214			}
215		}
216
217		if route == nil {
218			return nil, domain.ErrRouteNotFound
219		}
220
221		// Determine target port: check for label first, then fall back to first exposed port
222		// Use container.Image (the actual running image) instead of route.Image (config shorthand)
223		targetPort, err := s.getProxyPort(ctx, container.Image)
224		if err != nil {
225			return nil, log.WrapErr(err, "failed to determine proxy port")
226		}
227
228		hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
229		if err != nil {
230			return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
231		}
232
233		target = &domain.ProxyTarget{
234			Host:        "localhost",
235			Port:        hostPort,
236			ContainerID: container.ID,
237			Scheme:      "http",
238		}
239	}
240
241	// Cache the target
242	s.mu.Lock()
243	s.targets[domainName] = target
244	s.mu.Unlock()
245
246	return target, nil
247}
248
249// RegisterTarget registers a new proxy target for a domain.
250func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
251	s.mu.Lock()
252	defer s.mu.Unlock()
253
254	s.targets[domainName] = target
255	return nil
256}
257
258// UnregisterTarget removes a proxy target for a domain.
259func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
260	s.mu.Lock()
261	defer s.mu.Unlock()
262
263	delete(s.targets, domainName)
264	return nil
265}
266
267// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
268// This is used during zero-downtime deployments to switch traffic to a new container.
269func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
270	s.mu.Lock()
271	defer s.mu.Unlock()
272
273	delete(s.targets, domainName)
274}
275
276// RefreshTargets refreshes all proxy targets from container state.
277func (s *Service) RefreshTargets(ctx context.Context) error {
278	s.mu.Lock()
279	s.targets = make(map[string]*domain.ProxyTarget)
280	s.mu.Unlock()
281
282	log := zerowrap.FromCtx(ctx)
283	log.Debug().Msg("proxy targets cache cleared")
284	return nil
285}
286
287// UpdateConfig updates the service configuration.
288func (s *Service) UpdateConfig(config Config) {
289	s.mu.Lock()
290	s.config = config
291	s.mu.Unlock()
292}
293
294// Helper methods
295
296// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
297// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
298// to strip the Authorization header when using the default Director. Rewrite processes
299// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
300func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
301	return &httputil.ReverseProxy{
302		Rewrite: func(pr *httputil.ProxyRequest) {
303			pr.SetURL(targetURL)
304			// SECURITY: Let SetXForwarded() set X-Forwarded-Proto based on the actual
305			// connection state (r.TLS). We do NOT preserve the original X-Forwarded-Proto
306			// from the incoming request because we cannot verify it came from a trusted
307			// proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection
308			// to trick backends into thinking the connection is secure.
309			pr.SetXForwarded()
310			pr.Out.Host = targetURL.Host
311		},
312		Transport:      proxyTransport,
313		ErrorHandler:   errorHandler,
314		ModifyResponse: modifyResponse,
315	}
316}
317
318func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
319	log := zerowrap.FromCtx(r.Context())
320
321	targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port))
322	if err != nil {
323		log.WrapErr(err, "failed to parse target URL")
324		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
325		return
326	}
327
328	log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
329
330	proxy := newReverseProxy(targetURL,
331		func(w http.ResponseWriter, _ *http.Request, err error) {
332			// Check if this is a MaxBytesError (request body too large)
333			if err.Error() == "http: request body too large" {
334				log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
335				http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
336				return
337			}
338			log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
339			http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
340		},
341		func(resp *http.Response) error {
342			resp.Header.Set("X-Proxied-By", "Gordon")
343			return nil
344		},
345	)
346
347	log.Debug().
348		Str("target", targetURL.String()).
349		Str(zerowrap.FieldEntityID, target.ContainerID).
350		Msg("proxying request")
351
352	proxy.ServeHTTP(w, r)
353}
354
355func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
356	// Copy config under RLock to avoid data race with UpdateConfig
357	s.mu.RLock()
358	cfg := s.config
359	s.mu.RUnlock()
360
361	log := zerowrap.FromCtx(r.Context())
362
363	targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
364	if err != nil {
365		log.WrapErr(err, "failed to parse registry target URL")
366		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
367		return
368	}
369
370	proxy := newReverseProxy(targetURL,
371		func(w http.ResponseWriter, _ *http.Request, err error) {
372			// Check if this is a MaxBytesError (request body too large)
373			if err.Error() == "http: request body too large" {
374				log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
375				http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
376				return
377			}
378			log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
379			http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)
380		},
381		func(resp *http.Response) error {
382			resp.Header.Set("X-Proxied-By", "Gordon")
383			resp.Header.Set("X-Registry-Backend", "gordon-registry")
384
385			// Remove security headers from registry response to prevent duplicates.
386			// The proxy middleware already adds these headers, so the registry's
387			// copies would create duplicates in the final response.
388			resp.Header.Del("X-Content-Type-Options")
389			resp.Header.Del("X-Frame-Options")
390			resp.Header.Del("X-XSS-Protection")
391			resp.Header.Del("Referrer-Policy")
392			resp.Header.Del("Permissions-Policy")
393
394			return nil
395		},
396	)
397
398	log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
399
400	proxy.ServeHTTP(w, r)
401}
402
403func (s *Service) isRunningInContainer() bool {
404	// Check for /.dockerenv
405	if _, err := os.Stat("/.dockerenv"); err == nil {
406		return true
407	}
408
409	// Check cgroup for container indicators
410	if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
411		content := string(data)
412		if strings.Contains(content, "docker") ||
413			strings.Contains(content, "containerd") ||
414			strings.Contains(content, "podman") {
415			return true
416		}
417	}
418
419	// NOTE: Hostname length check (12 or 64 chars) was removed because it produced
420	// false positives on hosts with short hostnames (e.g., "web-server-1" = 12 chars),
421	// which would cause the proxy to use container network IPs instead of host port mappings.
422
423	// Check environment variables
424	if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
425		os.Getenv("DOCKER_CONTAINER") != "" ||
426		os.Getenv("container") != "" {
427		return true
428	}
429
430	return false
431}
432
433// getProxyPort determines the port to proxy to for an image.
434// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
435func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
436	log := zerowrap.FromCtx(ctx)
437
438	log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
439
440	// Check for explicit port label
441	labels, err := s.runtime.GetImageLabels(ctx, imageRef)
442	if err != nil {
443		log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
444	} else if portStr, ok := labels[domain.LabelProxyPort]; ok {
445		port, err := strconv.Atoi(portStr)
446		if err == nil && port > 0 {
447			log.Debug().Int("port", port).Msg("using proxy port from image label")
448			return port, nil
449		}
450		log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
451	}
452
453	// Fall back to first exposed port
454	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
455	if err != nil {
456		return 0, err
457	}
458	if len(exposedPorts) == 0 {
459		return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
460	}
461
462	log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
463	return exposedPorts[0], nil
464}

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