mirror of
https://github.com/traefik/traefik.git
synced 2026-06-17 19:09:29 +03:00
Add certificates menu and overview
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+68
-6
@@ -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 {
|
||||
|
||||
@@ -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": {}})
|
||||
|
||||
@@ -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}
|
||||
|
||||
+24
-12
@@ -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
|
||||
|
||||
+3
-1
@@ -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 = () => {
|
||||
</ErrorSuspenseWrapper>
|
||||
}
|
||||
/>
|
||||
<Route path="/certificates" element={<CertificatesPages.Certificates />} />
|
||||
<Route path="/certificates/:name" element={<CertificatesPages.Certificate />} />
|
||||
<Route path="/http/routers" element={<HTTPPages.HttpRouters />} />
|
||||
<Route path="/http/services" element={<HTTPPages.HttpServices />} />
|
||||
<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,
|
||||
label: 'Error',
|
||||
},
|
||||
expired: {
|
||||
color: colorByStatus.expired,
|
||||
icon: iconByStatus.expired,
|
||||
label: 'Expired',
|
||||
},
|
||||
loading: {
|
||||
color: colorByStatus.loading,
|
||||
icon: iconByStatus.loading,
|
||||
|
||||
@@ -9,6 +9,7 @@ export const iconByStatus: { [key in Resource.Status]: ReactNode } = {
|
||||
error: <FiAlertTriangle color="currentColor" size={20} />,
|
||||
enabled: <FiCheckCircle color="currentColor" size={20} />,
|
||||
disabled: <FiAlertTriangle color="currentColor" size={20} />,
|
||||
expired: <FiAlertTriangle 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%)',
|
||||
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 <FiCheckCircle color={color} size={size} />
|
||||
case 'disabled':
|
||||
return <FiAlertTriangle color={color} size={size} />
|
||||
case 'expired':
|
||||
return <FiAlertTriangle color={color} size={size} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -45,6 +45,11 @@
|
||||
"errors": 0
|
||||
}
|
||||
},
|
||||
"certificates": {
|
||||
"total": 9,
|
||||
"warnings": 1,
|
||||
"errors": 1
|
||||
},
|
||||
"features": {
|
||||
"tracing": "Prometheus",
|
||||
"metrics": "",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Certificates } from './Certificates'
|
||||
export { Certificate } from './Certificate'
|
||||
@@ -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 }
|
||||
|
||||
+19
-1
@@ -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: <LiaCertificateSolid color="currentColor" size={20} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
Vendored
+32
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user