v2.31.0-dev9 062dcae

service.go RawHistoryFolder

1// Package proxy implements the reverse proxy use case.
2package proxy
3
4import (
5	"context"
6	"fmt"
7	"net"
8	"os"
9	"strconv"
10	"strings"
11	"sync"
12	"sync/atomic"
13	"time"
14
15	"github.com/bnema/zerowrap"
16	"go.opentelemetry.io/otel"
17	"go.opentelemetry.io/otel/attribute"
18	"go.opentelemetry.io/otel/codes"
19	"go.opentelemetry.io/otel/trace"
20
21	"github.com/bnema/gordon/internal/boundaries/in"
22	"github.com/bnema/gordon/internal/boundaries/out"
23	"github.com/bnema/gordon/internal/domain"
24)
25
26var proxyTracer = otel.Tracer("gordon.proxy")
27
28// Config holds configuration needed by the proxy service.
29type Config struct {
30	RegistryDomain     string
31	RegistryPort       int
32	MaxBodySize        int64 // Maximum request body size in bytes (0 = no limit)
33	MaxResponseSize    int64 // Maximum response body size in bytes (0 = no limit)
34	MaxConcurrentConns int   // Maximum concurrent proxy connections (0 = no limit)
35}
36
37// Service implements the ProxyService interface.
38type Service struct {
39	runtime          out.ContainerRuntime
40	containerSvc     in.ContainerService
41	configSvc        in.ConfigService
42	config           Config
43	targets          map[string]*domain.ProxyTarget
44	mu               sync.RWMutex
45	inFlight         map[string]int
46	inFlightMu       sync.Mutex
47	registryInFlight atomic.Int64 // active registry proxy requests, for graceful drain
48}
49
50// NewService creates a new proxy service.
51func NewService(
52	runtime out.ContainerRuntime,
53	containerSvc in.ContainerService,
54	configSvc in.ConfigService,
55	config Config,
56) *Service {
57	return &Service{
58		runtime:      runtime,
59		containerSvc: containerSvc,
60		configSvc:    configSvc,
61		config:       config,
62		targets:      make(map[string]*domain.ProxyTarget),
63		inFlight:     make(map[string]int),
64	}
65}
66
67// GetTarget returns the proxy target for a given domain.
68func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {
69	canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
70	if !ok {
71		return nil, domain.ErrNoTargetAvailable
72	}
73	domainName = canonicalDomain
74
75	ctx, span := proxyTracer.Start(ctx, "proxy.get_target",
76		trace.WithAttributes(attribute.String("domain", domainName)))
77	defer func() {
78		if retErr != nil {
79			span.RecordError(retErr)
80			span.SetStatus(codes.Error, retErr.Error())
81		}
82		span.End()
83	}()
84
85	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
86		zerowrap.FieldLayer:   "usecase",
87		zerowrap.FieldUseCase: "GetTarget",
88		"domain":              domainName,
89	})
90	log := zerowrap.FromCtx(ctx)
91
92	// Check cache first
93	s.mu.RLock()
94	if target, exists := s.targets[domainName]; exists {
95		s.mu.RUnlock()
96		log.Debug().
97			Str("host", target.Host).
98			Int("port", target.Port).
99			Str("container_id", target.ContainerID).
100			Msg("using cached proxy target")
101		return target, nil
102	}
103	s.mu.RUnlock()
104
105	// Check if this is an external route
106	externalRoutes := s.configSvc.GetExternalRoutes()
107	if targetAddr, ok := externalRoutes[domainName]; ok {
108		return s.resolveExternalRoute(ctx, domainName, targetAddr, log)
109	}
110
111	// Get container for this domain
112	container, exists := s.containerSvc.Get(ctx, domainName)
113	if !exists {
114		log.Debug().Msg("container not found for domain")
115		return nil, domain.ErrNoTargetAvailable
116	}
117	log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
118
119	// Build target based on runtime mode
120
121	if s.isRunningInContainer() {
122		// Gordon is in a container - use container network
123		meta, err := s.resolveTargetMetadata(ctx, container.Image)
124		if err != nil {
125			return nil, log.WrapErr(err, "failed to resolve target metadata")
126		}
127		containerIP, _, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
128		if err != nil {
129			return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
130		}
131		target = &domain.ProxyTarget{
132			Host:        containerIP,
133			Port:        meta.Port,
134			ContainerID: container.ID,
135			Scheme:      "http",
136			Protocol:    meta.Protocol,
137		}
138	} else {
139		// Gordon is on the host - use host port mapping
140		routes := s.configSvc.GetRoutes(ctx)
141		var route *domain.Route
142		for _, r := range routes {
143			if r.Domain == domainName {
144				route = &r
145				break
146			}
147		}
148
149		if route == nil {
150			return nil, domain.ErrRouteNotFound
151		}
152
153		meta, err := s.resolveTargetMetadata(ctx, container.Image)
154		if err != nil {
155			return nil, log.WrapErr(err, "failed to resolve target metadata")
156		}
157
158		hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, meta.Port)
159		if err != nil {
160			return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": meta.Port})
161		}
162
163		target = &domain.ProxyTarget{
164			Host:        "localhost",
165			Port:        hostPort,
166			ContainerID: container.ID,
167			Scheme:      "http",
168			Protocol:    meta.Protocol,
169		}
170	}
171
172	// Cache the target
173	s.mu.Lock()
174	s.targets[domainName] = target
175	s.mu.Unlock()
176
177	return target, nil
178}
179
180// resolveExternalRoute resolves an external route target address into a ProxyTarget,
181// performing DNS validation and SSRF protection.
182func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr string, log zerowrap.Logger) (*domain.ProxyTarget, error) {
183	host, portStr, err := net.SplitHostPort(targetAddr)
184	if err != nil {
185		return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
186	}
187	port, err := strconv.Atoi(portStr)
188	if err != nil {
189		return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
190	}
191	if port < 1 || port > 65535 {
192		return nil, fmt.Errorf("invalid port in external route %q: port must be between 1 and 65535", targetAddr)
193	}
194
195	// SECURITY: Resolve DNS and validate that the target is not an internal/blocked
196	// network. We use the resolved IP as the proxy target to prevent DNS rebinding
197	// (TOCTOU) attacks where a hostname resolves to a public IP during validation
198	// but to a private IP when the proxy connects.
199	resolvedIP, err := ResolveAndValidateHost(host)
200	if err != nil {
201		log.Warn().
202			Err(err).
203			Str("host", host).
204			Str("domain", domainName).
205			Msg("SSRF protection: blocked external route to internal network")
206		return nil, err
207	}
208
209	// Preserve the original hostname for the Host header so virtual-hosted
210	// upstreams work correctly. The resolved IP is used for dialing only.
211	var originalHost string
212	if resolvedIP != host {
213		originalHost = host
214	}
215
216	t := &domain.ProxyTarget{
217		Host:         resolvedIP,
218		Port:         port,
219		ContainerID:  "", // Not a container
220		Scheme:       "http",
221		OriginalHost: originalHost,
222	}
223
224	// Cache external route target
225	s.mu.Lock()
226	s.targets[domainName] = t
227	s.mu.Unlock()
228
229	log.Debug().
230		Str("host", host).
231		Int("port", port).
232		Msg("using external route target")
233	return t, nil
234}
235
236// RegisterTarget registers a new proxy target for a domain.
237func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
238	canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
239	if !ok {
240		return domain.ErrRouteDomainInvalid
241	}
242
243	s.mu.Lock()
244	defer s.mu.Unlock()
245
246	s.targets[canonicalDomain] = target
247	return nil
248}
249
250// UnregisterTarget removes a proxy target for a domain.
251func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
252	canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
253	if !ok {
254		return domain.ErrRouteDomainInvalid
255	}
256
257	s.mu.Lock()
258	defer s.mu.Unlock()
259
260	delete(s.targets, canonicalDomain)
261	return nil
262}
263
264// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
265// This is used during zero-downtime deployments to switch traffic to a new container.
266func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
267	canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
268	if !ok {
269		return
270	}
271
272	s.mu.Lock()
273	defer s.mu.Unlock()
274
275	delete(s.targets, canonicalDomain)
276}
277
278// WaitForNoInFlight waits until no requests are currently proxied to the
279// given container, or until timeout/context cancellation.
280func (s *Service) WaitForNoInFlight(ctx context.Context, containerID string, timeout time.Duration) bool {
281	if containerID == "" {
282		return true
283	}
284	if timeout <= 0 {
285		timeout = 30 * time.Second
286	}
287
288	deadline := time.Now().Add(timeout)
289	for {
290		s.inFlightMu.Lock()
291		count := s.inFlight[containerID]
292		s.inFlightMu.Unlock()
293		if count <= 0 {
294			return true
295		}
296
297		if time.Now().After(deadline) {
298			return false
299		}
300
301		select {
302		case <-time.After(100 * time.Millisecond):
303		case <-ctx.Done():
304			return false
305		}
306	}
307}
308
309// RefreshTargets refreshes all proxy targets from container state.
310func (s *Service) RefreshTargets(ctx context.Context) error {
311	s.mu.Lock()
312	s.targets = make(map[string]*domain.ProxyTarget)
313	s.mu.Unlock()
314
315	log := zerowrap.FromCtx(ctx)
316	log.Debug().Msg("proxy targets cache cleared")
317	return nil
318}
319
320// UpdateConfig updates the service configuration.
321// Connection limiting uses an atomic counter checked against config, so
322// changing MaxConcurrentConns takes effect immediately without any race.
323func (s *Service) UpdateConfig(config Config) {
324	s.mu.Lock()
325	s.config = config
326	s.mu.Unlock()
327}
328
329// IsRegistryDomain returns true if the host matches the configured registry domain.
330func (s *Service) IsRegistryDomain(host string) bool {
331	canonicalHost, ok := domain.CanonicalRouteDomain(host)
332	if !ok {
333		return false
334	}
335
336	s.mu.RLock()
337	registryDomain, ok := domain.CanonicalRouteDomain(s.config.RegistryDomain)
338	s.mu.RUnlock()
339	return ok && canonicalHost == registryDomain
340}
341
342// IsKnownHost returns true if host is configured as registry, route, or external route.
343func (s *Service) IsKnownHost(ctx context.Context, host string) bool {
344	canonicalHost, ok := domain.CanonicalRouteDomain(host)
345	if !ok {
346		return false
347	}
348	if s.IsRegistryDomain(canonicalHost) {
349		return true
350	}
351	if _, err := s.configSvc.GetRoute(ctx, canonicalHost); err == nil {
352		return true
353	}
354	_, ok = s.configSvc.GetExternalRoutes()[canonicalHost]
355	return ok
356}
357
358// TrackInFlight records an in-flight request for a container.
359// Returns a release function that must be called when the request completes.
360func (s *Service) TrackInFlight(containerID string) func() {
361	if containerID == "" {
362		return func() {}
363	}
364
365	s.inFlightMu.Lock()
366	s.inFlight[containerID]++
367	s.inFlightMu.Unlock()
368
369	return func() {
370		s.inFlightMu.Lock()
371		if s.inFlight[containerID] > 1 {
372			s.inFlight[containerID]--
373		} else {
374			delete(s.inFlight, containerID)
375		}
376		s.inFlightMu.Unlock()
377	}
378}
379
380// TrackRegistryRequest increments the registry in-flight counter.
381func (s *Service) TrackRegistryRequest() {
382	s.registryInFlight.Add(1)
383}
384
385// ReleaseRegistryRequest decrements the registry in-flight counter.
386func (s *Service) ReleaseRegistryRequest() {
387	s.registryInFlight.Add(-1)
388}
389
390// ProxyConfig returns the current proxy configuration for adapter use.
391func (s *Service) ProxyConfig() in.ProxyServiceConfig {
392	s.mu.RLock()
393	defer s.mu.RUnlock()
394	return in.ProxyServiceConfig{
395		RegistryDomain:     s.config.RegistryDomain,
396		RegistryPort:       s.config.RegistryPort,
397		MaxBodySize:        s.config.MaxBodySize,
398		MaxResponseSize:    s.config.MaxResponseSize,
399		MaxConcurrentConns: s.config.MaxConcurrentConns,
400	}
401}
402
403// RegistryInFlight returns the current count of active registry proxy requests.
404func (s *Service) RegistryInFlight() int64 {
405	return s.registryInFlight.Load()
406}
407
408// DrainRegistryInFlight blocks until all in-flight registry proxy requests
409// complete or the timeout elapses. Returns true if drained cleanly, false if
410// timed out with requests still in flight. Call this before shutting down the
411// registry server.
412func (s *Service) DrainRegistryInFlight(timeout time.Duration) bool {
413	deadline := time.Now().Add(timeout)
414	for time.Now().Before(deadline) {
415		if s.registryInFlight.Load() == 0 {
416			return true
417		}
418		time.Sleep(10 * time.Millisecond)
419	}
420	return false
421}
422
423func (s *Service) isRunningInContainer() bool {
424	// Check for /.dockerenv
425	if _, err := os.Stat("/.dockerenv"); err == nil {
426		return true
427	}
428
429	// Check cgroup for container indicators
430	if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
431		content := string(data)
432		if strings.Contains(content, "docker") ||
433			strings.Contains(content, "containerd") ||
434			strings.Contains(content, "podman") {
435			return true
436		}
437	}
438
439	// NOTE: Hostname length check (12 or 64 chars) was removed because it produced
440	// false positives on hosts with short hostnames (e.g., "web-server-1" = 12 chars),
441	// which would cause the proxy to use container network IPs instead of host port mappings.
442
443	// Check environment variables
444	if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
445		os.Getenv("DOCKER_CONTAINER") != "" ||
446		os.Getenv("container") != "" {
447		return true
448	}
449
450	return false
451}

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