Ctrl K
ComunidadGuías…

internal/server/fleet_sync.go

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

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

package server

import (
	"database/sql"
	"errors"
	"fmt"
	"path/filepath"
	"reflect"
	"strings"
	"time"

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

type fleetUpsertResult struct {
	ID      int64
	Created bool
	Changed bool
}

type fleetSyncSummary struct {
	CertificatesCreated int
	CertificatesUpdated int
	ProxiesCreated      int
	ProxiesUpdated      int
	RedirectsCreated    int
	RedirectsUpdated    int
	RawRoutesCreated    int
	Layer4Created       int // v2.58.0 (issue #122)
	Layer4Updated       int
	Layer4Skipped       int // node-local
	RawRoutesUpdated    int
	// v2.33.0: resources deliberately left behind because they are marked
	// node-local. Counted and reported rather than silently dropped — an
	// operator seeing "12 proxies added" needs to know three others were
	// skipped on purpose, not lost to a bug.
	ProxiesSkipped   int
	RawRoutesSkipped int
	// v2.41.0: file-path certificates whose files CaddyUI could not read were
	// copied by path reference only — the files must exist on the target.
	CertificatesByPath int
}

func (s fleetSyncSummary) Changed() int {
	return s.CertificatesCreated + s.CertificatesUpdated +
		s.ProxiesCreated + s.ProxiesUpdated +
		s.RedirectsCreated + s.RedirectsUpdated +
		s.RawRoutesCreated + s.RawRoutesUpdated +
		s.Layer4Created + s.Layer4Updated
}

func (s fleetSyncSummary) String() string {
	out := fmt.Sprintf(
		"proxies: %d added, %d updated; redirects: %d added, %d updated; advanced routes: %d added, %d updated; certificates: %d added, %d updated",
		s.ProxiesCreated, s.ProxiesUpdated,
		s.RedirectsCreated, s.RedirectsUpdated,
		s.RawRoutesCreated, s.RawRoutesUpdated,
		s.CertificatesCreated, s.CertificatesUpdated,
	)
	if s.Layer4Created > 0 || s.Layer4Updated > 0 || s.Layer4Skipped > 0 {
		out += fmt.Sprintf("; layer4 proxies: %d added, %d updated", s.Layer4Created, s.Layer4Updated)
		if s.Layer4Skipped > 0 {
			out += fmt.Sprintf(", %d skipped as node-local", s.Layer4Skipped)
		}
	}
	if s.ProxiesSkipped > 0 || s.RawRoutesSkipped > 0 {
		out += fmt.Sprintf("; skipped as node-local: %d proxies, %d advanced routes",
			s.ProxiesSkipped, s.RawRoutesSkipped)
	}
	if s.CertificatesByPath > 0 {
		out += fmt.Sprintf("; %d certificate(s) copied by file path only — the files must exist on the target at the same paths", s.CertificatesByPath)
	}
	return out
}

// mappedCertificateID resolves a source certificate reference to its copy on
// the target, or 0 when the certificate has not been copied there. v2.41.0:
// a host created on the target keeps using the same custom certificate
// instead of silently falling back to Auto TLS.
func (s *Server) mappedCertificateID(sourceServerID, certificateID, targetServerID int64) int64 {
	if certificateID == 0 {
		return 0
	}
	id, err := s.mappedFleetTarget(sourceServerID, models.FleetResourceCertificate, certificateID, targetServerID)
	if err != nil {
		return 0
	}
	return id
}

func fleetOwnerID(owner sql.NullInt64) int64 {
	if owner.Valid {
		return owner.Int64
	}
	return 0
}

func (s *Server) validateFleetPair(sourceServerID, targetServerID int64) (*models.CaddyServer, *models.CaddyServer, error) {
	if sourceServerID <= 0 || targetServerID <= 0 || sourceServerID == targetServerID {
		return nil, nil, fmt.Errorf("choose two different Caddy Fleet environments")
	}
	source, err := models.GetCaddyServer(s.DB, sourceServerID)
	if err != nil {
		return nil, nil, fmt.Errorf("load source environment: %w", err)
	}
	target, err := models.GetCaddyServer(s.DB, targetServerID)
	if err != nil {
		return nil, nil, fmt.Errorf("load target environment: %w", err)
	}
	if source.Type != models.CaddyServerTypeManaged {
		return nil, nil, fmt.Errorf("source environment %q is monitoring-only", source.Name)
	}
	if target.Type != models.CaddyServerTypeManaged {
		return nil, nil, fmt.Errorf("target environment %q is monitoring-only", target.Name)
	}
	return source, target, nil
}

func (s *Server) mappedFleetTarget(sourceServerID int64, resourceKind string, sourceResourceID, targetServerID int64) (int64, error) {
	id, err := models.FleetDeploymentTarget(s.DB, sourceServerID, resourceKind, sourceResourceID, targetServerID)
	if errors.Is(err, sql.ErrNoRows) {
		return 0, nil
	}
	if err != nil {
		return 0, err
	}
	exists, err := models.FleetDeploymentTargetExists(s.DB, resourceKind, id, targetServerID)
	if err != nil {
		return 0, err
	}
	if !exists {
		return 0, nil
	}
	return id, nil
}

func preserveProxyTargetPolicy(copy *models.ProxyHost, existing *models.ProxyHost, targetServerID int64) {
	copy.ServerID = targetServerID
	copy.OwnerID = sql.NullInt64{}
	copy.OwnerEmail = ""
	copy.CreatedAt = time.Time{}
	copy.UpdatedAt = time.Time{}
	if existing == nil {
		copy.ID = 0
		copy.CertificateID = 0
		copy.DNSProvider = ""
		copy.DNSProfileID = ""
		copy.DNSZoneID = ""
		copy.DNSZoneName = ""
		copy.DNSRecordID = ""
		copy.DNSSkipRecord = false
		copy.CFDNSRecordID = ""
		copy.CFZoneID = ""
		copy.PBDNSRecordID = ""
		copy.PBDomain = ""
		return
	}
	copy.ID = existing.ID
	copy.CertificateID = existing.CertificateID
	copy.DNSProvider = existing.DNSProvider
	copy.DNSProfileID = existing.DNSProfileID
	copy.DNSZoneID = existing.DNSZoneID
	copy.DNSZoneName = existing.DNSZoneName
	copy.DNSRecordID = existing.DNSRecordID
	copy.DNSSkipRecord = existing.DNSSkipRecord
	copy.CFDNSRecordID = existing.CFDNSRecordID
	copy.CFZoneID = existing.CFZoneID
	copy.PBDNSRecordID = existing.PBDNSRecordID
	copy.PBDomain = existing.PBDomain
}

func (s *Server) upsertFleetProxyHost(sourceServerID, targetServerID int64, source models.ProxyHost, ownerID int64) (fleetUpsertResult, error) {
	// v2.57.1: the owner decides whether this copy may adopt an existing target
	// row, and callers pass a FORM-PARSED source whose OwnerID is not populated
	// (the owner is given to the insert separately). Always read it from the row.
	if source.ID > 0 {
		if cur, err := models.GetProxyHost(s.DB, source.ID); err == nil && cur != nil {
			source.OwnerID = cur.OwnerID
		}
	}
	targetID, err := s.mappedFleetTarget(sourceServerID, models.FleetResourceProxy, source.ID, targetServerID)
	if err != nil {
		return fleetUpsertResult{}, err
	}
	var existing *models.ProxyHost
	if targetID > 0 {
		existing, err = models.GetProxyHost(s.DB, targetID)
		if err != nil {
			return fleetUpsertResult{}, err
		}
	} else {
		targets, err := models.ListProxyHosts(s.DB, targetServerID, 0, true, nil)
		if err != nil {
			return fleetUpsertResult{}, err
		}
		for i := range targets {
			// Security (v2.57.1): a copy of a TENANT-owned source row may only
			// update the target row this source already created (the mapping
			// above). Adopting an unrelated row that merely has the same
			// domains let one customer overwrite another's host — or an
			// admin's — on the target, and reset its owner.
			if !s.isTenantOwner(source.OwnerID) && sameDomainSet(targets[i].DomainList(), source.DomainList()) {
				existing = &targets[i]
				targetID = targets[i].ID
				break
			}
		}
	}
	if existing == nil || !sameDomainSet(existing.DomainList(), source.DomainList()) {
		if conflict, err := models.DomainsConflict(s.DB, targetServerID, source.DomainList(), targetID, 0); err != nil {
			return fleetUpsertResult{}, err
		} else if conflict != "" {
			return fleetUpsertResult{}, fmt.Errorf("domain %q is already claimed on the target environment", conflict)
		}
	}

	copy := source
	preserveProxyTargetPolicy(&copy, existing, targetServerID)
	if existing == nil {
		copy.CertificateID = s.mappedCertificateID(sourceServerID, source.CertificateID, targetServerID)
	}
	created := existing == nil
	changed := true
	if existing != nil {
		current := *existing
		preserveProxyTargetPolicy(&current, existing, targetServerID)
		changed = !reflect.DeepEqual(current, copy) || fleetOwnerID(existing.OwnerID) != ownerID
	}
	if created {
		targetID, err = models.CreateProxyHost(s.DB, targetServerID, ownerID, &copy)
	} else if changed {
		err = models.UpdateProxyHost(s.DB, &copy)
		if err == nil {
			err = models.SetProxyHostOwner(s.DB, copy.ID, ownerID)
		}
	}
	if err != nil {
		return fleetUpsertResult{}, err
	}
	// v2.60.0 (issue #124): the copy uses the same middleware profile (profiles
	// are global), so a profile change on the source reaches the target too. A
	// different attachment counts as a change, so the target is re-synced.
	wantProfile := models.ProxyHostProfileID(s.DB, source.ID)
	if models.ProxyHostProfileID(s.DB, targetID) != wantProfile {
		if err := models.SetProxyHostProfile(s.DB, targetID, wantProfile); err != nil {
			return fleetUpsertResult{}, err
		}
		changed = true
	}
	if err := models.SaveFleetDeployment(s.DB, sourceServerID, models.FleetResourceProxy, source.ID, targetServerID, targetID); err != nil {
		return fleetUpsertResult{}, err
	}
	return fleetUpsertResult{ID: targetID, Created: created, Changed: changed}, nil
}

func preserveRedirectTargetPolicy(copy *models.RedirectionHost, existing *models.RedirectionHost) {
	copy.OwnerID = sql.NullInt64{}
	copy.OwnerEmail = ""
	copy.CreatedAt = time.Time{}
	copy.UpdatedAt = time.Time{}
	if existing == nil {
		copy.ID = 0
		copy.CertificateID = 0
		copy.DNSProvider = ""
		copy.DNSProfileID = ""
		copy.DNSZoneID = ""
		copy.DNSZoneName = ""
		copy.DNSRecordID = ""
		copy.DNSSkipRecord = false
		return
	}
	copy.ID = existing.ID
	copy.CertificateID = existing.CertificateID
	copy.DNSProvider = existing.DNSProvider
	copy.DNSProfileID = existing.DNSProfileID
	copy.DNSZoneID = existing.DNSZoneID
	copy.DNSZoneName = existing.DNSZoneName
	copy.DNSRecordID = existing.DNSRecordID
	copy.DNSSkipRecord = existing.DNSSkipRecord
}

func (s *Server) upsertFleetRedirectionHost(sourceServerID, targetServerID int64, source models.RedirectionHost, ownerID int64) (fleetUpsertResult, error) {
	if source.ID > 0 { // v2.57.1: owner from the row, not the caller's form-parsed copy — see upsertFleetProxyHost
		if cur, err := models.GetRedirectionHost(s.DB, source.ID); err == nil && cur != nil {
			source.OwnerID = cur.OwnerID
		}
	}
	targetID, err := s.mappedFleetTarget(sourceServerID, models.FleetResourceRedirect, source.ID, targetServerID)
	if err != nil {
		return fleetUpsertResult{}, err
	}
	var existing *models.RedirectionHost
	if targetID > 0 {
		existing, err = models.GetRedirectionHost(s.DB, targetID)
		if err != nil {
			return fleetUpsertResult{}, err
		}
	} else {
		targets, err := models.ListRedirectionHosts(s.DB, targetServerID, 0, true, nil)
		if err != nil {
			return fleetUpsertResult{}, err
		}
		for i := range targets {
			// Security (v2.57.1): a copy of a TENANT-owned source row may only
			// update the target row this source already created (the mapping
			// above). Adopting an unrelated row that merely has the same
			// domains let one customer overwrite another's host — or an
			// admin's — on the target, and reset its owner.
			if !s.isTenantOwner(source.OwnerID) && sameDomainSet(targets[i].DomainList(), source.DomainList()) {
				existing = &targets[i]
				targetID = targets[i].ID
				break
			}
		}
	}
	if existing == nil || !sameDomainSet(existing.DomainList(), source.DomainList()) {
		if conflict, err := models.DomainsConflict(s.DB, targetServerID, source.DomainList(), 0, targetID); err != nil {
			return fleetUpsertResult{}, err
		} else if conflict != "" {
			return fleetUpsertResult{}, fmt.Errorf("domain %q is already claimed on the target environment", conflict)
		}
	}

	copy := source
	preserveRedirectTargetPolicy(&copy, existing)
	if existing == nil {
		copy.CertificateID = s.mappedCertificateID(sourceServerID, source.CertificateID, targetServerID)
	}
	created := existing == nil
	changed := true
	if existing != nil {
		current := *existing
		preserveRedirectTargetPolicy(&current, existing)
		changed = !reflect.DeepEqual(current, copy) || fleetOwnerID(existing.OwnerID) != ownerID
	}
	if created {
		targetID, err = models.CreateRedirectionHost(s.DB, targetServerID, ownerID, &copy)
	} else if changed {
		err = models.UpdateRedirectionHost(s.DB, &copy)
		if err == nil {
			err = models.SetRedirectionHostOwner(s.DB, copy.ID, ownerID)
		}
	}
	if err != nil {
		return fleetUpsertResult{}, err
	}
	if err := models.SaveFleetDeployment(s.DB, sourceServerID, models.FleetResourceRedirect, source.ID, targetServerID, targetID); err != nil {
		return fleetUpsertResult{}, err
	}
	return fleetUpsertResult{ID: targetID, Created: created, Changed: changed}, nil
}

func rawRouteIdentityMatches(a, b models.RawRoute) bool {
	aHosts, bHosts := rawRouteHosts(a), rawRouteHosts(b)
	if len(aHosts) > 0 || len(bHosts) > 0 {
		return len(aHosts) > 0 && len(bHosts) > 0 && sameDomainSet(aHosts, bHosts)
	}
	return strings.EqualFold(strings.TrimSpace(a.Label), strings.TrimSpace(b.Label))
}

func preserveRawRouteTargetPolicy(copy *models.RawRoute, existing *models.RawRoute) {
	copy.OwnerID = sql.NullInt64{}
	copy.OwnerEmail = ""
	copy.CreatedAt = time.Time{}
	copy.UpdatedAt = time.Time{}
	if existing == nil {
		copy.ID = 0
		copy.CertificateID = 0
		copy.DNSProvider = ""
		copy.DNSProfileID = ""
		copy.DNSZoneID = ""
		copy.DNSZoneName = ""
		copy.DNSRecordID = ""
		copy.DNSSkipRecord = false
		return
	}
	copy.ID = existing.ID
	copy.CertificateID = existing.CertificateID
	copy.DNSProvider = existing.DNSProvider
	copy.DNSProfileID = existing.DNSProfileID
	copy.DNSZoneID = existing.DNSZoneID
	copy.DNSZoneName = existing.DNSZoneName
	copy.DNSRecordID = existing.DNSRecordID
	copy.DNSSkipRecord = existing.DNSSkipRecord
}

func (s *Server) upsertFleetRawRoute(sourceServerID, targetServerID int64, source models.RawRoute, ownerID int64) (fleetUpsertResult, error) {
	if source.ID > 0 { // v2.57.1: owner from the row, not the caller's form-parsed copy — see upsertFleetProxyHost
		if cur, err := models.GetRawRoute(s.DB, source.ID); err == nil && cur != nil {
			source.OwnerID = cur.OwnerID
		}
	}
	targetID, err := s.mappedFleetTarget(sourceServerID, models.FleetResourceRawRoute, source.ID, targetServerID)
	if err != nil {
		return fleetUpsertResult{}, err
	}
	var existing *models.RawRoute
	if targetID > 0 {
		existing, err = models.GetRawRoute(s.DB, targetID)
		if err != nil {
			return fleetUpsertResult{}, err
		}
	} else {
		targets, err := models.ListRawRoutes(s.DB, targetServerID, 0, true, nil)
		if err != nil {
			return fleetUpsertResult{}, err
		}
		for i := range targets {
			if !s.isTenantOwner(source.OwnerID) && rawRouteIdentityMatches(targets[i], source) { // v2.57.1: see upsertFleetProxyHost
				existing = &targets[i]
				targetID = targets[i].ID
				break
			}
		}
	}
	if existing == nil || !rawRouteIdentityMatches(*existing, source) {
		if conflict, err := models.DomainsConflict(s.DB, targetServerID, rawRouteHosts(source), 0, 0); err != nil {
			return fleetUpsertResult{}, err
		} else if conflict != "" {
			return fleetUpsertResult{}, fmt.Errorf("domain %q is already claimed on the target environment", conflict)
		}
	}

	copy := source
	preserveRawRouteTargetPolicy(&copy, existing)
	if existing == nil {
		copy.CertificateID = s.mappedCertificateID(sourceServerID, source.CertificateID, targetServerID)
	}
	created := existing == nil
	changed := true
	if existing != nil {
		current := *existing
		preserveRawRouteTargetPolicy(&current, existing)
		changed = !reflect.DeepEqual(current, copy) || fleetOwnerID(existing.OwnerID) != ownerID
	}
	if created {
		targetID, err = models.CreateRawRoute(s.DB, targetServerID, ownerID, &copy)
	} else if changed {
		err = models.UpdateRawRoute(s.DB, &copy)
		if err == nil {
			err = models.SetRawRouteOwner(s.DB, copy.ID, ownerID)
		}
	}
	if err != nil {
		return fleetUpsertResult{}, err
	}
	if err := models.SaveFleetDeployment(s.DB, sourceServerID, models.FleetResourceRawRoute, source.ID, targetServerID, targetID); err != nil {
		return fleetUpsertResult{}, err
	}
	return fleetUpsertResult{ID: targetID, Created: created, Changed: changed}, nil
}

// fleetCertificateCopy is how a source certificate is represented on a target
// node. Managed definitions copy their DNS-01 settings only — each node
// orders its own certificate. Stored PEMs copy their content. v2.41.0: a
// file-path certificate is copied as stored PEM when CaddyUI can read both
// files (the target host cannot see the source's disk, so a path would point
// at nothing there), and by path reference otherwise, in which case the
// files must exist on the target at the same paths; byPath reports that.
// v2.53.0: "can read" now also means "inside roots" (see confinedReadPath),
// so a certificate outside them degrades to a by-path copy instead of
// silently pulling an arbitrary host file into the database as a PEM.
func fleetCertificateCopy(source models.Certificate, roots []string) (copy models.Certificate, byPath bool) {
	copy = source
	copy.OwnerID = sql.NullInt64{}
	copy.OwnerEmail = ""
	copy.CreatedAt = time.Time{}
	copy.UpdatedAt = time.Time{}
	switch source.Source {
	case models.CertSourceManaged:
		copy.CertPEM, copy.KeyPEM, copy.CertPath, copy.KeyPath = "", "", "", ""
	case models.CertSourcePEM:
		copy.CertPath, copy.KeyPath = "", ""
		copy.DNSProvider, copy.DNSProfileID = "", ""
	case models.CertSourcePath:
		copy.DNSProvider, copy.DNSProfileID = "", ""
		certPEM, certErr := readCertificateFile(source.CertPath, roots)
		keyPEM, keyErr := readCertificateFile(source.KeyPath, roots)
		if certErr == nil && keyErr == nil && parsePEMLeaf(string(certPEM)) != nil && strings.Contains(string(keyPEM), "PRIVATE KEY") {
			copy.Source = models.CertSourcePEM
			copy.CertPEM, copy.KeyPEM = strings.TrimSpace(string(certPEM)), strings.TrimSpace(string(keyPEM))
			copy.CertPath, copy.KeyPath = "", ""
		} else {
			copy.CertPEM, copy.KeyPEM = "", ""
			byPath = true
		}
	}
	return copy, byPath
}

// sourcePushCertificateCopy builds the target-side copy for a Managed ACME
// certificate in "source push" fleet-distribution mode (v2.55.0, issue #115):
// instead of cloning the DNS-01 definition so the target orders its own
// certificate — fleetCertificateCopy's default for CertSourceManaged — it
// reads the source server's current certificate and private key straight out
// of Caddy's on-disk storage (findStorageCertificate, the same read path the
// v2.42.0 "export to a directory" feature uses) and hands back a plain PEM
// certificate. A live TLS probe (the other existing read path, used by
// certificate_probe.go) can never substitute for this: a TLS handshake never
// reveals the server's private key, only the public leaf certificate.
//
// The result deliberately carries neither DNS credentials nor the
// fleet-distribution fields: it is a plain PEM copy like any other, so the
// target needs no DNS provider, gets no automation-policy entry of its own,
// and is not itself mistaken for a push source if it is later used as one.
//
// Returns an error when the source has no Data directory configured, or has
// not completed its own ACME order yet. Callers must treat that as "try
// again on the next pass" and must not create or overwrite a target row with
// an empty or partial certificate.
func (s *Server) sourcePushCertificateCopy(sourceServerID int64, source models.Certificate) (models.Certificate, error) {
	srv, err := models.GetCaddyServer(s.DB, sourceServerID)
	if err != nil || srv == nil {
		return models.Certificate{}, fmt.Errorf("load source environment %d: %w", sourceServerID, err)
	}
	if strings.TrimSpace(srv.DataDir) == "" {
		return models.Certificate{}, fmt.Errorf("%s has no Data directory configured — set one under Caddy Fleet → edit server so CaddyUI can read the certificate it obtains for fleet distribution", srv.Name)
	}
	dataDir, err := safeAbsolutePath(srv.DataDir)
	if err != nil {
		return models.Certificate{}, fmt.Errorf("%s data directory: %w", srv.Name, err)
	}
	roots := s.certificateReadRoots()
	stored, err := findStorageCertificate(dataDir, source.DomainList(), roots)
	if err != nil {
		return models.Certificate{}, fmt.Errorf("%s has not completed its own ACME order for this certificate yet: %w", srv.Name, err)
	}
	certPEM, err := readCertificateFile(stored.CertPath, roots)
	if err != nil {
		return models.Certificate{}, fmt.Errorf("read %s: %w", stored.CertPath, err)
	}
	keyPEM, err := readCertificateFile(stored.KeyPath, roots)
	if err != nil {
		return models.Certificate{}, fmt.Errorf("read %s: %w", stored.KeyPath, err)
	}
	// v2.68.6 (issue #153): Caddy stores a wildcard and its apex as two
	// separate certificates, and a target certificate row holds exactly one
	// keypair, so one push cannot carry both. findStorageCertificate picks the
	// broadest match; say plainly which names it leaves unprotected instead of
	// pushing a partial certificate silently and breaking TLS on the target.
	if len(stored.Uncovered) > 0 {
		_ = models.LogActivity(s.DB, sourceServerID, "system", "cert_source_push",
			fmt.Sprintf("cert:%d", source.ID),
			fmt.Sprintf("pushed %s from %s, which does not cover %s — Caddy stores those as a separate certificate; give them their own certificate definition with its own push targets",
				filepath.Base(stored.CertPath), srv.Name, strings.Join(stored.Uncovered, ", ")), false)
	}
	copy := source
	copy.OwnerID = sql.NullInt64{}
	copy.OwnerEmail = ""
	copy.CreatedAt = time.Time{}
	copy.UpdatedAt = time.Time{}
	copy.Source = models.CertSourcePEM
	copy.CertPEM, copy.KeyPEM = strings.TrimSpace(string(certPEM)), strings.TrimSpace(string(keyPEM))
	copy.CertPath, copy.KeyPath = "", ""
	copy.DNSProvider, copy.DNSProfileID = "", ""
	copy.FleetDistributionMode, copy.FleetPushTargets = "", ""
	return copy, nil
}

// fleetCertificateMatches pairs a source certificate with a target row that
// has no deployment mapping yet: same domain set and the same kind (managed
// with managed, custom with custom — a PEM copy of a file-path source is
// still custom). Name breaks ties.
//
// v2.55.0 (issue #115): a Managed source in "source push" fleet-distribution
// mode produces a PEM copy, not a managed one (see sourcePushCertificateCopy),
// so it is compared as non-managed here too — otherwise a target's existing
// pushed PEM copy would never be recognized as "the same certificate" on a
// fresh scan (no deployment mapping yet) and a duplicate row would be created
// alongside it.
func fleetCertificateMatches(target, source models.Certificate) bool {
	if !sameDomainSet(target.DomainList(), source.DomainList()) {
		return false
	}
	sourceBecomesManagedCopy := source.Source == models.CertSourceManaged &&
		source.FleetDistributionMode != models.CertFleetDistributionSourcePush
	return (target.Source == models.CertSourceManaged) == sourceBecomesManagedCopy
}

// upsertFleetCertificate creates or updates the target's copy of source
// (see fleetCertificateCopy) and records the deployment mapping. byPath is
// true when a file-path certificate could only be copied by reference.
//
// v2.55.0 (issue #115): when source is a Managed ACME certificate in "source
// push" fleet-distribution mode, the target does not get its own DNS-01
// definition (fleetCertificateCopy's default for CertSourceManaged) — it gets
// the source's actual certificate and private key, read off the source
// server's Caddy storage (sourcePushCertificateCopy), so it needs no DNS
// credentials and gets no automation-policy entry of its own
// (buildDNSAutomationPolicies only emits one for Source == CertSourceManaged).
// An error here (no Data directory configured, or the source hasn't
// completed its own ACME order yet) is returned as-is: every caller already
// treats an error from this function as "skip this one and keep going," so
// nothing is created or overwritten on the target, and the next certificate
// lifecycle reconciler pass simply tries again.
func (s *Server) upsertFleetCertificate(sourceServerID, targetServerID int64, source models.Certificate, ownerID int64) (result fleetUpsertResult, byPath bool, err error) {
	if source.ID > 0 { // v2.57.1: owner from the row, not the caller's form-parsed copy — see upsertFleetProxyHost
		if cur, err := models.GetCertificate(s.DB, source.ID); err == nil && cur != nil {
			source.OwnerID = cur.OwnerID
		}
	}
	targetID, err := s.mappedFleetTarget(sourceServerID, models.FleetResourceCertificate, source.ID, targetServerID)
	if err != nil {
		return fleetUpsertResult{}, false, err
	}
	var existing *models.Certificate
	if targetID > 0 {
		existing, err = models.GetCertificate(s.DB, targetID)
		if err != nil {
			return fleetUpsertResult{}, false, err
		}
	} else {
		targets, err := models.ListCertificates(s.DB, targetServerID)
		if err != nil {
			return fleetUpsertResult{}, false, err
		}
		for i := range targets {
			if !s.isTenantOwner(source.OwnerID) && fleetCertificateMatches(targets[i], source) && (existing == nil || targets[i].Name == source.Name) { // v2.57.1
				existing = &targets[i]
				targetID = targets[i].ID
			}
		}
	}
	var copy models.Certificate
	if source.Source == models.CertSourceManaged && source.FleetDistributionMode == models.CertFleetDistributionSourcePush {
		copy, err = s.sourcePushCertificateCopy(sourceServerID, source)
		if err != nil {
			return fleetUpsertResult{}, false, err
		}
	} else {
		copy, byPath = fleetCertificateCopy(source, s.certificateReadRoots())
	}
	created := existing == nil
	changed := true
	if existing != nil {
		copy.ID = existing.ID
		current := *existing
		current.OwnerID = sql.NullInt64{}
		current.OwnerEmail = ""
		current.CreatedAt = time.Time{}
		current.UpdatedAt = time.Time{}
		if sameDomainSet(current.DomainList(), copy.DomainList()) {
			current.Domains = copy.Domains
		}
		changed = !reflect.DeepEqual(current, copy) || fleetOwnerID(existing.OwnerID) != ownerID
	}
	if created {
		copy.ID = 0
		targetID, err = models.CreateCertificate(s.DB, targetServerID, ownerID, &copy)
	} else if changed {
		copy.ID = existing.ID
		err = models.UpdateCertificate(s.DB, &copy)
		if err == nil {
			err = models.SetCertificateOwner(s.DB, copy.ID, ownerID)
		}
	}
	if err != nil {
		return fleetUpsertResult{}, byPath, err
	}
	if err := models.SaveFleetDeployment(s.DB, sourceServerID, models.FleetResourceCertificate, source.ID, targetServerID, targetID); err != nil {
		return fleetUpsertResult{}, byPath, err
	}
	return fleetUpsertResult{ID: targetID, Created: created, Changed: changed}, byPath, nil
}

// syncFleetConfiguration performs a one-way, non-destructive merge from the
// selected managed environment into another one. Source routes are created or
// updated; target-only routes remain untouched. Per-target DNS records and an
// existing target host's certificate choice are intentionally preserved; a
// host created by the sync references the copy of its source certificate.
func (s *Server) syncFleetConfiguration(actor string, sourceServerID, targetServerID int64) (fleetSyncSummary, error) {
	if _, _, err := s.validateFleetPair(sourceServerID, targetServerID); err != nil {
		return fleetSyncSummary{}, err
	}
	s.fleetDeployMu.Lock()
	defer s.fleetDeployMu.Unlock()

	certificates, err := models.ListCertificates(s.DB, sourceServerID)
	if err != nil {
		return fleetSyncSummary{}, fmt.Errorf("list source certificates: %w", err)
	}
	proxies, err := models.ListProxyHosts(s.DB, sourceServerID, 0, true, nil)
	if err != nil {
		return fleetSyncSummary{}, fmt.Errorf("list source proxy hosts: %w", err)
	}
	redirects, err := models.ListRedirectionHosts(s.DB, sourceServerID, 0, true, nil)
	if err != nil {
		return fleetSyncSummary{}, fmt.Errorf("list source redirects: %w", err)
	}
	rawRoutes, err := models.ListRawRoutes(s.DB, sourceServerID, 0, true, nil)
	if err != nil {
		return fleetSyncSummary{}, fmt.Errorf("list source advanced routes: %w", err)
	}

	var summary fleetSyncSummary
	var syncErrors []error
	// v2.41.0: every certificate travels, not only managed definitions —
	// see fleetCertificateCopy. Certificates go first so the hosts copied
	// below can resolve their certificate references (mappedCertificateID).
	for _, certificate := range certificates {
		result, byPath, err := s.upsertFleetCertificate(sourceServerID, targetServerID, certificate, fleetOwnerID(certificate.OwnerID))
		if err != nil {
			syncErrors = append(syncErrors, fmt.Errorf("certificate %q: %w", certificate.Name, err))
			continue
		}
		if byPath {
			summary.CertificatesByPath++
		}
		if result.Created {
			summary.CertificatesCreated++
		} else if result.Changed {
			summary.CertificatesUpdated++
		}
	}
	for _, proxy := range proxies {
		// v2.33.0: node-local hosts never leave their node. Their upstream
		// (a Docker service name, a VPN address) means nothing on the target,
		// so copying them would create a route pointing at nothing.
		if proxy.NodeLocal {
			summary.ProxiesSkipped++
			continue
		}
		result, err := s.upsertFleetProxyHost(sourceServerID, targetServerID, proxy, fleetOwnerID(proxy.OwnerID))
		if err != nil {
			syncErrors = append(syncErrors, fmt.Errorf("proxy %q: %w", proxy.Domains, err))
			continue
		}
		if result.Created {
			summary.ProxiesCreated++
		} else if result.Changed {
			summary.ProxiesUpdated++
		}
	}
	for _, redirect := range redirects {
		result, err := s.upsertFleetRedirectionHost(sourceServerID, targetServerID, redirect, fleetOwnerID(redirect.OwnerID))
		if err != nil {
			syncErrors = append(syncErrors, fmt.Errorf("redirect %q: %w", redirect.Domains, err))
			continue
		}
		if result.Created {
			summary.RedirectsCreated++
		} else if result.Changed {
			summary.RedirectsUpdated++
		}
	}
	for _, rawRoute := range rawRoutes {
		if rawRoute.NodeLocal { // v2.33.0 — see the proxy loop above
			summary.RawRoutesSkipped++
			continue
		}
		result, err := s.upsertFleetRawRoute(sourceServerID, targetServerID, rawRoute, fleetOwnerID(rawRoute.OwnerID))
		if err != nil {
			syncErrors = append(syncErrors, fmt.Errorf("advanced route %q: %w", rawRoute.Label, err))
			continue
		}
		if result.Created {
			summary.RawRoutesCreated++
		} else if result.Changed {
			summary.RawRoutesUpdated++
		}
	}

	// v2.58.0 (issue #122): Layer4 proxies travel with the rest.
	layer4Proxies, err := models.ListLayer4Proxies(s.DB, sourceServerID)
	if err != nil {
		syncErrors = append(syncErrors, fmt.Errorf("list source layer4 proxies: %w", err))
	}
	for _, l4 := range layer4Proxies {
		if l4.NodeLocal {
			summary.Layer4Skipped++
			continue
		}
		result, err := s.upsertFleetLayer4Proxy(sourceServerID, targetServerID, l4)
		if err != nil {
			syncErrors = append(syncErrors, fmt.Errorf("layer4 proxy %q: %w", l4.Name, err))
			continue
		}
		if result.Created {
			summary.Layer4Created++
		} else if result.Changed {
			summary.Layer4Updated++
		}
	}

	detail := fmt.Sprintf("source_server=%d target_server=%d %s", sourceServerID, targetServerID, summary.String())
	_ = models.LogActivity(s.DB, targetServerID, actor, "fleet_config_sync", fmt.Sprintf("server:%d", targetServerID), detail, len(syncErrors) == 0)
	return summary, errors.Join(syncErrors...)
}