1package middleware
2
3import (
4 "encoding/json"
5 "fmt"
6 "net"
7 "net/http"
8 "strconv"
9 "strings"
10
11 "github.com/bnema/zerowrap"
12
13 "github.com/bnema/gordon/internal/adapters/dto"
14 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
15 "github.com/bnema/gordon/internal/domain"
16)
17
18// cidrAllowlist is the shared implementation for CIDR-based access control middleware.
19// ipExtractor determines how the client IP is obtained from the request.
20// logLabel is used in the deny log message (e.g. "registry", "proxy origin").
21func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) string, logLabel string, log zerowrap.Logger) func(http.Handler) http.Handler {
22 return func(next http.Handler) http.Handler {
23 if len(allowedNets) == 0 {
24 return next
25 }
26 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27 clientIP := ipExtractor(r)
28
29 if httphelper.IsTrustedProxy(clientIP, httphelper.LocalhostNets) {
30 next.ServeHTTP(w, r)
31 return
32 }
33
34 if httphelper.IsTrustedProxy(clientIP, allowedNets) {
35 next.ServeHTTP(w, r)
36 return
37 }
38
39 log.Warn().
40 Str(zerowrap.FieldLayer, "adapter").
41 Str(zerowrap.FieldAdapter, "http").
42 Str(zerowrap.FieldMethod, r.Method).
43 Str(zerowrap.FieldClientIP, clientIP).
44 Msgf("%s access denied by CIDR allowlist", logLabel)
45
46 w.Header().Set("Content-Type", "application/json")
47 w.WriteHeader(http.StatusForbidden)
48 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
49 })
50 }
51}
52
53// RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges.
54// Localhost is always allowed so Gordon can pull from its own registry.
55// An empty allowedNets slice is a no-op (all traffic passes through).
56func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
57 return cidrAllowlist(allowedNets, func(r *http.Request) string {
58 return GetClientIP(r, trustedNets)
59 }, "registry", log)
60}
61
62// HTTPSRedirect redirects HTTP clients to the HTTPS port.
63//
64// When forceAll is true, all HTTP requests are redirected (for setups with no proxy).
65// Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost
66// pass through since they deliver Cloudflare-proxied traffic.
67// When tlsPort is 0, this is always a no-op.
68//
69// httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect
70// helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from
71// the public URL; hosts with an unknown explicit port preserve it as-is.
72func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger, isHostAllowed func(string) bool) func(http.Handler) http.Handler {
73 return HTTPSRedirectWithEligibility(proxyNets, httpPort, tlsPort, forceAll, log, isHostAllowed, nil, nil)
74}
75
76// HTTPSRedirectWithEligibility is HTTPSRedirect with an extra opt-out and
77// an enforced-TLS predicate:
78// - isRedirectEligible reports whether a KNOWN host should actually be
79// redirected (e.g. app hosts declared tls=never stay plain HTTP).
80// A known but ineligible host passes through to next instead of
81// redirecting; unknown/invalid hosts still get 400. Nil behaves like
82// HTTPSRedirect (every allowed host is eligible).
83// - isTLSAlways reports whether a host requires HTTPS unconditionally
84// (tls=always). Such a host is redirected when an HTTPS endpoint
85// exists and otherwise refused, never served over plaintext. Nil
86// disables the check.
87func HTTPSRedirectWithEligibility(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger, isHostAllowed func(string) bool, isRedirectEligible func(string) bool, isTLSAlways func(string) bool) func(http.Handler) http.Handler {
88 if tlsPort == 0 && isTLSAlways == nil {
89 return func(next http.Handler) http.Handler { return next }
90 }
91 if !forceAll && len(proxyNets) == 0 && isTLSAlways == nil {
92 log.Info().
93 Str(zerowrap.FieldLayer, "adapter").
94 Str(zerowrap.FieldAdapter, "http").
95 Msg("HTTP→HTTPS redirect disabled: proxy_allowed_ips is empty and force_https_redirect is false; set either to enable redirects")
96 return func(next http.Handler) http.Handler { return next }
97 }
98 return func(next http.Handler) http.Handler {
99 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100 // A tls=always host must never be served over plaintext. It is
101 // redirected when an HTTPS endpoint exists and refused when it
102 // does not, regardless of proxy/redirect configuration.
103 if isTLSAlways != nil && isTLSAlwaysForHost(r.Host, isTLSAlways) {
104 handleTLSAlways(w, r, httpPort, tlsPort, isHostAllowed, log)
105 return
106 }
107
108 if tlsPort == 0 {
109 next.ServeHTTP(w, r)
110 return
111 }
112 if !forceAll {
113 remoteIP := httphelper.ExtractRemoteIP(r.RemoteAddr)
114 if httphelper.IsTrustedOrLocal(remoteIP, proxyNets) {
115 next.ServeHTTP(w, r)
116 return
117 }
118 }
119
120 target, ok := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort, isHostAllowed)
121 if ok && isRedirectEligible != nil && !isRedirectEligibleForHost(r.Host, isRedirectEligible) {
122 // Known host that opted out of TLS (e.g. tls=never):
123 // serve plain HTTP through the normal chain.
124 next.ServeHTTP(w, r)
125 return
126 }
127 if !ok {
128 http.Error(w, "Bad Request", http.StatusBadRequest)
129 return
130 }
131
132 log.Debug().
133 Str("target", target).
134 Msg("redirecting HTTP client to HTTPS")
135
136 http.Redirect(w, r, target, http.StatusPermanentRedirect)
137 })
138 }
139}
140
141// handleTLSAlways redirects or refuses a request to a tls=always host. It
142// never forwards: the backend is not reached over plaintext.
143func handleTLSAlways(w http.ResponseWriter, r *http.Request, httpPort, tlsPort int, isHostAllowed func(string) bool, log zerowrap.Logger) {
144 if tlsPort == 0 {
145 log.Warn().Str("host", r.Host).Msg("plaintext refused: host requires TLS but no HTTPS endpoint is configured")
146 http.Error(w, "Misdirected Request", http.StatusMisdirectedRequest)
147 return
148 }
149 target, ok := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort, isHostAllowed)
150 if !ok {
151 http.Error(w, "Bad Request", http.StatusBadRequest)
152 return
153 }
154 http.Redirect(w, r, target, http.StatusPermanentRedirect)
155}
156
157// isTLSAlwaysForHost reports whether the canonical request Host requires
158// HTTPS. Invalid hosts are never treated as always.
159func isTLSAlwaysForHost(host string, isTLSAlways func(string) bool) bool {
160 canonical, ok := canonicalRedirectHost(host)
161 if !ok {
162 return false
163 }
164 return isTLSAlways(canonical)
165}
166
167// isRedirectEligibleForHost reports whether the canonical request Host
168// is eligible for redirect. Invalid hosts are never eligible.
169func isRedirectEligibleForHost(host string, isRedirectEligible func(string) bool) bool {
170 canonical, ok := canonicalRedirectHost(host)
171 if !ok {
172 return false
173 }
174 return isRedirectEligible(canonical)
175}
176
177// canonicalRedirectHost lowercases, strips ports/trailing dots and
178// validates the request Host, returning the canonical route domain.
179func canonicalRedirectHost(host string) (string, bool) {
180 host = strings.ToLower(strings.TrimSpace(host))
181 if host == "" {
182 return "", false
183 }
184 hostname, portStr, err := net.SplitHostPort(host)
185 if err != nil {
186 if strings.Contains(host, ":") {
187 return "", false
188 }
189 hostname = host
190 } else if port, err := strconv.Atoi(portStr); err != nil || port < 1 || port > 65535 {
191 return "", false
192 }
193 hostname = strings.TrimSuffix(hostname, ".")
194 return domain.CanonicalRouteDomain(hostname)
195}
196
197// httpsRedirectTarget derives the HTTPS redirect URL from a validated request Host.
198func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int, isHostAllowed func(string) bool) (string, bool) {
199 raw := strings.ToLower(strings.TrimSpace(host))
200 _, portStr, splitErr := net.SplitHostPort(raw)
201 hasPort := splitErr == nil
202 canonicalHost, ok := canonicalRedirectHost(host)
203 if !ok {
204 return "", false
205 }
206 if isHostAllowed == nil || !isHostAllowed(canonicalHost) {
207 return "", false
208 }
209
210 path := requestURI
211 if !strings.HasPrefix(path, "/") {
212 path = "/"
213 }
214
215 canonicalAuthority := canonicalHost
216 if hasPort {
217 canonicalAuthority = net.JoinHostPort(canonicalHost, portStr)
218 }
219 return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(canonicalAuthority, httpPort, tlsPort), path), true
220}
221
222// ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
223// This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only
224// trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server.
225// An empty allowedNets slice is a no-op (all traffic passes through).
226func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
227 return cidrAllowlist(allowedNets, func(r *http.Request) string {
228 return httphelper.ExtractRemoteIP(r.RemoteAddr)
229 }, "proxy origin", log)
230}