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 func(next http.Handler) http.Handler {
74 if tlsPort == 0 {
75 return next
76 }
77 if !forceAll && len(proxyNets) == 0 {
78 log.Info().
79 Str(zerowrap.FieldLayer, "adapter").
80 Str(zerowrap.FieldAdapter, "http").
81 Msg("HTTP→HTTPS redirect disabled: proxy_allowed_ips is empty and force_https_redirect is false; set either to enable redirects")
82 return next
83 }
84 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
85 if !forceAll {
86 remoteIP := httphelper.ExtractRemoteIP(r.RemoteAddr)
87 if httphelper.IsTrustedOrLocal(remoteIP, proxyNets) {
88 next.ServeHTTP(w, r)
89 return
90 }
91 }
92
93 target, ok := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort, isHostAllowed)
94 if !ok {
95 http.Error(w, "Bad Request", http.StatusBadRequest)
96 return
97 }
98
99 log.Debug().
100 Str("target", target).
101 Msg("redirecting HTTP client to HTTPS")
102
103 http.Redirect(w, r, target, http.StatusPermanentRedirect)
104 })
105 }
106}
107
108// httpsRedirectTarget derives the HTTPS redirect URL from a validated request Host.
109func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int, isHostAllowed func(string) bool) (string, bool) {
110 host = strings.ToLower(strings.TrimSpace(host))
111 if host == "" {
112 return "", false
113 }
114 hostname, portStr, err := net.SplitHostPort(host)
115 if err != nil {
116 if strings.Contains(host, ":") {
117 return "", false
118 }
119 hostname = host
120 } else if port, err := strconv.Atoi(portStr); err != nil || port < 1 || port > 65535 {
121 return "", false
122 }
123 hostname = strings.TrimSuffix(hostname, ".")
124 canonicalHost, ok := domain.CanonicalRouteDomain(hostname)
125 if !ok {
126 return "", false
127 }
128 if isHostAllowed == nil || !isHostAllowed(canonicalHost) {
129 return "", false
130 }
131
132 path := requestURI
133 if !strings.HasPrefix(path, "/") {
134 path = "/"
135 }
136
137 canonicalAuthority := canonicalHost
138 if portStr != "" {
139 canonicalAuthority = net.JoinHostPort(canonicalHost, portStr)
140 }
141 return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(canonicalAuthority, httpPort, tlsPort), path), true
142}
143
144// ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
145// This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only
146// trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server.
147// An empty allowedNets slice is a no-op (all traffic passes through).
148func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
149 return cidrAllowlist(allowedNets, func(r *http.Request) string {
150 return httphelper.ExtractRemoteIP(r.RemoteAddr)
151 }, "proxy origin", log)
152}