detached d015ae1

Simplify ACME onboarding hardening

commit
d015ae19fdb39852bf775d991955c6bdbd0f1a25
author
brice · · 2026-04-30 07:12 UTC
committer
brice · · 2026-04-30 07:17 UTC
parent
345260e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +101 -127
M internal/app/run.go +42 -24
@@ -622,7 +622,7 @@ func (si *serviceInit) initPublicTLS() error {
622622 log.Info().
623623 Str("email", si.cfg.TLS.ACME.Email).
624624 Str("challenge", string(effective.Mode)).
625- Msg("public ACME TLS initialized (read-only, no renewal loop)")
625+ Msg("public ACME TLS initialized (runtime start deferred)")
626626 }
627627
628628 si.svc.publicTLSSvc = svc
@@ -630,6 +630,9 @@ func (si *serviceInit) initPublicTLS() error {
630630 return nil
631631 }
632632
633+// publicTLSRuntime is the subset of in.PublicTLSService needed at server runtime
634+// after the HTTP listener is bound: reconcile missing certs and start the renewal
635+// loop. It is nil when ACME is disabled.
633636 type publicTLSRuntime interface {
634637 Reconcile(context.Context) error
635638 StartRenewalLoop(context.Context, time.Duration) <-chan struct{}
@@ -2118,8 +2121,20 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
21182121 }
21192122
21202123 httpsMux := http.NewServeMux()
2121- if obHandler != nil {
2122- httpsMux.Handle("/", gordonDomainOnboardingGate(cfg.Server.GordonDomain, obHandler, proxyHandler))
2124+ if obHandler != nil && cfg.Server.GordonDomain != "" {
2125+ gordonDomain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(cfg.Server.GordonDomain)), ".")
2126+ onboardingMux := http.NewServeMux()
2127+ registerOnboardingRoutes(onboardingMux, obHandler)
2128+ // Register onboarding paths host-gated so normal traffic hits
2129+ // proxyHandler directly through the catch-all / pattern.
2130+ httpsMux.Handle("GET /ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2131+ httpsMux.Handle("GET /ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2132+ httpsMux.Handle("GET /ca.mobileconfig", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2133+ httpsMux.Handle("GET /.well-known/gordon/", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2134+ httpsMux.Handle("GET /.well-known/gordon/ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2135+ httpsMux.Handle("GET /.well-known/gordon/ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2136+ httpsMux.Handle("GET /.well-known/gordon/ca.mobileconfig", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2137+ httpsMux.Handle("/", proxyHandler)
21232138 } else {
21242139 httpsMux.Handle("/", proxyHandler)
21252140 }
@@ -2140,6 +2155,19 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
21402155 return registryOut, proxyOut, httpsOut
21412156 }
21422157
2158+// registerOnboardingRoutes registers all CA onboarding HTTP routes on the
2159+// given mux. Both direct-HTTP and Gordon-domain HTTPS onboarding use this.
2160+func registerOnboardingRoutes(mux *http.ServeMux, ob *onboarding.Handler) {
2161+ mux.HandleFunc("GET /{$}", ob.ServeOnboardingPage)
2162+ mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage)
2163+ mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage)
2164+ mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert)
2165+ mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig)
2166+ mux.HandleFunc("GET /ca", ob.ServeOnboardingPage)
2167+ mux.HandleFunc("GET /ca.crt", ob.ServeCACert)
2168+ mux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)
2169+}
2170+
21432171 // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic
21442172 // by source IP. Trusted proxy IPs flow through to the normal proxy chain.
21452173 // Direct clients are served the CA onboarding flow on allowed paths and
@@ -2148,14 +2176,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
21482176 func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler {
21492177 // Build a small mux for direct-client onboarding paths.
21502178 onboardingMux := http.NewServeMux()
2151- onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage)
2152- onboardingMux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage)
2153- onboardingMux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage)
2154- onboardingMux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert)
2155- onboardingMux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig)
2156- onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage)
2157- onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert)
2158- onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)
2179+ registerOnboardingRoutes(onboardingMux, ob)
21592180
21602181 // Reserve ACME challenge path for future use.
21612182 onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) {
@@ -2182,7 +2203,9 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr
21822203 })
21832204 }
21842205
2185-func requestHostMatches(host, expected string) bool {
2206+// canonicalHostsEqual compares two hosts after normalising both: stripping
2207+// port, trimming spaces, lowercasing, and removing trailing dot.
2208+func canonicalHostsEqual(host, expected string) bool {
21862209 host = strings.TrimSpace(host)
21872210 if h, _, err := net.SplitHostPort(host); err == nil {
21882211 host = h
@@ -2192,19 +2215,14 @@ func requestHostMatches(host, expected string) bool {
21922215 return host == expected
21932216 }
21942217
2195-func gordonDomainOnboardingGate(gordonDomain string, ob *onboarding.Handler, proxyHandler http.Handler) http.Handler {
2196- mux := http.NewServeMux()
2197- mux.HandleFunc("GET /ca", ob.ServeOnboardingPage)
2198- mux.HandleFunc("GET /ca.crt", ob.ServeCACert)
2199- mux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)
2200- mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage)
2201- mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage)
2202- mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert)
2203- mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig)
2204-
2218+// gordonDomainOnboardingGate returns a handler that serves onboarding routes
2219+// only when the request host matches gordonDomain. For mismatched hosts it
2220+// delegates to proxyHandler. gordonDomain must already be canonicalised
2221+// (trimmed, lowered, trailing dot removed).
2222+func gordonDomainOnboardingGate(gordonDomain string, onboardingMux, proxyHandler http.Handler) http.Handler {
22052223 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2206- if gordonDomain != "" && requestHostMatches(r.Host, gordonDomain) {
2207- mux.ServeHTTP(w, r)
2224+ if gordonDomain != "" && canonicalHostsEqual(r.Host, gordonDomain) {
2225+ onboardingMux.ServeHTTP(w, r)
22082226 return
22092227 }
22102228 proxyHandler.ServeHTTP(w, r)
@@ -622,7 +622,7 @@ func (si *serviceInit) initPublicTLS() error {
622 log.Info().622 log.Info().
623 Str("email", si.cfg.TLS.ACME.Email).623 Str("email", si.cfg.TLS.ACME.Email).
624 Str("challenge", string(effective.Mode)).624 Str("challenge", string(effective.Mode)).
625- Msg("public ACME TLS initialized (read-only, no renewal loop)")625+ Msg("public ACME TLS initialized (runtime start deferred)")
626 }626 }
627 627
628 si.svc.publicTLSSvc = svc628 si.svc.publicTLSSvc = svc
@@ -630,6 +630,9 @@ func (si *serviceInit) initPublicTLS() error {
630 return nil630 return nil
631 }631 }
632 632
633+// publicTLSRuntime is the subset of in.PublicTLSService needed at server runtime
634+// after the HTTP listener is bound: reconcile missing certs and start the renewal
635+// loop. It is nil when ACME is disabled.
633 type publicTLSRuntime interface {636 type publicTLSRuntime interface {
634 Reconcile(context.Context) error637 Reconcile(context.Context) error
635 StartRenewalLoop(context.Context, time.Duration) <-chan struct{}638 StartRenewalLoop(context.Context, time.Duration) <-chan struct{}
@@ -2118,8 +2121,20 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2118 }2121 }
2119 2122
2120 httpsMux := http.NewServeMux()2123 httpsMux := http.NewServeMux()
2121- if obHandler != nil {2124+ if obHandler != nil && cfg.Server.GordonDomain != "" {
2122- httpsMux.Handle("/", gordonDomainOnboardingGate(cfg.Server.GordonDomain, obHandler, proxyHandler))2125+ gordonDomain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(cfg.Server.GordonDomain)), ".")
2126+ onboardingMux := http.NewServeMux()
2127+ registerOnboardingRoutes(onboardingMux, obHandler)
2128+ // Register onboarding paths host-gated so normal traffic hits
2129+ // proxyHandler directly through the catch-all / pattern.
2130+ httpsMux.Handle("GET /ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2131+ httpsMux.Handle("GET /ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2132+ httpsMux.Handle("GET /ca.mobileconfig", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2133+ httpsMux.Handle("GET /.well-known/gordon/", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2134+ httpsMux.Handle("GET /.well-known/gordon/ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2135+ httpsMux.Handle("GET /.well-known/gordon/ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2136+ httpsMux.Handle("GET /.well-known/gordon/ca.mobileconfig", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2137+ httpsMux.Handle("/", proxyHandler)
2123 } else {2138 } else {
2124 httpsMux.Handle("/", proxyHandler)2139 httpsMux.Handle("/", proxyHandler)
2125 }2140 }
@@ -2140,6 +2155,19 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2140 return registryOut, proxyOut, httpsOut2155 return registryOut, proxyOut, httpsOut
2141 }2156 }
2142 2157
2158+// registerOnboardingRoutes registers all CA onboarding HTTP routes on the
2159+// given mux. Both direct-HTTP and Gordon-domain HTTPS onboarding use this.
2160+func registerOnboardingRoutes(mux *http.ServeMux, ob *onboarding.Handler) {
2161+ mux.HandleFunc("GET /{$}", ob.ServeOnboardingPage)
2162+ mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage)
2163+ mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage)
2164+ mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert)
2165+ mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig)
2166+ mux.HandleFunc("GET /ca", ob.ServeOnboardingPage)
2167+ mux.HandleFunc("GET /ca.crt", ob.ServeCACert)
2168+ mux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)
2169+}
2170+
2143 // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic2171 // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic
2144 // by source IP. Trusted proxy IPs flow through to the normal proxy chain.2172 // by source IP. Trusted proxy IPs flow through to the normal proxy chain.
2145 // Direct clients are served the CA onboarding flow on allowed paths and2173 // Direct clients are served the CA onboarding flow on allowed paths and
@@ -2148,14 +2176,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2148 func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler {2176 func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler {
2149 // Build a small mux for direct-client onboarding paths.2177 // Build a small mux for direct-client onboarding paths.
2150 onboardingMux := http.NewServeMux()2178 onboardingMux := http.NewServeMux()
2151- onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage)2179+ registerOnboardingRoutes(onboardingMux, ob)
2152- onboardingMux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage)
2153- onboardingMux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage)
2154- onboardingMux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert)
2155- onboardingMux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig)
2156- onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage)
2157- onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert)
2158- onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)
2159 2180
2160 // Reserve ACME challenge path for future use.2181 // Reserve ACME challenge path for future use.
2161 onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) {2182 onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) {
@@ -2182,7 +2203,9 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr
2182 })2203 })
2183 }2204 }
2184 2205
2185-func requestHostMatches(host, expected string) bool {2206+// canonicalHostsEqual compares two hosts after normalising both: stripping
2207+// port, trimming spaces, lowercasing, and removing trailing dot.
2208+func canonicalHostsEqual(host, expected string) bool {
2186 host = strings.TrimSpace(host)2209 host = strings.TrimSpace(host)
2187 if h, _, err := net.SplitHostPort(host); err == nil {2210 if h, _, err := net.SplitHostPort(host); err == nil {
2188 host = h2211 host = h
@@ -2192,19 +2215,14 @@ func requestHostMatches(host, expected string) bool {
2192 return host == expected2215 return host == expected
2193 }2216 }
2194 2217
2195-func gordonDomainOnboardingGate(gordonDomain string, ob *onboarding.Handler, proxyHandler http.Handler) http.Handler {2218+// gordonDomainOnboardingGate returns a handler that serves onboarding routes
2196- mux := http.NewServeMux()2219+// only when the request host matches gordonDomain. For mismatched hosts it
2197- mux.HandleFunc("GET /ca", ob.ServeOnboardingPage)2220+// delegates to proxyHandler. gordonDomain must already be canonicalised
2198- mux.HandleFunc("GET /ca.crt", ob.ServeCACert)2221+// (trimmed, lowered, trailing dot removed).
2199- mux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)2222+func gordonDomainOnboardingGate(gordonDomain string, onboardingMux, proxyHandler http.Handler) http.Handler {
2200- mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage)
2201- mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage)
2202- mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert)
2203- mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig)
2204-
2205 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {2223 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2206- if gordonDomain != "" && requestHostMatches(r.Host, gordonDomain) {2224+ if gordonDomain != "" && canonicalHostsEqual(r.Host, gordonDomain) {
2207- mux.ServeHTTP(w, r)2225+ onboardingMux.ServeHTTP(w, r)
2208 return2226 return
2209 }2227 }
2210 proxyHandler.ServeHTTP(w, r)2228 proxyHandler.ServeHTTP(w, r)
M internal/app/run_http_handlers_test.go +48 -89
@@ -135,98 +135,50 @@ const directAddr = "203.0.113.10:12345"
135135 // trustedProxyAddr is an IP inside the 173.245.48.0/20 range.
136136 const trustedProxyAddr = "173.245.48.5:12345"
137137
138-func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) {
138+func TestCreateHTTPHandlers_DirectHTTPOnboarding_AllPaths(t *testing.T) {
139139 t.Parallel()
140- ca := newTestCA(t)
141- cfg := newOnboardingConfig()
142- svc := &services{caAdapter: ca}
143-
144- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
145-
146- req := httptest.NewRequest(http.MethodGet, "/", nil)
147- req.RemoteAddr = directAddr
148- req.Host = "o2.bnema.dev"
149- rec := httptest.NewRecorder()
150- httpHandler.ServeHTTP(rec, req)
151-
152- assert.Equal(t, http.StatusOK, rec.Code)
153- assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
154- assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
155-}
156-
157-func TestCreateHTTPHandlers_DirectHTTPWellKnownGordonCACertPath_ServesCACert(t *testing.T) {
158- t.Parallel()
159- ca := newTestCA(t)
160- cfg := newOnboardingConfig()
161- svc := &services{caAdapter: ca}
162-
163- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
164-
165- req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca.crt", nil)
166- req.RemoteAddr = directAddr
167- req.Host = "o2.bnema.dev"
168- rec := httptest.NewRecorder()
169- httpHandler.ServeHTTP(rec, req)
170-
171- assert.Equal(t, http.StatusOK, rec.Code)
172- assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert")
173- assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE")
174-}
175-
176-func TestCreateHTTPHandlers_DirectHTTPWellKnownGordonOnboarding_ServesOnboarding(t *testing.T) {
177- t.Parallel()
178- ca := newTestCA(t)
179- cfg := newOnboardingConfig()
180- svc := &services{caAdapter: ca}
181-
182- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
183-
184- req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil)
185- req.RemoteAddr = directAddr
186- req.Host = "o2.bnema.dev"
187- rec := httptest.NewRecorder()
188- httpHandler.ServeHTTP(rec, req)
189-
190- assert.Equal(t, http.StatusOK, rec.Code)
191- assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
192- assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
193-}
194-
195-func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) {
196- t.Parallel()
197- ca := newTestCA(t)
198- cfg := newOnboardingConfig()
199- svc := &services{caAdapter: ca}
200-
201- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
202-
203- req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
204- req.RemoteAddr = directAddr
205- req.Host = "o2.bnema.dev"
206- rec := httptest.NewRecorder()
207- httpHandler.ServeHTTP(rec, req)
208-
209- assert.Equal(t, http.StatusOK, rec.Code)
210- assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert")
211- assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE")
212-}
213140
214-func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) {
215- t.Parallel()
216- ca := newTestCA(t)
217- cfg := newOnboardingConfig()
218- svc := &services{caAdapter: ca}
219-
220- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
221-
222- req := httptest.NewRequest(http.MethodGet, "/web", nil)
223- req.RemoteAddr = directAddr
224- req.Host = "o2.bnema.dev"
225- rec := httptest.NewRecorder()
226- httpHandler.ServeHTTP(rec, req)
141+ tests := []struct {
142+ name string
143+ path string
144+ wantCode int
145+ wantContent string // substring check
146+ wantType string // Content-Type substring
147+ }{
148+ {name: "root serves onboarding", path: "/", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
149+ {name: ".well-known/gordon/ serves onboarding", path: "/.well-known/gordon/", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
150+ {name: ".well-known/gordon/ca.crt serves CA cert", path: "/.well-known/gordon/ca.crt", wantCode: http.StatusOK, wantContent: "BEGIN CERTIFICATE", wantType: "application/x-x509-ca-cert"},
151+ {name: "/ca.crt serves CA cert", path: "/ca.crt", wantCode: http.StatusOK, wantContent: "BEGIN CERTIFICATE", wantType: "application/x-x509-ca-cert"},
152+ {name: "unknown path returns forbidden", path: "/web", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
153+ {name: ".well-known/gordon/ca serves onboarding", path: "/.well-known/gordon/ca", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
154+ {name: "/ca serves onboarding", path: "/ca", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
155+ {name: "/ca.mobileconfig serves mobileconfig", path: "/ca.mobileconfig", wantCode: http.StatusOK, wantContent: "<?xml", wantType: "application/x-apple-aspen-config"},
156+ {name: ".well-known/gordon/ca.mobileconfig serves mobileconfig", path: "/.well-known/gordon/ca.mobileconfig", wantCode: http.StatusOK, wantContent: "<?xml", wantType: "application/x-apple-aspen-config"},
157+ }
227158
228- assert.Equal(t, http.StatusForbidden, rec.Code)
229- assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain")
159+ for _, tc := range tests {
160+ t.Run(tc.name, func(t *testing.T) {
161+ ca := newTestCA(t)
162+ cfg := newOnboardingConfig()
163+ svc := &services{caAdapter: ca}
164+
165+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
166+
167+ req := httptest.NewRequest(http.MethodGet, tc.path, nil)
168+ req.RemoteAddr = directAddr
169+ req.Host = "o2.bnema.dev"
170+ rec := httptest.NewRecorder()
171+ httpHandler.ServeHTTP(rec, req)
172+
173+ assert.Equal(t, tc.wantCode, rec.Code, "%s: status code", tc.name)
174+ if tc.wantType != "" {
175+ assert.Contains(t, rec.Header().Get("Content-Type"), tc.wantType, "%s: content type", tc.name)
176+ }
177+ if tc.wantContent != "" {
178+ assert.Contains(t, rec.Body.String(), tc.wantContent, "%s: body content", tc.name)
179+ }
180+ })
181+ }
230182 }
231183
232184 func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) {
@@ -482,6 +434,13 @@ func TestStartPublicTLSRuntime_ReconcilesAndStartsRenewalLoop(t *testing.T) {
482434 assert.Equal(t, 1, runtime.loopCalls)
483435 }
484436
437+func TestStartPublicTLSRuntime_NilSvc(t *testing.T) {
438+ t.Parallel()
439+
440+ err := startPublicTLSRuntime(context.Background(), nil, zerowrap.Default())
441+ require.NoError(t, err)
442+}
443+
485444 func TestStartPublicTLSRuntime_DoesNotStartRenewalLoopWhenReconcileFails(t *testing.T) {
486445 t.Parallel()
487446
@@ -135,98 +135,50 @@ const directAddr = "203.0.113.10:12345"
135 // trustedProxyAddr is an IP inside the 173.245.48.0/20 range.135 // trustedProxyAddr is an IP inside the 173.245.48.0/20 range.
136 const trustedProxyAddr = "173.245.48.5:12345"136 const trustedProxyAddr = "173.245.48.5:12345"
137 137
138-func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) {138+func TestCreateHTTPHandlers_DirectHTTPOnboarding_AllPaths(t *testing.T) {
139 t.Parallel()139 t.Parallel()
140- ca := newTestCA(t)
141- cfg := newOnboardingConfig()
142- svc := &services{caAdapter: ca}
143-
144- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
145-
146- req := httptest.NewRequest(http.MethodGet, "/", nil)
147- req.RemoteAddr = directAddr
148- req.Host = "o2.bnema.dev"
149- rec := httptest.NewRecorder()
150- httpHandler.ServeHTTP(rec, req)
151-
152- assert.Equal(t, http.StatusOK, rec.Code)
153- assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
154- assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
155-}
156-
157-func TestCreateHTTPHandlers_DirectHTTPWellKnownGordonCACertPath_ServesCACert(t *testing.T) {
158- t.Parallel()
159- ca := newTestCA(t)
160- cfg := newOnboardingConfig()
161- svc := &services{caAdapter: ca}
162-
163- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
164-
165- req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca.crt", nil)
166- req.RemoteAddr = directAddr
167- req.Host = "o2.bnema.dev"
168- rec := httptest.NewRecorder()
169- httpHandler.ServeHTTP(rec, req)
170-
171- assert.Equal(t, http.StatusOK, rec.Code)
172- assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert")
173- assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE")
174-}
175-
176-func TestCreateHTTPHandlers_DirectHTTPWellKnownGordonOnboarding_ServesOnboarding(t *testing.T) {
177- t.Parallel()
178- ca := newTestCA(t)
179- cfg := newOnboardingConfig()
180- svc := &services{caAdapter: ca}
181-
182- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
183-
184- req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil)
185- req.RemoteAddr = directAddr
186- req.Host = "o2.bnema.dev"
187- rec := httptest.NewRecorder()
188- httpHandler.ServeHTTP(rec, req)
189-
190- assert.Equal(t, http.StatusOK, rec.Code)
191- assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
192- assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
193-}
194-
195-func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) {
196- t.Parallel()
197- ca := newTestCA(t)
198- cfg := newOnboardingConfig()
199- svc := &services{caAdapter: ca}
200-
201- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
202-
203- req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
204- req.RemoteAddr = directAddr
205- req.Host = "o2.bnema.dev"
206- rec := httptest.NewRecorder()
207- httpHandler.ServeHTTP(rec, req)
208-
209- assert.Equal(t, http.StatusOK, rec.Code)
210- assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert")
211- assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE")
212-}
213 140
214-func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) {141+ tests := []struct {
215- t.Parallel()142+ name string
216- ca := newTestCA(t)143+ path string
217- cfg := newOnboardingConfig()144+ wantCode int
218- svc := &services{caAdapter: ca}145+ wantContent string // substring check
219-146+ wantType string // Content-Type substring
220- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)147+ }{
221-148+ {name: "root serves onboarding", path: "/", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
222- req := httptest.NewRequest(http.MethodGet, "/web", nil)149+ {name: ".well-known/gordon/ serves onboarding", path: "/.well-known/gordon/", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
223- req.RemoteAddr = directAddr150+ {name: ".well-known/gordon/ca.crt serves CA cert", path: "/.well-known/gordon/ca.crt", wantCode: http.StatusOK, wantContent: "BEGIN CERTIFICATE", wantType: "application/x-x509-ca-cert"},
224- req.Host = "o2.bnema.dev"151+ {name: "/ca.crt serves CA cert", path: "/ca.crt", wantCode: http.StatusOK, wantContent: "BEGIN CERTIFICATE", wantType: "application/x-x509-ca-cert"},
225- rec := httptest.NewRecorder()152+ {name: "unknown path returns forbidden", path: "/web", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
226- httpHandler.ServeHTTP(rec, req)153+ {name: ".well-known/gordon/ca serves onboarding", path: "/.well-known/gordon/ca", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
154+ {name: "/ca serves onboarding", path: "/ca", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
155+ {name: "/ca.mobileconfig serves mobileconfig", path: "/ca.mobileconfig", wantCode: http.StatusOK, wantContent: "<?xml", wantType: "application/x-apple-aspen-config"},
156+ {name: ".well-known/gordon/ca.mobileconfig serves mobileconfig", path: "/.well-known/gordon/ca.mobileconfig", wantCode: http.StatusOK, wantContent: "<?xml", wantType: "application/x-apple-aspen-config"},
157+ }
227 158
228- assert.Equal(t, http.StatusForbidden, rec.Code)159+ for _, tc := range tests {
229- assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain")160+ t.Run(tc.name, func(t *testing.T) {
161+ ca := newTestCA(t)
162+ cfg := newOnboardingConfig()
163+ svc := &services{caAdapter: ca}
164+
165+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
166+
167+ req := httptest.NewRequest(http.MethodGet, tc.path, nil)
168+ req.RemoteAddr = directAddr
169+ req.Host = "o2.bnema.dev"
170+ rec := httptest.NewRecorder()
171+ httpHandler.ServeHTTP(rec, req)
172+
173+ assert.Equal(t, tc.wantCode, rec.Code, "%s: status code", tc.name)
174+ if tc.wantType != "" {
175+ assert.Contains(t, rec.Header().Get("Content-Type"), tc.wantType, "%s: content type", tc.name)
176+ }
177+ if tc.wantContent != "" {
178+ assert.Contains(t, rec.Body.String(), tc.wantContent, "%s: body content", tc.name)
179+ }
180+ })
181+ }
230 }182 }
231 183
232 func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) {184 func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) {
@@ -482,6 +434,13 @@ func TestStartPublicTLSRuntime_ReconcilesAndStartsRenewalLoop(t *testing.T) {
482 assert.Equal(t, 1, runtime.loopCalls)434 assert.Equal(t, 1, runtime.loopCalls)
483 }435 }
484 436
437+func TestStartPublicTLSRuntime_NilSvc(t *testing.T) {
438+ t.Parallel()
439+
440+ err := startPublicTLSRuntime(context.Background(), nil, zerowrap.Default())
441+ require.NoError(t, err)
442+}
443+
485 func TestStartPublicTLSRuntime_DoesNotStartRenewalLoopWhenReconcileFails(t *testing.T) {444 func TestStartPublicTLSRuntime_DoesNotStartRenewalLoopWhenReconcileFails(t *testing.T) {
486 t.Parallel()445 t.Parallel()
487 446
M internal/usecase/publictls/service.go +11 -14
@@ -184,19 +184,26 @@ func (s *Service) Reconcile(ctx context.Context) error {
184184 s.mu.Lock()
185185 s.requiredHosts = required
186186
187- missing := make([]CertificateTarget, 0)
187+ batchSize := s.cfg.ObtainBatchSize
188+ if batchSize <= 0 {
189+ batchSize = defaultObtainBatchSize
190+ }
191+
192+ var missing []CertificateTarget
193+ pendingCount := 0
188194 for _, target := range targets {
189195 if s.certificateExistsLocked(target) {
190196 continue
191197 }
192- missing = append(missing, target)
198+ pendingCount++
199+ if len(missing) < batchSize {
200+ missing = append(missing, target)
201+ }
193202 }
194203 s.mu.Unlock()
195204
196205 // Process missing targets without holding s.mu or the store lock so
197206 // GetCertificate/Status are not blocked during network I/O (Obtain).
198- pendingCount := len(missing)
199- missing = limitMissingTargets(missing, s.cfg.ObtainBatchSize)
200207 if remaining := pendingCount - len(missing); remaining > 0 {
201208 log.Info().
202209 Int("obtain_batch_size", len(missing)).
@@ -209,16 +216,6 @@ func (s *Service) Reconcile(ctx context.Context) error {
209216 return nil
210217 }
211218
212-func limitMissingTargets(targets []CertificateTarget, batchSize int) []CertificateTarget {
213- if batchSize <= 0 {
214- batchSize = defaultObtainBatchSize
215- }
216- if len(targets) <= batchSize {
217- return targets
218- }
219- return targets[:batchSize]
220-}
221-
222219 func populateStoredCertificate(cert *out.StoredCertificate) error {
223220 if cert == nil {
224221 return fmt.Errorf("stored certificate is nil")
@@ -184,19 +184,26 @@ func (s *Service) Reconcile(ctx context.Context) error {
184 s.mu.Lock()184 s.mu.Lock()
185 s.requiredHosts = required185 s.requiredHosts = required
186 186
187- missing := make([]CertificateTarget, 0)187+ batchSize := s.cfg.ObtainBatchSize
188+ if batchSize <= 0 {
189+ batchSize = defaultObtainBatchSize
190+ }
191+
192+ var missing []CertificateTarget
193+ pendingCount := 0
188 for _, target := range targets {194 for _, target := range targets {
189 if s.certificateExistsLocked(target) {195 if s.certificateExistsLocked(target) {
190 continue196 continue
191 }197 }
192- missing = append(missing, target)198+ pendingCount++
199+ if len(missing) < batchSize {
200+ missing = append(missing, target)
201+ }
193 }202 }
194 s.mu.Unlock()203 s.mu.Unlock()
195 204
196 // Process missing targets without holding s.mu or the store lock so205 // Process missing targets without holding s.mu or the store lock so
197 // GetCertificate/Status are not blocked during network I/O (Obtain).206 // GetCertificate/Status are not blocked during network I/O (Obtain).
198- pendingCount := len(missing)
199- missing = limitMissingTargets(missing, s.cfg.ObtainBatchSize)
200 if remaining := pendingCount - len(missing); remaining > 0 {207 if remaining := pendingCount - len(missing); remaining > 0 {
201 log.Info().208 log.Info().
202 Int("obtain_batch_size", len(missing)).209 Int("obtain_batch_size", len(missing)).
@@ -209,16 +216,6 @@ func (s *Service) Reconcile(ctx context.Context) error {
209 return nil216 return nil
210 }217 }
211 218
212-func limitMissingTargets(targets []CertificateTarget, batchSize int) []CertificateTarget {
213- if batchSize <= 0 {
214- batchSize = defaultObtainBatchSize
215- }
216- if len(targets) <= batchSize {
217- return targets
218- }
219- return targets[:batchSize]
220-}
221-
222 func populateStoredCertificate(cert *out.StoredCertificate) error {219 func populateStoredCertificate(cert *out.StoredCertificate) error {
223 if cert == nil {220 if cert == nil {
224 return fmt.Errorf("stored certificate is nil")221 return fmt.Errorf("stored certificate is nil")

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