1// Package proxy implements the HTTP adapter for the reverse proxy.
2package proxy
3
4import (
5 "net"
6 "net/http"
7 "strconv"
8 "strings"
9 "sync/atomic"
10
11 "github.com/bnema/zerowrap"
12
13 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
14 "github.com/bnema/gordon/internal/boundaries/in"
15)
16
17// Handler implements http.Handler for the reverse proxy.
18// It owns all HTTP-level concerns: concurrency limiting, body size enforcement,
19// transport selection, and reverse proxy execution.
20// Routing decisions are delegated to the ProxyService usecase.
21type Handler struct {
22 proxySvc in.ProxyService
23 log zerowrap.Logger
24 trustedNets []*net.IPNet
25 appTransport http.RoundTripper
26 h2cTransport http.RoundTripper
27 registryTransport http.RoundTripper
28 // registryForwarding permits registry-domain requests to reach the
29 // internal registry through this public proxy. It is enabled only
30 // when registry authentication is on: with auth disabled the
31 // registry is a local-only service reached by direct loopback
32 // connection, never through public ingress.
33 registryForwarding bool
34 activeConns atomic.Int64
35}
36
37// NewHandler creates a new proxy HTTP handler. Registry forwarding is
38// denied until explicitly enabled with WithRegistryForwarding.
39func NewHandler(proxySvc in.ProxyService, trustedNets []*net.IPNet, log zerowrap.Logger) *Handler {
40 return &Handler{
41 proxySvc: proxySvc,
42 log: log,
43 trustedNets: trustedNets,
44 appTransport: newAppTransport(),
45 h2cTransport: newH2CTransport(),
46 registryTransport: newRegistryTransport(),
47 }
48}
49
50// WithRegistryForwarding enables forwarding public registry-domain requests
51// to the internal registry. Callers pass the installation auth state: a
52// disabled-auth registry must stay unreachable through public ingress.
53func (h *Handler) WithRegistryForwarding(enabled bool) *Handler {
54 h.registryForwarding = enabled
55 return h
56}
57
58// ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
59func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
60 cfg := h.proxySvc.ProxyConfig()
61
62 // SECURITY: Limit concurrent connections to prevent resource exhaustion.
63 if cfg.MaxConcurrentConns > 0 {
64 current := h.activeConns.Add(1)
65 if current > int64(cfg.MaxConcurrentConns) {
66 h.activeConns.Add(-1)
67 proxyError(w, "Service Unavailable", http.StatusServiceUnavailable)
68 return
69 }
70 defer h.activeConns.Add(-1)
71 }
72
73 // SECURITY: Limit request body size to prevent resource exhaustion.
74 if cfg.MaxBodySize > 0 {
75 r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
76 }
77
78 // Enrich request context with fields for downstream logging
79 ctx := zerowrap.CtxWithFields(r.Context(), map[string]any{
80 zerowrap.FieldLayer: "adapter",
81 zerowrap.FieldAdapter: "http",
82 zerowrap.FieldMethod: r.Method,
83 zerowrap.FieldPath: r.URL.Path,
84 zerowrap.FieldHost: r.Host,
85 zerowrap.FieldClientIP: middleware.GetClientIP(r, h.trustedNets),
86 })
87 r = r.WithContext(ctx)
88 log := zerowrap.FromCtx(ctx)
89
90 host := normalizeRequestHost(r.Host)
91 if host == "" {
92 proxyError(w, "Bad Request", http.StatusBadRequest)
93 return
94 }
95
96 // Check if this is the registry domain
97 if h.proxySvc.IsRegistryDomain(host) {
98 if !h.registryForwarding {
99 // Auth is disabled: the registry is local-only. Forwarding
100 // here would present the proxy's loopback peer to the
101 // registry, which trusts loopback, exposing anonymous
102 // reads and writes on public ingress.
103 log.Warn().Str(zerowrap.FieldHost, host).Msg("registry forwarding denied: authentication disabled")
104 proxyError(w, "404 page not found", http.StatusNotFound)
105 return
106 }
107 log.Debug().Msg("routing request to registry")
108 h.forwardToRegistry(w, r, cfg.RegistryPort)
109 return
110 }
111
112 // Get target for this domain
113 log.Debug().Str("resolving_target_for", host).Msg("looking up proxy target")
114 target, err := h.proxySvc.GetTarget(ctx, host)
115 if err != nil {
116 log.Warn().Err(err).Msg("no route found for domain")
117 proxyError(w, "404 page not found", http.StatusNotFound)
118 return
119 }
120
121 log.Debug().
122 Str("host", target.Host).
123 Int("port", target.Port).
124 Str("container_id", target.ContainerID).
125 Msg("resolved proxy target")
126
127 h.forwardToTarget(w, r, target, cfg.MaxResponseSize)
128}
129
130func normalizeRequestHost(host string) string {
131 host = strings.TrimSpace(host)
132 if h, port, err := net.SplitHostPort(host); err == nil {
133 portNum, err := strconv.Atoi(port)
134 if err != nil || portNum < 1 || portNum > 65535 {
135 return ""
136 }
137 host = h
138 } else if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
139 inner := strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
140 if ip := net.ParseIP(inner); ip == nil || ip.To4() != nil {
141 return ""
142 }
143 host = inner
144 } else if strings.Contains(host, ":") {
145 return ""
146 }
147 host = strings.ToLower(host)
148 host = strings.TrimSuffix(host, ".")
149 return host
150}