diff --git a/cmd/traefik/traefik.go b/cmd/traefik/traefik.go index d30ae6ae3..cc04a38f3 100644 --- a/cmd/traefik/traefik.go +++ b/cmd/traefik/traefik.go @@ -303,7 +303,7 @@ func setupServer(staticConfiguration *static.Configuration) (*server.Server, err dialerManager := tcp.NewDialerManager(spiffeX509Source) acmeHTTPHandler := getHTTPChallengeHandler(acmeProviders, httpChallengeProvider) - managerFactory := service.NewManagerFactory(*staticConfiguration, routinesPool, observabilityMgr, transportManager, proxyBuilder, acmeHTTPHandler) + managerFactory := service.NewManagerFactory(*staticConfiguration, routinesPool, observabilityMgr, transportManager, proxyBuilder, acmeHTTPHandler, tlsManager) // Router factory diff --git a/pkg/api/certificate.go b/pkg/api/certificate.go new file mode 100644 index 000000000..32bf9aae1 --- /dev/null +++ b/pkg/api/certificate.go @@ -0,0 +1,172 @@ +package api + +import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "fmt" + "sort" + "time" +) + +const ( + certStatusEnabled = "enabled" + certStatusWarning = "warning" + certStatusExpired = "expired" +) + +// certificateRepresentation represents a certificate in the API. +type certificateRepresentation struct { + Name string `json:"name"` // SHA-256 fingerprint of the DER-encoded certificate. + SANs []string `json:"sans"` + NotAfter time.Time `json:"notAfter"` + NotBefore time.Time `json:"notBefore"` + SerialNumber string `json:"serialNumber"` + CommonName string `json:"commonName"` + IssuerOrg string `json:"issuerOrg,omitempty"` + IssuerCN string `json:"issuerCN,omitempty"` + IssuerCountry string `json:"issuerCountry,omitempty"` + Organization string `json:"organization,omitempty"` + Country string `json:"country,omitempty"` + Version string `json:"version"` + KeyType string `json:"keyType"` + KeySize int `json:"keySize,omitempty"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + CertFingerprint string `json:"certFingerprint"` + PublicKeyFingerprint string `json:"publicKeyFingerprint"` + Status string `json:"status"` +} + +// Interface methods for sort.go compatibility. +func (c certificateRepresentation) name() string { + return c.CommonName +} + +func (c certificateRepresentation) status() string { + return c.Status +} + +func (c certificateRepresentation) issuer() string { + if c.IssuerOrg != "" { + return c.IssuerOrg + } + return c.IssuerCN +} + +func (c certificateRepresentation) validUntil() time.Time { + return c.NotAfter +} + +// buildCertificateRepresentation builds a certificateRepresentation from an x509 certificate. +func buildCertificateRepresentation(cert *x509.Certificate) certificateRepresentation { + keyType, keySize := extractKeyInfo(cert) + certFingerprint, pubKeyFingerprint := extractFingerprints(cert) + issuerOrg, issuerCN, issuerCountry := extractIssuerInfo(cert) + organization, country := extractSubjectInfo(cert) + + return certificateRepresentation{ + Name: certFingerprint, + SANs: extractSANs(cert), + NotAfter: cert.NotAfter, + NotBefore: cert.NotBefore, + SerialNumber: cert.SerialNumber.String(), + CommonName: cert.Subject.CommonName, + IssuerOrg: issuerOrg, + IssuerCN: issuerCN, + IssuerCountry: issuerCountry, + Organization: organization, + Country: country, + Version: formatVersion(cert.Version), + KeyType: keyType, + KeySize: keySize, + SignatureAlgorithm: cert.SignatureAlgorithm.String(), + CertFingerprint: certFingerprint, + PublicKeyFingerprint: pubKeyFingerprint, + Status: getCertificateStatus(cert.NotAfter), + } +} + +// extractSANs extracts Subject Alternative Names from a certificate. +func extractSANs(cert *x509.Certificate) []string { + sans := make([]string, 0, len(cert.DNSNames)+len(cert.IPAddresses)) + sans = append(sans, cert.DNSNames...) + for _, ip := range cert.IPAddresses { + sans = append(sans, ip.String()) + } + sort.Strings(sans) + return sans +} + +// extractKeyInfo determines the key type and size from a certificate. +func extractKeyInfo(cert *x509.Certificate) (keyType string, keySize int) { + keyType = "Unknown" + keySize = 0 + + switch pubKey := cert.PublicKey.(type) { + case *rsa.PublicKey: + keyType = "RSA" + keySize = pubKey.N.BitLen() + case *ecdsa.PublicKey: + keyType = "ECDSA" + keySize = pubKey.Curve.Params().BitSize + } + + return keyType, keySize +} + +// extractFingerprints calculates SHA-256 fingerprints for certificate and public key. +func extractFingerprints(cert *x509.Certificate) (certFingerprint, pubKeyFingerprint string) { + certHash := sha256.Sum256(cert.Raw) + certFingerprint = hex.EncodeToString(certHash[:]) + + pubKeyBytes, err := x509.MarshalPKIXPublicKey(cert.PublicKey) + if err == nil { + pubKeyHash := sha256.Sum256(pubKeyBytes) + pubKeyFingerprint = hex.EncodeToString(pubKeyHash[:]) + } + + return certFingerprint, pubKeyFingerprint +} + +// extractIssuerInfo extracts issuer information from a certificate. +func extractIssuerInfo(cert *x509.Certificate) (org, cn, country string) { + if len(cert.Issuer.Organization) > 0 { + org = cert.Issuer.Organization[0] + } + cn = cert.Issuer.CommonName + if len(cert.Issuer.Country) > 0 { + country = cert.Issuer.Country[0] + } + return org, cn, country +} + +// extractSubjectInfo extracts subject information from a certificate. +func extractSubjectInfo(cert *x509.Certificate) (organization, country string) { + if len(cert.Subject.Organization) > 0 { + organization = cert.Subject.Organization[0] + } + if len(cert.Subject.Country) > 0 { + country = cert.Subject.Country[0] + } + return organization, country +} + +// formatVersion formats the X.509 version for display. +func formatVersion(version int) string { + return fmt.Sprintf("v%d", version) +} + +// getCertificateStatus returns the status of a certificate based on its expiry. +func getCertificateStatus(notAfter time.Time) string { + remaining := time.Until(notAfter) + if remaining < 0 { + return certStatusExpired + } + // Show warning for certificates with validity less than 30 days left. + if remaining < 30*24*time.Hour { + return certStatusWarning + } + return certStatusEnabled +} diff --git a/pkg/api/handler.go b/pkg/api/handler.go index 2cd9e2270..49424d9be 100644 --- a/pkg/api/handler.go +++ b/pkg/api/handler.go @@ -11,6 +11,7 @@ import ( "github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/config/runtime" "github.com/traefik/traefik/v3/pkg/config/static" + "github.com/traefik/traefik/v3/pkg/tls" "github.com/traefik/traefik/v3/pkg/version" ) @@ -58,12 +59,14 @@ type Handler struct { // runtimeConfiguration is the data set used to create all the data representations exposed by the API. runtimeConfiguration *runtime.Configuration + + tlsManager *tls.Manager } // NewBuilder returns a http.Handler builder based on runtime.Configuration. -func NewBuilder(staticConfig static.Configuration) func(*runtime.Configuration) http.Handler { +func NewBuilder(staticConfig static.Configuration, tlsManager *tls.Manager) func(*runtime.Configuration) http.Handler { return func(configuration *runtime.Configuration) http.Handler { - return New(staticConfig, configuration).createRouter() + return New(staticConfig, configuration).WithTLSManager(tlsManager).createRouter() } } @@ -81,8 +84,14 @@ func New(staticConfig static.Configuration, runtimeConfig *runtime.Configuration } } +// WithTLSManager sets the TLS manager on the handler, enabling the certificate API endpoints. +func (h *Handler) WithTLSManager(tlsManager *tls.Manager) *Handler { + h.tlsManager = tlsManager + return h +} + // createRouter creates API routes and router. -func (h Handler) createRouter() *mux.Router { +func (h *Handler) createRouter() *mux.Router { router := mux.NewRouter().UseEncodedPath() apiRouter := router.PathPrefix(h.staticConfig.API.BasePath).Subrouter().UseEncodedPath() @@ -120,12 +129,15 @@ func (h Handler) createRouter() *mux.Router { apiRouter.Methods(http.MethodGet).Path("/api/udp/services").HandlerFunc(h.getUDPServices) apiRouter.Methods(http.MethodGet).Path("/api/udp/services/{serviceID}").HandlerFunc(h.getUDPService) + apiRouter.Methods(http.MethodGet).Path("/api/certificates").HandlerFunc(h.getCertificates) + apiRouter.Methods(http.MethodGet).Path("/api/certificates/{certificateID}").HandlerFunc(h.getCertificate) + version.Handler{}.Append(apiRouter) return router } -func (h Handler) getRuntimeConfiguration(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getRuntimeConfiguration(rw http.ResponseWriter, request *http.Request) { siRepr := make(map[string]*serviceInfoRepresentation, len(h.runtimeConfiguration.Services)) for k, v := range h.runtimeConfiguration.Services { siRepr[k] = &serviceInfoRepresentation{ diff --git a/pkg/api/handler_certificate.go b/pkg/api/handler_certificate.go new file mode 100644 index 000000000..159557857 --- /dev/null +++ b/pkg/api/handler_certificate.go @@ -0,0 +1,99 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/rs/zerolog/log" +) + +func keepCertificate(cert certificateRepresentation, criterion *searchCriterion) bool { + if criterion == nil { + return true + } + + // Combine all searchable fields + searchFields := make([]string, 0, 3+len(cert.SANs)) + searchFields = append(searchFields, cert.CommonName, cert.IssuerOrg, cert.IssuerCN) + searchFields = append(searchFields, cert.SANs...) + + return criterion.withStatus(cert.Status) && + criterion.searchIn(searchFields...) +} + +func (h *Handler) getCertificates(rw http.ResponseWriter, request *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + allCerts := h.extractCertificates() + + query := request.URL.Query() + criterion := newSearchCriterion(query) + + results := make([]certificateRepresentation, 0, len(allCerts)) + for _, cert := range allCerts { + if keepCertificate(cert, criterion) { + results = append(results, cert) + } + } + + sortCertificates(query, results) + + pageInfo, err := pagination(request, len(results)) + if err != nil { + writeError(rw, err.Error(), http.StatusBadRequest) + return + } + + rw.Header().Set(nextPageHeader, strconv.Itoa(pageInfo.nextPage)) + + err = json.NewEncoder(rw).Encode(results[pageInfo.startIndex:pageInfo.endIndex]) + if err != nil { + log.Error().Err(err).Msg("Unable to encode certificates") + writeError(rw, err.Error(), http.StatusInternalServerError) + } +} + +func (h *Handler) getCertificate(rw http.ResponseWriter, request *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + certID := mux.Vars(request)["certificateID"] + + if h.tlsManager == nil { + writeError(rw, fmt.Sprintf("certificate not found: %s", certID), http.StatusNotFound) + return + } + + certs := h.tlsManager.GetServerCertificates() + x509Cert, ok := certs[certID] + + if !ok { + writeError(rw, fmt.Sprintf("certificate not found: %s", certID), http.StatusNotFound) + return + } + + cert := buildCertificateRepresentation(x509Cert) + + if err := json.NewEncoder(rw).Encode(cert); err != nil { + log.Error().Err(err).Str("id", certID).Msg("Unable to encode certificate") + writeError(rw, err.Error(), http.StatusInternalServerError) + } +} + +func (h *Handler) extractCertificates() []certificateRepresentation { + if h.tlsManager == nil { + return []certificateRepresentation{} + } + + x509Certs := h.tlsManager.GetServerCertificates() + result := make([]certificateRepresentation, 0, len(x509Certs)) + + for _, cert := range x509Certs { + rep := buildCertificateRepresentation(cert) + result = append(result, rep) + } + + return result +} diff --git a/pkg/api/handler_certificate_test.go b/pkg/api/handler_certificate_test.go new file mode 100644 index 000000000..2ba1d9aab --- /dev/null +++ b/pkg/api/handler_certificate_test.go @@ -0,0 +1,603 @@ +package api + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/json" + "encoding/pem" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/traefik/traefik/v3/pkg/config/static" + tlspkg "github.com/traefik/traefik/v3/pkg/tls" + "github.com/traefik/traefik/v3/pkg/types" +) + +// generateTestCertificate creates a test certificate with the given parameters. +// The certificate will be valid from notBefore to notAfter. +func generateTestCertificate(commonName string, sans []string, notBefore, notAfter time.Time) (types.FileOrContent, types.FileOrContent, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", "", err + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return "", "", err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Acme Co"}, + CommonName: commonName, + }, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + + // Add SANs, distinguishing IP addresses from DNS names. + for _, san := range sans { + if ip := net.ParseIP(san); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, san) + } + } + + certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return "", "", err + } + + certPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }) + + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + + return types.FileOrContent(certPEM), types.FileOrContent(keyPEM), nil +} + +func TestHandler_Certificates(t *testing.T) { + type expected struct { + statusCode int + validateResponse func(t *testing.T, body []byte) + } + + type certSetup struct { + loadCerts bool + loadMultipleCerts bool + } + + // Generate test certificates dynamically with valid expiration dates + now := time.Now() + + // Certificate valid for 50+ years (status: "enabled") + localhostCert, localhostKey, err := generateTestCertificate( + "", + []string{"127.0.0.1", "::1", "example.com"}, + now.Add(-24*time.Hour), + now.Add(50*365*24*time.Hour), + ) + require.NoError(t, err) + + // Certificate with warning status (expires in 15 days) + warnCert, warnKey, err := generateTestCertificate( + "warning.com", + []string{"warning.com", "www.warning.com"}, + now.Add(-24*time.Hour), + now.Add(15*24*time.Hour), + ) + require.NoError(t, err) + + // Certificate with expired status (already expired) + expiredCert, expiredKey, err := generateTestCertificate( + "expired.com", + []string{"expired.com"}, + now.Add(-365*24*time.Hour), + now.Add(-24*time.Hour), + ) + require.NoError(t, err) + + // Certificate for search testing (different common name / SANs) + acmeCert, acmeKey, err := generateTestCertificate( + "acme.example.org", + []string{"acme.example.org", "api.acme.example.org"}, + now.Add(-24*time.Hour), + now.Add(50*365*24*time.Hour), + ) + require.NoError(t, err) + + // Compute fingerprint from the generated localhost cert PEM + block, _ := pem.Decode([]byte(localhostCert)) + parsed, _ := x509.ParseCertificate(block.Bytes) + hash := sha256.Sum256(parsed.Raw) + localhostFingerprint := hex.EncodeToString(hash[:]) + + testCases := []struct { + desc string + path string + setup certSetup + expected expected + }{ + { + desc: "all certificates, but no certificates loaded", + path: "/api/certificates", + setup: certSetup{loadCerts: false}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + assert.Empty(t, certs) + }, + }, + }, + { + desc: "all certificates, with one certificate loaded", + path: "/api/certificates", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + + cert := certs[0] + assert.Regexp(t, `^[0-9a-f]{64}$`, cert["name"]) + assert.Equal(t, "Acme Co", cert["issuerOrg"]) + assert.Equal(t, "enabled", cert["status"]) + assert.ElementsMatch(t, []any{"127.0.0.1", "::1", "example.com"}, cert["sans"]) + }, + }, + }, + { + desc: "certificates filtered by search text - example", + path: "/api/certificates?search=example", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + sans := certs[0]["sans"].([]any) + assert.Contains(t, sans, "example.com") + }, + }, + }, + { + desc: "certificates filtered by search text - no match", + path: "/api/certificates?search=nonexistent", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + assert.Empty(t, certs) + }, + }, + }, + { + desc: "certificates sorted by status", + path: "/api/certificates?sortBy=status", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + assert.Equal(t, "enabled", certs[0]["status"]) + }, + }, + }, + { + desc: "certificates sorted by validUntil descending", + path: "/api/certificates?sortBy=validUntil&direction=desc", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + }, + }, + }, + { + desc: "certificates filtered by status - enabled", + path: "/api/certificates?status=enabled", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + assert.Equal(t, "enabled", certs[0]["status"]) + }, + }, + }, + { + desc: "certificates filtered by status - expired", + path: "/api/certificates?status=expired", + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + assert.Empty(t, certs) + }, + }, + }, + { + desc: "one certificate by fingerprint", + path: "/api/certificates/" + localhostFingerprint, + setup: certSetup{loadCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var cert map[string]any + require.NoError(t, json.Unmarshal(body, &cert)) + assert.Regexp(t, `^[0-9a-f]{64}$`, cert["name"]) + assert.Equal(t, "enabled", cert["status"]) + assert.ElementsMatch(t, []any{"127.0.0.1", "::1", "example.com"}, cert["sans"]) + }, + }, + }, + { + desc: "certificate does not exist", + path: "/api/certificates/non-existent-certificate", + setup: certSetup{loadCerts: false}, + expected: expected{ + statusCode: http.StatusNotFound, + }, + }, + { + desc: "multiple certificates with different statuses", + path: "/api/certificates", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // Verify all statuses are present + statuses := make(map[string]int) + for _, cert := range certs { + status := cert["status"].(string) + statuses[status]++ + } + assert.Equal(t, 2, statuses["enabled"]) + assert.Equal(t, 1, statuses["warning"]) + assert.Equal(t, 1, statuses["expired"]) + }, + }, + }, + { + desc: "certificates sorted by name ascending", + path: "/api/certificates?sortBy=name&direction=asc", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // Verify names are in ascending order + prevName := "" + for _, cert := range certs { + commonName := cert["commonName"].(string) + if prevName != "" { + assert.LessOrEqual(t, prevName, commonName) + } + prevName = commonName + } + }, + }, + }, + { + desc: "certificates sorted by name descending", + path: "/api/certificates?sortBy=name&direction=desc", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // Verify names are in descending order + prevName := "zzzzzzz" + for _, cert := range certs { + commonName := cert["commonName"].(string) + assert.GreaterOrEqual(t, prevName, commonName) + prevName = commonName + } + }, + }, + }, + { + desc: "certificates sorted by status ascending", + path: "/api/certificates?sortBy=status&direction=asc", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // Verify statuses are in ascending order (enabled < expired < warning) + prevStatus := "" + for _, cert := range certs { + status := cert["status"].(string) + if prevStatus != "" { + assert.LessOrEqual(t, prevStatus, status) + } + prevStatus = status + } + }, + }, + }, + { + desc: "certificates sorted by issuer", + path: "/api/certificates?sortBy=issuer", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // All certificates have same issuer "Acme Co" + for _, cert := range certs { + assert.Equal(t, "Acme Co", cert["issuerOrg"]) + } + }, + }, + }, + { + desc: "certificates sorted by validUntil ascending", + path: "/api/certificates?sortBy=validUntil&direction=asc", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // Verify notAfter dates are in ascending order + var prevTime time.Time + for _, cert := range certs { + notAfter := cert["notAfter"].(string) + certTime, err := time.Parse(time.RFC3339, notAfter) + require.NoError(t, err) + if !prevTime.IsZero() { + assert.False(t, certTime.Before(prevTime)) + } + prevTime = certTime + } + }, + }, + }, + { + desc: "certificates filtered by status - warning", + path: "/api/certificates?status=warning", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + assert.Equal(t, "warning", certs[0]["status"]) + assert.Contains(t, certs[0]["sans"], "warning.com") + }, + }, + }, + { + desc: "certificates filtered by status - expired", + path: "/api/certificates?status=expired", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + assert.Equal(t, "expired", certs[0]["status"]) + assert.Contains(t, certs[0]["sans"], "expired.com") + }, + }, + }, + { + desc: "certificates filtered by search - commonName", + path: "/api/certificates?search=acme.example.org", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 1) + assert.Equal(t, "acme.example.org", certs[0]["commonName"]) + }, + }, + }, + { + desc: "certificates filtered by search - issuerOrg", + path: "/api/certificates?search=Acme", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + // All certificates have "Acme Co" as issuer + require.Len(t, certs, 4) + }, + }, + }, + { + desc: "certificates with comprehensive field validation", + path: "/api/certificates", + setup: certSetup{loadMultipleCerts: true}, + expected: expected{ + statusCode: http.StatusOK, + validateResponse: func(t *testing.T, body []byte) { + t.Helper() + var certs []map[string]any + require.NoError(t, json.Unmarshal(body, &certs)) + require.Len(t, certs, 4) + + // Check the certificate with commonName set (warning.com) + var certWithCN map[string]any + for _, c := range certs { + if c["commonName"] == "warning.com" { + certWithCN = c + break + } + } + require.NotNil(t, certWithCN, "Should find certificate with commonName") + + // Validate all expected fields are present + assert.NotEmpty(t, certWithCN["name"]) + assert.NotEmpty(t, certWithCN["sans"]) + assert.NotEmpty(t, certWithCN["notAfter"]) + assert.NotEmpty(t, certWithCN["notBefore"]) + assert.NotEmpty(t, certWithCN["serialNumber"]) + assert.Equal(t, "warning.com", certWithCN["commonName"]) + assert.NotEmpty(t, certWithCN["issuerOrg"]) + assert.NotEmpty(t, certWithCN["version"]) + assert.Equal(t, "RSA", certWithCN["keyType"]) + assert.InDelta(t, float64(2048), certWithCN["keySize"], 0) + assert.NotEmpty(t, certWithCN["signatureAlgorithm"]) + assert.NotEmpty(t, certWithCN["certFingerprint"]) + assert.NotEmpty(t, certWithCN["publicKeyFingerprint"]) + assert.Equal(t, "warning", certWithCN["status"]) + }, + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + tlsManager := tlspkg.NewManager(nil) + + if test.setup.loadCerts { + dynamicConfigs := []*tlspkg.CertAndStores{{ + Certificate: tlspkg.Certificate{ + CertFile: localhostCert, + KeyFile: localhostKey, + }, + }} + + tlsManager.UpdateConfigs(t.Context(), nil, nil, dynamicConfigs) + } + + if test.setup.loadMultipleCerts { + dynamicConfigs := []*tlspkg.CertAndStores{ + { + Certificate: tlspkg.Certificate{ + CertFile: localhostCert, + KeyFile: localhostKey, + }, + }, + { + Certificate: tlspkg.Certificate{ + CertFile: warnCert, + KeyFile: warnKey, + }, + }, + { + Certificate: tlspkg.Certificate{ + CertFile: expiredCert, + KeyFile: expiredKey, + }, + }, + { + Certificate: tlspkg.Certificate{ + CertFile: acmeCert, + KeyFile: acmeKey, + }, + }, + } + + tlsManager.UpdateConfigs(t.Context(), nil, nil, dynamicConfigs) + } + + handler := New(static.Configuration{API: &static.API{}, Global: &static.Global{}}, nil).WithTLSManager(tlsManager) + server := httptest.NewServer(handler.createRouter()) + + resp, err := http.DefaultClient.Get(server.URL + test.path) + require.NoError(t, err) + + require.Equal(t, test.expected.statusCode, resp.StatusCode) + + contents, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + err = resp.Body.Close() + require.NoError(t, err) + + // Only validate content type and body for success responses + if resp.StatusCode == http.StatusOK { + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + if test.expected.validateResponse != nil { + test.expected.validateResponse(t, contents) + } + } + }) + } +} diff --git a/pkg/api/handler_entrypoint.go b/pkg/api/handler_entrypoint.go index 2595d9faa..ced9a254e 100644 --- a/pkg/api/handler_entrypoint.go +++ b/pkg/api/handler_entrypoint.go @@ -19,7 +19,7 @@ type entryPointRepresentation struct { Name string `json:"name,omitempty"` } -func (h Handler) getEntryPoints(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getEntryPoints(rw http.ResponseWriter, request *http.Request) { results := make([]entryPointRepresentation, 0, len(h.staticConfig.EntryPoints)) for name, ep := range h.staticConfig.EntryPoints { @@ -50,7 +50,7 @@ func (h Handler) getEntryPoints(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getEntryPoint(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getEntryPoint(rw http.ResponseWriter, request *http.Request) { scapedEntryPointID := mux.Vars(request)["entryPointID"] entryPointID, err := url.PathUnescape(scapedEntryPointID) diff --git a/pkg/api/handler_http.go b/pkg/api/handler_http.go index 9cc71fd0e..4ec58003e 100644 --- a/pkg/api/handler_http.go +++ b/pkg/api/handler_http.go @@ -71,7 +71,7 @@ func newMiddlewareRepresentation(name string, mi *runtime.MiddlewareInfo) middle } } -func (h Handler) getRouters(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getRouters(rw http.ResponseWriter, request *http.Request) { results := make([]routerRepresentation, 0, len(h.runtimeConfiguration.Routers)) query := request.URL.Query() @@ -102,7 +102,7 @@ func (h Handler) getRouters(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getRouter(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getRouter(rw http.ResponseWriter, request *http.Request) { scapedRouterID := mux.Vars(request)["routerID"] routerID, err := url.PathUnescape(scapedRouterID) @@ -128,7 +128,7 @@ func (h Handler) getRouter(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getServices(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getServices(rw http.ResponseWriter, request *http.Request) { results := make([]serviceRepresentation, 0, len(h.runtimeConfiguration.Services)) query := request.URL.Query() @@ -159,7 +159,7 @@ func (h Handler) getServices(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getService(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getService(rw http.ResponseWriter, request *http.Request) { scapedServiceID := mux.Vars(request)["serviceID"] serviceID, err := url.PathUnescape(scapedServiceID) @@ -185,7 +185,7 @@ func (h Handler) getService(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getMiddlewares(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getMiddlewares(rw http.ResponseWriter, request *http.Request) { results := make([]middlewareRepresentation, 0, len(h.runtimeConfiguration.Middlewares)) query := request.URL.Query() @@ -216,7 +216,7 @@ func (h Handler) getMiddlewares(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getMiddleware(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getMiddleware(rw http.ResponseWriter, request *http.Request) { scapedMiddlewareID := mux.Vars(request)["middlewareID"] middlewareID, err := url.PathUnescape(scapedMiddlewareID) diff --git a/pkg/api/handler_overview.go b/pkg/api/handler_overview.go index 9279370a6..d9d55281c 100644 --- a/pkg/api/handler_overview.go +++ b/pkg/api/handler_overview.go @@ -8,6 +8,7 @@ import ( "github.com/rs/zerolog/log" "github.com/traefik/traefik/v3/pkg/config/runtime" "github.com/traefik/traefik/v3/pkg/config/static" + "github.com/traefik/traefik/v3/pkg/tls" ) type schemeOverview struct { @@ -30,14 +31,15 @@ type features struct { } type overview struct { - HTTP schemeOverview `json:"http"` - TCP schemeOverview `json:"tcp"` - UDP schemeOverview `json:"udp"` - Features features `json:"features,omitempty"` - Providers []string `json:"providers,omitempty"` + HTTP schemeOverview `json:"http"` + TCP schemeOverview `json:"tcp"` + UDP schemeOverview `json:"udp"` + Certificates *section `json:"certificates,omitempty"` + Features features `json:"features,omitempty"` + Providers []string `json:"providers,omitempty"` } -func (h Handler) getOverview(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getOverview(rw http.ResponseWriter, request *http.Request) { result := overview{ HTTP: schemeOverview{ Routers: getHTTPRouterSection(h.runtimeConfiguration.Routers), @@ -53,8 +55,9 @@ func (h Handler) getOverview(rw http.ResponseWriter, request *http.Request) { Routers: getUDPRouterSection(h.runtimeConfiguration.UDPRouters), Services: getUDPServiceSection(h.runtimeConfiguration.UDPServices), }, - Features: getFeatures(h.staticConfig), - Providers: getProviders(h.staticConfig), + Certificates: getCertificatesSection(h.tlsManager), + Features: getFeatures(h.staticConfig), + Providers: getProviders(h.staticConfig), } rw.Header().Set("Content-Type", "application/json") @@ -285,3 +288,29 @@ func getTracing(conf static.Configuration) string { return "" } + +func getCertificatesSection(tlsManager *tls.Manager) *section { + if tlsManager == nil { + return nil + } + + x509Certs := tlsManager.GetServerCertificates() + var countWarnings int + var countErrors int + + for _, cert := range x509Certs { + status := getCertificateStatus(cert.NotAfter) + switch status { + case certStatusExpired: + countErrors++ + case certStatusWarning: + countWarnings++ + } + } + + return §ion{ + Total: len(x509Certs), + Warnings: countWarnings, + Errors: countErrors, + } +} diff --git a/pkg/api/handler_support_dump.go b/pkg/api/handler_support_dump.go index 08f0e0e0a..82b5d7e20 100644 --- a/pkg/api/handler_support_dump.go +++ b/pkg/api/handler_support_dump.go @@ -13,7 +13,7 @@ import ( "github.com/traefik/traefik/v3/pkg/version" ) -func (h Handler) getSupportDump(rw http.ResponseWriter, req *http.Request) { +func (h *Handler) getSupportDump(rw http.ResponseWriter, req *http.Request) { logger := log.Ctx(req.Context()) staticConfig, err := redactor.Anonymize(h.staticConfig) diff --git a/pkg/api/handler_tcp.go b/pkg/api/handler_tcp.go index 39656c028..7b2b18046 100644 --- a/pkg/api/handler_tcp.go +++ b/pkg/api/handler_tcp.go @@ -66,7 +66,7 @@ func newTCPMiddlewareRepresentation(name string, mi *runtime.TCPMiddlewareInfo) } } -func (h Handler) getTCPRouters(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getTCPRouters(rw http.ResponseWriter, request *http.Request) { results := make([]tcpRouterRepresentation, 0, len(h.runtimeConfiguration.TCPRouters)) query := request.URL.Query() @@ -97,7 +97,7 @@ func (h Handler) getTCPRouters(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getTCPRouter(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getTCPRouter(rw http.ResponseWriter, request *http.Request) { scapedRouterID := mux.Vars(request)["routerID"] routerID, err := url.PathUnescape(scapedRouterID) @@ -123,7 +123,7 @@ func (h Handler) getTCPRouter(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getTCPServices(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getTCPServices(rw http.ResponseWriter, request *http.Request) { results := make([]tcpServiceRepresentation, 0, len(h.runtimeConfiguration.TCPServices)) query := request.URL.Query() @@ -154,7 +154,7 @@ func (h Handler) getTCPServices(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getTCPService(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getTCPService(rw http.ResponseWriter, request *http.Request) { scapedServiceID := mux.Vars(request)["serviceID"] serviceID, err := url.PathUnescape(scapedServiceID) @@ -180,7 +180,7 @@ func (h Handler) getTCPService(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getTCPMiddlewares(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getTCPMiddlewares(rw http.ResponseWriter, request *http.Request) { results := make([]tcpMiddlewareRepresentation, 0, len(h.runtimeConfiguration.Middlewares)) query := request.URL.Query() @@ -211,7 +211,7 @@ func (h Handler) getTCPMiddlewares(rw http.ResponseWriter, request *http.Request } } -func (h Handler) getTCPMiddleware(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getTCPMiddleware(rw http.ResponseWriter, request *http.Request) { scapedMiddlewareID := mux.Vars(request)["middlewareID"] middlewareID, err := url.PathUnescape(scapedMiddlewareID) diff --git a/pkg/api/handler_udp.go b/pkg/api/handler_udp.go index db7185abd..cede6c16f 100644 --- a/pkg/api/handler_udp.go +++ b/pkg/api/handler_udp.go @@ -45,7 +45,7 @@ func newUDPServiceRepresentation(name string, si *runtime.UDPServiceInfo) udpSer } } -func (h Handler) getUDPRouters(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getUDPRouters(rw http.ResponseWriter, request *http.Request) { results := make([]udpRouterRepresentation, 0, len(h.runtimeConfiguration.UDPRouters)) query := request.URL.Query() @@ -76,7 +76,7 @@ func (h Handler) getUDPRouters(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getUDPRouter(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getUDPRouter(rw http.ResponseWriter, request *http.Request) { scapedRouterID := mux.Vars(request)["routerID"] routerID, err := url.PathUnescape(scapedRouterID) @@ -102,7 +102,7 @@ func (h Handler) getUDPRouter(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getUDPServices(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getUDPServices(rw http.ResponseWriter, request *http.Request) { results := make([]udpServiceRepresentation, 0, len(h.runtimeConfiguration.UDPServices)) query := request.URL.Query() @@ -133,7 +133,7 @@ func (h Handler) getUDPServices(rw http.ResponseWriter, request *http.Request) { } } -func (h Handler) getUDPService(rw http.ResponseWriter, request *http.Request) { +func (h *Handler) getUDPService(rw http.ResponseWriter, request *http.Request) { scapedServiceID := mux.Vars(request)["serviceID"] serviceID, err := url.PathUnescape(scapedServiceID) diff --git a/pkg/api/sort.go b/pkg/api/sort.go index cf70679cd..c6135e619 100644 --- a/pkg/api/sort.go +++ b/pkg/api/sort.go @@ -4,6 +4,7 @@ import ( "cmp" "net/url" "sort" + "time" ) const ( @@ -14,6 +15,9 @@ const ( const ( ascendantSorting = "asc" descendantSorting = "desc" + + sortFieldName = "name" + sortFieldStatus = "status" ) type orderedWithName interface { @@ -31,6 +35,14 @@ type orderedRouter interface { entryPointsCount() int } +type orderedCertificate interface { + orderedWithName + + status() string + issuer() string + validUntil() time.Time +} + func sortRouters[T orderedRouter](values url.Values, routers []T) { sortBy := values.Get(sortByParam) @@ -40,7 +52,7 @@ func sortRouters[T orderedRouter](values url.Values, routers []T) { } switch sortBy { - case "name": + case sortFieldName: sortByName(direction, routers) case "provider": @@ -49,7 +61,7 @@ func sortRouters[T orderedRouter](values url.Values, routers []T) { case "priority": sortByFunc(direction, routers, func(i int) int { return routers[i].priority() }) - case "status": + case sortFieldStatus: sortByFunc(direction, routers, func(i int) string { return routers[i].status() }) case "rule": @@ -170,7 +182,7 @@ func sortServices[T orderedService](values url.Values, services []T) { } switch sortBy { - case "name": + case sortFieldName: sortByName(direction, services) case "type": @@ -182,7 +194,7 @@ func sortServices[T orderedService](values url.Values, services []T) { case "provider": sortByFunc(direction, services, func(i int) string { return services[i].provider() }) - case "status": + case sortFieldStatus: sortByFunc(direction, services, func(i int) string { return services[i].status() }) default: @@ -291,7 +303,7 @@ func sortMiddlewares[T orderedMiddleware](values url.Values, middlewares []T) { } switch sortBy { - case "name": + case sortFieldName: sortByName(direction, middlewares) case "type": @@ -300,7 +312,7 @@ func sortMiddlewares[T orderedMiddleware](values url.Values, middlewares []T) { case "provider": sortByFunc(direction, middlewares, func(i int) string { return middlewares[i].provider() }) - case "status": + case sortFieldStatus: sortByFunc(direction, middlewares, func(i int) string { return middlewares[i].status() }) default: @@ -340,6 +352,56 @@ func (m tcpMiddlewareRepresentation) status() string { return m.Status } +func sortCertificates[T orderedCertificate](values url.Values, certificates []T) { + sortBy := values.Get(sortByParam) + + direction := values.Get(directionParam) + if direction == "" { + direction = ascendantSorting + } + + switch sortBy { + case sortFieldName, "cn": + sortByName(direction, certificates) + + case sortFieldStatus: + sortByFunc(direction, certificates, func(i int) string { return certificates[i].status() }) + + case "issuer": + sortByFunc(direction, certificates, func(i int) string { return certificates[i].issuer() }) + + case "validUntil": + sortByTime(direction, certificates, func(i int) time.Time { return certificates[i].validUntil() }) + + default: + sortByName(direction, certificates) + } +} + +func sortByTime[T orderedWithName](direction string, results []T, fn func(int) time.Time) { + // Ascending + if direction == ascendantSorting { + sort.Slice(results, func(i, j int) bool { + ti, tj := fn(i), fn(j) + if ti.Equal(tj) { + return results[i].name() < results[j].name() + } + return ti.Before(tj) + }) + + return + } + + // Descending + sort.Slice(results, func(i, j int) bool { + ti, tj := fn(i), fn(j) + if ti.Equal(tj) { + return results[i].name() > results[j].name() + } + return ti.After(tj) + }) +} + func sortByName[T orderedWithName](direction string, results []T) { // Ascending if direction == ascendantSorting { diff --git a/pkg/server/routerfactory_test.go b/pkg/server/routerfactory_test.go index a287c22ee..11f9dd44c 100644 --- a/pkg/server/routerfactory_test.go +++ b/pkg/server/routerfactory_test.go @@ -54,7 +54,7 @@ func TestReuseService(t *testing.T) { transportManager := service.NewTransportManager(nil) transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) - managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil) + managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil, nil) tlsManager := tls.NewManager(nil) dialerManager := tcp.NewDialerManager(nil) @@ -180,7 +180,7 @@ func TestServerResponseEmptyBackend(t *testing.T) { transportManager := service.NewTransportManager(nil) transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) - managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil) + managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil, nil) tlsManager := tls.NewManager(nil) dialerManager := tcp.NewDialerManager(nil) @@ -226,7 +226,7 @@ func TestInternalServices(t *testing.T) { transportManager := service.NewTransportManager(nil) transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) - managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil) + managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil, nil) tlsManager := tls.NewManager(nil) dialerManager := tcp.NewDialerManager(nil) @@ -275,8 +275,8 @@ func TestRecursionService(t *testing.T) { transportManager := service.NewTransportManager(nil) transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) - managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil) tlsManager := tls.NewManager(nil) + managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil, tlsManager) dialerManager := tcp.NewDialerManager(nil) dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}}) diff --git a/pkg/server/service/managerfactory.go b/pkg/server/service/managerfactory.go index a850ec745..3d22ed38d 100644 --- a/pkg/server/service/managerfactory.go +++ b/pkg/server/service/managerfactory.go @@ -12,6 +12,7 @@ import ( "github.com/traefik/traefik/v3/pkg/observability/metrics" "github.com/traefik/traefik/v3/pkg/safe" "github.com/traefik/traefik/v3/pkg/server/middleware" + "github.com/traefik/traefik/v3/pkg/tls" ) // ManagerFactory a factory of service manager. @@ -32,7 +33,7 @@ type ManagerFactory struct { } // NewManagerFactory creates a new ManagerFactory. -func NewManagerFactory(staticConfiguration static.Configuration, routinesPool *safe.Pool, observabilityMgr *middleware.ObservabilityMgr, transportManager *TransportManager, proxyBuilder ProxyBuilder, acmeHTTPHandler http.Handler) *ManagerFactory { +func NewManagerFactory(staticConfiguration static.Configuration, routinesPool *safe.Pool, observabilityMgr *middleware.ObservabilityMgr, transportManager *TransportManager, proxyBuilder ProxyBuilder, acmeHTTPHandler http.Handler, tlsManager *tls.Manager) *ManagerFactory { factory := &ManagerFactory{ observabilityMgr: observabilityMgr, routinesPool: routinesPool, @@ -42,7 +43,7 @@ func NewManagerFactory(staticConfiguration static.Configuration, routinesPool *s } if staticConfiguration.API != nil { - apiRouterBuilder := api.NewBuilder(staticConfiguration) + apiRouterBuilder := api.NewBuilder(staticConfiguration, tlsManager) if staticConfiguration.API.Dashboard { factory.dashboardHandler = dashboard.Handler{BasePath: staticConfiguration.API.BasePath} diff --git a/pkg/tls/tlsmanager.go b/pkg/tls/tlsmanager.go index 9003c21f3..cf58922a2 100644 --- a/pkg/tls/tlsmanager.go +++ b/pkg/tls/tlsmanager.go @@ -2,8 +2,10 @@ package tls import ( "context" + "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/hex" "errors" "fmt" "hash/fnv" @@ -295,8 +297,11 @@ func (m *Manager) Get(storeName, configName string) (*tls.Config, error) { // GetServerCertificates returns all certificates from the default store, // as well as the user-defined default certificate (if it exists). -func (m *Manager) GetServerCertificates() []*x509.Certificate { - var certificates []*x509.Certificate +func (m *Manager) GetServerCertificates() map[string]*x509.Certificate { + m.lock.RLock() + defer m.lock.RUnlock() + + certificates := make(map[string]*x509.Certificate) // The default store is the only relevant, because it is the only one configurable. defaultStore, ok := m.stores[DefaultTLSStoreName] @@ -306,28 +311,35 @@ func (m *Manager) GetServerCertificates() []*x509.Certificate { // We iterate over all the certificates. if defaultStore.DynamicCerts != nil && defaultStore.DynamicCerts.Get() != nil { - for _, cert := range defaultStore.DynamicCerts.Get().(map[string]*CertificateData) { - x509Cert, err := x509.ParseCertificate(cert.Certificate.Certificate[0]) - if err != nil { - continue + certs, ok := defaultStore.DynamicCerts.Get().(map[string]*CertificateData) + if ok { + for _, cert := range certs { + // Use Leaf if available (it should always be populated by parseCertificate) + if cert.Certificate.Leaf == nil { + log.Warn().Msg("TLS: certificate Leaf is nil, skipping certificate in API response") + continue + } + hash := sha256.Sum256(cert.Certificate.Leaf.Raw) + fingerprint := hex.EncodeToString(hash[:]) + certificates[fingerprint] = cert.Certificate.Leaf } - - certificates = append(certificates, x509Cert) } } if defaultStore.DefaultCertificate != nil { - x509Cert, err := x509.ParseCertificate(defaultStore.DefaultCertificate.Certificate.Certificate[0]) - if err != nil { + if defaultStore.DefaultCertificate.Certificate.Leaf == nil { + log.Warn().Msg("TLS: default certificate Leaf is nil, skipping in API response") return certificates } // Excluding the generated Traefik default certificate. - if x509Cert.Subject.CommonName == generate.DefaultDomain { + if defaultStore.DefaultCertificate.Certificate.Leaf.Subject.CommonName == generate.DefaultDomain { return certificates } - certificates = append(certificates, x509Cert) + hash := sha256.Sum256(defaultStore.DefaultCertificate.Certificate.Leaf.Raw) + fingerprint := hex.EncodeToString(hash[:]) + certificates[fingerprint] = defaultStore.DefaultCertificate.Certificate.Leaf } return certificates diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 98ee8b9ce..9eef5bfc8 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -10,7 +10,7 @@ import fetch from './libs/fetch' import { VersionProvider } from 'contexts/version' import { useIsDarkMode } from 'hooks/use-theme' import ErrorSuspenseWrapper from 'layout/ErrorSuspenseWrapper' -import { Dashboard, HTTPPages, NotFound, TCPPages, UDPPages } from 'pages' +import { Dashboard, HTTPPages, NotFound, TCPPages, UDPPages, CertificatesPages } from 'pages' import { DashboardSkeleton } from 'pages/dashboard/Dashboard' import { HubDemoContext, HubDemoProvider } from 'pages/hub-demo/demoNavContext' @@ -48,6 +48,8 @@ export const Routes = () => { } /> + } /> + } /> } /> } /> } /> diff --git a/webui/src/components/certificates/CertExpiryBadge.tsx b/webui/src/components/certificates/CertExpiryBadge.tsx new file mode 100644 index 000000000..07e491218 --- /dev/null +++ b/webui/src/components/certificates/CertExpiryBadge.tsx @@ -0,0 +1,29 @@ +import { Badge } from '@traefik-labs/faency' + +type ExpiryStatus = { + variant: 'red' | 'orange' | 'green' + label: string +} + +export const getCertExpiryStatus = (daysLeft: number): ExpiryStatus => { + if (daysLeft < 0) return { variant: 'red', label: 'EXPIRED' } + if (daysLeft < 30) return { variant: 'orange', label: 'Expiring Soon' } + return { variant: 'green', label: 'Valid' } +} + +type CertExpiryBadgeProps = { + daysLeft: number + size?: 'small' | 'large' +} + +const CertExpiryBadge = ({ daysLeft, size = 'large' }: CertExpiryBadgeProps) => { + const { variant } = getCertExpiryStatus(daysLeft) + + return ( + + {daysLeft < 0 ? 'EXPIRED' : `${daysLeft} days`} + + ) +} + +export default CertExpiryBadge diff --git a/webui/src/components/certificates/CertificateDetails.tsx b/webui/src/components/certificates/CertificateDetails.tsx new file mode 100644 index 000000000..166e08042 --- /dev/null +++ b/webui/src/components/certificates/CertificateDetails.tsx @@ -0,0 +1,106 @@ +import { Badge, Box, Flex, Link } from '@traefik-labs/faency' +import { type ReactElement, useMemo } from 'react' + +import CertExpiryBadge, { getCertExpiryStatus } from 'components/certificates/CertExpiryBadge' +import DetailsCard, { ValText } from 'components/resources/DetailsCard' + +const isLinkableHostname = (value?: string) => { + if (!value) { + return false + } + + return !value.startsWith('*.') && !/\s/.test(value) && !/^(\d{1,3}\.){3}\d{1,3}$/.test(value) && !value.includes(':') +} + +export const CertificateDetails = ({ certificate }: { certificate: Certificate.Info }) => { + const validFrom = new Date(certificate.notBefore) + const validUntil = new Date(certificate.notAfter) + const certStatus = useMemo(() => getCertExpiryStatus(certificate.daysLeft), [certificate.daysLeft]) + + const issuedToItems = [ + { + key: 'Common Name', + val: isLinkableHostname(certificate.commonName) ? ( + + {certificate.commonName} + + ) : ( + {certificate.commonName || '-'} + ), + }, + { + key: 'Status', + val: ( + + {certStatus.label} + + ), + }, + { + key: 'Subject Alternative Names', + val: ( + + {certificate.sans.map((san) => ( + + {isLinkableHostname(san) ? ( + + {san} + + ) : ( + {san} + )} + + ))} + + ), + }, + { key: 'Organization', val: certificate.organization || '-' }, + { key: 'Country', val: certificate.country || '-' }, + ] + + const issuedByItems = [ + { key: 'Common Name', val: certificate.issuerCN || '-' }, + { key: 'Organization', val: certificate.issuerOrg || '-' }, + { key: 'Country', val: certificate.issuerCountry || '-' }, + ] + + const validityItems = [ + { key: 'Valid From', val: validFrom.toLocaleString() }, + { key: 'Valid Until', val: validUntil.toLocaleString() }, + { + key: 'Expiry', + val: , + }, + ] + + const technicalItems = [ + certificate.version && { key: 'Version', val: certificate.version }, + { key: 'Serial Number', val: certificate.serialNumber || 'N/A' }, + { key: 'Key Type', val: certificate.keyType || 'Unknown' }, + { key: 'Key Size', val: `${certificate.keySize || 0} bits` }, + { key: 'Signature Algorithm', val: certificate.signatureAlgorithm || 'Unknown' }, + ].filter(Boolean) as { key: string; val: string | ReactElement }[] + + const fingerprintItems = [ + { key: 'Certificate', val: certificate.certFingerprint || 'N/A' }, + { key: 'Public Key', val: certificate.publicKeyFingerprint || 'N/A' }, + ] + + return ( + + + + + + + + ) +} + +export default CertificateDetails diff --git a/webui/src/components/resources/ResourceStatus.tsx b/webui/src/components/resources/ResourceStatus.tsx index 043827959..8080f6e12 100644 --- a/webui/src/components/resources/ResourceStatus.tsx +++ b/webui/src/components/resources/ResourceStatus.tsx @@ -51,6 +51,11 @@ export const ResourceStatus = ({ status, withLabel = false, size = 20 }: Props) icon: iconByStatus.disabled, label: 'Error', }, + expired: { + color: colorByStatus.expired, + icon: iconByStatus.expired, + label: 'Expired', + }, loading: { color: colorByStatus.loading, icon: iconByStatus.loading, diff --git a/webui/src/components/resources/Status.tsx b/webui/src/components/resources/Status.tsx index 92b06c796..6fef9947b 100644 --- a/webui/src/components/resources/Status.tsx +++ b/webui/src/components/resources/Status.tsx @@ -9,6 +9,7 @@ export const iconByStatus: { [key in Resource.Status]: ReactNode } = { error: , enabled: , disabled: , + expired: , loading: , } @@ -20,6 +21,7 @@ export const colorByStatus: { [key in Resource.Status]: string } = { error: 'hsl(347, 100%, 60.0%)', enabled: '#30A46C', disabled: 'hsl(347, 100%, 60.0%)', + expired: 'hsl(347, 100%, 60.0%)', loading: 'hsla(0, 0%, 100%, 0.51)', } @@ -45,6 +47,8 @@ export default function Status({ css = {}, size = 20, status, color = 'white' }: return case 'disabled': return + case 'expired': + return default: return null } diff --git a/webui/src/hooks/use-certificates.ts b/webui/src/hooks/use-certificates.ts new file mode 100644 index 000000000..7198ca269 --- /dev/null +++ b/webui/src/hooks/use-certificates.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react' +import useSWR from 'swr' + +export const computeDaysLeft = (notAfter: string): number => + Math.floor((new Date(notAfter).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + +export const useCertificates = () => { + const { data, error } = useSWR('/certificates') + + const certificates: Certificate.Info[] = useMemo(() => { + if (!data) return [] + + return data.map((cert) => ({ + ...cert, + daysLeft: computeDaysLeft(cert.notAfter), + })) + }, [data]) + + return { + certificates, + error, + isLoading: !error && !data, + } +} + +export const useCertificate = (certId: string) => { + const { data, error } = useSWR(certId ? `/certificates/${certId}` : null) + + const certificate: Certificate.Info | null = useMemo(() => { + if (!data) return null + + return { + ...data, + daysLeft: computeDaysLeft(data.notAfter), + } + }, [data]) + + return { + certificate, + error, + isLoading: !!certId && !error && !data, + } +} diff --git a/webui/src/hooks/use-overview-totals.tsx b/webui/src/hooks/use-overview-totals.tsx index 42d896cf4..b3b7412a5 100644 --- a/webui/src/hooks/use-overview-totals.tsx +++ b/webui/src/hooks/use-overview-totals.tsx @@ -10,6 +10,7 @@ type TotalsResult = { http: TotalsResultItem tcp: TotalsResultItem udp: TotalsResultItem + certificates: number } const useTotals = (): TotalsResult => { @@ -30,6 +31,7 @@ const useTotals = (): TotalsResult => { routers: data?.udp?.routers?.total, services: data?.udp?.services?.total, }, + certificates: data?.certificates?.total, } } diff --git a/webui/src/layout/navigation/SideNavBar.tsx b/webui/src/layout/navigation/SideNavBar.tsx index fd9e27e61..855c76c96 100644 --- a/webui/src/layout/navigation/SideNavBar.tsx +++ b/webui/src/layout/navigation/SideNavBar.tsx @@ -135,7 +135,7 @@ export const SideNav = ({ const windowSize = useWindowSize() const { version } = useContext(VersionContext) - const { http, tcp, udp } = useTotals() + const { http, tcp, udp, certificates } = useTotals() const [isSmallScreen, setIsSmallScreen] = useState(false) @@ -155,8 +155,9 @@ export const SideNav = ({ '/tcp/middlewares': tcp?.middlewares as number, '/udp/routers': udp?.routers, '/udp/services': udp?.services, + '/certificates': certificates, }), - [http, tcp, udp], + [http, tcp, udp, certificates], ) return ( diff --git a/webui/src/mocks/data/api-certificates.json b/webui/src/mocks/data/api-certificates.json new file mode 100644 index 000000000..577197966 --- /dev/null +++ b/webui/src/mocks/data/api-certificates.json @@ -0,0 +1,210 @@ +[ + { + "name": "a1b2c3d4e5f60708090a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60", + "sans": [ + "example.com", + "foo.example.com", + "bar.example.com" + ], + "notAfter": "2026-06-15T10:00:00Z", + "notBefore": "2024-01-15T10:00:00Z", + "serialNumber": "03:E7:12:34:56:78:9A:BC:DE:F0:12:34:56:78:9A:BC", + "commonName": "example.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "Example Corporation", + "country": "US", + "version": "v3", + "keyType": "RSA", + "keySize": 2048, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "a1b2c3d4e5f60708090a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60", + "publicKeyFingerprint": "b2c3d4e5f60708090a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071", + "status": "enabled", + "resolver": "letsencrypt" + }, + { + "name": "c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3", + "sans": [ + "domain.com", + "foo.domain.com", + "bar.domain.com" + ], + "notAfter": "2026-05-20T10:00:00Z", + "notBefore": "2024-02-20T10:00:00Z", + "serialNumber": "04:A8:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD", + "commonName": "domain.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "Domain Services Inc", + "country": "GB", + "version": "v3", + "keyType": "RSA", + "keySize": 2048, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3", + "publicKeyFingerprint": "d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4", + "status": "enabled", + "resolver": "letsencrypt" + }, + { + "name": "c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3", + "sans": [ + "my.domain.com", + "foo.my.domain.com", + "bar.my.domain.com" + ], + "notAfter": "2026-04-10T10:00:00Z", + "notBefore": "2024-03-10T10:00:00Z", + "serialNumber": "05:B9:34:56:78:9A:BC:DE:F0:12:34:56:78:9A:BC:DE", + "commonName": "my.domain.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "My Domain Hosting", + "country": "DE", + "version": "v3", + "keyType": "RSA", + "keySize": 4096, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3", + "publicKeyFingerprint": "f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6", + "status": "enabled", + "resolver": "zerossl" + }, + { + "name": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809", + "sans": [ + "api.example.com", + "*.api.example.com" + ], + "notAfter": "2026-03-20T10:00:00Z", + "notBefore": "2024-02-20T10:00:00Z", + "serialNumber": "04:A8:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD", + "commonName": "api.example.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "Example API Services", + "country": "GB", + "version": "v3", + "keyType": "RSA", + "keySize": 4096, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809", + "publicKeyFingerprint": "2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a", + "status": "enabled", + "resolver": "letsencrypt" + }, + { + "name": "3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b", + "sans": [ + "test.example.com" + ], + "notAfter": "2026-02-01T10:00:00Z", + "notBefore": "2024-03-10T10:00:00Z", + "serialNumber": "05:B9:34:56:78:9A:BC:DE:F0:12:34:56:78:9A:BC:DE", + "commonName": "test.example.com", + "issuerCN": "ZeroSSL RSA Domain Secure Site CA", + "issuerOrg": "ZeroSSL", + "issuerCountry": "AT", + "organization": "Test Environment", + "country": "DE", + "version": "v3", + "keyType": "ECDSA", + "keySize": 256, + "signatureAlgorithm": "SHA384-ECDSA", + "certFingerprint": "3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b", + "publicKeyFingerprint": "4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c", + "status": "warning", + "resolver": "letsencrypt" + }, + { + "name": "d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3", + "sans": [ + "db.example.com" + ], + "notAfter": "2026-06-01T10:00:00Z", + "notBefore": "2024-06-01T10:00:00Z", + "serialNumber": "05:D2:56:78:9A:BC:DE:F0:12:34:56:78:9A:BC:DE:F0", + "commonName": "db.example.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "Database Services", + "country": "US", + "version": "v3", + "keyType": "RSA", + "keySize": 2048, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3", + "publicKeyFingerprint": "e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4", + "status": "enabled", + "resolver": "letsencrypt" + }, + { + "name": "08192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7", + "sans": [ + "old.example.com" + ], + "notAfter": "2025-12-01T10:00:00Z", + "notBefore": "2023-12-01T10:00:00Z", + "serialNumber": "02:C1:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF", + "commonName": "old.example.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "Legacy Systems", + "country": "CA", + "version": "v3", + "keyType": "RSA", + "keySize": 2048, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "08192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7", + "publicKeyFingerprint": "192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708", + "status": "expired" + }, + { + "name": "f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4", + "sans": [], + "notAfter": "2027-01-15T10:00:00Z", + "notBefore": "2024-01-15T10:00:00Z", + "serialNumber": "06:F3:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01", + "commonName": "legacy.internal.com", + "issuerCN": "Internal CA", + "issuerOrg": "Internal Certificate Authority", + "issuerCountry": "US", + "organization": "Internal Services", + "country": "US", + "version": "v3", + "keyType": "RSA", + "keySize": 2048, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4", + "publicKeyFingerprint": "a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5", + "status": "enabled" + }, + { + "name": "b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6", + "sans": [], + "notAfter": "2026-08-20T10:00:00Z", + "notBefore": "2024-05-20T10:00:00Z", + "serialNumber": "07:A5:78:9A:BC:DE:F0:12:34:56:78:9A:BC:DE:F0:12", + "commonName": "app.example.com", + "issuerCN": "Let's Encrypt Authority X3", + "issuerOrg": "Let's Encrypt", + "issuerCountry": "US", + "organization": "Application Services", + "country": "US", + "version": "v3", + "keyType": "RSA", + "keySize": 2048, + "signatureAlgorithm": "SHA256-RSA", + "certFingerprint": "b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6", + "publicKeyFingerprint": "c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7", + "status": "enabled", + "resolver": "letsencrypt" + } +] \ No newline at end of file diff --git a/webui/src/mocks/data/api-overview.json b/webui/src/mocks/data/api-overview.json index d01f3b403..6af278889 100644 --- a/webui/src/mocks/data/api-overview.json +++ b/webui/src/mocks/data/api-overview.json @@ -45,6 +45,11 @@ "errors": 0 } }, + "certificates": { + "total": 9, + "warnings": 1, + "errors": 1 + }, "features": { "tracing": "Prometheus", "metrics": "", diff --git a/webui/src/mocks/data/api-tcp_routers.json b/webui/src/mocks/data/api-tcp_routers.json index f908f66a8..2ec00a342 100644 --- a/webui/src/mocks/data/api-tcp_routers.json +++ b/webui/src/mocks/data/api-tcp_routers.json @@ -14,5 +14,23 @@ ], "priority": 10, "provider": "docker" + }, + { + "entryPoints": [ + "websecure-tcp" + ], + "service": "postgres", + "rule": "HostSNI(`db.example.com`)", + "tls": { + "options": "default", + "certResolver": "letsencrypt" + }, + "status": "enabled", + "name": "postgres@docker", + "using": [ + "websecure-tcp" + ], + "priority": 20, + "provider": "docker" } ] diff --git a/webui/src/mocks/handlers.ts b/webui/src/mocks/handlers.ts index 505506730..417704c62 100644 --- a/webui/src/mocks/handlers.ts +++ b/webui/src/mocks/handlers.ts @@ -1,5 +1,6 @@ import { http, passthrough } from 'msw' +import apiCertificates from './data/api-certificates.json' import apiEntrypoints from './data/api-entrypoints.json' import apiHttpMiddlewares from './data/api-http_middlewares.json' import apiHttpRouters from './data/api-http_routers.json' @@ -15,6 +16,7 @@ import eeApiErrors from './data/ee-api-errors.json' import { listHandlers } from './utils' export const getHandlers = (noDelay: boolean = false) => [ + ...listHandlers('/api/certificates', apiCertificates, noDelay), ...listHandlers('/api/entrypoints', apiEntrypoints, noDelay, true), ...listHandlers('/api/errors', eeApiErrors, noDelay), ...listHandlers('/api/http/middlewares', apiHttpMiddlewares, noDelay), diff --git a/webui/src/pages/certificates/Certificate.spec.tsx b/webui/src/pages/certificates/Certificate.spec.tsx new file mode 100644 index 000000000..a3dbc157d --- /dev/null +++ b/webui/src/pages/certificates/Certificate.spec.tsx @@ -0,0 +1,90 @@ +import * as useCertificates from '../../hooks/use-certificates' + +import { Certificate } from './Certificate' + +import { renderWithProviders } from 'utils/test' + +describe('', () => { + it('should render the loading state initially', () => { + vi.spyOn(useCertificates, 'useCertificate').mockImplementation(() => ({ + certificate: null, + error: null, + isLoading: true, + })) + + const { getByTestId } = renderWithProviders(, { + route: '/certificates/dW5rbm93bi1jZXJ0LWtleQ==', + withPage: true, + }) + + expect(getByTestId('skeleton')).toBeInTheDocument() + }) + + it('should render error message when API returns error', () => { + vi.spyOn(useCertificates, 'useCertificate').mockImplementation(() => ({ + certificate: null, + error: new Error('Internal Server Error'), + isLoading: false, + })) + + const { getByTestId } = renderWithProviders(, { + route: '/certificates/c29tZS1jZXJ0', + withPage: true, + }) + + expect(getByTestId('error-text')).toBeInTheDocument() + }) + + it('should render not found page when certificate is null', () => { + vi.spyOn(useCertificates, 'useCertificate').mockImplementation(() => ({ + certificate: null, + error: null, + isLoading: false, + })) + + const { getByTestId } = renderWithProviders(, { + route: '/certificates/bm90Zm91bmQ=', + withPage: true, + }) + + expect(getByTestId('Not found page')).toBeInTheDocument() + }) + + it('should render certificate details successfully', () => { + const mockCertificate = { + name: 'dGVzdC1jZXJ0', + commonName: 'test.com', + sans: ['test.com', 'www.test.com'], + issuerOrg: 'Test CA', + issuerCN: 'Test Root CA', + notAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + notBefore: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + status: 'enabled' as const, + serialNumber: '123456', + version: '3', + keyType: 'RSA', + signatureAlgorithm: 'SHA256WithRSA', + certFingerprint: 'a1b2c3d4e5f60708090a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60', + publicKeyFingerprint: 'b2c3d4e5f60708090a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071', + } + + vi.spyOn(useCertificates, 'useCertificate').mockImplementation(() => ({ + certificate: { ...mockCertificate, daysLeft: 365 }, + error: null, + isLoading: false, + })) + + const { getByText } = renderWithProviders(, { + route: '/certificates/dGVzdC1jZXJ0', + withPage: true, + }) + + // Check for actual rendered content + expect(getByText('Certificate')).toBeInTheDocument() + expect(getByText('Issued To')).toBeInTheDocument() + expect(getByText('Issued By')).toBeInTheDocument() + expect(getByText('www.test.com')).toBeInTheDocument() + expect(getByText('Test CA')).toBeInTheDocument() + expect(getByText('Test Root CA')).toBeInTheDocument() + }) +}) diff --git a/webui/src/pages/certificates/Certificate.tsx b/webui/src/pages/certificates/Certificate.tsx new file mode 100644 index 000000000..b07449361 --- /dev/null +++ b/webui/src/pages/certificates/Certificate.tsx @@ -0,0 +1,51 @@ +import { Box, Flex, H1, Skeleton, Text } from '@traefik-labs/faency' +import { useParams } from 'react-router-dom' + +import { CertificateDetails } from '../../components/certificates/CertificateDetails' +import { useCertificate } from '../../hooks/use-certificates' + +import { DetailsCardSkeleton } from 'components/resources/DetailsCard' +import PageTitle from 'layout/PageTitle' +import { NotFound } from 'pages/NotFound' + +export const Certificate = () => { + const { name } = useParams<{ name: string }>() + const { certificate, isLoading, error } = useCertificate(name || '') + + if (isLoading) { + return ( + + + + + + + + + + ) + } + + if (error) { + return ( + <> + + + Sorry, we could not fetch detail information for this Certificate right now. Please, try again later. + + + ) + } + + if (!certificate) { + return + } + + return ( + <> + +

{certificate.commonName || 'Certificate'}

+ + + ) +} diff --git a/webui/src/pages/certificates/Certificates.spec.tsx b/webui/src/pages/certificates/Certificates.spec.tsx new file mode 100644 index 000000000..25898e4c2 --- /dev/null +++ b/webui/src/pages/certificates/Certificates.spec.tsx @@ -0,0 +1,155 @@ +import { CertificateRenderRow, Certificates as CertificatesPage, CertificatesRender } from './Certificates' + +import * as useFetchWithPagination from 'hooks/use-fetch-with-pagination' +import { useFetchWithPaginationMock } from 'utils/mocks' +import { renderWithProviders } from 'utils/test' + +describe('', () => { + it('should render the certificates list', () => { + const pages = [ + { + name: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + commonName: 'example.com', + sans: ['example.com', '127.0.0.1', '::1'], + issuerOrg: 'Acme Co', + issuerCN: 'Acme Root CA', + notAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + notBefore: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + status: 'enabled', + }, + { + name: 'b2c3d4e5f6b2c3d4e5f6b2c3d4e5f6b2c3d4e5f6b2c3d4e5f6b2c3d4e5f6b2c3', + commonName: 'warning.com', + sans: ['warning.com', 'www.warning.com'], + issuerOrg: 'Warning CA', + issuerCN: '', + notAfter: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + notBefore: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(), + status: 'warning', + }, + { + name: 'c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6', + commonName: 'expired.com', + sans: ['expired.com'], + issuerOrg: 'Expired CA', + notAfter: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + notBefore: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(), + status: 'expired', + }, + ].map(CertificateRenderRow) + const mock = vi + .spyOn(useFetchWithPagination, 'default') + .mockImplementation(() => useFetchWithPaginationMock({ pages })) + + const { container, getByTestId } = renderWithProviders(, { + route: '/certificates', + withPage: true, + }) + + expect(mock).toHaveBeenCalled() + expect(getByTestId('/certificates page')).toBeInTheDocument() + const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1] + expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(3) + + // First certificate (enabled) + expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('testid="enabled"') + expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('example.com') + expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('Acme Co') + expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('days') + + // Second certificate (warning) + expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('testid="warning"') + expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('warning.com') + expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('Warning CA') + expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('days') + + // Third certificate (expired) + expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('testid="expired"') + expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('expired.com') + expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('Expired CA') + expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('EXPIRED') + }) + + it('should render "No data available" when the API returns empty array', async () => { + const { container, getByTestId } = renderWithProviders( + {}} + pageCount={1} + pages={[]} + />, + { route: '/certificates', withPage: true }, + ) + expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]') + const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2] + expect(tfoot.querySelectorAll('div[role="row"]')).toHaveLength(1) + expect(tfoot.querySelectorAll('div[role="row"]')[0].innerHTML).toContain('No data available') + }) + + it('should render "Failed to fetch data" when the API returns an error', async () => { + const { container } = renderWithProviders( + {}} + pageCount={1} + pages={[]} + />, + { route: '/certificates', withPage: true }, + ) + const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2] + expect(tfoot.querySelectorAll('div[role="row"]')).toHaveLength(1) + expect(tfoot.querySelectorAll('div[role="row"]')[0].innerHTML).toContain('Failed to fetch data') + }) + + it('should display certificate with expiry colors', () => { + // Test different expiry colors + const pages = [ + { + name: 'd4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5', + commonName: 'green.com', + sans: ['green.com'], + issuerOrg: 'Test CA', + notAfter: new Date(Date.now() + 100 * 24 * 60 * 60 * 1000).toISOString(), // 100 days = green + notBefore: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + status: 'enabled', + }, + { + name: 'e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6', + commonName: 'orange.com', + sans: ['orange.com'], + issuerOrg: 'Test CA', + notAfter: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(), // 10 days = orange + notBefore: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + status: 'warning', + }, + ].map(CertificateRenderRow) + + const { container } = renderWithProviders( + {}} + pageCount={1} + pages={pages} + />, + { route: '/certificates', withPage: true }, + ) + + const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1] + expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(2) + + // Green badge for >14 days + expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('green.com') + + // Orange badge for <14 days + expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('orange.com') + }) +}) diff --git a/webui/src/pages/certificates/Certificates.tsx b/webui/src/pages/certificates/Certificates.tsx new file mode 100644 index 000000000..0e80dbd75 --- /dev/null +++ b/webui/src/pages/certificates/Certificates.tsx @@ -0,0 +1,123 @@ +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefik-labs/faency' +import { useMemo } from 'react' +import useInfiniteScroll from 'react-infinite-scroll-hook' +import { useSearchParams } from 'react-router-dom' + +import { ScrollTopButton } from 'components/buttons/ScrollTopButton' +import CertExpiryBadge from 'components/certificates/CertExpiryBadge' +import { ResourceStatus } from 'components/resources/ResourceStatus' +import { SpinnerLoader } from 'components/SpinnerLoader' +import ClickableRow from 'components/tables/ClickableRow' +import SortableTh from 'components/tables/SortableTh' +import { searchParamsToState, TableFilter } from 'components/tables/TableFilter' +import TooltipText from 'components/TooltipText' +import { computeDaysLeft } from 'hooks/use-certificates' +import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination' +import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder' +import PageTitle from 'layout/PageTitle' + +export const CertificateRenderRow: RenderRowType = (row: unknown) => { + const cert = row as Certificate.Raw + const daysLeft = computeDaysLeft(cert.notAfter) + const validUntil = new Date(cert.notAfter).toLocaleDateString() + + return ( + + + + + + + + + + {cert.sans?.length > 0 ? cert.sans.join(', ') : '-'} + + + + + + + {validUntil} + + + + + + ) +} + +export const CertificatesRender = ({ + error, + isEmpty, + isLoadingMore, + isReachingEnd, + loadMore, + pageCount, + pages, +}: pagesResponseInterface) => { + const [infiniteRef] = useInfiniteScroll({ + loading: isLoadingMore, + hasNextPage: !isReachingEnd && !error, + onLoadMore: loadMore, + }) + + return ( + <> + + + + + + + + + + + + {pages} + {(isEmpty || !!error) && ( + + + + + + )} + + + {isLoadingMore ? : isReachingEnd && pageCount > 1 && } + + + ) +} + +export const Certificates = () => { + const [searchParams] = useSearchParams() + + const query = useMemo(() => searchParamsToState(searchParams), [searchParams]) + const { pages, pageCount, isLoadingMore, isReachingEnd, loadMore, error, isEmpty } = useFetchWithPagination( + '/certificates', + { + listContextKey: JSON.stringify(query), + renderRow: CertificateRenderRow, + renderLoader: () => null, + query, + }, + ) + + return ( + <> + + + + + ) +} diff --git a/webui/src/pages/certificates/index.ts b/webui/src/pages/certificates/index.ts new file mode 100644 index 000000000..87a0a571b --- /dev/null +++ b/webui/src/pages/certificates/index.ts @@ -0,0 +1,2 @@ +export { Certificates } from './Certificates' +export { Certificate } from './Certificate' diff --git a/webui/src/pages/index.ts b/webui/src/pages/index.ts index 3a84749a9..add207148 100644 --- a/webui/src/pages/index.ts +++ b/webui/src/pages/index.ts @@ -1,7 +1,8 @@ +import * as CertificatesPages from './certificates' import * as HTTPPages from './http' import * as TCPPages from './tcp' import * as UDPPages from './udp' export { Dashboard } from './dashboard/Dashboard' export { NotFound } from './NotFound' -export { HTTPPages, TCPPages, UDPPages } +export { HTTPPages, TCPPages, UDPPages, CertificatesPages } diff --git a/webui/src/routes.tsx b/webui/src/routes.tsx index 249584848..f546d1038 100644 --- a/webui/src/routes.tsx +++ b/webui/src/routes.tsx @@ -1,5 +1,11 @@ import { ReactNode } from 'react' -import { LiaProjectDiagramSolid, LiaServerSolid, LiaCogsSolid, LiaHomeSolid } from 'react-icons/lia' +import { + LiaProjectDiagramSolid, + LiaServerSolid, + LiaCogsSolid, + LiaHomeSolid, + LiaCertificateSolid, +} from 'react-icons/lia' export type Route = { path: string @@ -91,4 +97,16 @@ export const ROUTES: RouteSections[] = [ }, ], }, + { + section: 'certificates', + sectionLabel: 'Certificates', + items: [ + { + path: '/certificates', + activeMatches: ['/certificates/:name'], + label: 'Certificates', + icon: , + }, + ], + }, ] diff --git a/webui/src/types/resources.d.ts b/webui/src/types/resources.d.ts index 7bed033e0..42e486e89 100644 --- a/webui/src/types/resources.d.ts +++ b/webui/src/types/resources.d.ts @@ -1,5 +1,5 @@ declare namespace Resource { - type Status = 'info' | 'success' | 'warning' | 'error' | 'enabled' | 'disabled' | 'loading' + type Status = 'info' | 'success' | 'warning' | 'error' | 'enabled' | 'disabled' | 'expired' | 'loading' type DetailsData = Router.DetailsData & Service.Details & Middleware.DetailsData } @@ -19,10 +19,10 @@ declare namespace Router { } type TLS = { - options: string + options?: string certResolver: string domains: TlsDomain[] - passthrough: boolean + passthrough?: boolean } type Details = { @@ -121,3 +121,32 @@ declare namespace Middleware { routers?: Router.Details[] } } + +declare namespace Certificate { + /** Raw API response shape */ + type Raw = { + name: string + commonName?: string + sans: string[] + issuerOrg?: string + issuerCN?: string + issuerCountry?: string + organization?: string + country?: string + serialNumber: string + notBefore: string + notAfter: string + version: string + keyType: string + keySize?: number + signatureAlgorithm: string + certFingerprint: string + publicKeyFingerprint: string + status: 'enabled' | 'warning' | 'expired' + } + + /** Enriched certificate with computed fields */ + type Info = Raw & { + daysLeft: number + } +}