detached 4b72742

Merge pull request #154 from bnema/feat/http-ca-onboarding-hardening

Harden direct HTTP CA onboarding
commit
4b72742d6d64d1fbd6cc96a6c8e80d748dda4728
author
bnema · · 2026-04-02 18:19 UTC
committer
GitHub · · 2026-04-02 18:19 UTC
merge of
67f96d9 + ed50492 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0-dev2
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:
1616 |---------|-------------|---------------|
1717 | `gordon serve` | Start the Gordon server | [serve](./serve.md) |
1818 | `gordon auth` | Manage Gordon server authentication | [auth](./auth.md) |
19+| `gordon ca` | Manage the internal Certificate Authority | [ca](./ca.md) |
1920
2021 ## Management Commands (local or remote)
2122
@@ -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"
8181
8282 The static certificate is served for SNI-matching domains (including wildcards). All other domains get on-demand certs from the internal CA.
8383
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+
8496 #### HTTP to HTTPS redirect
8597
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.
8799
88100 ```toml
89101 [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 redirect96 #### 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 ```toml100 ```toml
89 [server]101 [server]
M internal/adapters/in/http/middleware/cidr.go +50 -20
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "net"
77 "net/http"
8+ "strconv"
89 "strings"
910
1011 "github.com/bnema/zerowrap"
@@ -50,8 +51,8 @@ func cidrAllowlist(allowedNets []*net.IPNet, ipExtractor func(*http.Request) str
5051 }
5152 }
5253
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 {
5556 host, _, err := net.SplitHostPort(remoteAddr)
5657 if err != nil {
5758 return remoteAddr
@@ -59,6 +60,34 @@ func extractRemoteIP(remoteAddr string) string {
5960 return host
6061 }
6162
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+
6291 // RegistryCIDRAllowlist returns middleware that restricts access to the given CIDR ranges.
6392 // Localhost is always allowed so Gordon can pull from its own registry.
6493 // 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
74103 // Otherwise, only non-trusted clients are redirected — trusted proxy IPs and localhost
75104 // pass through since they deliver Cloudflare-proxied traffic.
76105 // 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 {
78111 return func(next http.Handler) http.Handler {
79112 if tlsPort == 0 {
80113 return next
@@ -88,27 +121,14 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow
88121 }
89122 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90123 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) {
93126 next.ServeHTTP(w, r)
94127 return
95128 }
96129 }
97130
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)
112132
113133 log.Debug().
114134 Str("target", target).
@@ -119,12 +139,22 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow
119139 }
120140 }
121141
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+
122152 // ProxyCIDRAllowlist returns middleware that restricts proxy access to the given CIDR ranges.
123153 // This validates the direct network peer (RemoteAddr), not forwarded IPs, to ensure only
124154 // trusted sources (e.g. Cloudflare edge IPs) can reach the proxy server.
125155 // An empty allowedNets slice is a no-op (all traffic passes through).
126156 func ProxyCIDRAllowlist(allowedNets []*net.IPNet, log zerowrap.Logger) func(http.Handler) http.Handler {
127157 return cidrAllowlist(allowedNets, func(r *http.Request) string {
128- return extractRemoteIP(r.RemoteAddr)
158+ return ExtractRemoteIP(r.RemoteAddr)
129159 }, "proxy origin", log)
130160 }
@@ -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 remoteAddr58 return remoteAddr
@@ -59,6 +60,34 @@ func extractRemoteIP(remoteAddr string) string {
59 return host60 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 localhost103 // 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 next113 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 return127 return
95 }128 }
96 }129 }
97 130
98- host := r.Host131+ 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 only153 // 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
22
33 import (
44 "encoding/json"
5+ "fmt"
56 "net"
67 "net/http"
78 "net/http/httptest"
@@ -110,6 +111,102 @@ func TestRegistryCIDRAllowlist(t *testing.T) {
110111 }
111112 }
112113
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+
113210 func TestProxyCIDRAllowlist(t *testing.T) {
114211 log := testLogger()
115212 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 (
55 "html"
66 "net"
77 "net/http"
8+
9+ "github.com/bnema/gordon/internal/adapters/in/http/middleware"
810 )
911
10-// Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients.
12+// Handler serves CA onboarding endpoints for direct/Tailnet clients.
1113 type Handler struct {
1214 rootPEM []byte
1315 mobileconfig []byte
16+ fingerprint string
17+ httpPort int
1418 tlsPort int
1519 }
1620
1721 // 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 {
2026 return &Handler{
2127 rootPEM: rootPEM,
2228 mobileconfig: mobileconfig,
29+ fingerprint: fingerprint,
30+ httpPort: httpPort,
2331 tlsPort: tlsPort,
2432 }
2533 }
@@ -38,38 +46,51 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) {
3846 _, _ = w.Write(h.mobileconfig)
3947 }
4048
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
4861 }
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]
5164 }
52- return html.EscapeString(fmt.Sprintf("https://%s/", hostname))
65+ return net.ParseIP(host) != nil
5366 }
5467
5568 // ServeOnboardingPage serves the CA trust onboarding HTML page.
56-// Static onboarding page -- kept inline for simplicity.
5769 func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {
5870 w.Header().Set("Content-Type", "text/html; charset=utf-8")
5971
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 &#x2192;</a></p></div>`,
79+ redirectURL,
80+ )
81+ }
6182
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.
6483 _, _ = fmt.Fprintf(w, `<!DOCTYPE html>
6584 <html lang="en">
6685 <head>
6786 <meta charset="utf-8">
6887 <meta name="viewport" content="width=device-width, initial-scale=1">
69-<title>Gordon — Install CA Certificate</title>
88+<title>Gordon — Trust CA Certificate</title>
7089 <style>
7190 body { font-family: -apple-system, system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; color: #333; }
7291 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; }
7394 .platform { background: #f5f5f5; border-radius: 8px; padding: 16px; margin: 12px 0; }
7495 .platform h2 { font-size: 1.1em; margin: 0 0 8px; }
7596 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) {
7798 .skip { margin-top: 24px; font-size: 0.9em; }
7899 .skip a { color: #666; }
79100 code { background: #e5e7eb; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
101+ .note { margin-top: 20px; font-size: 0.85em; color: #666; }
80102 </style>
81103 </head>
82104 <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>
85113
86114 <div class="platform" id="ios">
87115 <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 &amp; Device Management</strong> to install, then <strong>Settings → General → About → Certificate Trust Settings</strong> to enable full trust.</p>
90118 </div>
91119
92120 <div class="platform" id="android">
93121 <h2>Android</h2>
94122 <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>
96124 </div>
97125
98126 <div class="platform" id="desktop">
99127 <h2>macOS / Linux / Windows</h2>
100128 <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>
102130 </div>
103131
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 &amp; Security → Certificates → View Certificates → Authorities → Import</strong> and select the downloaded file. Check <strong>Trust this CA to identify websites</strong>.</p>
106136 </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>
107141 </body>
108-</html>`, redirectURL)
142+</html>`, html.EscapeString(h.fingerprint), siteLink)
109143 }
@@ -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 []byte14 rootPEM []byte
13 mobileconfig []byte15 mobileconfig []byte
16+ fingerprint string
17+ httpPort int
14 tlsPort int18 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), and50+// 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.Host53+ return html.EscapeString("https://" + authority + "/")
46- if host, _, err := net.SplitHostPort(hostname); err == nil {54+}
47- hostname = host55+
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 &#x2192;</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 &amp; 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 &amp; 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 (
1212 "github.com/bnema/gordon/internal/adapters/in/http/onboarding"
1313 )
1414
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) {
1722 rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
1823 mobileconfig := []byte("<plist>com.apple.security.root</plist>")
1924
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+
2133 mux := http.NewServeMux()
2234 mux.HandleFunc("GET /ca", h.ServeOnboardingPage)
35+ mux.HandleFunc("HEAD /ca", h.ServeOnboardingPage)
2336 mux.HandleFunc("GET /ca.crt", h.ServeCACert)
37+ mux.HandleFunc("HEAD /ca.crt", h.ServeCACert)
2438 mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig)
39+ mux.HandleFunc("HEAD /ca.mobileconfig", h.ServeMobileconfig)
2540
2641 srv := httptest.NewServer(mux)
2742 t.Cleanup(srv.Close)
2843 return srv, rootPEM, mobileconfig
2944 }
3045
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+
3163 func TestHandler_CACert(t *testing.T) {
32- srv, rootPEM, _ := newTestServer(t, 443)
64+ srv, rootPEM, _ := newTestServer(t)
3365
3466 resp, err := http.Get(srv.URL + "/ca.crt")
3567 require.NoError(t, err)
@@ -44,7 +76,7 @@ func TestHandler_CACert(t *testing.T) {
4476 }
4577
4678 func TestHandler_Mobileconfig(t *testing.T) {
47- srv, _, _ := newTestServer(t, 443)
79+ srv, _, _ := newTestServer(t)
4880
4981 resp, err := http.Get(srv.URL + "/ca.mobileconfig")
5082 require.NoError(t, err)
@@ -59,7 +91,7 @@ func TestHandler_Mobileconfig(t *testing.T) {
5991 }
6092
6193 func TestHandler_OnboardingPage(t *testing.T) {
62- srv, _, _ := newTestServer(t, 443)
94+ srv, _, _ := newTestServer(t)
6395
6496 resp, err := http.Get(srv.URL + "/ca")
6597 require.NoError(t, err)
@@ -74,3 +106,111 @@ func TestHandler_OnboardingPage(t *testing.T) {
74106 assert.Contains(t, string(body), "/ca.crt")
75107 assert.Contains(t, string(body), "/ca.mobileconfig")
76108 }
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 &#x2192;</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, mobileconfig43 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 &#x2192;</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
16411641 middleware.PanicRecovery(log),
16421642 middleware.RequestLogger(log, trustedNets),
16431643 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),
16451645 }
16461646 if proxyCIDRMiddleware != nil {
16471647 httpProxyMiddlewares = append(httpProxyMiddlewares, proxyCIDRMiddleware)
@@ -1652,9 +1652,34 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
16521652 "gordon.proxy",
16531653 )
16541654
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.
16561674 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+ }
16581683
16591684 // HTTPS proxy handler chain: security headers + proxy + CA onboarding
16601685 // 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
16691694 }
16701695
16711696 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)
16851701 }
16861702 httpsMux.Handle("/", proxyHandler)
16871703
@@ -1690,6 +1706,54 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
16901706 return registryMux, proxyMux, httpsHandler
16911707 }
16921708
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+
16931757 func buildRegistryHandlerWithMiddleware(
16941758 svc *services,
16951759 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 onboarding1655+ // 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 onboarding1684 // HTTPS proxy handler chain: security headers + proxy + CA onboarding
1660 // Onboarding routes live on the TLS port so Tailnet / direct clients1685 // 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, httpsHandler1706 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
22
33 import (
44 "context"
5+ "io"
56 "net/http"
67 "net/http/httptest"
8+ "os"
9+ "path/filepath"
710 "testing"
811
9- adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
1012 "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"
1118 )
1219
1320 func TestCreateAuthService_Disabled_ReturnsNilServices(t *testing.T) {
@@ -67,6 +74,306 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T)
6774 }
6875 }
6976
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+
70377 func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {
71378 t.Parallel()
72379
@@ -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

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close