refactor: resolve pre-release hexarch follow-ups
- commit
- 0986ec35b6c61d526994d2d3f1271f3c65118c84
- author
- brice · · 2026-04-08 15:31 UTC
- parent
- a8338c0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +173 -89
M internal/adapters/in/cli/ca.go +4 -3
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/spf13/cobra" | |
| 14 | 14 | ||
| 15 | 15 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 16 | + | boundaryout "github.com/bnema/gordon/internal/boundaries/out" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | func newCACmd() *cobra.Command { | |
| @@ -108,7 +109,7 @@ func resolveCADataDir() (string, error) { | |||
| 108 | 109 | // loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer). | |
| 109 | 110 | // This is intentional: CLI commands like 'ca export' and 'ca info' are standalone | |
| 110 | 111 | // admin utilities that run outside the server lifecycle and don't need business logic. | |
| 111 | - | func loadCAFromDataDir(dataDir string) (*pkiadapter.CA, error) { | |
| 112 | + | func loadCAFromDataDir(dataDir string) (boundaryout.CertificateAuthority, error) { | |
| 112 | 113 | ca, err := pkiadapter.NewCA(dataDir, cliLogger()) | |
| 113 | 114 | if err != nil { | |
| 114 | 115 | return nil, fmt.Errorf("failed to load CA from %s: %w", dataDir, err) | |
| @@ -155,7 +156,7 @@ func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, j | |||
| 155 | 156 | } | |
| 156 | 157 | ||
| 157 | 158 | if uninstall { | |
| 158 | - | if err := pkiadapter.UninstallRoot(cert); err != nil { | |
| 159 | + | if err := ca.UninstallRoot(cert); err != nil { | |
| 159 | 160 | return fmt.Errorf("failed to uninstall root CA: %w", err) | |
| 160 | 161 | } | |
| 161 | 162 | if jsonOut { | |
| @@ -164,7 +165,7 @@ func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, j | |||
| 164 | 165 | return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores")) | |
| 165 | 166 | } | |
| 166 | 167 | ||
| 167 | - | if err := pkiadapter.InstallRoot(cert); err != nil { | |
| 168 | + | if err := ca.InstallRoot(cert); err != nil { | |
| 168 | 169 | return fmt.Errorf("failed to install root CA: %w", err) | |
| 169 | 170 | } | |
| 170 | 171 | if jsonOut { | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/spf13/cobra" | 13 | "github.com/spf13/cobra" | ||
| 14 | 14 | ||||
| 15 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | 15 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | ||
| 16 | + | boundaryout "github.com/bnema/gordon/internal/boundaries/out" | |||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 18 | func newCACmd() *cobra.Command { | 19 | func newCACmd() *cobra.Command { | ||
| @@ -108,7 +109,7 @@ func resolveCADataDir() (string, error) { | |||||
| 108 | // loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer). | 109 | // loadCAFromDataDir creates a CA adapter directly (bypassing the usecase layer). | ||
| 109 | // This is intentional: CLI commands like 'ca export' and 'ca info' are standalone | 110 | // This is intentional: CLI commands like 'ca export' and 'ca info' are standalone | ||
| 110 | // admin utilities that run outside the server lifecycle and don't need business logic. | 111 | // admin utilities that run outside the server lifecycle and don't need business logic. | ||
| 111 | - | func loadCAFromDataDir(dataDir string) (*pkiadapter.CA, error) { | 112 | + | func loadCAFromDataDir(dataDir string) (boundaryout.CertificateAuthority, error) { |
| 112 | ca, err := pkiadapter.NewCA(dataDir, cliLogger()) | 113 | ca, err := pkiadapter.NewCA(dataDir, cliLogger()) | ||
| 113 | if err != nil { | 114 | if err != nil { | ||
| 114 | return nil, fmt.Errorf("failed to load CA from %s: %w", dataDir, err) | 115 | return nil, fmt.Errorf("failed to load CA from %s: %w", dataDir, err) | ||
| @@ -155,7 +156,7 @@ func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, j | |||||
| 155 | } | 156 | } | ||
| 156 | 157 | ||||
| 157 | if uninstall { | 158 | if uninstall { | ||
| 158 | - | if err := pkiadapter.UninstallRoot(cert); err != nil { | 159 | + | if err := ca.UninstallRoot(cert); err != nil { |
| 159 | return fmt.Errorf("failed to uninstall root CA: %w", err) | 160 | return fmt.Errorf("failed to uninstall root CA: %w", err) | ||
| 160 | } | 161 | } | ||
| 161 | if jsonOut { | 162 | if jsonOut { | ||
| @@ -164,7 +165,7 @@ func runCAInstall(_ context.Context, out io.Writer, dataDir string, uninstall, j | |||||
| 164 | return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores")) | 165 | return cliWriteLine(out, cliRenderSuccess("Root CA removed from system trust stores")) | ||
| 165 | } | 166 | } | ||
| 166 | 167 | ||||
| 167 | - | if err := pkiadapter.InstallRoot(cert); err != nil { | 168 | + | if err := ca.InstallRoot(cert); err != nil { |
| 168 | return fmt.Errorf("failed to install root CA: %w", err) | 169 | return fmt.Errorf("failed to install root CA: %w", err) | ||
| 169 | } | 170 | } | ||
| 170 | if jsonOut { | 171 | if jsonOut { | ||
M internal/adapters/in/http/auth/handler.go +2 -2
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 15 | - | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 16 | 16 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 17 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 18 | 18 | ) | |
| @@ -166,7 +166,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 166 | 166 | } | |
| 167 | 167 | ||
| 168 | 168 | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { | |
| 169 | - | if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) { | |
| 169 | + | if httphelper.IsLocalhostRequest(r) && h.isInternalAuth(username, password) { | |
| 170 | 170 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | |
| 171 | 171 | return true, &domain.TokenClaims{ | |
| 172 | 172 | Subject: username, | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | "github.com/bnema/zerowrap" | 12 | "github.com/bnema/zerowrap" | ||
| 13 | 13 | ||||
| 14 | "github.com/bnema/gordon/internal/adapters/dto" | 14 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 15 | - | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" |
| 16 | "github.com/bnema/gordon/internal/boundaries/in" | 16 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 17 | "github.com/bnema/gordon/internal/domain" | 17 | "github.com/bnema/gordon/internal/domain" | ||
| 18 | ) | 18 | ) | ||
| @@ -166,7 +166,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 166 | } | 166 | } | ||
| 167 | 167 | ||||
| 168 | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { | 168 | func (h *Handler) authenticateTokenCredentials(ctx context.Context, r *http.Request, username, password string, log zerowrap.Logger) (bool, *domain.TokenClaims) { | ||
| 169 | - | if httputil.IsLocalhostRequest(r) && h.isInternalAuth(username, password) { | 169 | + | if httphelper.IsLocalhostRequest(r) && h.isInternalAuth(username, password) { |
| 170 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | 170 | log.Debug().Str("username", username).Msg("internal registry auth accepted") | ||
| 171 | return true, &domain.TokenClaims{ | 171 | return true, &domain.TokenClaims{ | ||
| 172 | Subject: username, | 172 | Subject: username, | ||
M internal/adapters/in/http/auth/handler_test.go +2 -2
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/assert" | |
| 14 | 14 | "github.com/stretchr/testify/mock" | |
| 15 | 15 | ||
| 16 | - | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 16 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 17 | 17 | "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 18 | 18 | "github.com/bnema/gordon/internal/domain" | |
| 19 | 19 | ) | |
| @@ -358,7 +358,7 @@ func TestIsLocalhostRequest(t *testing.T) { | |||
| 358 | 358 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | |
| 359 | 359 | req.RemoteAddr = tt.remoteAddr | |
| 360 | 360 | ||
| 361 | - | result := httputil.IsLocalhostRequest(req) | |
| 361 | + | result := httphelper.IsLocalhostRequest(req) | |
| 362 | 362 | ||
| 363 | 363 | assert.Equal(t, tt.want, result) | |
| 364 | 364 | }) | |
| @@ -13,7 +13,7 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/assert" | 13 | "github.com/stretchr/testify/assert" | ||
| 14 | "github.com/stretchr/testify/mock" | 14 | "github.com/stretchr/testify/mock" | ||
| 15 | 15 | ||||
| 16 | - | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | 16 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" |
| 17 | "github.com/bnema/gordon/internal/boundaries/in/mocks" | 17 | "github.com/bnema/gordon/internal/boundaries/in/mocks" | ||
| 18 | "github.com/bnema/gordon/internal/domain" | 18 | "github.com/bnema/gordon/internal/domain" | ||
| 19 | ) | 19 | ) | ||
| @@ -358,7 +358,7 @@ func TestIsLocalhostRequest(t *testing.T) { | |||||
| 358 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | 358 | req := httptest.NewRequest(http.MethodGet, "/auth/token", nil) | ||
| 359 | req.RemoteAddr = tt.remoteAddr | 359 | req.RemoteAddr = tt.remoteAddr | ||
| 360 | 360 | ||||
| 361 | - | result := httputil.IsLocalhostRequest(req) | 361 | + | result := httphelper.IsLocalhostRequest(req) |
| 362 | 362 | ||||
| 363 | assert.Equal(t, tt.want, result) | 363 | assert.Equal(t, tt.want, result) | ||
| 364 | }) | 364 | }) | ||
R internal/adapters/in/http/httputil/localhost.go → internal/adapters/in/http/httphelper/localhost.go +1 -1 renamed, 96% similar
| @@ -1,4 +1,4 @@ | |||
| 1 | - | package httputil | |
| 1 | + | package httphelper | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "net" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package httputil | 1 | + | package httphelper |
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "net" | 4 | "net" | ||
R internal/adapters/in/http/httputil/localhost_test.go → internal/adapters/in/http/httphelper/localhost_test.go +3 -3 renamed, 82% similar
| @@ -1,11 +1,11 @@ | |||
| 1 | - | package httputil_test | |
| 1 | + | package httphelper_test | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "net/http" | |
| 5 | 5 | "net/http/httptest" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | - | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 8 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | func TestIsLocalhostRequest(t *testing.T) { | |
| @@ -25,7 +25,7 @@ func TestIsLocalhostRequest(t *testing.T) { | |||
| 25 | 25 | t.Run(tt.name, func(t *testing.T) { | |
| 26 | 26 | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 27 | 27 | req.RemoteAddr = tt.remoteAddr | |
| 28 | - | if got := httputil.IsLocalhostRequest(req); got != tt.want { | |
| 28 | + | if got := httphelper.IsLocalhostRequest(req); got != tt.want { | |
| 29 | 29 | t.Errorf("IsLocalhostRequest(%q) = %v, want %v", tt.remoteAddr, got, tt.want) | |
| 30 | 30 | } | |
| 31 | 31 | }) | |
| @@ -1,11 +1,11 @@ | |||||
| 1 | - | package httputil_test | 1 | + | package httphelper_test |
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "net/http" | 4 | "net/http" | ||
| 5 | "net/http/httptest" | 5 | "net/http/httptest" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | - | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | 8 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" |
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | func TestIsLocalhostRequest(t *testing.T) { | 11 | func TestIsLocalhostRequest(t *testing.T) { | ||
| @@ -25,7 +25,7 @@ func TestIsLocalhostRequest(t *testing.T) { | |||||
| 25 | t.Run(tt.name, func(t *testing.T) { | 25 | t.Run(tt.name, func(t *testing.T) { | ||
| 26 | req := httptest.NewRequest(http.MethodGet, "/", nil) | 26 | req := httptest.NewRequest(http.MethodGet, "/", nil) | ||
| 27 | req.RemoteAddr = tt.remoteAddr | 27 | req.RemoteAddr = tt.remoteAddr | ||
| 28 | - | if got := httputil.IsLocalhostRequest(req); got != tt.want { | 28 | + | if got := httphelper.IsLocalhostRequest(req); got != tt.want { |
| 29 | t.Errorf("IsLocalhostRequest(%q) = %v, want %v", tt.remoteAddr, got, tt.want) | 29 | t.Errorf("IsLocalhostRequest(%q) = %v, want %v", tt.remoteAddr, got, tt.want) | ||
| 30 | } | 30 | } | ||
| 31 | }) | 31 | }) | ||
A internal/adapters/in/http/httphelper/trust.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | package httphelper | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net" | |
| 5 | + | "net/http" | |
| 6 | + | "strconv" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // localhostNets contains IPv4 and IPv6 loopback ranges. | |
| 11 | + | var localhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"}) | |
| 12 | + | ||
| 13 | + | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to [net.IPNet]. | |
| 14 | + | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | |
| 15 | + | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | |
| 16 | + | func ParseTrustedProxies(proxies []string) []*net.IPNet { | |
| 17 | + | var nets []*net.IPNet | |
| 18 | + | for _, proxy := range proxies { | |
| 19 | + | // Try parsing as CIDR | |
| 20 | + | _, ipNet, err := net.ParseCIDR(proxy) | |
| 21 | + | if err == nil { | |
| 22 | + | nets = append(nets, ipNet) | |
| 23 | + | continue | |
| 24 | + | } | |
| 25 | + | // Try parsing as single IP | |
| 26 | + | ip := net.ParseIP(proxy) | |
| 27 | + | if ip != nil { | |
| 28 | + | // Convert single IP to /32 or /128 CIDR | |
| 29 | + | bits := 32 | |
| 30 | + | if ip.To4() == nil { | |
| 31 | + | bits = 128 | |
| 32 | + | } | |
| 33 | + | nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | return nets | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // IsTrustedProxy reports whether the given IP address falls within any of the | |
| 40 | + | // trusted networks. Returns false if trustedNets is empty or the IP cannot be parsed. | |
| 41 | + | func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool { | |
| 42 | + | if len(trustedNets) == 0 { | |
| 43 | + | return false | |
| 44 | + | } | |
| 45 | + | parsedIP := net.ParseIP(ip) | |
| 46 | + | if parsedIP == nil { | |
| 47 | + | return false | |
| 48 | + | } | |
| 49 | + | for _, ipNet := range trustedNets { | |
| 50 | + | if ipNet.Contains(parsedIP) { | |
| 51 | + | return true | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // IsTrustedOrLocal reports whether an IP belongs to localhost or the given | |
| 58 | + | // trusted proxy networks. | |
| 59 | + | func IsTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool { | |
| 60 | + | return IsTrustedProxy(ip, localhostNets) || IsTrustedProxy(ip, proxyNets) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // ExtractRemoteIP returns the IP portion of an [http.Request.RemoteAddr], | |
| 64 | + | // stripping the port. When RemoteAddr lacks a port (e.g. Unix sockets or | |
| 65 | + | // certain proxy setups), it is returned as-is. | |
| 66 | + | func ExtractRemoteIP(remoteAddr string) string { | |
| 67 | + | host, _, err := net.SplitHostPort(remoteAddr) | |
| 68 | + | if err != nil { | |
| 69 | + | return remoteAddr | |
| 70 | + | } | |
| 71 | + | return host | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // IsTrustedSource reports whether the request's remote address is in trustedNets. | |
| 75 | + | // When RemoteAddr lacks a port, IPv6 brackets are stripped before checking. | |
| 76 | + | func IsTrustedSource(r *http.Request, trustedNets []*net.IPNet) bool { | |
| 77 | + | if len(trustedNets) == 0 { | |
| 78 | + | return false | |
| 79 | + | } | |
| 80 | + | host, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 81 | + | if err != nil { | |
| 82 | + | // RemoteAddr may be a bare IP without a port (e.g. from Unix sockets | |
| 83 | + | // or certain proxy setups). Fall back to using it directly. | |
| 84 | + | host = strings.TrimSuffix(strings.TrimPrefix(r.RemoteAddr, "["), "]") | |
| 85 | + | } | |
| 86 | + | return IsTrustedProxy(host, trustedNets) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // HTTPSAuthority converts an incoming Host header plus httpPort/tlsPort into | |
| 90 | + | // the correct HTTPS authority (host or host:port). | |
| 91 | + | // | |
| 92 | + | // Rules: | |
| 93 | + | // - No port in Host: omit TLS port (public reverse-proxy assumed on :443). | |
| 94 | + | // - Host port == httpPort: map to tlsPort. | |
| 95 | + | // - Any other explicit port: preserve it. | |
| 96 | + | func HTTPSAuthority(host string, httpPort, tlsPort int) string { | |
| 97 | + | hostname, portStr, err := net.SplitHostPort(host) | |
| 98 | + | if err != nil { | |
| 99 | + | // No port in Host header — omit TLS port from URL. | |
| 100 | + | return host | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | port, err := strconv.Atoi(portStr) | |
| 104 | + | if err == nil && port == httpPort { | |
| 105 | + | return net.JoinHostPort(hostname, strconv.Itoa(tlsPort)) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // Unknown explicit port — preserve it. | |
| 109 | + | return net.JoinHostPort(hostname, portStr) | |
| 110 | + | } | |
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | package httphelper | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "net/http" | |||
| 6 | + | "strconv" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // localhostNets contains IPv4 and IPv6 loopback ranges. | |||
| 11 | + | var localhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"}) | |||
| 12 | + | ||||
| 13 | + | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to [net.IPNet]. | |||
| 14 | + | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | |||
| 15 | + | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | |||
| 16 | + | func ParseTrustedProxies(proxies []string) []*net.IPNet { | |||
| 17 | + | var nets []*net.IPNet | |||
| 18 | + | for _, proxy := range proxies { | |||
| 19 | + | // Try parsing as CIDR | |||
| 20 | + | _, ipNet, err := net.ParseCIDR(proxy) | |||
| 21 | + | if err == nil { | |||
| 22 | + | nets = append(nets, ipNet) | |||
| 23 | + | continue | |||
| 24 | + | } | |||
| 25 | + | // Try parsing as single IP | |||
| 26 | + | ip := net.ParseIP(proxy) | |||
| 27 | + | if ip != nil { | |||
| 28 | + | // Convert single IP to /32 or /128 CIDR | |||
| 29 | + | bits := 32 | |||
| 30 | + | if ip.To4() == nil { | |||
| 31 | + | bits = 128 | |||
| 32 | + | } | |||
| 33 | + | nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | return nets | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // IsTrustedProxy reports whether the given IP address falls within any of the | |||
| 40 | + | // trusted networks. Returns false if trustedNets is empty or the IP cannot be parsed. | |||
| 41 | + | func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool { | |||
| 42 | + | if len(trustedNets) == 0 { | |||
| 43 | + | return false | |||
| 44 | + | } | |||
| 45 | + | parsedIP := net.ParseIP(ip) | |||
| 46 | + | if parsedIP == nil { | |||
| 47 | + | return false | |||
| 48 | + | } | |||
| 49 | + | for _, ipNet := range trustedNets { | |||
| 50 | + | if ipNet.Contains(parsedIP) { | |||
| 51 | + | return true | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | return false | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // IsTrustedOrLocal reports whether an IP belongs to localhost or the given | |||
| 58 | + | // trusted proxy networks. | |||
| 59 | + | func IsTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool { | |||
| 60 | + | return IsTrustedProxy(ip, localhostNets) || IsTrustedProxy(ip, proxyNets) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // ExtractRemoteIP returns the IP portion of an [http.Request.RemoteAddr], | |||
| 64 | + | // stripping the port. When RemoteAddr lacks a port (e.g. Unix sockets or | |||
| 65 | + | // certain proxy setups), it is returned as-is. | |||
| 66 | + | func ExtractRemoteIP(remoteAddr string) string { | |||
| 67 | + | host, _, err := net.SplitHostPort(remoteAddr) | |||
| 68 | + | if err != nil { | |||
| 69 | + | return remoteAddr | |||
| 70 | + | } | |||
| 71 | + | return host | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // IsTrustedSource reports whether the request's remote address is in trustedNets. | |||
| 75 | + | // When RemoteAddr lacks a port, IPv6 brackets are stripped before checking. | |||
| 76 | + | func IsTrustedSource(r *http.Request, trustedNets []*net.IPNet) bool { | |||
| 77 | + | if len(trustedNets) == 0 { | |||
| 78 | + | return false | |||
| 79 | + | } | |||
| 80 | + | host, _, err := net.SplitHostPort(r.RemoteAddr) | |||
| 81 | + | if err != nil { | |||
| 82 | + | // RemoteAddr may be a bare IP without a port (e.g. from Unix sockets | |||
| 83 | + | // or certain proxy setups). Fall back to using it directly. | |||
| 84 | + | host = strings.TrimSuffix(strings.TrimPrefix(r.RemoteAddr, "["), "]") | |||
| 85 | + | } | |||
| 86 | + | return IsTrustedProxy(host, trustedNets) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // HTTPSAuthority converts an incoming Host header plus httpPort/tlsPort into | |||
| 90 | + | // the correct HTTPS authority (host or host:port). | |||
| 91 | + | // | |||
| 92 | + | // Rules: | |||
| 93 | + | // - No port in Host: omit TLS port (public reverse-proxy assumed on :443). | |||
| 94 | + | // - Host port == httpPort: map to tlsPort. | |||
| 95 | + | // - Any other explicit port: preserve it. | |||
| 96 | + | func HTTPSAuthority(host string, httpPort, tlsPort int) string { | |||
| 97 | + | hostname, portStr, err := net.SplitHostPort(host) | |||
| 98 | + | if err != nil { | |||
| 99 | + | // No port in Host header — omit TLS port from URL. | |||
| 100 | + | return host | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | port, err := strconv.Atoi(portStr) | |||
| 104 | + | if err == nil && port == httpPort { | |||
| 105 | + | return net.JoinHostPort(hostname, strconv.Itoa(tlsPort)) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // Unknown explicit port — preserve it. | |||
| 109 | + | return net.JoinHostPort(hostname, portStr) | |||
| 110 | + | } | |||
M internal/adapters/in/http/middleware/auth.go +3 -3
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 15 | - | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | |
| 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 16 | 16 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 17 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 18 | 18 | ) | |
| @@ -59,7 +59,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, t | |||
| 59 | 59 | return func(next http.Handler) http.Handler { | |
| 60 | 60 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 61 | 61 | // Allow localhost requests only with internal instance credentials. | |
| 62 | - | if httputil.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) { | |
| 62 | + | if httphelper.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) { | |
| 63 | 63 | log.Debug(). | |
| 64 | 64 | Str(zerowrap.FieldLayer, "adapter"). | |
| 65 | 65 | Str(zerowrap.FieldAdapter, "http"). | |
| @@ -72,7 +72,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, t | |||
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | // Warn if not using TLS (skip for localhost — internal proxy traffic) | |
| 75 | - | if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httputil.IsLocalhostRequest(r) { | |
| 75 | + | if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httphelper.IsLocalhostRequest(r) { | |
| 76 | 76 | log.Warn(). | |
| 77 | 77 | Str(zerowrap.FieldLayer, "adapter"). | |
| 78 | 78 | Str(zerowrap.FieldAdapter, "http"). | |
| @@ -12,7 +12,7 @@ import ( | |||||
| 12 | "github.com/bnema/zerowrap" | 12 | "github.com/bnema/zerowrap" | ||
| 13 | 13 | ||||
| 14 | "github.com/bnema/gordon/internal/adapters/dto" | 14 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 15 | - | "github.com/bnema/gordon/internal/adapters/in/http/httputil" | 15 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" |
| 16 | "github.com/bnema/gordon/internal/boundaries/in" | 16 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 17 | "github.com/bnema/gordon/internal/domain" | 17 | "github.com/bnema/gordon/internal/domain" | ||
| 18 | ) | 18 | ) | ||
| @@ -59,7 +59,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, t | |||||
| 59 | return func(next http.Handler) http.Handler { | 59 | return func(next http.Handler) http.Handler { | ||
| 60 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 60 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 61 | // Allow localhost requests only with internal instance credentials. | 61 | // Allow localhost requests only with internal instance credentials. | ||
| 62 | - | if httputil.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) { | 62 | + | if httphelper.IsLocalhostRequest(r) && isInternalRegistryAuth(r, internalAuth) { |
| 63 | log.Debug(). | 63 | log.Debug(). | ||
| 64 | Str(zerowrap.FieldLayer, "adapter"). | 64 | Str(zerowrap.FieldLayer, "adapter"). | ||
| 65 | Str(zerowrap.FieldAdapter, "http"). | 65 | Str(zerowrap.FieldAdapter, "http"). | ||
| @@ -72,7 +72,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, t | |||||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | // Warn if not using TLS (skip for localhost — internal proxy traffic) | 74 | // Warn if not using TLS (skip for localhost — internal proxy traffic) | ||
| 75 | - | if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httputil.IsLocalhostRequest(r) { | 75 | + | if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httphelper.IsLocalhostRequest(r) { |
| 76 | log.Warn(). | 76 | log.Warn(). | ||
| 77 | Str(zerowrap.FieldLayer, "adapter"). | 77 | Str(zerowrap.FieldLayer, "adapter"). | ||
| 78 | Str(zerowrap.FieldAdapter, "http"). | 78 | Str(zerowrap.FieldAdapter, "http"). | ||
M internal/adapters/in/http/middleware/cidr.go +13 -25
| @@ -5,16 +5,16 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "net" | |
| 7 | 7 | "net/http" | |
| 8 | - | "strconv" | |
| 9 | 8 | "strings" | |
| 10 | 9 | ||
| 11 | 10 | "github.com/bnema/zerowrap" | |
| 12 | 11 | ||
| 13 | 12 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 13 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // localhostNets contains IPv4 and IPv6 loopback ranges that are always allowed. | |
| 17 | - | var localhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"}) | |
| 17 | + | var localhostNets = httphelper.ParseTrustedProxies([]string{"127.0.0.0/8", "::1"}) | |
| 18 | 18 | ||
| 19 | 19 | // cidrAllowlist is the shared implementation for CIDR-based access control middleware. | |
| 20 | 20 | // ipExtractor determines how the client IP is obtained from the request. | |
| @@ -52,40 +52,28 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str | |||
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | 54 | // ExtractRemoteIP returns the IP portion of a RemoteAddr, stripping the port. | |
| 55 | + | // | |
| 56 | + | // Deprecated: use httphelper.ExtractRemoteIP directly. This wrapper preserves | |
| 57 | + | // the existing middleware API for callers that have not migrated yet. | |
| 55 | 58 | func ExtractRemoteIP(remoteAddr string) string { | |
| 56 | - | host, _, err := net.SplitHostPort(remoteAddr) | |
| 57 | - | if err != nil { | |
| 58 | - | return remoteAddr | |
| 59 | - | } | |
| 60 | - | return host | |
| 59 | + | return httphelper.ExtractRemoteIP(remoteAddr) | |
| 61 | 60 | } | |
| 62 | 61 | ||
| 63 | 62 | // IsTrustedOrLocal reports whether an IP belongs to localhost or trusted proxy nets. | |
| 63 | + | // | |
| 64 | + | // Deprecated: use httphelper.IsTrustedOrLocal directly. This wrapper preserves | |
| 65 | + | // the existing middleware API for callers that have not migrated yet. | |
| 64 | 66 | func IsTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool { | |
| 65 | - | return IsTrustedProxy(ip, localhostNets) || IsTrustedProxy(ip, proxyNets) | |
| 67 | + | return httphelper.IsTrustedOrLocal(ip, proxyNets) | |
| 66 | 68 | } | |
| 67 | 69 | ||
| 68 | 70 | // HTTPSAuthority converts an incoming Host header plus httpPort/tlsPort into the | |
| 69 | 71 | // correct HTTPS authority (host or host:port). | |
| 70 | 72 | // | |
| 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. | |
| 73 | + | // Deprecated: use httphelper.HTTPSAuthority directly. This wrapper preserves | |
| 74 | + | // the existing middleware API for callers that have not migrated yet. | |
| 75 | 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, 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) | |
| 76 | + | return httphelper.HTTPSAuthority(host, httpPort, tlsPort) | |
| 89 | 77 | } | |
| 90 | 78 | ||
| 91 | 79 | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | |
| @@ -5,16 +5,16 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "net" | 6 | "net" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | - | "strconv" | |||
| 9 | "strings" | 8 | "strings" | ||
| 10 | 9 | ||||
| 11 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| 12 | 11 | ||||
| 13 | "github.com/bnema/gordon/internal/adapters/dto" | 12 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 13 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // localhostNets contains IPv4 and IPv6 loopback ranges that are always allowed. | 16 | // localhostNets contains IPv4 and IPv6 loopback ranges that are always allowed. | ||
| 17 | - | var localhostNets = ParseTrustedProxies([]string{"127.0.0.0/8", "::1"}) | 17 | + | var localhostNets = httphelper.ParseTrustedProxies([]string{"127.0.0.0/8", "::1"}) |
| 18 | 18 | ||||
| 19 | // cidrAllowlist is the shared implementation for CIDR-based access control middleware. | 19 | // cidrAllowlist is the shared implementation for CIDR-based access control middleware. | ||
| 20 | // ipExtractor determines how the client IP is obtained from the request. | 20 | // ipExtractor determines how the client IP is obtained from the request. | ||
| @@ -52,40 +52,28 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str | |||||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | // ExtractRemoteIP returns the IP portion of a RemoteAddr, stripping the port. | 54 | // ExtractRemoteIP returns the IP portion of a RemoteAddr, stripping the port. | ||
| 55 | + | // | |||
| 56 | + | // Deprecated: use httphelper.ExtractRemoteIP directly. This wrapper preserves | |||
| 57 | + | // the existing middleware API for callers that have not migrated yet. | |||
| 55 | func ExtractRemoteIP(remoteAddr string) string { | 58 | func ExtractRemoteIP(remoteAddr string) string { | ||
| 56 | - | host, _, err := net.SplitHostPort(remoteAddr) | 59 | + | return httphelper.ExtractRemoteIP(remoteAddr) |
| 57 | - | if err != nil { | |||
| 58 | - | return remoteAddr | |||
| 59 | - | } | |||
| 60 | - | return host | |||
| 61 | } | 60 | } | ||
| 62 | 61 | ||||
| 63 | // IsTrustedOrLocal reports whether an IP belongs to localhost or trusted proxy nets. | 62 | // IsTrustedOrLocal reports whether an IP belongs to localhost or trusted proxy nets. | ||
| 63 | + | // | |||
| 64 | + | // Deprecated: use httphelper.IsTrustedOrLocal directly. This wrapper preserves | |||
| 65 | + | // the existing middleware API for callers that have not migrated yet. | |||
| 64 | func IsTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool { | 66 | func IsTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool { | ||
| 65 | - | return IsTrustedProxy(ip, localhostNets) || IsTrustedProxy(ip, proxyNets) | 67 | + | return httphelper.IsTrustedOrLocal(ip, proxyNets) |
| 66 | } | 68 | } | ||
| 67 | 69 | ||||
| 68 | // HTTPSAuthority converts an incoming Host header plus httpPort/tlsPort into the | 70 | // HTTPSAuthority converts an incoming Host header plus httpPort/tlsPort into the | ||
| 69 | // correct HTTPS authority (host or host:port). | 71 | // correct HTTPS authority (host or host:port). | ||
| 70 | // | 72 | // | ||
| 71 | - | // Rules: | 73 | + | // Deprecated: use httphelper.HTTPSAuthority directly. This wrapper preserves |
| 72 | - | // - No port in Host → omit TLS port (public reverse-proxy assumed on :443). | 74 | + | // the existing middleware API for callers that have not migrated yet. |
| 73 | - | // - Host port == httpPort → map to tlsPort. | |||
| 74 | - | // - Any other explicit port → preserve it. | |||
| 75 | func HTTPSAuthority(host string, httpPort, tlsPort int) string { | 75 | func HTTPSAuthority(host string, httpPort, tlsPort int) string { | ||
| 76 | - | hostname, portStr, err := net.SplitHostPort(host) | 76 | + | return httphelper.HTTPSAuthority(host, httpPort, tlsPort) |
| 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 | } | 77 | } | ||
| 90 | 78 | ||||
| 91 | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | 79 | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | ||
M internal/adapters/in/http/middleware/clientip.go +11 -34
| @@ -4,12 +4,14 @@ import ( | |||
| 4 | 4 | "net" | |
| 5 | 5 | "net/http" | |
| 6 | 6 | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | // cloudflareNets contains Cloudflare's published IPv4 and IPv6 ranges. | |
| 10 | 12 | // Cf-Connecting-Ip is only trusted when the immediate upstream matches these. | |
| 11 | 13 | // Source: https://www.cloudflare.com/ips/ | |
| 12 | - | var cloudflareNets = ParseTrustedProxies([]string{ | |
| 14 | + | var cloudflareNets = httphelper.ParseTrustedProxies([]string{ | |
| 13 | 15 | // IPv4 | |
| 14 | 16 | "173.245.48.0/20", | |
| 15 | 17 | "103.21.244.0/22", | |
| @@ -57,45 +59,20 @@ func normalizeIP(raw string) string { | |||
| 57 | 59 | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | |
| 58 | 60 | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | |
| 59 | 61 | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | |
| 62 | + | // | |
| 63 | + | // Deprecated: use httphelper.ParseTrustedProxies directly. This wrapper preserves | |
| 64 | + | // the existing middleware API for callers that have not migrated yet. | |
| 60 | 65 | func ParseTrustedProxies(proxies []string) []*net.IPNet { | |
| 61 | - | var nets []*net.IPNet | |
| 62 | - | for _, proxy := range proxies { | |
| 63 | - | // Try parsing as CIDR | |
| 64 | - | _, ipNet, err := net.ParseCIDR(proxy) | |
| 65 | - | if err == nil { | |
| 66 | - | nets = append(nets, ipNet) | |
| 67 | - | continue | |
| 68 | - | } | |
| 69 | - | // Try parsing as single IP | |
| 70 | - | ip := net.ParseIP(proxy) | |
| 71 | - | if ip != nil { | |
| 72 | - | // Convert single IP to /32 or /128 CIDR | |
| 73 | - | bits := 32 | |
| 74 | - | if ip.To4() == nil { | |
| 75 | - | bits = 128 | |
| 76 | - | } | |
| 77 | - | nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | return nets | |
| 66 | + | return httphelper.ParseTrustedProxies(proxies) | |
| 81 | 67 | } | |
| 82 | 68 | ||
| 83 | 69 | // IsTrustedProxy checks if the given IP address is from a trusted proxy. | |
| 84 | 70 | // Returns false if trustedNets is empty or the IP cannot be parsed. | |
| 71 | + | // | |
| 72 | + | // Deprecated: use httphelper.IsTrustedProxy directly. This wrapper preserves | |
| 73 | + | // the existing middleware API for callers that have not migrated yet. | |
| 85 | 74 | func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool { | |
| 86 | - | if len(trustedNets) == 0 { | |
| 87 | - | return false | |
| 88 | - | } | |
| 89 | - | parsedIP := net.ParseIP(ip) | |
| 90 | - | if parsedIP == nil { | |
| 91 | - | return false | |
| 92 | - | } | |
| 93 | - | for _, ipNet := range trustedNets { | |
| 94 | - | if ipNet.Contains(parsedIP) { | |
| 95 | - | return true | |
| 96 | - | } | |
| 97 | - | } | |
| 98 | - | return false | |
| 75 | + | return httphelper.IsTrustedProxy(ip, trustedNets) | |
| 99 | 76 | } | |
| 100 | 77 | ||
| 101 | 78 | // GetClientIP extracts the client IP address from the request. | |
| @@ -4,12 +4,14 @@ import ( | |||||
| 4 | "net" | 4 | "net" | ||
| 5 | "net/http" | 5 | "net/http" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| 9 | // cloudflareNets contains Cloudflare's published IPv4 and IPv6 ranges. | 11 | // cloudflareNets contains Cloudflare's published IPv4 and IPv6 ranges. | ||
| 10 | // Cf-Connecting-Ip is only trusted when the immediate upstream matches these. | 12 | // Cf-Connecting-Ip is only trusted when the immediate upstream matches these. | ||
| 11 | // Source: https://www.cloudflare.com/ips/ | 13 | // Source: https://www.cloudflare.com/ips/ | ||
| 12 | - | var cloudflareNets = ParseTrustedProxies([]string{ | 14 | + | var cloudflareNets = httphelper.ParseTrustedProxies([]string{ |
| 13 | // IPv4 | 15 | // IPv4 | ||
| 14 | "173.245.48.0/20", | 16 | "173.245.48.0/20", | ||
| 15 | "103.21.244.0/22", | 17 | "103.21.244.0/22", | ||
| @@ -57,45 +59,20 @@ func normalizeIP(raw string) string { | |||||
| 57 | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | 59 | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | ||
| 58 | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | 60 | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | ||
| 59 | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | 61 | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | ||
| 62 | + | // | |||
| 63 | + | // Deprecated: use httphelper.ParseTrustedProxies directly. This wrapper preserves | |||
| 64 | + | // the existing middleware API for callers that have not migrated yet. | |||
| 60 | func ParseTrustedProxies(proxies []string) []*net.IPNet { | 65 | func ParseTrustedProxies(proxies []string) []*net.IPNet { | ||
| 61 | - | var nets []*net.IPNet | 66 | + | return httphelper.ParseTrustedProxies(proxies) |
| 62 | - | for _, proxy := range proxies { | |||
| 63 | - | // Try parsing as CIDR | |||
| 64 | - | _, ipNet, err := net.ParseCIDR(proxy) | |||
| 65 | - | if err == nil { | |||
| 66 | - | nets = append(nets, ipNet) | |||
| 67 | - | continue | |||
| 68 | - | } | |||
| 69 | - | // Try parsing as single IP | |||
| 70 | - | ip := net.ParseIP(proxy) | |||
| 71 | - | if ip != nil { | |||
| 72 | - | // Convert single IP to /32 or /128 CIDR | |||
| 73 | - | bits := 32 | |||
| 74 | - | if ip.To4() == nil { | |||
| 75 | - | bits = 128 | |||
| 76 | - | } | |||
| 77 | - | nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) | |||
| 78 | - | } | |||
| 79 | - | } | |||
| 80 | - | return nets | |||
| 81 | } | 67 | } | ||
| 82 | 68 | ||||
| 83 | // IsTrustedProxy checks if the given IP address is from a trusted proxy. | 69 | // IsTrustedProxy checks if the given IP address is from a trusted proxy. | ||
| 84 | // Returns false if trustedNets is empty or the IP cannot be parsed. | 70 | // Returns false if trustedNets is empty or the IP cannot be parsed. | ||
| 71 | + | // | |||
| 72 | + | // Deprecated: use httphelper.IsTrustedProxy directly. This wrapper preserves | |||
| 73 | + | // the existing middleware API for callers that have not migrated yet. | |||
| 85 | func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool { | 74 | func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool { | ||
| 86 | - | if len(trustedNets) == 0 { | 75 | + | return httphelper.IsTrustedProxy(ip, trustedNets) |
| 87 | - | return false | |||
| 88 | - | } | |||
| 89 | - | parsedIP := net.ParseIP(ip) | |||
| 90 | - | if parsedIP == nil { | |||
| 91 | - | return false | |||
| 92 | - | } | |||
| 93 | - | for _, ipNet := range trustedNets { | |||
| 94 | - | if ipNet.Contains(parsedIP) { | |||
| 95 | - | return true | |||
| 96 | - | } | |||
| 97 | - | } | |||
| 98 | - | return false | |||
| 99 | } | 76 | } | ||
| 100 | 77 | ||||
| 101 | // GetClientIP extracts the client IP address from the request. | 78 | // GetClientIP extracts the client IP address from the request. | ||
M internal/adapters/in/http/onboarding/handler.go +2 -2
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "net" | |
| 7 | 7 | "net/http" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 9 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | // Handler serves CA onboarding endpoints for direct/Tailnet clients. | |
| @@ -49,7 +49,7 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) { | |||
| 49 | 49 | // publicHTTPSURL derives the public HTTPS URL from the request Host header. | |
| 50 | 50 | // The result is HTML-escaped to prevent XSS via a crafted Host header. | |
| 51 | 51 | func (h *Handler) publicHTTPSURL(r *http.Request) string { | |
| 52 | - | authority := middleware.HTTPSAuthority(r.Host, h.httpPort, h.tlsPort) | |
| 52 | + | authority := httphelper.HTTPSAuthority(r.Host, h.httpPort, h.tlsPort) | |
| 53 | 53 | return html.EscapeString("https://" + authority + "/") | |
| 54 | 54 | } | |
| 55 | 55 | ||
| @@ -6,7 +6,7 @@ import ( | |||||
| 6 | "net" | 6 | "net" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | 8 | ||||
| 9 | - | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | 9 | + | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" |
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | // Handler serves CA onboarding endpoints for direct/Tailnet clients. | 12 | // Handler serves CA onboarding endpoints for direct/Tailnet clients. | ||
| @@ -49,7 +49,7 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) { | |||||
| 49 | // publicHTTPSURL derives the public HTTPS URL from the request Host header. | 49 | // publicHTTPSURL derives the public HTTPS URL from the request Host header. | ||
| 50 | // 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. | ||
| 51 | func (h *Handler) publicHTTPSURL(r *http.Request) string { | 51 | func (h *Handler) publicHTTPSURL(r *http.Request) string { | ||
| 52 | - | authority := middleware.HTTPSAuthority(r.Host, h.httpPort, h.tlsPort) | 52 | + | authority := httphelper.HTTPSAuthority(r.Host, h.httpPort, h.tlsPort) |
| 53 | return html.EscapeString("https://" + authority + "/") | 53 | return html.EscapeString("https://" + authority + "/") | ||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
M internal/adapters/in/http/proxy/forwarder.go +2 -11
| @@ -15,7 +15,7 @@ import ( | |||
| 15 | 15 | ||
| 16 | 16 | "github.com/bnema/zerowrap" | |
| 17 | 17 | ||
| 18 | - | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 18 | + | gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 19 | 19 | "github.com/bnema/gordon/internal/domain" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| @@ -214,16 +214,7 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou | |||
| 214 | 214 | ||
| 215 | 215 | // isTrustedSource reports whether the request's remote address is in trustedNets. | |
| 216 | 216 | func isTrustedSource(r *http.Request, trustedNets []*net.IPNet) bool { | |
| 217 | - | if len(trustedNets) == 0 { | |
| 218 | - | return false | |
| 219 | - | } | |
| 220 | - | host, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 221 | - | if err != nil { | |
| 222 | - | // RemoteAddr may be a bare IP without a port (e.g. from Unix sockets | |
| 223 | - | // or certain proxy setups). Fall back to using it directly. | |
| 224 | - | host = strings.TrimSuffix(strings.TrimPrefix(r.RemoteAddr, "["), "]") | |
| 225 | - | } | |
| 226 | - | return middleware.IsTrustedProxy(host, trustedNets) | |
| 217 | + | return gordonhttp.IsTrustedSource(r, trustedNets) | |
| 227 | 218 | } | |
| 228 | 219 | ||
| 229 | 220 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | |
| @@ -15,7 +15,7 @@ import ( | |||||
| 15 | 15 | ||||
| 16 | "github.com/bnema/zerowrap" | 16 | "github.com/bnema/zerowrap" | ||
| 17 | 17 | ||||
| 18 | - | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | 18 | + | gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper" |
| 19 | "github.com/bnema/gordon/internal/domain" | 19 | "github.com/bnema/gordon/internal/domain" | ||
| 20 | ) | 20 | ) | ||
| 21 | 21 | ||||
| @@ -214,16 +214,7 @@ func newReverseProxy(targetURL *url.URL, originalHost string, transport http.Rou | |||||
| 214 | 214 | ||||
| 215 | // isTrustedSource reports whether the request's remote address is in trustedNets. | 215 | // isTrustedSource reports whether the request's remote address is in trustedNets. | ||
| 216 | func isTrustedSource(r *http.Request, trustedNets []*net.IPNet) bool { | 216 | func isTrustedSource(r *http.Request, trustedNets []*net.IPNet) bool { | ||
| 217 | - | if len(trustedNets) == 0 { | 217 | + | return gordonhttp.IsTrustedSource(r, trustedNets) |
| 218 | - | return false | |||
| 219 | - | } | |||
| 220 | - | host, _, err := net.SplitHostPort(r.RemoteAddr) | |||
| 221 | - | if err != nil { | |||
| 222 | - | // RemoteAddr may be a bare IP without a port (e.g. from Unix sockets | |||
| 223 | - | // or certain proxy setups). Fall back to using it directly. | |||
| 224 | - | host = strings.TrimSuffix(strings.TrimPrefix(r.RemoteAddr, "["), "]") | |||
| 225 | - | } | |||
| 226 | - | return middleware.IsTrustedProxy(host, trustedNets) | |||
| 227 | } | 218 | } | ||
| 228 | 219 | ||||
| 229 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | 220 | // modifyResponse returns a function that adds proxy headers and enforces response size limits. | ||
M internal/adapters/in/http/proxy/forwarder_test.go +3 -3
| @@ -14,7 +14,7 @@ import ( | |||
| 14 | 14 | "github.com/stretchr/testify/mock" | |
| 15 | 15 | "github.com/stretchr/testify/require" | |
| 16 | 16 | ||
| 17 | - | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 17 | + | gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| 18 | 18 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 19 | 19 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 20 | 20 | "github.com/bnema/gordon/internal/domain" | |
| @@ -201,7 +201,7 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) { | |||
| 201 | 201 | assert.Equal(t, http.StatusOK, w.Code) | |
| 202 | 202 | } | |
| 203 | 203 | func TestNewReverseProxy_XForwardedProto(t *testing.T) { | |
| 204 | - | trustedNets := middleware.ParseTrustedProxies([]string{"10.0.0.0/8"}) | |
| 204 | + | trustedNets := gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8"}) | |
| 205 | 205 | ||
| 206 | 206 | tests := []struct { | |
| 207 | 207 | name string | |
| @@ -250,7 +250,7 @@ func TestNewReverseProxy_XForwardedProto(t *testing.T) { | |||
| 250 | 250 | remoteAddr: "[::1]", | |
| 251 | 251 | incomingProto: "https", | |
| 252 | 252 | wantProto: "https", | |
| 253 | - | nets: middleware.ParseTrustedProxies([]string{"::1/128"}), | |
| 253 | + | nets: gordonhttp.ParseTrustedProxies([]string{"::1/128"}), | |
| 254 | 254 | }, | |
| 255 | 255 | { | |
| 256 | 256 | name: "bare IPv4 no port untrusted source strips proto", | |
| @@ -14,7 +14,7 @@ import ( | |||||
| 14 | "github.com/stretchr/testify/mock" | 14 | "github.com/stretchr/testify/mock" | ||
| 15 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| 16 | 16 | ||||
| 17 | - | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | 17 | + | gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper" |
| 18 | "github.com/bnema/gordon/internal/boundaries/in" | 18 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 19 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | 19 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | ||
| 20 | "github.com/bnema/gordon/internal/domain" | 20 | "github.com/bnema/gordon/internal/domain" | ||
| @@ -201,7 +201,7 @@ func TestForwardToTarget_ProxyHeaderSet(t *testing.T) { | |||||
| 201 | assert.Equal(t, http.StatusOK, w.Code) | 201 | assert.Equal(t, http.StatusOK, w.Code) | ||
| 202 | } | 202 | } | ||
| 203 | func TestNewReverseProxy_XForwardedProto(t *testing.T) { | 203 | func TestNewReverseProxy_XForwardedProto(t *testing.T) { | ||
| 204 | - | trustedNets := middleware.ParseTrustedProxies([]string{"10.0.0.0/8"}) | 204 | + | trustedNets := gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8"}) |
| 205 | 205 | ||||
| 206 | tests := []struct { | 206 | tests := []struct { | ||
| 207 | name string | 207 | name string | ||
| @@ -250,7 +250,7 @@ func TestNewReverseProxy_XForwardedProto(t *testing.T) { | |||||
| 250 | remoteAddr: "[::1]", | 250 | remoteAddr: "[::1]", | ||
| 251 | incomingProto: "https", | 251 | incomingProto: "https", | ||
| 252 | wantProto: "https", | 252 | wantProto: "https", | ||
| 253 | - | nets: middleware.ParseTrustedProxies([]string{"::1/128"}), | 253 | + | nets: gordonhttp.ParseTrustedProxies([]string{"::1/128"}), |
| 254 | }, | 254 | }, | ||
| 255 | { | 255 | { | ||
| 256 | name: "bare IPv4 no port untrusted source strips proto", | 256 | name: "bare IPv4 no port untrusted source strips proto", | ||
M internal/adapters/out/pki/ca.go +10 -0
| @@ -449,6 +449,16 @@ func (ca *CA) IntermediateLifetime() time.Duration { | |||
| 449 | 449 | return intermediateLifetime | |
| 450 | 450 | } | |
| 451 | 451 | ||
| 452 | + | // InstallRoot installs the root CA certificate into the system trust store. | |
| 453 | + | func (ca *CA) InstallRoot(cert *x509.Certificate) error { | |
| 454 | + | return InstallRoot(cert) | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | // UninstallRoot removes the root CA certificate from the system trust store. | |
| 458 | + | func (ca *CA) UninstallRoot(cert *x509.Certificate) error { | |
| 459 | + | return UninstallRoot(cert) | |
| 460 | + | } | |
| 461 | + | ||
| 452 | 462 | func randomSerial() (*big.Int, error) { | |
| 453 | 463 | serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | |
| 454 | 464 | if err != nil { | |
| @@ -449,6 +449,16 @@ func (ca *CA) IntermediateLifetime() time.Duration { | |||||
| 449 | return intermediateLifetime | 449 | return intermediateLifetime | ||
| 450 | } | 450 | } | ||
| 451 | 451 | ||||
| 452 | + | // InstallRoot installs the root CA certificate into the system trust store. | |||
| 453 | + | func (ca *CA) InstallRoot(cert *x509.Certificate) error { | |||
| 454 | + | return InstallRoot(cert) | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | // UninstallRoot removes the root CA certificate from the system trust store. | |||
| 458 | + | func (ca *CA) UninstallRoot(cert *x509.Certificate) error { | |||
| 459 | + | return UninstallRoot(cert) | |||
| 460 | + | } | |||
| 461 | + | ||||
| 452 | func randomSerial() (*big.Int, error) { | 462 | func randomSerial() (*big.Int, error) { | ||
| 453 | serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | 463 | serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | ||
| 454 | if err != nil { | 464 | if err != nil { | ||
M internal/boundaries/out/pki.go +7 -0
| @@ -3,6 +3,7 @@ package out | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/tls" | |
| 6 | + | "crypto/x509" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/gordon/internal/domain" | |
| @@ -49,4 +50,10 @@ type CertificateAuthority interface { | |||
| 49 | 50 | ||
| 50 | 51 | // IntermediateLifetime returns the configured intermediate CA lifetime. | |
| 51 | 52 | IntermediateLifetime() time.Duration | |
| 53 | + | ||
| 54 | + | // InstallRoot installs the root CA certificate into the system trust store. | |
| 55 | + | InstallRoot(cert *x509.Certificate) error | |
| 56 | + | ||
| 57 | + | // UninstallRoot removes the root CA certificate from the system trust store. | |
| 58 | + | UninstallRoot(cert *x509.Certificate) error | |
| 52 | 59 | } | |
| @@ -3,6 +3,7 @@ package out | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "crypto/tls" | 5 | "crypto/tls" | ||
| 6 | + | "crypto/x509" | |||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/gordon/internal/domain" | 9 | "github.com/bnema/gordon/internal/domain" | ||
| @@ -49,4 +50,10 @@ type CertificateAuthority interface { | |||||
| 49 | 50 | ||||
| 50 | // IntermediateLifetime returns the configured intermediate CA lifetime. | 51 | // IntermediateLifetime returns the configured intermediate CA lifetime. | ||
| 51 | IntermediateLifetime() time.Duration | 52 | IntermediateLifetime() time.Duration | ||
| 53 | + | ||||
| 54 | + | // InstallRoot installs the root CA certificate into the system trust store. | |||
| 55 | + | InstallRoot(cert *x509.Certificate) error | |||
| 56 | + | ||||
| 57 | + | // UninstallRoot removes the root CA certificate from the system trust store. | |||
| 58 | + | UninstallRoot(cert *x509.Certificate) error | |||
| 52 | } | 59 | } | ||