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