v2.10.0 893d8fa

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

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