From 8b17fc1667f700f8a93419432bcd9325b6bff16a Mon Sep 17 00:00:00 2001
From: holomekc <30546982+holomekc@users.noreply.github.com>
Date: Tue, 7 Apr 2026 10:10:05 +0200
Subject: [PATCH] Add certificates menu and overview
---
cmd/traefik/traefik.go | 2 +-
pkg/api/certificate.go | 172 +++++
pkg/api/handler.go | 20 +-
pkg/api/handler_certificate.go | 99 +++
pkg/api/handler_certificate_test.go | 603 ++++++++++++++++++
pkg/api/handler_entrypoint.go | 4 +-
pkg/api/handler_http.go | 12 +-
pkg/api/handler_overview.go | 45 +-
pkg/api/handler_support_dump.go | 2 +-
pkg/api/handler_tcp.go | 12 +-
pkg/api/handler_udp.go | 8 +-
pkg/api/sort.go | 74 ++-
pkg/server/routerfactory_test.go | 8 +-
pkg/server/service/managerfactory.go | 5 +-
pkg/tls/tlsmanager.go | 36 +-
webui/src/App.tsx | 4 +-
.../certificates/CertExpiryBadge.tsx | 29 +
.../certificates/CertificateDetails.tsx | 106 +++
.../components/resources/ResourceStatus.tsx | 5 +
webui/src/components/resources/Status.tsx | 4 +
webui/src/hooks/use-certificates.ts | 43 ++
webui/src/hooks/use-overview-totals.tsx | 2 +
webui/src/layout/navigation/SideNavBar.tsx | 5 +-
webui/src/mocks/data/api-certificates.json | 210 ++++++
webui/src/mocks/data/api-overview.json | 5 +
webui/src/mocks/data/api-tcp_routers.json | 18 +
webui/src/mocks/handlers.ts | 2 +
.../pages/certificates/Certificate.spec.tsx | 90 +++
webui/src/pages/certificates/Certificate.tsx | 51 ++
.../pages/certificates/Certificates.spec.tsx | 155 +++++
webui/src/pages/certificates/Certificates.tsx | 123 ++++
webui/src/pages/certificates/index.ts | 2 +
webui/src/pages/index.ts | 3 +-
webui/src/routes.tsx | 20 +-
webui/src/types/resources.d.ts | 35 +-
35 files changed, 1950 insertions(+), 64 deletions(-)
create mode 100644 pkg/api/certificate.go
create mode 100644 pkg/api/handler_certificate.go
create mode 100644 pkg/api/handler_certificate_test.go
create mode 100644 webui/src/components/certificates/CertExpiryBadge.tsx
create mode 100644 webui/src/components/certificates/CertificateDetails.tsx
create mode 100644 webui/src/hooks/use-certificates.ts
create mode 100644 webui/src/mocks/data/api-certificates.json
create mode 100644 webui/src/pages/certificates/Certificate.spec.tsx
create mode 100644 webui/src/pages/certificates/Certificate.tsx
create mode 100644 webui/src/pages/certificates/Certificates.spec.tsx
create mode 100644 webui/src/pages/certificates/Certificates.tsx
create mode 100644 webui/src/pages/certificates/index.ts
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
+ }
+}