internal/server/fleet_sync_shared_certificate_test.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/X4Applegate/caddyui/internal/dns"
"github.com/X4Applegate/caddyui/internal/models"
"github.com/X4Applegate/caddyui/web"
)
// v2.55.0 (issue #115): "source push" fleet distribution for a Managed ACME
// certificate — one server performs the ACME order, CaddyUI pushes the
// resulting certificate and private key to the selected targets instead of
// each of them running independent ACME. writeStorageCertificate (defined in
// certificate_export_test.go) lays out a certificate the way certmagic does,
// so these tests exercise the exact same on-disk read path as the v2.42.0
// "export to a directory" feature.
// newSourcePushCertificate creates a Managed ACME certificate on
// sourceServerID with "source push" fleet distribution opted in for
// targetServerIDs — as if the certificate form were saved with the new
// toggle and those "Also configure on" boxes checked.
func newSourcePushCertificate(t *testing.T, s *Server, sourceServerID int64, domains string, targetServerIDs ...int64) models.Certificate {
t.Helper()
parts := make([]string, len(targetServerIDs))
for i, id := range targetServerIDs {
parts[i] = strconv.FormatInt(id, 10)
}
cert := models.Certificate{
// No DNSProfileID: dnsCredsFor only falls back to the legacy global
// per-provider settings (settingCFAPIToken etc.) when the profile ID
// is empty — see TestSourcePushCertificateCopiesCertAndKeyWithNoAutomationPolicyOnTarget.
Name: "shared-wildcard", Domains: domains, Source: models.CertSourceManaged,
DNSProvider: "cloudflare",
FleetDistributionMode: models.CertFleetDistributionSourcePush,
FleetPushTargets: strings.Join(parts, ","),
}
id, err := models.CreateCertificate(s.DB, sourceServerID, 0, &cert)
if err != nil {
t.Fatal(err)
}
cert.ID = id
return cert
}
// A source-push certificate is pushed to its target as a plain PEM copy
// (certificate and private key, read straight out of the source's Caddy
// storage) — no DNS credentials and no fleet-distribution fields of its own.
// The source keeps its own automation-policy entry (it still performs the
// ACME order); the target gets none at all, because
// buildDNSAutomationPolicies only emits one for Source == CertSourceManaged
// and the pushed copy is Source == CertSourcePEM.
func TestSourcePushCertificateCopiesCertAndKeyWithNoAutomationPolicyOnTarget(t *testing.T) {
s, sourceServerID, targetServerID := newFleetSyncTestServer(t)
dataDir := t.TempDir()
root := filepath.Join(dataDir, "caddy", "certificates")
serial := writeStorageCertificate(t, root, "acme-v02.api.letsencrypt.org-directory", "*.example.com", time.Now().Add(80*24*time.Hour))
srv, _ := models.GetCaddyServer(s.DB, sourceServerID)
srv.DataDir = dataDir
if err := models.UpdateCaddyServer(s.DB, srv); err != nil {
t.Fatal(err)
}
// So the source's own automation-policy assertion below reflects a real
// policy rather than "credentials missing".
if err := models.SetSetting(s.DB, settingCFAPIToken, "test-token"); err != nil {
t.Fatal(err)
}
cert := newSourcePushCertificate(t, s, sourceServerID, "*.example.com", targetServerID)
s.crossDeployCertificate("admin@example.com", sourceServerID, cert, cert.FleetPushTargetIDs())
targets, err := models.ListCertificates(s.DB, targetServerID)
if err != nil {
t.Fatal(err)
}
if len(targets) != 1 {
t.Fatalf("target certificates = %#v, want exactly one pushed copy", targets)
}
got := targets[0]
if got.Source != models.CertSourcePEM {
t.Fatalf("target source = %q, want pem — the target must never run its own ACME", got.Source)
}
if got.DNSProvider != "" || got.DNSProfileID != "" {
t.Fatalf("target DNS credentials = %q/%q, want empty — it must not be able to order its own certificate", got.DNSProvider, got.DNSProfileID)
}
if got.FleetDistributionMode != "" || got.FleetPushTargets != "" {
t.Fatalf("pushed copy fleet-distribution fields = %q/%q, want empty", got.FleetDistributionMode, got.FleetPushTargets)
}
leaf := parsePEMLeaf(got.CertPEM)
if leaf == nil || leaf.SerialNumber.Text(16) != serial {
t.Fatalf("pushed certificate = %v, want serial %s", leaf, serial)
}
if !strings.Contains(got.KeyPEM, "PRIVATE KEY") {
t.Fatalf("pushed private key missing: %q", got.KeyPEM)
}
// Requirement: only the source gets an automation-policy entry.
sourcePolicies := s.buildDNSAutomationPolicies(nil, nil, nil, []models.Certificate{cert})
if len(sourcePolicies) != 1 {
t.Fatalf("source automation policies = %#v, want exactly one — it still performs the ACME order", sourcePolicies)
}
targetPolicies := s.buildDNSAutomationPolicies(nil, nil, nil, targets)
if len(targetPolicies) != 0 {
t.Fatalf("target automation policies = %#v, want none — it must never attempt its own ACME order", targetPolicies)
}
}
// Before the source has completed its own ACME order — no Data directory
// configured yet, or a Data directory with nothing in storage yet — the push
// must not crash and must not create or overwrite the target with an empty
// or invalid certificate. It is skipped so the next certificate lifecycle
// reconciler pass can retry.
func TestSourcePushCertificateSkipsGracefullyBeforeSourceHasIssued(t *testing.T) {
s, sourceServerID, targetServerID := newFleetSyncTestServer(t)
cert := newSourcePushCertificate(t, s, sourceServerID, "*.example.com", targetServerID)
// No Data directory configured at all.
if _, _, err := s.upsertFleetCertificate(sourceServerID, targetServerID, cert, 0); err == nil {
t.Fatal("expected an error when the source has no Data directory configured")
} else if !strings.Contains(err.Error(), "Data directory") {
t.Fatalf("error = %v, want it to explain the missing Data directory", err)
}
if targets, _ := models.ListCertificates(s.DB, targetServerID); len(targets) != 0 {
t.Fatalf("target certificates = %#v, want none created from a failed push", targets)
}
// Data directory configured, but Caddy has not stored a certificate yet.
dataDir := t.TempDir()
srv, _ := models.GetCaddyServer(s.DB, sourceServerID)
srv.DataDir = dataDir
if err := models.UpdateCaddyServer(s.DB, srv); err != nil {
t.Fatal(err)
}
if _, _, err := s.upsertFleetCertificate(sourceServerID, targetServerID, cert, 0); err == nil {
t.Fatal("expected an error when the source hasn't completed its own ACME order yet")
} else if !strings.Contains(err.Error(), "has not completed its own ACME order") {
t.Fatalf("error = %v, want it to explain the missing certificate", err)
}
if targets, _ := models.ListCertificates(s.DB, targetServerID); len(targets) != 0 {
t.Fatalf("target certificates = %#v, want none created from a failed push", targets)
}
// Once the source has actually issued, the next attempt (simulating the
// reconciler's next pass) succeeds.
writeStorageCertificate(t, filepath.Join(dataDir, "caddy", "certificates"), "acme-v02.api.letsencrypt.org-directory", "*.example.com", time.Now().Add(80*24*time.Hour))
result, byPath, err := s.upsertFleetCertificate(sourceServerID, targetServerID, cert, 0)
if err != nil || byPath || !result.Created {
t.Fatalf("retry after issuance = %#v, byPath %v, err %v; want a fresh copy created", result, byPath, err)
}
}
// pushSharedManagedCertificates (the certificate lifecycle reconciler's hook)
// picks up a renewal on the source without the certificate form being saved
// again, lands it on the same target row, and leaves an unrelated
// default-mode managed certificate on the same source completely untouched.
func TestPushSharedManagedCertificatesPicksUpRenewalAndLeavesIndependentCertsAlone(t *testing.T) {
s, sourceServerID, targetServerID := newFleetSyncTestServer(t)
dataDir := t.TempDir()
root := filepath.Join(dataDir, "caddy", "certificates")
writeStorageCertificate(t, root, "acme-v02.api.letsencrypt.org-directory", "*.example.com", time.Now().Add(80*24*time.Hour))
srv, _ := models.GetCaddyServer(s.DB, sourceServerID)
srv.DataDir = dataDir
if err := models.UpdateCaddyServer(s.DB, srv); err != nil {
t.Fatal(err)
}
newSourcePushCertificate(t, s, sourceServerID, "*.example.com", targetServerID)
// An unrelated, default-mode managed certificate on the same source must
// never be touched by the reconciler hook (zero behavior change).
independent := models.Certificate{Name: "independent", Domains: "independent.example.com", Source: models.CertSourceManaged, DNSProvider: "cloudflare", DNSProfileID: "p"}
if _, err := models.CreateCertificate(s.DB, sourceServerID, 0, &independent); err != nil {
t.Fatal(err)
}
if attempted, err := s.pushSharedManagedCertificates(); err != nil || attempted != 1 {
t.Fatalf("pushSharedManagedCertificates = %d, %v; want exactly one source-push certificate attempted", attempted, err)
}
targets, _ := models.ListCertificates(s.DB, targetServerID)
if len(targets) != 1 {
t.Fatalf("target certificates = %#v, want exactly one pushed copy", targets)
}
firstLeaf := parsePEMLeaf(targets[0].CertPEM)
if firstLeaf == nil {
t.Fatalf("pushed certificate did not parse: %+v", targets[0])
}
firstSerial := firstLeaf.SerialNumber.Text(16)
targetCertID := targets[0].ID
// Simulate a renewal landing in the source's storage (same issuer
// directory, so it overwrites in place exactly as certmagic would).
renewedSerial := writeStorageCertificate(t, root, "acme-v02.api.letsencrypt.org-directory", "*.example.com", time.Now().Add(90*24*time.Hour))
if renewedSerial == firstSerial {
t.Fatal("test fixture produced the same serial twice")
}
if attempted, err := s.pushSharedManagedCertificates(); err != nil || attempted != 1 {
t.Fatalf("renewal pass = %d, %v", attempted, err)
}
targets, _ = models.ListCertificates(s.DB, targetServerID)
if len(targets) != 1 || targets[0].ID != targetCertID {
t.Fatalf("renewal should land on the same target row: %+v", targets)
}
if leaf := parsePEMLeaf(targets[0].CertPEM); leaf == nil || leaf.SerialNumber.Text(16) != renewedSerial {
t.Fatalf("target certificate = %v, want the renewed serial %s", leaf, renewedSerial)
}
// A third, unchanged pass touches nothing further (idempotent).
if attempted, err := s.pushSharedManagedCertificates(); err != nil || attempted != 1 {
t.Fatalf("steady-state pass = %d, %v", attempted, err)
}
targets, _ = models.ListCertificates(s.DB, targetServerID)
if len(targets) != 1 || targets[0].ID != targetCertID {
t.Fatalf("steady-state pass should not duplicate or move the target row: %+v", targets)
}
// The independent-mode certificate never got a target row at all.
for _, c := range targets {
if c.Domains == "independent.example.com" {
t.Fatalf("independent-mode certificate must not be cross-deployed by the reconciler hook: %+v", c)
}
}
}
// Without opting into source-push mode, cross-deploying a Managed ACME
// certificate is completely unchanged from pre-v2.55.0 behavior: the target
// gets its own DNS-01 definition (same DNS credentials) and orders its own
// certificate — no PEM material travels, and the target still gets its own
// automation-policy entry.
func TestCrossDeployManagedCertificateDefaultModeStillClonesDNSDefinitionOnly(t *testing.T) {
s, sourceServerID, targetServerID := newFleetSyncTestServer(t)
for key, value := range map[string]string{
settingRoute53AccessKeyID: "AKIAEXAMPLE",
settingRoute53SecretAccessKey: "secret",
} {
if err := models.SetSetting(s.DB, key, value); err != nil {
t.Fatal(err)
}
}
cert := models.Certificate{Name: "wildcard", Domains: "*.example.com", Source: models.CertSourceManaged, DNSProvider: dns.Route53}
id, err := models.CreateCertificate(s.DB, sourceServerID, 0, &cert)
if err != nil {
t.Fatal(err)
}
cert.ID = id
if cert.FleetDistributionMode != "" {
t.Fatalf("default FleetDistributionMode = %q, want empty", cert.FleetDistributionMode)
}
s.crossDeployCertificate("admin@example.com", sourceServerID, cert, []int64{targetServerID})
targets, _ := models.ListCertificates(s.DB, targetServerID)
if len(targets) != 1 {
t.Fatalf("target certificates = %#v, want exactly one cloned definition", targets)
}
got := targets[0]
if got.Source != models.CertSourceManaged {
t.Fatalf("target source = %q, want managed — default mode orders its own certificate", got.Source)
}
if got.DNSProvider != dns.Route53 {
t.Fatalf("target DNS provider = %q, want cloned from source", got.DNSProvider)
}
if got.CertPEM != "" || got.KeyPEM != "" {
t.Fatalf("target certificate/key = %q/%q, want empty — no PEM material travels in the default mode", got.CertPEM, got.KeyPEM)
}
// The target also gets its own automation-policy entry, exactly as before.
policies := s.buildDNSAutomationPolicies(nil, nil, nil, targets)
if len(policies) != 1 {
t.Fatalf("target automation policies = %#v, want exactly one — it must still run its own ACME", policies)
}
}
// fleetCertificateMatches must treat a source-push certificate's eventual
// copy as non-managed (it will be a PEM copy — see sourcePushCertificateCopy)
// while leaving the default-mode comparison exactly as it was.
func TestFleetCertificateMatchesSourcePushTreatsCopyAsNonManaged(t *testing.T) {
managedSource := models.Certificate{Domains: "*.example.com", Source: models.CertSourceManaged}
pushSource := models.Certificate{Domains: "*.example.com", Source: models.CertSourceManaged, FleetDistributionMode: models.CertFleetDistributionSourcePush}
managedTarget := models.Certificate{Domains: "*.example.com", Source: models.CertSourceManaged}
pemTarget := models.Certificate{Domains: "*.example.com", Source: models.CertSourcePEM}
if !fleetCertificateMatches(managedTarget, managedSource) {
t.Error("default mode: a managed target should match a managed source (unchanged pre-v2.55.0 behavior)")
}
if fleetCertificateMatches(pemTarget, managedSource) {
t.Error("default mode: a PEM target should not match a managed source (unchanged pre-v2.55.0 behavior)")
}
if fleetCertificateMatches(managedTarget, pushSource) {
t.Error("source-push mode: a managed target should not match a push-mode source — the copy it produces is PEM, not managed")
}
if !fleetCertificateMatches(pemTarget, pushSource) {
t.Error("source-push mode: a PEM target should match a push-mode source — that is what the copy actually is")
}
}
// certificate_form.html's new Go-template syntax (index on FleetPushTargetSet,
// the fleet_distribution_mode checkbox) is otherwise never exercised by the
// test suite — nothing calls server.New with the real embedded templates, so
// a runtime template error here (as opposed to a parse error, which would
// already fail any such call) would only ever surface by a human clicking
// into the certificate edit page. This renders the real compiled template
// through the real render path for both a fresh form and an existing
// source-push certificate, and checks the toggle/target checkbox reflect
// FleetDistributionMode/FleetPushTargetSet correctly in the markup.
func TestCertificateFormTemplateRendersSourcePushToggle(t *testing.T) {
s, sourceServerID, targetServerID := newFleetSyncTestServer(t)
tplFS, err := fs.Sub(web.FS, "templates")
if err != nil {
t.Fatal(err)
}
real, err := New(s.DB, nil, tplFS, nil, "", "test", "")
if err != nil {
t.Fatal(err)
}
target, err := models.GetCaddyServer(real.DB, targetServerID)
if err != nil {
t.Fatal(err)
}
cert := newSourcePushCertificate(t, real, sourceServerID, "*.example.com", targetServerID)
render := func(c *models.Certificate) string {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/certificates/new", nil)
real.render(rec, req, "certificate_form.html", map[string]any{
"Cert": c,
"OtherServers": []models.CaddyServer{*target},
"Section": "certs",
})
if rec.Code != http.StatusOK {
t.Fatalf("render status = %d, body = %s", rec.Code, rec.Body.String())
}
return rec.Body.String()
}
// Fresh form: the toggle markup must be present but unchecked, and the
// target checkbox must not be pre-checked.
fresh := render(&models.Certificate{Source: models.CertSourcePEM})
if !strings.Contains(fresh, `name="fleet_distribution_mode"`) {
t.Fatal("fresh form is missing the source-push toggle markup")
}
if strings.Contains(fresh, `name="fleet_distribution_mode" value="source_push" checked`) {
t.Fatal("fresh form must not pre-check the source-push toggle")
}
wantUnchecked := fmt.Sprintf(`value="%d" onchange`, targetServerID)
if !strings.Contains(fresh, wantUnchecked) {
t.Fatalf("fresh form should not pre-check target %d, body:\n%s", targetServerID, fresh)
}
// Editing the source-push certificate: both the toggle and its target
// checkbox must render pre-checked.
edited := render(&cert)
if !strings.Contains(edited, `name="fleet_distribution_mode" value="source_push" checked`) {
t.Fatalf("editing a source-push certificate should pre-check the toggle, body:\n%s", edited)
}
wantChecked := fmt.Sprintf(`value="%d" checked onchange`, targetServerID)
if !strings.Contains(edited, wantChecked) {
t.Fatalf("editing a source-push certificate should pre-check its target %q, body:\n%s", wantChecked, edited)
}
}