Ctrl K
ComunidadGuías…

internal/server/fleet_sync_certificates_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 (
	"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)
	}
}