Add certificates menu and overview

This commit is contained in:
holomekc
2026-04-07 10:10:05 +02:00
committed by GitHub
parent e24a61c14c
commit 8b17fc1667
35 changed files with 1950 additions and 64 deletions
+1 -1
View File
@@ -303,7 +303,7 @@ func setupServer(staticConfiguration *static.Configuration) (*server.Server, err
dialerManager := tcp.NewDialerManager(spiffeX509Source) dialerManager := tcp.NewDialerManager(spiffeX509Source)
acmeHTTPHandler := getHTTPChallengeHandler(acmeProviders, httpChallengeProvider) 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 // Router factory
+172
View File
@@ -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
}
+16 -4
View File
@@ -11,6 +11,7 @@ import (
"github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/config/dynamic"
"github.com/traefik/traefik/v3/pkg/config/runtime" "github.com/traefik/traefik/v3/pkg/config/runtime"
"github.com/traefik/traefik/v3/pkg/config/static" "github.com/traefik/traefik/v3/pkg/config/static"
"github.com/traefik/traefik/v3/pkg/tls"
"github.com/traefik/traefik/v3/pkg/version" "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 is the data set used to create all the data representations exposed by the API.
runtimeConfiguration *runtime.Configuration runtimeConfiguration *runtime.Configuration
tlsManager *tls.Manager
} }
// NewBuilder returns a http.Handler builder based on runtime.Configuration. // 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 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. // createRouter creates API routes and router.
func (h Handler) createRouter() *mux.Router { func (h *Handler) createRouter() *mux.Router {
router := mux.NewRouter().UseEncodedPath() router := mux.NewRouter().UseEncodedPath()
apiRouter := router.PathPrefix(h.staticConfig.API.BasePath).Subrouter().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").HandlerFunc(h.getUDPServices)
apiRouter.Methods(http.MethodGet).Path("/api/udp/services/{serviceID}").HandlerFunc(h.getUDPService) 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) version.Handler{}.Append(apiRouter)
return router 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)) siRepr := make(map[string]*serviceInfoRepresentation, len(h.runtimeConfiguration.Services))
for k, v := range h.runtimeConfiguration.Services { for k, v := range h.runtimeConfiguration.Services {
siRepr[k] = &serviceInfoRepresentation{ siRepr[k] = &serviceInfoRepresentation{
+99
View File
@@ -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
}
+603
View File
@@ -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)
}
}
})
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ type entryPointRepresentation struct {
Name string `json:"name,omitempty"` 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)) results := make([]entryPointRepresentation, 0, len(h.staticConfig.EntryPoints))
for name, ep := range 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"] scapedEntryPointID := mux.Vars(request)["entryPointID"]
entryPointID, err := url.PathUnescape(scapedEntryPointID) entryPointID, err := url.PathUnescape(scapedEntryPointID)
+6 -6
View File
@@ -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)) results := make([]routerRepresentation, 0, len(h.runtimeConfiguration.Routers))
query := request.URL.Query() 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"] scapedRouterID := mux.Vars(request)["routerID"]
routerID, err := url.PathUnescape(scapedRouterID) 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)) results := make([]serviceRepresentation, 0, len(h.runtimeConfiguration.Services))
query := request.URL.Query() 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"] scapedServiceID := mux.Vars(request)["serviceID"]
serviceID, err := url.PathUnescape(scapedServiceID) 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)) results := make([]middlewareRepresentation, 0, len(h.runtimeConfiguration.Middlewares))
query := request.URL.Query() 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"] scapedMiddlewareID := mux.Vars(request)["middlewareID"]
middlewareID, err := url.PathUnescape(scapedMiddlewareID) middlewareID, err := url.PathUnescape(scapedMiddlewareID)
+37 -8
View File
@@ -8,6 +8,7 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/traefik/traefik/v3/pkg/config/runtime" "github.com/traefik/traefik/v3/pkg/config/runtime"
"github.com/traefik/traefik/v3/pkg/config/static" "github.com/traefik/traefik/v3/pkg/config/static"
"github.com/traefik/traefik/v3/pkg/tls"
) )
type schemeOverview struct { type schemeOverview struct {
@@ -30,14 +31,15 @@ type features struct {
} }
type overview struct { type overview struct {
HTTP schemeOverview `json:"http"` HTTP schemeOverview `json:"http"`
TCP schemeOverview `json:"tcp"` TCP schemeOverview `json:"tcp"`
UDP schemeOverview `json:"udp"` UDP schemeOverview `json:"udp"`
Features features `json:"features,omitempty"` Certificates *section `json:"certificates,omitempty"`
Providers []string `json:"providers,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{ result := overview{
HTTP: schemeOverview{ HTTP: schemeOverview{
Routers: getHTTPRouterSection(h.runtimeConfiguration.Routers), Routers: getHTTPRouterSection(h.runtimeConfiguration.Routers),
@@ -53,8 +55,9 @@ func (h Handler) getOverview(rw http.ResponseWriter, request *http.Request) {
Routers: getUDPRouterSection(h.runtimeConfiguration.UDPRouters), Routers: getUDPRouterSection(h.runtimeConfiguration.UDPRouters),
Services: getUDPServiceSection(h.runtimeConfiguration.UDPServices), Services: getUDPServiceSection(h.runtimeConfiguration.UDPServices),
}, },
Features: getFeatures(h.staticConfig), Certificates: getCertificatesSection(h.tlsManager),
Providers: getProviders(h.staticConfig), Features: getFeatures(h.staticConfig),
Providers: getProviders(h.staticConfig),
} }
rw.Header().Set("Content-Type", "application/json") rw.Header().Set("Content-Type", "application/json")
@@ -285,3 +288,29 @@ func getTracing(conf static.Configuration) string {
return "" 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 &section{
Total: len(x509Certs),
Warnings: countWarnings,
Errors: countErrors,
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"github.com/traefik/traefik/v3/pkg/version" "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()) logger := log.Ctx(req.Context())
staticConfig, err := redactor.Anonymize(h.staticConfig) staticConfig, err := redactor.Anonymize(h.staticConfig)
+6 -6
View File
@@ -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)) results := make([]tcpRouterRepresentation, 0, len(h.runtimeConfiguration.TCPRouters))
query := request.URL.Query() 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"] scapedRouterID := mux.Vars(request)["routerID"]
routerID, err := url.PathUnescape(scapedRouterID) 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)) results := make([]tcpServiceRepresentation, 0, len(h.runtimeConfiguration.TCPServices))
query := request.URL.Query() 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"] scapedServiceID := mux.Vars(request)["serviceID"]
serviceID, err := url.PathUnescape(scapedServiceID) 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)) results := make([]tcpMiddlewareRepresentation, 0, len(h.runtimeConfiguration.Middlewares))
query := request.URL.Query() 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"] scapedMiddlewareID := mux.Vars(request)["middlewareID"]
middlewareID, err := url.PathUnescape(scapedMiddlewareID) middlewareID, err := url.PathUnescape(scapedMiddlewareID)
+4 -4
View File
@@ -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)) results := make([]udpRouterRepresentation, 0, len(h.runtimeConfiguration.UDPRouters))
query := request.URL.Query() 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"] scapedRouterID := mux.Vars(request)["routerID"]
routerID, err := url.PathUnescape(scapedRouterID) 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)) results := make([]udpServiceRepresentation, 0, len(h.runtimeConfiguration.UDPServices))
query := request.URL.Query() 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"] scapedServiceID := mux.Vars(request)["serviceID"]
serviceID, err := url.PathUnescape(scapedServiceID) serviceID, err := url.PathUnescape(scapedServiceID)
+68 -6
View File
@@ -4,6 +4,7 @@ import (
"cmp" "cmp"
"net/url" "net/url"
"sort" "sort"
"time"
) )
const ( const (
@@ -14,6 +15,9 @@ const (
const ( const (
ascendantSorting = "asc" ascendantSorting = "asc"
descendantSorting = "desc" descendantSorting = "desc"
sortFieldName = "name"
sortFieldStatus = "status"
) )
type orderedWithName interface { type orderedWithName interface {
@@ -31,6 +35,14 @@ type orderedRouter interface {
entryPointsCount() int entryPointsCount() int
} }
type orderedCertificate interface {
orderedWithName
status() string
issuer() string
validUntil() time.Time
}
func sortRouters[T orderedRouter](values url.Values, routers []T) { func sortRouters[T orderedRouter](values url.Values, routers []T) {
sortBy := values.Get(sortByParam) sortBy := values.Get(sortByParam)
@@ -40,7 +52,7 @@ func sortRouters[T orderedRouter](values url.Values, routers []T) {
} }
switch sortBy { switch sortBy {
case "name": case sortFieldName:
sortByName(direction, routers) sortByName(direction, routers)
case "provider": case "provider":
@@ -49,7 +61,7 @@ func sortRouters[T orderedRouter](values url.Values, routers []T) {
case "priority": case "priority":
sortByFunc(direction, routers, func(i int) int { return routers[i].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() }) sortByFunc(direction, routers, func(i int) string { return routers[i].status() })
case "rule": case "rule":
@@ -170,7 +182,7 @@ func sortServices[T orderedService](values url.Values, services []T) {
} }
switch sortBy { switch sortBy {
case "name": case sortFieldName:
sortByName(direction, services) sortByName(direction, services)
case "type": case "type":
@@ -182,7 +194,7 @@ func sortServices[T orderedService](values url.Values, services []T) {
case "provider": case "provider":
sortByFunc(direction, services, func(i int) string { return services[i].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() }) sortByFunc(direction, services, func(i int) string { return services[i].status() })
default: default:
@@ -291,7 +303,7 @@ func sortMiddlewares[T orderedMiddleware](values url.Values, middlewares []T) {
} }
switch sortBy { switch sortBy {
case "name": case sortFieldName:
sortByName(direction, middlewares) sortByName(direction, middlewares)
case "type": case "type":
@@ -300,7 +312,7 @@ func sortMiddlewares[T orderedMiddleware](values url.Values, middlewares []T) {
case "provider": case "provider":
sortByFunc(direction, middlewares, func(i int) string { return middlewares[i].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() }) sortByFunc(direction, middlewares, func(i int) string { return middlewares[i].status() })
default: default:
@@ -340,6 +352,56 @@ func (m tcpMiddlewareRepresentation) status() string {
return m.Status 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) { func sortByName[T orderedWithName](direction string, results []T) {
// Ascending // Ascending
if direction == ascendantSorting { if direction == ascendantSorting {
+4 -4
View File
@@ -54,7 +54,7 @@ func TestReuseService(t *testing.T) {
transportManager := service.NewTransportManager(nil) transportManager := service.NewTransportManager(nil)
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) 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) tlsManager := tls.NewManager(nil)
dialerManager := tcp.NewDialerManager(nil) dialerManager := tcp.NewDialerManager(nil)
@@ -180,7 +180,7 @@ func TestServerResponseEmptyBackend(t *testing.T) {
transportManager := service.NewTransportManager(nil) transportManager := service.NewTransportManager(nil)
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) 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) tlsManager := tls.NewManager(nil)
dialerManager := tcp.NewDialerManager(nil) dialerManager := tcp.NewDialerManager(nil)
@@ -226,7 +226,7 @@ func TestInternalServices(t *testing.T) {
transportManager := service.NewTransportManager(nil) transportManager := service.NewTransportManager(nil)
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) 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) tlsManager := tls.NewManager(nil)
dialerManager := tcp.NewDialerManager(nil) dialerManager := tcp.NewDialerManager(nil)
@@ -275,8 +275,8 @@ func TestRecursionService(t *testing.T) {
transportManager := service.NewTransportManager(nil) transportManager := service.NewTransportManager(nil)
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}}) transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}})
managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil)
tlsManager := tls.NewManager(nil) tlsManager := tls.NewManager(nil)
managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil, tlsManager)
dialerManager := tcp.NewDialerManager(nil) dialerManager := tcp.NewDialerManager(nil)
dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}}) dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}})
+3 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/traefik/traefik/v3/pkg/observability/metrics" "github.com/traefik/traefik/v3/pkg/observability/metrics"
"github.com/traefik/traefik/v3/pkg/safe" "github.com/traefik/traefik/v3/pkg/safe"
"github.com/traefik/traefik/v3/pkg/server/middleware" "github.com/traefik/traefik/v3/pkg/server/middleware"
"github.com/traefik/traefik/v3/pkg/tls"
) )
// ManagerFactory a factory of service manager. // ManagerFactory a factory of service manager.
@@ -32,7 +33,7 @@ type ManagerFactory struct {
} }
// NewManagerFactory creates a new ManagerFactory. // 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{ factory := &ManagerFactory{
observabilityMgr: observabilityMgr, observabilityMgr: observabilityMgr,
routinesPool: routinesPool, routinesPool: routinesPool,
@@ -42,7 +43,7 @@ func NewManagerFactory(staticConfiguration static.Configuration, routinesPool *s
} }
if staticConfiguration.API != nil { if staticConfiguration.API != nil {
apiRouterBuilder := api.NewBuilder(staticConfiguration) apiRouterBuilder := api.NewBuilder(staticConfiguration, tlsManager)
if staticConfiguration.API.Dashboard { if staticConfiguration.API.Dashboard {
factory.dashboardHandler = dashboard.Handler{BasePath: staticConfiguration.API.BasePath} factory.dashboardHandler = dashboard.Handler{BasePath: staticConfiguration.API.BasePath}
+24 -12
View File
@@ -2,8 +2,10 @@ package tls
import ( import (
"context" "context"
"crypto/sha256"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"hash/fnv" "hash/fnv"
@@ -295,8 +297,11 @@ func (m *Manager) Get(storeName, configName string) (*tls.Config, error) {
// GetServerCertificates returns all certificates from the default store, // GetServerCertificates returns all certificates from the default store,
// as well as the user-defined default certificate (if it exists). // as well as the user-defined default certificate (if it exists).
func (m *Manager) GetServerCertificates() []*x509.Certificate { func (m *Manager) GetServerCertificates() map[string]*x509.Certificate {
var certificates []*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. // The default store is the only relevant, because it is the only one configurable.
defaultStore, ok := m.stores[DefaultTLSStoreName] defaultStore, ok := m.stores[DefaultTLSStoreName]
@@ -306,28 +311,35 @@ func (m *Manager) GetServerCertificates() []*x509.Certificate {
// We iterate over all the certificates. // We iterate over all the certificates.
if defaultStore.DynamicCerts != nil && defaultStore.DynamicCerts.Get() != nil { if defaultStore.DynamicCerts != nil && defaultStore.DynamicCerts.Get() != nil {
for _, cert := range defaultStore.DynamicCerts.Get().(map[string]*CertificateData) { certs, ok := defaultStore.DynamicCerts.Get().(map[string]*CertificateData)
x509Cert, err := x509.ParseCertificate(cert.Certificate.Certificate[0]) if ok {
if err != nil { for _, cert := range certs {
continue // 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 { if defaultStore.DefaultCertificate != nil {
x509Cert, err := x509.ParseCertificate(defaultStore.DefaultCertificate.Certificate.Certificate[0]) if defaultStore.DefaultCertificate.Certificate.Leaf == nil {
if err != nil { log.Warn().Msg("TLS: default certificate Leaf is nil, skipping in API response")
return certificates return certificates
} }
// Excluding the generated Traefik default certificate. // Excluding the generated Traefik default certificate.
if x509Cert.Subject.CommonName == generate.DefaultDomain { if defaultStore.DefaultCertificate.Certificate.Leaf.Subject.CommonName == generate.DefaultDomain {
return certificates 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 return certificates
+3 -1
View File
@@ -10,7 +10,7 @@ import fetch from './libs/fetch'
import { VersionProvider } from 'contexts/version' import { VersionProvider } from 'contexts/version'
import { useIsDarkMode } from 'hooks/use-theme' import { useIsDarkMode } from 'hooks/use-theme'
import ErrorSuspenseWrapper from 'layout/ErrorSuspenseWrapper' 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 { DashboardSkeleton } from 'pages/dashboard/Dashboard'
import { HubDemoContext, HubDemoProvider } from 'pages/hub-demo/demoNavContext' import { HubDemoContext, HubDemoProvider } from 'pages/hub-demo/demoNavContext'
@@ -48,6 +48,8 @@ export const Routes = () => {
</ErrorSuspenseWrapper> </ErrorSuspenseWrapper>
} }
/> />
<Route path="/certificates" element={<CertificatesPages.Certificates />} />
<Route path="/certificates/:name" element={<CertificatesPages.Certificate />} />
<Route path="/http/routers" element={<HTTPPages.HttpRouters />} /> <Route path="/http/routers" element={<HTTPPages.HttpRouters />} />
<Route path="/http/services" element={<HTTPPages.HttpServices />} /> <Route path="/http/services" element={<HTTPPages.HttpServices />} />
<Route path="/http/middlewares" element={<HTTPPages.HttpMiddlewares />} /> <Route path="/http/middlewares" element={<HTTPPages.HttpMiddlewares />} />
@@ -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 (
<Badge size={size} variant={variant}>
{daysLeft < 0 ? 'EXPIRED' : `${daysLeft} days`}
</Badge>
)
}
export default CertExpiryBadge
@@ -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) ? (
<Link
variant="blue"
href={`//${certificate.commonName}`}
target="_blank"
rel="noopener noreferrer"
css={{ fontSize: 'inherit' }}
>
{certificate.commonName}
</Link>
) : (
<ValText>{certificate.commonName || '-'}</ValText>
),
},
{
key: 'Status',
val: (
<Badge size="large" variant={certStatus.variant}>
{certStatus.label}
</Badge>
),
},
{
key: 'Subject Alternative Names',
val: (
<Box>
{certificate.sans.map((san) => (
<Box key={san}>
{isLinkableHostname(san) ? (
<Link variant="blue" href={`//${san}`} target="_blank" rel="noopener noreferrer">
{san}
</Link>
) : (
<ValText>{san}</ValText>
)}
</Box>
))}
</Box>
),
},
{ 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: <CertExpiryBadge daysLeft={certificate.daysLeft} />,
},
]
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 (
<Flex direction="column" gap={2}>
<DetailsCard title="Issued To" items={issuedToItems} keyColumns={1} minKeyWidth="200px" />
<DetailsCard title="Issued By" items={issuedByItems} keyColumns={1} minKeyWidth="200px" />
<DetailsCard title="Validity" items={validityItems} keyColumns={1} minKeyWidth="200px" />
<DetailsCard title="Technical Details" items={technicalItems} keyColumns={1} minKeyWidth="200px" />
<DetailsCard title="SHA-256 Fingerprints" items={fingerprintItems} keyColumns={1} minKeyWidth="200px" />
</Flex>
)
}
export default CertificateDetails
@@ -51,6 +51,11 @@ export const ResourceStatus = ({ status, withLabel = false, size = 20 }: Props)
icon: iconByStatus.disabled, icon: iconByStatus.disabled,
label: 'Error', label: 'Error',
}, },
expired: {
color: colorByStatus.expired,
icon: iconByStatus.expired,
label: 'Expired',
},
loading: { loading: {
color: colorByStatus.loading, color: colorByStatus.loading,
icon: iconByStatus.loading, icon: iconByStatus.loading,
@@ -9,6 +9,7 @@ export const iconByStatus: { [key in Resource.Status]: ReactNode } = {
error: <FiAlertTriangle color="currentColor" size={20} />, error: <FiAlertTriangle color="currentColor" size={20} />,
enabled: <FiCheckCircle color="currentColor" size={20} />, enabled: <FiCheckCircle color="currentColor" size={20} />,
disabled: <FiAlertTriangle color="currentColor" size={20} />, disabled: <FiAlertTriangle color="currentColor" size={20} />,
expired: <FiAlertTriangle color="currentColor" size={20} />,
loading: <FiLoader color="currentColor" size={20} />, loading: <FiLoader color="currentColor" size={20} />,
} }
@@ -20,6 +21,7 @@ export const colorByStatus: { [key in Resource.Status]: string } = {
error: 'hsl(347, 100%, 60.0%)', error: 'hsl(347, 100%, 60.0%)',
enabled: '#30A46C', enabled: '#30A46C',
disabled: 'hsl(347, 100%, 60.0%)', disabled: 'hsl(347, 100%, 60.0%)',
expired: 'hsl(347, 100%, 60.0%)',
loading: 'hsla(0, 0%, 100%, 0.51)', loading: 'hsla(0, 0%, 100%, 0.51)',
} }
@@ -45,6 +47,8 @@ export default function Status({ css = {}, size = 20, status, color = 'white' }:
return <FiCheckCircle color={color} size={size} /> return <FiCheckCircle color={color} size={size} />
case 'disabled': case 'disabled':
return <FiAlertTriangle color={color} size={size} /> return <FiAlertTriangle color={color} size={size} />
case 'expired':
return <FiAlertTriangle color={color} size={size} />
default: default:
return null return null
} }
+43
View File
@@ -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<Certificate.Raw[]>('/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<Certificate.Raw>(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,
}
}
+2
View File
@@ -10,6 +10,7 @@ type TotalsResult = {
http: TotalsResultItem http: TotalsResultItem
tcp: TotalsResultItem tcp: TotalsResultItem
udp: TotalsResultItem udp: TotalsResultItem
certificates: number
} }
const useTotals = (): TotalsResult => { const useTotals = (): TotalsResult => {
@@ -30,6 +31,7 @@ const useTotals = (): TotalsResult => {
routers: data?.udp?.routers?.total, routers: data?.udp?.routers?.total,
services: data?.udp?.services?.total, services: data?.udp?.services?.total,
}, },
certificates: data?.certificates?.total,
} }
} }
+3 -2
View File
@@ -135,7 +135,7 @@ export const SideNav = ({
const windowSize = useWindowSize() const windowSize = useWindowSize()
const { version } = useContext(VersionContext) const { version } = useContext(VersionContext)
const { http, tcp, udp } = useTotals() const { http, tcp, udp, certificates } = useTotals()
const [isSmallScreen, setIsSmallScreen] = useState(false) const [isSmallScreen, setIsSmallScreen] = useState(false)
@@ -155,8 +155,9 @@ export const SideNav = ({
'/tcp/middlewares': tcp?.middlewares as number, '/tcp/middlewares': tcp?.middlewares as number,
'/udp/routers': udp?.routers, '/udp/routers': udp?.routers,
'/udp/services': udp?.services, '/udp/services': udp?.services,
'/certificates': certificates,
}), }),
[http, tcp, udp], [http, tcp, udp, certificates],
) )
return ( return (
+210
View File
@@ -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"
}
]
+5
View File
@@ -45,6 +45,11 @@
"errors": 0 "errors": 0
} }
}, },
"certificates": {
"total": 9,
"warnings": 1,
"errors": 1
},
"features": { "features": {
"tracing": "Prometheus", "tracing": "Prometheus",
"metrics": "", "metrics": "",
+18
View File
@@ -14,5 +14,23 @@
], ],
"priority": 10, "priority": 10,
"provider": "docker" "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"
} }
] ]
+2
View File
@@ -1,5 +1,6 @@
import { http, passthrough } from 'msw' import { http, passthrough } from 'msw'
import apiCertificates from './data/api-certificates.json'
import apiEntrypoints from './data/api-entrypoints.json' import apiEntrypoints from './data/api-entrypoints.json'
import apiHttpMiddlewares from './data/api-http_middlewares.json' import apiHttpMiddlewares from './data/api-http_middlewares.json'
import apiHttpRouters from './data/api-http_routers.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' import { listHandlers } from './utils'
export const getHandlers = (noDelay: boolean = false) => [ export const getHandlers = (noDelay: boolean = false) => [
...listHandlers('/api/certificates', apiCertificates, noDelay),
...listHandlers('/api/entrypoints', apiEntrypoints, noDelay, true), ...listHandlers('/api/entrypoints', apiEntrypoints, noDelay, true),
...listHandlers('/api/errors', eeApiErrors, noDelay), ...listHandlers('/api/errors', eeApiErrors, noDelay),
...listHandlers('/api/http/middlewares', apiHttpMiddlewares, noDelay), ...listHandlers('/api/http/middlewares', apiHttpMiddlewares, noDelay),
@@ -0,0 +1,90 @@
import * as useCertificates from '../../hooks/use-certificates'
import { Certificate } from './Certificate'
import { renderWithProviders } from 'utils/test'
describe('<CertificatePage />', () => {
it('should render the loading state initially', () => {
vi.spyOn(useCertificates, 'useCertificate').mockImplementation(() => ({
certificate: null,
error: null,
isLoading: true,
}))
const { getByTestId } = renderWithProviders(<Certificate />, {
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(<Certificate />, {
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(<Certificate />, {
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(<Certificate />, {
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()
})
})
@@ -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 (
<Box>
<PageTitle title={name || ''} />
<Skeleton css={{ height: '$7', width: '320px', mb: '$7' }} data-testid="skeleton" />
<Flex direction="column" gap={6}>
<DetailsCardSkeleton keyColumns={1} rows={5} />
<DetailsCardSkeleton keyColumns={1} rows={5} />
<DetailsCardSkeleton keyColumns={1} rows={5} />
</Flex>
</Box>
)
}
if (error) {
return (
<>
<PageTitle title={certificate?.commonName || name || ''} />
<Text data-testid="error-text">
Sorry, we could not fetch detail information for this Certificate right now. Please, try again later.
</Text>
</>
)
}
if (!certificate) {
return <NotFound />
}
return (
<>
<PageTitle title={`Certificate ${certificate.commonName}`} />
<H1 css={{ mb: '$4' }}>{certificate.commonName || 'Certificate'}</H1>
<CertificateDetails certificate={certificate} />
</>
)
}
@@ -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('<CertificatesPage />', () => {
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(<CertificatesPage />, {
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(
<CertificatesRender
error={undefined}
isEmpty={true}
isLoadingMore={false}
isReachingEnd={true}
loadMore={() => {}}
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(
<CertificatesRender
error={new Error('Test error')}
isEmpty={false}
isLoadingMore={false}
isReachingEnd={true}
loadMore={() => {}}
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(
<CertificatesRender
error={undefined}
isEmpty={false}
isLoadingMore={false}
isReachingEnd={true}
loadMore={() => {}}
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')
})
})
@@ -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 (
<ClickableRow key={cert.name} to={`/certificates/${cert.name}`}>
<AriaTd>
<ResourceStatus status={cert.status} />
</AriaTd>
<AriaTd>
<TooltipText text={cert.commonName || '-'} />
</AriaTd>
<AriaTd css={{ maxWidth: '240px' }}>
<Text css={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{cert.sans?.length > 0 ? cert.sans.join(', ') : '-'}
</Text>
</AriaTd>
<AriaTd>
<TooltipText text={cert.issuerOrg || cert.issuerCN || 'Unknown'} />
</AriaTd>
<AriaTd>
<Text>{validUntil}</Text>
</AriaTd>
<AriaTd>
<CertExpiryBadge daysLeft={daysLeft} size="small" />
</AriaTd>
</ClickableRow>
)
}
export const CertificatesRender = ({
error,
isEmpty,
isLoadingMore,
isReachingEnd,
loadMore,
pageCount,
pages,
}: pagesResponseInterface) => {
const [infiniteRef] = useInfiniteScroll({
loading: isLoadingMore,
hasNextPage: !isReachingEnd && !error,
onLoadMore: loadMore,
})
return (
<>
<AriaTable>
<AriaThead>
<AriaTr>
<SortableTh label="Status" isSortable sortByValue="status" css={{ width: '36px' }} />
<SortableTh label="Common Name" isSortable sortByValue="cn" />
<SortableTh label="SANs" css={{ maxWidth: '240px' }} />
<SortableTh label="Issuer" isSortable sortByValue="issuer" />
<SortableTh label="Valid Until" isSortable sortByValue="validUntil" css={{ width: '100px' }} />
<SortableTh label="Expiry" css={{ width: '100px' }} />
</AriaTr>
</AriaThead>
<AriaTbody>{pages}</AriaTbody>
{(isEmpty || !!error) && (
<AriaTfoot>
<AriaTr>
<EmptyPlaceholderTd message={error ? 'Failed to fetch data' : 'No data available'} />
</AriaTr>
</AriaTfoot>
)}
</AriaTable>
<Flex css={{ height: 60, alignItems: 'center', justifyContent: 'center' }} ref={infiniteRef}>
{isLoadingMore ? <SpinnerLoader /> : isReachingEnd && pageCount > 1 && <ScrollTopButton />}
</Flex>
</>
)
}
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 (
<>
<PageTitle title="Certificates" />
<TableFilter />
<CertificatesRender
error={error}
isEmpty={isEmpty}
isLoadingMore={isLoadingMore}
isReachingEnd={isReachingEnd}
loadMore={loadMore}
pageCount={pageCount}
pages={pages}
/>
</>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { Certificates } from './Certificates'
export { Certificate } from './Certificate'
+2 -1
View File
@@ -1,7 +1,8 @@
import * as CertificatesPages from './certificates'
import * as HTTPPages from './http' import * as HTTPPages from './http'
import * as TCPPages from './tcp' import * as TCPPages from './tcp'
import * as UDPPages from './udp' import * as UDPPages from './udp'
export { Dashboard } from './dashboard/Dashboard' export { Dashboard } from './dashboard/Dashboard'
export { NotFound } from './NotFound' export { NotFound } from './NotFound'
export { HTTPPages, TCPPages, UDPPages } export { HTTPPages, TCPPages, UDPPages, CertificatesPages }
+19 -1
View File
@@ -1,5 +1,11 @@
import { ReactNode } from 'react' 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 = { export type Route = {
path: string path: string
@@ -91,4 +97,16 @@ export const ROUTES: RouteSections[] = [
}, },
], ],
}, },
{
section: 'certificates',
sectionLabel: 'Certificates',
items: [
{
path: '/certificates',
activeMatches: ['/certificates/:name'],
label: 'Certificates',
icon: <LiaCertificateSolid color="currentColor" size={20} />,
},
],
},
] ]
+32 -3
View File
@@ -1,5 +1,5 @@
declare namespace Resource { 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 type DetailsData = Router.DetailsData & Service.Details & Middleware.DetailsData
} }
@@ -19,10 +19,10 @@ declare namespace Router {
} }
type TLS = { type TLS = {
options: string options?: string
certResolver: string certResolver: string
domains: TlsDomain[] domains: TlsDomain[]
passthrough: boolean passthrough?: boolean
} }
type Details = { type Details = {
@@ -121,3 +121,32 @@ declare namespace Middleware {
routers?: Router.Details[] 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
}
}