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)
15
16// localhostNets contains IPv4 and IPv6 loopback ranges that are always allowed.
17var localhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"})
18
19// cidrAllowlist is the shared implementation for CIDR-based access control middleware.
20// ipExtractor determines how the client IP is obtained from the request.
21// logLabel is used in the deny log message (e.g. "registry", "proxy origin").
22func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) string, logLabel string, log zerowrap.Logger) func(http.Handler) http.Handler {
23 return func(next http.Handler) http.Handler {
24 if len(allowedNets) == 0 {
25 return next
26 }
27 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28 clientIP := ipExtractor(r)
29
30 if IsTrustedProxy(clientIP, localhostNets) {
31 next.ServeHTTP(w, r)
32 return
33 }
34
35 if IsTrustedProxy(clientIP, allowedNets) {
36 next.ServeHTTP(w, r)
37 return
38 }
39
40 log.Warn().
41 Str(zerowrap.FieldLayer, "adapter").
42 Str(zerowrap.FieldAdapter, "http").
43 Str(zerowrap.FieldMethod, r.Method).
44 Str(zerowrap.FieldClientIP, clientIP).
45 Msgf("%s access denied by CIDR allowlist", logLabel)
46
47 w.Header().Set("Content-Type", "application/json")
48 w.WriteHeader(http.StatusForbidden)
49 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
50 })
51 }
52}
53
54// ExtractRemoteIP returns the IP portion of a RemoteAddr, stripping the port.
55func ExtractRemoteIP(remoteAddr string) string {
56 host, _, err := net.SplitHostPort(remoteAddr)
57 if err != nil {
58 return remoteAddr
59 }
60 return host
61}
62
63// IsTrustedOrLocal reports whether an IP belongs to localhost or trusted proxy nets.
64func IsTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool {
65 return IsTrustedProxy(ip, localhostNets) || IsTrustedProxy(ip, proxyNets)
66}
67
68// HTTPSAuthority converts an incoming Host header plus httpPort/tlsPort into the
69// correct HTTPS authority (host or host:port).
70//
71// Rules:
72// - No port in Host → omit TLS port (public reverse-proxy assumed on :443).
73// - Host port == httpPort → map to tlsPort.
74// - Any other explicit port → preserve it.
75func HTTPSAuthority(host string, httpPort, tlsPort int) string {
76 hostname, portStr, err := net.SplitHostPort(host)
77 if err != nil {
78 // No port in Host header — omit TLS port from URL.
79 return host
80 }
81
82 port, err := strconv.Atoi(portStr)
83 if err == nil && port == httpPort {
84 return net.JoinHostPort(hostname, strconv.Itoa(tlsPort))
85 }
86
87 // Unknown explicit port — preserve it.
88 return net.JoinHostPort(hostname, portStr)
89}
90
91// RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges.
92// Localhost is always allowed so Gordon can pull from its own registry.
93// An empty allowedNets slice is a no-op (all traffic passes through).
94func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
95 return cidrAllowlist(allowedNets, func(r *http.Request) string {
96 return GetClientIP(r, trustedNets)
97 }, "registry", log)
98}
99
100// HTTPSRedirect redirects HTTP clients to the HTTPS port.
101//
102// When forceAll is true, all HTTP requests are redirected (for setups with no proxy).
103// Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost
104// pass through since they deliver Cloudflare-proxied traffic.
105// When tlsPort is 0, this is always a no-op.
106//
107// httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect
108// helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from
109// the public URL; hosts with an unknown explicit port preserve it as-is.
110func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler {
111 return func(next http.Handler) http.Handler {
112 if tlsPort == 0 {
113 return next
114 }
115 if !forceAll && len(proxyNets) == 0 {
116 log.Info().
117 Str(zerowrap.FieldLayer, "adapter").
118 Str(zerowrap.FieldAdapter, "http").
119 Msg("HTTP→HTTPS redirect disabled: proxy_allowed_ips is empty and force_https_redirect is false; set either to enable redirects")
120 return next
121 }
122 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
123 if !forceAll {
124 remoteIP := ExtractRemoteIP(r.RemoteAddr)
125 if IsTrustedOrLocal(remoteIP, proxyNets) {
126 next.ServeHTTP(w, r)
127 return
128 }
129 }
130
131 target := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort)
132
133 log.Debug().
134 Str("target", target).
135 Msg("redirecting HTTP client to HTTPS")
136
137 http.Redirect(w, r, target, http.StatusPermanentRedirect)
138 })
139 }
140}
141
142// httpsRedirectTarget derives the HTTPS redirect URL from the request Host.
143func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string {
144 path := requestURI
145 if !strings.HasPrefix(path, "/") {
146 path = "/"
147 }
148
149 return fmt.Sprintf("https://%s%s", HTTPSAuthority(host, httpPort, tlsPort), path)
150}
151
152// ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
153// This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only
154// trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server.
155// An empty allowedNets slice is a no-op (all traffic passes through).
156func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
157 return cidrAllowlist(allowedNets, func(r *http.Request) string {
158 return ExtractRemoteIP(r.RemoteAddr)
159 }, "proxy origin", log)
160}