v2.31.0-dev8 ec8c5b9

service.go RawHistoryFolder

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

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