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}