internal/server/fleet_sync_certificates_test.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/X4Applegate/caddyui/internal/models"
)
const fleetTestKeyPEM = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg\n-----END PRIVATE KEY-----"
// v2.41.0: a PEM certificate travels with its content, a host created on
// the target references the copy, a replaced PEM propagates on the next
// sync, and an existing target host keeps its own certificate choice.
func TestSyncFleetConfigurationCopiesPEMCertificatesAndMapsReferences(t *testing.T) {
s, sourceServerID, targetServerID := newFleetSyncTestServer(t)
_, _, certPEM := startTLSFixture(t, time.Now().Add(30*24*time.Hour), "pem.example.com")
pemCert := models.Certificate{Name: "uploaded", Domains: "pem.example.com", Source: models.CertSourcePEM, CertPEM: strings.TrimSpace(string(certPEM)), KeyPEM: fleetTestKeyPEM}
pemID, err := models.CreateCertificate(s.DB, sourceServerID, 0, &pemCert)
if err != nil {
t.Fatal(err)
}
proxy := models.ProxyHost{Domains: "pem.example.com", ForwardScheme: "http", ForwardHost: "app", ForwardPort: 8080, Enabled: true, SSLEnabled: true, CertificateID: pemID}
if _, err := models.CreateProxyHost(s.DB, sourceServerID, 0, &proxy); err != nil {
t.Fatal(err)
}
// A target host that already exists keeps whatever it had (no certificate).
preexisting := models.ProxyHost{Domains: "kept.example.com", ForwardScheme: "http", ForwardHost: "kept", ForwardPort: 1, Enabled: true}
if _, err := models.CreateProxyHost(s.DB, targetServerID, 0, &preexisting); err != nil {
t.Fatal(err)
}
keptSource := models.ProxyHost{Domains: "kept.example.com", ForwardScheme: "http", ForwardHost: "kept", ForwardPort: 1, Enabled: true, CertificateID: pemID}
if _, err := models.CreateProxyHost(s.DB, sourceServerID, 0, &keptSource); err != nil {
t.Fatal(err)
}
first, err := s.syncFleetConfiguration("admin@example.com", sourceServerID, targetServerID)
if err != nil {
t.Fatal(err)
}
if first.CertificatesCreated != 1 || first.CertificatesByPath != 0 || first.ProxiesCreated != 1 || first.ProxiesUpdated != 0 {
t.Fatalf("first sync = %#v", first)
}
targetCerts, _ := models.ListCertificates(s.DB, targetServerID)
if len(targetCerts) != 1 || targetCerts[0].Source != models.CertSourcePEM || targetCerts[0].CertPEM != pemCert.CertPEM || targetCerts[0].KeyPEM != fleetTestKeyPEM || targetCerts[0].Name != "uploaded" {
t.Fatalf("target certificate = %+v, want the PEM copied verbatim", targetCerts)
}
targetCertID := targetCerts[0].ID
hosts, _ := models.ListProxyHosts(s.DB, targetServerID, 0, true, nil)
byDomain := map[string]models.ProxyHost{}
for _, h := range hosts {
byDomain[h.Domains] = h
}
if byDomain["pem.example.com"].CertificateID != targetCertID {
t.Errorf("created target host should reference the copied certificate %d, got %d", targetCertID, byDomain["pem.example.com"].CertificateID)
}
if byDomain["kept.example.com"].CertificateID != 0 {
t.Errorf("pre-existing target host must keep its own certificate choice, got %d", byDomain["kept.example.com"].CertificateID)
}
if !strings.Contains(first.String(), "certificates: 1 added") {
t.Errorf("summary = %q", first.String())
}
second, err := s.syncFleetConfiguration("admin@example.com", sourceServerID, targetServerID)
if err != nil || second.Changed() != 0 {
t.Fatalf("repeat sync should be idempotent: %#v, %v", second, err)
}
// Replace the PEM (a renewal) → the copy is updated, nothing else moves.
_, _, renewed := startTLSFixture(t, time.Now().Add(90*24*time.Hour), "pem.example.com")
pemCert.ID = pemID
pemCert.CertPEM = strings.TrimSpace(string(renewed))
if err := models.UpdateCertificate(s.DB, &pemCert); err != nil {
t.Fatal(err)
}
third, err := s.syncFleetConfiguration("admin@example.com", sourceServerID, targetServerID)
if err != nil || third.CertificatesUpdated != 1 || third.Changed() != 1 {
t.Fatalf("renewal sync = %#v, %v; want exactly one certificate update", third, err)
}
targetCerts, _ = models.ListCertificates(s.DB, targetServerID)
if len(targetCerts) != 1 || targetCerts[0].ID != targetCertID || targetCerts[0].CertPEM != pemCert.CertPEM {
t.Fatalf("renewed PEM should land on the same target row: %+v", targetCerts)
}
}
// A file-path certificate is copied as stored PEM when CaddyUI can read the
// files, and by path reference (counted, flagged) when it cannot.
func TestSyncFleetConfigurationCopiesFilePathCertificates(t *testing.T) {
s, sourceServerID, targetServerID := newFleetSyncTestServer(t)
_, _, certPEM := startTLSFixture(t, time.Now().Add(30*24*time.Hour), "files.example.com")
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem")
if err := os.WriteFile(certFile, certPEM, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyFile, []byte(fleetTestKeyPEM+"\n"), 0o600); err != nil {
t.Fatal(err)
}
// v2.53.0: "readable" now also means "inside certificateReadRoots", so the
// fixture directory has to be a configured root like a real deployment's.
if err := models.SetSetting(s.DB, settingCertificateReadRoots, dir); err != nil {
t.Fatal(err)
}
readable := models.Certificate{Name: "readable", Domains: "files.example.com", Source: models.CertSourcePath, CertPath: certFile, KeyPath: keyFile}
if _, err := models.CreateCertificate(s.DB, sourceServerID, 0, &readable); err != nil {
t.Fatal(err)
}
unreadable := models.Certificate{Name: "remote-only", Domains: "remote.example.com", Source: models.CertSourcePath, CertPath: "/certs/remote/fullchain.pem", KeyPath: "/certs/remote/privkey.pem"}
if _, err := models.CreateCertificate(s.DB, sourceServerID, 0, &unreadable); err != nil {
t.Fatal(err)
}
// Present on disk and perfectly parseable, but outside every configured
// root — confinement must keep it out of the database as a PEM.
offLimits := t.TempDir()
offCert, offKey := filepath.Join(offLimits, "fullchain.pem"), filepath.Join(offLimits, "privkey.pem")
if err := os.WriteFile(offCert, certPEM, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(offKey, []byte(fleetTestKeyPEM+"\n"), 0o600); err != nil {
t.Fatal(err)
}
confined := models.Certificate{Name: "outside-roots", Domains: "outside.example.com", Source: models.CertSourcePath, CertPath: offCert, KeyPath: offKey}
if _, err := models.CreateCertificate(s.DB, sourceServerID, 0, &confined); err != nil {
t.Fatal(err)
}
summary, err := s.syncFleetConfiguration("admin@example.com", sourceServerID, targetServerID)
if err != nil {
t.Fatal(err)
}
if summary.CertificatesCreated != 3 || summary.CertificatesByPath != 2 || !strings.Contains(summary.String(), "2 certificate(s) copied by file path only") {
t.Fatalf("summary = %#v (%s)", summary, summary.String())
}
targets, _ := models.ListCertificates(s.DB, targetServerID)
byName := map[string]models.Certificate{}
for _, c := range targets {
byName[c.Name] = c
}
got := byName["readable"]
if got.Source != models.CertSourcePEM || got.CertPEM != strings.TrimSpace(string(certPEM)) || got.KeyPEM != fleetTestKeyPEM || got.CertPath != "" {
t.Errorf("readable file-path certificate should be copied as PEM: %+v", got)
}
got = byName["remote-only"]
if got.Source != models.CertSourcePath || got.CertPath != unreadable.CertPath || got.KeyPath != unreadable.KeyPath || got.CertPEM != "" {
t.Errorf("unreadable file-path certificate should be copied by reference: %+v", got)
}
got = byName["outside-roots"]
if got.Source != models.CertSourcePath || got.CertPath != confined.CertPath || got.CertPEM != "" || got.KeyPEM != "" {
t.Errorf("certificate outside the read roots must not be inlined as PEM: %+v", got)
}
again, err := s.syncFleetConfiguration("admin@example.com", sourceServerID, targetServerID)
if err != nil || again.Changed() != 0 || again.CertificatesByPath != 2 {
t.Fatalf("repeat sync = %#v, %v; want no changes (the by-path count is informational)", again, err)
}
}
// "Also configure on" from the certificate form copies custom certificates
// too, and a host cross-deployed with a custom certificate brings it along.
func TestCrossDeployCertificateAndReferencedCertificate(t *testing.T) {
s, sourceServerID, targetServerID := newFleetSyncTestServer(t)
_, _, certPEM := startTLSFixture(t, time.Now().Add(30*24*time.Hour), "x.example.com")
cert := models.Certificate{Name: "x", Domains: "x.example.com", Source: models.CertSourcePEM, CertPEM: strings.TrimSpace(string(certPEM)), KeyPEM: fleetTestKeyPEM}
certID, err := models.CreateCertificate(s.DB, sourceServerID, 0, &cert)
if err != nil {
t.Fatal(err)
}
cert.ID = certID
s.crossDeployCertificate("admin@example.com", sourceServerID, cert, []int64{targetServerID})
targets, _ := models.ListCertificates(s.DB, targetServerID)
if len(targets) != 1 || targets[0].CertPEM != cert.CertPEM {
t.Fatalf("cross-deployed PEM certificate = %+v", targets)
}
// A second, unrelated PEM certificate is referenced by a proxy host that is
// cross-deployed on its own: the certificate must be copied first and the
// created host must point at the copy.
other := models.Certificate{Name: "y", Domains: "y.example.com", Source: models.CertSourcePEM, CertPEM: strings.TrimSpace(string(certPEM)), KeyPEM: fleetTestKeyPEM}
otherID, err := models.CreateCertificate(s.DB, sourceServerID, 0, &other)
if err != nil {
t.Fatal(err)
}
host := models.ProxyHost{Domains: "y.example.com", ForwardScheme: "http", ForwardHost: "y", ForwardPort: 80, Enabled: true, SSLEnabled: true, CertificateID: otherID}
host.ID, err = models.CreateProxyHost(s.DB, sourceServerID, 0, &host)
if err != nil {
t.Fatal(err)
}
s.crossDeployProxyHost("admin@example.com", sourceServerID, &host, []int64{targetServerID})
targets, _ = models.ListCertificates(s.DB, targetServerID)
var copied int64
for _, c := range targets {
if c.Name == "y" {
copied = c.ID
}
}
if copied == 0 {
t.Fatalf("the referenced certificate should have been copied first: %+v", targets)
}
hosts, _ := models.ListProxyHosts(s.DB, targetServerID, 0, true, nil)
if len(hosts) != 1 || hosts[0].CertificateID != copied {
t.Fatalf("cross-deployed host = %+v, want certificate_id %d", hosts, copied)
}
}