v2.31.0-dev9 062dcae

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	"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}

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 8

Tags 106

↑↓ moveenter openesc close