diff --git a/certificate-agent-config.yaml b/certificate-agent-config.yaml index 7a81f4e2..e549b5f4 100644 --- a/certificate-agent-config.yaml +++ b/certificate-agent-config.yaml @@ -56,12 +56,16 @@ certificates: permission: "0644" omit-root: true - # Fetch an existing certificate by ID. Skips issuance entirely. - # If private-key.path is configured but the certificate has no private key - # (e.g. ACME-issued), a warning is logged and the file is skipped. + # Distribution mode: deliver a certificate that already exists in Infisical instead of issuing one, + # so several hosts can share it. https://infisical.com/docs/integrations/platforms/certificate-agent # - certificate-id: "00000000-0000-0000-0000-000000000000" # lifecycle: # status-check-interval: "6h" + # use-latest: true # Also deliver renewals made in Infisical + # post-hooks: + # on-renewal: + # command: "systemctl reload nginx" + # timeout: 30 # file-output: # certificate: # path: "./certs/existing/certificate.crt" diff --git a/e2e/agent/agent_helpers.go b/e2e/agent/agent_helpers.go index f28ff1ef..09f32126 100644 --- a/e2e/agent/agent_helpers.go +++ b/e2e/agent/agent_helpers.go @@ -568,7 +568,8 @@ type agentUniversalAuthConfig struct { type agentCertificateConfig struct { ProjectSlug string `yaml:"project-slug,omitempty"` ApplicationName string `yaml:"application-name,omitempty"` - ProfileName string `yaml:"profile-name"` + ProfileName string `yaml:"profile-name,omitempty"` + CertificateID string `yaml:"certificate-id,omitempty"` CSR string `yaml:"csr,omitempty"` CSRPath string `yaml:"csr-path,omitempty"` Attributes *agentCertificateAttributes `yaml:"attributes,omitempty"` @@ -588,8 +589,9 @@ type agentCertificateAttributes struct { } type agentCertificateLifecycle struct { - RenewBeforeExpiry string `yaml:"renew-before-expiry"` - StatusCheckInterval string `yaml:"status-check-interval"` + RenewBeforeExpiry string `yaml:"renew-before-expiry,omitempty"` + StatusCheckInterval string `yaml:"status-check-interval,omitempty"` + UseLatest bool `yaml:"use-latest,omitempty"` } type agentCertificateFileOutput struct { @@ -623,6 +625,7 @@ func (h *CertAgentTestHelper) GenerateAgentConfig(opts AgentConfigOptions) strin ProjectSlug: cert.ProjectSlug, ApplicationName: cert.ApplicationName, ProfileName: cert.ProfileSlug, + CertificateID: cert.CertificateID, CSR: cert.CSR, CSRPath: cert.CSRPath, Attributes: &agentCertificateAttributes{ @@ -637,6 +640,7 @@ func (h *CertAgentTestHelper) GenerateAgentConfig(opts AgentConfigOptions) strin Lifecycle: agentCertificateLifecycle{ RenewBeforeExpiry: cert.RenewBeforeExpiry, StatusCheckInterval: cert.StatusCheckInterval, + UseLatest: cert.UseLatest, }, FileOutput: agentCertificateFileOutput{ Certificate: agentFileOutputEntry{Path: cert.CertPath, Permission: cert.CertPermission}, @@ -658,6 +662,10 @@ func (h *CertAgentTestHelper) GenerateAgentConfig(opts AgentConfigOptions) strin } } + if cert.CertificateID != "" { + c.Attributes = nil + } + certs = append(certs, c) } @@ -717,6 +725,8 @@ type CertificateConfigEntry struct { KeyPermission string ChainPermission string PostHookOnFailure string + CertificateID string + UseLatest bool CSR string CSRPath string KeyAlgorithm string @@ -1015,3 +1025,64 @@ func GenerateCSR(t *testing.T, commonName string) (csrPEM string, keyPEM string) return string(csrBuf), string(keyBuf) } + +func (h *CertAgentTestHelper) IssueCertificateDirectly(commonName string) string { + t := h.T + + respBody := h.doRequestWithToken("POST", "/v1/cert-manager/certificates", map[string]interface{}{ + "profileId": h.ProfileID, + "attributes": map[string]interface{}{ + "commonName": commonName, + "keyAlgorithm": "RSA_2048", + "signatureAlgorithm": "RSA-SHA256", + "ttl": "30d", + }, + }, h.IdentityToken) + + var parsed struct { + Certificate *struct { + CertificateID string `json:"certificateId"` + } `json:"certificate"` + } + require.NoError(t, json.Unmarshal(respBody, &parsed)) + require.NotNil(t, parsed.Certificate, "issue response did not include a certificate: %s", string(respBody)) + require.NotEmpty(t, parsed.Certificate.CertificateID) + + return parsed.Certificate.CertificateID +} + +func (h *CertAgentTestHelper) RenewCertificateDirectly(certificateID string) string { + t := h.T + + respBody := h.doRequestWithToken("POST", "/v1/cert-manager/certificates/"+certificateID+"/renew", map[string]interface{}{}, h.IdentityToken) + + var parsed struct { + CertificateID string `json:"certificateId"` + } + require.NoError(t, json.Unmarshal(respBody, &parsed)) + require.NotEmpty(t, parsed.CertificateID, "renew response did not include a certificate id: %s", string(respBody)) + + return parsed.CertificateID +} + +func (h *CertAgentTestHelper) RevokeCertificateDirectly(certificateID string) { + h.doRequestWithToken("POST", "/v1/cert-manager/certificates/"+certificateID+"/revoke", map[string]interface{}{ + "revocationReason": "UNSPECIFIED", + }, h.IdentityToken) +} + +func (h *CertAgentTestHelper) CertificateSerialNumber(certificateID string) string { + t := h.T + + respBody := h.doRequestWithToken("GET", "/v1/cert-manager/certificates/"+certificateID, nil, h.IdentityToken) + + var parsed struct { + Certificate struct { + SerialNumber string `json:"serialNumber"` + } `json:"certificate"` + } + require.NoError(t, json.Unmarshal(respBody, &parsed)) + require.NotEmpty(t, parsed.Certificate.SerialNumber) + + return parsed.Certificate.SerialNumber +} diff --git a/e2e/agent/certificate_test.go b/e2e/agent/certificate_test.go index 92421eed..edb80b9b 100644 --- a/e2e/agent/certificate_test.go +++ b/e2e/agent/certificate_test.go @@ -2,6 +2,8 @@ package agent_test import ( "context" + "crypto/x509" + "encoding/pem" "fmt" "log/slog" "net/http" @@ -2113,6 +2115,426 @@ func certAgent_V2ValidationRejectsProjectSlug(t *testing.T) { "Agent should reject v2 config that carries project-slug. stderr:\n%s", cmd.Stderr()) } +func certAgent_Distribution_FetchesExistingCertificate(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + helper := setupCertAgentTest(t, ctx, + agentHelpers.WithAllowKeyAlgorithms("RSA_2048"), + agentHelpers.WithAllowSignatureAlgorithms("SHA256-RSA"), + ) + + certificateID := helper.IssueCertificateDirectly("shared.example.com") + serialNumber := normalizeSerial(helper.CertificateSerialNumber(certificateID)) + + certDir := filepath.Join(helper.TempDir, "certs") + require.NoError(t, os.MkdirAll(certDir, 0755)) + certPath, keyPath, chainPath := agentHelpers.CertFilePaths(certDir) + + clientIDPath, clientSecretPath := helper.WriteCredentialFiles() + + configPath := helper.GenerateAgentConfig(agentHelpers.AgentConfigOptions{ + ClientIDPath: clientIDPath, + ClientSecretPath: clientSecretPath, + Certificates: []agentHelpers.CertificateConfigEntry{ + { + CertificateID: certificateID, + StatusCheckInterval: "5s", + CertPath: certPath, + KeyPath: keyPath, + ChainPath: chainPath, + }, + }, + }) + + cmd := helpers.Command{ + Test: t, + Args: []string{"cert-manager", "agent", "--config", configPath, "--verbose"}, + Env: map[string]string{}, + } + cmd.Start(ctx) + t.Cleanup(func() { + if t.Failed() { + t.Logf("Agent stderr:\n%s", cmd.Stderr()) + } + cmd.Stop() + }) + + result := helpers.WaitForStderr(t, helpers.WaitForStderrOptions{ + EnsureCmdRunning: &cmd, + ExpectedString: "certificate fetched successfully", + Timeout: 120 * time.Second, + Interval: 2 * time.Second, + }) + require.Equal(t, helpers.WaitSuccess, result, "Certificate was not fetched. stderr:\n%s", cmd.Stderr()) + + require.NotContains(t, cmd.Stderr(), "certificate issued successfully", "Distribution mode must not issue a new certificate") + + agentHelpers.VerifyCertificateFile(t, certPath) + agentHelpers.VerifyPrivateKeyFile(t, keyPath) + agentHelpers.VerifyChainFile(t, chainPath) + agentHelpers.VerifyCertificateCommonName(t, certPath, "shared.example.com") + require.Equal(t, serialNumber, readCertificateSerial(t, certPath), "Agent wrote a different certificate than the one it was pointed at") +} + +func certAgent_Distribution_FollowsServerSideRenewal(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + helper := setupCertAgentTest(t, ctx, + agentHelpers.WithAllowKeyAlgorithms("RSA_2048"), + agentHelpers.WithAllowSignatureAlgorithms("SHA256-RSA"), + ) + + certificateID := helper.IssueCertificateDirectly("followed.example.com") + + certDir := filepath.Join(helper.TempDir, "certs") + require.NoError(t, os.MkdirAll(certDir, 0755)) + certPath, keyPath, chainPath := agentHelpers.CertFilePaths(certDir) + + renewalMarker := filepath.Join(helper.TempDir, "renewal-hook.txt") + clientIDPath, clientSecretPath := helper.WriteCredentialFiles() + + configPath := helper.GenerateAgentConfig(agentHelpers.AgentConfigOptions{ + ClientIDPath: clientIDPath, + ClientSecretPath: clientSecretPath, + Certificates: []agentHelpers.CertificateConfigEntry{ + { + CertificateID: certificateID, + UseLatest: true, + StatusCheckInterval: "5s", + CertPath: certPath, + KeyPath: keyPath, + ChainPath: chainPath, + PostHookOnRenewal: fmt.Sprintf("touch %s", renewalMarker), + }, + }, + }) + + cmd := helpers.Command{ + Test: t, + Args: []string{"cert-manager", "agent", "--config", configPath, "--verbose"}, + Env: map[string]string{ + "PATH": os.Getenv("PATH"), + }, + } + cmd.Start(ctx) + t.Cleanup(func() { + if t.Failed() { + t.Logf("Agent stderr:\n%s", cmd.Stderr()) + } + cmd.Stop() + }) + + result := helpers.WaitForStderr(t, helpers.WaitForStderrOptions{ + EnsureCmdRunning: &cmd, + ExpectedString: "certificate fetched successfully", + Timeout: 120 * time.Second, + Interval: 2 * time.Second, + }) + require.Equal(t, helpers.WaitSuccess, result, "Initial fetch did not complete. stderr:\n%s", cmd.Stderr()) + + initialSerial := readCertificateSerial(t, certPath) + + renewedID := helper.RenewCertificateDirectly(certificateID) + require.NotEqual(t, certificateID, renewedID) + renewedSerial := normalizeSerial(helper.CertificateSerialNumber(renewedID)) + + waitResult := helpers.WaitFor(t, helpers.WaitForOptions{ + EnsureCmdRunning: &cmd, + Timeout: 120 * time.Second, + Interval: 3 * time.Second, + Condition: func() helpers.ConditionResult { + if readCertificateSerial(t, certPath) == renewedSerial { + return helpers.ConditionSuccess + } + return helpers.ConditionWait + }, + }) + require.Equal(t, helpers.WaitSuccess, waitResult, "Agent did not pick up the server-side renewal. stderr:\n%s", cmd.Stderr()) + + require.NotEqual(t, initialSerial, renewedSerial) + require.FileExists(t, renewalMarker, "on-renewal post-hook should run when a renewal is picked up") + agentHelpers.VerifyCertificateFile(t, certPath) + agentHelpers.VerifyPrivateKeyFile(t, keyPath) +} + +func certAgent_Distribution_StaysPinnedWithoutUseLatest(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + helper := setupCertAgentTest(t, ctx, + agentHelpers.WithAllowKeyAlgorithms("RSA_2048"), + agentHelpers.WithAllowSignatureAlgorithms("SHA256-RSA"), + ) + + certificateID := helper.IssueCertificateDirectly("pinned.example.com") + pinnedSerial := normalizeSerial(helper.CertificateSerialNumber(certificateID)) + + certDir := filepath.Join(helper.TempDir, "certs") + require.NoError(t, os.MkdirAll(certDir, 0755)) + certPath, keyPath, chainPath := agentHelpers.CertFilePaths(certDir) + + clientIDPath, clientSecretPath := helper.WriteCredentialFiles() + + configPath := helper.GenerateAgentConfig(agentHelpers.AgentConfigOptions{ + ClientIDPath: clientIDPath, + ClientSecretPath: clientSecretPath, + Certificates: []agentHelpers.CertificateConfigEntry{ + { + CertificateID: certificateID, + StatusCheckInterval: "5s", + CertPath: certPath, + KeyPath: keyPath, + ChainPath: chainPath, + }, + }, + }) + + cmd := helpers.Command{ + Test: t, + Args: []string{"cert-manager", "agent", "--config", configPath, "--verbose"}, + Env: map[string]string{}, + } + cmd.Start(ctx) + t.Cleanup(func() { + if t.Failed() { + t.Logf("Agent stderr:\n%s", cmd.Stderr()) + } + cmd.Stop() + }) + + result := helpers.WaitForStderr(t, helpers.WaitForStderrOptions{ + EnsureCmdRunning: &cmd, + ExpectedString: "certificate fetched successfully", + Timeout: 120 * time.Second, + Interval: 2 * time.Second, + }) + require.Equal(t, helpers.WaitSuccess, result, "Initial fetch did not complete. stderr:\n%s", cmd.Stderr()) + + helper.RenewCertificateDirectly(certificateID) + + time.Sleep(20 * time.Second) + + require.Equal(t, pinnedSerial, readCertificateSerial(t, certPath), + "Without use-latest the agent must keep serving the pinned certificate") +} + +func certAgent_Distribution_ReportsRevokedCertificate(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + helper := setupCertAgentTest(t, ctx, + agentHelpers.WithAllowKeyAlgorithms("RSA_2048"), + agentHelpers.WithAllowSignatureAlgorithms("SHA256-RSA"), + ) + + certificateID := helper.IssueCertificateDirectly("revoked.example.com") + helper.RevokeCertificateDirectly(certificateID) + + certDir := filepath.Join(helper.TempDir, "certs") + require.NoError(t, os.MkdirAll(certDir, 0755)) + certPath, keyPath, chainPath := agentHelpers.CertFilePaths(certDir) + + clientIDPath, clientSecretPath := helper.WriteCredentialFiles() + + configPath := helper.GenerateAgentConfig(agentHelpers.AgentConfigOptions{ + ClientIDPath: clientIDPath, + ClientSecretPath: clientSecretPath, + Certificates: []agentHelpers.CertificateConfigEntry{ + { + CertificateID: certificateID, + UseLatest: true, + StatusCheckInterval: "5s", + CertPath: certPath, + KeyPath: keyPath, + ChainPath: chainPath, + }, + }, + }) + + cmd := helpers.Command{ + Test: t, + Args: []string{"cert-manager", "agent", "--config", configPath, "--verbose"}, + Env: map[string]string{}, + } + cmd.Start(ctx) + t.Cleanup(func() { + if t.Failed() { + t.Logf("Agent stderr:\n%s", cmd.Stderr()) + } + cmd.Stop() + }) + + result := helpers.WaitForStderr(t, helpers.WaitForStderrOptions{ + EnsureCmdRunning: &cmd, + ExpectedString: "certificate is not active", + Timeout: 120 * time.Second, + Interval: 2 * time.Second, + }) + require.Equal(t, helpers.WaitSuccess, result, "Agent should report the revoked certificate. stderr:\n%s", cmd.Stderr()) + + require.NoFileExists(t, certPath, "A revoked certificate must not be written to disk") +} + +func certAgent_Distribution_RejectsUseLatestWithoutCertificateID(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + helper := setupCertAgentTest(t, ctx) + + certDir := filepath.Join(helper.TempDir, "certs") + require.NoError(t, os.MkdirAll(certDir, 0755)) + certPath, keyPath, chainPath := agentHelpers.CertFilePaths(certDir) + + clientIDPath, clientSecretPath := helper.WriteCredentialFiles() + + configPath := helper.GenerateAgentConfig(agentHelpers.AgentConfigOptions{ + ClientIDPath: clientIDPath, + ClientSecretPath: clientSecretPath, + Certificates: []agentHelpers.CertificateConfigEntry{ + { + ApplicationName: helper.ApplicationName, + ProfileSlug: helper.ProfileSlug, + CommonName: "invalid.example.com", + TTL: "1h", + UseLatest: true, + StatusCheckInterval: "5s", + CertPath: certPath, + KeyPath: keyPath, + ChainPath: chainPath, + }, + }, + }) + + cmd := helpers.Command{ + Test: t, + Args: []string{"cert-manager", "agent", "--config", configPath, "--verbose"}, + Env: map[string]string{}, + } + cmd.Start(ctx) + t.Cleanup(func() { + if t.Failed() { + t.Logf("Agent stderr:\n%s", cmd.Stderr()) + t.Logf("Agent stdout:\n%s", cmd.Stdout()) + } + cmd.Stop() + }) + + waitResult := helpers.WaitFor(t, helpers.WaitForOptions{ + Timeout: 30 * time.Second, + Interval: 2 * time.Second, + Condition: func() helpers.ConditionResult { + if strings.Contains(cmd.Stderr(), "'lifecycle.use-latest' is only supported together with 'certificate-id'") { + return helpers.ConditionSuccess + } + if !cmd.IsRunning() { + return helpers.ConditionBreakEarly + } + return helpers.ConditionWait + }, + }) + + require.True(t, waitResult == helpers.WaitSuccess || waitResult == helpers.WaitBreakEarly, + "Agent should reject use-latest without certificate-id. stderr:\n%s", cmd.Stderr()) + require.Contains(t, cmd.Stderr(), "'lifecycle.use-latest' is only supported together with 'certificate-id'") +} + +func certAgent_Distribution_WarnsOnRenewBeforeExpiryWithCertificateID(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + helper := setupCertAgentTest(t, ctx, + agentHelpers.WithAllowKeyAlgorithms("RSA_2048"), + agentHelpers.WithAllowSignatureAlgorithms("SHA256-RSA"), + ) + + certificateID := helper.IssueCertificateDirectly("norenew.example.com") + + certDir := filepath.Join(helper.TempDir, "certs") + require.NoError(t, os.MkdirAll(certDir, 0755)) + certPath, keyPath, chainPath := agentHelpers.CertFilePaths(certDir) + + clientIDPath, clientSecretPath := helper.WriteCredentialFiles() + + configPath := helper.GenerateAgentConfig(agentHelpers.AgentConfigOptions{ + ClientIDPath: clientIDPath, + ClientSecretPath: clientSecretPath, + Certificates: []agentHelpers.CertificateConfigEntry{ + { + CertificateID: certificateID, + RenewBeforeExpiry: "10d", + StatusCheckInterval: "5s", + CertPath: certPath, + KeyPath: keyPath, + ChainPath: chainPath, + }, + }, + }) + + cmd := helpers.Command{ + Test: t, + Args: []string{"cert-manager", "agent", "--config", configPath, "--verbose"}, + Env: map[string]string{}, + } + cmd.Start(ctx) + t.Cleanup(func() { + if t.Failed() { + t.Logf("Agent stderr:\n%s", cmd.Stderr()) + t.Logf("Agent stdout:\n%s", cmd.Stdout()) + } + cmd.Stop() + }) + + waitResult := helpers.WaitFor(t, helpers.WaitForOptions{ + Timeout: 30 * time.Second, + Interval: 2 * time.Second, + Condition: func() helpers.ConditionResult { + if strings.Contains(cmd.Stderr(), "'lifecycle.renew-before-expiry' is ignored when using 'certificate-id'") { + return helpers.ConditionSuccess + } + if !cmd.IsRunning() { + return helpers.ConditionBreakEarly + } + return helpers.ConditionWait + }, + }) + + require.Equal(t, helpers.WaitSuccess, waitResult, + "Agent should warn about renew-before-expiry and keep running. stderr:\n%s", cmd.Stderr()) + require.Contains(t, cmd.Stderr(), "use-latest", "The warning should point at the setting that does what they wanted") + require.FileExists(t, certPath, "The agent must keep working, since released versions accepted this field") +} + +func readCertificateSerial(t *testing.T, certPath string) string { + t.Helper() + + data, err := os.ReadFile(certPath) + if err != nil { + return "" + } + + block, _ := pem.Decode(data) + if block == nil { + return "" + } + + parsed, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "" + } + + return normalizeSerial(fmt.Sprintf("%x", parsed.SerialNumber)) +} + +func normalizeSerial(serial string) string { + trimmed := strings.TrimLeft(strings.ToLower(serial), "0") + if trimmed == "" { + return "0" + } + return trimmed +} + func TestCertAgent_InternalCA(t *testing.T) { t.Run("BasicCertificateIssuance", certAgent_BasicCertificateIssuance) t.Run("CertificateRenewal", certAgent_CertificateRenewal) @@ -2134,6 +2556,15 @@ func TestCertAgent_InternalCA(t *testing.T) { t.Run("V2ValidationRejectsProjectSlug", certAgent_V2ValidationRejectsProjectSlug) t.Run("OnRenewalPostHook", certAgent_OnRenewalPostHook) t.Run("SignatureAlgorithm", certAgent_SignatureAlgorithm) + t.Run("Distribution_FetchesExistingCertificate", certAgent_Distribution_FetchesExistingCertificate) + t.Run("Distribution_FollowsServerSideRenewal", certAgent_Distribution_FollowsServerSideRenewal) + t.Run("Distribution_StaysPinnedWithoutUseLatest", certAgent_Distribution_StaysPinnedWithoutUseLatest) + t.Run("Distribution_ReportsRevokedCertificate", certAgent_Distribution_ReportsRevokedCertificate) + t.Run("Distribution_RejectsUseLatestWithoutCertificateID", certAgent_Distribution_RejectsUseLatestWithoutCertificateID) + t.Run("Distribution_WarnsOnRenewBeforeExpiryWithCertificateID", certAgent_Distribution_WarnsOnRenewBeforeExpiryWithCertificateID) + t.Run("Distribution_RestartAfterRenewalRunsRenewalHook", certAgent_Distribution_RestartAfterRenewalRunsRenewalHook) + t.Run("Distribution_RestartWithoutChangeIsQuiet", certAgent_Distribution_RestartWithoutChangeIsQuiet) + t.Run("Distribution_RevokedLatestKeepsCurrentCertificate", certAgent_Distribution_RevokedLatestKeepsCurrentCertificate) } func TestCertAgent_AcmeCA(t *testing.T) { @@ -2148,3 +2579,263 @@ func TestCertAgent_AcmeCA(t *testing.T) { t.Run("PostHookExecution", certAgent_AcmeCA_PostHookExecution) t.Run("CSRBasedIssuance", certAgent_AcmeCA_CSRBasedIssuance) } + +func certAgent_Distribution_RestartAfterRenewalRunsRenewalHook(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + helper := setupCertAgentTest(t, ctx, + agentHelpers.WithAllowKeyAlgorithms("RSA_2048"), + agentHelpers.WithAllowSignatureAlgorithms("SHA256-RSA"), + ) + + certificateID := helper.IssueCertificateDirectly("restart-renewal.example.com") + + certDir := filepath.Join(helper.TempDir, "certs") + require.NoError(t, os.MkdirAll(certDir, 0755)) + certPath, keyPath, chainPath := agentHelpers.CertFilePaths(certDir) + + renewalMarker := filepath.Join(helper.TempDir, "renewal-hook.txt") + issuanceMarker := filepath.Join(helper.TempDir, "issuance-hook.txt") + clientIDPath, clientSecretPath := helper.WriteCredentialFiles() + + configPath := helper.GenerateAgentConfig(agentHelpers.AgentConfigOptions{ + ClientIDPath: clientIDPath, + ClientSecretPath: clientSecretPath, + Certificates: []agentHelpers.CertificateConfigEntry{ + { + CertificateID: certificateID, + UseLatest: true, + StatusCheckInterval: "5s", + CertPath: certPath, + KeyPath: keyPath, + ChainPath: chainPath, + PostHookOnIssuance: fmt.Sprintf("echo fired >> %s", issuanceMarker), + PostHookOnRenewal: fmt.Sprintf("echo fired >> %s", renewalMarker), + }, + }, + }) + + runAgent := func() *helpers.Command { + cmd := &helpers.Command{ + Test: t, + Args: []string{"cert-manager", "agent", "--config", configPath, "--verbose"}, + Env: map[string]string{"PATH": os.Getenv("PATH")}, + } + cmd.Start(ctx) + return cmd + } + + first := runAgent() + result := helpers.WaitForStderr(t, helpers.WaitForStderrOptions{ + EnsureCmdRunning: first, + ExpectedString: "certificate fetched successfully", + Timeout: 120 * time.Second, + Interval: 2 * time.Second, + }) + require.Equal(t, helpers.WaitSuccess, result, "Initial fetch did not complete. stderr:\n%s", first.Stderr()) + initialSerial := readCertificateSerial(t, certPath) + first.Stop() + + renewedID := helper.RenewCertificateDirectly(certificateID) + require.NotEmpty(t, renewedID) + + second := runAgent() + t.Cleanup(func() { + if t.Failed() { + t.Logf("Agent stderr:\n%s", second.Stderr()) + } + second.Stop() + }) + + require.Eventually(t, func() bool { + return readCertificateSerial(t, certPath) != initialSerial + }, 120*time.Second, 2*time.Second, "restarted agent never delivered the renewal. stderr:\n%s", second.Stderr()) + + require.Eventually(t, func() bool { + _, err := os.Stat(renewalMarker) + return err == nil + }, 60*time.Second, 2*time.Second, + "a renewal picked up after a restart must run the on-renewal hook, which is where reload commands live. stderr:\n%s", second.Stderr()) + + issuanceContents, err := os.ReadFile(issuanceMarker) + require.NoError(t, err) + require.Equal(t, 1, strings.Count(string(issuanceContents), "fired"), + "on-issuance must fire only for the very first delivery, not again after a restart") +} + +func certAgent_Distribution_RestartWithoutChangeIsQuiet(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + helper := setupCertAgentTest(t, ctx, + agentHelpers.WithAllowKeyAlgorithms("RSA_2048"), + agentHelpers.WithAllowSignatureAlgorithms("SHA256-RSA"), + ) + + certificateID := helper.IssueCertificateDirectly("restart-quiet.example.com") + + certDir := filepath.Join(helper.TempDir, "certs") + require.NoError(t, os.MkdirAll(certDir, 0755)) + certPath, keyPath, chainPath := agentHelpers.CertFilePaths(certDir) + + issuanceMarker := filepath.Join(helper.TempDir, "issuance-hook.txt") + clientIDPath, clientSecretPath := helper.WriteCredentialFiles() + + configPath := helper.GenerateAgentConfig(agentHelpers.AgentConfigOptions{ + ClientIDPath: clientIDPath, + ClientSecretPath: clientSecretPath, + Certificates: []agentHelpers.CertificateConfigEntry{ + { + CertificateID: certificateID, + StatusCheckInterval: "5s", + CertPath: certPath, + KeyPath: keyPath, + ChainPath: chainPath, + PostHookOnIssuance: fmt.Sprintf("echo fired >> %s", issuanceMarker), + }, + }, + }) + + runAgent := func() *helpers.Command { + cmd := &helpers.Command{ + Test: t, + Args: []string{"cert-manager", "agent", "--config", configPath, "--verbose"}, + Env: map[string]string{"PATH": os.Getenv("PATH")}, + } + cmd.Start(ctx) + return cmd + } + + first := runAgent() + result := helpers.WaitForStderr(t, helpers.WaitForStderrOptions{ + EnsureCmdRunning: first, + ExpectedString: "certificate fetched successfully", + Timeout: 120 * time.Second, + Interval: 2 * time.Second, + }) + require.Equal(t, helpers.WaitSuccess, result, "Initial fetch did not complete. stderr:\n%s", first.Stderr()) + + writtenAt := fileModTime(t, certPath) + first.Stop() + + second := runAgent() + t.Cleanup(func() { + if t.Failed() { + t.Logf("Agent stderr:\n%s", second.Stderr()) + } + second.Stop() + }) + + result = helpers.WaitForStderr(t, helpers.WaitForStderrOptions{ + EnsureCmdRunning: second, + ExpectedString: "certificate", + Timeout: 120 * time.Second, + Interval: 2 * time.Second, + }) + require.Equal(t, helpers.WaitSuccess, result, "restarted agent produced no output. stderr:\n%s", second.Stderr()) + time.Sleep(15 * time.Second) + + require.Equal(t, writtenAt, fileModTime(t, certPath), + "nothing changed, so a restart must not rewrite the certificate file") + + issuanceContents, err := os.ReadFile(issuanceMarker) + require.NoError(t, err) + require.Equal(t, 1, strings.Count(string(issuanceContents), "fired"), + "nothing changed, so a restart must not run any hook again") +} + +func certAgent_Distribution_RevokedLatestKeepsCurrentCertificate(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + helper := setupCertAgentTest(t, ctx, + agentHelpers.WithAllowKeyAlgorithms("RSA_2048"), + agentHelpers.WithAllowSignatureAlgorithms("SHA256-RSA"), + ) + + certificateID := helper.IssueCertificateDirectly("revoked-latest.example.com") + + certDir := filepath.Join(helper.TempDir, "certs") + require.NoError(t, os.MkdirAll(certDir, 0755)) + certPath, keyPath, chainPath := agentHelpers.CertFilePaths(certDir) + + renewalMarker := filepath.Join(helper.TempDir, "renewal-hook.txt") + failureMarker := filepath.Join(helper.TempDir, "failure-hook.txt") + clientIDPath, clientSecretPath := helper.WriteCredentialFiles() + + configPath := helper.GenerateAgentConfig(agentHelpers.AgentConfigOptions{ + ClientIDPath: clientIDPath, + ClientSecretPath: clientSecretPath, + Certificates: []agentHelpers.CertificateConfigEntry{ + { + CertificateID: certificateID, + UseLatest: true, + StatusCheckInterval: "5s", + CertPath: certPath, + KeyPath: keyPath, + ChainPath: chainPath, + PostHookOnRenewal: fmt.Sprintf("echo fired >> %s", renewalMarker), + PostHookOnFailure: fmt.Sprintf("echo fired >> %s", failureMarker), + }, + }, + }) + + cmd := helpers.Command{ + Test: t, + Args: []string{"cert-manager", "agent", "--config", configPath, "--verbose"}, + Env: map[string]string{"PATH": os.Getenv("PATH")}, + } + cmd.Start(ctx) + t.Cleanup(func() { + if t.Failed() { + t.Logf("Agent stderr:\n%s", cmd.Stderr()) + } + cmd.Stop() + }) + + result := helpers.WaitForStderr(t, helpers.WaitForStderrOptions{ + EnsureCmdRunning: &cmd, + ExpectedString: "certificate fetched successfully", + Timeout: 120 * time.Second, + Interval: 2 * time.Second, + }) + require.Equal(t, helpers.WaitSuccess, result, "Initial fetch did not complete. stderr:\n%s", cmd.Stderr()) + + originalSerial := readCertificateSerial(t, certPath) + require.NotEmpty(t, originalSerial) + + renewedID := helper.RenewCertificateDirectly(certificateID) + require.NotEmpty(t, renewedID) + + require.Eventually(t, func() bool { + return readCertificateSerial(t, certPath) != originalSerial + }, 120*time.Second, 2*time.Second, "agent never picked up the renewal. stderr:\n%s", cmd.Stderr()) + + servingSerial := readCertificateSerial(t, certPath) + _ = os.Remove(renewalMarker) + + helper.RevokeCertificateDirectly(renewedID) + + require.Eventually(t, func() bool { + _, err := os.Stat(failureMarker) + return err == nil + }, 90*time.Second, 2*time.Second, + "revoking the certificate being served must run the on-failure hook. stderr:\n%s", cmd.Stderr()) + + current := readCertificateSerial(t, certPath) + require.NotEqual(t, originalSerial, current, + "revocation must not roll the machine back onto the certificate the revoked one replaced") + require.Equal(t, servingSerial, current, "the delivered certificate must not change on revocation") + + _, err := os.Stat(renewalMarker) + require.True(t, os.IsNotExist(err), + "moving backwards onto an older certificate is not a renewal and must not run the on-renewal hook") +} + +func fileModTime(t *testing.T, path string) time.Time { + t.Helper() + info, err := os.Stat(path) + require.NoError(t, err) + return info.ModTime() +} diff --git a/packages/api/model.go b/packages/api/model.go index ec9aeddb..77cbdb50 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -1169,20 +1169,22 @@ type CertificateResponse struct { type RetrieveCertificateResponse struct { Certificate struct { - ID string `json:"id"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - Status string `json:"status"` - SerialNumber string `json:"serialNumber"` - CommonName string `json:"commonName"` - NotBefore time.Time `json:"notBefore"` - NotAfter time.Time `json:"notAfter"` - CaId string `json:"caId"` - KeyUsages []string `json:"keyUsages"` - ExtendedKeyUsages []string `json:"extendedKeyUsages"` - Certificate string `json:"certificate,omitempty"` - CertificateChain string `json:"certificateChain,omitempty"` - PrivateKey string `json:"privateKey,omitempty"` + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Status string `json:"status"` + SerialNumber string `json:"serialNumber"` + CommonName string `json:"commonName"` + NotBefore time.Time `json:"notBefore"` + NotAfter time.Time `json:"notAfter"` + CaId string `json:"caId"` + KeyUsages []string `json:"keyUsages"` + ExtendedKeyUsages []string `json:"extendedKeyUsages"` + Certificate string `json:"certificate,omitempty"` + CertificateChain string `json:"certificateChain,omitempty"` + PrivateKey string `json:"privateKey,omitempty"` + RenewedByCertificateID string `json:"renewedByCertificateId,omitempty"` + LatestRenewalCertificateID string `json:"latestRenewalCertificateId,omitempty"` } `json:"certificate"` } diff --git a/packages/cmd/agent.go b/packages/cmd/agent.go index 93ce80e5..d3d1efe7 100644 --- a/packages/cmd/agent.go +++ b/packages/cmd/agent.go @@ -7,12 +7,15 @@ import ( "bytes" "context" "crypto/sha256" + "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" + "encoding/pem" "errors" "fmt" "io/ioutil" + "math/big" "net/http" "os" "os/exec" @@ -63,6 +66,10 @@ const EXTERNAL_CA_MAX_POLLING_INTERVAL = 1 * time.Hour const DEFAULT_MONITORING_INTERVAL = 10 * time.Second const DEFAULT_MAX_FAILURE_RETRIES = 10 +const CHECK_DUE_SLACK = time.Second +const FAILURE_COOLDOWN_MULTIPLIER = 10 +const MAX_FAILURE_COOLDOWN = 1 * time.Hour + type PersistentCacheConfig struct { Type string `yaml:"type"` // file or kubernetes ServiceAccountTokenPath string `yaml:"service-account-token-path"` // relevant if type is kubernetes @@ -121,6 +128,7 @@ type CertificateState struct { ExpiresAt time.Time `json:"expires_at"` NextRenewalCheck time.Time `json:"next_renewal_check"` Status string `json:"status"` + LastReportedStatus string `json:"last_reported_status,omitempty"` LastError string `json:"last_error,omitempty"` RetryCount int `json:"retry_count"` LastRetry time.Time `json:"last_retry,omitempty"` @@ -202,6 +210,7 @@ type CertificateLifecycleConfig struct { StatusCheckInterval string `yaml:"status-check-interval"` FailureRetryInterval string `yaml:"failure-retry-interval,omitempty"` MaxFailureRetries int `yaml:"max-failure-retries,omitempty"` + UseLatest bool `yaml:"use-latest,omitempty"` } type CertificateAttributes struct { @@ -1111,6 +1120,7 @@ type AgentManager struct { accessTokenFetchedTime time.Time accessTokenRefreshedTime time.Time mutex sync.Mutex + tokenMutex sync.RWMutex filePaths []Sink // Store file paths if needed templates []TemplateWithID certificates []CertificateWithID @@ -1202,10 +1212,13 @@ func NewAgentManager(options NewAgentMangerOptions) *AgentManager { } func (tm *AgentManager) SetToken(token string, accessTokenTTL time.Duration, accessTokenMaxTTL time.Duration) { + tm.tokenMutex.Lock() + tm.accessToken = token + tm.tokenMutex.Unlock() + tm.mutex.Lock() defer tm.mutex.Unlock() - tm.accessToken = token tm.accessTokenTTL = accessTokenTTL tm.accessTokenMaxTTL = accessTokenMaxTTL @@ -1213,16 +1226,12 @@ func (tm *AgentManager) SetToken(token string, accessTokenTTL time.Duration, acc } func (tm *AgentManager) GetToken() string { - tm.mutex.Lock() - defer tm.mutex.Unlock() + tm.tokenMutex.RLock() + defer tm.tokenMutex.RUnlock() return tm.accessToken } -func (tm *AgentManager) getTokenUnsafe() string { - return tm.accessToken -} - func (tm *AgentManager) waitForToken(ctx context.Context) bool { ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() @@ -2165,9 +2174,16 @@ func validateCertificateSourceConfig(version string, certificates *[]AgentCertif if cert.Attributes != nil { return fmt.Errorf("certificate %d: 'attributes' is not supported when using 'certificate-id'", certIndex) } + if cert.Lifecycle.RenewBeforeExpiry != "" { + log.Warn().Msgf("certificate %d: 'lifecycle.renew-before-expiry' is ignored when using 'certificate-id' because the agent does not renew a certificate it only distributes. Set 'lifecycle.use-latest: true' if you want the agent to deliver renewals made on the server", certIndex) + } continue } + if cert.Lifecycle.UseLatest { + return fmt.Errorf("certificate %d: 'lifecycle.use-latest' is only supported together with 'certificate-id'", certIndex) + } + switch version { case AgentConfigVersionV1: if cert.ApplicationName != "" { @@ -2268,12 +2284,15 @@ func buildCertificateAttributes(certificate *AgentCertificateConfig) *api.Certif } func (tm *AgentManager) createAuthenticatedClient() (*resty.Client, error) { + return newAuthenticatedClient(tm.GetToken()) +} + +func newAuthenticatedClient(token string) (*resty.Client, error) { httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { return nil, fmt.Errorf("failed to create HTTP client: %v", err) } - token := tm.getTokenUnsafe() if token == "" { return nil, fmt.Errorf("no access token available") } @@ -2295,6 +2314,18 @@ func failureRetryIntervalFor(certificate *AgentCertificateConfig) time.Duration return statusCheckIntervalFor(certificate) } +func failureRetryCooldownFor(certificate *AgentCertificateConfig) time.Duration { + interval := failureRetryIntervalFor(certificate) + cooldown := interval * FAILURE_COOLDOWN_MULTIPLIER + if cooldown > MAX_FAILURE_COOLDOWN { + cooldown = MAX_FAILURE_COOLDOWN + } + if cooldown < interval { + return interval + } + return cooldown +} + func effectiveMaxFailureRetries(certificate *AgentCertificateConfig) int { if certificate.Lifecycle.MaxFailureRetries > 0 { return certificate.Lifecycle.MaxFailureRetries @@ -2302,9 +2333,61 @@ func effectiveMaxFailureRetries(certificate *AgentCertificateConfig) int { return DEFAULT_MAX_FAILURE_RETRIES } +func resolveCertificateFrom(httpClient *resty.Client, certificate *AgentCertificateConfig, anchorCertificateID string) (*api.RetrieveCertificateResponse, error) { + metadata, err := api.CallRetrieveCertificate(httpClient, anchorCertificateID) + if err != nil { + return nil, err + } + + if !certificate.Lifecycle.UseLatest { + return metadata, nil + } + + if metadata.Certificate.LatestRenewalCertificateID == "" { + if metadata.Certificate.RenewedByCertificateID != "" { + log.Warn().Msgf("certificate %s has been renewed but Infisical did not name a newer certificate to deliver, so the current one is being kept. This happens when the newer certificates have been revoked, or when the Infisical server predates 'lifecycle.use-latest'", anchorCertificateID) + } + return metadata, nil + } + + latestID := metadata.Certificate.LatestRenewalCertificateID + latest, err := api.CallRetrieveCertificate(httpClient, latestID) + if err != nil { + return nil, fmt.Errorf("failed to retrieve certificate %s, the latest renewal of certificate %s: %v", latestID, anchorCertificateID, err) + } + + return latest, nil +} + +func effectiveCertificateStatus(metadata *api.RetrieveCertificateResponse) string { + status := metadata.Certificate.Status + if status == "active" && !metadata.Certificate.NotAfter.IsZero() && time.Now().After(metadata.Certificate.NotAfter) { + return "expired" + } + return status +} + +func resolveCertificateToFetch(httpClient *resty.Client, certificate *AgentCertificateConfig, anchorCertificateID string) (*api.RetrieveCertificateResponse, error) { + metadata, err := resolveCertificateFrom(httpClient, certificate, anchorCertificateID) + if err == nil || anchorCertificateID == certificate.CertificateID { + return metadata, err + } + + log.Warn().Msgf("failed to resolve from the last delivered certificate %s (%v); retrying from the configured certificate-id %s", anchorCertificateID, err, certificate.CertificateID) + return resolveCertificateFrom(httpClient, certificate, certificate.CertificateID) +} + func (tm *AgentManager) FetchCertificate(certificateId int, certificate *AgentCertificateConfig) error { + log.Info().Str("Certificate", tm.getCertificateDisplayName(certificateId, certificate)).Msg("fetching certificate") + return tm.fetchCertificate(certificateId, certificate) +} + +func (tm *AgentManager) SyncFetchedCertificate(certificateId int, certificate *AgentCertificateConfig) error { + return tm.fetchCertificate(certificateId, certificate) +} + +func (tm *AgentManager) fetchCertificate(certificateId int, certificate *AgentCertificateConfig) error { displayName := tm.getCertificateDisplayName(certificateId, certificate) - log.Info().Str("Certificate", displayName).Msg("fetching certificate") httpClient, err := tm.createAuthenticatedClient() if err != nil { @@ -2321,27 +2404,72 @@ func (tm *AgentManager) FetchCertificate(certificateId int, certificate *AgentCe state.LastRetry = time.Now() } - metadata, err := api.CallRetrieveCertificate(httpClient, certificate.CertificateID) + tm.mutex.Lock() + previousCertificateID := tm.certificateStates[certificateId].CertificateID + tm.mutex.Unlock() + + anchorCertificateID := certificate.CertificateID + if certificate.Lifecycle.UseLatest && previousCertificateID != "" { + anchorCertificateID = previousCertificateID + } + + metadata, err := resolveCertificateToFetch(httpClient, certificate, anchorCertificateID) if err != nil { recordFailure(err.Error()) log.Error().Str("Certificate", displayName).Msgf("failed to fetch certificate metadata: %v", err) return fmt.Errorf("failed to fetch certificate: %v", err) } - if metadata.Certificate.Status != "active" { + resolvedCertificateID := metadata.Certificate.ID + + certificateStatus := effectiveCertificateStatus(metadata) + + if certificateStatus != "active" { + statusChanged := false func() { tm.mutex.Lock() defer tm.mutex.Unlock() state := tm.certificateStates[certificateId] - state.Status = metadata.Certificate.Status - state.LastError = fmt.Sprintf("certificate is in '%s' state", metadata.Certificate.Status) + statusChanged = state.LastReportedStatus != certificateStatus + state.LastReportedStatus = certificateStatus + state.Status = certificateStatus + state.LastError = fmt.Sprintf("certificate is in '%s' state", certificateStatus) state.LastRetry = time.Now() }() - log.Error().Str("Certificate", displayName).Str("status", metadata.Certificate.Status).Msg("certificate is not active; skipping fetch") - return fmt.Errorf("certificate %s is in '%s' state", certificate.CertificateID, metadata.Certificate.Status) + log.Error().Str("Certificate", displayName).Str("resolved", resolvedCertificateID).Str("status", certificateStatus).Msg("certificate is not active; skipping fetch") + + if statusChanged && certificate.PostHooks.OnFailure.Command != "" { + tm.ExecutePostHook(certificate.PostHooks.OnFailure.Command, certificate.PostHooks.OnFailure.Timeout, certificateStatus, certificateId, certificate) + } + + return fmt.Errorf("certificate %s is in '%s' state", resolvedCertificateID, certificateStatus) + } + + serialOnDiskMatches := serialMatchesCertificateOnDisk(certificate, metadata.Certificate.SerialNumber) + alreadyDelivered := previousCertificateID == "" && serialOnDiskMatches && allConfiguredOutputsExist(certificate) + + if previousCertificateID == resolvedCertificateID || alreadyDelivered { + tm.mutex.Lock() + defer tm.mutex.Unlock() + state := tm.certificateStates[certificateId] + state.CertificateID = resolvedCertificateID + state.SerialNumber = metadata.Certificate.SerialNumber + state.CommonName = metadata.Certificate.CommonName + state.Status = "active" + state.LastReportedStatus = "active" + state.ExpiresAt = metadata.Certificate.NotAfter + state.LastError = "" + state.RetryCount = 0 + return nil + } + + isReplacement := isReplacementOnDisk(certificate) && !serialOnDiskMatches + isRenewal := (previousCertificateID != "" || isReplacement) && !serialOnDiskMatches + if isRenewal { + log.Info().Str("Certificate", displayName).Str("previous", previousCertificateID).Str("resolved", resolvedCertificateID).Msg("a more recent renewal is available; fetching it") } - bundle, err := api.CallGetCertificateBundle(httpClient, certificate.CertificateID) + bundle, err := api.CallGetCertificateBundle(httpClient, resolvedCertificateID) if err != nil { recordFailure(err.Error()) log.Error().Str("Certificate", displayName).Msgf("failed to fetch certificate bundle: %v", err) @@ -2352,7 +2480,7 @@ func (tm *AgentManager) FetchCertificate(certificateId int, certificate *AgentCe reason := "certificate bundle did not include certificate content" recordFailure(reason) log.Error().Str("Certificate", displayName).Msg(reason) - return fmt.Errorf("certificate %s: %s", certificate.CertificateID, reason) + return fmt.Errorf("certificate %s: %s", resolvedCertificateID, reason) } serialNumber := bundle.SerialNumber @@ -2371,11 +2499,11 @@ func (tm *AgentManager) FetchCertificate(certificateId int, certificate *AgentCe CertificateChain: bundle.CertificateChain, PrivateKey: bundle.PrivateKey, SerialNumber: serialNumber, - CertificateID: metadata.Certificate.ID, + CertificateID: resolvedCertificateID, }, } - if err := tm.WriteCertificateFiles(certificate, certResponse); err != nil { + if err := tm.writeCertificateFiles(certificate, certResponse, isReplacement); err != nil { log.Error().Str("Certificate", displayName).Msgf("failed to write certificate files: %v", err) state.Status = "failed" state.LastError = fmt.Sprintf("failed to write files: %v", err) @@ -2384,19 +2512,24 @@ func (tm *AgentManager) FetchCertificate(certificateId int, certificate *AgentCe return err } - state.CertificateID = metadata.Certificate.ID + state.CertificateID = resolvedCertificateID state.SerialNumber = serialNumber state.CommonName = metadata.Certificate.CommonName state.IssuedAt = time.Now() state.ExpiresAt = metadata.Certificate.NotAfter state.Status = "active" + state.LastReportedStatus = "active" state.LastError = "" state.RetryCount = 0 state.NextRenewalCheck = time.Now().Add(statusCheckIntervalFor(certificate)) log.Info().Str("Certificate", displayName).Str("serial", serialNumber).Msg("certificate fetched successfully") - if certificate.PostHooks.OnIssuance.Command != "" { + if isRenewal { + if certificate.PostHooks.OnRenewal.Command != "" { + tm.ExecutePostHook(certificate.PostHooks.OnRenewal.Command, certificate.PostHooks.OnRenewal.Timeout, "renewal", certificateId, certificate) + } + } else if certificate.PostHooks.OnIssuance.Command != "" { tm.ExecutePostHook(certificate.PostHooks.OnIssuance.Command, certificate.PostHooks.OnIssuance.Timeout, "issuance", certificateId, certificate) } @@ -2672,7 +2805,63 @@ func (tm *AgentManager) handleFailedCertificateRequest(certificateId int, errorM } } +func isReplacementOnDisk(certificate *AgentCertificateConfig) bool { + if certificate.FileConfig.Certificate.Path == "" { + return false + } + _, err := os.Stat(certificate.FileConfig.Certificate.Path) + return err == nil +} + +func allConfiguredOutputsExist(certificate *AgentCertificateConfig) bool { + for _, path := range []string{ + certificate.FileConfig.Certificate.Path, + certificate.FileConfig.Chain.Path, + certificate.FileConfig.PrivateKey.Path, + } { + if path == "" { + continue + } + if _, err := os.Stat(path); err != nil { + return false + } + } + return true +} + +func serialMatchesCertificateOnDisk(certificate *AgentCertificateConfig, serialNumber string) bool { + if certificate.FileConfig.Certificate.Path == "" || serialNumber == "" { + return false + } + + contents, err := os.ReadFile(certificate.FileConfig.Certificate.Path) + if err != nil { + return false + } + + block, _ := pem.Decode(contents) + if block == nil { + return false + } + + parsed, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false + } + + expected, ok := new(big.Int).SetString(strings.TrimPrefix(strings.ToLower(serialNumber), "0x"), 16) + if !ok { + return false + } + + return parsed.SerialNumber.Cmp(expected) == 0 +} + func (tm *AgentManager) WriteCertificateFiles(certificate *AgentCertificateConfig, response *api.CertificateResponse) error { + return tm.writeCertificateFiles(certificate, response, false) +} + +func (tm *AgentManager) writeCertificateFiles(certificate *AgentCertificateConfig, response *api.CertificateResponse, isReplacement bool) error { getFilePermission := func(permission string) os.FileMode { if permission != "" { if perms, err := strconv.ParseInt(permission, 8, 32); err == nil { @@ -2706,6 +2895,13 @@ func (tm *AgentManager) WriteCertificateFiles(certificate *AgentCertificateConfi return fmt.Errorf("failed to write private key to %s: %v", privateKeyPath, err) } } else if privateKeyPath != "" { + if isReplacement { + if _, err := os.Stat(privateKeyPath); err == nil { + return fmt.Errorf( + "refusing to replace the certificate at %s: the new certificate has no private key in Infisical (expected for CSR or ACME issuance), so the existing key at %s would no longer match it. Remove 'private-key.path', or manage the key on this machine and reload the service yourself", + certificatePath, privateKeyPath) + } + } log.Warn().Str("path", privateKeyPath).Msg("private-key.path is configured but the certificate response does not include a private key (this is expected for certificates issued via ACME or stored without a private key); skipping private key file write") } @@ -2760,16 +2956,13 @@ func (tm *AgentManager) MonitorCertificates(ctx context.Context) { var monitoringInterval time.Duration = DEFAULT_MONITORING_INTERVAL for _, cert := range tm.certificates { - if interval, err := parseDurationWithDays(cert.Certificate.Lifecycle.StatusCheckInterval); err == nil { + if interval, err := parseDurationWithDays(cert.Certificate.Lifecycle.StatusCheckInterval); err == nil && interval > 0 { if monitoringInterval == 0 || interval < monitoringInterval { monitoringInterval = interval } } } - ticker := time.NewTicker(monitoringInterval) - defer ticker.Stop() - if !tm.waitForToken(ctx) { return } @@ -2788,6 +2981,9 @@ func (tm *AgentManager) MonitorCertificates(ctx context.Context) { } } + ticker := time.NewTicker(monitoringInterval) + defer ticker.Stop() + for { select { case <-ctx.Done(): @@ -2813,10 +3009,15 @@ func (tm *AgentManager) CheckCertificateRenewals() { displayName := tm.getCertificateDisplayName(cert.ID, referencedCert) if state.Status == "failed" { + retryInterval := failureRetryIntervalFor(referencedCert) if state.RetryCount >= effectiveMaxFailureRetries(referencedCert) { - continue + if !state.LastRetry.IsZero() && now.Sub(state.LastRetry) < failureRetryCooldownFor(referencedCert) { + continue + } + log.Warn().Str("Certificate", displayName).Msg("cooldown elapsed after exhausting the retry budget; resuming fetch attempts") + state.RetryCount = 0 } - if !state.LastRetry.IsZero() && now.Sub(state.LastRetry) < failureRetryIntervalFor(referencedCert) { + if !state.LastRetry.IsZero() && now.Sub(state.LastRetry) < retryInterval { continue } log.Info().Str("Certificate", displayName).Msg("retrying certificate fetch") @@ -2828,10 +3029,22 @@ func (tm *AgentManager) CheckCertificateRenewals() { continue } - if state.Status != "active" || state.CertificateID == "" { + if !state.NextRenewalCheck.IsZero() && now.Add(CHECK_DUE_SLACK).Before(state.NextRenewalCheck) { continue } - if !state.NextRenewalCheck.IsZero() && now.Before(state.NextRenewalCheck) { + + if referencedCert.Lifecycle.UseLatest { + tm.mutex.Unlock() + if err := tm.SyncFetchedCertificate(cert.ID, referencedCert); err != nil { + log.Error().Str("Certificate", displayName).Msgf("failed to check for a renewed certificate: %v", err) + } + tm.mutex.Lock() + + state.NextRenewalCheck = time.Now().Add(statusCheckIntervalFor(referencedCert)) + continue + } + + if state.Status != "active" || state.CertificateID == "" { continue } @@ -2849,7 +3062,7 @@ func (tm *AgentManager) CheckCertificateRenewals() { continue } - if state.Status != "active" || now.Before(state.NextRenewalCheck) { + if state.Status != "active" || now.Add(CHECK_DUE_SLACK).Before(state.NextRenewalCheck) { continue } @@ -2881,7 +3094,7 @@ func (tm *AgentManager) CheckCertificateStatus(certificateId int, infisicalCertI if err != nil { return fmt.Errorf("failed to create HTTP client: %v", err) } - httpClient.SetAuthToken(tm.getTokenUnsafe()) + httpClient.SetAuthToken(tm.GetToken()) response, err := api.CallRetrieveCertificate(httpClient, infisicalCertId) if err != nil { diff --git a/packages/cmd/agent_cert_latest_renewal_test.go b/packages/cmd/agent_cert_latest_renewal_test.go new file mode 100644 index 00000000..21cfa443 --- /dev/null +++ b/packages/cmd/agent_cert_latest_renewal_test.go @@ -0,0 +1,381 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type certRecord struct { + latestRenewal string + renewedBy string + status string +} + +func newCertificateServer(t *testing.T, certs map[string]certRecord) (*httptest.Server, func() []string) { + t.Helper() + + var mu sync.Mutex + var requested []string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var id string + if _, err := fmt.Sscanf(r.URL.Path, "/v1/cert-manager/certificates/%s", &id); err != nil { + http.NotFound(w, r) + return + } + + record, ok := certs[id] + if !ok { + http.NotFound(w, r) + return + } + + mu.Lock() + requested = append(requested, id) + mu.Unlock() + + status := record.status + if status == "" { + status = "active" + } + + var resp api.RetrieveCertificateResponse + resp.Certificate.ID = id + resp.Certificate.Status = status + resp.Certificate.RenewedByCertificateID = record.renewedBy + resp.Certificate.LatestRenewalCertificateID = record.latestRenewal + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + + return server, func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), requested...) + } +} + +func newCertificateServerWithRenewedBy(t *testing.T, certs map[string]certRecord) (*httptest.Server, func() []string) { + t.Helper() + return newCertificateServer(t, certs) +} + +func useLatestConfig(certificateID string) *AgentCertificateConfig { + cert := &AgentCertificateConfig{CertificateID: certificateID} + cert.Lifecycle.UseLatest = true + return cert +} + +func TestResolveCertificateToFetch_ResolvesLatestInTwoCallsRegardlessOfChainLength(t *testing.T) { + server, requests := newCertificateServer(t, map[string]certRecord{ + "cert-03": {latestRenewal: "cert-11"}, + "cert-11": {}, + }) + t.Cleanup(server.Close) + withMockInfisicalURL(t, server.URL) + + metadata, err := resolveCertificateToFetch(resty.New(), useLatestConfig("cert-03"), "cert-03") + + require.NoError(t, err) + assert.Equal(t, "cert-11", metadata.Certificate.ID) + assert.Equal(t, []string{"cert-03", "cert-11"}, requests(), + "resolution must cost exactly two calls no matter how long the chain is") +} + +func TestResolveCertificateToFetch_NeverRenewedCostsOneCall(t *testing.T) { + server, requests := newCertificateServer(t, map[string]certRecord{"cert-01": {}}) + t.Cleanup(server.Close) + withMockInfisicalURL(t, server.URL) + + metadata, err := resolveCertificateToFetch(resty.New(), useLatestConfig("cert-01"), "cert-01") + + require.NoError(t, err) + assert.Equal(t, "cert-01", metadata.Certificate.ID) + assert.Equal(t, []string{"cert-01"}, requests(), "a certificate that was never renewed must cost one call") +} + +func TestResolveCertificateToFetch_PinnedWhenUseLatestDisabled(t *testing.T) { + server, requests := newCertificateServer(t, map[string]certRecord{ + "cert-01": {latestRenewal: "cert-09"}, + "cert-09": {}, + }) + t.Cleanup(server.Close) + withMockInfisicalURL(t, server.URL) + + metadata, err := resolveCertificateToFetch(resty.New(), &AgentCertificateConfig{CertificateID: "cert-01"}, "cert-01") + + require.NoError(t, err) + assert.Equal(t, "cert-01", metadata.Certificate.ID, "without use-latest the agent stays pinned") + assert.Equal(t, []string{"cert-01"}, requests(), "pinned mode must not fetch the renewal") +} + +func TestResolveCertificateToFetch_PreservesTerminalStatus(t *testing.T) { + server, _ := newCertificateServer(t, map[string]certRecord{ + "cert-01": {latestRenewal: "cert-02"}, + "cert-02": {status: "revoked"}, + }) + t.Cleanup(server.Close) + withMockInfisicalURL(t, server.URL) + + metadata, err := resolveCertificateToFetch(resty.New(), useLatestConfig("cert-01"), "cert-01") + + require.NoError(t, err) + assert.Equal(t, "cert-02", metadata.Certificate.ID) + assert.Equal(t, "revoked", metadata.Certificate.Status) +} + +func TestResolveCertificateToFetch_ReportsMissingConfiguredCertificate(t *testing.T) { + server, _ := newCertificateServer(t, map[string]certRecord{}) + t.Cleanup(server.Close) + withMockInfisicalURL(t, server.URL) + + _, err := resolveCertificateToFetch(resty.New(), useLatestConfig("cert-gone"), "cert-gone") + require.Error(t, err) +} + +func TestResolveCertificateToFetch_NamesBothCertificatesWhenTheRenewalIsUnreachable(t *testing.T) { + server, _ := newCertificateServer(t, map[string]certRecord{"cert-01": {latestRenewal: "cert-missing"}}) + t.Cleanup(server.Close) + withMockInfisicalURL(t, server.URL) + + _, err := resolveCertificateToFetch(resty.New(), useLatestConfig("cert-01"), "cert-01") + + require.Error(t, err) + assert.Contains(t, err.Error(), "cert-missing") + assert.Contains(t, err.Error(), "cert-01") +} + +func TestValidateCertificateSourceConfig_UseLatestRequiresCertificateID(t *testing.T) { + for _, version := range []string{AgentConfigVersionV1, AgentConfigVersionV2} { + t.Run(version, func(t *testing.T) { + cert := AgentCertificateConfig{ProjectName: "proj", ProfileName: "prof"} + if version == AgentConfigVersionV2 { + cert = AgentCertificateConfig{ApplicationName: "app", ProfileName: "prof"} + } + cert.Lifecycle.UseLatest = true + + certs := []AgentCertificateConfig{cert} + err := validateCertificateSourceConfig(version, &certs) + require.Error(t, err) + assert.Contains(t, err.Error(), "use-latest") + assert.Contains(t, err.Error(), "certificate-id") + }) + } +} + +func TestValidateCertificateSourceConfig_UseLatestAllowedWithCertificateID(t *testing.T) { + cert := AgentCertificateConfig{CertificateID: "00000000-0000-0000-0000-000000000000"} + cert.Lifecycle.UseLatest = true + + certs := []AgentCertificateConfig{cert} + require.NoError(t, validateCertificateSourceConfig(AgentConfigVersionV2, &certs)) +} + +func TestValidateCertificateSourceConfig_AcceptsRenewBeforeExpiryWithCertificateID(t *testing.T) { + cert := AgentCertificateConfig{CertificateID: "00000000-0000-0000-0000-000000000000"} + cert.Lifecycle.RenewBeforeExpiry = "30d" + + certs := []AgentCertificateConfig{cert} + require.NoError(t, validateCertificateSourceConfig(AgentConfigVersionV2, &certs)) +} + +func TestValidateCertificateSourceConfig_StatusCheckIntervalAllowedWithCertificateID(t *testing.T) { + cert := AgentCertificateConfig{CertificateID: "00000000-0000-0000-0000-000000000000"} + cert.Lifecycle.StatusCheckInterval = "6h" + cert.Lifecycle.UseLatest = true + + certs := []AgentCertificateConfig{cert} + require.NoError(t, validateCertificateSourceConfig(AgentConfigVersionV2, &certs)) +} + +func TestResolveCertificateToFetch_KeepsCurrentWhenNoNewerCertificateIsNamed(t *testing.T) { + server, requests := newCertificateServerWithRenewedBy(t, map[string]certRecord{ + "cert-01": {renewedBy: "cert-02"}, + }) + t.Cleanup(server.Close) + withMockInfisicalURL(t, server.URL) + + metadata, err := resolveCertificateToFetch(resty.New(), useLatestConfig("cert-01"), "cert-01") + + require.NoError(t, err) + assert.Equal(t, "cert-01", metadata.Certificate.ID, "the current certificate keeps being delivered") + assert.Equal(t, []string{"cert-01"}, requests(), "it must not chase a certificate it was not given") +} + +func TestResolveCertificateToFetch_UnrenewedCertificateOnOlderServerIsFine(t *testing.T) { + server, requests := newCertificateServerWithRenewedBy(t, map[string]certRecord{"cert-01": {}}) + t.Cleanup(server.Close) + withMockInfisicalURL(t, server.URL) + + metadata, err := resolveCertificateToFetch(resty.New(), useLatestConfig("cert-01"), "cert-01") + + require.NoError(t, err) + assert.Equal(t, "cert-01", metadata.Certificate.ID) + assert.Equal(t, []string{"cert-01"}, requests()) +} + +func TestResolveCertificateToFetch_AnchorsOnTheLastDeliveredCertificate(t *testing.T) { + server, requests := newCertificateServer(t, map[string]certRecord{ + "cert-0001": {latestRenewal: "cert-5000"}, + "cert-4999": {latestRenewal: "cert-5000"}, + "cert-5000": {}, + }) + t.Cleanup(server.Close) + withMockInfisicalURL(t, server.URL) + + metadata, err := resolveCertificateToFetch(resty.New(), useLatestConfig("cert-0001"), "cert-4999") + + require.NoError(t, err) + assert.Equal(t, "cert-5000", metadata.Certificate.ID) + assert.Equal(t, []string{"cert-4999", "cert-5000"}, requests(), + "must resolve from the last delivered certificate, never touching the configured one") +} + +func TestResolveCertificateToFetch_FallsBackToConfiguredIDWhenTheAnchorIsGone(t *testing.T) { + server, requests := newCertificateServer(t, map[string]certRecord{ + "cert-0001": {latestRenewal: "cert-5000"}, + "cert-5000": {}, + }) + t.Cleanup(server.Close) + withMockInfisicalURL(t, server.URL) + + metadata, err := resolveCertificateToFetch(resty.New(), useLatestConfig("cert-0001"), "cert-deleted") + + require.NoError(t, err) + assert.Equal(t, "cert-5000", metadata.Certificate.ID) + assert.Equal(t, []string{"cert-0001", "cert-5000"}, requests(), + "a deleted anchor must fall back to the configured certificate-id, not fail") +} + +func TestResolveCertificateToFetch_AnchorErrorSurfacesWhenItIsTheConfiguredID(t *testing.T) { + server, _ := newCertificateServer(t, map[string]certRecord{}) + t.Cleanup(server.Close) + withMockInfisicalURL(t, server.URL) + + _, err := resolveCertificateToFetch(resty.New(), useLatestConfig("cert-gone"), "cert-gone") + require.Error(t, err) +} + +func TestResolveCertificateToFetch_SteadyStateIsOneCall(t *testing.T) { + server, requests := newCertificateServer(t, map[string]certRecord{"cert-5000": {}}) + t.Cleanup(server.Close) + withMockInfisicalURL(t, server.URL) + + metadata, err := resolveCertificateToFetch(resty.New(), useLatestConfig("cert-0001"), "cert-5000") + + require.NoError(t, err) + assert.Equal(t, "cert-5000", metadata.Certificate.ID) + assert.Equal(t, []string{"cert-5000"}, requests()) +} + +func distributionConfigWithKeyPath(dir string) *AgentCertificateConfig { + cert := &AgentCertificateConfig{CertificateID: "cert-01"} + cert.Lifecycle.UseLatest = true + cert.FileConfig.Certificate.Path = filepath.Join(dir, "certificate.crt") + cert.FileConfig.PrivateKey.Path = filepath.Join(dir, "private.key") + return cert +} + +func TestWriteCertificateFiles_KeylessFirstDeliveryWritesCertAndSkipsKey(t *testing.T) { + dir := t.TempDir() + cert := distributionConfigWithKeyPath(dir) + tm := &AgentManager{} + + err := tm.writeCertificateFiles(cert, &api.CertificateResponse{ + Certificate: &api.CertificateData{Certificate: "-----BEGIN CERTIFICATE-----\nfirst\n"}, + }, false) + + require.NoError(t, err) + require.FileExists(t, cert.FileConfig.Certificate.Path) + require.NoFileExists(t, cert.FileConfig.PrivateKey.Path) +} + +func TestWriteCertificateFiles_KeylessReplacementIsRefused(t *testing.T) { + dir := t.TempDir() + cert := distributionConfigWithKeyPath(dir) + tm := &AgentManager{} + + require.NoError(t, os.WriteFile(cert.FileConfig.Certificate.Path, []byte("old cert"), 0o644)) + require.NoError(t, os.WriteFile(cert.FileConfig.PrivateKey.Path, []byte("old key"), 0o600)) + + err := tm.writeCertificateFiles(cert, &api.CertificateResponse{ + Certificate: &api.CertificateData{Certificate: "-----BEGIN CERTIFICATE-----\nrenewed\n"}, + }, true) + + require.Error(t, err) + assert.Contains(t, err.Error(), "would no longer match") + + onDisk, readErr := os.ReadFile(cert.FileConfig.Certificate.Path) + require.NoError(t, readErr) + assert.Equal(t, "old cert", string(onDisk), "the certificate must be left alone when the write is refused") +} + +func TestWriteCertificateFiles_KeylessReplacementAllowedWhenNoKeyOnDisk(t *testing.T) { + dir := t.TempDir() + cert := distributionConfigWithKeyPath(dir) + tm := &AgentManager{} + + require.NoError(t, os.WriteFile(cert.FileConfig.Certificate.Path, []byte("old cert"), 0o644)) + + err := tm.writeCertificateFiles(cert, &api.CertificateResponse{ + Certificate: &api.CertificateData{Certificate: "-----BEGIN CERTIFICATE-----\nrenewed\n"}, + }, true) + + require.NoError(t, err, "with no key on disk there is nothing to mismatch") +} + +func TestWriteCertificateFiles_ReplacementWithKeyRewritesBoth(t *testing.T) { + dir := t.TempDir() + cert := distributionConfigWithKeyPath(dir) + tm := &AgentManager{} + + require.NoError(t, os.WriteFile(cert.FileConfig.Certificate.Path, []byte("old cert"), 0o644)) + require.NoError(t, os.WriteFile(cert.FileConfig.PrivateKey.Path, []byte("old key"), 0o600)) + + err := tm.writeCertificateFiles(cert, &api.CertificateResponse{ + Certificate: &api.CertificateData{ + Certificate: "-----BEGIN CERTIFICATE-----\nrenewed\n", + PrivateKey: "-----BEGIN PRIVATE KEY-----\nnew\n", + }, + }, true) + + require.NoError(t, err) + key, readErr := os.ReadFile(cert.FileConfig.PrivateKey.Path) + require.NoError(t, readErr) + assert.Contains(t, string(key), "new", "the key must be rotated with the certificate") +} + +func TestWriteCertificateFiles_KeylessReplacementRefusedAfterRestart(t *testing.T) { + dir := t.TempDir() + cert := distributionConfigWithKeyPath(dir) + tm := &AgentManager{} + + require.NoError(t, os.WriteFile(cert.FileConfig.Certificate.Path, []byte("delivered before restart"), 0o644)) + require.NoError(t, os.WriteFile(cert.FileConfig.PrivateKey.Path, []byte("key from before restart"), 0o600)) + + assert.True(t, isReplacementOnDisk(cert), "a certificate already on disk is a replacement regardless of memory") + + err := tm.writeCertificateFiles(cert, &api.CertificateResponse{ + Certificate: &api.CertificateData{Certificate: "-----BEGIN CERTIFICATE-----\nkeyless renewal\n"}, + }, isReplacementOnDisk(cert)) + + require.Error(t, err) + assert.Contains(t, err.Error(), "would no longer match") +} + +func TestIsReplacementOnDisk_FalseOnFirstRun(t *testing.T) { + cert := distributionConfigWithKeyPath(t.TempDir()) + assert.False(t, isReplacementOnDisk(cert), "nothing on disk yet means this is a first delivery") +}