// 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) } }