v2.9.0 8ebdfe0

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}
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		// SECURITY: Validate target is not an internal/blocked network (SSRF protection)
144		if err := ValidateExternalRouteTarget(host); err != nil {
145			log.Warn().
146				Err(err).
147				Str("host", host).
148				Str("domain", domainName).
149				Msg("SSRF protection: blocked external route to internal network")
150			return nil, err
151		}
152
153		target := &domain.ProxyTarget{
154			Host:        host,
155			Port:        port,
156			ContainerID: "", // Not a container
157			Scheme:      "http",
158		}
159
160		// Cache external route target
161		s.mu.Lock()
162		s.targets[domainName] = target
163		s.mu.Unlock()
164
165		log.Debug().
166			Str("host", host).
167			Int("port", port).
168			Msg("using external route target")
169		return target, nil
170	}
171
172	// Get container for this domain
173	container, exists := s.containerSvc.Get(ctx, domainName)
174	if !exists {
175		log.Debug().Msg("container not found for domain")
176		return nil, domain.ErrNoTargetAvailable
177	}
178	log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
179
180	// Build target based on runtime mode
181	var target *domain.ProxyTarget
182
183	if s.isRunningInContainer() {
184		// Gordon is in a container - use container network
185		containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
186		if err != nil {
187			return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
188		}
189		target = &domain.ProxyTarget{
190			Host:        containerIP,
191			Port:        containerPort,
192			ContainerID: container.ID,
193			Scheme:      "http",
194		}
195	} else {
196		// Gordon is on the host - use host port mapping
197		routes := s.configSvc.GetRoutes(ctx)
198		var route *domain.Route
199		for _, r := range routes {
200			if r.Domain == domainName {
201				route = &r
202				break
203			}
204		}
205
206		if route == nil {
207			return nil, domain.ErrRouteNotFound
208		}
209
210		// Determine target port: check for label first, then fall back to first exposed port
211		// Use container.Image (the actual running image) instead of route.Image (config shorthand)
212		targetPort, err := s.getProxyPort(ctx, container.Image)
213		if err != nil {
214			return nil, log.WrapErr(err, "failed to determine proxy port")
215		}
216
217		hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
218		if err != nil {
219			return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
220		}
221
222		target = &domain.ProxyTarget{
223			Host:        "localhost",
224			Port:        hostPort,
225			ContainerID: container.ID,
226			Scheme:      "http",
227		}
228	}
229
230	// Cache the target
231	s.mu.Lock()
232	s.targets[domainName] = target
233	s.mu.Unlock()
234
235	return target, nil
236}
237
238// RegisterTarget registers a new proxy target for a domain.
239func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
240	s.mu.Lock()
241	defer s.mu.Unlock()
242
243	s.targets[domainName] = target
244	return nil
245}
246
247// UnregisterTarget removes a proxy target for a domain.
248func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
249	s.mu.Lock()
250	defer s.mu.Unlock()
251
252	delete(s.targets, domainName)
253	return nil
254}
255
256// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
257// This is used during zero-downtime deployments to switch traffic to a new container.
258func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
259	s.mu.Lock()
260	defer s.mu.Unlock()
261
262	delete(s.targets, domainName)
263}
264
265// RefreshTargets refreshes all proxy targets from container state.
266func (s *Service) RefreshTargets(ctx context.Context) error {
267	s.mu.Lock()
268	s.targets = make(map[string]*domain.ProxyTarget)
269	s.mu.Unlock()
270
271	log := zerowrap.FromCtx(ctx)
272	log.Debug().Msg("proxy targets cache cleared")
273	return nil
274}
275
276// UpdateConfig updates the service configuration.
277func (s *Service) UpdateConfig(config Config) {
278	s.config = config
279}
280
281// Helper methods
282
283// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
284// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
285// to strip the Authorization header when using the default Director. Rewrite processes
286// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
287func newReverseProxy(targetURL *url.URL, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
288	return &httputil.ReverseProxy{
289		Rewrite: func(pr *httputil.ProxyRequest) {
290			pr.SetURL(targetURL)
291			// Preserve original X-Forwarded-Proto from upstream proxy (e.g., Cloudflare)
292			// before SetXForwarded overwrites it based on local connection
293			origProto := pr.In.Header.Get("X-Forwarded-Proto")
294			pr.SetXForwarded()
295			if origProto != "" {
296				pr.Out.Header.Set("X-Forwarded-Proto", origProto)
297			}
298			pr.Out.Host = targetURL.Host
299		},
300		Transport:      proxyTransport,
301		ErrorHandler:   errorHandler,
302		ModifyResponse: modifyResponse,
303	}
304}
305
306func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
307	log := zerowrap.FromCtx(r.Context())
308
309	targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port))
310	if err != nil {
311		log.WrapErr(err, "failed to parse target URL")
312		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
313		return
314	}
315
316	log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
317
318	proxy := newReverseProxy(targetURL,
319		func(w http.ResponseWriter, _ *http.Request, err error) {
320			log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
321			http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
322		},
323		func(resp *http.Response) error {
324			resp.Header.Set("X-Proxied-By", "Gordon")
325			return nil
326		},
327	)
328
329	log.Debug().
330		Str("target", targetURL.String()).
331		Str(zerowrap.FieldEntityID, target.ContainerID).
332		Msg("proxying request")
333
334	proxy.ServeHTTP(w, r)
335}
336
337func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
338	log := zerowrap.FromCtx(r.Context())
339
340	targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", s.config.RegistryPort))
341	if err != nil {
342		log.WrapErr(err, "failed to parse registry target URL")
343		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
344		return
345	}
346
347	proxy := newReverseProxy(targetURL,
348		func(w http.ResponseWriter, _ *http.Request, err error) {
349			log.Error().Err(err).Int("registry_port", s.config.RegistryPort).Msg("registry proxy error")
350			http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)
351		},
352		func(resp *http.Response) error {
353			resp.Header.Set("X-Proxied-By", "Gordon")
354			resp.Header.Set("X-Registry-Backend", "gordon-registry")
355
356			// Remove security headers from registry response to prevent duplicates.
357			// The proxy middleware already adds these headers, so the registry's
358			// copies would create duplicates in the final response.
359			resp.Header.Del("X-Content-Type-Options")
360			resp.Header.Del("X-Frame-Options")
361			resp.Header.Del("X-XSS-Protection")
362			resp.Header.Del("Referrer-Policy")
363			resp.Header.Del("Permissions-Policy")
364
365			return nil
366		},
367	)
368
369	log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
370
371	proxy.ServeHTTP(w, r)
372}
373
374func (s *Service) isRunningInContainer() bool {
375	// Check for /.dockerenv
376	if _, err := os.Stat("/.dockerenv"); err == nil {
377		return true
378	}
379
380	// Check cgroup for container indicators
381	if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
382		content := string(data)
383		if strings.Contains(content, "docker") ||
384			strings.Contains(content, "containerd") ||
385			strings.Contains(content, "podman") {
386			return true
387		}
388	}
389
390	// Check hostname pattern
391	if hostname, err := os.Hostname(); err == nil {
392		if len(hostname) == 12 || len(hostname) == 64 {
393			return true
394		}
395	}
396
397	// Check environment variables
398	if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
399		os.Getenv("DOCKER_CONTAINER") != "" ||
400		os.Getenv("container") != "" {
401		return true
402	}
403
404	return false
405}
406
407// getProxyPort determines the port to proxy to for an image.
408// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
409func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
410	log := zerowrap.FromCtx(ctx)
411
412	log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
413
414	// Check for explicit port label
415	labels, err := s.runtime.GetImageLabels(ctx, imageRef)
416	if err != nil {
417		log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
418	} else if portStr, ok := labels[domain.LabelProxyPort]; ok {
419		port, err := strconv.Atoi(portStr)
420		if err == nil && port > 0 {
421			log.Debug().Int("port", port).Msg("using proxy port from image label")
422			return port, nil
423		}
424		log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
425	}
426
427	// Fall back to first exposed port
428	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
429	if err != nil {
430		return 0, err
431	}
432	if len(exposedPorts) == 0 {
433		return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
434	}
435
436	log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
437	return exposedPorts[0], nil
438}

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