v2.2.0 64d39bf

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	target, err := s.GetTarget(ctx, r.Host)
93	if err != nil {
94		log.Warn().Err(err).Msg("no route found for domain")
95		http.NotFound(w, r)
96		return
97	}
98
99	log.Debug().
100		Str("host", target.Host).
101		Int("port", target.Port).
102		Str("container_id", target.ContainerID).
103		Msg("resolved proxy target")
104
105	s.proxyToTarget(w, r, target)
106}
107
108// GetTarget returns the proxy target for a given domain.
109func (s *Service) GetTarget(ctx context.Context, domainName string) (*domain.ProxyTarget, error) {
110	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
111		zerowrap.FieldLayer:   "usecase",
112		zerowrap.FieldUseCase: "GetTarget",
113		"domain":              domainName,
114	})
115	log := zerowrap.FromCtx(ctx)
116
117	// Check cache first
118	s.mu.RLock()
119	if target, exists := s.targets[domainName]; exists {
120		s.mu.RUnlock()
121		log.Debug().
122			Str("host", target.Host).
123			Int("port", target.Port).
124			Str("container_id", target.ContainerID).
125			Msg("using cached proxy target")
126		return target, nil
127	}
128	s.mu.RUnlock()
129
130	// Check if this is an external route
131	externalRoutes := s.configSvc.GetExternalRoutes()
132	if targetAddr, ok := externalRoutes[domainName]; ok {
133		host, portStr, err := net.SplitHostPort(targetAddr)
134		if err != nil {
135			return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
136		}
137		port, err := strconv.Atoi(portStr)
138		if err != nil {
139			return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
140		}
141
142		target := &domain.ProxyTarget{
143			Host:        host,
144			Port:        port,
145			ContainerID: "", // Not a container
146			Scheme:      "http",
147		}
148
149		// Cache external route target
150		s.mu.Lock()
151		s.targets[domainName] = target
152		s.mu.Unlock()
153
154		log.Debug().
155			Str("host", host).
156			Int("port", port).
157			Msg("using external route target")
158		return target, nil
159	}
160
161	// Get container for this domain
162	container, exists := s.containerSvc.Get(ctx, domainName)
163	if !exists {
164		log.Debug().Msg("container not found for domain")
165		return nil, domain.ErrNoTargetAvailable
166	}
167	log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
168
169	// Build target based on runtime mode
170	var target *domain.ProxyTarget
171
172	if s.isRunningInContainer() {
173		// Gordon is in a container - use container network
174		containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
175		if err != nil {
176			return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
177		}
178		target = &domain.ProxyTarget{
179			Host:        containerIP,
180			Port:        containerPort,
181			ContainerID: container.ID,
182			Scheme:      "http",
183		}
184	} else {
185		// Gordon is on the host - use host port mapping
186		routes := s.configSvc.GetRoutes(ctx)
187		var route *domain.Route
188		for _, r := range routes {
189			if r.Domain == domainName {
190				route = &r
191				break
192			}
193		}
194
195		if route == nil {
196			return nil, domain.ErrRouteNotFound
197		}
198
199		// Determine target port: check for label first, then fall back to first exposed port
200		// Use container.Image (the actual running image) instead of route.Image (config shorthand)
201		targetPort, err := s.getProxyPort(ctx, container.Image)
202		if err != nil {
203			return nil, log.WrapErr(err, "failed to determine proxy port")
204		}
205
206		hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
207		if err != nil {
208			return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
209		}
210
211		target = &domain.ProxyTarget{
212			Host:        "localhost",
213			Port:        hostPort,
214			ContainerID: container.ID,
215			Scheme:      "http",
216		}
217	}
218
219	// Cache the target
220	s.mu.Lock()
221	s.targets[domainName] = target
222	s.mu.Unlock()
223
224	return target, nil
225}
226
227// RegisterTarget registers a new proxy target for a domain.
228func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
229	s.mu.Lock()
230	defer s.mu.Unlock()
231
232	s.targets[domainName] = target
233	return nil
234}
235
236// UnregisterTarget removes a proxy target for a domain.
237func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
238	s.mu.Lock()
239	defer s.mu.Unlock()
240
241	delete(s.targets, domainName)
242	return nil
243}
244
245// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
246// This is used during zero-downtime deployments to switch traffic to a new container.
247func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
248	s.mu.Lock()
249	defer s.mu.Unlock()
250
251	delete(s.targets, domainName)
252}
253
254// RefreshTargets refreshes all proxy targets from container state.
255func (s *Service) RefreshTargets(ctx context.Context) error {
256	s.mu.Lock()
257	s.targets = make(map[string]*domain.ProxyTarget)
258	s.mu.Unlock()
259
260	log := zerowrap.FromCtx(ctx)
261	log.Debug().Msg("proxy targets cache cleared")
262	return nil
263}
264
265// UpdateConfig updates the service configuration.
266func (s *Service) UpdateConfig(config Config) {
267	s.config = config
268}
269
270// Helper methods
271
272func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
273	log := zerowrap.FromCtx(r.Context())
274
275	targetURL, err := url.Parse(fmt.Sprintf("%s://%s:%d", target.Scheme, target.Host, target.Port))
276	if err != nil {
277		log.WrapErr(err, "failed to parse target URL")
278		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
279		return
280	}
281
282	proxy := httputil.NewSingleHostReverseProxy(targetURL)
283	proxy.Transport = proxyTransport
284
285	proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) {
286		log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error")
287		http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
288	}
289
290	proxy.ModifyResponse = func(resp *http.Response) error {
291		resp.Header.Set("X-Proxied-By", "Gordon")
292		resp.Header.Set("X-Container-ID", target.ContainerID)
293		return nil
294	}
295
296	log.Debug().
297		Str("target", targetURL.String()).
298		Str(zerowrap.FieldEntityID, target.ContainerID).
299		Msg("proxying request")
300
301	proxy.ServeHTTP(w, r)
302}
303
304func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
305	log := zerowrap.FromCtx(r.Context())
306
307	targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", s.config.RegistryPort))
308	if err != nil {
309		log.WrapErr(err, "failed to parse registry target URL")
310		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
311		return
312	}
313
314	proxy := httputil.NewSingleHostReverseProxy(targetURL)
315	proxy.Transport = proxyTransport
316
317	proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) {
318		log.Error().Err(err).Int("registry_port", s.config.RegistryPort).Msg("registry proxy error")
319		http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)
320	}
321
322	proxy.ModifyResponse = func(resp *http.Response) error {
323		resp.Header.Set("X-Proxied-By", "Gordon")
324		resp.Header.Set("X-Registry-Backend", "gordon-registry")
325		return nil
326	}
327
328	log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
329
330	proxy.ServeHTTP(w, r)
331}
332
333func (s *Service) isRunningInContainer() bool {
334	// Check for /.dockerenv
335	if _, err := os.Stat("/.dockerenv"); err == nil {
336		return true
337	}
338
339	// Check cgroup for container indicators
340	if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
341		content := string(data)
342		if strings.Contains(content, "docker") ||
343			strings.Contains(content, "containerd") ||
344			strings.Contains(content, "podman") {
345			return true
346		}
347	}
348
349	// Check hostname pattern
350	if hostname, err := os.Hostname(); err == nil {
351		if len(hostname) == 12 || len(hostname) == 64 {
352			return true
353		}
354	}
355
356	// Check environment variables
357	if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
358		os.Getenv("DOCKER_CONTAINER") != "" ||
359		os.Getenv("container") != "" {
360		return true
361	}
362
363	return false
364}
365
366// getProxyPort determines the port to proxy to for an image.
367// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
368func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
369	log := zerowrap.FromCtx(ctx)
370
371	log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
372
373	// Check for explicit port label
374	labels, err := s.runtime.GetImageLabels(ctx, imageRef)
375	if err != nil {
376		log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
377	} else if portStr, ok := labels[domain.LabelProxyPort]; ok {
378		port, err := strconv.Atoi(portStr)
379		if err == nil && port > 0 {
380			log.Debug().Int("port", port).Msg("using proxy port from image label")
381			return port, nil
382		}
383		log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
384	}
385
386	// Fall back to first exposed port
387	exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
388	if err != nil {
389		return 0, err
390	}
391	if len(exposedPorts) == 0 {
392		return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
393	}
394
395	log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
396	return exposedPorts[0], nil
397}

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