detached 956a990

feat(http): restrict direct HTTP to CA onboarding

Direct non-proxy HTTP clients now get a CA onboarding gate:
- GET /,/ca,/ca.crt,/ca.mobileconfig serve onboarding resources
- /.well-known/acme-challenge/* reserved with 404
- all other direct HTTP paths return 403
- trusted proxy HTTP traffic continues unchanged
- force_https_redirect does not bypass direct onboarding
- HTTPS /ca* routes gain implicit HEAD support
- tls_port=0 keeps current non-onboarding behavior

The onboarding handler is constructed once when caAdapter != nil
and TLSPort != 0, shared between HTTP gate and HTTPS mux.
commit
956a990756873572fe24aefb8dbdedeab7f9cef6
author
brice · · 2026-04-01 09:38 UTC
parent
f0b7906
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +350 -17
M internal/app/run.go +92 -17
@@ -1652,9 +1652,31 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
16521652 "gordon.proxy",
16531653 )
16541654
1655- // HTTP proxyMux: Cloudflare-proxied traffic only, no onboarding
1655+ // Build the onboarding handler once if internal CA is available and TLS is enabled.
1656+ var obHandler *onboarding.Handler
1657+ if svc.caAdapter != nil && cfg.Server.TLSPort != 0 {
1658+ mobileconfigBytes := pkiadapter.GenerateMobileconfig(
1659+ svc.caAdapter.RootCertificateDER(),
1660+ svc.caAdapter.RootCommonName(),
1661+ )
1662+ obHandler = onboarding.NewHandler(
1663+ svc.caAdapter.RootCertificate(),
1664+ mobileconfigBytes,
1665+ svc.caAdapter.RootFingerprint(),
1666+ cfg.Server.Port,
1667+ cfg.Server.TLSPort,
1668+ )
1669+ }
1670+
1671+ // HTTP proxyMux: trusted proxy traffic flows through the normal proxy chain.
1672+ // Direct clients get an onboarding gate (when CA is available) placed BEFORE
1673+ // HTTPSRedirect so force_https_redirect cannot bypass onboarding.
16561674 proxyMux := http.NewServeMux()
1657- proxyMux.Handle("/", httpProxyWithMiddleware)
1675+ if obHandler != nil {
1676+ proxyMux.Handle("/", directHTTPOnboardingGate(obHandler, proxyAllowedNets, httpProxyWithMiddleware, log))
1677+ } else {
1678+ proxyMux.Handle("/", httpProxyWithMiddleware)
1679+ }
16581680
16591681 // HTTPS proxy handler chain: security headers + proxy + CA onboarding
16601682 // Onboarding routes live on the TLS port so Tailnet / direct clients
@@ -1669,21 +1691,10 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
16691691 }
16701692
16711693 httpsMux := http.NewServeMux()
1672- if svc.caAdapter != nil {
1673- mobileconfigBytes := pkiadapter.GenerateMobileconfig(
1674- svc.caAdapter.RootCertificateDER(),
1675- svc.caAdapter.RootCommonName(),
1676- )
1677- onboardingHandler := onboarding.NewHandler(
1678- svc.caAdapter.RootCertificate(),
1679- mobileconfigBytes,
1680- svc.caAdapter.RootFingerprint(),
1681- cfg.Server.Port,
1682- cfg.Server.TLSPort,
1683- )
1684- httpsMux.HandleFunc("GET /ca", onboardingHandler.ServeOnboardingPage)
1685- httpsMux.HandleFunc("GET /ca.crt", onboardingHandler.ServeCACert)
1686- httpsMux.HandleFunc("GET /ca.mobileconfig", onboardingHandler.ServeMobileconfig)
1694+ if obHandler != nil {
1695+ httpsMux.HandleFunc("GET /ca", obHandler.ServeOnboardingPage)
1696+ httpsMux.HandleFunc("GET /ca.crt", obHandler.ServeCACert)
1697+ httpsMux.HandleFunc("GET /ca.mobileconfig", obHandler.ServeMobileconfig)
16871698 }
16881699 httpsMux.Handle("/", proxyHandler)
16891700
@@ -1692,6 +1703,70 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
16921703 return registryMux, proxyMux, httpsHandler
16931704 }
16941705
1706+// directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic
1707+// by source IP. Trusted proxy IPs flow through to the normal proxy chain.
1708+// Direct clients are served the CA onboarding flow on allowed paths and
1709+// receive 403 on everything else. This gate runs BEFORE HTTPSRedirect so
1710+// force_https_redirect cannot bypass onboarding for direct clients.
1711+func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler {
1712+ // Build a small mux for direct-client onboarding paths.
1713+ onboardingMux := http.NewServeMux()
1714+ onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage)
1715+ onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage)
1716+ onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert)
1717+ onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)
1718+
1719+ // Reserve ACME challenge path for future use.
1720+ onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) {
1721+ http.Error(w, "not found", http.StatusNotFound)
1722+ })
1723+
1724+ // Catch-all: reject any other direct HTTP request.
1725+ // Uses a method-aware split: GET writes a body, HEAD gets an empty 403.
1726+ onboardingMux.HandleFunc("/", directHTTPForbidden)
1727+
1728+ onboardingWithMiddleware := middleware.Chain(
1729+ middleware.PanicRecovery(log),
1730+ middleware.SecurityHeaders,
1731+ )(onboardingMux)
1732+
1733+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1734+ remoteIP := extractDirectIP(r.RemoteAddr)
1735+ if isTrustedOrLocal(remoteIP, proxyNets) {
1736+ proxyChain.ServeHTTP(w, r)
1737+ return
1738+ }
1739+ onboardingWithMiddleware.ServeHTTP(w, r)
1740+ })
1741+}
1742+
1743+// directHTTPForbidden responds with 403 for non-onboarding HTTP paths.
1744+// HEAD requests get an empty body per HTTP semantics.
1745+func directHTTPForbidden(w http.ResponseWriter, r *http.Request) {
1746+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
1747+ w.WriteHeader(http.StatusForbidden)
1748+ if r.Method != http.MethodHead {
1749+ _, _ = w.Write([]byte("Only certificate onboarding is available over HTTP.\n"))
1750+ }
1751+}
1752+
1753+// extractDirectIP returns the IP portion of a RemoteAddr, stripping the port.
1754+func extractDirectIP(remoteAddr string) string {
1755+ host, _, err := net.SplitHostPort(remoteAddr)
1756+ if err != nil {
1757+ return remoteAddr
1758+ }
1759+ return host
1760+}
1761+
1762+// isTrustedOrLocal checks whether an IP belongs to trusted proxy nets or localhost.
1763+func isTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool {
1764+ if parsed := net.ParseIP(ip); parsed != nil && parsed.IsLoopback() {
1765+ return true
1766+ }
1767+ return middleware.IsTrustedProxy(ip, proxyNets)
1768+}
1769+
16951770 func buildRegistryHandlerWithMiddleware(
16961771 svc *services,
16971772 cfg Config,
@@ -1652,9 +1652,31 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1652 "gordon.proxy",1652 "gordon.proxy",
1653 )1653 )
1654 1654
1655- // HTTP proxyMux: Cloudflare-proxied traffic only, no onboarding1655+ // Build the onboarding handler once if internal CA is available and TLS is enabled.
1656+ var obHandler *onboarding.Handler
1657+ if svc.caAdapter != nil && cfg.Server.TLSPort != 0 {
1658+ mobileconfigBytes := pkiadapter.GenerateMobileconfig(
1659+ svc.caAdapter.RootCertificateDER(),
1660+ svc.caAdapter.RootCommonName(),
1661+ )
1662+ obHandler = onboarding.NewHandler(
1663+ svc.caAdapter.RootCertificate(),
1664+ mobileconfigBytes,
1665+ svc.caAdapter.RootFingerprint(),
1666+ cfg.Server.Port,
1667+ cfg.Server.TLSPort,
1668+ )
1669+ }
1670+
1671+ // HTTP proxyMux: trusted proxy traffic flows through the normal proxy chain.
1672+ // Direct clients get an onboarding gate (when CA is available) placed BEFORE
1673+ // HTTPSRedirect so force_https_redirect cannot bypass onboarding.
1656 proxyMux := http.NewServeMux()1674 proxyMux := http.NewServeMux()
1657- proxyMux.Handle("/", httpProxyWithMiddleware)1675+ if obHandler != nil {
1676+ proxyMux.Handle("/", directHTTPOnboardingGate(obHandler, proxyAllowedNets, httpProxyWithMiddleware, log))
1677+ } else {
1678+ proxyMux.Handle("/", httpProxyWithMiddleware)
1679+ }
1658 1680
1659 // HTTPS proxy handler chain: security headers + proxy + CA onboarding1681 // HTTPS proxy handler chain: security headers + proxy + CA onboarding
1660 // Onboarding routes live on the TLS port so Tailnet / direct clients1682 // Onboarding routes live on the TLS port so Tailnet / direct clients
@@ -1669,21 +1691,10 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1669 }1691 }
1670 1692
1671 httpsMux := http.NewServeMux()1693 httpsMux := http.NewServeMux()
1672- if svc.caAdapter != nil {1694+ if obHandler != nil {
1673- mobileconfigBytes := pkiadapter.GenerateMobileconfig(1695+ httpsMux.HandleFunc("GET /ca", obHandler.ServeOnboardingPage)
1674- svc.caAdapter.RootCertificateDER(),1696+ httpsMux.HandleFunc("GET /ca.crt", obHandler.ServeCACert)
1675- svc.caAdapter.RootCommonName(),1697+ httpsMux.HandleFunc("GET /ca.mobileconfig", obHandler.ServeMobileconfig)
1676- )
1677- onboardingHandler := onboarding.NewHandler(
1678- svc.caAdapter.RootCertificate(),
1679- mobileconfigBytes,
1680- svc.caAdapter.RootFingerprint(),
1681- cfg.Server.Port,
1682- cfg.Server.TLSPort,
1683- )
1684- httpsMux.HandleFunc("GET /ca", onboardingHandler.ServeOnboardingPage)
1685- httpsMux.HandleFunc("GET /ca.crt", onboardingHandler.ServeCACert)
1686- httpsMux.HandleFunc("GET /ca.mobileconfig", onboardingHandler.ServeMobileconfig)
1687 }1698 }
1688 httpsMux.Handle("/", proxyHandler)1699 httpsMux.Handle("/", proxyHandler)
1689 1700
@@ -1692,6 +1703,70 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
1692 return registryMux, proxyMux, httpsHandler1703 return registryMux, proxyMux, httpsHandler
1693 }1704 }
1694 1705
1706+// directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic
1707+// by source IP. Trusted proxy IPs flow through to the normal proxy chain.
1708+// Direct clients are served the CA onboarding flow on allowed paths and
1709+// receive 403 on everything else. This gate runs BEFORE HTTPSRedirect so
1710+// force_https_redirect cannot bypass onboarding for direct clients.
1711+func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler {
1712+ // Build a small mux for direct-client onboarding paths.
1713+ onboardingMux := http.NewServeMux()
1714+ onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage)
1715+ onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage)
1716+ onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert)
1717+ onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)
1718+
1719+ // Reserve ACME challenge path for future use.
1720+ onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) {
1721+ http.Error(w, "not found", http.StatusNotFound)
1722+ })
1723+
1724+ // Catch-all: reject any other direct HTTP request.
1725+ // Uses a method-aware split: GET writes a body, HEAD gets an empty 403.
1726+ onboardingMux.HandleFunc("/", directHTTPForbidden)
1727+
1728+ onboardingWithMiddleware := middleware.Chain(
1729+ middleware.PanicRecovery(log),
1730+ middleware.SecurityHeaders,
1731+ )(onboardingMux)
1732+
1733+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1734+ remoteIP := extractDirectIP(r.RemoteAddr)
1735+ if isTrustedOrLocal(remoteIP, proxyNets) {
1736+ proxyChain.ServeHTTP(w, r)
1737+ return
1738+ }
1739+ onboardingWithMiddleware.ServeHTTP(w, r)
1740+ })
1741+}
1742+
1743+// directHTTPForbidden responds with 403 for non-onboarding HTTP paths.
1744+// HEAD requests get an empty body per HTTP semantics.
1745+func directHTTPForbidden(w http.ResponseWriter, r *http.Request) {
1746+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
1747+ w.WriteHeader(http.StatusForbidden)
1748+ if r.Method != http.MethodHead {
1749+ _, _ = w.Write([]byte("Only certificate onboarding is available over HTTP.\n"))
1750+ }
1751+}
1752+
1753+// extractDirectIP returns the IP portion of a RemoteAddr, stripping the port.
1754+func extractDirectIP(remoteAddr string) string {
1755+ host, _, err := net.SplitHostPort(remoteAddr)
1756+ if err != nil {
1757+ return remoteAddr
1758+ }
1759+ return host
1760+}
1761+
1762+// isTrustedOrLocal checks whether an IP belongs to trusted proxy nets or localhost.
1763+func isTrustedOrLocal(ip string, proxyNets []*net.IPNet) bool {
1764+ if parsed := net.ParseIP(ip); parsed != nil && parsed.IsLoopback() {
1765+ return true
1766+ }
1767+ return middleware.IsTrustedProxy(ip, proxyNets)
1768+}
1769+
1695 func buildRegistryHandlerWithMiddleware(1770 func buildRegistryHandlerWithMiddleware(
1696 svc *services,1771 svc *services,
1697 cfg Config,1772 cfg Config,
M internal/app/run_http_handlers_test.go +258 -0
@@ -6,7 +6,11 @@ import (
66 "net/http/httptest"
77 "testing"
88
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
912 adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
13+ pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
1014 "github.com/bnema/zerowrap"
1115 )
1216
@@ -67,6 +71,260 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T)
6771 }
6872 }
6973
74+// --- Task 3: Direct HTTP onboarding gate integration tests ---
75+
76+// newTestCA creates a temporary CA adapter for integration tests.
77+func newTestCA(t *testing.T) *pkiadapter.CA {
78+ t.Helper()
79+ ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
80+ require.NoError(t, err)
81+ return ca
82+}
83+
84+// newOnboardingConfig returns a Config with trusted proxy CIDRs and TLS enabled.
85+func newOnboardingConfig() Config {
86+ cfg := Config{}
87+ cfg.Server.Port = 8088
88+ cfg.Server.TLSPort = 8443
89+ cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample
90+ return cfg
91+}
92+
93+// directAddr is an IP outside the trusted proxy CIDR range.
94+const directAddr = "203.0.113.10:12345"
95+
96+// trustedProxyAddr is an IP inside the 173.245.48.0/20 range.
97+const trustedProxyAddr = "173.245.48.5:12345"
98+
99+func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) {
100+ t.Parallel()
101+ ca := newTestCA(t)
102+ cfg := newOnboardingConfig()
103+ svc := &services{caAdapter: ca}
104+
105+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
106+
107+ req := httptest.NewRequest(http.MethodGet, "/", nil)
108+ req.RemoteAddr = directAddr
109+ req.Host = "o2.bnema.dev"
110+ rec := httptest.NewRecorder()
111+ httpHandler.ServeHTTP(rec, req)
112+
113+ assert.Equal(t, http.StatusOK, rec.Code)
114+ assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
115+ assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
116+}
117+
118+func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) {
119+ t.Parallel()
120+ ca := newTestCA(t)
121+ cfg := newOnboardingConfig()
122+ svc := &services{caAdapter: ca}
123+
124+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
125+
126+ req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
127+ req.RemoteAddr = directAddr
128+ req.Host = "o2.bnema.dev"
129+ rec := httptest.NewRecorder()
130+ httpHandler.ServeHTTP(rec, req)
131+
132+ assert.Equal(t, http.StatusOK, rec.Code)
133+ assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert")
134+ assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE")
135+}
136+
137+func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) {
138+ t.Parallel()
139+ ca := newTestCA(t)
140+ cfg := newOnboardingConfig()
141+ svc := &services{caAdapter: ca}
142+
143+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
144+
145+ req := httptest.NewRequest(http.MethodGet, "/web", nil)
146+ req.RemoteAddr = directAddr
147+ req.Host = "o2.bnema.dev"
148+ rec := httptest.NewRecorder()
149+ httpHandler.ServeHTTP(rec, req)
150+
151+ assert.Equal(t, http.StatusForbidden, rec.Code)
152+ assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain")
153+}
154+
155+func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) {
156+ t.Parallel()
157+ ca := newTestCA(t)
158+ cfg := newOnboardingConfig()
159+ svc := &services{caAdapter: ca}
160+
161+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
162+
163+ req := httptest.NewRequest(http.MethodGet, "/", nil)
164+ req.RemoteAddr = trustedProxyAddr
165+ req.Host = "app.example.com"
166+ rec := httptest.NewRecorder()
167+ httpHandler.ServeHTTP(rec, req)
168+
169+ // Trusted proxy traffic should NOT get the onboarding page.
170+ // It should flow through to the normal proxy handler (which returns 502/404/etc,
171+ // but definitely not onboarding HTML).
172+ assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
173+}
174+
175+func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResource(t *testing.T) {
176+ t.Parallel()
177+ ca := newTestCA(t)
178+ cfg := newOnboardingConfig()
179+ svc := &services{caAdapter: ca}
180+
181+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
182+
183+ req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
184+ req.RemoteAddr = trustedProxyAddr
185+ req.Host = "app.example.com"
186+ rec := httptest.NewRecorder()
187+ httpHandler.ServeHTTP(rec, req)
188+
189+ // Trusted proxy traffic must NOT leak the internal CA cert.
190+ assert.NotEqual(t, "application/x-x509-ca-cert", rec.Header().Get("Content-Type"))
191+ assert.NotContains(t, rec.Body.String(), "BEGIN CERTIFICATE")
192+}
193+
194+func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *testing.T) {
195+ t.Parallel()
196+ ca := newTestCA(t)
197+ cfg := newOnboardingConfig()
198+ cfg.Server.ForceHTTPSRedirect = true
199+ svc := &services{caAdapter: ca}
200+
201+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
202+
203+ req := httptest.NewRequest(http.MethodGet, "/", nil)
204+ req.RemoteAddr = directAddr
205+ req.Host = "o2.bnema.dev"
206+ rec := httptest.NewRecorder()
207+ httpHandler.ServeHTTP(rec, req)
208+
209+ // Direct onboarding must win over force_https_redirect.
210+ assert.Equal(t, http.StatusOK, rec.Code)
211+ assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
212+}
213+
214+func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) {
215+ t.Parallel()
216+ ca := newTestCA(t)
217+ cfg := newOnboardingConfig()
218+ svc := &services{caAdapter: ca}
219+
220+ _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
221+
222+ req := httptest.NewRequest(http.MethodGet, "/ca", nil)
223+ req.RemoteAddr = directAddr
224+ req.Host = "o2.bnema.dev"
225+ rec := httptest.NewRecorder()
226+ httpsHandler.ServeHTTP(rec, req)
227+
228+ assert.Equal(t, http.StatusOK, rec.Code)
229+ assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
230+}
231+
232+func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
233+ t.Parallel()
234+ ca := newTestCA(t)
235+ cfg := newOnboardingConfig()
236+ svc := &services{caAdapter: ca}
237+
238+ _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
239+
240+ // Use httptest.NewServer so Go's net/http server strips the body for HEAD.
241+ // httptest.NewRecorder does not apply server-level HEAD body stripping.
242+ ts := httptest.NewServer(httpsHandler)
243+ defer ts.Close()
244+
245+ for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} {
246+ resp, err := ts.Client().Head(ts.URL + path)
247+ require.NoError(t, err)
248+ resp.Body.Close()
249+
250+ assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", path)
251+ }
252+}
253+
254+func TestCreateHTTPHandlers_DirectHTTPForbiddenHEAD_ReturnsForbidden(t *testing.T) {
255+ t.Parallel()
256+ ca := newTestCA(t)
257+ cfg := newOnboardingConfig()
258+ svc := &services{caAdapter: ca}
259+
260+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
261+
262+ req := httptest.NewRequest(http.MethodHead, "/web", nil)
263+ req.RemoteAddr = directAddr
264+ req.Host = "o2.bnema.dev"
265+ rec := httptest.NewRecorder()
266+ httpHandler.ServeHTTP(rec, req)
267+
268+ assert.Equal(t, http.StatusForbidden, rec.Code)
269+ assert.Empty(t, rec.Body.String(), "HEAD 403 should have empty body")
270+}
271+
272+func TestCreateHTTPHandlers_DirectHTTPACMEChallenge_Returns404(t *testing.T) {
273+ t.Parallel()
274+ ca := newTestCA(t)
275+ cfg := newOnboardingConfig()
276+ svc := &services{caAdapter: ca}
277+
278+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
279+
280+ req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/test-token", nil)
281+ req.RemoteAddr = directAddr
282+ req.Host = "o2.bnema.dev"
283+ rec := httptest.NewRecorder()
284+ httpHandler.ServeHTTP(rec, req)
285+
286+ assert.Equal(t, http.StatusNotFound, rec.Code)
287+}
288+
289+func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) {
290+ t.Parallel()
291+ ca := newTestCA(t)
292+ cfg := newOnboardingConfig()
293+ cfg.Server.TLSPort = 0 // TLS disabled
294+ svc := &services{caAdapter: ca}
295+
296+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
297+
298+ req := httptest.NewRequest(http.MethodGet, "/", nil)
299+ req.RemoteAddr = directAddr
300+ req.Host = "o2.bnema.dev"
301+ rec := httptest.NewRecorder()
302+ httpHandler.ServeHTTP(rec, req)
303+
304+ // With TLS disabled, onboarding should NOT be served.
305+ assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
306+}
307+
308+func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
309+ t.Parallel()
310+ // Verify that initPKI returns an error when TLS is enabled but CA init fails.
311+ // We test this by providing a non-writable data dir.
312+ si := &serviceInit{
313+ ctx: context.Background(),
314+ cfg: Config{},
315+ log: zerowrap.Default(),
316+ svc: &services{},
317+ }
318+ si.cfg.Server.TLSPort = 8443
319+ // Point data dir to a non-existent path under /proc to guarantee failure.
320+ si.cfg.Server.DataDir = "/proc/nonexistent/path"
321+
322+ err := si.initPKI()
323+ assert.Error(t, err, "initPKI should fail when CA cannot be initialized")
324+}
325+
326+// --- End Task 3 tests ---
327+
70328 func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {
71329 t.Parallel()
72330
@@ -6,7 +6,11 @@ import (
6 "net/http/httptest"6 "net/http/httptest"
7 "testing"7 "testing"
8 8
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
9 adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"12 adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
13+ pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
10 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
11 )15 )
12 16
@@ -67,6 +71,260 @@ func TestCreateHTTPHandlers_LocalMode_RestrictsRegistryToLoopback(t *testing.T)
67 }71 }
68 }72 }
69 73
74+// --- Task 3: Direct HTTP onboarding gate integration tests ---
75+
76+// newTestCA creates a temporary CA adapter for integration tests.
77+func newTestCA(t *testing.T) *pkiadapter.CA {
78+ t.Helper()
79+ ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default())
80+ require.NoError(t, err)
81+ return ca
82+}
83+
84+// newOnboardingConfig returns a Config with trusted proxy CIDRs and TLS enabled.
85+func newOnboardingConfig() Config {
86+ cfg := Config{}
87+ cfg.Server.Port = 8088
88+ cfg.Server.TLSPort = 8443
89+ cfg.Server.ProxyAllowedIPs = []string{"173.245.48.0/20"} // Cloudflare sample
90+ return cfg
91+}
92+
93+// directAddr is an IP outside the trusted proxy CIDR range.
94+const directAddr = "203.0.113.10:12345"
95+
96+// trustedProxyAddr is an IP inside the 173.245.48.0/20 range.
97+const trustedProxyAddr = "173.245.48.5:12345"
98+
99+func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) {
100+ t.Parallel()
101+ ca := newTestCA(t)
102+ cfg := newOnboardingConfig()
103+ svc := &services{caAdapter: ca}
104+
105+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
106+
107+ req := httptest.NewRequest(http.MethodGet, "/", nil)
108+ req.RemoteAddr = directAddr
109+ req.Host = "o2.bnema.dev"
110+ rec := httptest.NewRecorder()
111+ httpHandler.ServeHTTP(rec, req)
112+
113+ assert.Equal(t, http.StatusOK, rec.Code)
114+ assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
115+ assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
116+}
117+
118+func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) {
119+ t.Parallel()
120+ ca := newTestCA(t)
121+ cfg := newOnboardingConfig()
122+ svc := &services{caAdapter: ca}
123+
124+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
125+
126+ req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
127+ req.RemoteAddr = directAddr
128+ req.Host = "o2.bnema.dev"
129+ rec := httptest.NewRecorder()
130+ httpHandler.ServeHTTP(rec, req)
131+
132+ assert.Equal(t, http.StatusOK, rec.Code)
133+ assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert")
134+ assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE")
135+}
136+
137+func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) {
138+ t.Parallel()
139+ ca := newTestCA(t)
140+ cfg := newOnboardingConfig()
141+ svc := &services{caAdapter: ca}
142+
143+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
144+
145+ req := httptest.NewRequest(http.MethodGet, "/web", nil)
146+ req.RemoteAddr = directAddr
147+ req.Host = "o2.bnema.dev"
148+ rec := httptest.NewRecorder()
149+ httpHandler.ServeHTTP(rec, req)
150+
151+ assert.Equal(t, http.StatusForbidden, rec.Code)
152+ assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain")
153+}
154+
155+func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) {
156+ t.Parallel()
157+ ca := newTestCA(t)
158+ cfg := newOnboardingConfig()
159+ svc := &services{caAdapter: ca}
160+
161+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
162+
163+ req := httptest.NewRequest(http.MethodGet, "/", nil)
164+ req.RemoteAddr = trustedProxyAddr
165+ req.Host = "app.example.com"
166+ rec := httptest.NewRecorder()
167+ httpHandler.ServeHTTP(rec, req)
168+
169+ // Trusted proxy traffic should NOT get the onboarding page.
170+ // It should flow through to the normal proxy handler (which returns 502/404/etc,
171+ // but definitely not onboarding HTML).
172+ assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
173+}
174+
175+func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResource(t *testing.T) {
176+ t.Parallel()
177+ ca := newTestCA(t)
178+ cfg := newOnboardingConfig()
179+ svc := &services{caAdapter: ca}
180+
181+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
182+
183+ req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
184+ req.RemoteAddr = trustedProxyAddr
185+ req.Host = "app.example.com"
186+ rec := httptest.NewRecorder()
187+ httpHandler.ServeHTTP(rec, req)
188+
189+ // Trusted proxy traffic must NOT leak the internal CA cert.
190+ assert.NotEqual(t, "application/x-x509-ca-cert", rec.Header().Get("Content-Type"))
191+ assert.NotContains(t, rec.Body.String(), "BEGIN CERTIFICATE")
192+}
193+
194+func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *testing.T) {
195+ t.Parallel()
196+ ca := newTestCA(t)
197+ cfg := newOnboardingConfig()
198+ cfg.Server.ForceHTTPSRedirect = true
199+ svc := &services{caAdapter: ca}
200+
201+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
202+
203+ req := httptest.NewRequest(http.MethodGet, "/", nil)
204+ req.RemoteAddr = directAddr
205+ req.Host = "o2.bnema.dev"
206+ rec := httptest.NewRecorder()
207+ httpHandler.ServeHTTP(rec, req)
208+
209+ // Direct onboarding must win over force_https_redirect.
210+ assert.Equal(t, http.StatusOK, rec.Code)
211+ assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
212+}
213+
214+func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) {
215+ t.Parallel()
216+ ca := newTestCA(t)
217+ cfg := newOnboardingConfig()
218+ svc := &services{caAdapter: ca}
219+
220+ _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
221+
222+ req := httptest.NewRequest(http.MethodGet, "/ca", nil)
223+ req.RemoteAddr = directAddr
224+ req.Host = "o2.bnema.dev"
225+ rec := httptest.NewRecorder()
226+ httpsHandler.ServeHTTP(rec, req)
227+
228+ assert.Equal(t, http.StatusOK, rec.Code)
229+ assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
230+}
231+
232+func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
233+ t.Parallel()
234+ ca := newTestCA(t)
235+ cfg := newOnboardingConfig()
236+ svc := &services{caAdapter: ca}
237+
238+ _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default())
239+
240+ // Use httptest.NewServer so Go's net/http server strips the body for HEAD.
241+ // httptest.NewRecorder does not apply server-level HEAD body stripping.
242+ ts := httptest.NewServer(httpsHandler)
243+ defer ts.Close()
244+
245+ for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} {
246+ resp, err := ts.Client().Head(ts.URL + path)
247+ require.NoError(t, err)
248+ resp.Body.Close()
249+
250+ assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD %s should return 200", path)
251+ }
252+}
253+
254+func TestCreateHTTPHandlers_DirectHTTPForbiddenHEAD_ReturnsForbidden(t *testing.T) {
255+ t.Parallel()
256+ ca := newTestCA(t)
257+ cfg := newOnboardingConfig()
258+ svc := &services{caAdapter: ca}
259+
260+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
261+
262+ req := httptest.NewRequest(http.MethodHead, "/web", nil)
263+ req.RemoteAddr = directAddr
264+ req.Host = "o2.bnema.dev"
265+ rec := httptest.NewRecorder()
266+ httpHandler.ServeHTTP(rec, req)
267+
268+ assert.Equal(t, http.StatusForbidden, rec.Code)
269+ assert.Empty(t, rec.Body.String(), "HEAD 403 should have empty body")
270+}
271+
272+func TestCreateHTTPHandlers_DirectHTTPACMEChallenge_Returns404(t *testing.T) {
273+ t.Parallel()
274+ ca := newTestCA(t)
275+ cfg := newOnboardingConfig()
276+ svc := &services{caAdapter: ca}
277+
278+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
279+
280+ req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/test-token", nil)
281+ req.RemoteAddr = directAddr
282+ req.Host = "o2.bnema.dev"
283+ rec := httptest.NewRecorder()
284+ httpHandler.ServeHTTP(rec, req)
285+
286+ assert.Equal(t, http.StatusNotFound, rec.Code)
287+}
288+
289+func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) {
290+ t.Parallel()
291+ ca := newTestCA(t)
292+ cfg := newOnboardingConfig()
293+ cfg.Server.TLSPort = 0 // TLS disabled
294+ svc := &services{caAdapter: ca}
295+
296+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default())
297+
298+ req := httptest.NewRequest(http.MethodGet, "/", nil)
299+ req.RemoteAddr = directAddr
300+ req.Host = "o2.bnema.dev"
301+ rec := httptest.NewRecorder()
302+ httpHandler.ServeHTTP(rec, req)
303+
304+ // With TLS disabled, onboarding should NOT be served.
305+ assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
306+}
307+
308+func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
309+ t.Parallel()
310+ // Verify that initPKI returns an error when TLS is enabled but CA init fails.
311+ // We test this by providing a non-writable data dir.
312+ si := &serviceInit{
313+ ctx: context.Background(),
314+ cfg: Config{},
315+ log: zerowrap.Default(),
316+ svc: &services{},
317+ }
318+ si.cfg.Server.TLSPort = 8443
319+ // Point data dir to a non-existent path under /proc to guarantee failure.
320+ si.cfg.Server.DataDir = "/proc/nonexistent/path"
321+
322+ err := si.initPKI()
323+ assert.Error(t, err, "initPKI should fail when CA cannot be initialized")
324+}
325+
326+// --- End Task 3 tests ---
327+
70 func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {328 func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) {
71 t.Parallel()329 t.Parallel()
72 330

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