v2.31.0-dev2 4b72742

cidr.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close