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}