v2.4.1 7c3b292

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	"gordon/internal/boundaries/in"
20	"gordon/internal/boundaries/out"
21	"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}
43
44// Service implements the ProxyService interface.
45type Service struct {
46	runtime      out.ContainerRuntime
47	containerSvc in.ContainerService
48	configSvc    in.ConfigService
49	config       Config
50	targets      map[string]*domain.ProxyTarget
51	mu           sync.RWMutex
52}
53
54// NewService creates a new proxy service.
55func NewService(
56	runtime out.ContainerRuntime,
57	containerSvc in.ContainerService,
58	configSvc in.ConfigService,
59	config Config,
60) *Service {
61	return &Service{
62		runtime:      runtime,
63		containerSvc: containerSvc,
64		configSvc:    configSvc,
65		config:       config,
66		targets:      make(map[string]*domain.ProxyTarget),
67	}
68}
69
70// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
71func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
72	// Enrich request context with fields for downstream logging
73	ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
74		zerowrap.FieldLayer:    "adapter",
75		zerowrap.FieldAdapter:  "http",
76		zerowrap.FieldMethod:   r.Method,
77		zerowrap.FieldPath:     r.URL.Path,
78		zerowrap.FieldHost:     r.Host,
79		zerowrap.FieldClientIP: r.RemoteAddr,
80	})
81	r = r.WithContext(ctx)
82	log := zerowrap.FromCtx(ctx)
83
84	// Check if this is the registry domain
85	if s.config.RegistryDomain != "" && r.Host == s.config.RegistryDomain {
86		log.Info().Msg("routing request to registry")
87		s.proxyToRegistry(w, r)
88		return
89	}
90
91	// Get target for this domain
92	log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
93	target, err := s.GetTarget(ctx, r.Host)
94	if err != nil {
95		log.Warn().Err(err).Msg("no route found for domain")
96		http.NotFound(w, r)
97		return
98	}
99
100	log.Debug().
101		Str("host", target.Host).
102		Int("port", target.Port).
103		Str("container_id", target.ContainerID).
104		Msg("resolved proxy target")
105
106	s.proxyToTarget(w, r, target)
107}
108
109// GetTarget returns the proxy target for a given domain.
110func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) {
111	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
112		zerowrap.FieldLayer:   "usecase",
113		zerowrap.FieldUseCase: "GetTarget",
114		"domain":              domainName,
115	})
116	log := zerowrap.FromCtx(ctx)
117
118	// Check cache first
119	s.mu.RLock()
120	if target, exists := s.targets[domainName]; exists {
121		s.mu.RUnlock()
122		log.Debug().
123			Str("host", target.Host).
124			Int("port", target.Port).
125			Str("container_id", target.ContainerID).
126			Msg("using cached proxy target")
127		return target, nil
128	}
129	s.mu.RUnlock()
130
131	// Check if this is an external route
132	externalRoutes := s.configSvc.GetExternalRoutes()
133	if targetAddr, ok := externalRoutes[domainName]; ok {
134		host, portStr, err := net.SplitHostPort(targetAddr)
135		if err != nil {
136			return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
137		}
138		port, err := strconv.Atoi(portStr)
139		if err != nil {
140			return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
141		}
142
143		target := &domain.ProxyTarget{
144			Host:        host,
145			Port:        port,
146			ContainerID: "", // Not a container
147			Scheme:      "http",
148		}
149
150		// Cache external route target
151		s.mu.Lock()
152		s.targets[domainName] = target
153		s.mu.Unlock()
154
155		log.Debug().
156			Str("host", host).
157			Int("port", port).
158			Msg("using external route target")
159		return target, nil
160	}
161
162	// Get container for this domain
163	container, exists := s.containerSvc.Get(ctx, domainName)
164	if !exists {
165		log.Debug().Msg("container not found for domain")
166		return nil, domain.ErrNoTargetAvailable
167	}
168	log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
169
170	// Build target based on runtime mode
171	var target *domain.ProxyTarget
172
173	if s.isRunningInContainer() {
174		// Gordon is in a container - use container network
175		containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
176		if err != nil {
177			return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
178		}
179		target = &domain.ProxyTarget{
180			Host:        containerIP,
181			Port:        containerPort,
182			ContainerID: container.ID,
183			Scheme:      "http",
184		}
185	} else {
186		// Gordon is on the host - use host port mapping
187		routes := s.configSvc.GetRoutes(ctx)
188		var route *domain.Route
189		for _, r := range routes {
190			if r.Domain == domainName {
191				route = &r
192				break
193			}
194		}
195
196		if route == nil {
197			return nil, domain.ErrRouteNotFound
198		}
199
200		// Determine target port: check for label first, then fall back to first exposed port
201		// Use container.Image (the actual running image) instead of route.Image (config shorthand)
202		targetPort, err := s.getProxyPort(ctx, container.Image)
203		if err != nil {
204			return nil, log.WrapErr(err, "failed to determine proxy port")
205		}
206
207		hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
208		if err != nil {
209			return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
210		}
211
212		target = &domain.ProxyTarget{
213			Host:        "localhost",
214			Port:        hostPort,
215			ContainerID: container.ID,
216			Scheme:      "http",
217		}
218	}
219
220	// Cache the target
221	s.mu.Lock()
222	s.targets[domainName] = target
223	s.mu.Unlock()
224
225	return target, nil
226}
227
228// RegisterTarget registers a new proxy target for a domain.
229func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
230	s.mu.Lock()
231	defer s.mu.Unlock()
232
233	s.targets[domainName] = target
234	return nil
235}
236
237// UnregisterTarget removes a proxy target for a domain.
238func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
239	s.mu.Lock()
240	defer s.mu.Unlock()
241
242	delete(s.targets, domainName)
243	return nil
244}
245
246// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
247// This is used during zero-downtime deployments to switch traffic to a new container.
248func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
249	s.mu.Lock()
250	defer s.mu.Unlock()
251
252	delete(s.targets, domainName)
253}
254
255// RefreshTargets refreshes all proxy targets from container state.
256func (s *Service) RefreshTargets(ctx context.Context) error {
257	s.mu.Lock()
258	s.targets = make(map[string]*domain.ProxyTarget)
259	s.mu.Unlock()
260
261	log := zerowrap.FromCtx(ctx)
262	log.Debug().Msg("proxy targets cache cleared")
263	return nil
264}
265
266// UpdateConfig updates the service configuration.
267func (s *Service) UpdateConfig(config Config) {
268	s.config = config
269}
270
271// Helper methods
272
273// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
274// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
275// to strip the Authorization header when using the default Director. Rewrite processes
276// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
277func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
278	return &httputil.ReverseProxy{
279		Rewrite: func(pr *httputil.ProxyRequest) {
280			pr.SetURL(targetURL)
281			// Preserve original X-Forwarded-Proto from upstream proxy (e.g., Cloudflare)
282			// before SetXForwarded overwrites it based on local connection
283			origProto := pr.In.Header.Get("X-Forwarded-Proto")
284			pr.SetXForwarded()
285			if origProto != "" {
286				pr.Out.Header.Set("X-Forwarded-Proto", origProto)
287			}
288			pr.Out.Host = targetURL.Host
289		},
290		Transport:      proxyTransport,
291		ErrorHandler:   errorHandler,
292		ModifyResponse: modifyResponse,
293	}
294}
295
296func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
297	log := zerowrap.FromCtx(r.Context())
298
299	targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port))
300	if err != nil {
301		log.WrapErr(err, "failed to parse target URL")
302		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
303		return
304	}
305
306	log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
307
308	proxy := newReverseProxy(targetURL,
309		func(w http.ResponseWriter, _ *http.Request, err error) {
310			log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
311			http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
312		},
313		func(resp *http.Response) error {
314			resp.Header.Set("X-Proxied-By", "Gordon")
315			resp.Header.Set("X-Container-ID", target.ContainerID)
316			return nil
317		},
318	)
319
320	log.Debug().
321		Str("target", targetURL.String()).
322		Str(zerowrap.FieldEntityID, target.ContainerID).
323		Msg("proxying request")
324
325	proxy.ServeHTTP(w, r)
326}
327
328func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
329	log := zerowrap.FromCtx(r.Context())
330
331	targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", s.config.RegistryPort))
332	if err != nil {
333		log.WrapErr(err, "failed to parse registry target URL")
334		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
335		return
336	}
337
338	proxy := newReverseProxy(targetURL,
339		func(w http.ResponseWriter, _ *http.Request, err error) {
340			log.Error().Err(err).Int("registry_port", s.config.RegistryPort).Msg("registry proxy error")
341			http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)
342		},
343		func(resp *http.Response) error {
344			resp.Header.Set("X-Proxied-By", "Gordon")
345			resp.Header.Set("X-Registry-Backend", "gordon-registry")
346			return nil
347		},
348	)
349
350	log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
351
352	proxy.ServeHTTP(w, r)
353}
354
355func (s *Service) isRunningInContainer() bool {
356	// Check for /.dockerenv
357	if _, err := os.Stat("/.dockerenv"); err == nil {
358		return true
359	}
360
361	// Check cgroup for container indicators
362	if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
363		content := string(data)
364		if strings.Contains(content, "docker") ||
365			strings.Contains(content, "containerd") ||
366			strings.Contains(content, "podman") {
367			return true
368		}
369	}
370
371	// Check hostname pattern
372	if hostname, err := os.Hostname(); err == nil {
373		if len(hostname) == 12 || len(hostname) == 64 {
374			return true
375		}
376	}
377
378	// Check environment variables
379	if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
380		os.Getenv("DOCKER_CONTAINER") != "" ||
381		os.Getenv("container") != "" {
382		return true
383	}
384
385	return false
386}
387
388// getProxyPort determines the port to proxy to for an image.
389// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
390func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
391	log := zerowrap.FromCtx(ctx)
392
393	log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
394
395	// Check for explicit port label
396	labels, err := s.runtime.GetImageLabels(ctx, imageRef)
397	if err != nil {
398		log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
399	} else if portStr, ok := labels[domain.LabelProxyPort]; ok {
400		port, err := strconv.Atoi(portStr)
401		if err == nil && port > 0 {
402			log.Debug().Int("port", port).Msg("using proxy port from image label")
403			return port, nil
404		}
405		log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
406	}
407
408	// Fall back to first exposed port
409	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
410	if err != nil {
411		return 0, err
412	}
413	if len(exposedPorts) == 0 {
414		return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
415	}
416
417	log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
418	return exposedPorts[0], nil
419}

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