1package middleware
2
3import (
4 "encoding/json"
5 "fmt"
6 "net"
7 "net/http"
8 "strings"
9
10 "github.com/bnema/zerowrap"
11
12 "github.com/bnema/gordon/internal/adapters/dto"
13 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
14)
15
16// cidrAllowlist is the shared implementation for CIDR-based access control middleware.
17// ipExtractor determines how the client IP is obtained from the request.
18// logLabel is used in the deny log message (e.g. "registry", "proxy origin").
19func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) string, logLabel string, log zerowrap.Logger) func(http.Handler) http.Handler {
20 return func(next http.Handler) http.Handler {
21 if len(allowedNets) == 0 {
22 return next
23 }
24 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25 clientIP := ipExtractor(r)
26
27 if httphelper.IsTrustedProxy(clientIP, httphelper.LocalhostNets) {
28 next.ServeHTTP(w, r)
29 return
30 }
31
32 if httphelper.IsTrustedProxy(clientIP, allowedNets) {
33 next.ServeHTTP(w, r)
34 return
35 }
36
37 log.Warn().
38 Str(zerowrap.FieldLayer, "adapter").
39 Str(zerowrap.FieldAdapter, "http").
40 Str(zerowrap.FieldMethod, r.Method).
41 Str(zerowrap.FieldClientIP, clientIP).
42 Msgf("%s access denied by CIDR allowlist", logLabel)
43
44 w.Header().Set("Content-Type", "application/json")
45 w.WriteHeader(http.StatusForbidden)
46 _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: "Forbidden"})
47 })
48 }
49}
50
51// RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges.
52// Localhost is always allowed so Gordon can pull from its own registry.
53// An empty allowedNets slice is a no-op (all traffic passes through).
54func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
55 return cidrAllowlist(allowedNets, func(r *http.Request) string {
56 return GetClientIP(r, trustedNets)
57 }, "registry", log)
58}
59
60// HTTPSRedirect redirects HTTP clients to the HTTPS port.
61//
62// When forceAll is true, all HTTP requests are redirected (for setups with no proxy).
63// Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost
64// pass through since they deliver Cloudflare-proxied traffic.
65// When tlsPort is 0, this is always a no-op.
66//
67// httpPort is the configured HTTP listener port (cfg.Server.Port) so the redirect
68// helper can map it to tlsPort. Hosts with no explicit port omit the TLS port from
69// the public URL; hosts with an unknown explicit port preserve it as-is.
70func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler {
71 return func(next http.Handler) http.Handler {
72 if tlsPort == 0 {
73 return next
74 }
75 if !forceAll && len(proxyNets) == 0 {
76 log.Info().
77 Str(zerowrap.FieldLayer, "adapter").
78 Str(zerowrap.FieldAdapter, "http").
79 Msg("HTTP→HTTPS redirect disabled: proxy_allowed_ips is empty and force_https_redirect is false; set either to enable redirects")
80 return next
81 }
82 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
83 if !forceAll {
84 remoteIP := httphelper.ExtractRemoteIP(r.RemoteAddr)
85 if httphelper.IsTrustedOrLocal(remoteIP, proxyNets) {
86 next.ServeHTTP(w, r)
87 return
88 }
89 }
90
91 target := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort)
92
93 log.Debug().
94 Str("target", target).
95 Msg("redirecting HTTP client to HTTPS")
96
97 http.Redirect(w, r, target, http.StatusPermanentRedirect)
98 })
99 }
100}
101
102// httpsRedirectTarget derives the HTTPS redirect URL from the request Host.
103func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string {
104 path := requestURI
105 if !strings.HasPrefix(path, "/") {
106 path = "/"
107 }
108
109 return fmt.Sprintf("https://%s%s", httphelper.HTTPSAuthority(host, httpPort, tlsPort), path)
110}
111
112// ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
113// This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only
114// trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server.
115// An empty allowedNets slice is a no-op (all traffic passes through).
116func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
117 return cidrAllowlist(allowedNets, func(r *http.Request) string {
118 return httphelper.ExtractRemoteIP(r.RemoteAddr)
119 }, "proxy origin", log)
120}