detached 29b9578

refactor: consolidate RemoteAddr/trust helpers and host-port mapping

- Export ExtractRemoteIP, add IsTrustedOrLocal and HTTPSAuthority in
  middleware/cidr.go
- Remove duplicated extractDirectIP and isTrustedOrLocal from run.go
- Rewrite httpsRedirectTarget and onboarding publicHTTPSURL to use
  shared HTTPSAuthority helper
- Remove plan-artifact comments from test files
commit
29b95785c74c049b00f7cc421b3164717a53b1c4
author
brice · · 2026-04-01 12:00 UTC
parent
8739892
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +39 -65
M internal/adapters/in/http/middleware/cidr.go +33 -21
@@ -51,8 +51,8 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str
5151 }
5252 }
5353
54-// extractRemoteIP returns the IP portion of r.RemoteAddr, stripping the port.
55-func extractRemoteIP(remoteAddr string) string {
54+// ExtractRemoteIP returns the IP portion of a RemoteAddr, stripping the port.
55+func ExtractRemoteIP(remoteAddr string) string {
5656 host, _, err := net.SplitHostPort(remoteAddr)
5757 if err != nil {
5858 return remoteAddr
@@ -60,6 +60,34 @@ func extractRemoteIP(remoteAddr string) string {
6060 return host
6161 }
6262
63+// IsTrustedOrLocal reports whether an IP belongs to localhost or trusted proxy nets.
64+func 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.
75+func 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, _ := strconv.Atoi(portStr)
83+ if 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+
6391 // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges.
6492 // Localhost is always allowed so Gordon can pull from its own registry.
6593 // An empty allowedNets slice is a no-op (all traffic passes through).
@@ -93,7 +121,7 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
93121 }
94122 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95123 if !forceAll {
96- remoteIP := extractRemoteIP(r.RemoteAddr)
124+ remoteIP := ExtractRemoteIP(r.RemoteAddr)
97125 if IsTrustedProxy(remoteIP, localhostNets) || IsTrustedProxy(remoteIP, proxyNets) {
98126 next.ServeHTTP(w, r)
99127 return
@@ -112,29 +140,13 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
112140 }
113141
114142 // httpsRedirectTarget derives the HTTPS redirect URL from the request Host.
115-//
116-// - No port in Host → omit TLS port (public reverse-proxy assumed on :443).
117-// - Host port == httpPort → map to tlsPort.
118-// - Any other explicit port → preserve it.
119143 func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string {
120144 path := requestURI
121145 if !strings.HasPrefix(path, "/") {
122146 path = "/"
123147 }
124148
125- hostname, portStr, err := net.SplitHostPort(host)
126- if err != nil {
127- // No port in Host header — omit TLS port from URL.
128- return fmt.Sprintf("https://%s%s", host, path)
129- }
130-
131- port, _ := strconv.Atoi(portStr)
132- if port == httpPort {
133- return fmt.Sprintf("https://%s:%d%s", hostname, tlsPort, path)
134- }
135-
136- // Unknown explicit port — preserve it.
137- return fmt.Sprintf("https://%s:%s%s", hostname, portStr, path)
149+ return fmt.Sprintf("https://%s%s", HTTPSAuthority(host, httpPort, tlsPort), path)
138150 }
139151
140152 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
@@ -143,6 +155,6 @@ func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string
143155 // An empty allowedNets slice is a no-op (all traffic passes through).
144156 func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
145157 return cidrAllowlist(allowedNets, func(r *http.Request) string {
146- return extractRemoteIP(r.RemoteAddr)
158+ return ExtractRemoteIP(r.RemoteAddr)
147159 }, "proxy origin", log)
148160 }
@@ -51,8 +51,8 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str
51 }51 }
52 }52 }
53 53
54-// extractRemoteIP returns the IP portion of r.RemoteAddr, stripping the port.54+// ExtractRemoteIP returns the IP portion of a RemoteAddr, stripping the port.
55-func extractRemoteIP(remoteAddr string) string {55+func ExtractRemoteIP(remoteAddr string) string {
56 host, _, err := net.SplitHostPort(remoteAddr)56 host, _, err := net.SplitHostPort(remoteAddr)
57 if err != nil {57 if err != nil {
58 return remoteAddr58 return remoteAddr
@@ -60,6 +60,34 @@ func extractRemoteIP(remoteAddr string) string {
60 return host60 return host
61 }61 }
62 62
63+// IsTrustedOrLocal reports whether an IP belongs to localhost or trusted proxy nets.
64+func 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.
75+func 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, _ := strconv.Atoi(portStr)
83+ if 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+
63 // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges.91 // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges.
64 // Localhost is always allowed so Gordon can pull from its own registry.92 // Localhost is always allowed so Gordon can pull from its own registry.
65 // An empty allowedNets slice is a no-op (all traffic passes through).93 // An empty allowedNets slice is a no-op (all traffic passes through).
@@ -93,7 +121,7 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
93 }121 }
94 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {122 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95 if !forceAll {123 if !forceAll {
96- remoteIP := extractRemoteIP(r.RemoteAddr)124+ remoteIP := ExtractRemoteIP(r.RemoteAddr)
97 if IsTrustedProxy(remoteIP, localhostNets) || IsTrustedProxy(remoteIP, proxyNets) {125 if IsTrustedProxy(remoteIP, localhostNets) || IsTrustedProxy(remoteIP, proxyNets) {
98 next.ServeHTTP(w, r)126 next.ServeHTTP(w, r)
99 return127 return
@@ -112,29 +140,13 @@ func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool,
112 }140 }
113 141
114 // httpsRedirectTarget derives the HTTPS redirect URL from the request Host.142 // httpsRedirectTarget derives the HTTPS redirect URL from the request Host.
115-//
116-// - No port in Host → omit TLS port (public reverse-proxy assumed on :443).
117-// - Host port == httpPort → map to tlsPort.
118-// - Any other explicit port → preserve it.
119 func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string {143 func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string {
120 path := requestURI144 path := requestURI
121 if !strings.HasPrefix(path, "/") {145 if !strings.HasPrefix(path, "/") {
122 path = "/"146 path = "/"
123 }147 }
124 148
125- hostname, portStr, err := net.SplitHostPort(host)149+ return fmt.Sprintf("https://%s%s", HTTPSAuthority(host, httpPort, tlsPort), path)
126- if err != nil {
127- // No port in Host header — omit TLS port from URL.
128- return fmt.Sprintf("https://%s%s", host, path)
129- }
130-
131- port, _ := strconv.Atoi(portStr)
132- if port == httpPort {
133- return fmt.Sprintf("https://%s:%d%s", hostname, tlsPort, path)
134- }
135-
136- // Unknown explicit port — preserve it.
137- return fmt.Sprintf("https://%s:%s%s", hostname, portStr, path)
138 }150 }
139 151
140 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.152 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
@@ -143,6 +155,6 @@ func httpsRedirectTarget(host, requestURI string, httpPort, tlsPort int) string
143 // An empty allowedNets slice is a no-op (all traffic passes through).155 // An empty allowedNets slice is a no-op (all traffic passes through).
144 func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {156 func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
145 return cidrAllowlist(allowedNets, func(r *http.Request) string {157 return cidrAllowlist(allowedNets, func(r *http.Request) string {
146- return extractRemoteIP(r.RemoteAddr)158+ return ExtractRemoteIP(r.RemoteAddr)
147 }, "proxy origin", log)159 }, "proxy origin", log)
148 }160 }
M internal/adapters/in/http/onboarding/handler.go +4 -19
@@ -5,7 +5,8 @@ import (
55 "html"
66 "net"
77 "net/http"
8- "strconv"
8+
9+ "github.com/bnema/gordon/internal/adapters/in/http/middleware"
910 )
1011
1112 // Handler serves CA onboarding endpoints for direct/Tailnet clients.
@@ -46,26 +47,10 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) {
4647 }
4748
4849 // publicHTTPSURL derives the public HTTPS URL from the request Host header.
49-//
50-// Rules:
51-// - no port in Host => https://host/ (omit internal TLS port)
52-// - Host port == httpPort => https://host:tlsPort/
53-// - any other explicit port => preserve it
54-//
5550 // The result is HTML-escaped to prevent XSS via a crafted Host header.
5651 func (h *Handler) publicHTTPSURL(r *http.Request) string {
57- host, portStr, err := net.SplitHostPort(r.Host)
58- if err != nil {
59- // No port in Host header — use bare hostname.
60- return html.EscapeString("https://" + r.Host + "/")
61- }
62-
63- // Ignore error: an invalid explicit port falls through to the preserve-as-is branch.
64- port, _ := strconv.Atoi(portStr)
65- if port == h.httpPort {
66- return html.EscapeString(fmt.Sprintf("https://%s:%d/", host, h.tlsPort))
67- }
68- return html.EscapeString(fmt.Sprintf("https://%s:%s/", host, portStr))
52+ authority := middleware.HTTPSAuthority(r.Host, h.httpPort, h.tlsPort)
53+ return html.EscapeString("https://" + authority + "/")
6954 }
7055
7156 // isRawIP reports whether the host (without port) is a raw IP literal.
@@ -5,7 +5,8 @@ import (
5 "html"5 "html"
6 "net"6 "net"
7 "net/http"7 "net/http"
8- "strconv"8+
9+ "github.com/bnema/gordon/internal/adapters/in/http/middleware"
9 )10 )
10 11
11 // Handler serves CA onboarding endpoints for direct/Tailnet clients.12 // Handler serves CA onboarding endpoints for direct/Tailnet clients.
@@ -46,26 +47,10 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) {
46 }47 }
47 48
48 // publicHTTPSURL derives the public HTTPS URL from the request Host header.49 // publicHTTPSURL derives the public HTTPS URL from the request Host header.
49-//
50-// Rules:
51-// - no port in Host => https://host/ (omit internal TLS port)
52-// - Host port == httpPort => https://host:tlsPort/
53-// - any other explicit port => preserve it
54-//
55 // The result is HTML-escaped to prevent XSS via a crafted Host header.50 // The result is HTML-escaped to prevent XSS via a crafted Host header.
56 func (h *Handler) publicHTTPSURL(r *http.Request) string {51 func (h *Handler) publicHTTPSURL(r *http.Request) string {
57- host, portStr, err := net.SplitHostPort(r.Host)52+ authority := middleware.HTTPSAuthority(r.Host, h.httpPort, h.tlsPort)
58- if err != nil {53+ return html.EscapeString("https://" + authority + "/")
59- // No port in Host header — use bare hostname.
60- return html.EscapeString("https://" + r.Host + "/")
61- }
62-
63- // Ignore error: an invalid explicit port falls through to the preserve-as-is branch.
64- port, _ := strconv.Atoi(portStr)
65- if port == h.httpPort {
66- return html.EscapeString(fmt.Sprintf("https://%s:%d/", host, h.tlsPort))
67- }
68- return html.EscapeString(fmt.Sprintf("https://%s:%s/", host, portStr))
69 }54 }
70 55
71 // isRawIP reports whether the host (without port) is a raw IP literal.56 // isRawIP reports whether the host (without port) is a raw IP literal.
M internal/adapters/in/http/onboarding/handler_test.go +0 -2
@@ -107,8 +107,6 @@ func TestHandler_OnboardingPage(t *testing.T) {
107107 assert.Contains(t, string(body), "/ca.mobileconfig")
108108 }
109109
110-// --- New tests per plan Task 1 ---
111-
112110 func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) {
113111 srv, _, _ := newTestServer(t)
114112 body := getOnboardingBody(t, srv, "o2.bnema.dev")
@@ -107,8 +107,6 @@ func TestHandler_OnboardingPage(t *testing.T) {
107 assert.Contains(t, string(body), "/ca.mobileconfig")107 assert.Contains(t, string(body), "/ca.mobileconfig")
108 }108 }
109 109
110-// --- New tests per plan Task 1 ---
111-
112 func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) {110 func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) {
113 srv, _, _ := newTestServer(t)111 srv, _, _ := newTestServer(t)
114 body := getOnboardingBody(t, srv, "o2.bnema.dev")112 body := getOnboardingBody(t, srv, "o2.bnema.dev")
M internal/app/run.go +2 -19
@@ -1732,8 +1732,8 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr
17321732 )(onboardingMux)
17331733
17341734 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1735- remoteIP := extractDirectIP(r.RemoteAddr)
1736- if isTrustedOrLocal(remoteIP, proxyNets) {
1735+ remoteIP := middleware.ExtractRemoteIP(r.RemoteAddr)
1736+ if middleware.IsTrustedOrLocal(remoteIP, proxyNets) {
17371737 proxyChain.ServeHTTP(w, r)
17381738 return
17391739 }
@@ -1751,23 +1751,6 @@ func directHTTPForbidden(w http.ResponseWriter, r *http.Request) {
17511751 }
17521752 }
17531753
1754-// extractDirectIP returns the IP portion of a RemoteAddr, stripping the port.
1755-func extractDirectIP(remoteAddr string) string {
1756- host, _, err := net.SplitHostPort(remoteAddr)
1757- if err != nil {
1758- return remoteAddr
1759- }
1760- return host
1761-}
1762-
1763-// isTrustedOrLocal checks whether an IP belongs to trusted proxy nets or localhost.
1764-func isTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool {
1765- if parsed := net.ParseIP(ip); parsed != nil && parsed.IsLoopback() {
1766- return true
1767- }
1768- return middleware.IsTrustedProxy(ip, proxyNets)
1769-}
1770-
17711754 func buildRegistryHandlerWithMiddleware(
17721755 svc *services,
17731756 cfg Config,
@@ -1732,8 +1732,8 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr
1732 )(onboardingMux)1732 )(onboardingMux)
1733 1733
1734 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {1734 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1735- remoteIP := extractDirectIP(r.RemoteAddr)1735+ remoteIP := middleware.ExtractRemoteIP(r.RemoteAddr)
1736- if isTrustedOrLocal(remoteIP, proxyNets) {1736+ if middleware.IsTrustedOrLocal(remoteIP, proxyNets) {
1737 proxyChain.ServeHTTP(w, r)1737 proxyChain.ServeHTTP(w, r)
1738 return1738 return
1739 }1739 }
@@ -1751,23 +1751,6 @@ func directHTTPForbidden(w http.ResponseWriter, r *http.Request) {
1751 }1751 }
1752 }1752 }
1753 1753
1754-// extractDirectIP returns the IP portion of a RemoteAddr, stripping the port.
1755-func extractDirectIP(remoteAddr string) string {
1756- host, _, err := net.SplitHostPort(remoteAddr)
1757- if err != nil {
1758- return remoteAddr
1759- }
1760- return host
1761-}
1762-
1763-// isTrustedOrLocal checks whether an IP belongs to trusted proxy nets or localhost.
1764-func isTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool {
1765- if parsed := net.ParseIP(ip); parsed != nil && parsed.IsLoopback() {
1766- return true
1767- }
1768- return middleware.IsTrustedProxy(ip, proxyNets)
1769-}
1770-
1771 func buildRegistryHandlerWithMiddleware(1754 func buildRegistryHandlerWithMiddleware(
1772 svc *services,1755 svc *services,
1773 cfg Config,1756 cfg Config,
M internal/app/run_http_handlers_test.go +0 -4
@@ -71,8 +71,6 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T)
7171 }
7272 }
7373
74-// --- Task 3: Direct HTTP onboarding gate integration tests ---
75-
7674 // newTestCA creates a temporary CA adapter for integration tests.
7775 func newTestCA(t *testing.T) *pkiadapter.CA {
7876 t.Helper()
@@ -323,8 +321,6 @@ func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
323321 assert.Error(t, err, "initPKI should fail when CA cannot be initialized")
324322 }
325323
326-// --- End Task 3 tests ---
327-
328324 func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {
329325 t.Parallel()
330326
@@ -71,8 +71,6 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T)
71 }71 }
72 }72 }
73 73
74-// --- Task 3: Direct HTTP onboarding gate integration tests ---
75-
76 // newTestCA creates a temporary CA adapter for integration tests.74 // newTestCA creates a temporary CA adapter for integration tests.
77 func newTestCA(t *testing.T) *pkiadapter.CA {75 func newTestCA(t *testing.T) *pkiadapter.CA {
78 t.Helper()76 t.Helper()
@@ -323,8 +321,6 @@ func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
323 assert.Error(t, err, "initPKI should fail when CA cannot be initialized")321 assert.Error(t, err, "initPKI should fail when CA cannot be initialized")
324 }322 }
325 323
326-// --- End Task 3 tests ---
327-
328 func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {324 func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {
329 t.Parallel()325 t.Parallel()
330 326

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