detached 923d96f

feat(pki): add CA onboarding HTTP handler with cert download and landing page

commit
923d96f6c4f9c757777b00fa347fab01f1fff984
author
brice · · 2026-03-29 16:46 UTC
parent
ad80185
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +162 -0
A internal/adapters/in/http/onboarding/handler.go +107 -0
@@ -0,0 +1,107 @@
1+package onboarding
2+
3+import (
4+ "fmt"
5+ "net"
6+ "net/http"
7+
8+ pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
9+)
10+
11+// Handler serves CA onboarding endpoints on the HTTP port.
12+type Handler struct {
13+ rootPEM []byte
14+ rootDER []byte
15+ rootCN string
16+ cidrs []*net.IPNet // Cloudflare CIDRs — if source IP matches, don't serve onboarding
17+}
18+
19+// NewHandler creates an onboarding handler.
20+func NewHandler(rootPEM, rootDER []byte, rootCN string, cloudflareCIDRs []*net.IPNet) *Handler {
21+ return &Handler{
22+ rootPEM: rootPEM,
23+ rootDER: rootDER,
24+ rootCN: rootCN,
25+ cidrs: cloudflareCIDRs,
26+ }
27+}
28+
29+// ServeCACert serves the root CA certificate as PEM.
30+func (h *Handler) ServeCACert(w http.ResponseWriter, _ *http.Request) {
31+ w.Header().Set("Content-Type", "application/x-x509-ca-cert")
32+ w.Header().Set("Content-Disposition", "attachment; filename=gordon-ca.crt")
33+ w.Write(h.rootPEM)
34+}
35+
36+// ServeMobileconfig serves an iOS configuration profile with the root CA.
37+func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) {
38+ mc := pkiadapter.GenerateMobileconfig(h.rootDER, h.rootCN)
39+ w.Header().Set("Content-Type", "application/x-apple-aspen-config")
40+ w.Header().Set("Content-Disposition", "attachment; filename=gordon-ca.mobileconfig")
41+ w.Write(mc)
42+}
43+
44+// ServeOnboardingPage serves the CA trust onboarding HTML page.
45+func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {
46+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
47+
48+ scheme := "https"
49+ host := r.Host
50+ redirectURL := fmt.Sprintf("%s://%s/", scheme, host)
51+
52+ fmt.Fprintf(w, `<!DOCTYPE html>
53+<html lang="en">
54+<head>
55+<meta charset="utf-8">
56+<meta name="viewport" content="width=device-width, initial-scale=1">
57+<title>Gordon — Install CA Certificate</title>
58+<style>
59+ body { font-family: -apple-system, system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; color: #333; }
60+ h1 { font-size: 1.5em; }
61+ .platform { background: #f5f5f5; border-radius: 8px; padding: 16px; margin: 12px 0; }
62+ .platform h2 { font-size: 1.1em; margin: 0 0 8px; }
63+ a.btn { display: inline-block; background: #2563eb; color: white; padding: 10px 20px; border-radius: 6px; text-decoration: none; margin: 4px 0; }
64+ a.btn:hover { background: #1d4ed8; }
65+ .skip { margin-top: 24px; font-size: 0.9em; }
66+ .skip a { color: #666; }
67+ code { background: #e5e7eb; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
68+</style>
69+</head>
70+<body>
71+<h1>Gordon — Secure Access Setup</h1>
72+<p>To access this service over HTTPS, install Gordon's CA certificate on your device. This is a one-time step.</p>
73+
74+<div class="platform" id="ios">
75+ <h2>iOS / iPadOS</h2>
76+ <p><a class="btn" href="/ca.mobileconfig">Install Profile</a></p>
77+ <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>
78+</div>
79+
80+<div class="platform" id="android">
81+ <h2>Android</h2>
82+ <p><a class="btn" href="/ca.crt">Download Certificate</a></p>
83+ <p>After downloading, go to <strong>Settings → Security → Install a certificate → CA certificate</strong>.</p>
84+</div>
85+
86+<div class="platform" id="desktop">
87+ <h2>macOS / Linux / Windows</h2>
88+ <p><a class="btn" href="/ca.crt">Download Certificate</a></p>
89+ <p>Or run on the Gordon host: <code>sudo gordon ca install</code></p>
90+</div>
91+
92+<div class="skip">
93+ <p>Already installed? <a href="%s" onclick="document.cookie='gordon-ca-installed=1;path=/;max-age=315360000;SameSite=Lax'">Go to site →</a></p>
94+</div>
95+</body>
96+</html>`, redirectURL)
97+}
98+
99+// IsCloudflareIP checks if the given IP is in Cloudflare's CIDR ranges.
100+func (h *Handler) IsCloudflareIP(ip net.IP) bool {
101+ for _, cidr := range h.cidrs {
102+ if cidr.Contains(ip) {
103+ return true
104+ }
105+ }
106+ return false
107+}
@@ -0,0 +1,107 @@
1+package onboarding
2+
3+import (
4+ "fmt"
5+ "net"
6+ "net/http"
7+
8+ pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
9+)
10+
11+// Handler serves CA onboarding endpoints on the HTTP port.
12+type Handler struct {
13+ rootPEM []byte
14+ rootDER []byte
15+ rootCN string
16+ cidrs []*net.IPNet // Cloudflare CIDRs — if source IP matches, don't serve onboarding
17+}
18+
19+// NewHandler creates an onboarding handler.
20+func NewHandler(rootPEM, rootDER []byte, rootCN string, cloudflareCIDRs []*net.IPNet) *Handler {
21+ return &Handler{
22+ rootPEM: rootPEM,
23+ rootDER: rootDER,
24+ rootCN: rootCN,
25+ cidrs: cloudflareCIDRs,
26+ }
27+}
28+
29+// ServeCACert serves the root CA certificate as PEM.
30+func (h *Handler) ServeCACert(w http.ResponseWriter, _ *http.Request) {
31+ w.Header().Set("Content-Type", "application/x-x509-ca-cert")
32+ w.Header().Set("Content-Disposition", "attachment; filename=gordon-ca.crt")
33+ w.Write(h.rootPEM)
34+}
35+
36+// ServeMobileconfig serves an iOS configuration profile with the root CA.
37+func (h *Handler) ServeMobileconfig(w http.ResponseWriter, _ *http.Request) {
38+ mc := pkiadapter.GenerateMobileconfig(h.rootDER, h.rootCN)
39+ w.Header().Set("Content-Type", "application/x-apple-aspen-config")
40+ w.Header().Set("Content-Disposition", "attachment; filename=gordon-ca.mobileconfig")
41+ w.Write(mc)
42+}
43+
44+// ServeOnboardingPage serves the CA trust onboarding HTML page.
45+func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {
46+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
47+
48+ scheme := "https"
49+ host := r.Host
50+ redirectURL := fmt.Sprintf("%s://%s/", scheme, host)
51+
52+ fmt.Fprintf(w, `<!DOCTYPE html>
53+<html lang="en">
54+<head>
55+<meta charset="utf-8">
56+<meta name="viewport" content="width=device-width, initial-scale=1">
57+<title>Gordon — Install CA Certificate</title>
58+<style>
59+ body { font-family: -apple-system, system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; color: #333; }
60+ h1 { font-size: 1.5em; }
61+ .platform { background: #f5f5f5; border-radius: 8px; padding: 16px; margin: 12px 0; }
62+ .platform h2 { font-size: 1.1em; margin: 0 0 8px; }
63+ a.btn { display: inline-block; background: #2563eb; color: white; padding: 10px 20px; border-radius: 6px; text-decoration: none; margin: 4px 0; }
64+ a.btn:hover { background: #1d4ed8; }
65+ .skip { margin-top: 24px; font-size: 0.9em; }
66+ .skip a { color: #666; }
67+ code { background: #e5e7eb; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
68+</style>
69+</head>
70+<body>
71+<h1>Gordon — Secure Access Setup</h1>
72+<p>To access this service over HTTPS, install Gordon's CA certificate on your device. This is a one-time step.</p>
73+
74+<div class="platform" id="ios">
75+ <h2>iOS / iPadOS</h2>
76+ <p><a class="btn" href="/ca.mobileconfig">Install Profile</a></p>
77+ <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>
78+</div>
79+
80+<div class="platform" id="android">
81+ <h2>Android</h2>
82+ <p><a class="btn" href="/ca.crt">Download Certificate</a></p>
83+ <p>After downloading, go to <strong>Settings → Security → Install a certificate → CA certificate</strong>.</p>
84+</div>
85+
86+<div class="platform" id="desktop">
87+ <h2>macOS / Linux / Windows</h2>
88+ <p><a class="btn" href="/ca.crt">Download Certificate</a></p>
89+ <p>Or run on the Gordon host: <code>sudo gordon ca install</code></p>
90+</div>
91+
92+<div class="skip">
93+ <p>Already installed? <a href="%s" onclick="document.cookie='gordon-ca-installed=1;path=/;max-age=315360000;SameSite=Lax'">Go to site →</a></p>
94+</div>
95+</body>
96+</html>`, redirectURL)
97+}
98+
99+// IsCloudflareIP checks if the given IP is in Cloudflare's CIDR ranges.
100+func (h *Handler) IsCloudflareIP(ip net.IP) bool {
101+ for _, cidr := range h.cidrs {
102+ if cidr.Contains(ip) {
103+ return true
104+ }
105+ }
106+ return false
107+}
A internal/adapters/in/http/onboarding/handler_test.go +55 -0
@@ -0,0 +1,55 @@
1+package onboarding_test
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "testing"
7+
8+ "github.com/bnema/gordon/internal/adapters/in/http/onboarding"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+func TestHandler_CACert(t *testing.T) {
13+ rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
14+ rootDER := []byte("fakeDER")
15+ h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", nil)
16+
17+ req := httptest.NewRequest("GET", "/ca.crt", nil)
18+ w := httptest.NewRecorder()
19+ h.ServeCACert(w, req)
20+
21+ assert.Equal(t, http.StatusOK, w.Code)
22+ assert.Equal(t, "application/x-x509-ca-cert", w.Header().Get("Content-Type"))
23+ assert.Equal(t, rootPEM, w.Body.Bytes())
24+}
25+
26+func TestHandler_Mobileconfig(t *testing.T) {
27+ rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
28+ rootDER := []byte("fakeDER")
29+ h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", nil)
30+
31+ req := httptest.NewRequest("GET", "/ca.mobileconfig", nil)
32+ w := httptest.NewRecorder()
33+ h.ServeMobileconfig(w, req)
34+
35+ assert.Equal(t, http.StatusOK, w.Code)
36+ assert.Equal(t, "application/x-apple-aspen-config", w.Header().Get("Content-Type"))
37+ assert.Contains(t, w.Body.String(), "com.apple.security.root")
38+}
39+
40+func TestHandler_OnboardingPage(t *testing.T) {
41+ rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
42+ rootDER := []byte("fakeDER")
43+ h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", nil)
44+
45+ req := httptest.NewRequest("GET", "/ca", nil)
46+ w := httptest.NewRecorder()
47+ h.ServeOnboardingPage(w, req)
48+
49+ assert.Equal(t, http.StatusOK, w.Code)
50+ assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type"))
51+ body := w.Body.String()
52+ assert.Contains(t, body, "Gordon")
53+ assert.Contains(t, body, "/ca.crt")
54+ assert.Contains(t, body, "/ca.mobileconfig")
55+}
@@ -0,0 +1,55 @@
1+package onboarding_test
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "testing"
7+
8+ "github.com/bnema/gordon/internal/adapters/in/http/onboarding"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+func TestHandler_CACert(t *testing.T) {
13+ rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
14+ rootDER := []byte("fakeDER")
15+ h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", nil)
16+
17+ req := httptest.NewRequest("GET", "/ca.crt", nil)
18+ w := httptest.NewRecorder()
19+ h.ServeCACert(w, req)
20+
21+ assert.Equal(t, http.StatusOK, w.Code)
22+ assert.Equal(t, "application/x-x509-ca-cert", w.Header().Get("Content-Type"))
23+ assert.Equal(t, rootPEM, w.Body.Bytes())
24+}
25+
26+func TestHandler_Mobileconfig(t *testing.T) {
27+ rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
28+ rootDER := []byte("fakeDER")
29+ h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", nil)
30+
31+ req := httptest.NewRequest("GET", "/ca.mobileconfig", nil)
32+ w := httptest.NewRecorder()
33+ h.ServeMobileconfig(w, req)
34+
35+ assert.Equal(t, http.StatusOK, w.Code)
36+ assert.Equal(t, "application/x-apple-aspen-config", w.Header().Get("Content-Type"))
37+ assert.Contains(t, w.Body.String(), "com.apple.security.root")
38+}
39+
40+func TestHandler_OnboardingPage(t *testing.T) {
41+ rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n")
42+ rootDER := []byte("fakeDER")
43+ h := onboarding.NewHandler(rootPEM, rootDER, "Gordon Test CA", nil)
44+
45+ req := httptest.NewRequest("GET", "/ca", nil)
46+ w := httptest.NewRecorder()
47+ h.ServeOnboardingPage(w, req)
48+
49+ assert.Equal(t, http.StatusOK, w.Code)
50+ assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type"))
51+ body := w.Body.String()
52+ assert.Contains(t, body, "Gordon")
53+ assert.Contains(t, body, "/ca.crt")
54+ assert.Contains(t, body, "/ca.mobileconfig")
55+}

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