1// Package proxy implements the reverse proxy use case.
2package proxy
3
4import (
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "net"
10 "net/http"
11 "net/http/httputil"
12 "net/url"
13 "os"
14 "strconv"
15 "strings"
16 "sync"
17 "sync/atomic"
18 "time"
19
20 "github.com/bnema/zerowrap"
21 "go.opentelemetry.io/otel"
22 "go.opentelemetry.io/otel/attribute"
23 "go.opentelemetry.io/otel/codes"
24 "go.opentelemetry.io/otel/trace"
25
26 "github.com/bnema/gordon/internal/boundaries/in"
27 "github.com/bnema/gordon/internal/boundaries/out"
28 "github.com/bnema/gordon/internal/domain"
29)
30
31var proxyTracer = otel.Tracer("gordon.proxy")
32
33// proxyTransport is a shared HTTP transport for proxying to application containers.
34// ResponseHeaderTimeout is kept short to detect unresponsive backends quickly.
35var proxyTransport = &http.Transport{
36 DialContext: (&net.Dialer{
37 Timeout: 10 * time.Second,
38 KeepAlive: 30 * time.Second,
39 }).DialContext,
40 TLSHandshakeTimeout: 10 * time.Second,
41 ResponseHeaderTimeout: 30 * time.Second,
42 MaxIdleConns: 100,
43 MaxIdleConnsPerHost: 10,
44 IdleConnTimeout: 90 * time.Second,
45}
46
47// registryTransport is used for the registry reverse proxy (localhost loopback).
48// It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy
49// perform blocking operations (image pull, container start, readiness checks) that
50// can take well over 30 seconds.
51var registryTransport = &http.Transport{
52 DialContext: (&net.Dialer{
53 Timeout: 10 * time.Second,
54 KeepAlive: 30 * time.Second,
55 }).DialContext,
56 ResponseHeaderTimeout: 3 * time.Minute,
57 MaxIdleConns: 20,
58 MaxIdleConnsPerHost: 10,
59 IdleConnTimeout: 90 * time.Second,
60}
61
62// Config holds configuration needed by the proxy service.
63type Config struct {
64 RegistryDomain string
65 RegistryPort int
66 MaxBodySize int64 // Maximum request body size in bytes (0 = no limit)
67 MaxResponseSize int64 // Maximum response body size in bytes (0 = no limit)
68 MaxConcurrentConns int // Maximum concurrent proxy connections (0 = no limit)
69}
70
71// Service implements the ProxyService interface.
72type Service struct {
73 runtime out.ContainerRuntime
74 containerSvc in.ContainerService
75 configSvc in.ConfigService
76 config Config
77 targets map[string]*domain.ProxyTarget
78 mu sync.RWMutex
79 activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
80 inFlight map[string]int
81 inFlightMu sync.Mutex
82}
83
84// NewService creates a new proxy service.
85func NewService(
86 runtime out.ContainerRuntime,
87 containerSvc in.ContainerService,
88 configSvc in.ConfigService,
89 config Config,
90) *Service {
91 return &Service{
92 runtime: runtime,
93 containerSvc: containerSvc,
94 configSvc: configSvc,
95 config: config,
96 targets: make(map[string]*domain.ProxyTarget),
97 inFlight: make(map[string]int),
98 }
99}
100
101// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
102func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
103 // Copy config under RLock to avoid data race with UpdateConfig
104 s.mu.RLock()
105 cfg := s.config
106 s.mu.RUnlock()
107
108 // SECURITY: Limit concurrent connections to prevent resource exhaustion.
109 // Uses an atomic counter so config reloads don't create a race between
110 // an old and new semaphore — the counter is shared across reloads.
111 if cfg.MaxConcurrentConns > 0 {
112 current := s.activeConns.Add(1)
113 if current > int64(cfg.MaxConcurrentConns) {
114 s.activeConns.Add(-1)
115 proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
116 return
117 }
118 defer s.activeConns.Add(-1)
119 }
120
121 // SECURITY: Limit request body size to prevent resource exhaustion.
122 if cfg.MaxBodySize > 0 {
123 r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
124 }
125
126 // Enrich request context with fields for downstream logging
127 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
128 zerowrap.FieldLayer: "adapter",
129 zerowrap.FieldAdapter: "http",
130 zerowrap.FieldMethod: r.Method,
131 zerowrap.FieldPath: r.URL.Path,
132 zerowrap.FieldHost: r.Host,
133 zerowrap.FieldClientIP: r.RemoteAddr,
134 })
135 r = r.WithContext(ctx)
136 log := zerowrap.FromCtx(ctx)
137
138 // Check if this is the registry domain
139 if cfg.RegistryDomain != "" && r.Host == cfg.RegistryDomain {
140 log.Info().Msg("routing request to registry")
141 s.proxyToRegistry(w, r)
142 return
143 }
144
145 // Get target for this domain
146 log.Debug().Str("resolving_target_for", r.Host).Msg("looking up proxy target")
147 target, err := s.GetTarget(ctx, r.Host)
148 if err != nil {
149 log.Warn().Err(err).Msg("no route found for domain")
150 proxyError(w, "404 page not found", http.StatusNotFound)
151 return
152 }
153
154 log.Debug().
155 Str("host", target.Host).
156 Int("port", target.Port).
157 Str("container_id", target.ContainerID).
158 Msg("resolved proxy target")
159
160 s.proxyToTarget(w, r, target)
161}
162
163// GetTarget returns the proxy target for a given domain.
164func (s *Service) GetTarget(ctx context.Context, domainName string) (target *domain.ProxyTarget, retErr error) {
165 ctx, span := proxyTracer.Start(ctx, "proxy.get_target",
166 trace.WithAttributes(attribute.String("domain", domainName)))
167 defer func() {
168 if retErr != nil {
169 span.RecordError(retErr)
170 span.SetStatus(codes.Error, retErr.Error())
171 }
172 span.End()
173 }()
174
175 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
176 zerowrap.FieldLayer: "usecase",
177 zerowrap.FieldUseCase: "GetTarget",
178 "domain": domainName,
179 })
180 log := zerowrap.FromCtx(ctx)
181
182 // Check cache first
183 s.mu.RLock()
184 if target, exists := s.targets[domainName]; exists {
185 s.mu.RUnlock()
186 log.Debug().
187 Str("host", target.Host).
188 Int("port", target.Port).
189 Str("container_id", target.ContainerID).
190 Msg("using cached proxy target")
191 return target, nil
192 }
193 s.mu.RUnlock()
194
195 // Check if this is an external route
196 externalRoutes := s.configSvc.GetExternalRoutes()
197 if targetAddr, ok := externalRoutes[domainName]; ok {
198 return s.resolveExternalRoute(ctx, domainName, targetAddr, log)
199 }
200
201 // Get container for this domain
202 container, exists := s.containerSvc.Get(ctx, domainName)
203 if !exists {
204 log.Debug().Msg("container not found for domain")
205 return nil, domain.ErrNoTargetAvailable
206 }
207 log.Debug().Str("container_id", container.ID).Str("image", container.Image).Msg("found container for domain")
208
209 // Build target based on runtime mode
210
211 if s.isRunningInContainer() {
212 // Gordon is in a container - use container network
213 containerIP, containerPort, err := s.runtime.GetContainerNetworkInfo(ctx, container.ID)
214 if err != nil {
215 return nil, log.WrapErrWithFields(err, "failed to get container network info", map[string]any{zerowrap.FieldEntityID: container.ID})
216 }
217 target = &domain.ProxyTarget{
218 Host: containerIP,
219 Port: containerPort,
220 ContainerID: container.ID,
221 Scheme: "http",
222 }
223 } else {
224 // Gordon is on the host - use host port mapping
225 routes := s.configSvc.GetRoutes(ctx)
226 var route *domain.Route
227 for _, r := range routes {
228 if r.Domain == domainName {
229 route = &r
230 break
231 }
232 }
233
234 if route == nil {
235 return nil, domain.ErrRouteNotFound
236 }
237
238 // Determine target port: check for label first, then fall back to first exposed port
239 // Use container.Image (the actual running image) instead of route.Image (config shorthand)
240 targetPort, err := s.getProxyPort(ctx, container.Image)
241 if err != nil {
242 return nil, log.WrapErr(err, "failed to determine proxy port")
243 }
244
245 hostPort, err := s.runtime.GetContainerPort(ctx, container.ID, targetPort)
246 if err != nil {
247 return nil, log.WrapErrWithFields(err, "failed to get host port mapping", map[string]any{"internal_port": targetPort})
248 }
249
250 target = &domain.ProxyTarget{
251 Host: "localhost",
252 Port: hostPort,
253 ContainerID: container.ID,
254 Scheme: "http",
255 }
256 }
257
258 // Cache the target
259 s.mu.Lock()
260 s.targets[domainName] = target
261 s.mu.Unlock()
262
263 return target, nil
264}
265
266// resolveExternalRoute resolves an external route target address into a ProxyTarget,
267// performing DNS validation and SSRF protection.
268func (s *Service) resolveExternalRoute(_ context.Context, domainName, targetAddr string, log zerowrap.Logger) (*domain.ProxyTarget, error) {
269 host, portStr, err := net.SplitHostPort(targetAddr)
270 if err != nil {
271 return nil, log.WrapErrWithFields(err, "invalid external route target", map[string]any{"target": targetAddr})
272 }
273 port, err := strconv.Atoi(portStr)
274 if err != nil {
275 return nil, log.WrapErrWithFields(err, "invalid port in external route", map[string]any{"target": targetAddr})
276 }
277
278 // SECURITY: Resolve DNS and validate that the target is not an internal/blocked
279 // network. We use the resolved IP as the proxy target to prevent DNS rebinding
280 // (TOCTOU) attacks where a hostname resolves to a public IP during validation
281 // but to a private IP when the proxy connects.
282 resolvedIP, err := ResolveAndValidateHost(host)
283 if err != nil {
284 log.Warn().
285 Err(err).
286 Str("host", host).
287 Str("domain", domainName).
288 Msg("SSRF protection: blocked external route to internal network")
289 return nil, err
290 }
291
292 // Preserve the original hostname for the Host header so virtual-hosted
293 // upstreams work correctly. The resolved IP is used for dialing only.
294 var originalHost string
295 if resolvedIP != host {
296 originalHost = host
297 }
298
299 t := &domain.ProxyTarget{
300 Host: resolvedIP,
301 Port: port,
302 ContainerID: "", // Not a container
303 Scheme: "http",
304 OriginalHost: originalHost,
305 }
306
307 // Cache external route target
308 s.mu.Lock()
309 s.targets[domainName] = t
310 s.mu.Unlock()
311
312 log.Debug().
313 Str("host", host).
314 Int("port", port).
315 Msg("using external route target")
316 return t, nil
317}
318
319// RegisterTarget registers a new proxy target for a domain.
320func (s *Service) RegisterTarget(_ context.Context, domainName string, target *domain.ProxyTarget) error {
321 s.mu.Lock()
322 defer s.mu.Unlock()
323
324 s.targets[domainName] = target
325 return nil
326}
327
328// UnregisterTarget removes a proxy target for a domain.
329func (s *Service) UnregisterTarget(_ context.Context, domainName string) error {
330 s.mu.Lock()
331 defer s.mu.Unlock()
332
333 delete(s.targets, domainName)
334 return nil
335}
336
337// InvalidateTarget removes a cached proxy target, forcing re-lookup on next request.
338// This is used during zero-downtime deployments to switch traffic to a new container.
339func (s *Service) InvalidateTarget(_ context.Context, domainName string) {
340 s.mu.Lock()
341 defer s.mu.Unlock()
342
343 delete(s.targets, domainName)
344}
345
346// WaitForNoInFlight waits until no requests are currently proxied to the
347// given container, or until timeout/context cancellation.
348func (s *Service) WaitForNoInFlight(ctx context.Context, containerID string, timeout time.Duration) bool {
349 if containerID == "" {
350 return true
351 }
352 if timeout <= 0 {
353 timeout = 30 * time.Second
354 }
355
356 deadline := time.Now().Add(timeout)
357 for {
358 s.inFlightMu.Lock()
359 count := s.inFlight[containerID]
360 s.inFlightMu.Unlock()
361 if count <= 0 {
362 return true
363 }
364
365 if time.Now().After(deadline) {
366 return false
367 }
368
369 select {
370 case <-time.After(100 * time.Millisecond):
371 case <-ctx.Done():
372 return false
373 }
374 }
375}
376
377// RefreshTargets refreshes all proxy targets from container state.
378func (s *Service) RefreshTargets(ctx context.Context) error {
379 s.mu.Lock()
380 s.targets = make(map[string]*domain.ProxyTarget)
381 s.mu.Unlock()
382
383 log := zerowrap.FromCtx(ctx)
384 log.Debug().Msg("proxy targets cache cleared")
385 return nil
386}
387
388// UpdateConfig updates the service configuration.
389// Connection limiting uses an atomic counter checked against config, so
390// changing MaxConcurrentConns takes effect immediately without any race.
391func (s *Service) UpdateConfig(config Config) {
392 s.mu.Lock()
393 s.config = config
394 s.mu.Unlock()
395}
396
397// modifyResponse returns a function that modifies backend responses.
398// It adds proxy headers and enforces the response size limit if configured.
399func (s *Service) modifyResponse(cfg Config) func(*http.Response) error {
400 return func(resp *http.Response) error {
401 resp.Header.Set("X-Proxied-By", "Gordon")
402
403 // SECURITY: Enforce response size limit to prevent memory exhaustion
404 // from malicious or buggy backends streaming unbounded data.
405 if cfg.MaxResponseSize > 0 {
406 if resp.ContentLength > cfg.MaxResponseSize {
407 resp.Body.Close()
408 const msg = "Response Too Large"
409 resp.StatusCode = http.StatusBadGateway
410 resp.Body = io.NopCloser(strings.NewReader(msg))
411 resp.ContentLength = int64(len(msg))
412 resp.Header.Set("Content-Type", "text/plain")
413 resp.Header.Set("Cache-Control", "no-store")
414 resp.Header.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
415 // Ensure framing/encoding headers match the substituted body
416 resp.Header.Del("Transfer-Encoding")
417 resp.Header.Del("Content-Encoding")
418 resp.Header.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
419 return nil
420 }
421 // For chunked/streaming responses where Content-Length is unknown,
422 // wrap with LimitReader to enforce the cap.
423 if resp.ContentLength < 0 {
424 resp.Body = &limitedReadCloser{
425 ReadCloser: resp.Body,
426 remaining: cfg.MaxResponseSize,
427 }
428 }
429 }
430
431 return nil
432 }
433}
434
435// limitedReadCloser wraps an io.ReadCloser with a byte limit.
436// When the limit is exceeded, subsequent reads return an error and the
437// underlying body is closed exactly once to release upstream resources.
438type limitedReadCloser struct {
439 io.ReadCloser
440 remaining int64
441 closed bool
442}
443
444func (l *limitedReadCloser) Read(p []byte) (int, error) {
445 if l.remaining <= 0 {
446 // Close the underlying body to release upstream connection/resources
447 if !l.closed {
448 l.closed = true
449 l.ReadCloser.Close()
450 }
451 return 0, fmt.Errorf("response body exceeded maximum size limit")
452 }
453 if int64(len(p)) > l.remaining {
454 p = p[:l.remaining]
455 }
456 n, err := l.ReadCloser.Read(p)
457 l.remaining -= int64(n)
458 return n, err
459}
460
461func (l *limitedReadCloser) Close() error {
462 if l.closed {
463 return nil
464 }
465 l.closed = true
466 return l.ReadCloser.Close()
467}
468
469// proxyError writes an error response with security headers appropriate for
470// proxy-generated error pages. CSP is set here (not in global middleware)
471// because Gordon proxies arbitrary web apps and a blanket CSP would break them.
472func proxyError(w http.ResponseWriter, msg string, code int) {
473 w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
474 w.Header().Set("Cache-Control", "no-store")
475 http.Error(w, msg, code)
476}
477
478// Helper methods
479
480// newReverseProxy creates a reverse proxy using Rewrite instead of Director to prevent
481// hop-by-hop header attacks. A malicious client could send "Connection: Authorization"
482// to strip the Authorization header when using the default Director. Rewrite processes
483// headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
484//
485// originalHost, when non-empty, overrides the Host header sent to the backend.
486// This is used for DNS-pinned targets where the proxy dials an IP but needs to
487// send the original hostname for virtual-hosted upstreams.
488func newReverseProxy(targetURL *url.URL, originalHost string, errorHandler func(http.ResponseWriter, *http.Request, error), modifyResponse func(*http.Response) error) *httputil.ReverseProxy {
489 return &httputil.ReverseProxy{
490 Rewrite: func(pr *httputil.ProxyRequest) {
491 pr.SetURL(targetURL)
492 // SECURITY: Let SetXForwarded() set X-Forwarded-Proto based on the actual
493 // connection state (r.TLS). We do NOT preserve the original X-Forwarded-Proto
494 // from the incoming request because we cannot verify it came from a trusted
495 // proxy. An attacker could spoof X-Forwarded-Proto: https on an HTTP connection
496 // to trick backends into thinking the connection is secure.
497 pr.SetXForwarded()
498 if originalHost != "" {
499 // DNS pinning: dial the resolved IP but send the original hostname
500 // so virtual-hosted upstreams route the request correctly.
501 pr.Out.Host = net.JoinHostPort(originalHost, targetURL.Port())
502 } else {
503 pr.Out.Host = targetURL.Host
504 }
505 },
506 Transport: proxyTransport,
507 ErrorHandler: errorHandler,
508 ModifyResponse: modifyResponse,
509 }
510}
511
512func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *domain.ProxyTarget) {
513 s.mu.RLock()
514 cfg := s.config
515 s.mu.RUnlock()
516
517 log := zerowrap.FromCtx(r.Context())
518
519 // Use net.JoinHostPort for IPv6-safe URL construction (brackets are added automatically)
520 targetURL, err := url.Parse(fmt.Sprintf("%s://%s", target.Scheme, net.JoinHostPort(target.Host, strconv.Itoa(target.Port))))
521 if err != nil {
522 log.WrapErr(err, "failed to parse target URL")
523 proxyError(w, "Internal Server Error", http.StatusInternalServerError)
524 return
525 }
526
527 log.Debug().Str("target_url", targetURL.String()).Msg("proxying to target")
528
529 // If DNS pinning resolved hostname to IP, preserve the original hostname
530 // for the Host header so virtual-hosted upstreams work correctly.
531 originalHost := target.OriginalHost
532 releaseInFlight := s.trackInFlight(target.ContainerID)
533 defer releaseInFlight()
534
535 proxy := newReverseProxy(targetURL, originalHost,
536 func(w http.ResponseWriter, _ *http.Request, err error) {
537 var maxBytesErr *http.MaxBytesError
538 if errors.As(err, &maxBytesErr) {
539 log.Warn().Err(err).Str("target", targetURL.String()).Msg("proxy error: request body too large")
540 proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
541 return
542 }
543 log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
544 proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
545 },
546 s.modifyResponse(cfg),
547 )
548
549 log.Debug().
550 Str("target", targetURL.String()).
551 Str(zerowrap.FieldEntityID, target.ContainerID).
552 Msg("proxying request")
553
554 proxy.ServeHTTP(w, r)
555}
556
557func (s *Service) trackInFlight(containerID string) func() {
558 if containerID == "" {
559 return func() {}
560 }
561
562 s.inFlightMu.Lock()
563 s.inFlight[containerID]++
564 s.inFlightMu.Unlock()
565
566 return func() {
567 s.inFlightMu.Lock()
568 if s.inFlight[containerID] > 1 {
569 s.inFlight[containerID]--
570 } else {
571 delete(s.inFlight, containerID)
572 }
573 s.inFlightMu.Unlock()
574 }
575}
576
577// proxyToRegistry forwards requests to the local registry HTTP server.
578// SECURITY: This uses http://localhost:{port} which is safe because the registry
579// runs on the same host and traffic never leaves the loopback interface. If Gordon
580// ever supports running the registry on a separate host, this must use TLS with
581// certificate verification.
582func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
583 // Copy config under RLock to avoid data race with UpdateConfig
584 s.mu.RLock()
585 cfg := s.config
586 s.mu.RUnlock()
587
588 log := zerowrap.FromCtx(r.Context())
589
590 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
591 if err != nil {
592 log.WrapErr(err, "failed to parse registry target URL")
593 proxyError(w, "Internal Server Error", http.StatusInternalServerError)
594 return
595 }
596
597 proxy := &httputil.ReverseProxy{
598 Rewrite: func(pr *httputil.ProxyRequest) {
599 pr.SetURL(targetURL)
600 pr.SetXForwarded()
601 pr.Out.Host = targetURL.Host
602 },
603 Transport: registryTransport,
604 ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
605 var maxBytesErr *http.MaxBytesError
606 if errors.As(err, &maxBytesErr) {
607 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
608 proxyError(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
609 return
610 }
611 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
612 proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
613 },
614 ModifyResponse: func(resp *http.Response) error {
615 resp.Header.Set("X-Proxied-By", "Gordon")
616 resp.Header.Set("X-Registry-Backend", "gordon-registry")
617
618 // Remove security headers from registry response to prevent duplicates.
619 resp.Header.Del("X-Content-Type-Options")
620 resp.Header.Del("X-Frame-Options")
621 resp.Header.Del("X-XSS-Protection")
622 resp.Header.Del("Referrer-Policy")
623 resp.Header.Del("Permissions-Policy")
624 resp.Header.Del("Content-Security-Policy")
625 resp.Header.Del("Strict-Transport-Security")
626
627 return nil
628 },
629 }
630
631 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
632
633 proxy.ServeHTTP(w, r)
634}
635
636func (s *Service) isRunningInContainer() bool {
637 // Check for /.dockerenv
638 if _, err := os.Stat("/.dockerenv"); err == nil {
639 return true
640 }
641
642 // Check cgroup for container indicators
643 if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
644 content := string(data)
645 if strings.Contains(content, "docker") ||
646 strings.Contains(content, "containerd") ||
647 strings.Contains(content, "podman") {
648 return true
649 }
650 }
651
652 // NOTE: Hostname length check (12 or 64 chars) was removed because it produced
653 // false positives on hosts with short hostnames (e.g., "web-server-1" = 12 chars),
654 // which would cause the proxy to use container network IPs instead of host port mappings.
655
656 // Check environment variables
657 if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
658 os.Getenv("DOCKER_CONTAINER") != "" ||
659 os.Getenv("container") != "" {
660 return true
661 }
662
663 return false
664}
665
666// getProxyPort determines the port to proxy to for an image.
667// It checks for the gordon.proxy.port label first, then falls back to the first exposed port.
668func (s *Service) getProxyPort(ctx context.Context, imageRef string) (int, error) {
669 log := zerowrap.FromCtx(ctx)
670
671 log.Debug().Str("image_ref", imageRef).Msg("determining proxy port for image")
672
673 // Check for explicit port label
674 labels, err := s.runtime.GetImageLabels(ctx, imageRef)
675 if err != nil {
676 log.Debug().Err(err).Msg("failed to get image labels, falling back to exposed ports")
677 } else if portStr, ok := labels[domain.LabelProxyPort]; ok {
678 port, err := strconv.Atoi(portStr)
679 if err == nil && port > 0 {
680 log.Debug().Int("port", port).Msg("using proxy port from image label")
681 return port, nil
682 }
683 log.Warn().Str("port_value", portStr).Msg("invalid gordon.proxy.port label value")
684 }
685
686 // Fall back to first exposed port
687 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
688 if err != nil {
689 return 0, err
690 }
691 if len(exposedPorts) == 0 {
692 return 0, fmt.Errorf("no exposed ports found for image %s", imageRef)
693 }
694
695 log.Debug().Int("port", exposedPorts[0]).Msg("using first exposed port")
696 return exposedPorts[0], nil
697}