Ctrl K
ComunidadGuías…

internal/server/certificate_probe_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: Apache-2.0

package server

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"math/big"
	"net"
	"path/filepath"
	"strings"
	"testing"
	"time"

	appdb "github.com/X4Applegate/caddyui/internal/db"
	"github.com/X4Applegate/caddyui/internal/models"
)

// startTLSFixture serves a self-signed certificate for dnsNames on a
// loopback port and returns the address plus the certificate it presents.
func startTLSFixture(t *testing.T, notAfter time.Time, dnsNames ...string) (string, *x509.Certificate, []byte) {
	t.Helper()
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		t.Fatal(err)
	}
	tmpl := &x509.Certificate{
		SerialNumber: big.NewInt(time.Now().UnixNano()),
		Subject:      pkix.Name{CommonName: dnsNames[0], Organization: []string{"CaddyUI test fixture"}},
		NotBefore:    time.Now().Add(-time.Hour),
		NotAfter:     notAfter,
		DNSNames:     dnsNames,
		KeyUsage:     x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}
	der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
	if err != nil {
		t.Fatal(err)
	}
	leaf, err := x509.ParseCertificate(der)
	if err != nil {
		t.Fatal(err)
	}
	ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
		Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}},
		MinVersion:   tls.VersionTLS12,
	})
	if err != nil {
		t.Fatal(err)
	}
	go func() {
		for {
			conn, err := ln.Accept()
			if err != nil {
				return
			}
			go func(c net.Conn) {
				if tc, ok := c.(*tls.Conn); ok {
					_ = tc.Handshake()
				}
				_ = c.Close()
			}(conn)
		}
	}()
	t.Cleanup(func() { _ = ln.Close() })
	return ln.Addr().String(), leaf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}

func newProbeTestServer(t *testing.T, target string) *Server {
	t.Helper()
	conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = conn.Close() })
	if _, err := models.CreateCaddyServer(conn, &models.CaddyServer{Name: "Primary", AdminURL: "http://127.0.0.1:2019", Type: models.CaddyServerTypeManaged}); err != nil {
		t.Fatal(err)
	}
	s := &Server{DB: conn}
	s.certProbeTargetFn = func(int64, string) string { return target }
	return s
}

// v2.39.0: a file-path certificate CaddyUI cannot read is described from what
// the node serves for its domain; the result round-trips through settings,
// the scheduled refresh updates it, and deleting the certificate clears it.
func TestProbeCustomCertificateReadsServedLeaf(t *testing.T) {
	notAfter := time.Now().Add(40 * 24 * time.Hour).Truncate(time.Second)
	addr, leaf, _ := startTLSFixture(t, notAfter, "files.example.test", "www.files.example.test")
	s := newProbeTestServer(t, addr)
	servers, err := models.ListCaddyServers(s.DB)
	if err != nil || len(servers) != 1 {
		t.Fatalf("servers = %+v, err=%v", servers, err)
	}
	serverID := servers[0].ID
	cert := models.Certificate{Name: "wildcard-files", Domains: "files.example.test", Source: models.CertSourcePath,
		CertPath: "/nonexistent/fullchain.pem", KeyPath: "/nonexistent/privkey.pem"}
	id, err := models.CreateCertificate(s.DB, serverID, 0, &cert)
	if err != nil {
		t.Fatal(err)
	}
	cert.ID = id

	info := s.probeCustomCertificate(serverID, cert)
	if info.Status != "healthy" || !info.HasCertificate() {
		t.Fatalf("status = %q (%s), want healthy", info.Status, info.Error)
	}
	if info.ProbeName != "files.example.test" || info.Target != addr || info.ServerName != "Primary" {
		t.Errorf("probe name/target/server = %q/%q/%q", info.ProbeName, info.Target, info.ServerName)
	}
	if !info.NotAfter.Equal(leaf.NotAfter) || info.DaysLeft() < 38 || info.DaysLeft() > 40 {
		t.Errorf("NotAfter = %v (%d days), want %v", info.NotAfter, info.DaysLeft(), leaf.NotAfter)
	}
	want := summarizeX509(leaf)
	if info.Fingerprint != want.Fingerprint || info.SerialNumber != want.SerialNumber || info.IssuerCN != "files.example.test" || info.KeyType != "ECDSA" || info.KeyBits != 256 {
		t.Errorf("summary mismatch: %+v vs %+v", info, want)
	}
	if strings.Join(info.SANs, ",") != "files.example.test,www.files.example.test" {
		t.Errorf("SANs = %v", info.SANs)
	}

	if s.certificateProbeFor(id) != nil {
		t.Fatal("nothing should be stored before the first store")
	}
	if err := s.storeCertificateProbe(info); err != nil {
		t.Fatal(err)
	}
	stored := s.certificateProbeFor(id)
	if stored == nil || stored.Fingerprint != info.Fingerprint || !stored.NotAfter.Equal(*info.NotAfter) || stored.Status != "healthy" {
		t.Fatalf("stored = %+v, want the probe result back", stored)
	}

	// Scheduled/manual refresh probes every custom certificate on the server.
	before := stored.CheckedAt
	time.Sleep(10 * time.Millisecond)
	n, err := s.refreshCertificateProbes(serverID)
	if err != nil || n != 1 {
		t.Fatalf("refresh = %d, %v; want 1 certificate probed", n, err)
	}
	if again := s.certificateProbeFor(id); again == nil || !again.CheckedAt.After(before) {
		t.Fatalf("refresh should have re-probed and stored a newer result, got %+v", again)
	}

	// Expiry for the dashboard comes from the probe when the file is unreadable.
	if exp := s.customCertificateExpiries([]models.Certificate{cert}); !exp[id].Equal(leaf.NotAfter) {
		t.Errorf("customCertificateExpiries = %v, want %v from the probe", exp, leaf.NotAfter)
	}

	s.deleteCertificateProbe(id)
	if s.certificateProbeFor(id) != nil {
		t.Fatal("probe result should be gone after delete")
	}
}

