fix(onboarding): correct public URLs and client trust guidance
- commit
- aab20abf74e8e8633cd274bfa39bf282157eae2b
- author
- brice · · 2026-04-01 09:24 UTC
- parent
- 67f96d9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 5 | 5 | "html" | |
| 6 | 6 | "net" | |
| 7 | 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 | 12 | type Handler struct { | |
| 12 | 13 | rootPEM []byte | |
| 13 | 14 | mobileconfig []byte | |
| 15 | + | fingerprint string | |
| 16 | + | httpPort int | |
| 14 | 17 | tlsPort int | |
| 15 | 18 | } | |
| 16 | 19 | ||
| 17 | 20 | // 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 { | |
| 20 | 25 | return &Handler{ | |
| 21 | 26 | rootPEM: rootPEM, | |
| 22 | 27 | mobileconfig: mobileconfig, | |
| 28 | + | fingerprint: fingerprint, | |
| 29 | + | httpPort: httpPort, | |
| 23 | 30 | tlsPort: tlsPort, | |
| 24 | 31 | } | |
| 25 | 32 | } | |
| @@ -38,38 +45,72 @@ func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) { | |||
| 38 | 45 | _, _ = w.Write(h.mobileconfig) | |
| 39 | 46 | } | |
| 40 | 47 | ||
| 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 + "/") | |
| 48 | 61 | } | |
| 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 | |
| 51 | 75 | } | |
| 52 | - | return html.EscapeString(fmt.Sprintf("https://%s/", hostname)) | |
| 76 | + | return net.ParseIP(host) != nil | |
| 53 | 77 | } | |
| 54 | 78 | ||
| 55 | 79 | // ServeOnboardingPage serves the CA trust onboarding HTML page. | |
| 56 | - | // Static onboarding page -- kept inline for simplicity. | |
| 57 | 80 | func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) { | |
| 58 | 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 →</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 | 103 | _, _ = fmt.Fprintf(w, `<!DOCTYPE html> | |
| 65 | 104 | <html lang="en"> | |
| 66 | 105 | <head> | |
| 67 | 106 | <meta charset="utf-8"> | |
| 68 | 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 | 109 | <style> | |
| 71 | 110 | body { font-family: -apple-system, system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; color: #333; } | |
| 72 | 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 | 114 | .platform { background: #f5f5f5; border-radius: 8px; padding: 16px; margin: 12px 0; } | |
| 74 | 115 | .platform h2 { font-size: 1.1em; margin: 0 0 8px; } | |
| 75 | 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 | 118 | .skip { margin-top: 24px; font-size: 0.9em; } | |
| 78 | 119 | .skip a { color: #666; } | |
| 79 | 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 | 122 | </style> | |
| 81 | 123 | </head> | |
| 82 | 124 | <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> | |
| 85 | 133 | ||
| 86 | 134 | <div class="platform" id="ios"> | |
| 87 | 135 | <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 & Device Management</strong> to install, then <strong>Settings → General → About → Certificate Trust Settings</strong> to enable full trust.</p> | |
| 90 | 138 | </div> | |
| 91 | 139 | ||
| 92 | 140 | <div class="platform" id="android"> | |
| 93 | 141 | <h2>Android</h2> | |
| 94 | 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 | 144 | </div> | |
| 97 | 145 | ||
| 98 | 146 | <div class="platform" id="desktop"> | |
| 99 | 147 | <h2>macOS / Linux / Windows</h2> | |
| 100 | 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 | 150 | </div> | |
| 103 | 151 | ||
| 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 & Security → Certificates → View Certificates → Authorities → Import</strong> and select the downloaded file. Check <strong>Trust this CA to identify websites</strong>.</p> | |
| 106 | 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 | 161 | </body> | |
| 108 | - | </html>`, redirectURL) | |
| 162 | + | </html>`, html.EscapeString(h.fingerprint), siteLink) | |
| 109 | 163 | } | |
| @@ -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 []byte | 13 | rootPEM []byte | ||
| 13 | mobileconfig []byte | 14 | mobileconfig []byte | ||
| 15 | + | fingerprint string | |||
| 16 | + | httpPort int | |||
| 14 | tlsPort int | 17 | 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), and | 49 | + | // |
| 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.Host | 52 | + | // - Host port == httpPort => https://host:tlsPort/ |
| 46 | - | if host, _, err := net.SplitHostPort(hostname); err == nil { | 53 | + | // - any other explicit port => preserve it |
| 47 | - | hostname = host | 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 + "/") | |||
| 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 →</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 & 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 & 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 ( | |||
| 12 | 12 | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | - | func newTestServer(t *testing.T, tlsPort int) (*httptest.Server, []byte, []byte) { | |
| 16 | - | t.Helper() | |
| 15 | + | const ( | |
| 16 | + | testFingerprint = "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99" | |
| 17 | + | testHTTPPort = 8088 | |
| 18 | + | testTLSPort = 8443 | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | func newTestHandler() (*onboarding.Handler, []byte, []byte) { | |
| 17 | 22 | rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") | |
| 18 | 23 | mobileconfig := []byte("<plist>com.apple.security.root</plist>") | |
| 19 | 24 | ||
| 20 | - | h := onboarding.NewHandler(rootPEM, mobileconfig, tlsPort) | |
| 25 | + | h := onboarding.NewHandler(rootPEM, mobileconfig, testFingerprint, testHTTPPort, testTLSPort) | |
| 26 | + | return h, rootPEM, mobileconfig | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func newTestServer(t *testing.T) (*httptest.Server, []byte, []byte) { | |
| 30 | + | t.Helper() | |
| 31 | + | h, rootPEM, mobileconfig := newTestHandler() | |
| 32 | + | ||
| 21 | 33 | mux := http.NewServeMux() | |
| 22 | 34 | mux.HandleFunc("GET /ca", h.ServeOnboardingPage) | |
| 35 | + | mux.HandleFunc("HEAD /ca", h.ServeOnboardingPage) | |
| 23 | 36 | mux.HandleFunc("GET /ca.crt", h.ServeCACert) | |
| 37 | + | mux.HandleFunc("HEAD /ca.crt", h.ServeCACert) | |
| 24 | 38 | mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig) | |
| 39 | + | mux.HandleFunc("HEAD /ca.mobileconfig", h.ServeMobileconfig) | |
| 25 | 40 | ||
| 26 | 41 | srv := httptest.NewServer(mux) | |
| 27 | 42 | t.Cleanup(srv.Close) | |
| 28 | 43 | return srv, rootPEM, mobileconfig | |
| 29 | 44 | } | |
| 30 | 45 | ||
| 46 | + | // getOnboardingBody issues a GET /ca with a custom Host header and returns the body. | |
| 47 | + | func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string { | |
| 48 | + | t.Helper() | |
| 49 | + | req, err := http.NewRequest(http.MethodGet, srv.URL+"/ca", nil) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | req.Host = host | |
| 52 | + | ||
| 53 | + | resp, err := http.DefaultClient.Do(req) | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | defer resp.Body.Close() | |
| 56 | + | ||
| 57 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 58 | + | body, err := io.ReadAll(resp.Body) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | return string(body) | |
| 61 | + | } | |
| 62 | + | ||
| 31 | 63 | func TestHandler_CACert(t *testing.T) { | |
| 32 | - | srv, rootPEM, _ := newTestServer(t, 443) | |
| 64 | + | srv, rootPEM, _ := newTestServer(t) | |
| 33 | 65 | ||
| 34 | 66 | resp, err := http.Get(srv.URL + "/ca.crt") | |
| 35 | 67 | require.NoError(t, err) | |
| @@ -44,7 +76,7 @@ func TestHandler_CACert(t *testing.T) { | |||
| 44 | 76 | } | |
| 45 | 77 | ||
| 46 | 78 | func TestHandler_Mobileconfig(t *testing.T) { | |
| 47 | - | srv, _, _ := newTestServer(t, 443) | |
| 79 | + | srv, _, _ := newTestServer(t) | |
| 48 | 80 | ||
| 49 | 81 | resp, err := http.Get(srv.URL + "/ca.mobileconfig") | |
| 50 | 82 | require.NoError(t, err) | |
| @@ -59,7 +91,7 @@ func TestHandler_Mobileconfig(t *testing.T) { | |||
| 59 | 91 | } | |
| 60 | 92 | ||
| 61 | 93 | func TestHandler_OnboardingPage(t *testing.T) { | |
| 62 | - | srv, _, _ := newTestServer(t, 443) | |
| 94 | + | srv, _, _ := newTestServer(t) | |
| 63 | 95 | ||
| 64 | 96 | resp, err := http.Get(srv.URL + "/ca") | |
| 65 | 97 | require.NoError(t, err) | |
| @@ -74,3 +106,113 @@ func TestHandler_OnboardingPage(t *testing.T) { | |||
| 74 | 106 | assert.Contains(t, string(body), "/ca.crt") | |
| 75 | 107 | assert.Contains(t, string(body), "/ca.mobileconfig") | |
| 76 | 108 | } | |
| 109 | + | ||
| 110 | + | // --- 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, mobileconfig | 43 | return srv, rootPEM, mobileconfig | ||
| 29 | } | 44 | } | ||
| 30 | 45 | ||||
| 46 | + | // getOnboardingBody issues a GET /ca with a custom Host header and returns the body. | |||
| 47 | + | func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string { | |||
| 48 | + | t.Helper() | |||
| 49 | + | req, err := http.NewRequest(http.MethodGet, srv.URL+"/ca", nil) | |||
| 50 | + | require.NoError(t, err) | |||
| 51 | + | req.Host = host | |||
| 52 | + | ||||
| 53 | + | resp, err := http.DefaultClient.Do(req) | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | defer resp.Body.Close() | |||
| 56 | + | ||||
| 57 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 58 | + | body, err := io.ReadAll(resp.Body) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | return string(body) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 31 | func TestHandler_CACert(t *testing.T) { | 63 | func TestHandler_CACert(t *testing.T) { | ||
| 32 | - | srv, rootPEM, _ := newTestServer(t, 443) | 64 | + | srv, rootPEM, _ := newTestServer(t) |
| 33 | 65 | ||||
| 34 | resp, err := http.Get(srv.URL + "/ca.crt") | 66 | resp, err := http.Get(srv.URL + "/ca.crt") | ||
| 35 | require.NoError(t, err) | 67 | require.NoError(t, err) | ||
| @@ -44,7 +76,7 @@ func TestHandler_CACert(t *testing.T) { | |||||
| 44 | } | 76 | } | ||
| 45 | 77 | ||||
| 46 | func TestHandler_Mobileconfig(t *testing.T) { | 78 | func TestHandler_Mobileconfig(t *testing.T) { | ||
| 47 | - | srv, _, _ := newTestServer(t, 443) | 79 | + | srv, _, _ := newTestServer(t) |
| 48 | 80 | ||||
| 49 | resp, err := http.Get(srv.URL + "/ca.mobileconfig") | 81 | resp, err := http.Get(srv.URL + "/ca.mobileconfig") | ||
| 50 | require.NoError(t, err) | 82 | require.NoError(t, err) | ||
| @@ -59,7 +91,7 @@ func TestHandler_Mobileconfig(t *testing.T) { | |||||
| 59 | } | 91 | } | ||
| 60 | 92 | ||||
| 61 | func TestHandler_OnboardingPage(t *testing.T) { | 93 | func TestHandler_OnboardingPage(t *testing.T) { | ||
| 62 | - | srv, _, _ := newTestServer(t, 443) | 94 | + | srv, _, _ := newTestServer(t) |
| 63 | 95 | ||||
| 64 | resp, err := http.Get(srv.URL + "/ca") | 96 | resp, err := http.Get(srv.URL + "/ca") | ||
| 65 | require.NoError(t, err) | 97 | require.NoError(t, err) | ||
| @@ -74,3 +106,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 | |||
| 1677 | 1677 | onboardingHandler := onboarding.NewHandler( | |
| 1678 | 1678 | svc.caAdapter.RootCertificate(), | |
| 1679 | 1679 | mobileconfigBytes, | |
| 1680 | + | svc.caAdapter.RootFingerprint(), | |
| 1681 | + | cfg.Server.Port, | |
| 1680 | 1682 | cfg.Server.TLSPort, | |
| 1681 | 1683 | ) | |
| 1682 | 1684 | 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) | ||