// 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(©, existing, targetServerID) if existing == nil { copy.CertificateID = s.mappedCertificateID(sourceServerID, source.CertificateID, targetServerID) } created := existing == nil changed := true if existing != nil { current := *existing preserveProxyTargetPolicy(¤t, existing, targetServerID) changed = !reflect.DeepEqual(current, copy) || fleetOwnerID(existing.OwnerID) != ownerID } if created { targetID, err = models.CreateProxyHost(s.DB, targetServerID, ownerID, ©) } else if changed { err = models.UpdateProxyHost(s.DB, ©) 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(©, existing) if existing == nil { copy.CertificateID = s.mappedCertificateID(sourceServerID, source.CertificateID, targetServerID) } created := existing == nil changed := true if existing != nil { current := *existing preserveRedirectTargetPolicy(¤t, existing) changed = !reflect.DeepEqual(current, copy) || fleetOwnerID(existing.OwnerID) != ownerID } if created { targetID, err = models.CreateRedirectionHost(s.DB, targetServerID, ownerID, ©) } else if changed { err = models.UpdateRedirectionHost(s.DB, ©) 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(©, existing) if existing == nil { copy.CertificateID = s.mappedCertificateID(sourceServerID, source.CertificateID, targetServerID) } created := existing == nil changed := true if existing != nil { current := *existing preserveRawRouteTargetPolicy(¤t, existing) changed = !reflect.DeepEqual(current, copy) || fleetOwnerID(existing.OwnerID) != ownerID } if created { targetID, err = models.CreateRawRoute(s.DB, targetServerID, ownerID, ©) } else if changed { err = models.UpdateRawRoute(s.DB, ©) 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, ©) } else if changed { copy.ID = existing.ID err = models.UpdateCertificate(s.DB, ©) 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...) }