// A node that answers with a certificate for another name is reported as a
// mismatch (with the served names in the error), and one that cannot be
// reached as unavailable; neither counts as a certificate for the row.
func TestProbeCustomCertificateMismatchAndUnavailable(t *testing.T) {
	addr, _, _ := startTLSFixture(t, time.Now().Add(24*time.Hour), "other.example.test")
	s := newProbeTestServer(t, addr)
	cert := models.Certificate{ID: 7, Name: "x", Domains: "files.example.test", Source: models.CertSourcePath, CertPath: "/nope.pem", KeyPath: "/nope.key"}

	info := s.probeCustomCertificate(1, cert)
	if info.Status != "mismatch" || info.HasCertificate() || !strings.Contains(info.Error, "other.example.test") || !strings.Contains(info.Error, "files.example.test") {
		t.Fatalf("mismatch probe = %+v", info)
	}
	if info.NotAfter == nil {
		t.Error("the served certificate's dates should still be reported on a mismatch")
	}

	s.certProbeTargetFn = func(int64, string) string { return "127.0.0.1:1" }
	info = s.probeCustomCertificate(1, cert)
	if info.Status != "unavailable" || info.HasCertificate() || info.Error == "" || info.NotAfter != nil {
		t.Fatalf("unavailable probe = %+v", info)
	}

	// Wildcard-only definitions are probed through a synthetic child name.
	wild := models.Certificate{ID: 8, Domains: "*.example.test", Source: models.CertSourcePEM}
	if got := s.probeCustomCertificate(1, wild).ProbeName; got != "caddyui-probe.example.test" {
		t.Errorf("wildcard probe name = %q", got)
	}
}

