Merge pull request #154 from bnema/feat/http-ca-onboarding-hardening
Harden direct HTTP CA onboarding
9 files · +880 -71
A docs/cli/ca.md +124 -0
| @@ -0,0 +1,124 @@ | |||
| 1 | + | # CA Commands | |
| 2 | + | ||
| 3 | + | Manage Gordon's internal Certificate Authority. | |
| 4 | + | ||
| 5 | + | ## gordon ca | |
| 6 | + | ||
| 7 | + | ### Subcommands | |
| 8 | + | ||
| 9 | + | | Subcommand | Description | | |
| 10 | + | |------------|-------------| | |
| 11 | + | | `export` | Export the root CA certificate in PEM format | | |
| 12 | + | | `info` | Show CA status (root CN, fingerprint, intermediate expiry) | | |
| 13 | + | | `install` | Install/uninstall the root CA in system trust stores | | |
| 14 | + | ||
| 15 | + | All subcommands require `server.tls_port` to be non-zero in the Gordon configuration. | |
| 16 | + | ||
| 17 | + | --- | |
| 18 | + | ||
| 19 | + | ## gordon ca export | |
| 20 | + | ||
| 21 | + | Export the root CA certificate for manual trust installation on clients. | |
| 22 | + | ||
| 23 | + | ```bash | |
| 24 | + | gordon ca export # Print PEM to stdout | |
| 25 | + | gordon ca export --out ca.pem # Write to file | |
| 26 | + | ``` | |
| 27 | + | ||
| 28 | + | ### Options | |
| 29 | + | ||
| 30 | + | | Option | Description | | |
| 31 | + | |--------|-------------| | |
| 32 | + | | `--out` | Write certificate to file instead of stdout | | |
| 33 | + | ||
| 34 | + | --- | |
| 35 | + | ||
| 36 | + | ## gordon ca info | |
| 37 | + | ||
| 38 | + | Show CA status information: root common name, SHA-256 fingerprint, and intermediate certificate expiry. | |
| 39 | + | ||
| 40 | + | ```bash | |
| 41 | + | gordon ca info | |
| 42 | + | gordon ca info --json | |
| 43 | + | ``` | |
| 44 | + | ||
| 45 | + | ### Options | |
| 46 | + | ||
| 47 | + | | Option | Description | | |
| 48 | + | |--------|-------------| | |
| 49 | + | | `--json` | Output as JSON | | |
| 50 | + | ||
| 51 | + | ### JSON Output | |
| 52 | + | ||
| 53 | + | ```json | |
| 54 | + | { | |
| 55 | + | "root_cn": "Gordon Internal Root CA", | |
| 56 | + | "fingerprint": "SHA256:AB:CD:...", | |
| 57 | + | "intermediate_expiry": "2026-07-01T12:00:00Z", | |
| 58 | + | "intermediate_ttl": "2160h0m0s" | |
| 59 | + | } | |
| 60 | + | ``` | |
| 61 | + | ||
| 62 | + | --- | |
| 63 | + | ||
| 64 | + | ## gordon ca install | |
| 65 | + | ||
| 66 | + | Install or uninstall the root CA certificate in system, Firefox, and Java trust stores on the **Gordon host machine**. Requires root privileges. | |
| 67 | + | ||
| 68 | + | ```bash | |
| 69 | + | sudo gordon ca install | |
| 70 | + | sudo gordon ca install --uninstall | |
| 71 | + | ``` | |
| 72 | + | ||
| 73 | + | ### Options | |
| 74 | + | ||
| 75 | + | | Option | Description | | |
| 76 | + | |--------|-------------| | |
| 77 | + | | `--uninstall` | Remove the CA from trust stores instead of installing | | |
| 78 | + | | `--json` | Output as JSON | | |
| 79 | + | ||
| 80 | + | > **Important:** This command is for the machine running Gordon, not for arbitrary clients. Remote users should import the certificate through one of the methods below. | |
| 81 | + | ||
| 82 | + | --- | |
| 83 | + | ||
| 84 | + | ## Client Trust Setup | |
| 85 | + | ||
| 86 | + | Clients that connect directly to Gordon's HTTPS port need the root CA certificate in their trust store. Gordon provides the certificate through a browser-accessible onboarding page at `https://<gordon-host>/ca`, or over plain HTTP at `http://<gordon-host>/ca` for first-time setup. | |
| 87 | + | ||
| 88 | + | ### Firefox / Zen (NSS certificate store) | |
| 89 | + | ||
| 90 | + | Firefox and Zen use their own certificate store, separate from the OS. | |
| 91 | + | ||
| 92 | + | 1. Open `https://<gordon-host>/ca.crt` — Firefox will prompt to import the certificate. | |
| 93 | + | 2. Check **Trust this CA to identify websites** and confirm. | |
| 94 | + | ||
| 95 | + | Alternatively, import manually: **Settings > Privacy & Security > Certificates > View Certificates > Import**. | |
| 96 | + | ||
| 97 | + | ### Linux System Trust Store | |
| 98 | + | ||
| 99 | + | ```bash | |
| 100 | + | # Download the certificate | |
| 101 | + | curl -k https://<gordon-host>/ca.crt -o gordon-ca.pem | |
| 102 | + | ||
| 103 | + | # Debian / Ubuntu | |
| 104 | + | sudo cp gordon-ca.pem /usr/local/share/ca-certificates/gordon-ca.crt | |
| 105 | + | sudo update-ca-certificates | |
| 106 | + | ||
| 107 | + | # Fedora / RHEL | |
| 108 | + | sudo cp gordon-ca.pem /etc/pki/ca-trust/source/anchors/gordon-ca.pem | |
| 109 | + | sudo update-ca-trust | |
| 110 | + | ``` | |
| 111 | + | ||
| 112 | + | ### Mobile Devices (iOS / Android) | |
| 113 | + | ||
| 114 | + | Visit `http://<gordon-host>/ca` from the device browser. The onboarding page offers: | |
| 115 | + | ||
| 116 | + | - **iOS:** Download the `.mobileconfig` profile, then install it in **Settings > General > VPN & Device Management**. | |
| 117 | + | - **Android:** Download `ca.crt`, then install it in **Settings > Security > Encryption & credentials > Install a certificate**. | |
| 118 | + | ||
| 119 | + | --- | |
| 120 | + | ||
| 121 | + | ## Related | |
| 122 | + | ||
| 123 | + | - [Server Configuration](../config/server.md) — `tls_port`, `force_https_redirect`, and internal CA settings | |
| 124 | + | - [CLI Commands](./index.md) | |
| @@ -0,0 +1,124 @@ | |||||
| 1 | + | # CA Commands | |||
| 2 | + | ||||
| 3 | + | Manage Gordon's internal Certificate Authority. | |||
| 4 | + | ||||
| 5 | + | ## gordon ca | |||
| 6 | + | ||||
| 7 | + | ### Subcommands | |||
| 8 | + | ||||
| 9 | + | | Subcommand | Description | | |||
| 10 | + | |------------|-------------| | |||
| 11 | + | | `export` | Export the root CA certificate in PEM format | | |||
| 12 | + | | `info` | Show CA status (root CN, fingerprint, intermediate expiry) | | |||
| 13 | + | | `install` | Install/uninstall the root CA in system trust stores | | |||
| 14 | + | ||||
| 15 | + | All subcommands require `server.tls_port` to be non-zero in the Gordon configuration. | |||
| 16 | + | ||||
| 17 | + | --- | |||
| 18 | + | ||||
| 19 | + | ## gordon ca export | |||
| 20 | + | ||||
| 21 | + | Export the root CA certificate for manual trust installation on clients. | |||
| 22 | + | ||||
| 23 | + | ```bash | |||
| 24 | + | gordon ca export # Print PEM to stdout | |||
| 25 | + | gordon ca export --out ca.pem # Write to file | |||
| 26 | + | ``` | |||
| 27 | + | ||||
| 28 | + | ### Options | |||
| 29 | + | ||||
| 30 | + | | Option | Description | | |||
| 31 | + | |--------|-------------| | |||
| 32 | + | | `--out` | Write certificate to file instead of stdout | | |||
| 33 | + | ||||
| 34 | + | --- | |||
| 35 | + | ||||
| 36 | + | ## gordon ca info | |||
| 37 | + | ||||
| 38 | + | Show CA status information: root common name, SHA-256 fingerprint, and intermediate certificate expiry. | |||
| 39 | + | ||||
| 40 | + | ```bash | |||
| 41 | + | gordon ca info | |||
| 42 | + | gordon ca info --json | |||
| 43 | + | ``` | |||
| 44 | + | ||||
| 45 | + | ### Options | |||
| 46 | + | ||||
| 47 | + | | Option | Description | | |||
| 48 | + | |--------|-------------| | |||
| 49 | + | | `--json` | Output as JSON | | |||
| 50 | + | ||||
| 51 | + | ### JSON Output | |||
| 52 | + | ||||
| 53 | + | ```json | |||
| 54 | + | { | |||
| 55 | + | "root_cn": "Gordon Internal Root CA", | |||
| 56 | + | "fingerprint": "SHA256:AB:CD:...", | |||
| 57 | + | "intermediate_expiry": "2026-07-01T12:00:00Z", | |||
| 58 | + | "intermediate_ttl": "2160h0m0s" | |||
| 59 | + | } | |||
| 60 | + | ``` | |||
| 61 | + | ||||
| 62 | + | --- | |||
| 63 | + | ||||
| 64 | + | ## gordon ca install | |||
| 65 | + | ||||
| 66 | + | Install or uninstall the root CA certificate in system, Firefox, and Java trust stores on the **Gordon host machine**. Requires root privileges. | |||
| 67 | + | ||||
| 68 | + | ```bash | |||
| 69 | + | sudo gordon ca install | |||
| 70 | + | sudo gordon ca install --uninstall | |||
| 71 | + | ``` | |||
| 72 | + | ||||
| 73 | + | ### Options | |||
| 74 | + | ||||
| 75 | + | | Option | Description | | |||
| 76 | + | |--------|-------------| | |||
| 77 | + | | `--uninstall` | Remove the CA from trust stores instead of installing | | |||
| 78 | + | | `--json` | Output as JSON | | |||
| 79 | + | ||||
| 80 | + | > **Important:** This command is for the machine running Gordon, not for arbitrary clients. Remote users should import the certificate through one of the methods below. | |||
| 81 | + | ||||
| 82 | + | --- | |||
| 83 | + | ||||
| 84 | + | ## Client Trust Setup | |||
| 85 | + | ||||
| 86 | + | Clients that connect directly to Gordon's HTTPS port need the root CA certificate in their trust store. Gordon provides the certificate through a browser-accessible onboarding page at `https://<gordon-host>/ca`, or over plain HTTP at `http://<gordon-host>/ca` for first-time setup. | |||
| 87 | + | ||||
| 88 | + | ### Firefox / Zen (NSS certificate store) | |||
| 89 | + | ||||
| 90 | + | Firefox and Zen use their own certificate store, separate from the OS. | |||
| 91 | + | ||||
| 92 | + | 1. Open `https://<gordon-host>/ca.crt` — Firefox will prompt to import the certificate. | |||
| 93 | + | 2. Check **Trust this CA to identify websites** and confirm. | |||
| 94 | + | ||||
| 95 | + | Alternatively, import manually: **Settings > Privacy & Security > Certificates > View Certificates > Import**. | |||
| 96 | + | ||||
| 97 | + | ### Linux System Trust Store | |||
| 98 | + | ||||
| 99 | + | ```bash | |||
| 100 | + | # Download the certificate | |||
| 101 | + | curl -k https://<gordon-host>/ca.crt -o gordon-ca.pem | |||
| 102 | + | ||||
| 103 | + | # Debian / Ubuntu | |||
| 104 | + | sudo cp gordon-ca.pem /usr/local/share/ca-certificates/gordon-ca.crt | |||
| 105 | + | sudo update-ca-certificates | |||
| 106 | + | ||||
| 107 | + | # Fedora / RHEL | |||
| 108 | + | sudo cp gordon-ca.pem /etc/pki/ca-trust/source/anchors/gordon-ca.pem | |||
| 109 | + | sudo update-ca-trust | |||
| 110 | + | ``` | |||
| 111 | + | ||||
| 112 | + | ### Mobile Devices (iOS / Android) | |||
| 113 | + | ||||
| 114 | + | Visit `http://<gordon-host>/ca` from the device browser. The onboarding page offers: | |||
| 115 | + | ||||
| 116 | + | - **iOS:** Download the `.mobileconfig` profile, then install it in **Settings > General > VPN & Device Management**. | |||
| 117 | + | - **Android:** Download `ca.crt`, then install it in **Settings > Security > Encryption & credentials > Install a certificate**. | |||
| 118 | + | ||||
| 119 | + | --- | |||
| 120 | + | ||||
| 121 | + | ## Related | |||
| 122 | + | ||||
| 123 | + | - [Server Configuration](../config/server.md) — `tls_port`, `force_https_redirect`, and internal CA settings | |||
| 124 | + | - [CLI Commands](./index.md) | |||
M docs/cli/index.md +1 -0
| @@ -16,6 +16,7 @@ Commands are organized by where they run: | |||
| 16 | 16 | |---------|-------------|---------------| | |
| 17 | 17 | | `gordon serve` | Start the Gordon server | [serve](./serve.md) | | |
| 18 | 18 | | `gordon auth` | Manage Gordon server authentication | [auth](./auth.md) | | |
| 19 | + | | `gordon ca` | Manage the internal Certificate Authority | [ca](./ca.md) | | |
| 19 | 20 | ||
| 20 | 21 | ## Management Commands (local or remote) | |
| 21 | 22 | ||
| @@ -16,6 +16,7 @@ Commands are organized by where they run: | |||||
| 16 | |---------|-------------|---------------| | 16 | |---------|-------------|---------------| | ||
| 17 | | `gordon serve` | Start the Gordon server | [serve](./serve.md) | | 17 | | `gordon serve` | Start the Gordon server | [serve](./serve.md) | | ||
| 18 | | `gordon auth` | Manage Gordon server authentication | [auth](./auth.md) | | 18 | | `gordon auth` | Manage Gordon server authentication | [auth](./auth.md) | | ||
| 19 | + | | `gordon ca` | Manage the internal Certificate Authority | [ca](./ca.md) | | |||
| 19 | 20 | ||||
| 20 | ## Management Commands (local or remote) | 21 | ## Management Commands (local or remote) | ||
| 21 | 22 | ||||
M docs/config/server.md +13 -1
| @@ -81,9 +81,21 @@ tls_key_file = "/etc/gordon/tls/key.pem" | |||
| 81 | 81 | ||
| 82 | 82 | The static certificate is served for SNI-matching domains (including wildcards). All other domains get on-demand certs from the internal CA. | |
| 83 | 83 | ||
| 84 | + | #### Direct HTTP Onboarding | |
| 85 | + | ||
| 86 | + | When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only: | |
| 87 | + | ||
| 88 | + | - `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — serve the onboarding page and CA certificate downloads | |
| 89 | + | - `/.well-known/acme-challenge/*` — returns `404 Not Found` (reserved for future ACME support) | |
| 90 | + | - All other paths return `403 Forbidden` | |
| 91 | + | ||
| 92 | + | This lets new clients discover and trust the internal CA over plain HTTP without exposing the full application. Trusted proxy traffic (e.g. from Cloudflare) continues through the normal HTTP proxy path unaffected. | |
| 93 | + | ||
| 94 | + | The onboarding page is also available on HTTPS at `/ca`. | |
| 95 | + | ||
| 84 | 96 | #### HTTP to HTTPS redirect | |
| 85 | 97 | ||
| 86 | - | When clients connect directly (no Cloudflare or reverse proxy in front), enable `force_https_redirect` to redirect all HTTP traffic to the HTTPS port: | |
| 98 | + | When clients connect directly (no Cloudflare or reverse proxy in front), enable `force_https_redirect` to redirect all HTTP traffic to the HTTPS port. Direct HTTP onboarding paths are still served even with this flag enabled. | |
| 87 | 99 | ||
| 88 | 100 | ```toml | |
| 89 | 101 | [server] | |
| @@ -81,9 +81,21 @@ tls_key_file = "/etc/gordon/tls/key.pem" | |||||
| 81 | 81 | ||||
| 82 | The static certificate is served for SNI-matching domains (including wildcards). All other domains get on-demand certs from the internal CA. | 82 | The static certificate is served for SNI-matching domains (including wildcards). All other domains get on-demand certs from the internal CA. | ||
| 83 | 83 | ||||
| 84 | + | #### Direct HTTP Onboarding | |||
| 85 | + | ||||
| 86 | + | When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only: | |||
| 87 | + | ||||
| 88 | + | - `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — serve the onboarding page and CA certificate downloads | |||
| 89 | + | - `/.well-known/acme-challenge/*` — returns `404 Not Found` (reserved for future ACME support) | |||
| 90 | + | - All other paths return `403 Forbidden` | |||
| 91 | + | ||||
| 92 | + | This lets new clients discover and trust the internal CA over plain HTTP without exposing the full application. Trusted proxy traffic (e.g. from Cloudflare) continues through the normal HTTP proxy path unaffected. | |||
| 93 | + | ||||
| 94 | + | The onboarding page is also available on HTTPS at `/ca`. | |||
| 95 | + | ||||
| 84 | #### HTTP to HTTPS redirect | 96 | #### HTTP to HTTPS redirect | ||
| 85 | 97 | ||||
| 86 | - | When clients connect directly (no Cloudflare or reverse proxy in front), enable `force_https_redirect` to redirect all HTTP traffic to the HTTPS port: | 98 | + | When clients connect directly (no Cloudflare or reverse proxy in front), enable `force_https_redirect` to redirect all HTTP traffic to the HTTPS port. Direct HTTP onboarding paths are still served even with this flag enabled. |
| 87 | 99 | ||||
| 88 | ```toml | 100 | ```toml | ||
| 89 | [server] | 101 | [server] | ||
M internal/adapters/in/http/middleware/cidr.go +50 -20
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "net" | |
| 7 | 7 | "net/http" | |
| 8 | + | "strconv" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/zerowrap" | |
| @@ -50,8 +51,8 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str | |||
| 50 | 51 | } | |
| 51 | 52 | } | |
| 52 | 53 | ||
| 53 | - | // extractRemoteIP returns the IP portion of r.RemoteAddr, stripping the port. | |
| 54 | - | func extractRemoteIP(remoteAddr string) string { | |
| 54 | + | // ExtractRemoteIP returns the IP portion of a RemoteAddr, stripping the port. | |
| 55 | + | func ExtractRemoteIP(remoteAddr string) string { | |
| 55 | 56 | host, _, err := net.SplitHostPort(remoteAddr) | |
| 56 | 57 | if err != nil { | |
| 57 | 58 | return remoteAddr | |
| @@ -59,6 +60,34 @@ func extractRemoteIP(remoteAddr string) string { | |||
| 59 | 60 | return host | |
| 60 | 61 | } | |
| 61 | 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, 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 | + | ||
| 62 | 91 | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | |
| 63 | 92 | // Localhost is always allowed so Gordon can pull from its own registry. | |
| 64 | 93 | // An empty allowedNets slice is a no-op (all traffic passes through). | |
| @@ -74,7 +103,11 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||
| 74 | 103 | // Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost | |
| 75 | 104 | // pass through since they deliver Cloudflare-proxied traffic. | |
| 76 | 105 | // When tlsPort is 0, this is always a no-op. | |
| 77 | - | func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 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. | |
| 110 | + | func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 78 | 111 | return func(next http.Handler) http.Handler { | |
| 79 | 112 | if tlsPort == 0 { | |
| 80 | 113 | return next | |
| @@ -88,27 +121,14 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow | |||
| 88 | 121 | } | |
| 89 | 122 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 90 | 123 | if !forceAll { | |
| 91 | - | remoteIP := extractRemoteIP(r.RemoteAddr) | |
| 92 | - | if IsTrustedProxy(remoteIP, localhostNets) || IsTrustedProxy(remoteIP, proxyNets) { | |
| 124 | + | remoteIP := ExtractRemoteIP(r.RemoteAddr) | |
| 125 | + | if IsTrustedOrLocal(remoteIP, proxyNets) { | |
| 93 | 126 | next.ServeHTTP(w, r) | |
| 94 | 127 | return | |
| 95 | 128 | } | |
| 96 | 129 | } | |
| 97 | 130 | ||
| 98 | - | host := r.Host | |
| 99 | - | if h, _, err := net.SplitHostPort(host); err == nil { | |
| 100 | - | host = h | |
| 101 | - | } | |
| 102 | - | path := r.RequestURI | |
| 103 | - | if !strings.HasPrefix(path, "/") { | |
| 104 | - | path = "/" | |
| 105 | - | } | |
| 106 | - | var target string | |
| 107 | - | if tlsPort == 443 { | |
| 108 | - | target = fmt.Sprintf("https://%s%s", host, path) | |
| 109 | - | } else { | |
| 110 | - | target = fmt.Sprintf("https://%s:%d%s", host, tlsPort, path) | |
| 111 | - | } | |
| 131 | + | target := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort) | |
| 112 | 132 | ||
| 113 | 133 | log.Debug(). | |
| 114 | 134 | Str("target", target). | |
| @@ -119,12 +139,22 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow | |||
| 119 | 139 | } | |
| 120 | 140 | } | |
| 121 | 141 | ||
| 142 | + | // httpsRedirectTarget derives the HTTPS redirect URL from the request Host. | |
| 143 | + | func 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 | + | ||
| 122 | 152 | // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges. | |
| 123 | 153 | // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only | |
| 124 | 154 | // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server. | |
| 125 | 155 | // An empty allowedNets slice is a no-op (all traffic passes through). | |
| 126 | 156 | func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 127 | 157 | return cidrAllowlist(allowedNets, func(r *http.Request) string { | |
| 128 | - | return extractRemoteIP(r.RemoteAddr) | |
| 158 | + | return ExtractRemoteIP(r.RemoteAddr) | |
| 129 | 159 | }, "proxy origin", log) | |
| 130 | 160 | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "net" | 6 | "net" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | + | "strconv" | |||
| 8 | "strings" | 9 | "strings" | ||
| 9 | 10 | ||||
| 10 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| @@ -50,8 +51,8 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str | |||||
| 50 | } | 51 | } | ||
| 51 | } | 52 | } | ||
| 52 | 53 | ||||
| 53 | - | // extractRemoteIP returns the IP portion of r.RemoteAddr, stripping the port. | 54 | + | // ExtractRemoteIP returns the IP portion of a RemoteAddr, stripping the port. |
| 54 | - | func extractRemoteIP(remoteAddr string) string { | 55 | + | func ExtractRemoteIP(remoteAddr string) string { |
| 55 | host, _, err := net.SplitHostPort(remoteAddr) | 56 | host, _, err := net.SplitHostPort(remoteAddr) | ||
| 56 | if err != nil { | 57 | if err != nil { | ||
| 57 | return remoteAddr | 58 | return remoteAddr | ||
| @@ -59,6 +60,34 @@ func extractRemoteIP(remoteAddr string) string { | |||||
| 59 | return host | 60 | return host | ||
| 60 | } | 61 | } | ||
| 61 | 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, 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 | + | ||||
| 62 | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | 91 | // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges. | ||
| 63 | // 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. | ||
| 64 | // 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). | ||
| @@ -74,7 +103,11 @@ func RegistryCIDRAllowlist(allowedNets, trustedNets []*net.IPNet, log zerowrap.L | |||||
| 74 | // Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost | 103 | // Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost | ||
| 75 | // pass through since they deliver Cloudflare-proxied traffic. | 104 | // pass through since they deliver Cloudflare-proxied traffic. | ||
| 76 | // When tlsPort is 0, this is always a no-op. | 105 | // When tlsPort is 0, this is always a no-op. | ||
| 77 | - | func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler { | 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. | |||
| 110 | + | func HTTPSRedirect(proxyNets []*net.IPNet, httpPort, tlsPort int, forceAll bool, log zerowrap.Logger) func(http.Handler) http.Handler { | |||
| 78 | return func(next http.Handler) http.Handler { | 111 | return func(next http.Handler) http.Handler { | ||
| 79 | if tlsPort == 0 { | 112 | if tlsPort == 0 { | ||
| 80 | return next | 113 | return next | ||
| @@ -88,27 +121,14 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow | |||||
| 88 | } | 121 | } | ||
| 89 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 122 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 90 | if !forceAll { | 123 | if !forceAll { | ||
| 91 | - | remoteIP := extractRemoteIP(r.RemoteAddr) | 124 | + | remoteIP := ExtractRemoteIP(r.RemoteAddr) |
| 92 | - | if IsTrustedProxy(remoteIP, localhostNets) || IsTrustedProxy(remoteIP, proxyNets) { | 125 | + | if IsTrustedOrLocal(remoteIP, proxyNets) { |
| 93 | next.ServeHTTP(w, r) | 126 | next.ServeHTTP(w, r) | ||
| 94 | return | 127 | return | ||
| 95 | } | 128 | } | ||
| 96 | } | 129 | } | ||
| 97 | 130 | ||||
| 98 | - | host := r.Host | 131 | + | target := httpsRedirectTarget(r.Host, r.RequestURI, httpPort, tlsPort) |
| 99 | - | if h, _, err := net.SplitHostPort(host); err == nil { | |||
| 100 | - | host = h | |||
| 101 | - | } | |||
| 102 | - | path := r.RequestURI | |||
| 103 | - | if !strings.HasPrefix(path, "/") { | |||
| 104 | - | path = "/" | |||
| 105 | - | } | |||
| 106 | - | var target string | |||
| 107 | - | if tlsPort == 443 { | |||
| 108 | - | target = fmt.Sprintf("https://%s%s", host, path) | |||
| 109 | - | } else { | |||
| 110 | - | target = fmt.Sprintf("https://%s:%d%s", host, tlsPort, path) | |||
| 111 | - | } | |||
| 112 | 132 | ||||
| 113 | log.Debug(). | 133 | log.Debug(). | ||
| 114 | Str("target", target). | 134 | Str("target", target). | ||
| @@ -119,12 +139,22 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow | |||||
| 119 | } | 139 | } | ||
| 120 | } | 140 | } | ||
| 121 | 141 | ||||
| 142 | + | // httpsRedirectTarget derives the HTTPS redirect URL from the request Host. | |||
| 143 | + | func 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 | + | ||||
| 122 | // 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. | ||
| 123 | // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only | 153 | // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only | ||
| 124 | // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server. | 154 | // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server. | ||
| 125 | // 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). | ||
| 126 | 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 { | ||
| 127 | return cidrAllowlist(allowedNets, func(r *http.Request) string { | 157 | return cidrAllowlist(allowedNets, func(r *http.Request) string { | ||
| 128 | - | return extractRemoteIP(r.RemoteAddr) | 158 | + | return ExtractRemoteIP(r.RemoteAddr) |
| 129 | }, "proxy origin", log) | 159 | }, "proxy origin", log) | ||
| 130 | } | 160 | } | ||
M internal/adapters/in/http/middleware/cidr_test.go +97 -0
| @@ -2,6 +2,7 @@ package middleware | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/json" | |
| 5 | + | "fmt" | |
| 5 | 6 | "net" | |
| 6 | 7 | "net/http" | |
| 7 | 8 | "net/http/httptest" | |
| @@ -110,6 +111,102 @@ func TestRegistryCIDRAllowlist(t *testing.T) { | |||
| 110 | 111 | } | |
| 111 | 112 | } | |
| 112 | 113 | ||
| 114 | + | func TestHTTPSRedirect_NoPortHost_OmitsTLSPort(t *testing.T) { | |
| 115 | + | log := testLogger() | |
| 116 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 117 | + | w.WriteHeader(http.StatusOK) | |
| 118 | + | }) | |
| 119 | + | ||
| 120 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |
| 121 | + | srv := httptest.NewServer(handler) | |
| 122 | + | defer srv.Close() | |
| 123 | + | ||
| 124 | + | client := srv.Client() | |
| 125 | + | client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } | |
| 126 | + | ||
| 127 | + | req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil) | |
| 128 | + | req.Host = "o2.bnema.dev" | |
| 129 | + | resp, err := client.Do(req) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | defer resp.Body.Close() | |
| 132 | + | ||
| 133 | + | assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode) | |
| 134 | + | assert.Equal(t, "https://o2.bnema.dev/", resp.Header.Get("Location")) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestHTTPSRedirect_HTTPListenerPort_MapsToTLSPort(t *testing.T) { | |
| 138 | + | log := testLogger() | |
| 139 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 140 | + | w.WriteHeader(http.StatusOK) | |
| 141 | + | }) | |
| 142 | + | ||
| 143 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |
| 144 | + | srv := httptest.NewServer(handler) | |
| 145 | + | defer srv.Close() | |
| 146 | + | ||
| 147 | + | client := srv.Client() | |
| 148 | + | client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } | |
| 149 | + | ||
| 150 | + | req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil) | |
| 151 | + | req.Host = "o2.bnema.dev:8088" | |
| 152 | + | resp, err := client.Do(req) | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | defer resp.Body.Close() | |
| 155 | + | ||
| 156 | + | assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode) | |
| 157 | + | assert.Equal(t, "https://o2.bnema.dev:8443/", resp.Header.Get("Location")) | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) { | |
| 161 | + | log := testLogger() | |
| 162 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 163 | + | w.WriteHeader(http.StatusOK) | |
| 164 | + | }) | |
| 165 | + | ||
| 166 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |
| 167 | + | srv := httptest.NewServer(handler) | |
| 168 | + | defer srv.Close() | |
| 169 | + | ||
| 170 | + | client := srv.Client() | |
| 171 | + | client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } | |
| 172 | + | ||
| 173 | + | req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil) | |
| 174 | + | req.Host = "o2.bnema.dev:9999" | |
| 175 | + | resp, err := client.Do(req) | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | defer resp.Body.Close() | |
| 178 | + | ||
| 179 | + | assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode) | |
| 180 | + | assert.Equal(t, "https://o2.bnema.dev:9999/", resp.Header.Get("Location")) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestHTTPSRedirect_InvalidExplicitPort_IsPreserved(t *testing.T) { | |
| 184 | + | log := testLogger() | |
| 185 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 186 | + | w.WriteHeader(http.StatusOK) | |
| 187 | + | }) | |
| 188 | + | ||
| 189 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |
| 190 | + | srv := httptest.NewServer(handler) | |
| 191 | + | defer srv.Close() | |
| 192 | + | ||
| 193 | + | // Use a raw TCP connection because Go's HTTP client rejects the | |
| 194 | + | // invalid port in the Location header before CheckRedirect fires. | |
| 195 | + | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | defer conn.Close() | |
| 198 | + | ||
| 199 | + | _, err = fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: o2.bnema.dev:abcd\r\nConnection: close\r\n\r\n") | |
| 200 | + | require.NoError(t, err) | |
| 201 | + | ||
| 202 | + | var buf [4096]byte | |
| 203 | + | n, _ := conn.Read(buf[:]) | |
| 204 | + | raw := string(buf[:n]) | |
| 205 | + | ||
| 206 | + | assert.Contains(t, raw, "308") | |
| 207 | + | assert.Contains(t, raw, "Location: https://o2.bnema.dev:abcd/") | |
| 208 | + | } | |
| 209 | + | ||
| 113 | 210 | func TestProxyCIDRAllowlist(t *testing.T) { | |
| 114 | 211 | log := testLogger() | |
| 115 | 212 | redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| @@ -2,6 +2,7 @@ package middleware | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "encoding/json" | 4 | "encoding/json" | ||
| 5 | + | "fmt" | |||
| 5 | "net" | 6 | "net" | ||
| 6 | "net/http" | 7 | "net/http" | ||
| 7 | "net/http/httptest" | 8 | "net/http/httptest" | ||
| @@ -110,6 +111,102 @@ func TestRegistryCIDRAllowlist(t *testing.T) { | |||||
| 110 | } | 111 | } | ||
| 111 | } | 112 | } | ||
| 112 | 113 | ||||
| 114 | + | func TestHTTPSRedirect_NoPortHost_OmitsTLSPort(t *testing.T) { | |||
| 115 | + | log := testLogger() | |||
| 116 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 117 | + | w.WriteHeader(http.StatusOK) | |||
| 118 | + | }) | |||
| 119 | + | ||||
| 120 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |||
| 121 | + | srv := httptest.NewServer(handler) | |||
| 122 | + | defer srv.Close() | |||
| 123 | + | ||||
| 124 | + | client := srv.Client() | |||
| 125 | + | client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } | |||
| 126 | + | ||||
| 127 | + | req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil) | |||
| 128 | + | req.Host = "o2.bnema.dev" | |||
| 129 | + | resp, err := client.Do(req) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | defer resp.Body.Close() | |||
| 132 | + | ||||
| 133 | + | assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode) | |||
| 134 | + | assert.Equal(t, "https://o2.bnema.dev/", resp.Header.Get("Location")) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestHTTPSRedirect_HTTPListenerPort_MapsToTLSPort(t *testing.T) { | |||
| 138 | + | log := testLogger() | |||
| 139 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 140 | + | w.WriteHeader(http.StatusOK) | |||
| 141 | + | }) | |||
| 142 | + | ||||
| 143 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |||
| 144 | + | srv := httptest.NewServer(handler) | |||
| 145 | + | defer srv.Close() | |||
| 146 | + | ||||
| 147 | + | client := srv.Client() | |||
| 148 | + | client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } | |||
| 149 | + | ||||
| 150 | + | req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil) | |||
| 151 | + | req.Host = "o2.bnema.dev:8088" | |||
| 152 | + | resp, err := client.Do(req) | |||
| 153 | + | require.NoError(t, err) | |||
| 154 | + | defer resp.Body.Close() | |||
| 155 | + | ||||
| 156 | + | assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode) | |||
| 157 | + | assert.Equal(t, "https://o2.bnema.dev:8443/", resp.Header.Get("Location")) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func TestHTTPSRedirect_UnknownExplicitPort_IsPreserved(t *testing.T) { | |||
| 161 | + | log := testLogger() | |||
| 162 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 163 | + | w.WriteHeader(http.StatusOK) | |||
| 164 | + | }) | |||
| 165 | + | ||||
| 166 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |||
| 167 | + | srv := httptest.NewServer(handler) | |||
| 168 | + | defer srv.Close() | |||
| 169 | + | ||||
| 170 | + | client := srv.Client() | |||
| 171 | + | client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } | |||
| 172 | + | ||||
| 173 | + | req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil) | |||
| 174 | + | req.Host = "o2.bnema.dev:9999" | |||
| 175 | + | resp, err := client.Do(req) | |||
| 176 | + | require.NoError(t, err) | |||
| 177 | + | defer resp.Body.Close() | |||
| 178 | + | ||||
| 179 | + | assert.Equal(t, http.StatusPermanentRedirect, resp.StatusCode) | |||
| 180 | + | assert.Equal(t, "https://o2.bnema.dev:9999/", resp.Header.Get("Location")) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestHTTPSRedirect_InvalidExplicitPort_IsPreserved(t *testing.T) { | |||
| 184 | + | log := testLogger() | |||
| 185 | + | ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 186 | + | w.WriteHeader(http.StatusOK) | |||
| 187 | + | }) | |||
| 188 | + | ||||
| 189 | + | handler := HTTPSRedirect(nil, 8088, 8443, true, log)(ok) | |||
| 190 | + | srv := httptest.NewServer(handler) | |||
| 191 | + | defer srv.Close() | |||
| 192 | + | ||||
| 193 | + | // Use a raw TCP connection because Go's HTTP client rejects the | |||
| 194 | + | // invalid port in the Location header before CheckRedirect fires. | |||
| 195 | + | conn, err := net.Dial("tcp", srv.Listener.Addr().String()) | |||
| 196 | + | require.NoError(t, err) | |||
| 197 | + | defer conn.Close() | |||
| 198 | + | ||||
| 199 | + | _, err = fmt.Fprintf(conn, "GET / HTTP/1.1\r\nHost: o2.bnema.dev:abcd\r\nConnection: close\r\n\r\n") | |||
| 200 | + | require.NoError(t, err) | |||
| 201 | + | ||||
| 202 | + | var buf [4096]byte | |||
| 203 | + | n, _ := conn.Read(buf[:]) | |||
| 204 | + | raw := string(buf[:n]) | |||
| 205 | + | ||||
| 206 | + | assert.Contains(t, raw, "308") | |||
| 207 | + | assert.Contains(t, raw, "Location: https://o2.bnema.dev:abcd/") | |||
| 208 | + | } | |||
| 209 | + | ||||
| 113 | func TestProxyCIDRAllowlist(t *testing.T) { | 210 | func TestProxyCIDRAllowlist(t *testing.T) { | ||
| 114 | log := testLogger() | 211 | log := testLogger() | ||
| 115 | redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 212 | redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
M internal/adapters/in/http/onboarding/handler.go +61 -27
| @@ -5,21 +5,29 @@ import ( | |||
| 5 | 5 | "html" | |
| 6 | 6 | "net" | |
| 7 | 7 | "net/http" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| 10 | - | // Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients. | |
| 12 | + | // Handler serves CA onboarding endpoints for direct/Tailnet clients. | |
| 11 | 13 | type Handler struct { | |
| 12 | 14 | rootPEM []byte | |
| 13 | 15 | mobileconfig []byte | |
| 16 | + | fingerprint string | |
| 17 | + | httpPort int | |
| 14 | 18 | tlsPort int | |
| 15 | 19 | } | |
| 16 | 20 | ||
| 17 | 21 | // NewHandler creates an onboarding handler. | |
| 18 | - | // tlsPort is the HTTPS port used to build redirect URLs on the onboarding page. | |
| 19 | - | func NewHandler(rootPEM, mobileconfig []byte, tlsPort int) *Handler { | |
| 22 | + | // fingerprint is the SHA-256 fingerprint of the root CA certificate. | |
| 23 | + | // httpPort is the configured HTTP listener port (used to map to tlsPort). | |
| 24 | + | // tlsPort is the configured HTTPS listener port. | |
| 25 | + | func NewHandler(rootPEM, mobileconfig []byte, fingerprint string, httpPort, tlsPort int) *Handler { | |
| 20 | 26 | return &Handler{ | |
| 21 | 27 | rootPEM: rootPEM, | |
| 22 | 28 | mobileconfig: mobileconfig, | |
| 29 | + | fingerprint: fingerprint, | |
| 30 | + | httpPort: httpPort, | |
| 23 | 31 | tlsPort: tlsPort, | |
| 24 | 32 | } | |
| 25 | 33 | } | |
| @@ -38,38 +46,51 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) { | |||
| 38 | 46 | _, _ = w.Write(h.mobileconfig) | |
| 39 | 47 | } | |
| 40 | 48 | ||
| 41 | - | // normalizeHTTPSURL builds an https:// URL from r.Host and the configured TLS port. | |
| 42 | - | // It strips any existing port, applies the configured TLS port (omitting :443), and | |
| 43 | - | // HTML-escapes the result to prevent XSS via a crafted Host header. | |
| 44 | - | func (h *Handler) normalizeHTTPSURL(r *http.Request) string { | |
| 45 | - | hostname := r.Host | |
| 46 | - | if host, _, err := net.SplitHostPort(hostname); err == nil { | |
| 47 | - | hostname = host | |
| 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. | |
| 51 | + | func (h *Handler) publicHTTPSURL(r *http.Request) string { | |
| 52 | + | authority := middleware.HTTPSAuthority(r.Host, h.httpPort, h.tlsPort) | |
| 53 | + | return html.EscapeString("https://" + authority + "/") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // isRawIP reports whether the host (without port) is a raw IP literal. | |
| 57 | + | func isRawIP(hostHeader string) bool { | |
| 58 | + | host, _, err := net.SplitHostPort(hostHeader) | |
| 59 | + | if err != nil { | |
| 60 | + | host = hostHeader | |
| 48 | 61 | } | |
| 49 | - | if h.tlsPort != 0 && h.tlsPort != 443 { | |
| 50 | - | hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort) | |
| 62 | + | if len(host) >= 2 && host[0] == '[' && host[len(host)-1] == ']' { | |
| 63 | + | host = host[1 : len(host)-1] | |
| 51 | 64 | } | |
| 52 | - | return html.EscapeString(fmt.Sprintf("https://%s/", hostname)) | |
| 65 | + | return net.ParseIP(host) != nil | |
| 53 | 66 | } | |
| 54 | 67 | ||
| 55 | 68 | // ServeOnboardingPage serves the CA trust onboarding HTML page. | |
| 56 | - | // Static onboarding page -- kept inline for simplicity. | |
| 57 | 69 | func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) { | |
| 58 | 70 | w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
| 59 | 71 | ||
| 60 | - | redirectURL := h.normalizeHTTPSURL(r) | |
| 72 | + | redirectURL := h.publicHTTPSURL(r) | |
| 73 | + | showSiteLink := !isRawIP(r.Host) | |
| 74 | + | ||
| 75 | + | siteLink := "" | |
| 76 | + | if showSiteLink { | |
| 77 | + | siteLink = fmt.Sprintf( | |
| 78 | + | `<div class="skip"><p>Already installed? <a href="%s">Go to site →</a></p></div>`, | |
| 79 | + | redirectURL, | |
| 80 | + | ) | |
| 81 | + | } | |
| 61 | 82 | ||
| 62 | - | // Cookie is set client-side on click (not on page load) to mark CA installation complete. | |
| 63 | - | // HttpOnly cannot be applied to JS-set cookies -- acceptable since this is a UI hint, not a session token. | |
| 64 | 83 | _, _ = fmt.Fprintf(w, `<!DOCTYPE html> | |
| 65 | 84 | <html lang="en"> | |
| 66 | 85 | <head> | |
| 67 | 86 | <meta charset="utf-8"> | |
| 68 | 87 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 69 | - | <title>Gordon — Install CA Certificate</title> | |
| 88 | + | <title>Gordon — Trust CA Certificate</title> | |
| 70 | 89 | <style> | |
| 71 | 90 | body { font-family: -apple-system, system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; color: #333; } | |
| 72 | 91 | h1 { font-size: 1.5em; } | |
| 92 | + | .fingerprint { background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 12px 16px; margin: 16px 0; font-family: monospace; font-size: 0.85em; word-break: break-all; } | |
| 93 | + | .fingerprint strong { display: block; margin-bottom: 4px; font-family: -apple-system, system-ui, sans-serif; } | |
| 73 | 94 | .platform { background: #f5f5f5; border-radius: 8px; padding: 16px; margin: 12px 0; } | |
| 74 | 95 | .platform h2 { font-size: 1.1em; margin: 0 0 8px; } | |
| 75 | 96 | a.btn { display: inline-block; background: #2563eb; color: white; padding: 10px 20px; border-radius: 6px; text-decoration: none; margin: 4px 0; } | |
| @@ -77,33 +98,46 @@ func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) { | |||
| 77 | 98 | .skip { margin-top: 24px; font-size: 0.9em; } | |
| 78 | 99 | .skip a { color: #666; } | |
| 79 | 100 | code { background: #e5e7eb; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } | |
| 101 | + | .note { margin-top: 20px; font-size: 0.85em; color: #666; } | |
| 80 | 102 | </style> | |
| 81 | 103 | </head> | |
| 82 | 104 | <body> | |
| 83 | - | <h1>Gordon — Secure Access Setup</h1> | |
| 84 | - | <p>To access this service over HTTPS, install Gordon's CA certificate on your device. This is a one-time step.</p> | |
| 105 | + | <h1>Gordon — Trust CA Certificate</h1> | |
| 106 | + | <p>This Gordon instance uses its own internal CA for HTTPS. Import the CA certificate into your device or browser to connect securely.</p> | |
| 107 | + | ||
| 108 | + | <div class="fingerprint"> | |
| 109 | + | <strong>CA Fingerprint (SHA-256)</strong> | |
| 110 | + | %s | |
| 111 | + | </div> | |
| 112 | + | <p style="font-size:0.85em;color:#666;">Verify this fingerprint matches the output of <code>gordon ca info</code> on the server.</p> | |
| 85 | 113 | ||
| 86 | 114 | <div class="platform" id="ios"> | |
| 87 | 115 | <h2>iOS / iPadOS</h2> | |
| 88 | - | <p><a class="btn" href="/ca.mobileconfig">Install Profile</a></p> | |
| 89 | - | <p>After downloading, go to <strong>Settings → General → VPN & Device Management</strong> to complete installation, then <strong>Settings → General → About → Certificate Trust Settings</strong> to enable full trust.</p> | |
| 116 | + | <p><a class="btn" href="/ca.mobileconfig">Download Profile</a></p> | |
| 117 | + | <p>After downloading, go to <strong>Settings → General → VPN & Device Management</strong> to install, then <strong>Settings → General → About → Certificate Trust Settings</strong> to enable full trust.</p> | |
| 90 | 118 | </div> | |
| 91 | 119 | ||
| 92 | 120 | <div class="platform" id="android"> | |
| 93 | 121 | <h2>Android</h2> | |
| 94 | 122 | <p><a class="btn" href="/ca.crt">Download Certificate</a></p> | |
| 95 | - | <p>After downloading, go to <strong>Settings → Security → Install a certificate → CA certificate</strong>.</p> | |
| 123 | + | <p>Go to <strong>Settings → Security → Install a certificate → CA certificate</strong> and select the downloaded file.</p> | |
| 96 | 124 | </div> | |
| 97 | 125 | ||
| 98 | 126 | <div class="platform" id="desktop"> | |
| 99 | 127 | <h2>macOS / Linux / Windows</h2> | |
| 100 | 128 | <p><a class="btn" href="/ca.crt">Download Certificate</a></p> | |
| 101 | - | <p>Or run on the Gordon host: <code>sudo gordon ca install</code></p> | |
| 129 | + | <p>Double-click the downloaded file and add it to your system trust store, or import it via your OS certificate manager.</p> | |
| 102 | 130 | </div> | |
| 103 | 131 | ||
| 104 | - | <div class="skip"> | |
| 105 | - | <p>Already installed? <a href="%s" onclick="document.cookie='gordon-ca-installed=1;path=/;max-age=315360000;SameSite=Lax;Secure'">Go to site →</a></p> | |
| 132 | + | <div class="platform" id="firefox"> | |
| 133 | + | <h2>Firefox / Zen</h2> | |
| 134 | + | <p><a class="btn" href="/ca.crt">Download Certificate</a></p> | |
| 135 | + | <p>Firefox and Zen use their own certificate store (NSS). Go to <strong>Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import</strong> and select the downloaded file. Check <strong>Trust this CA to identify websites</strong>.</p> | |
| 106 | 136 | </div> | |
| 137 | + | ||
| 138 | + | %s | |
| 139 | + | ||
| 140 | + | <p class="note">The <code>gordon ca install</code> command is for the Gordon server host only, not for client devices.</p> | |
| 107 | 141 | </body> | |
| 108 | - | </html>`, redirectURL) | |
| 142 | + | </html>`, html.EscapeString(h.fingerprint), siteLink) | |
| 109 | 143 | } | |
| @@ -5,21 +5,29 @@ import ( | |||||
| 5 | "html" | 5 | "html" | ||
| 6 | "net" | 6 | "net" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/adapters/in/http/middleware" | |||
| 8 | ) | 10 | ) | ||
| 9 | 11 | ||||
| 10 | - | // Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients. | 12 | + | // Handler serves CA onboarding endpoints for direct/Tailnet clients. |
| 11 | type Handler struct { | 13 | type Handler struct { | ||
| 12 | rootPEM []byte | 14 | rootPEM []byte | ||
| 13 | mobileconfig []byte | 15 | mobileconfig []byte | ||
| 16 | + | fingerprint string | |||
| 17 | + | httpPort int | |||
| 14 | tlsPort int | 18 | tlsPort int | ||
| 15 | } | 19 | } | ||
| 16 | 20 | ||||
| 17 | // NewHandler creates an onboarding handler. | 21 | // NewHandler creates an onboarding handler. | ||
| 18 | - | // tlsPort is the HTTPS port used to build redirect URLs on the onboarding page. | 22 | + | // fingerprint is the SHA-256 fingerprint of the root CA certificate. |
| 19 | - | func NewHandler(rootPEM, mobileconfig []byte, tlsPort int) *Handler { | 23 | + | // httpPort is the configured HTTP listener port (used to map to tlsPort). |
| 24 | + | // tlsPort is the configured HTTPS listener port. | |||
| 25 | + | func NewHandler(rootPEM, mobileconfig []byte, fingerprint string, httpPort, tlsPort int) *Handler { | |||
| 20 | return &Handler{ | 26 | return &Handler{ | ||
| 21 | rootPEM: rootPEM, | 27 | rootPEM: rootPEM, | ||
| 22 | mobileconfig: mobileconfig, | 28 | mobileconfig: mobileconfig, | ||
| 29 | + | fingerprint: fingerprint, | |||
| 30 | + | httpPort: httpPort, | |||
| 23 | tlsPort: tlsPort, | 31 | tlsPort: tlsPort, | ||
| 24 | } | 32 | } | ||
| 25 | } | 33 | } | ||
| @@ -38,38 +46,51 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) { | |||||
| 38 | _, _ = w.Write(h.mobileconfig) | 46 | _, _ = w.Write(h.mobileconfig) | ||
| 39 | } | 47 | } | ||
| 40 | 48 | ||||
| 41 | - | // normalizeHTTPSURL builds an https:// URL from r.Host and the configured TLS port. | 49 | + | // publicHTTPSURL derives the public HTTPS URL from the request Host header. |
| 42 | - | // It strips any existing port, applies the configured TLS port (omitting :443), and | 50 | + | // The result is HTML-escaped to prevent XSS via a crafted Host header. |
| 43 | - | // HTML-escapes the result to prevent XSS via a crafted Host header. | 51 | + | func (h *Handler) publicHTTPSURL(r *http.Request) string { |
| 44 | - | func (h *Handler) normalizeHTTPSURL(r *http.Request) string { | 52 | + | authority := middleware.HTTPSAuthority(r.Host, h.httpPort, h.tlsPort) |
| 45 | - | hostname := r.Host | 53 | + | return html.EscapeString("https://" + authority + "/") |
| 46 | - | if host, _, err := net.SplitHostPort(hostname); err == nil { | 54 | + | } |
| 47 | - | hostname = host | 55 | + | |
| 56 | + | // isRawIP reports whether the host (without port) is a raw IP literal. | |||
| 57 | + | func isRawIP(hostHeader string) bool { | |||
| 58 | + | host, _, err := net.SplitHostPort(hostHeader) | |||
| 59 | + | if err != nil { | |||
| 60 | + | host = hostHeader | |||
| 48 | } | 61 | } | ||
| 49 | - | if h.tlsPort != 0 && h.tlsPort != 443 { | 62 | + | if len(host) >= 2 && host[0] == '[' && host[len(host)-1] == ']' { |
| 50 | - | hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort) | 63 | + | host = host[1 : len(host)-1] |
| 51 | } | 64 | } | ||
| 52 | - | return html.EscapeString(fmt.Sprintf("https://%s/", hostname)) | 65 | + | return net.ParseIP(host) != nil |
| 53 | } | 66 | } | ||
| 54 | 67 | ||||
| 55 | // ServeOnboardingPage serves the CA trust onboarding HTML page. | 68 | // ServeOnboardingPage serves the CA trust onboarding HTML page. | ||
| 56 | - | // Static onboarding page -- kept inline for simplicity. | |||
| 57 | func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) { | 69 | func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) { | ||
| 58 | w.Header().Set("Content-Type", "text/html; charset=utf-8") | 70 | w.Header().Set("Content-Type", "text/html; charset=utf-8") | ||
| 59 | 71 | ||||
| 60 | - | redirectURL := h.normalizeHTTPSURL(r) | 72 | + | redirectURL := h.publicHTTPSURL(r) |
| 73 | + | showSiteLink := !isRawIP(r.Host) | |||
| 74 | + | ||||
| 75 | + | siteLink := "" | |||
| 76 | + | if showSiteLink { | |||
| 77 | + | siteLink = fmt.Sprintf( | |||
| 78 | + | `<div class="skip"><p>Already installed? <a href="%s">Go to site →</a></p></div>`, | |||
| 79 | + | redirectURL, | |||
| 80 | + | ) | |||
| 81 | + | } | |||
| 61 | 82 | ||||
| 62 | - | // Cookie is set client-side on click (not on page load) to mark CA installation complete. | |||
| 63 | - | // HttpOnly cannot be applied to JS-set cookies -- acceptable since this is a UI hint, not a session token. | |||
| 64 | _, _ = fmt.Fprintf(w, `<!DOCTYPE html> | 83 | _, _ = fmt.Fprintf(w, `<!DOCTYPE html> | ||
| 65 | <html lang="en"> | 84 | <html lang="en"> | ||
| 66 | <head> | 85 | <head> | ||
| 67 | <meta charset="utf-8"> | 86 | <meta charset="utf-8"> | ||
| 68 | <meta name="viewport" content="width=device-width, initial-scale=1"> | 87 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 69 | - | <title>Gordon — Install CA Certificate</title> | 88 | + | <title>Gordon — Trust CA Certificate</title> |
| 70 | <style> | 89 | <style> | ||
| 71 | body { font-family: -apple-system, system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; color: #333; } | 90 | body { font-family: -apple-system, system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; color: #333; } | ||
| 72 | h1 { font-size: 1.5em; } | 91 | h1 { font-size: 1.5em; } | ||
| 92 | + | .fingerprint { background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 12px 16px; margin: 16px 0; font-family: monospace; font-size: 0.85em; word-break: break-all; } | |||
| 93 | + | .fingerprint strong { display: block; margin-bottom: 4px; font-family: -apple-system, system-ui, sans-serif; } | |||
| 73 | .platform { background: #f5f5f5; border-radius: 8px; padding: 16px; margin: 12px 0; } | 94 | .platform { background: #f5f5f5; border-radius: 8px; padding: 16px; margin: 12px 0; } | ||
| 74 | .platform h2 { font-size: 1.1em; margin: 0 0 8px; } | 95 | .platform h2 { font-size: 1.1em; margin: 0 0 8px; } | ||
| 75 | a.btn { display: inline-block; background: #2563eb; color: white; padding: 10px 20px; border-radius: 6px; text-decoration: none; margin: 4px 0; } | 96 | a.btn { display: inline-block; background: #2563eb; color: white; padding: 10px 20px; border-radius: 6px; text-decoration: none; margin: 4px 0; } | ||
| @@ -77,33 +98,46 @@ func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) { | |||||
| 77 | .skip { margin-top: 24px; font-size: 0.9em; } | 98 | .skip { margin-top: 24px; font-size: 0.9em; } | ||
| 78 | .skip a { color: #666; } | 99 | .skip a { color: #666; } | ||
| 79 | code { background: #e5e7eb; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } | 100 | code { background: #e5e7eb; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; } | ||
| 101 | + | .note { margin-top: 20px; font-size: 0.85em; color: #666; } | |||
| 80 | </style> | 102 | </style> | ||
| 81 | </head> | 103 | </head> | ||
| 82 | <body> | 104 | <body> | ||
| 83 | - | <h1>Gordon — Secure Access Setup</h1> | 105 | + | <h1>Gordon — Trust CA Certificate</h1> |
| 84 | - | <p>To access this service over HTTPS, install Gordon's CA certificate on your device. This is a one-time step.</p> | 106 | + | <p>This Gordon instance uses its own internal CA for HTTPS. Import the CA certificate into your device or browser to connect securely.</p> |
| 107 | + | ||||
| 108 | + | <div class="fingerprint"> | |||
| 109 | + | <strong>CA Fingerprint (SHA-256)</strong> | |||
| 110 | + | %s | |||
| 111 | + | </div> | |||
| 112 | + | <p style="font-size:0.85em;color:#666;">Verify this fingerprint matches the output of <code>gordon ca info</code> on the server.</p> | |||
| 85 | 113 | ||||
| 86 | <div class="platform" id="ios"> | 114 | <div class="platform" id="ios"> | ||
| 87 | <h2>iOS / iPadOS</h2> | 115 | <h2>iOS / iPadOS</h2> | ||
| 88 | - | <p><a class="btn" href="/ca.mobileconfig">Install Profile</a></p> | 116 | + | <p><a class="btn" href="/ca.mobileconfig">Download Profile</a></p> |
| 89 | - | <p>After downloading, go to <strong>Settings → General → VPN & Device Management</strong> to complete installation, then <strong>Settings → General → About → Certificate Trust Settings</strong> to enable full trust.</p> | 117 | + | <p>After downloading, go to <strong>Settings → General → VPN & Device Management</strong> to install, then <strong>Settings → General → About → Certificate Trust Settings</strong> to enable full trust.</p> |
| 90 | </div> | 118 | </div> | ||
| 91 | 119 | ||||
| 92 | <div class="platform" id="android"> | 120 | <div class="platform" id="android"> | ||
| 93 | <h2>Android</h2> | 121 | <h2>Android</h2> | ||
| 94 | <p><a class="btn" href="/ca.crt">Download Certificate</a></p> | 122 | <p><a class="btn" href="/ca.crt">Download Certificate</a></p> | ||
| 95 | - | <p>After downloading, go to <strong>Settings → Security → Install a certificate → CA certificate</strong>.</p> | 123 | + | <p>Go to <strong>Settings → Security → Install a certificate → CA certificate</strong> and select the downloaded file.</p> |
| 96 | </div> | 124 | </div> | ||
| 97 | 125 | ||||
| 98 | <div class="platform" id="desktop"> | 126 | <div class="platform" id="desktop"> | ||
| 99 | <h2>macOS / Linux / Windows</h2> | 127 | <h2>macOS / Linux / Windows</h2> | ||
| 100 | <p><a class="btn" href="/ca.crt">Download Certificate</a></p> | 128 | <p><a class="btn" href="/ca.crt">Download Certificate</a></p> | ||
| 101 | - | <p>Or run on the Gordon host: <code>sudo gordon ca install</code></p> | 129 | + | <p>Double-click the downloaded file and add it to your system trust store, or import it via your OS certificate manager.</p> |
| 102 | </div> | 130 | </div> | ||
| 103 | 131 | ||||
| 104 | - | <div class="skip"> | 132 | + | <div class="platform" id="firefox"> |
| 105 | - | <p>Already installed? <a href="%s" onclick="document.cookie='gordon-ca-installed=1;path=/;max-age=315360000;SameSite=Lax;Secure'">Go to site →</a></p> | 133 | + | <h2>Firefox / Zen</h2> |
| 134 | + | <p><a class="btn" href="/ca.crt">Download Certificate</a></p> | |||
| 135 | + | <p>Firefox and Zen use their own certificate store (NSS). Go to <strong>Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import</strong> and select the downloaded file. Check <strong>Trust this CA to identify websites</strong>.</p> | |||
| 106 | </div> | 136 | </div> | ||
| 137 | + | ||||
| 138 | + | %s | |||
| 139 | + | ||||
| 140 | + | <p class="note">The <code>gordon ca install</code> command is for the Gordon server host only, not for client devices.</p> | |||
| 107 | </body> | 141 | </body> | ||
| 108 | - | </html>`, redirectURL) | 142 | + | </html>`, html.EscapeString(h.fingerprint), siteLink) |
| 109 | } | 143 | } | ||
M internal/adapters/in/http/onboarding/handler_test.go +146 -6
| @@ -12,24 +12,56 @@ import ( | |||
| 12 | 12 | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | - | func newTestServer(t *testing.T, tlsPort int) (*httptest.Server, []byte, []byte) { | |
| 16 | - | t.Helper() | |
| 15 | + | const ( | |
| 16 | + | testFingerprint = "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99" | |
| 17 | + | testHTTPPort = 8088 | |
| 18 | + | testTLSPort = 8443 | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func newTestHandler() (*onboarding.Handler, []byte, []byte) { | |
| 17 | 22 | rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") | |
| 18 | 23 | mobileconfig := []byte("<plist>com.apple.security.root</plist>") | |
| 19 | 24 | ||
| 20 | - | h := onboarding.NewHandler(rootPEM, mobileconfig, tlsPort) | |
| 25 | + | h := onboarding.NewHandler(rootPEM, mobileconfig, testFingerprint, testHTTPPort, testTLSPort) | |
| 26 | + | return h, rootPEM, mobileconfig | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func newTestServer(t *testing.T) (*httptest.Server, []byte, []byte) { | |
| 30 | + | t.Helper() | |
| 31 | + | h, rootPEM, mobileconfig := newTestHandler() | |
| 32 | + | ||
| 21 | 33 | mux := http.NewServeMux() | |
| 22 | 34 | mux.HandleFunc("GET /ca", h.ServeOnboardingPage) | |
| 35 | + | mux.HandleFunc("HEAD /ca", h.ServeOnboardingPage) | |
| 23 | 36 | mux.HandleFunc("GET /ca.crt", h.ServeCACert) | |
| 37 | + | mux.HandleFunc("HEAD /ca.crt", h.ServeCACert) | |
| 24 | 38 | mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig) | |
| 39 | + | mux.HandleFunc("HEAD /ca.mobileconfig", h.ServeMobileconfig) | |
| 25 | 40 | ||
| 26 | 41 | srv := httptest.NewServer(mux) | |
| 27 | 42 | t.Cleanup(srv.Close) | |
| 28 | 43 | return srv, rootPEM, mobileconfig | |
| 29 | 44 | } | |
| 30 | 45 | ||
| 46 | + | // getOnboardingBody issues a GET /ca with a custom Host header and returns the body. | |
| 47 | + | func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string { | |
| 48 | + | t.Helper() | |
| 49 | + | req, err := http.NewRequest(http.MethodGet, srv.URL+"/ca", nil) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | req.Host = host | |
| 52 | + | ||
| 53 | + | resp, err := http.DefaultClient.Do(req) | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | defer resp.Body.Close() | |
| 56 | + | ||
| 57 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 58 | + | body, err := io.ReadAll(resp.Body) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | return string(body) | |
| 61 | + | } | |
| 62 | + | ||
| 31 | 63 | func TestHandler_CACert(t *testing.T) { | |
| 32 | - | srv, rootPEM, _ := newTestServer(t, 443) | |
| 64 | + | srv, rootPEM, _ := newTestServer(t) | |
| 33 | 65 | ||
| 34 | 66 | resp, err := http.Get(srv.URL + "/ca.crt") | |
| 35 | 67 | require.NoError(t, err) | |
| @@ -44,7 +76,7 @@ func TestHandler_CACert(t *testing.T) { | |||
| 44 | 76 | } | |
| 45 | 77 | ||
| 46 | 78 | func TestHandler_Mobileconfig(t *testing.T) { | |
| 47 | - | srv, _, _ := newTestServer(t, 443) | |
| 79 | + | srv, _, _ := newTestServer(t) | |
| 48 | 80 | ||
| 49 | 81 | resp, err := http.Get(srv.URL + "/ca.mobileconfig") | |
| 50 | 82 | require.NoError(t, err) | |
| @@ -59,7 +91,7 @@ func TestHandler_Mobileconfig(t *testing.T) { | |||
| 59 | 91 | } | |
| 60 | 92 | ||
| 61 | 93 | func TestHandler_OnboardingPage(t *testing.T) { | |
| 62 | - | srv, _, _ := newTestServer(t, 443) | |
| 94 | + | srv, _, _ := newTestServer(t) | |
| 63 | 95 | ||
| 64 | 96 | resp, err := http.Get(srv.URL + "/ca") | |
| 65 | 97 | require.NoError(t, err) | |
| @@ -74,3 +106,111 @@ func TestHandler_OnboardingPage(t *testing.T) { | |||
| 74 | 106 | assert.Contains(t, string(body), "/ca.crt") | |
| 75 | 107 | assert.Contains(t, string(body), "/ca.mobileconfig") | |
| 76 | 108 | } | |
| 109 | + | ||
| 110 | + | func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) { | |
| 111 | + | srv, _, _ := newTestServer(t) | |
| 112 | + | body := getOnboardingBody(t, srv, "o2.bnema.dev") | |
| 113 | + | ||
| 114 | + | assert.Contains(t, body, "https://o2.bnema.dev/") | |
| 115 | + | assert.NotContains(t, body, ":8443") | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestHandler_OnboardingPage_ExplicitHTTPPortMapsToTLSPort(t *testing.T) { | |
| 119 | + | srv, _, _ := newTestServer(t) | |
| 120 | + | body := getOnboardingBody(t, srv, "o2.bnema.dev:8088") | |
| 121 | + | ||
| 122 | + | assert.Contains(t, body, "https://o2.bnema.dev:8443/") | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestHandler_OnboardingPage_ExplicitNonHTTPPortIsPreserved(t *testing.T) { | |
| 126 | + | srv, _, _ := newTestServer(t) | |
| 127 | + | body := getOnboardingBody(t, srv, "o2.bnema.dev:9999") | |
| 128 | + | ||
| 129 | + | assert.Contains(t, body, "https://o2.bnema.dev:9999/") | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestHandler_OnboardingPage_IPHostHidesGoToSiteLink(t *testing.T) { | |
| 133 | + | srv, _, _ := newTestServer(t) | |
| 134 | + | body := getOnboardingBody(t, srv, "100.83.240.53") | |
| 135 | + | ||
| 136 | + | assert.NotContains(t, body, "Go to site") | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestHandler_OnboardingPage_IncludesFingerprintAndClientImportCopy(t *testing.T) { | |
| 140 | + | srv, _, _ := newTestServer(t) | |
| 141 | + | body := getOnboardingBody(t, srv, "o2.bnema.dev") | |
| 142 | + | ||
| 143 | + | // Fingerprint must be visible | |
| 144 | + | assert.Contains(t, body, testFingerprint) | |
| 145 | + | ||
| 146 | + | // Client-import focused: Firefox/Zen guidance present | |
| 147 | + | assert.Contains(t, body, "Firefox") | |
| 148 | + | assert.Contains(t, body, "Zen") | |
| 149 | + | ||
| 150 | + | // Should NOT suggest gordon ca install as the primary action | |
| 151 | + | assert.NotContains(t, body, "sudo gordon ca install") | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestHandler_OnboardingPage_GoToSiteLinkIsPlainLink(t *testing.T) { | |
| 155 | + | srv, _, _ := newTestServer(t) | |
| 156 | + | ||
| 157 | + | body := getOnboardingBody(t, srv, "o2.bnema.dev") | |
| 158 | + | ||
| 159 | + | assert.Contains(t, body, `<a href="https://o2.bnema.dev/">Go to site →</a>`) | |
| 160 | + | assert.NotContains(t, body, "onclick=") | |
| 161 | + | assert.NotContains(t, body, "document.cookie") | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestHandler_OnboardingPage_HEAD(t *testing.T) { | |
| 165 | + | srv, _, _ := newTestServer(t) | |
| 166 | + | ||
| 167 | + | req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca", nil) | |
| 168 | + | require.NoError(t, err) | |
| 169 | + | ||
| 170 | + | resp, err := http.DefaultClient.Do(req) | |
| 171 | + | require.NoError(t, err) | |
| 172 | + | defer resp.Body.Close() | |
| 173 | + | ||
| 174 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 175 | + | assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) | |
| 176 | + | ||
| 177 | + | body, err := io.ReadAll(resp.Body) | |
| 178 | + | require.NoError(t, err) | |
| 179 | + | assert.Empty(t, body) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestHandler_CACert_HEAD(t *testing.T) { | |
| 183 | + | srv, _, _ := newTestServer(t) | |
| 184 | + | ||
| 185 | + | req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.crt", nil) | |
| 186 | + | require.NoError(t, err) | |
| 187 | + | ||
| 188 | + | resp, err := http.DefaultClient.Do(req) | |
| 189 | + | require.NoError(t, err) | |
| 190 | + | defer resp.Body.Close() | |
| 191 | + | ||
| 192 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 193 | + | assert.Equal(t, "application/x-x509-ca-cert", resp.Header.Get("Content-Type")) | |
| 194 | + | ||
| 195 | + | body, err := io.ReadAll(resp.Body) | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | assert.Empty(t, body) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestHandler_Mobileconfig_HEAD(t *testing.T) { | |
| 201 | + | srv, _, _ := newTestServer(t) | |
| 202 | + | ||
| 203 | + | req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.mobileconfig", nil) | |
| 204 | + | require.NoError(t, err) | |
| 205 | + | ||
| 206 | + | resp, err := http.DefaultClient.Do(req) | |
| 207 | + | require.NoError(t, err) | |
| 208 | + | defer resp.Body.Close() | |
| 209 | + | ||
| 210 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 211 | + | assert.Equal(t, "application/x-apple-aspen-config", resp.Header.Get("Content-Type")) | |
| 212 | + | ||
| 213 | + | body, err := io.ReadAll(resp.Body) | |
| 214 | + | require.NoError(t, err) | |
| 215 | + | assert.Empty(t, body) | |
| 216 | + | } | |
| @@ -12,24 +12,56 @@ import ( | |||||
| 12 | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | 12 | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | - | func newTestServer(t *testing.T, tlsPort int) (*httptest.Server, []byte, []byte) { | 15 | + | const ( |
| 16 | - | t.Helper() | 16 | + | testFingerprint = "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99" |
| 17 | + | testHTTPPort = 8088 | |||
| 18 | + | testTLSPort = 8443 | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | func newTestHandler() (*onboarding.Handler, []byte, []byte) { | |||
| 17 | rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") | 22 | rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") | ||
| 18 | mobileconfig := []byte("<plist>com.apple.security.root</plist>") | 23 | mobileconfig := []byte("<plist>com.apple.security.root</plist>") | ||
| 19 | 24 | ||||
| 20 | - | h := onboarding.NewHandler(rootPEM, mobileconfig, tlsPort) | 25 | + | h := onboarding.NewHandler(rootPEM, mobileconfig, testFingerprint, testHTTPPort, testTLSPort) |
| 26 | + | return h, rootPEM, mobileconfig | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func newTestServer(t *testing.T) (*httptest.Server, []byte, []byte) { | |||
| 30 | + | t.Helper() | |||
| 31 | + | h, rootPEM, mobileconfig := newTestHandler() | |||
| 32 | + | ||||
| 21 | mux := http.NewServeMux() | 33 | mux := http.NewServeMux() | ||
| 22 | mux.HandleFunc("GET /ca", h.ServeOnboardingPage) | 34 | mux.HandleFunc("GET /ca", h.ServeOnboardingPage) | ||
| 35 | + | mux.HandleFunc("HEAD /ca", h.ServeOnboardingPage) | |||
| 23 | mux.HandleFunc("GET /ca.crt", h.ServeCACert) | 36 | mux.HandleFunc("GET /ca.crt", h.ServeCACert) | ||
| 37 | + | mux.HandleFunc("HEAD /ca.crt", h.ServeCACert) | |||
| 24 | mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig) | 38 | mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig) | ||
| 39 | + | mux.HandleFunc("HEAD /ca.mobileconfig", h.ServeMobileconfig) | |||
| 25 | 40 | ||||
| 26 | srv := httptest.NewServer(mux) | 41 | srv := httptest.NewServer(mux) | ||
| 27 | t.Cleanup(srv.Close) | 42 | t.Cleanup(srv.Close) | ||
| 28 | return srv, rootPEM, mobileconfig | 43 | return srv, rootPEM, mobileconfig | ||
| 29 | } | 44 | } | ||
| 30 | 45 | ||||
| 46 | + | // getOnboardingBody issues a GET /ca with a custom Host header and returns the body. | |||
| 47 | + | func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string { | |||
| 48 | + | t.Helper() | |||
| 49 | + | req, err := http.NewRequest(http.MethodGet, srv.URL+"/ca", nil) | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | req.Host = host | |||
| 52 | + | ||||
| 53 | + | resp, err := http.DefaultClient.Do(req) | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | defer resp.Body.Close() | |||
| 56 | + | ||||
| 57 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 58 | + | body, err := io.ReadAll(resp.Body) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | return string(body) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 31 | func TestHandler_CACert(t *testing.T) { | 63 | func TestHandler_CACert(t *testing.T) { | ||
| 32 | - | srv, rootPEM, _ := newTestServer(t, 443) | 64 | + | srv, rootPEM, _ := newTestServer(t) |
| 33 | 65 | ||||
| 34 | resp, err := http.Get(srv.URL + "/ca.crt") | 66 | resp, err := http.Get(srv.URL + "/ca.crt") | ||
| 35 | require.NoError(t, err) | 67 | require.NoError(t, err) | ||
| @@ -44,7 +76,7 @@ func TestHandler_CACert(t *testing.T) { | |||||
| 44 | } | 76 | } | ||
| 45 | 77 | ||||
| 46 | func TestHandler_Mobileconfig(t *testing.T) { | 78 | func TestHandler_Mobileconfig(t *testing.T) { | ||
| 47 | - | srv, _, _ := newTestServer(t, 443) | 79 | + | srv, _, _ := newTestServer(t) |
| 48 | 80 | ||||
| 49 | resp, err := http.Get(srv.URL + "/ca.mobileconfig") | 81 | resp, err := http.Get(srv.URL + "/ca.mobileconfig") | ||
| 50 | require.NoError(t, err) | 82 | require.NoError(t, err) | ||
| @@ -59,7 +91,7 @@ func TestHandler_Mobileconfig(t *testing.T) { | |||||
| 59 | } | 91 | } | ||
| 60 | 92 | ||||
| 61 | func TestHandler_OnboardingPage(t *testing.T) { | 93 | func TestHandler_OnboardingPage(t *testing.T) { | ||
| 62 | - | srv, _, _ := newTestServer(t, 443) | 94 | + | srv, _, _ := newTestServer(t) |
| 63 | 95 | ||||
| 64 | resp, err := http.Get(srv.URL + "/ca") | 96 | resp, err := http.Get(srv.URL + "/ca") | ||
| 65 | require.NoError(t, err) | 97 | require.NoError(t, err) | ||
| @@ -74,3 +106,111 @@ func TestHandler_OnboardingPage(t *testing.T) { | |||||
| 74 | assert.Contains(t, string(body), "/ca.crt") | 106 | assert.Contains(t, string(body), "/ca.crt") | ||
| 75 | assert.Contains(t, string(body), "/ca.mobileconfig") | 107 | assert.Contains(t, string(body), "/ca.mobileconfig") | ||
| 76 | } | 108 | } | ||
| 109 | + | ||||
| 110 | + | func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) { | |||
| 111 | + | srv, _, _ := newTestServer(t) | |||
| 112 | + | body := getOnboardingBody(t, srv, "o2.bnema.dev") | |||
| 113 | + | ||||
| 114 | + | assert.Contains(t, body, "https://o2.bnema.dev/") | |||
| 115 | + | assert.NotContains(t, body, ":8443") | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestHandler_OnboardingPage_ExplicitHTTPPortMapsToTLSPort(t *testing.T) { | |||
| 119 | + | srv, _, _ := newTestServer(t) | |||
| 120 | + | body := getOnboardingBody(t, srv, "o2.bnema.dev:8088") | |||
| 121 | + | ||||
| 122 | + | assert.Contains(t, body, "https://o2.bnema.dev:8443/") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestHandler_OnboardingPage_ExplicitNonHTTPPortIsPreserved(t *testing.T) { | |||
| 126 | + | srv, _, _ := newTestServer(t) | |||
| 127 | + | body := getOnboardingBody(t, srv, "o2.bnema.dev:9999") | |||
| 128 | + | ||||
| 129 | + | assert.Contains(t, body, "https://o2.bnema.dev:9999/") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestHandler_OnboardingPage_IPHostHidesGoToSiteLink(t *testing.T) { | |||
| 133 | + | srv, _, _ := newTestServer(t) | |||
| 134 | + | body := getOnboardingBody(t, srv, "100.83.240.53") | |||
| 135 | + | ||||
| 136 | + | assert.NotContains(t, body, "Go to site") | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestHandler_OnboardingPage_IncludesFingerprintAndClientImportCopy(t *testing.T) { | |||
| 140 | + | srv, _, _ := newTestServer(t) | |||
| 141 | + | body := getOnboardingBody(t, srv, "o2.bnema.dev") | |||
| 142 | + | ||||
| 143 | + | // Fingerprint must be visible | |||
| 144 | + | assert.Contains(t, body, testFingerprint) | |||
| 145 | + | ||||
| 146 | + | // Client-import focused: Firefox/Zen guidance present | |||
| 147 | + | assert.Contains(t, body, "Firefox") | |||
| 148 | + | assert.Contains(t, body, "Zen") | |||
| 149 | + | ||||
| 150 | + | // Should NOT suggest gordon ca install as the primary action | |||
| 151 | + | assert.NotContains(t, body, "sudo gordon ca install") | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestHandler_OnboardingPage_GoToSiteLinkIsPlainLink(t *testing.T) { | |||
| 155 | + | srv, _, _ := newTestServer(t) | |||
| 156 | + | ||||
| 157 | + | body := getOnboardingBody(t, srv, "o2.bnema.dev") | |||
| 158 | + | ||||
| 159 | + | assert.Contains(t, body, `<a href="https://o2.bnema.dev/">Go to site →</a>`) | |||
| 160 | + | assert.NotContains(t, body, "onclick=") | |||
| 161 | + | assert.NotContains(t, body, "document.cookie") | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func TestHandler_OnboardingPage_HEAD(t *testing.T) { | |||
| 165 | + | srv, _, _ := newTestServer(t) | |||
| 166 | + | ||||
| 167 | + | req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca", nil) | |||
| 168 | + | require.NoError(t, err) | |||
| 169 | + | ||||
| 170 | + | resp, err := http.DefaultClient.Do(req) | |||
| 171 | + | require.NoError(t, err) | |||
| 172 | + | defer resp.Body.Close() | |||
| 173 | + | ||||
| 174 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 175 | + | assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) | |||
| 176 | + | ||||
| 177 | + | body, err := io.ReadAll(resp.Body) | |||
| 178 | + | require.NoError(t, err) | |||
| 179 | + | assert.Empty(t, body) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestHandler_CACert_HEAD(t *testing.T) { | |||
| 183 | + | srv, _, _ := newTestServer(t) | |||
| 184 | + | ||||
| 185 | + | req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.crt", nil) | |||
| 186 | + | require.NoError(t, err) | |||
| 187 | + | ||||
| 188 | + | resp, err := http.DefaultClient.Do(req) | |||
| 189 | + | require.NoError(t, err) | |||
| 190 | + | defer resp.Body.Close() | |||
| 191 | + | ||||
| 192 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 193 | + | assert.Equal(t, "application/x-x509-ca-cert", resp.Header.Get("Content-Type")) | |||
| 194 | + | ||||
| 195 | + | body, err := io.ReadAll(resp.Body) | |||
| 196 | + | require.NoError(t, err) | |||
| 197 | + | assert.Empty(t, body) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestHandler_Mobileconfig_HEAD(t *testing.T) { | |||
| 201 | + | srv, _, _ := newTestServer(t) | |||
| 202 | + | ||||
| 203 | + | req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.mobileconfig", nil) | |||
| 204 | + | require.NoError(t, err) | |||
| 205 | + | ||||
| 206 | + | resp, err := http.DefaultClient.Do(req) | |||
| 207 | + | require.NoError(t, err) | |||
| 208 | + | defer resp.Body.Close() | |||
| 209 | + | ||||
| 210 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 211 | + | assert.Equal(t, "application/x-apple-aspen-config", resp.Header.Get("Content-Type")) | |||
| 212 | + | ||||
| 213 | + | body, err := io.ReadAll(resp.Body) | |||
| 214 | + | require.NoError(t, err) | |||
| 215 | + | assert.Empty(t, body) | |||
| 216 | + | } | |||
M internal/app/run.go +80 -16
| @@ -1641,7 +1641,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1641 | 1641 | middleware.PanicRecovery(log), | |
| 1642 | 1642 | middleware.RequestLogger(log, trustedNets), | |
| 1643 | 1643 | middleware.SecurityHeaders, | |
| 1644 | - | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log), | |
| 1644 | + | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log), | |
| 1645 | 1645 | } | |
| 1646 | 1646 | if proxyCIDRMiddleware != nil { | |
| 1647 | 1647 | httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware) | |
| @@ -1652,9 +1652,34 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1652 | 1652 | "gordon.proxy", | |
| 1653 | 1653 | ) | |
| 1654 | 1654 | ||
| 1655 | - | // HTTP proxyMux: Cloudflare-proxied traffic only, no onboarding | |
| 1655 | + | // Build the onboarding handler once if internal CA is available and TLS is enabled. | |
| 1656 | + | var obHandler *onboarding.Handler | |
| 1657 | + | if svc.caAdapter != nil && cfg.Server.TLSPort != 0 { | |
| 1658 | + | mobileconfigBytes := pkiadapter.GenerateMobileconfig( | |
| 1659 | + | svc.caAdapter.RootCertificateDER(), | |
| 1660 | + | svc.caAdapter.RootCommonName(), | |
| 1661 | + | ) | |
| 1662 | + | obHandler = onboarding.NewHandler( | |
| 1663 | + | svc.caAdapter.RootCertificate(), | |
| 1664 | + | mobileconfigBytes, | |
| 1665 | + | svc.caAdapter.RootFingerprint(), | |
| 1666 | + | cfg.Server.Port, | |
| 1667 | + | cfg.Server.TLSPort, | |
| 1668 | + | ) | |
| 1669 | + | } | |
| 1670 | + | ||
| 1671 | + | // HTTP proxyMux: trusted proxy traffic flows through the normal proxy chain. | |
| 1672 | + | // Direct clients get an onboarding gate (when CA is available) placed BEFORE | |
| 1673 | + | // HTTPSRedirect so force_https_redirect cannot bypass onboarding. | |
| 1656 | 1674 | proxyMux := http.NewServeMux() | |
| 1657 | - | proxyMux.Handle("/", httpProxyWithMiddleware) | |
| 1675 | + | if proxyCIDRMiddleware != nil && proxyAllowedNets == nil { | |
| 1676 | + | // Invalid proxy_allowed_ips: deny all traffic (fail-closed). | |
| 1677 | + | proxyMux.Handle("/", proxyCIDRMiddleware(httpProxyWithMiddleware)) | |
| 1678 | + | } else if obHandler != nil { | |
| 1679 | + | proxyMux.Handle("/", directHTTPOnboardingGate(obHandler, proxyAllowedNets, httpProxyWithMiddleware, log)) | |
| 1680 | + | } else { | |
| 1681 | + | proxyMux.Handle("/", httpProxyWithMiddleware) | |
| 1682 | + | } | |
| 1658 | 1683 | ||
| 1659 | 1684 | // HTTPS proxy handler chain: security headers + proxy + CA onboarding | |
| 1660 | 1685 | // Onboarding routes live on the TLS port so Tailnet / direct clients | |
| @@ -1669,19 +1694,10 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1669 | 1694 | } | |
| 1670 | 1695 | ||
| 1671 | 1696 | httpsMux := http.NewServeMux() | |
| 1672 | - | if svc.caAdapter != nil { | |
| 1673 | - | mobileconfigBytes := pkiadapter.GenerateMobileconfig( | |
| 1674 | - | svc.caAdapter.RootCertificateDER(), | |
| 1675 | - | svc.caAdapter.RootCommonName(), | |
| 1676 | - | ) | |
| 1677 | - | onboardingHandler := onboarding.NewHandler( | |
| 1678 | - | svc.caAdapter.RootCertificate(), | |
| 1679 | - | mobileconfigBytes, | |
| 1680 | - | cfg.Server.TLSPort, | |
| 1681 | - | ) | |
| 1682 | - | httpsMux.HandleFunc("GET /ca", onboardingHandler.ServeOnboardingPage) | |
| 1683 | - | httpsMux.HandleFunc("GET /ca.crt", onboardingHandler.ServeCACert) | |
| 1684 | - | httpsMux.HandleFunc("GET /ca.mobileconfig", onboardingHandler.ServeMobileconfig) | |
| 1697 | + | if obHandler != nil { | |
| 1698 | + | httpsMux.HandleFunc("GET /ca", obHandler.ServeOnboardingPage) | |
| 1699 | + | httpsMux.HandleFunc("GET /ca.crt", obHandler.ServeCACert) | |
| 1700 | + | httpsMux.HandleFunc("GET /ca.mobileconfig", obHandler.ServeMobileconfig) | |
| 1685 | 1701 | } | |
| 1686 | 1702 | httpsMux.Handle("/", proxyHandler) | |
| 1687 | 1703 | ||
| @@ -1690,6 +1706,54 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1690 | 1706 | return registryMux, proxyMux, httpsHandler | |
| 1691 | 1707 | } | |
| 1692 | 1708 | ||
| 1709 | + | // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic | |
| 1710 | + | // by source IP. Trusted proxy IPs flow through to the normal proxy chain. | |
| 1711 | + | // Direct clients are served the CA onboarding flow on allowed paths and | |
| 1712 | + | // receive 403 on everything else. This gate runs BEFORE HTTPSRedirect so | |
| 1713 | + | // force_https_redirect cannot bypass onboarding for direct clients. | |
| 1714 | + | func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler { | |
| 1715 | + | // Build a small mux for direct-client onboarding paths. | |
| 1716 | + | onboardingMux := http.NewServeMux() | |
| 1717 | + | onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage) | |
| 1718 | + | onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage) | |
| 1719 | + | onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert) | |
| 1720 | + | onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig) | |
| 1721 | + | ||
| 1722 | + | // Reserve ACME challenge path for future use. | |
| 1723 | + | onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) { | |
| 1724 | + | http.Error(w, "not found", http.StatusNotFound) | |
| 1725 | + | }) | |
| 1726 | + | ||
| 1727 | + | // Catch-all: reject any other direct HTTP request. | |
| 1728 | + | // Uses a method-aware split: GET writes a body, HEAD gets an empty 403. | |
| 1729 | + | onboardingMux.HandleFunc("/", directHTTPForbidden) | |
| 1730 | + | ||
| 1731 | + | onboardingWithMiddleware := middleware.Chain( | |
| 1732 | + | middleware.PanicRecovery(log), | |
| 1733 | + | middleware.RequestLogger(log), // Intentionally omit trusted proxy nets so direct onboarding logs use RemoteAddr only. | |
| 1734 | + | middleware.SecurityHeaders, | |
| 1735 | + | )(onboardingMux) | |
| 1736 | + | ||
| 1737 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 1738 | + | remoteIP := middleware.ExtractRemoteIP(r.RemoteAddr) | |
| 1739 | + | if middleware.IsTrustedOrLocal(remoteIP, proxyNets) { | |
| 1740 | + | proxyChain.ServeHTTP(w, r) | |
| 1741 | + | return | |
| 1742 | + | } | |
| 1743 | + | onboardingWithMiddleware.ServeHTTP(w, r) | |
| 1744 | + | }) | |
| 1745 | + | } | |
| 1746 | + | ||
| 1747 | + | // directHTTPForbidden responds with 403 for non-onboarding HTTP paths. | |
| 1748 | + | // HEAD requests get an empty body per HTTP semantics. | |
| 1749 | + | func directHTTPForbidden(w http.ResponseWriter, r *http.Request) { | |
| 1750 | + | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| 1751 | + | w.WriteHeader(http.StatusForbidden) | |
| 1752 | + | if r.Method != http.MethodHead { | |
| 1753 | + | _, _ = w.Write([]byte("Only certificate onboarding is available over HTTP.\n")) | |
| 1754 | + | } | |
| 1755 | + | } | |
| 1756 | + | ||
| 1693 | 1757 | func buildRegistryHandlerWithMiddleware( | |
| 1694 | 1758 | svc *services, | |
| 1695 | 1759 | cfg Config, | |
| @@ -1641,7 +1641,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1641 | middleware.PanicRecovery(log), | 1641 | middleware.PanicRecovery(log), | ||
| 1642 | middleware.RequestLogger(log, trustedNets), | 1642 | middleware.RequestLogger(log, trustedNets), | ||
| 1643 | middleware.SecurityHeaders, | 1643 | middleware.SecurityHeaders, | ||
| 1644 | - | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log), | 1644 | + | middleware.HTTPSRedirect(proxyAllowedNets, cfg.Server.Port, cfg.Server.TLSPort, cfg.Server.ForceHTTPSRedirect, log), |
| 1645 | } | 1645 | } | ||
| 1646 | if proxyCIDRMiddleware != nil { | 1646 | if proxyCIDRMiddleware != nil { | ||
| 1647 | httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware) | 1647 | httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware) | ||
| @@ -1652,9 +1652,34 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1652 | "gordon.proxy", | 1652 | "gordon.proxy", | ||
| 1653 | ) | 1653 | ) | ||
| 1654 | 1654 | ||||
| 1655 | - | // HTTP proxyMux: Cloudflare-proxied traffic only, no onboarding | 1655 | + | // Build the onboarding handler once if internal CA is available and TLS is enabled. |
| 1656 | + | var obHandler *onboarding.Handler | |||
| 1657 | + | if svc.caAdapter != nil && cfg.Server.TLSPort != 0 { | |||
| 1658 | + | mobileconfigBytes := pkiadapter.GenerateMobileconfig( | |||
| 1659 | + | svc.caAdapter.RootCertificateDER(), | |||
| 1660 | + | svc.caAdapter.RootCommonName(), | |||
| 1661 | + | ) | |||
| 1662 | + | obHandler = onboarding.NewHandler( | |||
| 1663 | + | svc.caAdapter.RootCertificate(), | |||
| 1664 | + | mobileconfigBytes, | |||
| 1665 | + | svc.caAdapter.RootFingerprint(), | |||
| 1666 | + | cfg.Server.Port, | |||
| 1667 | + | cfg.Server.TLSPort, | |||
| 1668 | + | ) | |||
| 1669 | + | } | |||
| 1670 | + | ||||
| 1671 | + | // HTTP proxyMux: trusted proxy traffic flows through the normal proxy chain. | |||
| 1672 | + | // Direct clients get an onboarding gate (when CA is available) placed BEFORE | |||
| 1673 | + | // HTTPSRedirect so force_https_redirect cannot bypass onboarding. | |||
| 1656 | proxyMux := http.NewServeMux() | 1674 | proxyMux := http.NewServeMux() | ||
| 1657 | - | proxyMux.Handle("/", httpProxyWithMiddleware) | 1675 | + | if proxyCIDRMiddleware != nil && proxyAllowedNets == nil { |
| 1676 | + | // Invalid proxy_allowed_ips: deny all traffic (fail-closed). | |||
| 1677 | + | proxyMux.Handle("/", proxyCIDRMiddleware(httpProxyWithMiddleware)) | |||
| 1678 | + | } else if obHandler != nil { | |||
| 1679 | + | proxyMux.Handle("/", directHTTPOnboardingGate(obHandler, proxyAllowedNets, httpProxyWithMiddleware, log)) | |||
| 1680 | + | } else { | |||
| 1681 | + | proxyMux.Handle("/", httpProxyWithMiddleware) | |||
| 1682 | + | } | |||
| 1658 | 1683 | ||||
| 1659 | // HTTPS proxy handler chain: security headers + proxy + CA onboarding | 1684 | // HTTPS proxy handler chain: security headers + proxy + CA onboarding | ||
| 1660 | // Onboarding routes live on the TLS port so Tailnet / direct clients | 1685 | // Onboarding routes live on the TLS port so Tailnet / direct clients | ||
| @@ -1669,19 +1694,10 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1669 | } | 1694 | } | ||
| 1670 | 1695 | ||||
| 1671 | httpsMux := http.NewServeMux() | 1696 | httpsMux := http.NewServeMux() | ||
| 1672 | - | if svc.caAdapter != nil { | 1697 | + | if obHandler != nil { |
| 1673 | - | mobileconfigBytes := pkiadapter.GenerateMobileconfig( | 1698 | + | httpsMux.HandleFunc("GET /ca", obHandler.ServeOnboardingPage) |
| 1674 | - | svc.caAdapter.RootCertificateDER(), | 1699 | + | httpsMux.HandleFunc("GET /ca.crt", obHandler.ServeCACert) |
| 1675 | - | svc.caAdapter.RootCommonName(), | 1700 | + | httpsMux.HandleFunc("GET /ca.mobileconfig", obHandler.ServeMobileconfig) |
| 1676 | - | ) | |||
| 1677 | - | onboardingHandler := onboarding.NewHandler( | |||
| 1678 | - | svc.caAdapter.RootCertificate(), | |||
| 1679 | - | mobileconfigBytes, | |||
| 1680 | - | cfg.Server.TLSPort, | |||
| 1681 | - | ) | |||
| 1682 | - | httpsMux.HandleFunc("GET /ca", onboardingHandler.ServeOnboardingPage) | |||
| 1683 | - | httpsMux.HandleFunc("GET /ca.crt", onboardingHandler.ServeCACert) | |||
| 1684 | - | httpsMux.HandleFunc("GET /ca.mobileconfig", onboardingHandler.ServeMobileconfig) | |||
| 1685 | } | 1701 | } | ||
| 1686 | httpsMux.Handle("/", proxyHandler) | 1702 | httpsMux.Handle("/", proxyHandler) | ||
| 1687 | 1703 | ||||
| @@ -1690,6 +1706,54 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 1690 | return registryMux, proxyMux, httpsHandler | 1706 | return registryMux, proxyMux, httpsHandler | ||
| 1691 | } | 1707 | } | ||
| 1692 | 1708 | ||||
| 1709 | + | // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic | |||
| 1710 | + | // by source IP. Trusted proxy IPs flow through to the normal proxy chain. | |||
| 1711 | + | // Direct clients are served the CA onboarding flow on allowed paths and | |||
| 1712 | + | // receive 403 on everything else. This gate runs BEFORE HTTPSRedirect so | |||
| 1713 | + | // force_https_redirect cannot bypass onboarding for direct clients. | |||
| 1714 | + | func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler { | |||
| 1715 | + | // Build a small mux for direct-client onboarding paths. | |||
| 1716 | + | onboardingMux := http.NewServeMux() | |||
| 1717 | + | onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage) | |||
| 1718 | + | onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage) | |||
| 1719 | + | onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert) | |||
| 1720 | + | onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig) | |||
| 1721 | + | ||||
| 1722 | + | // Reserve ACME challenge path for future use. | |||
| 1723 | + | onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) { | |||
| 1724 | + | http.Error(w, "not found", http.StatusNotFound) | |||
| 1725 | + | }) | |||
| 1726 | + | ||||
| 1727 | + | // Catch-all: reject any other direct HTTP request. | |||
| 1728 | + | // Uses a method-aware split: GET writes a body, HEAD gets an empty 403. | |||
| 1729 | + | onboardingMux.HandleFunc("/", directHTTPForbidden) | |||
| 1730 | + | ||||
| 1731 | + | onboardingWithMiddleware := middleware.Chain( | |||
| 1732 | + | middleware.PanicRecovery(log), | |||
| 1733 | + | middleware.RequestLogger(log), // Intentionally omit trusted proxy nets so direct onboarding logs use RemoteAddr only. | |||
| 1734 | + | middleware.SecurityHeaders, | |||
| 1735 | + | )(onboardingMux) | |||
| 1736 | + | ||||
| 1737 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 1738 | + | remoteIP := middleware.ExtractRemoteIP(r.RemoteAddr) | |||
| 1739 | + | if middleware.IsTrustedOrLocal(remoteIP, proxyNets) { | |||
| 1740 | + | proxyChain.ServeHTTP(w, r) | |||
| 1741 | + | return | |||
| 1742 | + | } | |||
| 1743 | + | onboardingWithMiddleware.ServeHTTP(w, r) | |||
| 1744 | + | }) | |||
| 1745 | + | } | |||
| 1746 | + | ||||
| 1747 | + | // directHTTPForbidden responds with 403 for non-onboarding HTTP paths. | |||
| 1748 | + | // HEAD requests get an empty body per HTTP semantics. | |||
| 1749 | + | func directHTTPForbidden(w http.ResponseWriter, r *http.Request) { | |||
| 1750 | + | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |||
| 1751 | + | w.WriteHeader(http.StatusForbidden) | |||
| 1752 | + | if r.Method != http.MethodHead { | |||
| 1753 | + | _, _ = w.Write([]byte("Only certificate onboarding is available over HTTP.\n")) | |||
| 1754 | + | } | |||
| 1755 | + | } | |||
| 1756 | + | ||||
| 1693 | func buildRegistryHandlerWithMiddleware( | 1757 | func buildRegistryHandlerWithMiddleware( | ||
| 1694 | svc *services, | 1758 | svc *services, | ||
| 1695 | cfg Config, | 1759 | cfg Config, | ||
M internal/app/run_http_handlers_test.go +308 -1
| @@ -2,12 +2,19 @@ package app | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "io" | |
| 5 | 6 | "net/http" | |
| 6 | 7 | "net/http/httptest" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 7 | 10 | "testing" | |
| 8 | 11 | ||
| 9 | - | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 10 | 12 | "github.com/bnema/zerowrap" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 16 | + | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 17 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 11 | 18 | ) | |
| 12 | 19 | ||
| 13 | 20 | func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) { | |
| @@ -67,6 +74,306 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) | |||
| 67 | 74 | } | |
| 68 | 75 | } | |
| 69 | 76 | ||
| 77 | + | // newTestCA creates a temporary CA adapter for integration tests. | |
| 78 | + | func newTestCA(t *testing.T) *pkiadapter.CA { | |
| 79 | + | t.Helper() | |
| 80 | + | ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default()) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | return ca | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // newOnboardingConfig returns a Config with trusted proxy CIDRs and TLS enabled. | |
| 86 | + | func newOnboardingConfig() Config { | |
| 87 | + | cfg := Config{} | |
| 88 | + | cfg.Server.Port = 8088 | |
| 89 | + | cfg.Server.TLSPort = 8443 | |
| 90 | + | cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample | |
| 91 | + | return cfg | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // directAddr is an IP outside the trusted proxy CIDR range. | |
| 95 | + | const directAddr = "203.0.113.10:12345" | |
| 96 | + | ||
| 97 | + | // trustedProxyAddr is an IP inside the 173.245.48.0/20 range. | |
| 98 | + | const trustedProxyAddr = "173.245.48.5:12345" | |
| 99 | + | ||
| 100 | + | func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) { | |
| 101 | + | t.Parallel() | |
| 102 | + | ca := newTestCA(t) | |
| 103 | + | cfg := newOnboardingConfig() | |
| 104 | + | svc := &services{caAdapter: ca} | |
| 105 | + | ||
| 106 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 107 | + | ||
| 108 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 109 | + | req.RemoteAddr = directAddr | |
| 110 | + | req.Host = "o2.bnema.dev" | |
| 111 | + | rec := httptest.NewRecorder() | |
| 112 | + | httpHandler.ServeHTTP(rec, req) | |
| 113 | + | ||
| 114 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 115 | + | assert.Contains(t, rec.Header().Get("Content-Type"), "text/html") | |
| 116 | + | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) { | |
| 120 | + | t.Parallel() | |
| 121 | + | ca := newTestCA(t) | |
| 122 | + | cfg := newOnboardingConfig() | |
| 123 | + | svc := &services{caAdapter: ca} | |
| 124 | + | ||
| 125 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 126 | + | ||
| 127 | + | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | |
| 128 | + | req.RemoteAddr = directAddr | |
| 129 | + | req.Host = "o2.bnema.dev" | |
| 130 | + | rec := httptest.NewRecorder() | |
| 131 | + | httpHandler.ServeHTTP(rec, req) | |
| 132 | + | ||
| 133 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 134 | + | assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert") | |
| 135 | + | assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE") | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) { | |
| 139 | + | t.Parallel() | |
| 140 | + | ca := newTestCA(t) | |
| 141 | + | cfg := newOnboardingConfig() | |
| 142 | + | svc := &services{caAdapter: ca} | |
| 143 | + | ||
| 144 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 145 | + | ||
| 146 | + | req := httptest.NewRequest(http.MethodGet, "/web", nil) | |
| 147 | + | req.RemoteAddr = directAddr | |
| 148 | + | req.Host = "o2.bnema.dev" | |
| 149 | + | rec := httptest.NewRecorder() | |
| 150 | + | httpHandler.ServeHTTP(rec, req) | |
| 151 | + | ||
| 152 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 153 | + | assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain") | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) { | |
| 157 | + | t.Parallel() | |
| 158 | + | ca := newTestCA(t) | |
| 159 | + | cfg := newOnboardingConfig() | |
| 160 | + | svc := &services{caAdapter: ca} | |
| 161 | + | ||
| 162 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 163 | + | ||
| 164 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 165 | + | req.RemoteAddr = trustedProxyAddr | |
| 166 | + | req.Host = "app.example.com" | |
| 167 | + | rec := httptest.NewRecorder() | |
| 168 | + | httpHandler.ServeHTTP(rec, req) | |
| 169 | + | ||
| 170 | + | // Trusted proxy traffic should NOT get the onboarding page. | |
| 171 | + | // It should flow through to the normal proxy handler (which returns 502/404/etc, | |
| 172 | + | // but definitely not onboarding HTML). | |
| 173 | + | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResource(t *testing.T) { | |
| 177 | + | t.Parallel() | |
| 178 | + | ca := newTestCA(t) | |
| 179 | + | cfg := newOnboardingConfig() | |
| 180 | + | svc := &services{caAdapter: ca} | |
| 181 | + | ||
| 182 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 183 | + | ||
| 184 | + | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | |
| 185 | + | req.RemoteAddr = trustedProxyAddr | |
| 186 | + | req.Host = "app.example.com" | |
| 187 | + | rec := httptest.NewRecorder() | |
| 188 | + | httpHandler.ServeHTTP(rec, req) | |
| 189 | + | ||
| 190 | + | // Trusted proxy traffic must NOT leak the internal CA cert. | |
| 191 | + | assert.NotEqual(t, "application/x-x509-ca-cert", rec.Header().Get("Content-Type")) | |
| 192 | + | assert.NotContains(t, rec.Body.String(), "BEGIN CERTIFICATE") | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *testing.T) { | |
| 196 | + | t.Parallel() | |
| 197 | + | ca := newTestCA(t) | |
| 198 | + | cfg := newOnboardingConfig() | |
| 199 | + | cfg.Server.ForceHTTPSRedirect = true | |
| 200 | + | svc := &services{caAdapter: ca} | |
| 201 | + | ||
| 202 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 203 | + | ||
| 204 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 205 | + | req.RemoteAddr = directAddr | |
| 206 | + | req.Host = "o2.bnema.dev" | |
| 207 | + | rec := httptest.NewRecorder() | |
| 208 | + | httpHandler.ServeHTTP(rec, req) | |
| 209 | + | ||
| 210 | + | // Direct onboarding must win over force_https_redirect. | |
| 211 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 212 | + | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) { | |
| 216 | + | t.Parallel() | |
| 217 | + | ca := newTestCA(t) | |
| 218 | + | cfg := newOnboardingConfig() | |
| 219 | + | svc := &services{caAdapter: ca} | |
| 220 | + | ||
| 221 | + | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 222 | + | ||
| 223 | + | req := httptest.NewRequest(http.MethodGet, "/ca", nil) | |
| 224 | + | req.RemoteAddr = directAddr | |
| 225 | + | req.Host = "o2.bnema.dev" | |
| 226 | + | rec := httptest.NewRecorder() | |
| 227 | + | httpsHandler.ServeHTTP(rec, req) | |
| 228 | + | ||
| 229 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 230 | + | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) { | |
| 234 | + | t.Parallel() | |
| 235 | + | ca := newTestCA(t) | |
| 236 | + | cfg := newOnboardingConfig() | |
| 237 | + | svc := &services{caAdapter: ca} | |
| 238 | + | ||
| 239 | + | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 240 | + | ||
| 241 | + | // Use httptest.NewServer so Go's net/http server strips the body for HEAD. | |
| 242 | + | // httptest.NewRecorder does not apply server-level HEAD body stripping. | |
| 243 | + | ts := httptest.NewServer(httpsHandler) | |
| 244 | + | defer ts.Close() | |
| 245 | + | ||
| 246 | + | for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} { | |
| 247 | + | resp, err := ts.Client().Head(ts.URL + path) | |
| 248 | + | require.NoError(t, err) | |
| 249 | + | ||
| 250 | + | body, err := io.ReadAll(resp.Body) | |
| 251 | + | require.NoError(t, err) | |
| 252 | + | require.NoError(t, resp.Body.Close()) | |
| 253 | + | ||
| 254 | + | assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", path) | |
| 255 | + | assert.Empty(t, body, "HEAD %s should have empty body", path) | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *testing.T) { | |
| 260 | + | t.Parallel() | |
| 261 | + | ca := newTestCA(t) | |
| 262 | + | cfg := newOnboardingConfig() | |
| 263 | + | svc := &services{caAdapter: ca} | |
| 264 | + | ||
| 265 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 266 | + | ||
| 267 | + | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 268 | + | r.RemoteAddr = directAddr | |
| 269 | + | r.Host = "o2.bnema.dev" | |
| 270 | + | httpHandler.ServeHTTP(w, r) | |
| 271 | + | })) | |
| 272 | + | defer ts.Close() | |
| 273 | + | ||
| 274 | + | for _, tc := range []struct { | |
| 275 | + | path string | |
| 276 | + | contentType string | |
| 277 | + | }{ | |
| 278 | + | {path: "/ca", contentType: "text/html"}, | |
| 279 | + | {path: "/ca.crt", contentType: "application/x-x509-ca-cert"}, | |
| 280 | + | } { | |
| 281 | + | resp, err := ts.Client().Head(ts.URL + tc.path) | |
| 282 | + | require.NoError(t, err) | |
| 283 | + | ||
| 284 | + | body, err := io.ReadAll(resp.Body) | |
| 285 | + | require.NoError(t, err) | |
| 286 | + | require.NoError(t, resp.Body.Close()) | |
| 287 | + | ||
| 288 | + | assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", tc.path) | |
| 289 | + | assert.Contains(t, resp.Header.Get("Content-Type"), tc.contentType) | |
| 290 | + | assert.Empty(t, body, "HEAD %s should have empty body", tc.path) | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func TestCreateHTTPHandlers_DirectHTTPForbiddenHEAD_ReturnsForbidden(t *testing.T) { | |
| 295 | + | t.Parallel() | |
| 296 | + | ca := newTestCA(t) | |
| 297 | + | cfg := newOnboardingConfig() | |
| 298 | + | svc := &services{caAdapter: ca} | |
| 299 | + | ||
| 300 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 301 | + | ||
| 302 | + | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 303 | + | r.RemoteAddr = directAddr | |
| 304 | + | r.Host = "o2.bnema.dev" | |
| 305 | + | httpHandler.ServeHTTP(w, r) | |
| 306 | + | })) | |
| 307 | + | defer ts.Close() | |
| 308 | + | ||
| 309 | + | resp, err := ts.Client().Head(ts.URL + "/web") | |
| 310 | + | require.NoError(t, err) | |
| 311 | + | ||
| 312 | + | body, err := io.ReadAll(resp.Body) | |
| 313 | + | require.NoError(t, err) | |
| 314 | + | require.NoError(t, resp.Body.Close()) | |
| 315 | + | ||
| 316 | + | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | |
| 317 | + | assert.Empty(t, body, "HEAD 403 should have empty body") | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | func TestCreateHTTPHandlers_DirectHTTPACMEChallenge_Returns404(t *testing.T) { | |
| 321 | + | t.Parallel() | |
| 322 | + | ca := newTestCA(t) | |
| 323 | + | cfg := newOnboardingConfig() | |
| 324 | + | svc := &services{caAdapter: ca} | |
| 325 | + | ||
| 326 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 327 | + | ||
| 328 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/test-token", nil) | |
| 329 | + | req.RemoteAddr = directAddr | |
| 330 | + | req.Host = "o2.bnema.dev" | |
| 331 | + | rec := httptest.NewRecorder() | |
| 332 | + | httpHandler.ServeHTTP(rec, req) | |
| 333 | + | ||
| 334 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) { | |
| 338 | + | t.Parallel() | |
| 339 | + | ca := newTestCA(t) | |
| 340 | + | cfg := newOnboardingConfig() | |
| 341 | + | cfg.Server.TLSPort = 0 // TLS disabled | |
| 342 | + | svc := &services{caAdapter: ca} | |
| 343 | + | ||
| 344 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |
| 345 | + | ||
| 346 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 347 | + | req.RemoteAddr = directAddr | |
| 348 | + | req.Host = "o2.bnema.dev" | |
| 349 | + | rec := httptest.NewRecorder() | |
| 350 | + | httpHandler.ServeHTTP(rec, req) | |
| 351 | + | ||
| 352 | + | // With TLS disabled, onboarding should NOT be served. | |
| 353 | + | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) { | |
| 357 | + | t.Parallel() | |
| 358 | + | // Verify that initPKI returns an error when TLS is enabled but CA init fails. | |
| 359 | + | // We test this by providing a non-writable data dir. | |
| 360 | + | si := &serviceInit{ | |
| 361 | + | ctx: context.Background(), | |
| 362 | + | cfg: Config{}, | |
| 363 | + | log: zerowrap.Default(), | |
| 364 | + | svc: &services{}, | |
| 365 | + | } | |
| 366 | + | si.cfg.Server.TLSPort = 8443 | |
| 367 | + | dataDir := t.TempDir() | |
| 368 | + | blockingPath := filepath.Join(dataDir, "not-a-directory") | |
| 369 | + | require.NoError(t, os.WriteFile(blockingPath, []byte("blocking file"), 0600)) | |
| 370 | + | // Point data dir to a file path so PKI directory creation fails portably. | |
| 371 | + | si.cfg.Server.DataDir = blockingPath | |
| 372 | + | ||
| 373 | + | err := si.initPKI() | |
| 374 | + | assert.Error(t, err, "initPKI should fail when CA cannot be initialized") | |
| 375 | + | } | |
| 376 | + | ||
| 70 | 377 | func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) { | |
| 71 | 378 | t.Parallel() | |
| 72 | 379 | ||
| @@ -2,12 +2,19 @@ package app | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "io" | |||
| 5 | "net/http" | 6 | "net/http" | ||
| 6 | "net/http/httptest" | 7 | "net/http/httptest" | ||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 7 | "testing" | 10 | "testing" | ||
| 8 | 11 | ||||
| 9 | - | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | |||
| 10 | "github.com/bnema/zerowrap" | 12 | "github.com/bnema/zerowrap" | ||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | |||
| 17 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |||
| 11 | ) | 18 | ) | ||
| 12 | 19 | ||||
| 13 | func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) { | 20 | func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) { | ||
| @@ -67,6 +74,306 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T) | |||||
| 67 | } | 74 | } | ||
| 68 | } | 75 | } | ||
| 69 | 76 | ||||
| 77 | + | // newTestCA creates a temporary CA adapter for integration tests. | |||
| 78 | + | func newTestCA(t *testing.T) *pkiadapter.CA { | |||
| 79 | + | t.Helper() | |||
| 80 | + | ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default()) | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | return ca | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // newOnboardingConfig returns a Config with trusted proxy CIDRs and TLS enabled. | |||
| 86 | + | func newOnboardingConfig() Config { | |||
| 87 | + | cfg := Config{} | |||
| 88 | + | cfg.Server.Port = 8088 | |||
| 89 | + | cfg.Server.TLSPort = 8443 | |||
| 90 | + | cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample | |||
| 91 | + | return cfg | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // directAddr is an IP outside the trusted proxy CIDR range. | |||
| 95 | + | const directAddr = "203.0.113.10:12345" | |||
| 96 | + | ||||
| 97 | + | // trustedProxyAddr is an IP inside the 173.245.48.0/20 range. | |||
| 98 | + | const trustedProxyAddr = "173.245.48.5:12345" | |||
| 99 | + | ||||
| 100 | + | func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) { | |||
| 101 | + | t.Parallel() | |||
| 102 | + | ca := newTestCA(t) | |||
| 103 | + | cfg := newOnboardingConfig() | |||
| 104 | + | svc := &services{caAdapter: ca} | |||
| 105 | + | ||||
| 106 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 107 | + | ||||
| 108 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 109 | + | req.RemoteAddr = directAddr | |||
| 110 | + | req.Host = "o2.bnema.dev" | |||
| 111 | + | rec := httptest.NewRecorder() | |||
| 112 | + | httpHandler.ServeHTTP(rec, req) | |||
| 113 | + | ||||
| 114 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 115 | + | assert.Contains(t, rec.Header().Get("Content-Type"), "text/html") | |||
| 116 | + | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) { | |||
| 120 | + | t.Parallel() | |||
| 121 | + | ca := newTestCA(t) | |||
| 122 | + | cfg := newOnboardingConfig() | |||
| 123 | + | svc := &services{caAdapter: ca} | |||
| 124 | + | ||||
| 125 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 126 | + | ||||
| 127 | + | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | |||
| 128 | + | req.RemoteAddr = directAddr | |||
| 129 | + | req.Host = "o2.bnema.dev" | |||
| 130 | + | rec := httptest.NewRecorder() | |||
| 131 | + | httpHandler.ServeHTTP(rec, req) | |||
| 132 | + | ||||
| 133 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 134 | + | assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert") | |||
| 135 | + | assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE") | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) { | |||
| 139 | + | t.Parallel() | |||
| 140 | + | ca := newTestCA(t) | |||
| 141 | + | cfg := newOnboardingConfig() | |||
| 142 | + | svc := &services{caAdapter: ca} | |||
| 143 | + | ||||
| 144 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 145 | + | ||||
| 146 | + | req := httptest.NewRequest(http.MethodGet, "/web", nil) | |||
| 147 | + | req.RemoteAddr = directAddr | |||
| 148 | + | req.Host = "o2.bnema.dev" | |||
| 149 | + | rec := httptest.NewRecorder() | |||
| 150 | + | httpHandler.ServeHTTP(rec, req) | |||
| 151 | + | ||||
| 152 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |||
| 153 | + | assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain") | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) { | |||
| 157 | + | t.Parallel() | |||
| 158 | + | ca := newTestCA(t) | |||
| 159 | + | cfg := newOnboardingConfig() | |||
| 160 | + | svc := &services{caAdapter: ca} | |||
| 161 | + | ||||
| 162 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 163 | + | ||||
| 164 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 165 | + | req.RemoteAddr = trustedProxyAddr | |||
| 166 | + | req.Host = "app.example.com" | |||
| 167 | + | rec := httptest.NewRecorder() | |||
| 168 | + | httpHandler.ServeHTTP(rec, req) | |||
| 169 | + | ||||
| 170 | + | // Trusted proxy traffic should NOT get the onboarding page. | |||
| 171 | + | // It should flow through to the normal proxy handler (which returns 502/404/etc, | |||
| 172 | + | // but definitely not onboarding HTML). | |||
| 173 | + | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResource(t *testing.T) { | |||
| 177 | + | t.Parallel() | |||
| 178 | + | ca := newTestCA(t) | |||
| 179 | + | cfg := newOnboardingConfig() | |||
| 180 | + | svc := &services{caAdapter: ca} | |||
| 181 | + | ||||
| 182 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 183 | + | ||||
| 184 | + | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | |||
| 185 | + | req.RemoteAddr = trustedProxyAddr | |||
| 186 | + | req.Host = "app.example.com" | |||
| 187 | + | rec := httptest.NewRecorder() | |||
| 188 | + | httpHandler.ServeHTTP(rec, req) | |||
| 189 | + | ||||
| 190 | + | // Trusted proxy traffic must NOT leak the internal CA cert. | |||
| 191 | + | assert.NotEqual(t, "application/x-x509-ca-cert", rec.Header().Get("Content-Type")) | |||
| 192 | + | assert.NotContains(t, rec.Body.String(), "BEGIN CERTIFICATE") | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *testing.T) { | |||
| 196 | + | t.Parallel() | |||
| 197 | + | ca := newTestCA(t) | |||
| 198 | + | cfg := newOnboardingConfig() | |||
| 199 | + | cfg.Server.ForceHTTPSRedirect = true | |||
| 200 | + | svc := &services{caAdapter: ca} | |||
| 201 | + | ||||
| 202 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 203 | + | ||||
| 204 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 205 | + | req.RemoteAddr = directAddr | |||
| 206 | + | req.Host = "o2.bnema.dev" | |||
| 207 | + | rec := httptest.NewRecorder() | |||
| 208 | + | httpHandler.ServeHTTP(rec, req) | |||
| 209 | + | ||||
| 210 | + | // Direct onboarding must win over force_https_redirect. | |||
| 211 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 212 | + | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) { | |||
| 216 | + | t.Parallel() | |||
| 217 | + | ca := newTestCA(t) | |||
| 218 | + | cfg := newOnboardingConfig() | |||
| 219 | + | svc := &services{caAdapter: ca} | |||
| 220 | + | ||||
| 221 | + | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 222 | + | ||||
| 223 | + | req := httptest.NewRequest(http.MethodGet, "/ca", nil) | |||
| 224 | + | req.RemoteAddr = directAddr | |||
| 225 | + | req.Host = "o2.bnema.dev" | |||
| 226 | + | rec := httptest.NewRecorder() | |||
| 227 | + | httpsHandler.ServeHTTP(rec, req) | |||
| 228 | + | ||||
| 229 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 230 | + | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) { | |||
| 234 | + | t.Parallel() | |||
| 235 | + | ca := newTestCA(t) | |||
| 236 | + | cfg := newOnboardingConfig() | |||
| 237 | + | svc := &services{caAdapter: ca} | |||
| 238 | + | ||||
| 239 | + | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 240 | + | ||||
| 241 | + | // Use httptest.NewServer so Go's net/http server strips the body for HEAD. | |||
| 242 | + | // httptest.NewRecorder does not apply server-level HEAD body stripping. | |||
| 243 | + | ts := httptest.NewServer(httpsHandler) | |||
| 244 | + | defer ts.Close() | |||
| 245 | + | ||||
| 246 | + | for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} { | |||
| 247 | + | resp, err := ts.Client().Head(ts.URL + path) | |||
| 248 | + | require.NoError(t, err) | |||
| 249 | + | ||||
| 250 | + | body, err := io.ReadAll(resp.Body) | |||
| 251 | + | require.NoError(t, err) | |||
| 252 | + | require.NoError(t, resp.Body.Close()) | |||
| 253 | + | ||||
| 254 | + | assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", path) | |||
| 255 | + | assert.Empty(t, body, "HEAD %s should have empty body", path) | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *testing.T) { | |||
| 260 | + | t.Parallel() | |||
| 261 | + | ca := newTestCA(t) | |||
| 262 | + | cfg := newOnboardingConfig() | |||
| 263 | + | svc := &services{caAdapter: ca} | |||
| 264 | + | ||||
| 265 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 266 | + | ||||
| 267 | + | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 268 | + | r.RemoteAddr = directAddr | |||
| 269 | + | r.Host = "o2.bnema.dev" | |||
| 270 | + | httpHandler.ServeHTTP(w, r) | |||
| 271 | + | })) | |||
| 272 | + | defer ts.Close() | |||
| 273 | + | ||||
| 274 | + | for _, tc := range []struct { | |||
| 275 | + | path string | |||
| 276 | + | contentType string | |||
| 277 | + | }{ | |||
| 278 | + | {path: "/ca", contentType: "text/html"}, | |||
| 279 | + | {path: "/ca.crt", contentType: "application/x-x509-ca-cert"}, | |||
| 280 | + | } { | |||
| 281 | + | resp, err := ts.Client().Head(ts.URL + tc.path) | |||
| 282 | + | require.NoError(t, err) | |||
| 283 | + | ||||
| 284 | + | body, err := io.ReadAll(resp.Body) | |||
| 285 | + | require.NoError(t, err) | |||
| 286 | + | require.NoError(t, resp.Body.Close()) | |||
| 287 | + | ||||
| 288 | + | assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", tc.path) | |||
| 289 | + | assert.Contains(t, resp.Header.Get("Content-Type"), tc.contentType) | |||
| 290 | + | assert.Empty(t, body, "HEAD %s should have empty body", tc.path) | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func TestCreateHTTPHandlers_DirectHTTPForbiddenHEAD_ReturnsForbidden(t *testing.T) { | |||
| 295 | + | t.Parallel() | |||
| 296 | + | ca := newTestCA(t) | |||
| 297 | + | cfg := newOnboardingConfig() | |||
| 298 | + | svc := &services{caAdapter: ca} | |||
| 299 | + | ||||
| 300 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 301 | + | ||||
| 302 | + | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 303 | + | r.RemoteAddr = directAddr | |||
| 304 | + | r.Host = "o2.bnema.dev" | |||
| 305 | + | httpHandler.ServeHTTP(w, r) | |||
| 306 | + | })) | |||
| 307 | + | defer ts.Close() | |||
| 308 | + | ||||
| 309 | + | resp, err := ts.Client().Head(ts.URL + "/web") | |||
| 310 | + | require.NoError(t, err) | |||
| 311 | + | ||||
| 312 | + | body, err := io.ReadAll(resp.Body) | |||
| 313 | + | require.NoError(t, err) | |||
| 314 | + | require.NoError(t, resp.Body.Close()) | |||
| 315 | + | ||||
| 316 | + | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | |||
| 317 | + | assert.Empty(t, body, "HEAD 403 should have empty body") | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func TestCreateHTTPHandlers_DirectHTTPACMEChallenge_Returns404(t *testing.T) { | |||
| 321 | + | t.Parallel() | |||
| 322 | + | ca := newTestCA(t) | |||
| 323 | + | cfg := newOnboardingConfig() | |||
| 324 | + | svc := &services{caAdapter: ca} | |||
| 325 | + | ||||
| 326 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 327 | + | ||||
| 328 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/test-token", nil) | |||
| 329 | + | req.RemoteAddr = directAddr | |||
| 330 | + | req.Host = "o2.bnema.dev" | |||
| 331 | + | rec := httptest.NewRecorder() | |||
| 332 | + | httpHandler.ServeHTTP(rec, req) | |||
| 333 | + | ||||
| 334 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) { | |||
| 338 | + | t.Parallel() | |||
| 339 | + | ca := newTestCA(t) | |||
| 340 | + | cfg := newOnboardingConfig() | |||
| 341 | + | cfg.Server.TLSPort = 0 // TLS disabled | |||
| 342 | + | svc := &services{caAdapter: ca} | |||
| 343 | + | ||||
| 344 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default()) | |||
| 345 | + | ||||
| 346 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 347 | + | req.RemoteAddr = directAddr | |||
| 348 | + | req.Host = "o2.bnema.dev" | |||
| 349 | + | rec := httptest.NewRecorder() | |||
| 350 | + | httpHandler.ServeHTTP(rec, req) | |||
| 351 | + | ||||
| 352 | + | // With TLS disabled, onboarding should NOT be served. | |||
| 353 | + | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) { | |||
| 357 | + | t.Parallel() | |||
| 358 | + | // Verify that initPKI returns an error when TLS is enabled but CA init fails. | |||
| 359 | + | // We test this by providing a non-writable data dir. | |||
| 360 | + | si := &serviceInit{ | |||
| 361 | + | ctx: context.Background(), | |||
| 362 | + | cfg: Config{}, | |||
| 363 | + | log: zerowrap.Default(), | |||
| 364 | + | svc: &services{}, | |||
| 365 | + | } | |||
| 366 | + | si.cfg.Server.TLSPort = 8443 | |||
| 367 | + | dataDir := t.TempDir() | |||
| 368 | + | blockingPath := filepath.Join(dataDir, "not-a-directory") | |||
| 369 | + | require.NoError(t, os.WriteFile(blockingPath, []byte("blocking file"), 0600)) | |||
| 370 | + | // Point data dir to a file path so PKI directory creation fails portably. | |||
| 371 | + | si.cfg.Server.DataDir = blockingPath | |||
| 372 | + | ||||
| 373 | + | err := si.initPKI() | |||
| 374 | + | assert.Error(t, err, "initPKI should fail when CA cannot be initialized") | |||
| 375 | + | } | |||
| 376 | + | ||||
| 70 | func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) { | 377 | func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) { | ||
| 71 | t.Parallel() | 378 | t.Parallel() | ||
| 72 | 379 | ||||