detached aab20ab

fix(onboarding): correct public URLs and client trust guidance

commit
aab20abf74e8e8633cd274bfa39bf282157eae2b
author
brice · · 2026-04-01 09:24 UTC
parent
67f96d9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +231 -33
M internal/adapters/in/http/onboarding/handler.go +81 -27
@@ -5,21 +5,28 @@ import (
55 "html"
66 "net"
77 "net/http"
8+ "strconv"
89 )
910
10-// Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients.
11+// Handler serves CA onboarding endpoints for direct/Tailnet clients.
1112 type Handler struct {
1213 rootPEM []byte
1314 mobileconfig []byte
15+ fingerprint string
16+ httpPort int
1417 tlsPort int
1518 }
1619
1720 // 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 {
21+// fingerprint is the SHA-256 fingerprint of the root CA certificate.
22+// httpPort is the configured HTTP listener port (used to map to tlsPort).
23+// tlsPort is the configured HTTPS listener port.
24+func NewHandler(rootPEM, mobileconfig []byte, fingerprint string, httpPort, tlsPort int) *Handler {
2025 return &Handler{
2126 rootPEM: rootPEM,
2227 mobileconfig: mobileconfig,
28+ fingerprint: fingerprint,
29+ httpPort: httpPort,
2330 tlsPort: tlsPort,
2431 }
2532 }
@@ -38,38 +45,72 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) {
3845 _, _ = w.Write(h.mobileconfig)
3946 }
4047
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
48+// publicHTTPSURL derives the public HTTPS URL from the request Host header.
49+//
50+// Rules:
51+// - no port in Host => https://host/ (omit internal TLS port)
52+// - Host port == httpPort => https://host:tlsPort/
53+// - any other explicit port => preserve it
54+//
55+// The result is HTML-escaped to prevent XSS via a crafted Host header.
56+func (h *Handler) publicHTTPSURL(r *http.Request) string {
57+ host, portStr, err := net.SplitHostPort(r.Host)
58+ if err != nil {
59+ // No port in Host header — use bare hostname.
60+ return html.EscapeString("https://" + r.Host + "/")
4861 }
49- if h.tlsPort != 0 && h.tlsPort != 443 {
50- hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort)
62+
63+ port, _ := strconv.Atoi(portStr)
64+ if port == h.httpPort {
65+ return html.EscapeString(fmt.Sprintf("https://%s:%d/", host, h.tlsPort))
66+ }
67+ return html.EscapeString(fmt.Sprintf("https://%s:%s/", host, portStr))
68+}
69+
70+// isRawIP reports whether the host (without port) is a raw IP literal.
71+func isRawIP(hostHeader string) bool {
72+ host, _, err := net.SplitHostPort(hostHeader)
73+ if err != nil {
74+ host = hostHeader
5175 }
52- return html.EscapeString(fmt.Sprintf("https://%s/", hostname))
76+ return net.ParseIP(host) != nil
5377 }
5478
5579 // ServeOnboardingPage serves the CA trust onboarding HTML page.
56-// Static onboarding page -- kept inline for simplicity.
5780 func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {
5881 w.Header().Set("Content-Type", "text/html; charset=utf-8")
5982
60- redirectURL := h.normalizeHTTPSURL(r)
83+ redirectURL := h.publicHTTPSURL(r)
84+ showSiteLink := !isRawIP(r.Host)
85+
86+ // On HTTP (r.TLS == nil), omit Secure from the cookie so browsers persist it.
87+ // On HTTPS, do not set the cookie at all — it is an HTTP-bootstrap-only UX hint.
88+ cookieJS := ""
89+ if r.TLS == nil {
90+ cookieJS = fmt.Sprintf(
91+ `onclick="document.cookie='gordon-ca-installed=1;path=/;max-age=315360000;SameSite=Lax'"`,
92+ )
93+ }
94+
95+ siteLink := ""
96+ if showSiteLink {
97+ siteLink = fmt.Sprintf(
98+ `<div class="skip"><p>Already installed? <a href="%s" %s>Go to site &#x2192;</a></p></div>`,
99+ redirectURL, cookieJS,
100+ )
101+ }
61102
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.
64103 _, _ = fmt.Fprintf(w, `<!DOCTYPE html>
65104 <html lang="en">
66105 <head>
67106 <meta charset="utf-8">
68107 <meta name="viewport" content="width=device-width, initial-scale=1">
69-<title>Gordon — Install CA Certificate</title>
108+<title>Gordon — Trust CA Certificate</title>
70109 <style>
71110 body { font-family: -apple-system, system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; color: #333; }
72111 h1 { font-size: 1.5em; }
112+ .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; }
113+ .fingerprint strong { display: block; margin-bottom: 4px; font-family: -apple-system, system-ui, sans-serif; }
73114 .platform { background: #f5f5f5; border-radius: 8px; padding: 16px; margin: 12px 0; }
74115 .platform h2 { font-size: 1.1em; margin: 0 0 8px; }
75116 a.btn { display: inline-block; background: #2563eb; color: white; padding: 10px 20px; border-radius: 6px; text-decoration: none; margin: 4px 0; }
@@ -77,33 +118,46 @@ func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {
77118 .skip { margin-top: 24px; font-size: 0.9em; }
78119 .skip a { color: #666; }
79120 code { background: #e5e7eb; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
121+ .note { margin-top: 20px; font-size: 0.85em; color: #666; }
80122 </style>
81123 </head>
82124 <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>
125+<h1>Gordon — Trust CA Certificate</h1>
126+<p>This Gordon instance uses its own internal CA for HTTPS. Import the CA certificate into your device or browser to connect securely.</p>
127+
128+<div class="fingerprint">
129+ <strong>CA Fingerprint (SHA-256)</strong>
130+ %s
131+</div>
132+<p style="font-size:0.85em;color:#666;">Verify this fingerprint matches the output of <code>gordon ca info</code> on the server.</p>
85133
86134 <div class="platform" id="ios">
87135 <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>
136+ <p><a class="btn" href="/ca.mobileconfig">Download Profile</a></p>
137+ <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>
90138 </div>
91139
92140 <div class="platform" id="android">
93141 <h2>Android</h2>
94142 <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>
143+ <p>Go to <strong>Settings → Security → Install a certificate → CA certificate</strong> and select the downloaded file.</p>
96144 </div>
97145
98146 <div class="platform" id="desktop">
99147 <h2>macOS / Linux / Windows</h2>
100148 <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>
149+ <p>Double-click the downloaded file and add it to your system trust store, or import it via your OS certificate manager.</p>
102150 </div>
103151
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>
152+<div class="platform" id="firefox">
153+ <h2>Firefox / Zen</h2>
154+ <p><a class="btn" href="/ca.crt">Download Certificate</a></p>
155+ <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>
106156 </div>
157+
158+%s
159+
160+<p class="note">The <code>gordon ca install</code> command is for the Gordon server host only, not for client devices.</p>
107161 </body>
108-</html>`, redirectURL)
162+</html>`, html.EscapeString(h.fingerprint), siteLink)
109163 }
@@ -5,21 +5,28 @@ import (
5 "html"5 "html"
6 "net"6 "net"
7 "net/http"7 "net/http"
8+ "strconv"
8 )9 )
9 10
10-// Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients.11+// Handler serves CA onboarding endpoints for direct/Tailnet clients.
11 type Handler struct {12 type Handler struct {
12 rootPEM []byte13 rootPEM []byte
13 mobileconfig []byte14 mobileconfig []byte
15+ fingerprint string
16+ httpPort int
14 tlsPort int17 tlsPort int
15 }18 }
16 19
17 // NewHandler creates an onboarding handler.20 // NewHandler creates an onboarding handler.
18-// tlsPort is the HTTPS port used to build redirect URLs on the onboarding page.21+// fingerprint is the SHA-256 fingerprint of the root CA certificate.
19-func NewHandler(rootPEM, mobileconfig []byte, tlsPort int) *Handler {22+// httpPort is the configured HTTP listener port (used to map to tlsPort).
23+// tlsPort is the configured HTTPS listener port.
24+func NewHandler(rootPEM, mobileconfig []byte, fingerprint string, httpPort, tlsPort int) *Handler {
20 return &Handler{25 return &Handler{
21 rootPEM: rootPEM,26 rootPEM: rootPEM,
22 mobileconfig: mobileconfig,27 mobileconfig: mobileconfig,
28+ fingerprint: fingerprint,
29+ httpPort: httpPort,
23 tlsPort: tlsPort,30 tlsPort: tlsPort,
24 }31 }
25 }32 }
@@ -38,38 +45,72 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) {
38 _, _ = w.Write(h.mobileconfig)45 _, _ = w.Write(h.mobileconfig)
39 }46 }
40 47
41-// normalizeHTTPSURL builds an https:// URL from r.Host and the configured TLS port.48+// publicHTTPSURL derives the public HTTPS URL from the request Host header.
42-// It strips any existing port, applies the configured TLS port (omitting :443), and49+//
43-// HTML-escapes the result to prevent XSS via a crafted Host header.50+// Rules:
44-func (h *Handler) normalizeHTTPSURL(r *http.Request) string {51+// - no port in Host => https://host/ (omit internal TLS port)
45- hostname := r.Host52+// - Host port == httpPort => https://host:tlsPort/
46- if host, _, err := net.SplitHostPort(hostname); err == nil {53+// - any other explicit port => preserve it
47- hostname = host54+//
55+// The result is HTML-escaped to prevent XSS via a crafted Host header.
56+func (h *Handler) publicHTTPSURL(r *http.Request) string {
57+ host, portStr, err := net.SplitHostPort(r.Host)
58+ if err != nil {
59+ // No port in Host header — use bare hostname.
60+ return html.EscapeString("https://" + r.Host + "/")
48 }61 }
49- if h.tlsPort != 0 && h.tlsPort != 443 {62+
50- hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort)63+ port, _ := strconv.Atoi(portStr)
64+ if port == h.httpPort {
65+ return html.EscapeString(fmt.Sprintf("https://%s:%d/", host, h.tlsPort))
66+ }
67+ return html.EscapeString(fmt.Sprintf("https://%s:%s/", host, portStr))
68+}
69+
70+// isRawIP reports whether the host (without port) is a raw IP literal.
71+func isRawIP(hostHeader string) bool {
72+ host, _, err := net.SplitHostPort(hostHeader)
73+ if err != nil {
74+ host = hostHeader
51 }75 }
52- return html.EscapeString(fmt.Sprintf("https://%s/", hostname))76+ return net.ParseIP(host) != nil
53 }77 }
54 78
55 // ServeOnboardingPage serves the CA trust onboarding HTML page.79 // 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) {80 func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {
58 w.Header().Set("Content-Type", "text/html; charset=utf-8")81 w.Header().Set("Content-Type", "text/html; charset=utf-8")
59 82
60- redirectURL := h.normalizeHTTPSURL(r)83+ redirectURL := h.publicHTTPSURL(r)
84+ showSiteLink := !isRawIP(r.Host)
85+
86+ // On HTTP (r.TLS == nil), omit Secure from the cookie so browsers persist it.
87+ // On HTTPS, do not set the cookie at all — it is an HTTP-bootstrap-only UX hint.
88+ cookieJS := ""
89+ if r.TLS == nil {
90+ cookieJS = fmt.Sprintf(
91+ `onclick="document.cookie='gordon-ca-installed=1;path=/;max-age=315360000;SameSite=Lax'"`,
92+ )
93+ }
94+
95+ siteLink := ""
96+ if showSiteLink {
97+ siteLink = fmt.Sprintf(
98+ `<div class="skip"><p>Already installed? <a href="%s" %s>Go to site &#x2192;</a></p></div>`,
99+ redirectURL, cookieJS,
100+ )
101+ }
61 102
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>103 _, _ = fmt.Fprintf(w, `<!DOCTYPE html>
65 <html lang="en">104 <html lang="en">
66 <head>105 <head>
67 <meta charset="utf-8">106 <meta charset="utf-8">
68 <meta name="viewport" content="width=device-width, initial-scale=1">107 <meta name="viewport" content="width=device-width, initial-scale=1">
69-<title>Gordon — Install CA Certificate</title>108+<title>Gordon — Trust CA Certificate</title>
70 <style>109 <style>
71 body { font-family: -apple-system, system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; color: #333; }110 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; }111 h1 { font-size: 1.5em; }
112+ .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; }
113+ .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; }114 .platform { background: #f5f5f5; border-radius: 8px; padding: 16px; margin: 12px 0; }
74 .platform h2 { font-size: 1.1em; margin: 0 0 8px; }115 .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; }116 a.btn { display: inline-block; background: #2563eb; color: white; padding: 10px 20px; border-radius: 6px; text-decoration: none; margin: 4px 0; }
@@ -77,33 +118,46 @@ func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {
77 .skip { margin-top: 24px; font-size: 0.9em; }118 .skip { margin-top: 24px; font-size: 0.9em; }
78 .skip a { color: #666; }119 .skip a { color: #666; }
79 code { background: #e5e7eb; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }120 code { background: #e5e7eb; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
121+ .note { margin-top: 20px; font-size: 0.85em; color: #666; }
80 </style>122 </style>
81 </head>123 </head>
82 <body>124 <body>
83-<h1>Gordon — Secure Access Setup</h1>125+<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>126+<p>This Gordon instance uses its own internal CA for HTTPS. Import the CA certificate into your device or browser to connect securely.</p>
127+
128+<div class="fingerprint">
129+ <strong>CA Fingerprint (SHA-256)</strong>
130+ %s
131+</div>
132+<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 133
86 <div class="platform" id="ios">134 <div class="platform" id="ios">
87 <h2>iOS / iPadOS</h2>135 <h2>iOS / iPadOS</h2>
88- <p><a class="btn" href="/ca.mobileconfig">Install Profile</a></p>136+ <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>137+ <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>138 </div>
91 139
92 <div class="platform" id="android">140 <div class="platform" id="android">
93 <h2>Android</h2>141 <h2>Android</h2>
94 <p><a class="btn" href="/ca.crt">Download Certificate</a></p>142 <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>143+ <p>Go to <strong>Settings → Security → Install a certificate → CA certificate</strong> and select the downloaded file.</p>
96 </div>144 </div>
97 145
98 <div class="platform" id="desktop">146 <div class="platform" id="desktop">
99 <h2>macOS / Linux / Windows</h2>147 <h2>macOS / Linux / Windows</h2>
100 <p><a class="btn" href="/ca.crt">Download Certificate</a></p>148 <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>149+ <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>150 </div>
103 151
104-<div class="skip">152+<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>153+ <h2>Firefox / Zen</h2>
154+ <p><a class="btn" href="/ca.crt">Download Certificate</a></p>
155+ <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>156 </div>
157+
158+%s
159+
160+<p class="note">The <code>gordon ca install</code> command is for the Gordon server host only, not for client devices.</p>
107 </body>161 </body>
108-</html>`, redirectURL)162+</html>`, html.EscapeString(h.fingerprint), siteLink)
109 }163 }
M internal/adapters/in/http/onboarding/handler_test.go +148 -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,113 @@ 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+// --- New tests per plan Task 1 ---
111+
112+func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) {
113+ srv, _, _ := newTestServer(t)
114+ body := getOnboardingBody(t, srv, "o2.bnema.dev")
115+
116+ assert.Contains(t, body, "https://o2.bnema.dev/")
117+ assert.NotContains(t, body, ":8443")
118+}
119+
120+func TestHandler_OnboardingPage_ExplicitHTTPPortMapsToTLSPort(t *testing.T) {
121+ srv, _, _ := newTestServer(t)
122+ body := getOnboardingBody(t, srv, "o2.bnema.dev:8088")
123+
124+ assert.Contains(t, body, "https://o2.bnema.dev:8443/")
125+}
126+
127+func TestHandler_OnboardingPage_ExplicitNonHTTPPortIsPreserved(t *testing.T) {
128+ srv, _, _ := newTestServer(t)
129+ body := getOnboardingBody(t, srv, "o2.bnema.dev:9999")
130+
131+ assert.Contains(t, body, "https://o2.bnema.dev:9999/")
132+}
133+
134+func TestHandler_OnboardingPage_IPHostHidesGoToSiteLink(t *testing.T) {
135+ srv, _, _ := newTestServer(t)
136+ body := getOnboardingBody(t, srv, "100.83.240.53")
137+
138+ assert.NotContains(t, body, "Go to site")
139+}
140+
141+func TestHandler_OnboardingPage_IncludesFingerprintAndClientImportCopy(t *testing.T) {
142+ srv, _, _ := newTestServer(t)
143+ body := getOnboardingBody(t, srv, "o2.bnema.dev")
144+
145+ // Fingerprint must be visible
146+ assert.Contains(t, body, testFingerprint)
147+
148+ // Client-import focused: Firefox/Zen guidance present
149+ assert.Contains(t, body, "Firefox")
150+ assert.Contains(t, body, "Zen")
151+
152+ // Should NOT suggest gordon ca install as the primary action
153+ assert.NotContains(t, body, "sudo gordon ca install")
154+}
155+
156+func TestHandler_OnboardingPage_HTTPVariantDoesNotUseSecureCookieHint(t *testing.T) {
157+ srv, _, _ := newTestServer(t)
158+
159+ // Default httptest server is HTTP, so request is non-TLS.
160+ body := getOnboardingBody(t, srv, "o2.bnema.dev")
161+
162+ // The cookie JS must NOT include "Secure" on the HTTP variant.
163+ assert.NotContains(t, body, ";Secure")
164+}
165+
166+func TestHandler_OnboardingPage_HEAD(t *testing.T) {
167+ srv, _, _ := newTestServer(t)
168+
169+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca", nil)
170+ require.NoError(t, err)
171+
172+ resp, err := http.DefaultClient.Do(req)
173+ require.NoError(t, err)
174+ defer resp.Body.Close()
175+
176+ assert.Equal(t, http.StatusOK, resp.StatusCode)
177+ assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
178+
179+ body, err := io.ReadAll(resp.Body)
180+ require.NoError(t, err)
181+ assert.Empty(t, body)
182+}
183+
184+func TestHandler_CACert_HEAD(t *testing.T) {
185+ srv, _, _ := newTestServer(t)
186+
187+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.crt", nil)
188+ require.NoError(t, err)
189+
190+ resp, err := http.DefaultClient.Do(req)
191+ require.NoError(t, err)
192+ defer resp.Body.Close()
193+
194+ assert.Equal(t, http.StatusOK, resp.StatusCode)
195+ assert.Equal(t, "application/x-x509-ca-cert", resp.Header.Get("Content-Type"))
196+
197+ body, err := io.ReadAll(resp.Body)
198+ require.NoError(t, err)
199+ assert.Empty(t, body)
200+}
201+
202+func TestHandler_Mobileconfig_HEAD(t *testing.T) {
203+ srv, _, _ := newTestServer(t)
204+
205+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.mobileconfig", nil)
206+ require.NoError(t, err)
207+
208+ resp, err := http.DefaultClient.Do(req)
209+ require.NoError(t, err)
210+ defer resp.Body.Close()
211+
212+ assert.Equal(t, http.StatusOK, resp.StatusCode)
213+ assert.Equal(t, "application/x-apple-aspen-config", resp.Header.Get("Content-Type"))
214+
215+ body, err := io.ReadAll(resp.Body)
216+ require.NoError(t, err)
217+ assert.Empty(t, body)
218+}
@@ -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,113 @@ 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+// --- New tests per plan Task 1 ---
111+
112+func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) {
113+ srv, _, _ := newTestServer(t)
114+ body := getOnboardingBody(t, srv, "o2.bnema.dev")
115+
116+ assert.Contains(t, body, "https://o2.bnema.dev/")
117+ assert.NotContains(t, body, ":8443")
118+}
119+
120+func TestHandler_OnboardingPage_ExplicitHTTPPortMapsToTLSPort(t *testing.T) {
121+ srv, _, _ := newTestServer(t)
122+ body := getOnboardingBody(t, srv, "o2.bnema.dev:8088")
123+
124+ assert.Contains(t, body, "https://o2.bnema.dev:8443/")
125+}
126+
127+func TestHandler_OnboardingPage_ExplicitNonHTTPPortIsPreserved(t *testing.T) {
128+ srv, _, _ := newTestServer(t)
129+ body := getOnboardingBody(t, srv, "o2.bnema.dev:9999")
130+
131+ assert.Contains(t, body, "https://o2.bnema.dev:9999/")
132+}
133+
134+func TestHandler_OnboardingPage_IPHostHidesGoToSiteLink(t *testing.T) {
135+ srv, _, _ := newTestServer(t)
136+ body := getOnboardingBody(t, srv, "100.83.240.53")
137+
138+ assert.NotContains(t, body, "Go to site")
139+}
140+
141+func TestHandler_OnboardingPage_IncludesFingerprintAndClientImportCopy(t *testing.T) {
142+ srv, _, _ := newTestServer(t)
143+ body := getOnboardingBody(t, srv, "o2.bnema.dev")
144+
145+ // Fingerprint must be visible
146+ assert.Contains(t, body, testFingerprint)
147+
148+ // Client-import focused: Firefox/Zen guidance present
149+ assert.Contains(t, body, "Firefox")
150+ assert.Contains(t, body, "Zen")
151+
152+ // Should NOT suggest gordon ca install as the primary action
153+ assert.NotContains(t, body, "sudo gordon ca install")
154+}
155+
156+func TestHandler_OnboardingPage_HTTPVariantDoesNotUseSecureCookieHint(t *testing.T) {
157+ srv, _, _ := newTestServer(t)
158+
159+ // Default httptest server is HTTP, so request is non-TLS.
160+ body := getOnboardingBody(t, srv, "o2.bnema.dev")
161+
162+ // The cookie JS must NOT include "Secure" on the HTTP variant.
163+ assert.NotContains(t, body, ";Secure")
164+}
165+
166+func TestHandler_OnboardingPage_HEAD(t *testing.T) {
167+ srv, _, _ := newTestServer(t)
168+
169+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca", nil)
170+ require.NoError(t, err)
171+
172+ resp, err := http.DefaultClient.Do(req)
173+ require.NoError(t, err)
174+ defer resp.Body.Close()
175+
176+ assert.Equal(t, http.StatusOK, resp.StatusCode)
177+ assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
178+
179+ body, err := io.ReadAll(resp.Body)
180+ require.NoError(t, err)
181+ assert.Empty(t, body)
182+}
183+
184+func TestHandler_CACert_HEAD(t *testing.T) {
185+ srv, _, _ := newTestServer(t)
186+
187+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.crt", nil)
188+ require.NoError(t, err)
189+
190+ resp, err := http.DefaultClient.Do(req)
191+ require.NoError(t, err)
192+ defer resp.Body.Close()
193+
194+ assert.Equal(t, http.StatusOK, resp.StatusCode)
195+ assert.Equal(t, "application/x-x509-ca-cert", resp.Header.Get("Content-Type"))
196+
197+ body, err := io.ReadAll(resp.Body)
198+ require.NoError(t, err)
199+ assert.Empty(t, body)
200+}
201+
202+func TestHandler_Mobileconfig_HEAD(t *testing.T) {
203+ srv, _, _ := newTestServer(t)
204+
205+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.mobileconfig", nil)
206+ require.NoError(t, err)
207+
208+ resp, err := http.DefaultClient.Do(req)
209+ require.NoError(t, err)
210+ defer resp.Body.Close()
211+
212+ assert.Equal(t, http.StatusOK, resp.StatusCode)
213+ assert.Equal(t, "application/x-apple-aspen-config", resp.Header.Get("Content-Type"))
214+
215+ body, err := io.ReadAll(resp.Body)
216+ require.NoError(t, err)
217+ assert.Empty(t, body)
218+}
M internal/app/run.go +2 -0
@@ -1677,6 +1677,8 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
16771677 onboardingHandler := onboarding.NewHandler(
16781678 svc.caAdapter.RootCertificate(),
16791679 mobileconfigBytes,
1680+ svc.caAdapter.RootFingerprint(),
1681+ cfg.Server.Port,
16801682 cfg.Server.TLSPort,
16811683 )
16821684 httpsMux.HandleFunc("GET /ca", onboardingHandler.ServeOnboardingPage)
@@ -1677,6 +1677,8 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1677 onboardingHandler := onboarding.NewHandler(1677 onboardingHandler := onboarding.NewHandler(
1678 svc.caAdapter.RootCertificate(),1678 svc.caAdapter.RootCertificate(),
1679 mobileconfigBytes,1679 mobileconfigBytes,
1680+ svc.caAdapter.RootFingerprint(),
1681+ cfg.Server.Port,
1680 cfg.Server.TLSPort,1682 cfg.Server.TLSPort,
1681 )1683 )
1682 httpsMux.HandleFunc("GET /ca", onboardingHandler.ServeOnboardingPage)1684 httpsMux.HandleFunc("GET /ca", onboardingHandler.ServeOnboardingPage)

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