// Inspect prefers what CaddyUI can read itself and marks the probe fallback;
// when both are known it says whether Caddy serves the same certificate.
func TestFillCertificateInspectDataFallsBackToProbe(t *testing.T) {
	addr, leaf, leafPEM := startTLSFixture(t, time.Now().Add(10*24*time.Hour), "files.example.test")
	s := newProbeTestServer(t, addr)
	servers, _ := models.ListCaddyServers(s.DB)
	serverID := servers[0].ID

	unreadable := models.Certificate{Name: "path", Domains: "files.example.test", Source: models.CertSourcePath, CertPath: "/nonexistent/fullchain.pem", KeyPath: "/nonexistent/privkey.pem"}
	unreadableID, err := models.CreateCertificate(s.DB, serverID, 0, &unreadable)
	if err != nil {
		t.Fatal(err)
	}
	unreadable.ID = unreadableID
	data := map[string]any{}
	s.fillCertificateInspectData(data, unreadableID, unreadable)
	if data["FromProbe"] != true || data["Fingerprint"] != summarizeX509(leaf).Fingerprint || data["Issuer"] == "" || data["ReadError"] == nil {
		t.Fatalf("unreadable path should be described from the probe, got %+v", data)
	}
	if days, _ := data["DaysLeft"].(int); days < 8 || days > 10 {
		t.Errorf("DaysLeft = %v, want ~9", data["DaysLeft"])
	}
	if s.certificateProbeFor(unreadableID) == nil {
		t.Error("the fresh probe should have been stored for the list page")
	}

	// Stored PEM identical to what Caddy serves: read locally, served matches.
	stored := models.Certificate{Name: "pem", Domains: "files.example.test", Source: models.CertSourcePEM, CertPEM: string(leafPEM), KeyPEM: "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----"}
	storedID, err := models.CreateCertificate(s.DB, serverID, 0, &stored)
	if err != nil {
		t.Fatal(err)
	}
	stored.ID = storedID
	data = map[string]any{}
	s.fillCertificateInspectData(data, storedID, stored)
	if data["FromProbe"] != nil || data["ServedKnown"] != true || data["ServedMatches"] != true {
		t.Fatalf("stored PEM matching the served leaf: %+v", data)
	}

	// A different served certificate is flagged, not silently accepted.
	otherAddr, _, _ := startTLSFixture(t, time.Now().Add(90*24*time.Hour), "files.example.test")
	s.certProbeTargetFn = func(int64, string) string { return otherAddr }
	s.deleteCertificateProbe(storedID)
	data = map[string]any{}
	s.fillCertificateInspectData(data, storedID, stored)
	if data["ServedKnown"] != true || data["ServedMatches"] != false {
		t.Fatalf("a renewed-but-not-reloaded certificate should show as different: %+v", data)
	}
	if exp := s.customCertificateExpiries([]models.Certificate{{ID: storedID, Source: models.CertSourcePEM, CertPEM: string(leafPEM)}}); !exp[storedID].Equal(leaf.NotAfter) {
		t.Errorf("a readable PEM wins over the probe for expiry: %v", exp)
	}
}

// The Operations dashboard counts file-path certificates once their expiry
// is known through the probe; stored PEMs still work without the map.
func TestDashboardRecommendationsCountPathCertificates(t *testing.T) {
	now := time.Now()
	pathCert := models.Certificate{ID: 1, Name: "path", Domains: "a.example.test", Source: models.CertSourcePath, CertPath: "/x.pem", KeyPath: "/x.key"}
	recs := buildDashboardRecommendations(dashboardRecommendationInput{
		Certificates:      []models.Certificate{pathCert},
		CertificateExpiry: map[int64]time.Time{1: now.Add(10 * 24 * time.Hour)},
		Now:               now,
	})
	found := false
	for _, r := range recs {
		if r.Title == "Custom certificates expire soon" && strings.Contains(r.Detail, "1 custom certificate(s)") {
			found = true
		}
	}
	if !found {
		t.Fatalf("expected an expiring-soon recommendation for the probed path certificate, got %+v", recs)
	}
	// Unknown expiry → no claim either way.
	for _, r := range buildDashboardRecommendations(dashboardRecommendationInput{Certificates: []models.Certificate{pathCert}, Now: now}) {
		if strings.HasPrefix(r.Title, "Custom certificates") {
			t.Fatalf("no expiry known, so no expiry recommendation expected: %+v", r)
		}
	}
}

// Certificate file paths are admin input: only absolute, clean paths without
// ".." are ever opened.
func TestSafeAbsolutePath(t *testing.T) {
	for _, bad := range []string{"", "certs/x.pem", "/certs/../etc/passwd", "/certs/..x.pem", "relative/../x"} {
		if _, err := safeAbsolutePath(bad); err == nil {
			t.Errorf("%q should be rejected", bad)
		}
	}
	if got, err := safeAbsolutePath(" /certs//example.com/./fullchain.pem "); err != nil || got != "/certs/example.com/fullchain.pem" {
		t.Errorf("clean absolute path: %q, %v", got, err)
	}
	if _, err := readCertificateFile("/nonexistent/../x.pem", []string{"/certs"}); err == nil || !strings.Contains(err.Error(), "..") {
		t.Errorf("readCertificateFile must refuse traversal, got %v", err)
	}
}