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)
|
dialerManager := tcp.NewDialerManager(spiffeX509Source)
|
||||||
acmeHTTPHandler := getHTTPChallengeHandler(acmeProviders, httpChallengeProvider)
|
acmeHTTPHandler := getHTTPChallengeHandler(acmeProviders, httpChallengeProvider)
|
||||||
managerFactory := service.NewManagerFactory(*staticConfiguration, routinesPool, observabilityMgr, transportManager, proxyBuilder, acmeHTTPHandler)
|
managerFactory := service.NewManagerFactory(*staticConfiguration, routinesPool, observabilityMgr, transportManager, proxyBuilder, acmeHTTPHandler, tlsManager)
|
||||||
|
|
||||||
// Router factory
|
// Router factory
|
||||||
|
|
||||||
|
|||||||
@@ -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/dynamic"
|
||||||
"github.com/traefik/traefik/v3/pkg/config/runtime"
|
"github.com/traefik/traefik/v3/pkg/config/runtime"
|
||||||
"github.com/traefik/traefik/v3/pkg/config/static"
|
"github.com/traefik/traefik/v3/pkg/config/static"
|
||||||
|
"github.com/traefik/traefik/v3/pkg/tls"
|
||||||
"github.com/traefik/traefik/v3/pkg/version"
|
"github.com/traefik/traefik/v3/pkg/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,12 +59,14 @@ type Handler struct {
|
|||||||
|
|
||||||
// runtimeConfiguration is the data set used to create all the data representations exposed by the API.
|
// runtimeConfiguration is the data set used to create all the data representations exposed by the API.
|
||||||
runtimeConfiguration *runtime.Configuration
|
runtimeConfiguration *runtime.Configuration
|
||||||
|
|
||||||
|
tlsManager *tls.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBuilder returns a http.Handler builder based on runtime.Configuration.
|
// NewBuilder returns a http.Handler builder based on runtime.Configuration.
|
||||||
func NewBuilder(staticConfig static.Configuration) func(*runtime.Configuration) http.Handler {
|
func NewBuilder(staticConfig static.Configuration, tlsManager *tls.Manager) func(*runtime.Configuration) http.Handler {
|
||||||
return func(configuration *runtime.Configuration) http.Handler {
|
return func(configuration *runtime.Configuration) http.Handler {
|
||||||
return New(staticConfig, configuration).createRouter()
|
return New(staticConfig, configuration).WithTLSManager(tlsManager).createRouter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +84,14 @@ func New(staticConfig static.Configuration, runtimeConfig *runtime.Configuration
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithTLSManager sets the TLS manager on the handler, enabling the certificate API endpoints.
|
||||||
|
func (h *Handler) WithTLSManager(tlsManager *tls.Manager) *Handler {
|
||||||
|
h.tlsManager = tlsManager
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
// createRouter creates API routes and router.
|
// createRouter creates API routes and router.
|
||||||
func (h Handler) createRouter() *mux.Router {
|
func (h *Handler) createRouter() *mux.Router {
|
||||||
router := mux.NewRouter().UseEncodedPath()
|
router := mux.NewRouter().UseEncodedPath()
|
||||||
|
|
||||||
apiRouter := router.PathPrefix(h.staticConfig.API.BasePath).Subrouter().UseEncodedPath()
|
apiRouter := router.PathPrefix(h.staticConfig.API.BasePath).Subrouter().UseEncodedPath()
|
||||||
@@ -120,12 +129,15 @@ func (h Handler) createRouter() *mux.Router {
|
|||||||
apiRouter.Methods(http.MethodGet).Path("/api/udp/services").HandlerFunc(h.getUDPServices)
|
apiRouter.Methods(http.MethodGet).Path("/api/udp/services").HandlerFunc(h.getUDPServices)
|
||||||
apiRouter.Methods(http.MethodGet).Path("/api/udp/services/{serviceID}").HandlerFunc(h.getUDPService)
|
apiRouter.Methods(http.MethodGet).Path("/api/udp/services/{serviceID}").HandlerFunc(h.getUDPService)
|
||||||
|
|
||||||
|
apiRouter.Methods(http.MethodGet).Path("/api/certificates").HandlerFunc(h.getCertificates)
|
||||||
|
apiRouter.Methods(http.MethodGet).Path("/api/certificates/{certificateID}").HandlerFunc(h.getCertificate)
|
||||||
|
|
||||||
version.Handler{}.Append(apiRouter)
|
version.Handler{}.Append(apiRouter)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getRuntimeConfiguration(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getRuntimeConfiguration(rw http.ResponseWriter, request *http.Request) {
|
||||||
siRepr := make(map[string]*serviceInfoRepresentation, len(h.runtimeConfiguration.Services))
|
siRepr := make(map[string]*serviceInfoRepresentation, len(h.runtimeConfiguration.Services))
|
||||||
for k, v := range h.runtimeConfiguration.Services {
|
for k, v := range h.runtimeConfiguration.Services {
|
||||||
siRepr[k] = &serviceInfoRepresentation{
|
siRepr[k] = &serviceInfoRepresentation{
|
||||||
|
|||||||
@@ -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"`
|
Name string `json:"name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getEntryPoints(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getEntryPoints(rw http.ResponseWriter, request *http.Request) {
|
||||||
results := make([]entryPointRepresentation, 0, len(h.staticConfig.EntryPoints))
|
results := make([]entryPointRepresentation, 0, len(h.staticConfig.EntryPoints))
|
||||||
|
|
||||||
for name, ep := range h.staticConfig.EntryPoints {
|
for name, ep := range h.staticConfig.EntryPoints {
|
||||||
@@ -50,7 +50,7 @@ func (h Handler) getEntryPoints(rw http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getEntryPoint(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getEntryPoint(rw http.ResponseWriter, request *http.Request) {
|
||||||
scapedEntryPointID := mux.Vars(request)["entryPointID"]
|
scapedEntryPointID := mux.Vars(request)["entryPointID"]
|
||||||
|
|
||||||
entryPointID, err := url.PathUnescape(scapedEntryPointID)
|
entryPointID, err := url.PathUnescape(scapedEntryPointID)
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func newMiddlewareRepresentation(name string, mi *runtime.MiddlewareInfo) middle
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getRouters(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getRouters(rw http.ResponseWriter, request *http.Request) {
|
||||||
results := make([]routerRepresentation, 0, len(h.runtimeConfiguration.Routers))
|
results := make([]routerRepresentation, 0, len(h.runtimeConfiguration.Routers))
|
||||||
|
|
||||||
query := request.URL.Query()
|
query := request.URL.Query()
|
||||||
@@ -102,7 +102,7 @@ func (h Handler) getRouters(rw http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getRouter(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getRouter(rw http.ResponseWriter, request *http.Request) {
|
||||||
scapedRouterID := mux.Vars(request)["routerID"]
|
scapedRouterID := mux.Vars(request)["routerID"]
|
||||||
|
|
||||||
routerID, err := url.PathUnescape(scapedRouterID)
|
routerID, err := url.PathUnescape(scapedRouterID)
|
||||||
@@ -128,7 +128,7 @@ func (h Handler) getRouter(rw http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getServices(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getServices(rw http.ResponseWriter, request *http.Request) {
|
||||||
results := make([]serviceRepresentation, 0, len(h.runtimeConfiguration.Services))
|
results := make([]serviceRepresentation, 0, len(h.runtimeConfiguration.Services))
|
||||||
|
|
||||||
query := request.URL.Query()
|
query := request.URL.Query()
|
||||||
@@ -159,7 +159,7 @@ func (h Handler) getServices(rw http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getService(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getService(rw http.ResponseWriter, request *http.Request) {
|
||||||
scapedServiceID := mux.Vars(request)["serviceID"]
|
scapedServiceID := mux.Vars(request)["serviceID"]
|
||||||
|
|
||||||
serviceID, err := url.PathUnescape(scapedServiceID)
|
serviceID, err := url.PathUnescape(scapedServiceID)
|
||||||
@@ -185,7 +185,7 @@ func (h Handler) getService(rw http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getMiddlewares(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getMiddlewares(rw http.ResponseWriter, request *http.Request) {
|
||||||
results := make([]middlewareRepresentation, 0, len(h.runtimeConfiguration.Middlewares))
|
results := make([]middlewareRepresentation, 0, len(h.runtimeConfiguration.Middlewares))
|
||||||
|
|
||||||
query := request.URL.Query()
|
query := request.URL.Query()
|
||||||
@@ -216,7 +216,7 @@ func (h Handler) getMiddlewares(rw http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getMiddleware(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getMiddleware(rw http.ResponseWriter, request *http.Request) {
|
||||||
scapedMiddlewareID := mux.Vars(request)["middlewareID"]
|
scapedMiddlewareID := mux.Vars(request)["middlewareID"]
|
||||||
|
|
||||||
middlewareID, err := url.PathUnescape(scapedMiddlewareID)
|
middlewareID, err := url.PathUnescape(scapedMiddlewareID)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/traefik/traefik/v3/pkg/config/runtime"
|
"github.com/traefik/traefik/v3/pkg/config/runtime"
|
||||||
"github.com/traefik/traefik/v3/pkg/config/static"
|
"github.com/traefik/traefik/v3/pkg/config/static"
|
||||||
|
"github.com/traefik/traefik/v3/pkg/tls"
|
||||||
)
|
)
|
||||||
|
|
||||||
type schemeOverview struct {
|
type schemeOverview struct {
|
||||||
@@ -33,11 +34,12 @@ type overview struct {
|
|||||||
HTTP schemeOverview `json:"http"`
|
HTTP schemeOverview `json:"http"`
|
||||||
TCP schemeOverview `json:"tcp"`
|
TCP schemeOverview `json:"tcp"`
|
||||||
UDP schemeOverview `json:"udp"`
|
UDP schemeOverview `json:"udp"`
|
||||||
|
Certificates *section `json:"certificates,omitempty"`
|
||||||
Features features `json:"features,omitempty"`
|
Features features `json:"features,omitempty"`
|
||||||
Providers []string `json:"providers,omitempty"`
|
Providers []string `json:"providers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getOverview(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getOverview(rw http.ResponseWriter, request *http.Request) {
|
||||||
result := overview{
|
result := overview{
|
||||||
HTTP: schemeOverview{
|
HTTP: schemeOverview{
|
||||||
Routers: getHTTPRouterSection(h.runtimeConfiguration.Routers),
|
Routers: getHTTPRouterSection(h.runtimeConfiguration.Routers),
|
||||||
@@ -53,6 +55,7 @@ func (h Handler) getOverview(rw http.ResponseWriter, request *http.Request) {
|
|||||||
Routers: getUDPRouterSection(h.runtimeConfiguration.UDPRouters),
|
Routers: getUDPRouterSection(h.runtimeConfiguration.UDPRouters),
|
||||||
Services: getUDPServiceSection(h.runtimeConfiguration.UDPServices),
|
Services: getUDPServiceSection(h.runtimeConfiguration.UDPServices),
|
||||||
},
|
},
|
||||||
|
Certificates: getCertificatesSection(h.tlsManager),
|
||||||
Features: getFeatures(h.staticConfig),
|
Features: getFeatures(h.staticConfig),
|
||||||
Providers: getProviders(h.staticConfig),
|
Providers: getProviders(h.staticConfig),
|
||||||
}
|
}
|
||||||
@@ -285,3 +288,29 @@ func getTracing(conf static.Configuration) string {
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getCertificatesSection(tlsManager *tls.Manager) *section {
|
||||||
|
if tlsManager == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
x509Certs := tlsManager.GetServerCertificates()
|
||||||
|
var countWarnings int
|
||||||
|
var countErrors int
|
||||||
|
|
||||||
|
for _, cert := range x509Certs {
|
||||||
|
status := getCertificateStatus(cert.NotAfter)
|
||||||
|
switch status {
|
||||||
|
case certStatusExpired:
|
||||||
|
countErrors++
|
||||||
|
case certStatusWarning:
|
||||||
|
countWarnings++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return §ion{
|
||||||
|
Total: len(x509Certs),
|
||||||
|
Warnings: countWarnings,
|
||||||
|
Errors: countErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"github.com/traefik/traefik/v3/pkg/version"
|
"github.com/traefik/traefik/v3/pkg/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h Handler) getSupportDump(rw http.ResponseWriter, req *http.Request) {
|
func (h *Handler) getSupportDump(rw http.ResponseWriter, req *http.Request) {
|
||||||
logger := log.Ctx(req.Context())
|
logger := log.Ctx(req.Context())
|
||||||
|
|
||||||
staticConfig, err := redactor.Anonymize(h.staticConfig)
|
staticConfig, err := redactor.Anonymize(h.staticConfig)
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func newTCPMiddlewareRepresentation(name string, mi *runtime.TCPMiddlewareInfo)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getTCPRouters(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getTCPRouters(rw http.ResponseWriter, request *http.Request) {
|
||||||
results := make([]tcpRouterRepresentation, 0, len(h.runtimeConfiguration.TCPRouters))
|
results := make([]tcpRouterRepresentation, 0, len(h.runtimeConfiguration.TCPRouters))
|
||||||
|
|
||||||
query := request.URL.Query()
|
query := request.URL.Query()
|
||||||
@@ -97,7 +97,7 @@ func (h Handler) getTCPRouters(rw http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getTCPRouter(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getTCPRouter(rw http.ResponseWriter, request *http.Request) {
|
||||||
scapedRouterID := mux.Vars(request)["routerID"]
|
scapedRouterID := mux.Vars(request)["routerID"]
|
||||||
|
|
||||||
routerID, err := url.PathUnescape(scapedRouterID)
|
routerID, err := url.PathUnescape(scapedRouterID)
|
||||||
@@ -123,7 +123,7 @@ func (h Handler) getTCPRouter(rw http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getTCPServices(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getTCPServices(rw http.ResponseWriter, request *http.Request) {
|
||||||
results := make([]tcpServiceRepresentation, 0, len(h.runtimeConfiguration.TCPServices))
|
results := make([]tcpServiceRepresentation, 0, len(h.runtimeConfiguration.TCPServices))
|
||||||
|
|
||||||
query := request.URL.Query()
|
query := request.URL.Query()
|
||||||
@@ -154,7 +154,7 @@ func (h Handler) getTCPServices(rw http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getTCPService(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getTCPService(rw http.ResponseWriter, request *http.Request) {
|
||||||
scapedServiceID := mux.Vars(request)["serviceID"]
|
scapedServiceID := mux.Vars(request)["serviceID"]
|
||||||
|
|
||||||
serviceID, err := url.PathUnescape(scapedServiceID)
|
serviceID, err := url.PathUnescape(scapedServiceID)
|
||||||
@@ -180,7 +180,7 @@ func (h Handler) getTCPService(rw http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getTCPMiddlewares(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getTCPMiddlewares(rw http.ResponseWriter, request *http.Request) {
|
||||||
results := make([]tcpMiddlewareRepresentation, 0, len(h.runtimeConfiguration.Middlewares))
|
results := make([]tcpMiddlewareRepresentation, 0, len(h.runtimeConfiguration.Middlewares))
|
||||||
|
|
||||||
query := request.URL.Query()
|
query := request.URL.Query()
|
||||||
@@ -211,7 +211,7 @@ func (h Handler) getTCPMiddlewares(rw http.ResponseWriter, request *http.Request
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getTCPMiddleware(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getTCPMiddleware(rw http.ResponseWriter, request *http.Request) {
|
||||||
scapedMiddlewareID := mux.Vars(request)["middlewareID"]
|
scapedMiddlewareID := mux.Vars(request)["middlewareID"]
|
||||||
|
|
||||||
middlewareID, err := url.PathUnescape(scapedMiddlewareID)
|
middlewareID, err := url.PathUnescape(scapedMiddlewareID)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func newUDPServiceRepresentation(name string, si *runtime.UDPServiceInfo) udpSer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getUDPRouters(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getUDPRouters(rw http.ResponseWriter, request *http.Request) {
|
||||||
results := make([]udpRouterRepresentation, 0, len(h.runtimeConfiguration.UDPRouters))
|
results := make([]udpRouterRepresentation, 0, len(h.runtimeConfiguration.UDPRouters))
|
||||||
|
|
||||||
query := request.URL.Query()
|
query := request.URL.Query()
|
||||||
@@ -76,7 +76,7 @@ func (h Handler) getUDPRouters(rw http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getUDPRouter(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getUDPRouter(rw http.ResponseWriter, request *http.Request) {
|
||||||
scapedRouterID := mux.Vars(request)["routerID"]
|
scapedRouterID := mux.Vars(request)["routerID"]
|
||||||
|
|
||||||
routerID, err := url.PathUnescape(scapedRouterID)
|
routerID, err := url.PathUnescape(scapedRouterID)
|
||||||
@@ -102,7 +102,7 @@ func (h Handler) getUDPRouter(rw http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getUDPServices(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getUDPServices(rw http.ResponseWriter, request *http.Request) {
|
||||||
results := make([]udpServiceRepresentation, 0, len(h.runtimeConfiguration.UDPServices))
|
results := make([]udpServiceRepresentation, 0, len(h.runtimeConfiguration.UDPServices))
|
||||||
|
|
||||||
query := request.URL.Query()
|
query := request.URL.Query()
|
||||||
@@ -133,7 +133,7 @@ func (h Handler) getUDPServices(rw http.ResponseWriter, request *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) getUDPService(rw http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getUDPService(rw http.ResponseWriter, request *http.Request) {
|
||||||
scapedServiceID := mux.Vars(request)["serviceID"]
|
scapedServiceID := mux.Vars(request)["serviceID"]
|
||||||
|
|
||||||
serviceID, err := url.PathUnescape(scapedServiceID)
|
serviceID, err := url.PathUnescape(scapedServiceID)
|
||||||
|
|||||||
+68
-6
@@ -4,6 +4,7 @@ import (
|
|||||||
"cmp"
|
"cmp"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -14,6 +15,9 @@ const (
|
|||||||
const (
|
const (
|
||||||
ascendantSorting = "asc"
|
ascendantSorting = "asc"
|
||||||
descendantSorting = "desc"
|
descendantSorting = "desc"
|
||||||
|
|
||||||
|
sortFieldName = "name"
|
||||||
|
sortFieldStatus = "status"
|
||||||
)
|
)
|
||||||
|
|
||||||
type orderedWithName interface {
|
type orderedWithName interface {
|
||||||
@@ -31,6 +35,14 @@ type orderedRouter interface {
|
|||||||
entryPointsCount() int
|
entryPointsCount() int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type orderedCertificate interface {
|
||||||
|
orderedWithName
|
||||||
|
|
||||||
|
status() string
|
||||||
|
issuer() string
|
||||||
|
validUntil() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
func sortRouters[T orderedRouter](values url.Values, routers []T) {
|
func sortRouters[T orderedRouter](values url.Values, routers []T) {
|
||||||
sortBy := values.Get(sortByParam)
|
sortBy := values.Get(sortByParam)
|
||||||
|
|
||||||
@@ -40,7 +52,7 @@ func sortRouters[T orderedRouter](values url.Values, routers []T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch sortBy {
|
switch sortBy {
|
||||||
case "name":
|
case sortFieldName:
|
||||||
sortByName(direction, routers)
|
sortByName(direction, routers)
|
||||||
|
|
||||||
case "provider":
|
case "provider":
|
||||||
@@ -49,7 +61,7 @@ func sortRouters[T orderedRouter](values url.Values, routers []T) {
|
|||||||
case "priority":
|
case "priority":
|
||||||
sortByFunc(direction, routers, func(i int) int { return routers[i].priority() })
|
sortByFunc(direction, routers, func(i int) int { return routers[i].priority() })
|
||||||
|
|
||||||
case "status":
|
case sortFieldStatus:
|
||||||
sortByFunc(direction, routers, func(i int) string { return routers[i].status() })
|
sortByFunc(direction, routers, func(i int) string { return routers[i].status() })
|
||||||
|
|
||||||
case "rule":
|
case "rule":
|
||||||
@@ -170,7 +182,7 @@ func sortServices[T orderedService](values url.Values, services []T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch sortBy {
|
switch sortBy {
|
||||||
case "name":
|
case sortFieldName:
|
||||||
sortByName(direction, services)
|
sortByName(direction, services)
|
||||||
|
|
||||||
case "type":
|
case "type":
|
||||||
@@ -182,7 +194,7 @@ func sortServices[T orderedService](values url.Values, services []T) {
|
|||||||
case "provider":
|
case "provider":
|
||||||
sortByFunc(direction, services, func(i int) string { return services[i].provider() })
|
sortByFunc(direction, services, func(i int) string { return services[i].provider() })
|
||||||
|
|
||||||
case "status":
|
case sortFieldStatus:
|
||||||
sortByFunc(direction, services, func(i int) string { return services[i].status() })
|
sortByFunc(direction, services, func(i int) string { return services[i].status() })
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -291,7 +303,7 @@ func sortMiddlewares[T orderedMiddleware](values url.Values, middlewares []T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch sortBy {
|
switch sortBy {
|
||||||
case "name":
|
case sortFieldName:
|
||||||
sortByName(direction, middlewares)
|
sortByName(direction, middlewares)
|
||||||
|
|
||||||
case "type":
|
case "type":
|
||||||
@@ -300,7 +312,7 @@ func sortMiddlewares[T orderedMiddleware](values url.Values, middlewares []T) {
|
|||||||
case "provider":
|
case "provider":
|
||||||
sortByFunc(direction, middlewares, func(i int) string { return middlewares[i].provider() })
|
sortByFunc(direction, middlewares, func(i int) string { return middlewares[i].provider() })
|
||||||
|
|
||||||
case "status":
|
case sortFieldStatus:
|
||||||
sortByFunc(direction, middlewares, func(i int) string { return middlewares[i].status() })
|
sortByFunc(direction, middlewares, func(i int) string { return middlewares[i].status() })
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -340,6 +352,56 @@ func (m tcpMiddlewareRepresentation) status() string {
|
|||||||
return m.Status
|
return m.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sortCertificates[T orderedCertificate](values url.Values, certificates []T) {
|
||||||
|
sortBy := values.Get(sortByParam)
|
||||||
|
|
||||||
|
direction := values.Get(directionParam)
|
||||||
|
if direction == "" {
|
||||||
|
direction = ascendantSorting
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sortBy {
|
||||||
|
case sortFieldName, "cn":
|
||||||
|
sortByName(direction, certificates)
|
||||||
|
|
||||||
|
case sortFieldStatus:
|
||||||
|
sortByFunc(direction, certificates, func(i int) string { return certificates[i].status() })
|
||||||
|
|
||||||
|
case "issuer":
|
||||||
|
sortByFunc(direction, certificates, func(i int) string { return certificates[i].issuer() })
|
||||||
|
|
||||||
|
case "validUntil":
|
||||||
|
sortByTime(direction, certificates, func(i int) time.Time { return certificates[i].validUntil() })
|
||||||
|
|
||||||
|
default:
|
||||||
|
sortByName(direction, certificates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortByTime[T orderedWithName](direction string, results []T, fn func(int) time.Time) {
|
||||||
|
// Ascending
|
||||||
|
if direction == ascendantSorting {
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
ti, tj := fn(i), fn(j)
|
||||||
|
if ti.Equal(tj) {
|
||||||
|
return results[i].name() < results[j].name()
|
||||||
|
}
|
||||||
|
return ti.Before(tj)
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Descending
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
ti, tj := fn(i), fn(j)
|
||||||
|
if ti.Equal(tj) {
|
||||||
|
return results[i].name() > results[j].name()
|
||||||
|
}
|
||||||
|
return ti.After(tj)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func sortByName[T orderedWithName](direction string, results []T) {
|
func sortByName[T orderedWithName](direction string, results []T) {
|
||||||
// Ascending
|
// Ascending
|
||||||
if direction == ascendantSorting {
|
if direction == ascendantSorting {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestReuseService(t *testing.T) {
|
|||||||
transportManager := service.NewTransportManager(nil)
|
transportManager := service.NewTransportManager(nil)
|
||||||
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}})
|
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}})
|
||||||
|
|
||||||
managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil)
|
managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil, nil)
|
||||||
tlsManager := tls.NewManager(nil)
|
tlsManager := tls.NewManager(nil)
|
||||||
|
|
||||||
dialerManager := tcp.NewDialerManager(nil)
|
dialerManager := tcp.NewDialerManager(nil)
|
||||||
@@ -180,7 +180,7 @@ func TestServerResponseEmptyBackend(t *testing.T) {
|
|||||||
transportManager := service.NewTransportManager(nil)
|
transportManager := service.NewTransportManager(nil)
|
||||||
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}})
|
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}})
|
||||||
|
|
||||||
managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil)
|
managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, proxyBuilderMock{}, nil, nil)
|
||||||
tlsManager := tls.NewManager(nil)
|
tlsManager := tls.NewManager(nil)
|
||||||
|
|
||||||
dialerManager := tcp.NewDialerManager(nil)
|
dialerManager := tcp.NewDialerManager(nil)
|
||||||
@@ -226,7 +226,7 @@ func TestInternalServices(t *testing.T) {
|
|||||||
transportManager := service.NewTransportManager(nil)
|
transportManager := service.NewTransportManager(nil)
|
||||||
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}})
|
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}})
|
||||||
|
|
||||||
managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil)
|
managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil, nil)
|
||||||
tlsManager := tls.NewManager(nil)
|
tlsManager := tls.NewManager(nil)
|
||||||
|
|
||||||
dialerManager := tcp.NewDialerManager(nil)
|
dialerManager := tcp.NewDialerManager(nil)
|
||||||
@@ -275,8 +275,8 @@ func TestRecursionService(t *testing.T) {
|
|||||||
transportManager := service.NewTransportManager(nil)
|
transportManager := service.NewTransportManager(nil)
|
||||||
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}})
|
transportManager.Update(map[string]*dynamic.ServersTransport{"default@internal": {}})
|
||||||
|
|
||||||
managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil)
|
|
||||||
tlsManager := tls.NewManager(nil)
|
tlsManager := tls.NewManager(nil)
|
||||||
|
managerFactory := service.NewManagerFactory(staticConfig, nil, nil, transportManager, nil, nil, tlsManager)
|
||||||
|
|
||||||
dialerManager := tcp.NewDialerManager(nil)
|
dialerManager := tcp.NewDialerManager(nil)
|
||||||
dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}})
|
dialerManager.Update(map[string]*dynamic.TCPServersTransport{"default@internal": {}})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/traefik/traefik/v3/pkg/observability/metrics"
|
"github.com/traefik/traefik/v3/pkg/observability/metrics"
|
||||||
"github.com/traefik/traefik/v3/pkg/safe"
|
"github.com/traefik/traefik/v3/pkg/safe"
|
||||||
"github.com/traefik/traefik/v3/pkg/server/middleware"
|
"github.com/traefik/traefik/v3/pkg/server/middleware"
|
||||||
|
"github.com/traefik/traefik/v3/pkg/tls"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ManagerFactory a factory of service manager.
|
// ManagerFactory a factory of service manager.
|
||||||
@@ -32,7 +33,7 @@ type ManagerFactory struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewManagerFactory creates a new ManagerFactory.
|
// NewManagerFactory creates a new ManagerFactory.
|
||||||
func NewManagerFactory(staticConfiguration static.Configuration, routinesPool *safe.Pool, observabilityMgr *middleware.ObservabilityMgr, transportManager *TransportManager, proxyBuilder ProxyBuilder, acmeHTTPHandler http.Handler) *ManagerFactory {
|
func NewManagerFactory(staticConfiguration static.Configuration, routinesPool *safe.Pool, observabilityMgr *middleware.ObservabilityMgr, transportManager *TransportManager, proxyBuilder ProxyBuilder, acmeHTTPHandler http.Handler, tlsManager *tls.Manager) *ManagerFactory {
|
||||||
factory := &ManagerFactory{
|
factory := &ManagerFactory{
|
||||||
observabilityMgr: observabilityMgr,
|
observabilityMgr: observabilityMgr,
|
||||||
routinesPool: routinesPool,
|
routinesPool: routinesPool,
|
||||||
@@ -42,7 +43,7 @@ func NewManagerFactory(staticConfiguration static.Configuration, routinesPool *s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if staticConfiguration.API != nil {
|
if staticConfiguration.API != nil {
|
||||||
apiRouterBuilder := api.NewBuilder(staticConfiguration)
|
apiRouterBuilder := api.NewBuilder(staticConfiguration, tlsManager)
|
||||||
|
|
||||||
if staticConfiguration.API.Dashboard {
|
if staticConfiguration.API.Dashboard {
|
||||||
factory.dashboardHandler = dashboard.Handler{BasePath: staticConfiguration.API.BasePath}
|
factory.dashboardHandler = dashboard.Handler{BasePath: staticConfiguration.API.BasePath}
|
||||||
|
|||||||
+23
-11
@@ -2,8 +2,10 @@ package tls
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
@@ -295,8 +297,11 @@ func (m *Manager) Get(storeName, configName string) (*tls.Config, error) {
|
|||||||
|
|
||||||
// GetServerCertificates returns all certificates from the default store,
|
// GetServerCertificates returns all certificates from the default store,
|
||||||
// as well as the user-defined default certificate (if it exists).
|
// as well as the user-defined default certificate (if it exists).
|
||||||
func (m *Manager) GetServerCertificates() []*x509.Certificate {
|
func (m *Manager) GetServerCertificates() map[string]*x509.Certificate {
|
||||||
var certificates []*x509.Certificate
|
m.lock.RLock()
|
||||||
|
defer m.lock.RUnlock()
|
||||||
|
|
||||||
|
certificates := make(map[string]*x509.Certificate)
|
||||||
|
|
||||||
// The default store is the only relevant, because it is the only one configurable.
|
// The default store is the only relevant, because it is the only one configurable.
|
||||||
defaultStore, ok := m.stores[DefaultTLSStoreName]
|
defaultStore, ok := m.stores[DefaultTLSStoreName]
|
||||||
@@ -306,28 +311,35 @@ func (m *Manager) GetServerCertificates() []*x509.Certificate {
|
|||||||
|
|
||||||
// We iterate over all the certificates.
|
// We iterate over all the certificates.
|
||||||
if defaultStore.DynamicCerts != nil && defaultStore.DynamicCerts.Get() != nil {
|
if defaultStore.DynamicCerts != nil && defaultStore.DynamicCerts.Get() != nil {
|
||||||
for _, cert := range defaultStore.DynamicCerts.Get().(map[string]*CertificateData) {
|
certs, ok := defaultStore.DynamicCerts.Get().(map[string]*CertificateData)
|
||||||
x509Cert, err := x509.ParseCertificate(cert.Certificate.Certificate[0])
|
if ok {
|
||||||
if err != nil {
|
for _, cert := range certs {
|
||||||
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
|
hash := sha256.Sum256(cert.Certificate.Leaf.Raw)
|
||||||
certificates = append(certificates, x509Cert)
|
fingerprint := hex.EncodeToString(hash[:])
|
||||||
|
certificates[fingerprint] = cert.Certificate.Leaf
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if defaultStore.DefaultCertificate != nil {
|
if defaultStore.DefaultCertificate != nil {
|
||||||
x509Cert, err := x509.ParseCertificate(defaultStore.DefaultCertificate.Certificate.Certificate[0])
|
if defaultStore.DefaultCertificate.Certificate.Leaf == nil {
|
||||||
if err != nil {
|
log.Warn().Msg("TLS: default certificate Leaf is nil, skipping in API response")
|
||||||
return certificates
|
return certificates
|
||||||
}
|
}
|
||||||
|
|
||||||
// Excluding the generated Traefik default certificate.
|
// Excluding the generated Traefik default certificate.
|
||||||
if x509Cert.Subject.CommonName == generate.DefaultDomain {
|
if defaultStore.DefaultCertificate.Certificate.Leaf.Subject.CommonName == generate.DefaultDomain {
|
||||||
return certificates
|
return certificates
|
||||||
}
|
}
|
||||||
|
|
||||||
certificates = append(certificates, x509Cert)
|
hash := sha256.Sum256(defaultStore.DefaultCertificate.Certificate.Leaf.Raw)
|
||||||
|
fingerprint := hex.EncodeToString(hash[:])
|
||||||
|
certificates[fingerprint] = defaultStore.DefaultCertificate.Certificate.Leaf
|
||||||
}
|
}
|
||||||
|
|
||||||
return certificates
|
return certificates
|
||||||
|
|||||||
+3
-1
@@ -10,7 +10,7 @@ import fetch from './libs/fetch'
|
|||||||
import { VersionProvider } from 'contexts/version'
|
import { VersionProvider } from 'contexts/version'
|
||||||
import { useIsDarkMode } from 'hooks/use-theme'
|
import { useIsDarkMode } from 'hooks/use-theme'
|
||||||
import ErrorSuspenseWrapper from 'layout/ErrorSuspenseWrapper'
|
import ErrorSuspenseWrapper from 'layout/ErrorSuspenseWrapper'
|
||||||
import { Dashboard, HTTPPages, NotFound, TCPPages, UDPPages } from 'pages'
|
import { Dashboard, HTTPPages, NotFound, TCPPages, UDPPages, CertificatesPages } from 'pages'
|
||||||
import { DashboardSkeleton } from 'pages/dashboard/Dashboard'
|
import { DashboardSkeleton } from 'pages/dashboard/Dashboard'
|
||||||
import { HubDemoContext, HubDemoProvider } from 'pages/hub-demo/demoNavContext'
|
import { HubDemoContext, HubDemoProvider } from 'pages/hub-demo/demoNavContext'
|
||||||
|
|
||||||
@@ -48,6 +48,8 @@ export const Routes = () => {
|
|||||||
</ErrorSuspenseWrapper>
|
</ErrorSuspenseWrapper>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/certificates" element={<CertificatesPages.Certificates />} />
|
||||||
|
<Route path="/certificates/:name" element={<CertificatesPages.Certificate />} />
|
||||||
<Route path="/http/routers" element={<HTTPPages.HttpRouters />} />
|
<Route path="/http/routers" element={<HTTPPages.HttpRouters />} />
|
||||||
<Route path="/http/services" element={<HTTPPages.HttpServices />} />
|
<Route path="/http/services" element={<HTTPPages.HttpServices />} />
|
||||||
<Route path="/http/middlewares" element={<HTTPPages.HttpMiddlewares />} />
|
<Route path="/http/middlewares" element={<HTTPPages.HttpMiddlewares />} />
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Badge } from '@traefik-labs/faency'
|
||||||
|
|
||||||
|
type ExpiryStatus = {
|
||||||
|
variant: 'red' | 'orange' | 'green'
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCertExpiryStatus = (daysLeft: number): ExpiryStatus => {
|
||||||
|
if (daysLeft < 0) return { variant: 'red', label: 'EXPIRED' }
|
||||||
|
if (daysLeft < 30) return { variant: 'orange', label: 'Expiring Soon' }
|
||||||
|
return { variant: 'green', label: 'Valid' }
|
||||||
|
}
|
||||||
|
|
||||||
|
type CertExpiryBadgeProps = {
|
||||||
|
daysLeft: number
|
||||||
|
size?: 'small' | 'large'
|
||||||
|
}
|
||||||
|
|
||||||
|
const CertExpiryBadge = ({ daysLeft, size = 'large' }: CertExpiryBadgeProps) => {
|
||||||
|
const { variant } = getCertExpiryStatus(daysLeft)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge size={size} variant={variant}>
|
||||||
|
{daysLeft < 0 ? 'EXPIRED' : `${daysLeft} days`}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CertExpiryBadge
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { Badge, Box, Flex, Link } from '@traefik-labs/faency'
|
||||||
|
import { type ReactElement, useMemo } from 'react'
|
||||||
|
|
||||||
|
import CertExpiryBadge, { getCertExpiryStatus } from 'components/certificates/CertExpiryBadge'
|
||||||
|
import DetailsCard, { ValText } from 'components/resources/DetailsCard'
|
||||||
|
|
||||||
|
const isLinkableHostname = (value?: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !value.startsWith('*.') && !/\s/.test(value) && !/^(\d{1,3}\.){3}\d{1,3}$/.test(value) && !value.includes(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CertificateDetails = ({ certificate }: { certificate: Certificate.Info }) => {
|
||||||
|
const validFrom = new Date(certificate.notBefore)
|
||||||
|
const validUntil = new Date(certificate.notAfter)
|
||||||
|
const certStatus = useMemo(() => getCertExpiryStatus(certificate.daysLeft), [certificate.daysLeft])
|
||||||
|
|
||||||
|
const issuedToItems = [
|
||||||
|
{
|
||||||
|
key: 'Common Name',
|
||||||
|
val: isLinkableHostname(certificate.commonName) ? (
|
||||||
|
<Link
|
||||||
|
variant="blue"
|
||||||
|
href={`//${certificate.commonName}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
css={{ fontSize: 'inherit' }}
|
||||||
|
>
|
||||||
|
{certificate.commonName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<ValText>{certificate.commonName || '-'}</ValText>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Status',
|
||||||
|
val: (
|
||||||
|
<Badge size="large" variant={certStatus.variant}>
|
||||||
|
{certStatus.label}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Subject Alternative Names',
|
||||||
|
val: (
|
||||||
|
<Box>
|
||||||
|
{certificate.sans.map((san) => (
|
||||||
|
<Box key={san}>
|
||||||
|
{isLinkableHostname(san) ? (
|
||||||
|
<Link variant="blue" href={`//${san}`} target="_blank" rel="noopener noreferrer">
|
||||||
|
{san}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<ValText>{san}</ValText>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'Organization', val: certificate.organization || '-' },
|
||||||
|
{ key: 'Country', val: certificate.country || '-' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const issuedByItems = [
|
||||||
|
{ key: 'Common Name', val: certificate.issuerCN || '-' },
|
||||||
|
{ key: 'Organization', val: certificate.issuerOrg || '-' },
|
||||||
|
{ key: 'Country', val: certificate.issuerCountry || '-' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const validityItems = [
|
||||||
|
{ key: 'Valid From', val: validFrom.toLocaleString() },
|
||||||
|
{ key: 'Valid Until', val: validUntil.toLocaleString() },
|
||||||
|
{
|
||||||
|
key: 'Expiry',
|
||||||
|
val: <CertExpiryBadge daysLeft={certificate.daysLeft} />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const technicalItems = [
|
||||||
|
certificate.version && { key: 'Version', val: certificate.version },
|
||||||
|
{ key: 'Serial Number', val: certificate.serialNumber || 'N/A' },
|
||||||
|
{ key: 'Key Type', val: certificate.keyType || 'Unknown' },
|
||||||
|
{ key: 'Key Size', val: `${certificate.keySize || 0} bits` },
|
||||||
|
{ key: 'Signature Algorithm', val: certificate.signatureAlgorithm || 'Unknown' },
|
||||||
|
].filter(Boolean) as { key: string; val: string | ReactElement }[]
|
||||||
|
|
||||||
|
const fingerprintItems = [
|
||||||
|
{ key: 'Certificate', val: certificate.certFingerprint || 'N/A' },
|
||||||
|
{ key: 'Public Key', val: certificate.publicKeyFingerprint || 'N/A' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap={2}>
|
||||||
|
<DetailsCard title="Issued To" items={issuedToItems} keyColumns={1} minKeyWidth="200px" />
|
||||||
|
<DetailsCard title="Issued By" items={issuedByItems} keyColumns={1} minKeyWidth="200px" />
|
||||||
|
<DetailsCard title="Validity" items={validityItems} keyColumns={1} minKeyWidth="200px" />
|
||||||
|
<DetailsCard title="Technical Details" items={technicalItems} keyColumns={1} minKeyWidth="200px" />
|
||||||
|
<DetailsCard title="SHA-256 Fingerprints" items={fingerprintItems} keyColumns={1} minKeyWidth="200px" />
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CertificateDetails
|
||||||
@@ -51,6 +51,11 @@ export const ResourceStatus = ({ status, withLabel = false, size = 20 }: Props)
|
|||||||
icon: iconByStatus.disabled,
|
icon: iconByStatus.disabled,
|
||||||
label: 'Error',
|
label: 'Error',
|
||||||
},
|
},
|
||||||
|
expired: {
|
||||||
|
color: colorByStatus.expired,
|
||||||
|
icon: iconByStatus.expired,
|
||||||
|
label: 'Expired',
|
||||||
|
},
|
||||||
loading: {
|
loading: {
|
||||||
color: colorByStatus.loading,
|
color: colorByStatus.loading,
|
||||||
icon: iconByStatus.loading,
|
icon: iconByStatus.loading,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const iconByStatus: { [key in Resource.Status]: ReactNode } = {
|
|||||||
error: <FiAlertTriangle color="currentColor" size={20} />,
|
error: <FiAlertTriangle color="currentColor" size={20} />,
|
||||||
enabled: <FiCheckCircle color="currentColor" size={20} />,
|
enabled: <FiCheckCircle color="currentColor" size={20} />,
|
||||||
disabled: <FiAlertTriangle color="currentColor" size={20} />,
|
disabled: <FiAlertTriangle color="currentColor" size={20} />,
|
||||||
|
expired: <FiAlertTriangle color="currentColor" size={20} />,
|
||||||
loading: <FiLoader color="currentColor" size={20} />,
|
loading: <FiLoader color="currentColor" size={20} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export const colorByStatus: { [key in Resource.Status]: string } = {
|
|||||||
error: 'hsl(347, 100%, 60.0%)',
|
error: 'hsl(347, 100%, 60.0%)',
|
||||||
enabled: '#30A46C',
|
enabled: '#30A46C',
|
||||||
disabled: 'hsl(347, 100%, 60.0%)',
|
disabled: 'hsl(347, 100%, 60.0%)',
|
||||||
|
expired: 'hsl(347, 100%, 60.0%)',
|
||||||
loading: 'hsla(0, 0%, 100%, 0.51)',
|
loading: 'hsla(0, 0%, 100%, 0.51)',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +47,8 @@ export default function Status({ css = {}, size = 20, status, color = 'white' }:
|
|||||||
return <FiCheckCircle color={color} size={size} />
|
return <FiCheckCircle color={color} size={size} />
|
||||||
case 'disabled':
|
case 'disabled':
|
||||||
return <FiAlertTriangle color={color} size={size} />
|
return <FiAlertTriangle color={color} size={size} />
|
||||||
|
case 'expired':
|
||||||
|
return <FiAlertTriangle color={color} size={size} />
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
http: TotalsResultItem
|
||||||
tcp: TotalsResultItem
|
tcp: TotalsResultItem
|
||||||
udp: TotalsResultItem
|
udp: TotalsResultItem
|
||||||
|
certificates: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const useTotals = (): TotalsResult => {
|
const useTotals = (): TotalsResult => {
|
||||||
@@ -30,6 +31,7 @@ const useTotals = (): TotalsResult => {
|
|||||||
routers: data?.udp?.routers?.total,
|
routers: data?.udp?.routers?.total,
|
||||||
services: data?.udp?.services?.total,
|
services: data?.udp?.services?.total,
|
||||||
},
|
},
|
||||||
|
certificates: data?.certificates?.total,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export const SideNav = ({
|
|||||||
const windowSize = useWindowSize()
|
const windowSize = useWindowSize()
|
||||||
const { version } = useContext(VersionContext)
|
const { version } = useContext(VersionContext)
|
||||||
|
|
||||||
const { http, tcp, udp } = useTotals()
|
const { http, tcp, udp, certificates } = useTotals()
|
||||||
|
|
||||||
const [isSmallScreen, setIsSmallScreen] = useState(false)
|
const [isSmallScreen, setIsSmallScreen] = useState(false)
|
||||||
|
|
||||||
@@ -155,8 +155,9 @@ export const SideNav = ({
|
|||||||
'/tcp/middlewares': tcp?.middlewares as number,
|
'/tcp/middlewares': tcp?.middlewares as number,
|
||||||
'/udp/routers': udp?.routers,
|
'/udp/routers': udp?.routers,
|
||||||
'/udp/services': udp?.services,
|
'/udp/services': udp?.services,
|
||||||
|
'/certificates': certificates,
|
||||||
}),
|
}),
|
||||||
[http, tcp, udp],
|
[http, tcp, udp, certificates],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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
|
"errors": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"certificates": {
|
||||||
|
"total": 9,
|
||||||
|
"warnings": 1,
|
||||||
|
"errors": 1
|
||||||
|
},
|
||||||
"features": {
|
"features": {
|
||||||
"tracing": "Prometheus",
|
"tracing": "Prometheus",
|
||||||
"metrics": "",
|
"metrics": "",
|
||||||
|
|||||||
@@ -14,5 +14,23 @@
|
|||||||
],
|
],
|
||||||
"priority": 10,
|
"priority": 10,
|
||||||
"provider": "docker"
|
"provider": "docker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entryPoints": [
|
||||||
|
"websecure-tcp"
|
||||||
|
],
|
||||||
|
"service": "postgres",
|
||||||
|
"rule": "HostSNI(`db.example.com`)",
|
||||||
|
"tls": {
|
||||||
|
"options": "default",
|
||||||
|
"certResolver": "letsencrypt"
|
||||||
|
},
|
||||||
|
"status": "enabled",
|
||||||
|
"name": "postgres@docker",
|
||||||
|
"using": [
|
||||||
|
"websecure-tcp"
|
||||||
|
],
|
||||||
|
"priority": 20,
|
||||||
|
"provider": "docker"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { http, passthrough } from 'msw'
|
import { http, passthrough } from 'msw'
|
||||||
|
|
||||||
|
import apiCertificates from './data/api-certificates.json'
|
||||||
import apiEntrypoints from './data/api-entrypoints.json'
|
import apiEntrypoints from './data/api-entrypoints.json'
|
||||||
import apiHttpMiddlewares from './data/api-http_middlewares.json'
|
import apiHttpMiddlewares from './data/api-http_middlewares.json'
|
||||||
import apiHttpRouters from './data/api-http_routers.json'
|
import apiHttpRouters from './data/api-http_routers.json'
|
||||||
@@ -15,6 +16,7 @@ import eeApiErrors from './data/ee-api-errors.json'
|
|||||||
import { listHandlers } from './utils'
|
import { listHandlers } from './utils'
|
||||||
|
|
||||||
export const getHandlers = (noDelay: boolean = false) => [
|
export const getHandlers = (noDelay: boolean = false) => [
|
||||||
|
...listHandlers('/api/certificates', apiCertificates, noDelay),
|
||||||
...listHandlers('/api/entrypoints', apiEntrypoints, noDelay, true),
|
...listHandlers('/api/entrypoints', apiEntrypoints, noDelay, true),
|
||||||
...listHandlers('/api/errors', eeApiErrors, noDelay),
|
...listHandlers('/api/errors', eeApiErrors, noDelay),
|
||||||
...listHandlers('/api/http/middlewares', apiHttpMiddlewares, noDelay),
|
...listHandlers('/api/http/middlewares', apiHttpMiddlewares, noDelay),
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import * as useCertificates from '../../hooks/use-certificates'
|
||||||
|
|
||||||
|
import { Certificate } from './Certificate'
|
||||||
|
|
||||||
|
import { renderWithProviders } from 'utils/test'
|
||||||
|
|
||||||
|
describe('<CertificatePage />', () => {
|
||||||
|
it('should render the loading state initially', () => {
|
||||||
|
vi.spyOn(useCertificates, 'useCertificate').mockImplementation(() => ({
|
||||||
|
certificate: null,
|
||||||
|
error: null,
|
||||||
|
isLoading: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { getByTestId } = renderWithProviders(<Certificate />, {
|
||||||
|
route: '/certificates/dW5rbm93bi1jZXJ0LWtleQ==',
|
||||||
|
withPage: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render error message when API returns error', () => {
|
||||||
|
vi.spyOn(useCertificates, 'useCertificate').mockImplementation(() => ({
|
||||||
|
certificate: null,
|
||||||
|
error: new Error('Internal Server Error'),
|
||||||
|
isLoading: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { getByTestId } = renderWithProviders(<Certificate />, {
|
||||||
|
route: '/certificates/c29tZS1jZXJ0',
|
||||||
|
withPage: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render not found page when certificate is null', () => {
|
||||||
|
vi.spyOn(useCertificates, 'useCertificate').mockImplementation(() => ({
|
||||||
|
certificate: null,
|
||||||
|
error: null,
|
||||||
|
isLoading: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { getByTestId } = renderWithProviders(<Certificate />, {
|
||||||
|
route: '/certificates/bm90Zm91bmQ=',
|
||||||
|
withPage: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render certificate details successfully', () => {
|
||||||
|
const mockCertificate = {
|
||||||
|
name: 'dGVzdC1jZXJ0',
|
||||||
|
commonName: 'test.com',
|
||||||
|
sans: ['test.com', 'www.test.com'],
|
||||||
|
issuerOrg: 'Test CA',
|
||||||
|
issuerCN: 'Test Root CA',
|
||||||
|
notAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
notBefore: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
status: 'enabled' as const,
|
||||||
|
serialNumber: '123456',
|
||||||
|
version: '3',
|
||||||
|
keyType: 'RSA',
|
||||||
|
signatureAlgorithm: 'SHA256WithRSA',
|
||||||
|
certFingerprint: 'a1b2c3d4e5f60708090a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60',
|
||||||
|
publicKeyFingerprint: 'b2c3d4e5f60708090a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071',
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.spyOn(useCertificates, 'useCertificate').mockImplementation(() => ({
|
||||||
|
certificate: { ...mockCertificate, daysLeft: 365 },
|
||||||
|
error: null,
|
||||||
|
isLoading: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { getByText } = renderWithProviders(<Certificate />, {
|
||||||
|
route: '/certificates/dGVzdC1jZXJ0',
|
||||||
|
withPage: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check for actual rendered content
|
||||||
|
expect(getByText('Certificate')).toBeInTheDocument()
|
||||||
|
expect(getByText('Issued To')).toBeInTheDocument()
|
||||||
|
expect(getByText('Issued By')).toBeInTheDocument()
|
||||||
|
expect(getByText('www.test.com')).toBeInTheDocument()
|
||||||
|
expect(getByText('Test CA')).toBeInTheDocument()
|
||||||
|
expect(getByText('Test Root CA')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Box, Flex, H1, Skeleton, Text } from '@traefik-labs/faency'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { CertificateDetails } from '../../components/certificates/CertificateDetails'
|
||||||
|
import { useCertificate } from '../../hooks/use-certificates'
|
||||||
|
|
||||||
|
import { DetailsCardSkeleton } from 'components/resources/DetailsCard'
|
||||||
|
import PageTitle from 'layout/PageTitle'
|
||||||
|
import { NotFound } from 'pages/NotFound'
|
||||||
|
|
||||||
|
export const Certificate = () => {
|
||||||
|
const { name } = useParams<{ name: string }>()
|
||||||
|
const { certificate, isLoading, error } = useCertificate(name || '')
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<PageTitle title={name || ''} />
|
||||||
|
<Skeleton css={{ height: '$7', width: '320px', mb: '$7' }} data-testid="skeleton" />
|
||||||
|
<Flex direction="column" gap={6}>
|
||||||
|
<DetailsCardSkeleton keyColumns={1} rows={5} />
|
||||||
|
<DetailsCardSkeleton keyColumns={1} rows={5} />
|
||||||
|
<DetailsCardSkeleton keyColumns={1} rows={5} />
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={certificate?.commonName || name || ''} />
|
||||||
|
<Text data-testid="error-text">
|
||||||
|
Sorry, we could not fetch detail information for this Certificate right now. Please, try again later.
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!certificate) {
|
||||||
|
return <NotFound />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title={`Certificate ${certificate.commonName}`} />
|
||||||
|
<H1 css={{ mb: '$4' }}>{certificate.commonName || 'Certificate'}</H1>
|
||||||
|
<CertificateDetails certificate={certificate} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { CertificateRenderRow, Certificates as CertificatesPage, CertificatesRender } from './Certificates'
|
||||||
|
|
||||||
|
import * as useFetchWithPagination from 'hooks/use-fetch-with-pagination'
|
||||||
|
import { useFetchWithPaginationMock } from 'utils/mocks'
|
||||||
|
import { renderWithProviders } from 'utils/test'
|
||||||
|
|
||||||
|
describe('<CertificatesPage />', () => {
|
||||||
|
it('should render the certificates list', () => {
|
||||||
|
const pages = [
|
||||||
|
{
|
||||||
|
name: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
|
||||||
|
commonName: 'example.com',
|
||||||
|
sans: ['example.com', '127.0.0.1', '::1'],
|
||||||
|
issuerOrg: 'Acme Co',
|
||||||
|
issuerCN: 'Acme Root CA',
|
||||||
|
notAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
notBefore: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
status: 'enabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'b2c3d4e5f6b2c3d4e5f6b2c3d4e5f6b2c3d4e5f6b2c3d4e5f6b2c3d4e5f6b2c3',
|
||||||
|
commonName: 'warning.com',
|
||||||
|
sans: ['warning.com', 'www.warning.com'],
|
||||||
|
issuerOrg: 'Warning CA',
|
||||||
|
issuerCN: '',
|
||||||
|
notAfter: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
notBefore: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
status: 'warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6c3d4e5f6',
|
||||||
|
commonName: 'expired.com',
|
||||||
|
sans: ['expired.com'],
|
||||||
|
issuerOrg: 'Expired CA',
|
||||||
|
notAfter: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
notBefore: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
status: 'expired',
|
||||||
|
},
|
||||||
|
].map(CertificateRenderRow)
|
||||||
|
const mock = vi
|
||||||
|
.spyOn(useFetchWithPagination, 'default')
|
||||||
|
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
|
||||||
|
|
||||||
|
const { container, getByTestId } = renderWithProviders(<CertificatesPage />, {
|
||||||
|
route: '/certificates',
|
||||||
|
withPage: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mock).toHaveBeenCalled()
|
||||||
|
expect(getByTestId('/certificates page')).toBeInTheDocument()
|
||||||
|
const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1]
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(3)
|
||||||
|
|
||||||
|
// First certificate (enabled)
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('testid="enabled"')
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('example.com')
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('Acme Co')
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('days')
|
||||||
|
|
||||||
|
// Second certificate (warning)
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('testid="warning"')
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('warning.com')
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('Warning CA')
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('days')
|
||||||
|
|
||||||
|
// Third certificate (expired)
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('testid="expired"')
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('expired.com')
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('Expired CA')
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('EXPIRED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render "No data available" when the API returns empty array', async () => {
|
||||||
|
const { container, getByTestId } = renderWithProviders(
|
||||||
|
<CertificatesRender
|
||||||
|
error={undefined}
|
||||||
|
isEmpty={true}
|
||||||
|
isLoadingMore={false}
|
||||||
|
isReachingEnd={true}
|
||||||
|
loadMore={() => {}}
|
||||||
|
pageCount={1}
|
||||||
|
pages={[]}
|
||||||
|
/>,
|
||||||
|
{ route: '/certificates', withPage: true },
|
||||||
|
)
|
||||||
|
expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]')
|
||||||
|
const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2]
|
||||||
|
expect(tfoot.querySelectorAll('div[role="row"]')).toHaveLength(1)
|
||||||
|
expect(tfoot.querySelectorAll('div[role="row"]')[0].innerHTML).toContain('No data available')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render "Failed to fetch data" when the API returns an error', async () => {
|
||||||
|
const { container } = renderWithProviders(
|
||||||
|
<CertificatesRender
|
||||||
|
error={new Error('Test error')}
|
||||||
|
isEmpty={false}
|
||||||
|
isLoadingMore={false}
|
||||||
|
isReachingEnd={true}
|
||||||
|
loadMore={() => {}}
|
||||||
|
pageCount={1}
|
||||||
|
pages={[]}
|
||||||
|
/>,
|
||||||
|
{ route: '/certificates', withPage: true },
|
||||||
|
)
|
||||||
|
const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2]
|
||||||
|
expect(tfoot.querySelectorAll('div[role="row"]')).toHaveLength(1)
|
||||||
|
expect(tfoot.querySelectorAll('div[role="row"]')[0].innerHTML).toContain('Failed to fetch data')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should display certificate with expiry colors', () => {
|
||||||
|
// Test different expiry colors
|
||||||
|
const pages = [
|
||||||
|
{
|
||||||
|
name: 'd4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5f6d4e5',
|
||||||
|
commonName: 'green.com',
|
||||||
|
sans: ['green.com'],
|
||||||
|
issuerOrg: 'Test CA',
|
||||||
|
notAfter: new Date(Date.now() + 100 * 24 * 60 * 60 * 1000).toISOString(), // 100 days = green
|
||||||
|
notBefore: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
status: 'enabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6e5f6',
|
||||||
|
commonName: 'orange.com',
|
||||||
|
sans: ['orange.com'],
|
||||||
|
issuerOrg: 'Test CA',
|
||||||
|
notAfter: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(), // 10 days = orange
|
||||||
|
notBefore: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
status: 'warning',
|
||||||
|
},
|
||||||
|
].map(CertificateRenderRow)
|
||||||
|
|
||||||
|
const { container } = renderWithProviders(
|
||||||
|
<CertificatesRender
|
||||||
|
error={undefined}
|
||||||
|
isEmpty={false}
|
||||||
|
isLoadingMore={false}
|
||||||
|
isReachingEnd={true}
|
||||||
|
loadMore={() => {}}
|
||||||
|
pageCount={1}
|
||||||
|
pages={pages}
|
||||||
|
/>,
|
||||||
|
{ route: '/certificates', withPage: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1]
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
||||||
|
|
||||||
|
// Green badge for >14 days
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('green.com')
|
||||||
|
|
||||||
|
// Orange badge for <14 days
|
||||||
|
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('orange.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefik-labs/faency'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||||
|
import CertExpiryBadge from 'components/certificates/CertExpiryBadge'
|
||||||
|
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||||
|
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||||
|
import ClickableRow from 'components/tables/ClickableRow'
|
||||||
|
import SortableTh from 'components/tables/SortableTh'
|
||||||
|
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||||
|
import TooltipText from 'components/TooltipText'
|
||||||
|
import { computeDaysLeft } from 'hooks/use-certificates'
|
||||||
|
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||||
|
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
||||||
|
import PageTitle from 'layout/PageTitle'
|
||||||
|
|
||||||
|
export const CertificateRenderRow: RenderRowType = (row: unknown) => {
|
||||||
|
const cert = row as Certificate.Raw
|
||||||
|
const daysLeft = computeDaysLeft(cert.notAfter)
|
||||||
|
const validUntil = new Date(cert.notAfter).toLocaleDateString()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClickableRow key={cert.name} to={`/certificates/${cert.name}`}>
|
||||||
|
<AriaTd>
|
||||||
|
<ResourceStatus status={cert.status} />
|
||||||
|
</AriaTd>
|
||||||
|
<AriaTd>
|
||||||
|
<TooltipText text={cert.commonName || '-'} />
|
||||||
|
</AriaTd>
|
||||||
|
<AriaTd css={{ maxWidth: '240px' }}>
|
||||||
|
<Text css={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||||
|
{cert.sans?.length > 0 ? cert.sans.join(', ') : '-'}
|
||||||
|
</Text>
|
||||||
|
</AriaTd>
|
||||||
|
<AriaTd>
|
||||||
|
<TooltipText text={cert.issuerOrg || cert.issuerCN || 'Unknown'} />
|
||||||
|
</AriaTd>
|
||||||
|
<AriaTd>
|
||||||
|
<Text>{validUntil}</Text>
|
||||||
|
</AriaTd>
|
||||||
|
<AriaTd>
|
||||||
|
<CertExpiryBadge daysLeft={daysLeft} size="small" />
|
||||||
|
</AriaTd>
|
||||||
|
</ClickableRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CertificatesRender = ({
|
||||||
|
error,
|
||||||
|
isEmpty,
|
||||||
|
isLoadingMore,
|
||||||
|
isReachingEnd,
|
||||||
|
loadMore,
|
||||||
|
pageCount,
|
||||||
|
pages,
|
||||||
|
}: pagesResponseInterface) => {
|
||||||
|
const [infiniteRef] = useInfiniteScroll({
|
||||||
|
loading: isLoadingMore,
|
||||||
|
hasNextPage: !isReachingEnd && !error,
|
||||||
|
onLoadMore: loadMore,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AriaTable>
|
||||||
|
<AriaThead>
|
||||||
|
<AriaTr>
|
||||||
|
<SortableTh label="Status" isSortable sortByValue="status" css={{ width: '36px' }} />
|
||||||
|
<SortableTh label="Common Name" isSortable sortByValue="cn" />
|
||||||
|
<SortableTh label="SANs" css={{ maxWidth: '240px' }} />
|
||||||
|
<SortableTh label="Issuer" isSortable sortByValue="issuer" />
|
||||||
|
<SortableTh label="Valid Until" isSortable sortByValue="validUntil" css={{ width: '100px' }} />
|
||||||
|
<SortableTh label="Expiry" css={{ width: '100px' }} />
|
||||||
|
</AriaTr>
|
||||||
|
</AriaThead>
|
||||||
|
<AriaTbody>{pages}</AriaTbody>
|
||||||
|
{(isEmpty || !!error) && (
|
||||||
|
<AriaTfoot>
|
||||||
|
<AriaTr>
|
||||||
|
<EmptyPlaceholderTd message={error ? 'Failed to fetch data' : 'No data available'} />
|
||||||
|
</AriaTr>
|
||||||
|
</AriaTfoot>
|
||||||
|
)}
|
||||||
|
</AriaTable>
|
||||||
|
<Flex css={{ height: 60, alignItems: 'center', justifyContent: 'center' }} ref={infiniteRef}>
|
||||||
|
{isLoadingMore ? <SpinnerLoader /> : isReachingEnd && pageCount > 1 && <ScrollTopButton />}
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Certificates = () => {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
|
const query = useMemo(() => searchParamsToState(searchParams), [searchParams])
|
||||||
|
const { pages, pageCount, isLoadingMore, isReachingEnd, loadMore, error, isEmpty } = useFetchWithPagination(
|
||||||
|
'/certificates',
|
||||||
|
{
|
||||||
|
listContextKey: JSON.stringify(query),
|
||||||
|
renderRow: CertificateRenderRow,
|
||||||
|
renderLoader: () => null,
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle title="Certificates" />
|
||||||
|
<TableFilter />
|
||||||
|
<CertificatesRender
|
||||||
|
error={error}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
isLoadingMore={isLoadingMore}
|
||||||
|
isReachingEnd={isReachingEnd}
|
||||||
|
loadMore={loadMore}
|
||||||
|
pageCount={pageCount}
|
||||||
|
pages={pages}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 HTTPPages from './http'
|
||||||
import * as TCPPages from './tcp'
|
import * as TCPPages from './tcp'
|
||||||
import * as UDPPages from './udp'
|
import * as UDPPages from './udp'
|
||||||
|
|
||||||
export { Dashboard } from './dashboard/Dashboard'
|
export { Dashboard } from './dashboard/Dashboard'
|
||||||
export { NotFound } from './NotFound'
|
export { NotFound } from './NotFound'
|
||||||
export { HTTPPages, TCPPages, UDPPages }
|
export { HTTPPages, TCPPages, UDPPages, CertificatesPages }
|
||||||
|
|||||||
+19
-1
@@ -1,5 +1,11 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { LiaProjectDiagramSolid, LiaServerSolid, LiaCogsSolid, LiaHomeSolid } from 'react-icons/lia'
|
import {
|
||||||
|
LiaProjectDiagramSolid,
|
||||||
|
LiaServerSolid,
|
||||||
|
LiaCogsSolid,
|
||||||
|
LiaHomeSolid,
|
||||||
|
LiaCertificateSolid,
|
||||||
|
} from 'react-icons/lia'
|
||||||
|
|
||||||
export type Route = {
|
export type Route = {
|
||||||
path: string
|
path: string
|
||||||
@@ -91,4 +97,16 @@ export const ROUTES: RouteSections[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
section: 'certificates',
|
||||||
|
sectionLabel: 'Certificates',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
path: '/certificates',
|
||||||
|
activeMatches: ['/certificates/:name'],
|
||||||
|
label: 'Certificates',
|
||||||
|
icon: <LiaCertificateSolid color="currentColor" size={20} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Vendored
+32
-3
@@ -1,5 +1,5 @@
|
|||||||
declare namespace Resource {
|
declare namespace Resource {
|
||||||
type Status = 'info' | 'success' | 'warning' | 'error' | 'enabled' | 'disabled' | 'loading'
|
type Status = 'info' | 'success' | 'warning' | 'error' | 'enabled' | 'disabled' | 'expired' | 'loading'
|
||||||
|
|
||||||
type DetailsData = Router.DetailsData & Service.Details & Middleware.DetailsData
|
type DetailsData = Router.DetailsData & Service.Details & Middleware.DetailsData
|
||||||
}
|
}
|
||||||
@@ -19,10 +19,10 @@ declare namespace Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TLS = {
|
type TLS = {
|
||||||
options: string
|
options?: string
|
||||||
certResolver: string
|
certResolver: string
|
||||||
domains: TlsDomain[]
|
domains: TlsDomain[]
|
||||||
passthrough: boolean
|
passthrough?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Details = {
|
type Details = {
|
||||||
@@ -121,3 +121,32 @@ declare namespace Middleware {
|
|||||||
routers?: Router.Details[]
|
routers?: Router.Details[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare namespace Certificate {
|
||||||
|
/** Raw API response shape */
|
||||||
|
type Raw = {
|
||||||
|
name: string
|
||||||
|
commonName?: string
|
||||||
|
sans: string[]
|
||||||
|
issuerOrg?: string
|
||||||
|
issuerCN?: string
|
||||||
|
issuerCountry?: string
|
||||||
|
organization?: string
|
||||||
|
country?: string
|
||||||
|
serialNumber: string
|
||||||
|
notBefore: string
|
||||||
|
notAfter: string
|
||||||
|
version: string
|
||||||
|
keyType: string
|
||||||
|
keySize?: number
|
||||||
|
signatureAlgorithm: string
|
||||||
|
certFingerprint: string
|
||||||
|
publicKeyFingerprint: string
|
||||||
|
status: 'enabled' | 'warning' | 'expired'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enriched certificate with computed fields */
|
||||||
|
type Info = Raw & {
|
||||||
|
daysLeft: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user