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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 1652 | 1652 | "gordon.proxy", | |
| 1653 | 1653 | ) | |
| 1654 | 1654 | ||
| 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. | |
| 1656 | 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 | 1681 | // HTTPS proxy handler chain: security headers + proxy + CA onboarding | |
| 1660 | 1682 | // 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 | 1693 | 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) | |
| 1687 | 1698 | } | |
| 1688 | 1699 | httpsMux.Handle("/", proxyHandler) | |
| 1689 | 1700 | ||
| @@ -1692,6 +1703,70 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 1692 | 1703 | 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 | 1770 | func buildRegistryHandlerWithMiddleware( | |
| 1696 | 1771 | svc *services, | |
| 1697 | 1772 | 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 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. | |||
| 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 onboarding | 1681 | // HTTPS proxy handler chain: security headers + proxy + CA onboarding | ||
| 1660 | // Onboarding routes live on the TLS port so Tailnet / direct clients | 1682 | // 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, httpsHandler | 1703 | 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 ( | |||
| 6 | 6 | "net/http/httptest" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 9 | 12 | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 13 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 10 | 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 | 328 | func TestBuildRegistryCIDRAllowlistMiddleware_InvalidEntries_DenyAll(t *testing.T) { | |
| 71 | 329 | t.Parallel() | |
| 72 | 330 | ||
| @@ -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 | ||||