From 2f2cbbb9fae5f4d64ef4075927e5fb80f6e47a47 Mon Sep 17 00:00:00 2001 From: Eric Brandes Date: Thu, 2 Jul 2026 13:36:46 -0500 Subject: [PATCH 1/6] Private CA root trust: install roots into the OS trust store and report status The poll response now carries the authoritative set of private root CAs behind this agent's deploy configs (ca_id, root PEM, sha256, auto_install). The agent: - persists them to config.json, preserving status fields across polls; entries the server drops are removed from config but the root is never uninstalled from the OS trust store - verifies trust on change, on install-error retry, or every 24h (never on every 30s poll): Windows via Test-Path/Import-Certificate against Cert:\LocalMachine\Root; Linux via ca-certificates anchors (Debian update-ca-certificates / RHEL update-ca-trust extract) - auto-installs missing roots when auto_install is set, otherwise reports NOT_TRUSTED; statuses (TRUSTED/INSTALLED/NOT_TRUSTED/ERROR_INSTALL/ UNSUPPORTED_OS) post on change to the new update-private-ca-status endpoint - echoes (ca_id, root_sha256, auto_install, status) in the poll request so the server can diff and short-circuit with 204 as usual Co-Authored-By: Claude Fable 5 --- agent/agent.go | 11 ++ agent/truststore.go | 184 ++++++++++++++++++++++++++++++++ agent/truststore_linux.go | 104 ++++++++++++++++++ agent/truststore_other.go | 22 ++++ agent/truststore_windows.go | 78 ++++++++++++++ api/config-poller.go | 40 ++++++- api/update-private-ca-status.go | 85 +++++++++++++++ config/config.go | 12 +++ 8 files changed, 531 insertions(+), 5 deletions(-) create mode 100644 agent/truststore.go create mode 100644 agent/truststore_linux.go create mode 100644 agent/truststore_other.go create mode 100644 agent/truststore_windows.go create mode 100644 api/update-private-ca-status.go diff --git a/agent/agent.go b/agent/agent.go index 594a57d..163a936 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -64,6 +64,13 @@ func PollAndSync(forceSync bool) { reportAgentError(fmt.Errorf("update status: %w", err), "", "") } } + + caStatuses := SynchronizePrivateCAs() + if len(caStatuses) > 0 { + if err := api.UpdatePrivateCaStatus(caStatuses); err != nil { + reportAgentError(fmt.Errorf("update private ca status: %w", err), "", "") + } + } } func NeedsRegistration() bool { @@ -132,6 +139,10 @@ func PollForConfiguration(forceSync bool) (configChanges map[string]ConfigChange removeKeystoreConfig() } + // Like the keystore config, private CA updates apply even when the agent + // is locked. The poll response list is authoritative on a full response. + applyPrivateCAUpdates(response.PrivateCAs) + if response.LockRequested && !isLocked { if err := config.CreateLockFile(config.CurrentPath); err != nil { return nil, err diff --git a/agent/truststore.go b/agent/truststore.go new file mode 100644 index 0000000..50548ae --- /dev/null +++ b/agent/truststore.go @@ -0,0 +1,184 @@ +package agent + +import ( + "fmt" + "log" + "strings" + "time" + + "github.com/certkit-io/certkit-agent/api" + "github.com/certkit-io/certkit-agent/config" +) + +const ( + caStatusTrusted = "TRUSTED" + caStatusInstalled = "INSTALLED" + caStatusNotTrusted = "NOT_TRUSTED" + caStatusErrorInstall = "ERROR_INSTALL" + caStatusUnsupportedOS = "UNSUPPORTED_OS" +) + +const ( + privateCaRecheckInterval = 24 * time.Hour + privateCaMessageMaxLength = 500 +) + +// applyPrivateCAUpdates applies the server's private CA list as the +// authoritative set: entries not present are dropped from config (the root is +// never uninstalled from the OS trust store). Status fields are preserved for +// entries whose root is unchanged; a changed root or auto_install flag clears +// LastVerified so the CA is re-checked on the next synchronization. +func applyPrivateCAUpdates(incoming []api.PollResponsePrivateCA) { + previous := config.CurrentConfig.PrivateCAs + + previousByID := make(map[string]config.PrivateCAConfig, len(previous)) + for _, ca := range previous { + if ca.Id != "" { + previousByID[ca.Id] = ca + } + } + + updated := make([]config.PrivateCAConfig, 0, len(incoming)) + changed := false + for _, in := range incoming { + if in.Id == "" { + continue + } + + entry := config.PrivateCAConfig{ + Id: in.Id, + Name: in.Name, + RootCAPEM: in.RootCAPEM, + RootSHA256: in.RootSHA256, + AutoInstall: in.AutoInstall, + } + + prev, existed := previousByID[in.Id] + if existed && strings.EqualFold(prev.RootSHA256, in.RootSHA256) { + entry.LastStatus = prev.LastStatus + entry.InstalledByAgent = prev.InstalledByAgent + if prev.AutoInstall == in.AutoInstall { + entry.LastVerified = prev.LastVerified + } + } + + if !existed { + log.Printf("Private CA %s (%s) added by server", in.Id, in.Name) + changed = true + } else if prev.Name != in.Name || + prev.RootCAPEM != in.RootCAPEM || + !strings.EqualFold(prev.RootSHA256, in.RootSHA256) || + prev.AutoInstall != in.AutoInstall { + changed = true + } + + updated = append(updated, entry) + } + + for _, prev := range previous { + stillManaged := false + for _, ca := range updated { + if ca.Id == prev.Id { + stillManaged = true + break + } + } + if !stillManaged { + log.Printf("Private CA %s (%s) no longer managed by server; removing from config (root is left in the OS trust store)", prev.Id, prev.Name) + changed = true + } + } + + if !changed { + return + } + + config.CurrentConfig.PrivateCAs = updated + if err := config.SaveConfig(&config.CurrentConfig, config.CurrentPath); err != nil { + log.Printf("Error saving config after private CA update: %v", err) + } +} + +func SynchronizePrivateCAs() []api.AgentPrivateCaStatusUpdate { + statuses := make([]api.AgentPrivateCaStatusUpdate, 0, len(config.CurrentConfig.PrivateCAs)) + configDirty := false + now := time.Now().UTC() + + for i := range config.CurrentConfig.PrivateCAs { + ca := &config.CurrentConfig.PrivateCAs[i] + + if !shouldCheckPrivateCa(*ca, now) { + continue + } + + newStatus, message := checkPrivateCa(ca) + + verifiedAt := now + ca.LastVerified = &verifiedAt + configDirty = true + + if newStatus != ca.LastStatus { + statuses = append(statuses, api.AgentPrivateCaStatusUpdate{ + CaId: ca.Id, + Status: newStatus, + InstalledByAgent: ca.InstalledByAgent, + Message: message, + LastStatusDate: now, + }) + ca.LastStatus = newStatus + } + } + + if configDirty { + if err := config.SaveConfig(&config.CurrentConfig, config.CurrentPath); err != nil { + reportAgentError(err, "", "") + } + } + + return statuses +} + +func shouldCheckPrivateCa(ca config.PrivateCAConfig, now time.Time) bool { + if ca.LastStatus == caStatusErrorInstall { + return true + } + if ca.LastVerified == nil { + return true + } + return now.Sub(*ca.LastVerified) >= privateCaRecheckInterval +} + +func checkPrivateCa(ca *config.PrivateCAConfig) (status string, message string) { + if reason := privateCaTrustStoreUnsupportedReason(); reason != "" { + return caStatusUnsupportedOS, truncatePrivateCaMessage(reason) + } + + trusted, err := isRootCaTrusted(*ca) + if err != nil { + log.Printf("Error checking trust store for private CA %s: %v", ca.Id, err) + return caStatusErrorInstall, truncatePrivateCaMessage(fmt.Sprintf("Error checking trust store: %v", err)) + } + if trusted { + return caStatusTrusted, "" + } + if !ca.AutoInstall { + return caStatusNotTrusted, "" + } + + log.Printf("Installing private CA root %s (%s) into the OS trust store", ca.Id, ca.Name) + if err := installRootCa(*ca); err != nil { + log.Printf("Error installing private CA root %s: %v", ca.Id, err) + return caStatusErrorInstall, truncatePrivateCaMessage(fmt.Sprintf("Error installing root CA: %v", err)) + } + + ca.InstalledByAgent = true + log.Printf("Installed private CA root %s (%s) into the OS trust store", ca.Id, ca.Name) + return caStatusInstalled, "" +} + +func truncatePrivateCaMessage(message string) string { + if len(message) <= privateCaMessageMaxLength { + return message + } + return message[:privateCaMessageMaxLength] +} diff --git a/agent/truststore_linux.go b/agent/truststore_linux.go new file mode 100644 index 0000000..224ea53 --- /dev/null +++ b/agent/truststore_linux.go @@ -0,0 +1,104 @@ +//go:build linux + +package agent + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/certkit-io/certkit-agent/config" + "github.com/certkit-io/certkit-agent/utils" +) + +const ( + debianAnchorDir = "/usr/local/share/ca-certificates" + rhelAnchorDir = "/etc/pki/ca-trust/source/anchors" +) + +func privateCaTrustStoreUnsupportedReason() string { + if dirExists(debianAnchorDir) || dirExists(rhelAnchorDir) { + return "" + } + return fmt.Sprintf("no supported trust store found (neither %s nor %s exists)", debianAnchorDir, rhelAnchorDir) +} + +func isRootCaTrusted(ca config.PrivateCAConfig) (bool, error) { + anchorPath := anchorPathForCa(ca.Id) + + exists, err := utils.FileExists(anchorPath) + if err != nil { + return false, fmt.Errorf("stat anchor file %s: %w", anchorPath, err) + } + if !exists { + return false, nil + } + + data, err := os.ReadFile(anchorPath) + if err != nil { + return false, fmt.Errorf("read anchor file %s: %w", anchorPath, err) + } + + // A malformed anchor is treated as not trusted so auto-install rewrites it. + block, _ := pem.Decode(data) + if block == nil || block.Type != "CERTIFICATE" { + return false, nil + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false, nil + } + + sum := sha256.Sum256(cert.Raw) + return strings.EqualFold(hex.EncodeToString(sum[:]), ca.RootSHA256), nil +} + +func installRootCa(ca config.PrivateCAConfig) error { + anchorPath := anchorPathForCa(ca.Id) + + updateCmdName := "update-ca-certificates" + var updateCmdArgs []string + if !dirExists(debianAnchorDir) { + updateCmdName = "update-ca-trust" + updateCmdArgs = []string{"extract"} + } + + log.Printf("Writing private CA root to %s", anchorPath) + if err := utils.WriteFileAtomic(anchorPath, []byte(ca.RootCAPEM), 0o644); err != nil { + return fmt.Errorf("write anchor file %s: %w", anchorPath, err) + } + + cmd := exec.Command(updateCmdName, updateCmdArgs...) + out, err := cmd.CombinedOutput() + output := strings.TrimSpace(string(out)) + if output != "" { + log.Printf("%s output:\n%s", updateCmdName, output) + } + if err != nil { + if output != "" { + return fmt.Errorf("%s failed: %w: %s", updateCmdName, err, output) + } + return fmt.Errorf("%s failed: %w", updateCmdName, err) + } + + return nil +} + +func anchorPathForCa(caId string) string { + if dirExists(debianAnchorDir) { + return filepath.Join(debianAnchorDir, "certkit-"+caId+".crt") + } + return filepath.Join(rhelAnchorDir, "certkit-"+caId+".pem") +} + +func dirExists(path string) bool { + fi, err := os.Stat(path) + return err == nil && fi.IsDir() +} diff --git a/agent/truststore_other.go b/agent/truststore_other.go new file mode 100644 index 0000000..93e8fbd --- /dev/null +++ b/agent/truststore_other.go @@ -0,0 +1,22 @@ +//go:build !windows && !linux + +package agent + +import ( + "fmt" + "runtime" + + "github.com/certkit-io/certkit-agent/config" +) + +func privateCaTrustStoreUnsupportedReason() string { + return fmt.Sprintf("private CA trust store management is not supported on %s", runtime.GOOS) +} + +func isRootCaTrusted(_ config.PrivateCAConfig) (bool, error) { + return false, fmt.Errorf("private CA trust store management is not supported on %s", runtime.GOOS) +} + +func installRootCa(_ config.PrivateCAConfig) error { + return fmt.Errorf("private CA trust store management is not supported on %s", runtime.GOOS) +} diff --git a/agent/truststore_windows.go b/agent/truststore_windows.go new file mode 100644 index 0000000..0d2f863 --- /dev/null +++ b/agent/truststore_windows.go @@ -0,0 +1,78 @@ +//go:build windows + +package agent + +import ( + "crypto/sha1" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "os" + "strings" + + "github.com/certkit-io/certkit-agent/config" + "github.com/certkit-io/certkit-agent/utils" +) + +func privateCaTrustStoreUnsupportedReason() string { + return "" +} + +func isRootCaTrusted(ca config.PrivateCAConfig) (bool, error) { + thumbprint, err := rootCaThumbprint(ca.RootCAPEM) + if err != nil { + return false, err + } + + script := fmt.Sprintf(` +$thumb = '%s' +Test-Path ("Cert:\LocalMachine\Root\" + $thumb) +`, escapePowerShellString(thumbprint)) + out, err := utils.RunPowerShell(script) + logPowerShellOutput("isRootCaTrusted", out) + if err != nil { + return false, err + } + return strings.EqualFold(strings.TrimSpace(out), "True"), nil +} + +func installRootCa(ca config.PrivateCAConfig) error { + tempFile, err := os.CreateTemp("", "certkit-*.crt") + if err != nil { + return fmt.Errorf("create temp crt: %w", err) + } + tempPath := tempFile.Name() + defer os.Remove(tempPath) + + if _, err := tempFile.Write([]byte(ca.RootCAPEM)); err != nil { + _ = tempFile.Close() + return fmt.Errorf("write temp crt: %w", err) + } + if err := tempFile.Close(); err != nil { + return fmt.Errorf("close temp crt: %w", err) + } + + script := fmt.Sprintf(` +Import-Certificate -FilePath '%s' -CertStoreLocation Cert:\LocalMachine\Root | Out-Null +`, escapePowerShellString(tempPath)) + out, err := utils.RunPowerShell(script) + logPowerShellOutput("installRootCa", out) + if err != nil { + return fmt.Errorf("import root CA certificate: %w", err) + } + return nil +} + +func rootCaThumbprint(rootCaPem string) (string, error) { + block, _ := pem.Decode([]byte(rootCaPem)) + if block == nil || block.Type != "CERTIFICATE" { + return "", fmt.Errorf("no certificate block found in root CA PEM") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", fmt.Errorf("parse root CA certificate: %w", err) + } + sum := sha1.Sum(cert.Raw) + return strings.ToUpper(hex.EncodeToString(sum[:])), nil +} diff --git a/api/config-poller.go b/api/config-poller.go index 56a1e91..d50a6e5 100644 --- a/api/config-poller.go +++ b/api/config-poller.go @@ -15,6 +15,7 @@ import ( type ConfigurationPollRequest struct { CertificateConfigurations []PollRequestCertificateConfig `json:"certificate_configurations"` + PrivateCAs []PollRequestPrivateCA `json:"private_cas,omitempty"` IsLocked bool `json:"is_locked"` ForceFullSync bool `json:"force_full_sync,omitempty"` } @@ -26,10 +27,26 @@ type PollRequestCertificateConfig struct { LatestCertificateSha1 string `json:"latest_certificate_sha1"` } +type PollRequestPrivateCA struct { + Id string `json:"ca_id"` + RootSHA256 string `json:"root_sha256"` + AutoInstall bool `json:"auto_install"` + Status string `json:"status,omitempty"` +} + +type PollResponsePrivateCA struct { + Id string `json:"ca_id"` + Name string `json:"name"` + RootCAPEM string `json:"root_ca_pem"` + RootSHA256 string `json:"root_sha256"` + AutoInstall bool `json:"auto_install"` +} + // ConfigurationPollResponse is the agent-facing decoded poll response. // VariablesByConfigId is memory-only — json:"-" prevents accidental serialization. type ConfigurationPollResponse struct { UpdatedCertificateConfigurations []config.CertificateConfiguration `json:"updated_certificate_configurations"` + PrivateCAs []PollResponsePrivateCA `json:"private_cas,omitempty"` LockRequested bool `json:"lock_requested"` Keystore *config.KeystoreConfig `json:"keystore,omitempty"` UpdateAvailable *UpdateSignal `json:"update_available,omitempty"` @@ -46,11 +63,12 @@ type pollResponseConfig struct { } type pollResponseWire struct { - UpdatedCertificateConfigurations []pollResponseConfig `json:"updated_certificate_configurations"` - LockRequested bool `json:"lock_requested"` - Keystore *config.KeystoreConfig `json:"keystore,omitempty"` - UpdateAvailable *UpdateSignal `json:"update_available,omitempty"` - ForceAutodiscover bool `json:"force_autodiscover,omitempty"` + UpdatedCertificateConfigurations []pollResponseConfig `json:"updated_certificate_configurations"` + PrivateCAs []PollResponsePrivateCA `json:"private_cas,omitempty"` + LockRequested bool `json:"lock_requested"` + Keystore *config.KeystoreConfig `json:"keystore,omitempty"` + UpdateAvailable *UpdateSignal `json:"update_available,omitempty"` + ForceAutodiscover bool `json:"force_autodiscover,omitempty"` } type UpdateSignal struct { @@ -82,6 +100,16 @@ func PollForConfiguration(forceFullSync bool) (*ConfigurationPollResponse, error }) } + requestPrivateCAs := make([]PollRequestPrivateCA, 0, len(config.CurrentConfig.PrivateCAs)) + for _, ca := range config.CurrentConfig.PrivateCAs { + requestPrivateCAs = append(requestPrivateCAs, PollRequestPrivateCA{ + Id: ca.Id, + RootSHA256: ca.RootSHA256, + AutoInstall: ca.AutoInstall, + Status: ca.LastStatus, + }) + } + isLocked, err := config.IsLocked(config.CurrentPath) if err != nil { return nil, fmt.Errorf("check lock file: %w", err) @@ -89,6 +117,7 @@ func PollForConfiguration(forceFullSync bool) (*ConfigurationPollResponse, error payload := ConfigurationPollRequest{ CertificateConfigurations: requestConfigs, + PrivateCAs: requestPrivateCAs, IsLocked: isLocked, ForceFullSync: forceFullSync, } @@ -159,6 +188,7 @@ func PollForConfiguration(forceFullSync bool) (*ConfigurationPollResponse, error return &ConfigurationPollResponse{ UpdatedCertificateConfigurations: configs, + PrivateCAs: wire.PrivateCAs, LockRequested: wire.LockRequested, Keystore: wire.Keystore, UpdateAvailable: wire.UpdateAvailable, diff --git a/api/update-private-ca-status.go b/api/update-private-ca-status.go new file mode 100644 index 0000000..a4496c9 --- /dev/null +++ b/api/update-private-ca-status.go @@ -0,0 +1,85 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/certkit-io/certkit-agent/auth" + "github.com/certkit-io/certkit-agent/config" + "github.com/certkit-io/certkit-agent/utils" +) + +type AgentPrivateCaStatusUpdate struct { + CaId string `json:"ca_id"` + Status string `json:"status"` + InstalledByAgent bool `json:"installed_by_agent"` + Message string `json:"message,omitempty"` + LastStatusDate time.Time `json:"last_status_date"` +} + +type AgentPrivateCaStatusUpdateBatch struct { + Updates []AgentPrivateCaStatusUpdate `json:"updates"` +} + +func UpdatePrivateCaStatus(updates []AgentPrivateCaStatusUpdate) error { + if config.CurrentConfig.Agent == nil || config.CurrentConfig.Agent.AgentId == "" { + return fmt.Errorf("missing agent id") + } + if len(updates) == 0 { + return fmt.Errorf("no updates to send") + } + + payload := AgentPrivateCaStatusUpdateBatch{ + Updates: updates, + } + + requestBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal json: %w", err) + } + + req, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("%s/api/agent/v1/%s/update-private-ca-status", config.CurrentConfig.ApiBase, config.CurrentConfig.Agent.AgentId), + bytes.NewReader(requestBody), + ) + if err != nil { + return fmt.Errorf("new request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + privKey, err := config.CurrentConfig.Auth.KeyPair.DecodePrivateKey() + if err != nil { + return fmt.Errorf("decode private key: %w", err) + } + + if err := auth.SignRequest(req, config.CurrentConfig.Agent.AgentId, config.CurrentConfig.Version.Version, privKey, time.Now()); err != nil { + return fmt.Errorf("sign request: %w", err) + } + + resp, err := doRequest(newHTTPClient(), req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return nil + } + if resp.StatusCode == http.StatusForbidden { + utils.MarkAgentUnauthorized() + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + return fmt.Errorf("update private ca status failed: status=%d body=%s", resp.StatusCode, body) +} diff --git a/config/config.go b/config/config.go index c4c4445..eaf7dc3 100644 --- a/config/config.go +++ b/config/config.go @@ -23,6 +23,7 @@ type Config struct { Bootstrap *BootstrapCreds `json:"bootstrap,omitempty"` Agent *AgentCreds `json:"agent,omitempty"` CertificateConfigurations []CertificateConfiguration `json:"certificate_configurations,omitempty"` + PrivateCAs []PrivateCAConfig `json:"private_cas,omitempty"` Keystore *KeystoreConfig `json:"keystore,omitempty"` InventorySent bool `json:"inventory_sent,omitempty"` Auth *AuthCreds `json:"auth,omitempty"` @@ -68,6 +69,17 @@ type CertificateConfiguration struct { ConfigType string `json:"config_type"` } +type PrivateCAConfig struct { + Id string `json:"ca_id"` + Name string `json:"name"` + RootCAPEM string `json:"root_ca_pem"` + RootSHA256 string `json:"root_sha256"` + AutoInstall bool `json:"auto_install"` + LastStatus string `json:"last_status,omitempty"` + InstalledByAgent bool `json:"installed_by_agent,omitempty"` + LastVerified *time.Time `json:"last_verified,omitempty"` +} + func (c CertificateConfiguration) UsesWindowsCertStore() bool { ct := strings.ToLower(c.ConfigType) return ct == "windows-cert-store" || ct == "iis" || ct == "rdp" || ct == "rras" || ct == "direct-access" || ct == "exchange" From c4e66edc83e5fa058b071e2dcd664e0396bc8826 Mon Sep 17 00:00:00 2001 From: Eric Brandes Date: Tue, 21 Jul 2026 12:52:30 -0500 Subject: [PATCH 2/6] Better cert store detection in linux, handling more OS combinations --- agent/truststore.go | 2 +- agent/truststore_linux.go | 79 ++++++++++++++++++++++++++------------- 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/agent/truststore.go b/agent/truststore.go index 50548ae..aa81747 100644 --- a/agent/truststore.go +++ b/agent/truststore.go @@ -20,7 +20,7 @@ const ( const ( privateCaRecheckInterval = 24 * time.Hour - privateCaMessageMaxLength = 500 + privateCaMessageMaxLength = 2500 ) // applyPrivateCAUpdates applies the server's private CA list as the diff --git a/agent/truststore_linux.go b/agent/truststore_linux.go index 224ea53..529c103 100644 --- a/agent/truststore_linux.go +++ b/agent/truststore_linux.go @@ -18,20 +18,59 @@ import ( "github.com/certkit-io/certkit-agent/utils" ) -const ( - debianAnchorDir = "/usr/local/share/ca-certificates" - rhelAnchorDir = "/etc/pki/ca-trust/source/anchors" -) +// linuxTrustStore describes one distro family's OS trust store: where anchor +// certificates go and the command that rebuilds the system bundle from them. +type linuxTrustStore struct { + anchorDir string + anchorExt string + updateCmd string + updateArgs []string +} + +// A family matches only when both its anchor directory and its update command +// are present — both ship in that family's ca-certificates package. Matching +// on the pair is what keeps the families apart: SUSE's update-ca-certificates +// shares its name with Debian's but reads /etc/pki/trust/anchors, and an +// anchor directory alone doesn't prove the update tooling is installed +// (minimal container images). +var linuxTrustStores = []linuxTrustStore{ + {anchorDir: "/usr/local/share/ca-certificates", anchorExt: ".crt", updateCmd: "update-ca-certificates"}, // Debian/Ubuntu/Alpine (only .crt files are picked up) + {anchorDir: "/etc/pki/ca-trust/source/anchors", anchorExt: ".pem", updateCmd: "update-ca-trust", updateArgs: []string{"extract"}}, // RHEL/Fedora/Amazon Linux + {anchorDir: "/etc/pki/trust/anchors", anchorExt: ".pem", updateCmd: "update-ca-certificates"}, // SUSE/openSUSE + {anchorDir: "/etc/ca-certificates/trust-source/anchors", anchorExt: ".pem", updateCmd: "update-ca-trust"}, // Arch +} + +func detectTrustStore() *linuxTrustStore { + for i := range linuxTrustStores { + trustStore := &linuxTrustStores[i] + if !dirExists(trustStore.anchorDir) { + continue + } + if _, err := exec.LookPath(trustStore.updateCmd); err != nil { + continue + } + return trustStore + } + return nil +} + +func (ts *linuxTrustStore) anchorPath(caId string) string { + return filepath.Join(ts.anchorDir, "certkit-"+caId+ts.anchorExt) +} func privateCaTrustStoreUnsupportedReason() string { - if dirExists(debianAnchorDir) || dirExists(rhelAnchorDir) { + if detectTrustStore() != nil { return "" } - return fmt.Sprintf("no supported trust store found (neither %s nor %s exists)", debianAnchorDir, rhelAnchorDir) + return "no supported trust store found: no known anchor directory has its update command on PATH (is the ca-certificates package installed?)" } func isRootCaTrusted(ca config.PrivateCAConfig) (bool, error) { - anchorPath := anchorPathForCa(ca.Id) + trustStore := detectTrustStore() + if trustStore == nil { + return false, fmt.Errorf("no supported trust store found") + } + anchorPath := trustStore.anchorPath(ca.Id) exists, err := utils.FileExists(anchorPath) if err != nil { @@ -61,43 +100,33 @@ func isRootCaTrusted(ca config.PrivateCAConfig) (bool, error) { } func installRootCa(ca config.PrivateCAConfig) error { - anchorPath := anchorPathForCa(ca.Id) - - updateCmdName := "update-ca-certificates" - var updateCmdArgs []string - if !dirExists(debianAnchorDir) { - updateCmdName = "update-ca-trust" - updateCmdArgs = []string{"extract"} + trustStore := detectTrustStore() + if trustStore == nil { + return fmt.Errorf("no supported trust store found") } + anchorPath := trustStore.anchorPath(ca.Id) log.Printf("Writing private CA root to %s", anchorPath) if err := utils.WriteFileAtomic(anchorPath, []byte(ca.RootCAPEM), 0o644); err != nil { return fmt.Errorf("write anchor file %s: %w", anchorPath, err) } - cmd := exec.Command(updateCmdName, updateCmdArgs...) + cmd := exec.Command(trustStore.updateCmd, trustStore.updateArgs...) out, err := cmd.CombinedOutput() output := strings.TrimSpace(string(out)) if output != "" { - log.Printf("%s output:\n%s", updateCmdName, output) + log.Printf("%s output:\n%s", trustStore.updateCmd, output) } if err != nil { if output != "" { - return fmt.Errorf("%s failed: %w: %s", updateCmdName, err, output) + return fmt.Errorf("%s failed: %w: %s", trustStore.updateCmd, err, output) } - return fmt.Errorf("%s failed: %w", updateCmdName, err) + return fmt.Errorf("%s failed: %w", trustStore.updateCmd, err) } return nil } -func anchorPathForCa(caId string) string { - if dirExists(debianAnchorDir) { - return filepath.Join(debianAnchorDir, "certkit-"+caId+".crt") - } - return filepath.Join(rhelAnchorDir, "certkit-"+caId+".pem") -} - func dirExists(path string) bool { fi, err := os.Stat(path) return err == nil && fi.IsDir() From dc6b93ae5ed60f659cfd762a41ad65a98e4852f7 Mon Sep 17 00:00:00 2001 From: Eric Brandes Date: Tue, 21 Jul 2026 13:31:45 -0500 Subject: [PATCH 3/6] Adjust the /dev/ simple agent docker container to contain ca-certificates to help with private pki testing. --- dev/Dockerfile | 7 +++++++ dev/README.md | 2 ++ dev/docker-compose.yml | 4 ++-- 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 dev/Dockerfile diff --git a/dev/Dockerfile b/dev/Dockerfile new file mode 100644 index 0000000..07752ac --- /dev/null +++ b/dev/Dockerfile @@ -0,0 +1,7 @@ +FROM golang:1.24 + +# The private CA trust-store flow needs the Debian ca-certificates package: +# it provides /usr/local/share/ca-certificates and update-ca-certificates. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* diff --git a/dev/README.md b/dev/README.md index 15d86b8..e4cb2d3 100644 --- a/dev/README.md +++ b/dev/README.md @@ -6,6 +6,8 @@ can register and fetch certificates from the server. Files - `docker-compose.yml`: Compose definition for the agent container. +- `Dockerfile`: golang:1.24 plus the ca-certificates package so the + private CA trust-store flow (anchor dir + update-ca-certificates) works. - `config.json`: Generated at runtime (ignored by git). - `.env`: Local-only environment values for the container (ignored by git). diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 6efbfc5..d236d50 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -1,10 +1,10 @@ services: agent: - image: golang:1.24 + build: . hostname: "eric-rocks" working_dir: /app volumes: - ../:/app env_file: - ./.env - command: bash -c "go build -ldflags '-X main.version=v1.9.0' -o ./dist/certkit-agent ./cmd/certkit-agent && ./dist/certkit-agent run --config /app/dev/config.json" + command: bash -c "go build -ldflags '-X main.version=v1.12.0' -o ./dist/certkit-agent ./cmd/certkit-agent && ./dist/certkit-agent run --config /app/dev/config.json" From 55c5863647ea1f74d05d506744b75fbca9fa29cc Mon Sep 17 00:00:00 2001 From: Eric Brandes Date: Tue, 21 Jul 2026 13:43:30 -0500 Subject: [PATCH 4/6] Final final word on status preservation across agent restarts? Maybe. --- agent/agent.go | 6 +- agent/agent_test.go | 134 +++++++++++++++++++++++----- agent/synchronize.go | 52 ++++++----- agent/synchronize_windows_common.go | 17 ++-- agent/truststore.go | 11 ++- agent/truststore_test.go | 52 +++++++++++ 6 files changed, 212 insertions(+), 60 deletions(-) create mode 100644 agent/truststore_test.go diff --git a/agent/agent.go b/agent/agent.go index 163a936..343aa33 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -160,7 +160,7 @@ func PollForConfiguration(forceSync bool) (configChanges map[string]ConfigChange configChanges = applyLockedConfigUpdates(response.UpdatedCertificateConfigurations) } else { configChanges = detectChangedConfigs(config.CurrentConfig.CertificateConfigurations, response.UpdatedCertificateConfigurations) - preserveRetryableStatuses(config.CurrentConfig.CertificateConfigurations, response.UpdatedCertificateConfigurations) + preserveUnresolvedStatuses(config.CurrentConfig.CertificateConfigurations, response.UpdatedCertificateConfigurations) config.CurrentConfig.CertificateConfigurations = response.UpdatedCertificateConfigurations } @@ -214,7 +214,7 @@ func applyLockedConfigUpdates(updated []config.CertificateConfiguration) map[str return changedIDs } -func preserveRetryableStatuses(previousConfigurations, incomingConfigurations []config.CertificateConfiguration) { +func preserveUnresolvedStatuses(previousConfigurations, incomingConfigurations []config.CertificateConfiguration) { previousByID := make(map[string]config.CertificateConfiguration, len(previousConfigurations)) for _, cfg := range previousConfigurations { if cfg.Id != "" { @@ -227,7 +227,7 @@ func preserveRetryableStatuses(previousConfigurations, incomingConfigurations [] if !ok { continue } - if isRetryableStatus(prev.LastStatus) { + if isUnresolvedStatus(prev.LastStatus) { incomingConfigurations[i].LastStatus = prev.LastStatus } } diff --git a/agent/agent_test.go b/agent/agent_test.go index ab775c9..de27ad1 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -1,6 +1,16 @@ package agent import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha1" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "math/big" + "os" "path/filepath" "slices" "testing" @@ -251,12 +261,12 @@ func TestDetectChangedConfigs_EmptyIncomingListReturnsEmptyMap(t *testing.T) { } } -func TestPreserveRetryableStatuses_PreservesUpdateCommandFailure(t *testing.T) { +func TestPreserveUnresolvedStatuses_PreservesUpdateCommandFailure(t *testing.T) { incoming := []config.CertificateConfiguration{ {Id: "a", LastStatus: statusSynced}, } - preserveRetryableStatuses( + preserveUnresolvedStatuses( []config.CertificateConfiguration{ {Id: "a", LastStatus: statusErrorUpdateCmd}, }, @@ -268,12 +278,12 @@ func TestPreserveRetryableStatuses_PreservesUpdateCommandFailure(t *testing.T) { } } -func TestPreserveRetryableStatuses_DoesNotPreserveSynced(t *testing.T) { +func TestPreserveUnresolvedStatuses_DoesNotPreserveSynced(t *testing.T) { incoming := []config.CertificateConfiguration{ {Id: "a", LastStatus: ""}, } - preserveRetryableStatuses( + preserveUnresolvedStatuses( []config.CertificateConfiguration{ {Id: "a", LastStatus: statusSynced}, }, @@ -285,30 +295,43 @@ func TestPreserveRetryableStatuses_DoesNotPreserveSynced(t *testing.T) { } } -func TestIsRetryableStatus(t *testing.T) { - retryableStatuses := []string{ +func TestIsUnresolvedStatus(t *testing.T) { + unresolved := []string{ statusErrorUpdateCmd, statusWaitingWindow, - statusPendingSync, statusErrorGetCert, statusErrorWriteCert, statusErrorGeneral, } - for _, status := range retryableStatuses { - if !isRetryableStatus(status) { - t.Fatalf("isRetryableStatus(%q) = false, want true", status) + for _, status := range unresolved { + if !isUnresolvedStatus(status) { + t.Fatalf("isUnresolvedStatus(%q) = false, want true", status) } } for _, status := range []string{"", statusSynced} { - if isRetryableStatus(status) { - t.Fatalf("isRetryableStatus(%q) = true, want false", status) + if isUnresolvedStatus(status) { + t.Fatalf("isUnresolvedStatus(%q) = true, want false", status) } } } -func TestSynchronizeCertificates_ProcessesUpdateCommandFailureDuringNormalPoll(t *testing.T) { +func TestIsPendingWorkStatus(t *testing.T) { + if !isPendingWorkStatus(statusWaitingWindow) { + t.Fatalf("isPendingWorkStatus(%q) = false, want true", statusWaitingWindow) + } + + // Error statuses must NOT re-enter synchronization on an unchanged + // config — a failure sticks until the config is saved again on the app. + for _, status := range []string{"", statusSynced, statusErrorUpdateCmd, statusErrorGetCert, statusErrorWriteCert, statusErrorGeneral} { + if isPendingWorkStatus(status) { + t.Fatalf("isPendingWorkStatus(%q) = true, want false", status) + } + } +} + +func TestSynchronizeCertificates_DoesNotRetryFailureDuringNormalPoll(t *testing.T) { previousConfig := config.CurrentConfig previousPath := config.CurrentPath t.Cleanup(func() { @@ -329,15 +352,15 @@ func TestSynchronizeCertificates_ProcessesUpdateCommandFailureDuringNormalPoll(t statuses := SynchronizeCertificates(nil, false) - if len(statuses) != 1 { - t.Fatalf("len(statuses) = %d, want 1", len(statuses)) + if len(statuses) != 0 { + t.Fatalf("len(statuses) = %d, want 0 (failed config must not retry without a change)", len(statuses)) } - if statuses[0].Status != statusErrorGeneral { - t.Fatalf("Status = %q, want %q", statuses[0].Status, statusErrorGeneral) + if got := config.CurrentConfig.CertificateConfigurations[0].LastStatus; got != statusErrorUpdateCmd { + t.Fatalf("LastStatus = %q, want %q preserved", got, statusErrorUpdateCmd) } } -func TestSynchronizeCertificates_ReportsRepeatedErrorStatus(t *testing.T) { +func TestSynchronizeCertificates_ReportsRepeatedErrorStatusOnRetry(t *testing.T) { previousConfig := config.CurrentConfig previousPath := config.CurrentPath t.Cleanup(func() { @@ -350,8 +373,9 @@ func TestSynchronizeCertificates_ReportsRepeatedErrorStatus(t *testing.T) { CertificateConfigurations: []config.CertificateConfiguration{ { // Missing destination paths fail with ERROR_GENERAL, the same - // status the config is already in — the retry must still be - // reported so the backend sees the fresh message. + // status the config is already in — the retry (triggered by a + // config change) must still be reported so the backend sees + // the fresh message. Id: "a", CertificateId: "cert-a", LastStatus: statusErrorGeneral, @@ -359,7 +383,7 @@ func TestSynchronizeCertificates_ReportsRepeatedErrorStatus(t *testing.T) { }, } - statuses := SynchronizeCertificates(nil, false) + statuses := SynchronizeCertificates(map[string]ConfigChange{"a": {Changed: true}}, false) if len(statuses) != 1 { t.Fatalf("len(statuses) = %d, want 1", len(statuses)) @@ -369,6 +393,72 @@ func TestSynchronizeCertificates_ReportsRepeatedErrorStatus(t *testing.T) { } } +// writeSelfSignedCertPEM writes a freshly generated self-signed certificate to +// path and returns its SHA1 fingerprint in hex. +func writeSelfSignedCertPEM(t *testing.T, path string) string { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "certkit-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + if err := os.WriteFile(path, pemBytes, 0o600); err != nil { + t.Fatalf("write cert: %v", err) + } + sum := sha1.Sum(der) + return hex.EncodeToString(sum[:]) +} + +func TestSynchronizeCertificates_ForceSyncPreservesFailureWhenNothingToDo(t *testing.T) { + previousConfig := config.CurrentConfig + previousPath := config.CurrentPath + t.Cleanup(func() { + config.CurrentConfig = previousConfig + config.CurrentPath = previousPath + }) + + // The on-disk certificate matches the expected SHA1, so a force sync (as + // happens on every agent restart) has nothing to do. The standing update + // command failure must persist — no erroneous SYNCED may be pushed. + certPath := filepath.Join(t.TempDir(), "cert.pem") + certSha1 := writeSelfSignedCertPEM(t, certPath) + + config.CurrentPath = filepath.Join(t.TempDir(), "config.json") + config.CurrentConfig = config.Config{ + CertificateConfigurations: []config.CertificateConfiguration{ + { + Id: "a", + CertificateId: "cert-a", + PemDestination: certPath, + AllInOne: true, + LatestCertificateSha1: certSha1, + UpdateCmd: "echo hi", + LastStatus: statusErrorUpdateCmd, + }, + }, + } + + statuses := SynchronizeCertificates(nil, true) + + if len(statuses) != 0 { + t.Fatalf("len(statuses) = %d, want 0 (got %+v)", len(statuses), statuses) + } + if got := config.CurrentConfig.CertificateConfigurations[0].LastStatus; got != statusErrorUpdateCmd { + t.Fatalf("LastStatus = %q, want %q preserved", got, statusErrorUpdateCmd) + } +} + func TestIsErrorStatus(t *testing.T) { for _, status := range []string{statusErrorUpdateCmd, statusErrorGetCert, statusErrorWriteCert, statusErrorGeneral} { if !isErrorStatus(status) { @@ -376,7 +466,7 @@ func TestIsErrorStatus(t *testing.T) { } } - for _, status := range []string{"", statusSynced, statusPendingSync, statusWaitingWindow} { + for _, status := range []string{"", statusSynced, statusWaitingWindow} { if isErrorStatus(status) { t.Fatalf("isErrorStatus(%q) = true, want false", status) } diff --git a/agent/synchronize.go b/agent/synchronize.go index d5381ae..d76626d 100644 --- a/agent/synchronize.go +++ b/agent/synchronize.go @@ -20,7 +20,6 @@ import ( const ( statusSynced = "SYNCED" - statusPendingSync = "PENDING_SYNC" statusWaitingWindow = "WAITING_FOR_WINDOW" statusErrorUpdateCmd = "ERROR_UPDATE_CMD" statusErrorGetCert = "ERROR_GET_CERTS" @@ -36,7 +35,7 @@ func SynchronizeCertificates(configChanges map[string]ConfigChange, forceSync bo cfg := &config.CurrentConfig.CertificateConfigurations[i] change := configChanges[cfg.Id] - if !change.Changed && !forceSync && !isRetryableStatus(cfg.LastStatus) { + if !change.Changed && !forceSync && !isPendingWorkStatus(cfg.LastStatus) { continue } if change.Changed { @@ -66,18 +65,22 @@ func SynchronizeCertificates(configChanges map[string]ConfigChange, forceSync bo return statuses } -func isRetryableStatus(status string) bool { - switch status { - case statusErrorUpdateCmd, - statusWaitingWindow, - statusPendingSync, - statusErrorGetCert, - statusErrorWriteCert, - statusErrorGeneral: - return true - default: - return false - } +// isUnresolvedStatus reports whether a locally tracked status must survive +// the poll response replacing the configuration list. Error statuses persist +// so a failure is never forgotten (or masked by a later no-op SYNCED); +// WAITING_FOR_WINDOW persists because the work it describes hasn't happened +// yet. +func isUnresolvedStatus(status string) bool { + return isErrorStatus(status) || status == statusWaitingWindow +} + +// isPendingWorkStatus reports whether a status re-enters synchronization even +// though the configuration hasn't changed. Error statuses are deliberately +// excluded: a failure sticks until the configuration is saved again on the +// server or the certificate rotates, rather than re-running a failing update +// command on every poll cycle. +func isPendingWorkStatus(status string) bool { + return status == statusWaitingWindow } func isErrorStatus(status string) bool { @@ -132,11 +135,7 @@ func synchronizeCertificate(cfg config.CertificateConfiguration, change ConfigCh return synchronizeWindowsCertStoreCertificate(cfg, change) } - retryUpdateOnly := cfg.LastStatus == statusErrorUpdateCmd || cfg.LastStatus == statusWaitingWindow - retryFull := cfg.LastStatus == statusPendingSync || - cfg.LastStatus == statusErrorGetCert || - cfg.LastStatus == statusErrorWriteCert || - cfg.LastStatus == statusErrorGeneral + resumeApply := cfg.LastStatus == statusWaitingWindow isPfx := cfg.IsPfx isJks := cfg.IsJKS() @@ -159,8 +158,8 @@ func synchronizeCertificate(cfg config.CertificateConfiguration, change ConfigCh return status } - shouldFetch := needsFetch || change.FormatChanged || retryFull - needsApply := needsFetch || change.Changed || retryUpdateOnly || retryFull + shouldFetch := needsFetch || change.FormatChanged + needsApply := needsFetch || change.Changed || resumeApply if shouldFetch { if isJks { @@ -246,9 +245,7 @@ func synchronizeCertificate(cfg config.CertificateConfiguration, change ConfigCh if !needsFetch && change.Changed { log.Print("Running update cmd due to configuration change...") } - if (retryUpdateOnly || retryFull) && cfg.LastStatus != statusWaitingWindow { - log.Print("Retrying update command due to previous failure...") - } else if (retryUpdateOnly || retryFull) && cfg.LastStatus == statusWaitingWindow { + if resumeApply { log.Print("Running update command inside deploy window...") } if commandOutput, err := runUpdateCommand(cfg, getUpdateVariables(cfg.Id)); err != nil { @@ -260,6 +257,13 @@ func synchronizeCertificate(cfg config.CertificateConfiguration, change ConfigCh } } } else { + // Nothing was done, so a standing failure must not be overwritten + // with SYNCED — it sticks until the configuration is saved again on + // the server or the certificate rotates. + if isErrorStatus(cfg.LastStatus) { + log.Printf("Config %s unchanged since last failure (%s); leaving status as-is (config=%s).", cfg.Id, cfg.LastStatus, cfg.Id) + return api.AgentConfigStatusUpdate{} + } log.Printf("Synchronization checks complete. No action taken, everything up to date (config=%s).", cfg.Id) } diff --git a/agent/synchronize_windows_common.go b/agent/synchronize_windows_common.go index d64edb7..1dff381 100644 --- a/agent/synchronize_windows_common.go +++ b/agent/synchronize_windows_common.go @@ -37,11 +37,7 @@ func synchronizeWindowsServiceCert(cfg config.CertificateConfiguration, change C return status } - retryUpdateOnly := cfg.LastStatus == statusErrorUpdateCmd || cfg.LastStatus == statusWaitingWindow - retryFull := cfg.LastStatus == statusPendingSync || - cfg.LastStatus == statusErrorGetCert || - cfg.LastStatus == statusErrorWriteCert || - cfg.LastStatus == statusErrorGeneral + resumeApply := cfg.LastStatus == statusWaitingWindow exists, err := certInStore(thumbprint) if err != nil { @@ -51,8 +47,8 @@ func synchronizeWindowsServiceCert(cfg config.CertificateConfiguration, change C } needsFetch := !exists - shouldFetch := needsFetch || retryFull - shouldApply := needsFetch || change.Changed || retryUpdateOnly || retryFull + shouldFetch := needsFetch + shouldApply := needsFetch || change.Changed || resumeApply importedPfx := false if shouldFetch { @@ -116,6 +112,13 @@ func synchronizeWindowsServiceCert(cfg config.CertificateConfiguration, change C if importedPfx || appliedCert { log.Printf("%s synchronization complete for (config=%s). (imported_pfx=%t, applied_cert=%t)", svc.serviceName, cfg.Id, importedPfx, appliedCert) } else { + // Nothing was done, so a standing failure must not be overwritten + // with SYNCED — it sticks until the configuration is saved again on + // the server or the certificate rotates. + if isErrorStatus(cfg.LastStatus) { + log.Printf("%s configuration (config=%s) unchanged since last failure (%s); leaving status as-is.", svc.serviceName, cfg.Id, cfg.LastStatus) + return api.AgentConfigStatusUpdate{} + } log.Printf("%s configuration (config=%s) synchronization checks complete. No action taken, everything up to date.", svc.serviceName, cfg.Id) } diff --git a/agent/truststore.go b/agent/truststore.go index aa81747..ef076b3 100644 --- a/agent/truststore.go +++ b/agent/truststore.go @@ -117,7 +117,9 @@ func SynchronizePrivateCAs() []api.AgentPrivateCaStatusUpdate { ca.LastVerified = &verifiedAt configDirty = true - if newStatus != ca.LastStatus { + // Like certificate statuses, a repeated error is re-sent so a + // retried failure refreshes the message on the backend. + if newStatus != ca.LastStatus || newStatus == caStatusErrorInstall { statuses = append(statuses, api.AgentPrivateCaStatusUpdate{ CaId: ca.Id, Status: newStatus, @@ -138,10 +140,11 @@ func SynchronizePrivateCAs() []api.AgentPrivateCaStatusUpdate { return statuses } +// shouldCheckPrivateCa gates the trust store check. A failed install gets no +// fast retry: like certificate configurations, it re-runs only when the CA's +// configuration changes on the server (which clears LastVerified) or at the +// normal recheck interval — never on every poll cycle. func shouldCheckPrivateCa(ca config.PrivateCAConfig, now time.Time) bool { - if ca.LastStatus == caStatusErrorInstall { - return true - } if ca.LastVerified == nil { return true } diff --git a/agent/truststore_test.go b/agent/truststore_test.go new file mode 100644 index 0000000..4f961c5 --- /dev/null +++ b/agent/truststore_test.go @@ -0,0 +1,52 @@ +package agent + +import ( + "testing" + "time" + + "github.com/certkit-io/certkit-agent/config" +) + +func TestShouldCheckPrivateCa(t *testing.T) { + now := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC) + recent := now.Add(-time.Minute) + stale := now.Add(-privateCaRecheckInterval) + + tests := []struct { + name string + ca config.PrivateCAConfig + want bool + }{ + { + name: "never verified", + ca: config.PrivateCAConfig{}, + want: true, + }, + { + name: "recently verified", + ca: config.PrivateCAConfig{LastStatus: caStatusTrusted, LastVerified: &recent}, + want: false, + }, + { + name: "recheck interval elapsed", + ca: config.PrivateCAConfig{LastStatus: caStatusTrusted, LastVerified: &stale}, + want: true, + }, + { + // A failed install must not fast-retry on every poll: it waits + // for a server-side config change (which clears LastVerified) or + // the normal recheck interval. + name: "recent install failure", + ca: config.PrivateCAConfig{LastStatus: caStatusErrorInstall, LastVerified: &recent}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldCheckPrivateCa(tt.ca, now); got != tt.want { + t.Fatalf("shouldCheckPrivateCa(%s) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} From 7b1d81aa993f7c53a561bd7260f54172964e1fe3 Mon Sep 17 00:00:00 2001 From: Eric Brandes Date: Tue, 21 Jul 2026 13:49:05 -0500 Subject: [PATCH 5/6] Reorder CA sync vs cert sync --- agent/agent.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 343aa33..4f751f7 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -58,19 +58,22 @@ func PollAndSync(forceSync bool) { return } - statuses := SynchronizeCertificates(configChanges, forceSync) - if len(statuses) > 0 { - if err := api.UpdateConfigStatus(statuses); err != nil { - reportAgentError(fmt.Errorf("update status: %w", err), "", "") - } - } - + // Private CA roots install before certificates deploy so that update + // commands (and the services they reload) can already trust chains + // issued by those CAs on the very first synchronization. caStatuses := SynchronizePrivateCAs() if len(caStatuses) > 0 { if err := api.UpdatePrivateCaStatus(caStatuses); err != nil { reportAgentError(fmt.Errorf("update private ca status: %w", err), "", "") } } + + statuses := SynchronizeCertificates(configChanges, forceSync) + if len(statuses) > 0 { + if err := api.UpdateConfigStatus(statuses); err != nil { + reportAgentError(fmt.Errorf("update status: %w", err), "", "") + } + } } func NeedsRegistration() bool { From dd2e81ef906811f7e4b3d9bb83f535ad92efe5c8 Mon Sep 17 00:00:00 2001 From: Eric Brandes Date: Fri, 24 Jul 2026 09:08:29 -0500 Subject: [PATCH 6/6] Bump IIS site count sanity check --- inventory/iis_windows.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inventory/iis_windows.go b/inventory/iis_windows.go index e4929ea..1e2c349 100644 --- a/inventory/iis_windows.go +++ b/inventory/iis_windows.go @@ -106,7 +106,7 @@ Import-Module WebAdministration } } } | - Select-Object -First 250 + Select-Object -First 500 ) | ConvertTo-Json ` out, err := utils.RunPowerShell(script)