v2.31.0-dev19 35467d7

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			RouteHost:   domainName,
138		}
139	} else {
140		// Gordon is on the host - use host port mapping
141		routes := s.configSvc.GetRoutes(ctx)
142		var route *domain.Route
143		for _, r := range routes {
144			if r.Domain == domainName {
145				route = &r
146				break
147			}
148		}
149
150		if route == nil {
151			return nil, domain.ErrRouteNotFound
152		}
153
154		meta, err := s.resolveTargetMetadata(ctx, container.Image)
155		if err != nil {
156			return nil, log.WrapErr(err, "failed to resolve target metadata")
157		}
158
159		hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, meta.Port)
160		if err != nil {
161			return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": meta.Port})
162		}
163
164		target = &domain.ProxyTarget{
165			Host:        "localhost",
166			Port:        hostPort,
167			ContainerID: container.ID,
168			Scheme:      "http",
169			Protocol:    meta.Protocol,
170			RouteHost:   domainName,
171		}
172	}
173
174	// Cache the target
175	s.mu.Lock()
176	s.targets[domainName] = target
177	s.mu.Unlock()
178
179	return target, nil
180}
181
182// resolveExternalRoute resolves an external route target address into a ProxyTarget,
183// performing DNS validation and SSRF protection.
184func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr string, log zerowrap.Logger) (*domain.ProxyTarget, error) {
185	host, portStr, err := net.SplitHostPort(targetAddr)
186	if err != nil {
187		return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
188	}
189	port, err := strconv.Atoi(portStr)
190	if err != nil {
191		return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
192	}
193	if port < 1 || port > 65535 {
194		return nil, fmt.Errorf("invalid port in external route %q: port must be between 1 and 65535", targetAddr)
195	}
196
197	// SECURITY: Resolve DNS and validate that the target is not an internal/blocked
198	// network. We use the resolved IP as the proxy target to prevent DNS rebinding
199	// (TOCTOU) attacks where a hostname resolves to a public IP during validation
200	// but to a private IP when the proxy connects.
201	resolvedIP, err := ResolveAndValidateHost(host)
202	if err != nil {
203		log.Warn().
204			Err(err).
205			Str("host", host).
206			Str("domain", domainName).
207			Msg("SSRF protection: blocked external route to internal network")
208		return nil, err
209	}
210
211	// Preserve the original hostname for the Host header so virtual-hosted
212	// upstreams work correctly. The resolved IP is used for dialing only.
213	var originalHost string
214	if resolvedIP != host {
215		originalHost = host
216	}
217
218	t := &domain.ProxyTarget{
219		Host:         resolvedIP,
220		Port:         port,
221		ContainerID:  "", // Not a container
222		Scheme:       "http",
223		OriginalHost: originalHost,
224	}
225
226	// Cache external route target
227	s.mu.Lock()
228	s.targets[domainName] = t
229	s.mu.Unlock()
230
231	log.Debug().
232		Str("host", host).
233		Int("port", port).
234		Msg("using external route target")
235	return t, 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	canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
241	if !ok {
242		return domain.ErrRouteDomainInvalid
243	}
244
245	s.mu.Lock()
246	defer s.mu.Unlock()
247
248	s.targets[canonicalDomain] = target
249	return nil
250}
251
252// UnregisterTarget removes a proxy target for a domain.
253func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
254	canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
255	if !ok {
256		return domain.ErrRouteDomainInvalid
257	}
258
259	s.mu.Lock()
260	defer s.mu.Unlock()
261
262	delete(s.targets, canonicalDomain)
263	return nil
264}
265
266// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
267// This is used during zero-downtime deployments to switch traffic to a new container.
268func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
269	canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
270	if !ok {
271		return
272	}
273
274	s.mu.Lock()
275	defer s.mu.Unlock()
276
277	delete(s.targets, canonicalDomain)
278}
279
280// WaitForNoInFlight waits until no requests are currently proxied to the
281// given container, or until timeout/context cancellation.
282func (s *Service) WaitForNoInFlight(ctx context.Context, containerID string, timeout time.Duration) bool {
283	if containerID == "" {
284		return true
285	}
286	if timeout <= 0 {
287		timeout = 30 * time.Second
288	}
289
290	deadline := time.Now().Add(timeout)
291	for {
292		s.inFlightMu.Lock()
293		count := s.inFlight[containerID]
294		s.inFlightMu.Unlock()
295		if count <= 0 {
296			return true
297		}
298
299		if time.Now().After(deadline) {
300			return false
301		}
302
303		select {
304		case <-time.After(100 * time.Millisecond):
305		case <-ctx.Done():
306			return false
307		}
308	}
309}
310
311// RefreshTargets refreshes all proxy targets from container state.
312func (s *Service) RefreshTargets(ctx context.Context) error {
313	s.mu.Lock()
314	s.targets = make(map[string]*domain.ProxyTarget)
315	s.mu.Unlock()
316
317	log := zerowrap.FromCtx(ctx)
318	log.Debug().Msg("proxy targets cache cleared")
319	return nil
320}
321
322// UpdateConfig updates the service configuration.
323// Connection limiting uses an atomic counter checked against config, so
324// changing MaxConcurrentConns takes effect immediately without any race.
325func (s *Service) UpdateConfig(config Config) {
326	s.mu.Lock()
327	s.config = config
328	s.mu.Unlock()
329}
330
331// IsRegistryDomain returns true if the host matches the configured registry domain.
332func (s *Service) IsRegistryDomain(host string) bool {
333	canonicalHost, ok := domain.CanonicalRouteDomain(host)
334	if !ok {
335		return false
336	}
337
338	s.mu.RLock()
339	registryDomain, ok := domain.CanonicalRouteDomain(s.config.RegistryDomain)
340	s.mu.RUnlock()
341	return ok && canonicalHost == registryDomain
342}
343
344// IsKnownHost returns true if host is configured as registry, route, or external route.
345func (s *Service) IsKnownHost(ctx context.Context, host string) bool {
346	canonicalHost, ok := domain.CanonicalRouteDomain(host)
347	if !ok {
348		return false
349	}
350	if s.IsRegistryDomain(canonicalHost) {
351		return true
352	}
353	if _, err := s.configSvc.GetRoute(ctx, canonicalHost); err == nil {
354		return true
355	}
356	_, ok = s.configSvc.GetExternalRoutes()[canonicalHost]
357	return ok
358}
359
360// TrackInFlight records an in-flight request for a container.
361// Returns a release function that must be called when the request completes.
362func (s *Service) TrackInFlight(containerID string) func() {
363	if containerID == "" {
364		return func() {}
365	}
366
367	s.inFlightMu.Lock()
368	s.inFlight[containerID]++
369	s.inFlightMu.Unlock()
370
371	return func() {
372		s.inFlightMu.Lock()
373		if s.inFlight[containerID] > 1 {
374			s.inFlight[containerID]--
375		} else {
376			delete(s.inFlight, containerID)
377		}
378		s.inFlightMu.Unlock()
379	}
380}
381
382// TrackRegistryRequest increments the registry in-flight counter.
383func (s *Service) TrackRegistryRequest() {
384	s.registryInFlight.Add(1)
385}
386
387// ReleaseRegistryRequest decrements the registry in-flight counter.
388func (s *Service) ReleaseRegistryRequest() {
389	s.registryInFlight.Add(-1)
390}
391
392// ProxyConfig returns the current proxy configuration for adapter use.
393func (s *Service) ProxyConfig() in.ProxyServiceConfig {
394	s.mu.RLock()
395	defer s.mu.RUnlock()
396	return in.ProxyServiceConfig{
397		RegistryDomain:     s.config.RegistryDomain,
398		RegistryPort:       s.config.RegistryPort,
399		MaxBodySize:        s.config.MaxBodySize,
400		MaxResponseSize:    s.config.MaxResponseSize,
401		MaxConcurrentConns: s.config.MaxConcurrentConns,
402	}
403}
404
405// RegistryInFlight returns the current count of active registry proxy requests.
406func (s *Service) RegistryInFlight() int64 {
407	return s.registryInFlight.Load()
408}
409
410// DrainRegistryInFlight blocks until all in-flight registry proxy requests
411// complete or the timeout elapses. Returns true if drained cleanly, false if
412// timed out with requests still in flight. Call this before shutting down the
413// registry server.
414func (s *Service) DrainRegistryInFlight(timeout time.Duration) bool {
415	deadline := time.Now().Add(timeout)
416	for time.Now().Before(deadline) {
417		if s.registryInFlight.Load() == 0 {
418			return true
419		}
420		time.Sleep(10 * time.Millisecond)
421	}
422	return false
423}
424
425func (s *Service) isRunningInContainer() bool {
426	// Check for /.dockerenv
427	if _, err := os.Stat("/.dockerenv"); err == nil {
428		return true
429	}
430
431	// Check cgroup for container indicators
432	if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
433		content := string(data)
434		if strings.Contains(content, "docker") ||
435			strings.Contains(content, "containerd") ||
436			strings.Contains(content, "podman") {
437			return true
438		}
439	}
440
441	// NOTE: Hostname length check (12 or 64 chars) was removed because it produced
442	// false positives on hosts with short hostnames (e.g., "web-server-1" = 12 chars),
443	// which would cause the proxy to use container network IPs instead of host port mappings.
444
445	// Check environment variables
446	if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
447		os.Getenv("DOCKER_CONTAINER") != "" ||
448		os.Getenv("container") != "" {
449		return true
450	}
451
452	return false
453}

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