From f7e594d603c82500165ff664c97f2d2eb665906f Mon Sep 17 00:00:00 2001 From: Ananth Date: Thu, 22 Jan 2026 08:21:19 +0000 Subject: [PATCH 1/3] Use retryablehttp for HAProxy client HTTP requests Instead of writing our own retry logic for HTTP requests in the HAProxy client, we use retryablehttp. --- go.mod | 2 +- pkg/config/config.go | 8 +- pkg/haproxy/client.go | 185 ++++++------------------------------- pkg/haproxy/client_test.go | 164 -------------------------------- 4 files changed, 35 insertions(+), 324 deletions(-) diff --git a/go.mod b/go.mod index 395e20b..c724c7f 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.5 require ( github.com/go-acme/lego v2.7.2+incompatible github.com/go-acme/lego/v4 v4.25.2 + github.com/hashicorp/go-retryablehttp v0.7.8 github.com/hashicorp/vault/api v1.20.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/pkg/errors v0.9.1 @@ -101,7 +102,6 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect diff --git a/pkg/config/config.go b/pkg/config/config.go index 16e0ba7..b5ad930 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -55,15 +55,15 @@ type Config struct { // Configuration values specific to the certificatee tool type Certificatee struct { - UpdateInterval time.Duration `envconfig:"CERTIFICATEE_UPDATE_INTERVAL" default:"24h"` + UpdateInterval time.Duration `envconfig:"CERTIFICATEE_UPDATE_INTERVAL" default:"10m"` RenewBeforeDays int `envconfig:"CERTIFICATEE_RENEW_BEFORE_DAYS" default:"30"` // HAProxyDataPlaneAPIURLs is a comma-separated list of HAProxy Data Plane API URLs // Example: "http://127.0.0.1:5555,https://haproxy2.local:5555" - HAProxyDataPlaneAPIURLs []string `envconfig:"HAPROXY_DATAPLANE_API_URLS" default:""` + HAProxyDataPlaneAPIURLs []string `envconfig:"HAPROXY_DATAPLANE_API_URLS" default:"127.0.0.1:5555"` // HAProxyDataPlaneAPIUser is the username for HAProxy Data Plane API basic auth - HAProxyDataPlaneAPIUser string `envconfig:"HAPROXY_DATAPLANE_API_USER" default:""` + HAProxyDataPlaneAPIUser string `envconfig:"HAPROXY_DATAPLANE_API_USER"` // HAProxyDataPlaneAPIPassword is the password for HAProxy Data Plane API basic auth - HAProxyDataPlaneAPIPassword string `envconfig:"HAPROXY_DATAPLANE_API_PASSWORD" default:""` + HAProxyDataPlaneAPIPassword string `envconfig:"HAPROXY_DATAPLANE_API_PASSWORD""` // HAProxyDataPlaneAPIInsecure skips TLS certificate verification (not recommended for production) HAProxyDataPlaneAPIInsecure bool `envconfig:"HAPROXY_DATAPLANE_API_INSECURE" default:"false"` } diff --git a/pkg/haproxy/client.go b/pkg/haproxy/client.go index f97a1a5..16d50cb 100644 --- a/pkg/haproxy/client.go +++ b/pkg/haproxy/client.go @@ -8,7 +8,6 @@ import ( "encoding/pem" "fmt" "io" - "math" "mime/multipart" "net/http" "net/url" @@ -16,6 +15,7 @@ import ( "strings" "time" + "github.com/hashicorp/go-retryablehttp" "github.com/pkg/errors" "github.com/sirupsen/logrus" ) @@ -27,22 +27,6 @@ const ( DefaultRetryMaxDelay = 30 * time.Second ) -// RetryConfig holds retry configuration for HAProxy connections -type RetryConfig struct { - MaxRetries int // Maximum number of retry attempts (0 = no retries) - BaseDelay time.Duration // Initial delay between retries - MaxDelay time.Duration // Maximum delay between retries (for exponential backoff) -} - -// DefaultRetryConfig returns the default retry configuration -func DefaultRetryConfig() RetryConfig { - return RetryConfig{ - MaxRetries: DefaultMaxRetries, - BaseDelay: DefaultRetryBaseDelay, - MaxDelay: DefaultRetryMaxDelay, - } -} - // CertInfo holds certificate information from HAProxy Data Plane API type CertInfo struct { Filename string `json:"file"` @@ -62,13 +46,12 @@ type CertInfo struct { // Client is a HAProxy Data Plane API client type Client struct { - baseURL string - username string - password string - httpClient *http.Client - logger *logrus.Logger - retryConfig RetryConfig - timeout time.Duration + baseURL string + username string + password string + httpClient *http.Client + logger *logrus.Logger + timeout time.Duration } // ClientConfig holds configuration for creating a new Client @@ -100,17 +83,18 @@ func NewClient(cfg ClientConfig, logger *logrus.Logger) (*Client, error) { }, } + httpClient := retryablehttp.NewClient() + httpClient.Logger = logger + httpClient.HTTPClient.Transport = transport + httpClient.HTTPClient.Timeout = timeout + return &Client{ - baseURL: cfg.BaseURL, - username: cfg.Username, - password: cfg.Password, - httpClient: &http.Client{ - Timeout: timeout, - Transport: transport, - }, - logger: logger, - retryConfig: DefaultRetryConfig(), - timeout: timeout, + baseURL: cfg.BaseURL, + username: cfg.Username, + password: cfg.Password, + httpClient: httpClient.StandardClient(), + logger: logger, + timeout: timeout, }, nil } @@ -144,132 +128,23 @@ func (c *Client) Endpoint() string { return c.baseURL } -// SetRetryConfig sets the retry configuration for this client -func (c *Client) SetRetryConfig(config RetryConfig) { - c.retryConfig = config -} - -// GetRetryConfig returns the current retry configuration -func (c *Client) GetRetryConfig() RetryConfig { - return c.retryConfig -} - -// calculateBackoff calculates the delay for the given retry attempt using exponential backoff -func (c *Client) calculateBackoff(attempt int) time.Duration { - if attempt <= 0 { - return c.retryConfig.BaseDelay - } - - // Exponential backoff: baseDelay * 2^attempt - delay := float64(c.retryConfig.BaseDelay) * math.Pow(2, float64(attempt)) - - // Cap at max delay - if delay > float64(c.retryConfig.MaxDelay) { - delay = float64(c.retryConfig.MaxDelay) - } - - return time.Duration(delay) -} - // doRequest performs an HTTP request with retry logic func (c *Client) doRequest(method, path string, body io.Reader, contentType string) (*http.Response, error) { - var lastErr error url := c.baseURL + path - for attempt := 0; attempt <= c.retryConfig.MaxRetries; attempt++ { - if attempt > 0 { - delay := c.calculateBackoff(attempt - 1) - c.logger.Debugf("Retry %d/%d for %s after %v", attempt, c.retryConfig.MaxRetries, c.baseURL, delay) - time.Sleep(delay) - } - - // Need to recreate body for retries if it was consumed - var reqBody io.Reader - if body != nil { - // Read body into buffer for potential retries - if attempt == 0 { - reqBody = body - } else { - // Body was already consumed, skip retry with body - reqBody = nil - } - } - - // Create request - req, err := http.NewRequest(method, url, reqBody) - if err != nil { - return nil, errors.Wrap(err, "failed to create request") - } - - // Set headers - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } - if c.username != "" { - req.SetBasicAuth(c.username, c.password) - } - - // Execute request - resp, err := c.httpClient.Do(req) - if err == nil { - if attempt > 0 { - c.logger.Infof("Successfully connected to %s after %d retries", c.baseURL, attempt) - } - return resp, nil - } - - lastErr = err - c.logger.Debugf("Request attempt %d failed for %s: %v", attempt+1, c.baseURL, err) + // Create request + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, errors.Wrap(err, "failed to create request") } - - return nil, errors.Wrapf(lastErr, "failed to connect to HAProxy Data Plane API at %s after %d attempts", c.baseURL, c.retryConfig.MaxRetries+1) -} - -// doRequestWithBodyBuffer performs an HTTP request with retry logic, buffering body for retries -func (c *Client) doRequestWithBodyBuffer(method, path string, bodyData []byte, contentType string) (*http.Response, error) { - var lastErr error - url := c.baseURL + path - - for attempt := 0; attempt <= c.retryConfig.MaxRetries; attempt++ { - if attempt > 0 { - delay := c.calculateBackoff(attempt - 1) - c.logger.Debugf("Retry %d/%d for %s after %v", attempt, c.retryConfig.MaxRetries, c.baseURL, delay) - time.Sleep(delay) - } - - // Create request with fresh body reader - var body io.Reader - if bodyData != nil { - body = bytes.NewReader(bodyData) - } - - req, err := http.NewRequest(method, url, body) - if err != nil { - return nil, errors.Wrap(err, "failed to create request") - } - - // Set headers - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } - if c.username != "" { - req.SetBasicAuth(c.username, c.password) - } - - // Execute request - resp, err := c.httpClient.Do(req) - if err == nil { - if attempt > 0 { - c.logger.Infof("Successfully connected to %s after %d retries", c.baseURL, attempt) - } - return resp, nil - } - - lastErr = err - c.logger.Debugf("Request attempt %d failed for %s: %v", attempt+1, c.baseURL, err) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if c.username != "" { + req.SetBasicAuth(c.username, c.password) } - return nil, errors.Wrapf(lastErr, "failed to connect to HAProxy Data Plane API at %s after %d attempts", c.baseURL, c.retryConfig.MaxRetries+1) + return c.httpClient.Do(req) } // SSLCertificateEntry represents an SSL certificate entry from storage API @@ -455,7 +330,7 @@ func (c *Client) UpdateCertificate(certName, pemData string) error { // Send PUT request to replace certificate path := fmt.Sprintf("/v2/services/haproxy/runtime/certs/%s", certName) - resp, err := c.doRequestWithBodyBuffer("PUT", path, buf.Bytes(), writer.FormDataContentType()) + resp, err := c.doRequest("PUT", path, &buf, writer.FormDataContentType()) if err != nil { return err } @@ -490,7 +365,7 @@ func (c *Client) CreateCertificate(certName, pemData string) error { } // Send POST request to create certificate - resp, err := c.doRequestWithBodyBuffer("POST", "/v2/services/haproxy/runtime/certs", buf.Bytes(), writer.FormDataContentType()) + resp, err := c.doRequest("POST", "/v2/services/haproxy/runtime/certs", &buf, writer.FormDataContentType()) if err != nil { return err } diff --git a/pkg/haproxy/client_test.go b/pkg/haproxy/client_test.go index 30865a8..d1e9a5b 100644 --- a/pkg/haproxy/client_test.go +++ b/pkg/haproxy/client_test.go @@ -2,7 +2,6 @@ package haproxy import ( "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" @@ -13,10 +12,6 @@ import ( "github.com/sirupsen/logrus" ) -// ============================================================================= -// Unit Tests for Helper Functions -// ============================================================================= - func TestExtractDomainFromPath(t *testing.T) { tests := []struct { input string @@ -207,10 +202,6 @@ func TestParseDataPlaneAPITime(t *testing.T) { } } -// ============================================================================= -// Unit Tests for Client Constructors -// ============================================================================= - func TestNewClient(t *testing.T) { logger := logrus.New() logger.SetLevel(logrus.PanicLevel) // Suppress logs in tests @@ -370,10 +361,6 @@ func TestClientEndpoint(t *testing.T) { } } -// ============================================================================= -// Mock HAProxy Data Plane API Server -// ============================================================================= - // mockDataPlaneAPI simulates the HAProxy Data Plane API type mockDataPlaneAPI struct { server *httptest.Server @@ -442,10 +429,6 @@ func (m *mockDataPlaneAPI) SetHandler(method, path string, handler http.HandlerF m.handlers[method+" "+path] = handler } -// ============================================================================= -// Integration Tests -// ============================================================================= - func TestListCertificates(t *testing.T) { logger := logrus.New() logger.SetLevel(logrus.PanicLevel) @@ -878,10 +861,6 @@ func TestDeleteCertificate(t *testing.T) { } } -// ============================================================================= -// Authentication Tests -// ============================================================================= - func TestBasicAuth(t *testing.T) { logger := logrus.New() logger.SetLevel(logrus.PanicLevel) @@ -943,10 +922,6 @@ func TestBasicAuth(t *testing.T) { }) } -// ============================================================================= -// Connection Error Tests -// ============================================================================= - func TestConnectionError(t *testing.T) { logger := logrus.New() logger.SetLevel(logrus.PanicLevel) @@ -960,109 +935,12 @@ func TestConnectionError(t *testing.T) { t.Fatalf("NewClient() error = %v", err) } - // Disable retries for faster test - client.SetRetryConfig(RetryConfig{ - MaxRetries: 0, - BaseDelay: 10 * time.Millisecond, - MaxDelay: 50 * time.Millisecond, - }) - _, err = client.ListCertificates() if err == nil { t.Error("ListCertificates() expected connection error, got nil") } } -// ============================================================================= -// Retry Logic Tests -// ============================================================================= - -func TestDefaultRetryConfig(t *testing.T) { - config := DefaultRetryConfig() - - if config.MaxRetries != DefaultMaxRetries { - t.Errorf("MaxRetries = %d, want %d", config.MaxRetries, DefaultMaxRetries) - } - if config.BaseDelay != DefaultRetryBaseDelay { - t.Errorf("BaseDelay = %v, want %v", config.BaseDelay, DefaultRetryBaseDelay) - } - if config.MaxDelay != DefaultRetryMaxDelay { - t.Errorf("MaxDelay = %v, want %v", config.MaxDelay, DefaultRetryMaxDelay) - } -} - -func TestClientRetryConfig(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.PanicLevel) - - client, err := NewClient(ClientConfig{BaseURL: "http://localhost:5555"}, logger) - if err != nil { - t.Fatalf("NewClient() error = %v", err) - } - - // Check default config is applied - config := client.GetRetryConfig() - if config.MaxRetries != DefaultMaxRetries { - t.Errorf("Default MaxRetries = %d, want %d", config.MaxRetries, DefaultMaxRetries) - } - - // Set custom config - customConfig := RetryConfig{ - MaxRetries: 5, - BaseDelay: 500 * time.Millisecond, - MaxDelay: 10 * time.Second, - } - client.SetRetryConfig(customConfig) - - // Verify custom config - config = client.GetRetryConfig() - if config.MaxRetries != 5 { - t.Errorf("Custom MaxRetries = %d, want 5", config.MaxRetries) - } - if config.BaseDelay != 500*time.Millisecond { - t.Errorf("Custom BaseDelay = %v, want 500ms", config.BaseDelay) - } -} - -func TestCalculateBackoff(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.PanicLevel) - - client, err := NewClient(ClientConfig{BaseURL: "http://localhost:5555"}, logger) - if err != nil { - t.Fatalf("NewClient() error = %v", err) - } - - // Set known config for predictable testing - client.SetRetryConfig(RetryConfig{ - MaxRetries: 5, - BaseDelay: 1 * time.Second, - MaxDelay: 30 * time.Second, - }) - - tests := []struct { - attempt int - expected time.Duration - }{ - {0, 1 * time.Second}, // 1s * 2^0 = 1s - {1, 2 * time.Second}, // 1s * 2^1 = 2s - {2, 4 * time.Second}, // 1s * 2^2 = 4s - {3, 8 * time.Second}, // 1s * 2^3 = 8s - {4, 16 * time.Second}, // 1s * 2^4 = 16s - {5, 30 * time.Second}, // 1s * 2^5 = 32s, capped at 30s - {10, 30 * time.Second}, // Capped at max - } - - for _, tt := range tests { - t.Run(fmt.Sprintf("attempt_%d", tt.attempt), func(t *testing.T) { - delay := client.calculateBackoff(tt.attempt) - if delay != tt.expected { - t.Errorf("calculateBackoff(%d) = %v, want %v", tt.attempt, delay, tt.expected) - } - }) - } -} - func TestRetryOnConnectionFailure(t *testing.T) { logger := logrus.New() logger.SetLevel(logrus.PanicLevel) @@ -1076,13 +954,6 @@ func TestRetryOnConnectionFailure(t *testing.T) { t.Fatalf("NewClient() error = %v", err) } - // Set fast retry config for testing - client.SetRetryConfig(RetryConfig{ - MaxRetries: 2, - BaseDelay: 10 * time.Millisecond, - MaxDelay: 50 * time.Millisecond, - }) - start := time.Now() _, err = client.ListCertificates() elapsed := time.Since(start) @@ -1102,38 +973,3 @@ func TestRetryOnConnectionFailure(t *testing.T) { t.Errorf("Error should mention retry attempts: %v", err) } } - -func TestNoRetryWithZeroMaxRetries(t *testing.T) { - logger := logrus.New() - logger.SetLevel(logrus.PanicLevel) - - // Create client pointing to non-existent server - client, err := NewClient(ClientConfig{ - BaseURL: "http://127.0.0.1:59997", - Timeout: 50 * time.Millisecond, - }, logger) - if err != nil { - t.Fatalf("NewClient() error = %v", err) - } - - // Set no retries - client.SetRetryConfig(RetryConfig{ - MaxRetries: 0, // No retries - BaseDelay: 10 * time.Millisecond, - MaxDelay: 50 * time.Millisecond, - }) - - start := time.Now() - _, err = client.ListCertificates() - elapsed := time.Since(start) - - // Should fail immediately (no retries) - if err == nil { - t.Error("Expected connection error, got nil") - } - - // Should be fast since no retries - if elapsed > 500*time.Millisecond { - t.Errorf("Should have failed quickly without retries, elapsed: %v", elapsed) - } -} From 85fd677a1aa00f517daba50dabfc8964d774c953 Mon Sep 17 00:00:00 2001 From: Ananth Bhaskararaman Date: Thu, 22 Jan 2026 15:09:22 +0530 Subject: [PATCH 2/3] Move the haproxy cert tool out Use retryablehttp client and simplify code. Moved the haproxy cert tool out into its own repo. --- .github/workflows/test.yml | 3 - README.md | 2 +- cmd/certificatee/list_certs.go | 125 ---------- cmd/certificatee/main.go | 40 +-- cmd/certificatee/sync.go | 2 +- devenv.nix | 17 +- go.mod | 1 + go.sum | 4 +- pkg/haproxy/client.go | 43 +++- test/integration/dataplaneapi.yaml | 34 --- test/integration/haproxy.cfg | 34 --- test/integration/run-tests.sh | 377 ----------------------------- 12 files changed, 44 insertions(+), 638 deletions(-) delete mode 100644 cmd/certificatee/list_certs.go delete mode 100644 test/integration/dataplaneapi.yaml delete mode 100644 test/integration/haproxy.cfg delete mode 100755 test/integration/run-tests.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08447ea..8c9d5e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,8 +70,5 @@ jobs: - - name: Run Integration Tests - run: devenv shell integration-test - - name: Build Binaries run: devenv shell build diff --git a/README.md b/README.md index 95dd897..68fd4da 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The main certificate issuing tool that manages certificates through ACME (Let's ### Certificatee -A tool that synchronizes certificates from Vault to HAProxy using the HAProxy Data Plane API. It monitors certificates loaded in HAProxy and updates them when: +A daemon that synchronizes certificates from Vault to HAProxy using the HAProxy Data Plane API. It monitors certificates loaded in HAProxy and updates them when: - The certificate is expiring within the configured threshold (default: 30 days) - The certificate serial number differs from the one stored in Vault diff --git a/cmd/certificatee/list_certs.go b/cmd/certificatee/list_certs.go deleted file mode 100644 index e90fad8..0000000 --- a/cmd/certificatee/list_certs.go +++ /dev/null @@ -1,125 +0,0 @@ -package main - -import ( - "fmt" - "os" - "text/tabwriter" - "time" - - legoLog "github.com/go-acme/lego/v4/log" - "github.com/vinted/certificator/pkg/config" - "github.com/vinted/certificator/pkg/haproxy" -) - -func listCertsCmd(args []string) { - cfg, err := config.LoadConfig() - if err != nil { - cfg.Log.Logger.Fatal(err) - } - - logger := cfg.Log.Logger - legoLog.Logger = logger - - // Validate HAProxy Data Plane API configuration - if len(cfg.Certificatee.HAProxyDataPlaneAPIURLs) == 0 { - logger.Fatal("HAPROXY_DATAPLANE_API_URLS must be set (comma-separated list of Data Plane API URLs)") - } - - // Check for verbose flag - verbose := false - for _, arg := range args { - if arg == "-v" || arg == "--verbose" { - verbose = true - break - } - } - - haproxyClients, err := createHAProxyClients(cfg, logger) - if err != nil { - logger.Fatal(err) - } - - // Process each HAProxy endpoint - for _, client := range haproxyClients { - if err := listCertificates(client, verbose); err != nil { - logger.Errorf("Failed to list certificates from %s: %v", client.Endpoint(), err) - } - } -} - -func listCertificates(client *haproxy.Client, verbose bool) error { - endpoint := client.Endpoint() - fmt.Printf("\n=== Certificates on %s ===\n\n", endpoint) - - if verbose { - // Use ListCertificateRefs for verbose mode to get both display names and file paths - certRefs, err := client.ListCertificateRefs() - if err != nil { - return fmt.Errorf("failed to list certificates: %w", err) - } - - if len(certRefs) == 0 { - fmt.Println("No certificates found.") - return nil - } - - // Show detailed info for each certificate - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintln(w, "NAME\tSUBJECT\tISSUER\tNOT BEFORE\tNOT AFTER\tSERIAL") - _, _ = fmt.Fprintln(w, "----\t-------\t------\t----------\t---------\t------") - - for _, ref := range certRefs { - info, err := client.GetCertificateInfoByRef(ref) - if err != nil { - _, _ = fmt.Fprintf(w, "%s\t\t\t\t\t\n", ref.DisplayName, err) - continue - } - - notBefore := formatTime(info.NotBefore) - notAfter := formatTime(info.NotAfter) - - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", - ref.DisplayName, - truncate(info.Subject, 30), - truncate(info.Issuer, 30), - notBefore, - notAfter, - info.Serial, - ) - } - _ = w.Flush() - fmt.Printf("\nTotal: %d certificate(s)\n", len(certRefs)) - } else { - // Simple list - certPaths, err := client.ListCertificates() - if err != nil { - return fmt.Errorf("failed to list certificates: %w", err) - } - - if len(certPaths) == 0 { - fmt.Println("No certificates found.") - return nil - } - - for _, certPath := range certPaths { - fmt.Println(certPath) - } - fmt.Printf("\nTotal: %d certificate(s)\n", len(certPaths)) - } - - return nil -} - -func formatTime(t time.Time) string { - if t.IsZero() { - return "N/A" - } - return t.Format("2006-01-02") -} - -func truncate(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen-3] + "..." -} diff --git a/cmd/certificatee/main.go b/cmd/certificatee/main.go index 9a5968c..e6ea765 100644 --- a/cmd/certificatee/main.go +++ b/cmd/certificatee/main.go @@ -1,47 +1,9 @@ package main -import ( - "fmt" - "os" -) - var ( version = "dev" // GoReleaser will inject the Git tag here ) func main() { - // Parse subcommand - args := os.Args[1:] - cmd := "sync" // default command - - if len(args) > 0 && args[0] != "" && args[0][0] != '-' { - cmd = args[0] - args = args[1:] - } - - switch cmd { - case "sync": - syncCmd(args) - case "list-certs": - listCertsCmd(args) - case "help", "-h", "--help": - printUsage() - os.Exit(0) - default: - fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmd) - printUsage() - os.Exit(1) - } -} - -func printUsage() { - fmt.Println("Usage: certificatee [command] [options]") - fmt.Println() - fmt.Println("Commands:") - fmt.Println(" sync Run the certificate sync daemon (default)") - fmt.Println(" list-certs List certificates from HAProxy instances") - fmt.Println(" help Show this help message") - fmt.Println() - fmt.Println("Options for list-certs:") - fmt.Println(" -v, --verbose Show detailed certificate information") + syncCmd() } diff --git a/cmd/certificatee/sync.go b/cmd/certificatee/sync.go index 2c195b9..4c04821 100644 --- a/cmd/certificatee/sync.go +++ b/cmd/certificatee/sync.go @@ -16,7 +16,7 @@ import ( "github.com/vinted/certificator/pkg/vault" ) -func syncCmd(_ []string) { +func syncCmd() { cfg, err := config.LoadConfig() if err != nil { cfg.Log.Logger.Fatal(err) diff --git a/devenv.nix b/devenv.nix index c618031..1d99882 100644 --- a/devenv.nix +++ b/devenv.nix @@ -77,11 +77,10 @@ in scripts = { build.exec = '' export BUILD_DIR="''${BUILD_DIR:-build}" - echo "Building certificator to $BUILD_DIR..." - clean + echo "Building certificator in $BUILD_DIR..." mkdir -p "$BUILD_DIR" - go build -o "$BUILD_DIR" -v ./cmd/certificator - go build -o "$BUILD_DIR" -v ./cmd/certificatee + go build -o "$BUILD_DIR" ./cmd/certificator + go build -o "$BUILD_DIR" ./cmd/certificatee echo "Build complete!" ''; @@ -171,11 +170,6 @@ in echo "Clean complete!" ''; - # Integration test for certificatee list-certs - integration-test.exec = '' - echo "=== Running Integration Tests ===" - ${lib.getExe pkgs.bash} ./test/integration/run-tests.sh - ''; }; # Shell hook - runs when entering the devenv @@ -198,7 +192,6 @@ in echo " tidy - Tidy go.mod dependencies" echo " check - Run all checks (fmt, vet, lint, test)" echo " clean - Clean build artifacts" - echo " integration-test - Run HAProxy integration tests" echo "" ''; @@ -207,10 +200,6 @@ in echo "Running devenv tests..." go version command go test -v ./... - - echo "" - echo "Running integration tests..." - bash ./test/integration/run-tests.sh ''; git-hooks.hooks = { diff --git a/go.mod b/go.mod index c724c7f..0e562f3 100644 --- a/go.mod +++ b/go.mod @@ -174,6 +174,7 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.18.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1210 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect diff --git a/go.sum b/go.sum index 81e5293..7dbb13d 100644 --- a/go.sum +++ b/go.sum @@ -901,8 +901,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= diff --git a/pkg/haproxy/client.go b/pkg/haproxy/client.go index 16d50cb..e3a1fcf 100644 --- a/pkg/haproxy/client.go +++ b/pkg/haproxy/client.go @@ -20,13 +20,6 @@ import ( "github.com/sirupsen/logrus" ) -// Default retry configuration -const ( - DefaultMaxRetries = 3 - DefaultRetryBaseDelay = 1 * time.Second - DefaultRetryMaxDelay = 30 * time.Second -) - // CertInfo holds certificate information from HAProxy Data Plane API type CertInfo struct { Filename string `json:"file"` @@ -84,7 +77,7 @@ func NewClient(cfg ClientConfig, logger *logrus.Logger) (*Client, error) { } httpClient := retryablehttp.NewClient() - httpClient.Logger = logger + httpClient.Logger = &logrusLeveledLogger{logger: logger} httpClient.HTTPClient.Transport = transport httpClient.HTTPClient.Timeout = timeout @@ -433,3 +426,37 @@ func NormalizeSerial(serial string) string { re := regexp.MustCompile(`[^a-fA-F0-9]`) return strings.ToUpper(re.ReplaceAllString(serial, "")) } + +// logrusLeveledLogger wraps a logrus.Logger to implement retryablehttp.LeveledLogger +type logrusLeveledLogger struct { + logger *logrus.Logger +} + +func (l *logrusLeveledLogger) Error(msg string, keysAndValues ...interface{}) { + l.logger.WithFields(toLogrusFields(keysAndValues)).Error(msg) +} + +func (l *logrusLeveledLogger) Info(msg string, keysAndValues ...interface{}) { + l.logger.WithFields(toLogrusFields(keysAndValues)).Info(msg) +} + +func (l *logrusLeveledLogger) Debug(msg string, keysAndValues ...interface{}) { + l.logger.WithFields(toLogrusFields(keysAndValues)).Debug(msg) +} + +func (l *logrusLeveledLogger) Warn(msg string, keysAndValues ...interface{}) { + l.logger.WithFields(toLogrusFields(keysAndValues)).Warn(msg) +} + +// toLogrusFields converts key-value pairs to logrus.Fields +func toLogrusFields(keysAndValues []any) logrus.Fields { + fields := logrus.Fields{} + for i := 0; i+1 < len(keysAndValues); i += 2 { + key, ok := keysAndValues[i].(string) + if !ok { + continue + } + fields[key] = keysAndValues[i+1] + } + return fields +} diff --git a/test/integration/dataplaneapi.yaml b/test/integration/dataplaneapi.yaml deleted file mode 100644 index daa8f95..0000000 --- a/test/integration/dataplaneapi.yaml +++ /dev/null @@ -1,34 +0,0 @@ -config_version: 2 -name: test-dataplaneapi - -dataplaneapi: - host: 0.0.0.0 - port: 5555 - scheme: - - http - - user: - - name: admin - password: adminpwd - insecure: true - - resources: - maps_dir: /tmp/haproxy-test/maps - ssl_certs_dir: /tmp/haproxy-certs - spoe_dir: /tmp/haproxy-test/spoe - - transaction: - transaction_dir: /tmp/haproxy-test/transactions - -haproxy: - config_file: /tmp/haproxy-test/haproxy.cfg - haproxy_bin: haproxy - reload: - reload_delay: 5 - reload_cmd: "kill -SIGUSR2 $(cat /tmp/haproxy-test/haproxy.pid)" - restart_cmd: "kill -SIGUSR2 $(cat /tmp/haproxy-test/haproxy.pid)" - reload_strategy: custom - -log: - log_to: stdout - log_level: debug diff --git a/test/integration/haproxy.cfg b/test/integration/haproxy.cfg deleted file mode 100644 index 037b1d2..0000000 --- a/test/integration/haproxy.cfg +++ /dev/null @@ -1,34 +0,0 @@ -global - log stdout format raw local0 info - stats socket /tmp/haproxy.sock mode 660 level admin expose-fd listeners - stats timeout 30s - # SSL/TLS settings - ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256 - ssl-default-bind-options ssl-min-ver TLSv1.2 - # Certificate storage directory - crt-base /tmp/haproxy-certs - -defaults - log global - mode http - option httplog - option dontlognull - timeout connect 5000 - timeout client 50000 - timeout server 50000 - -frontend http_front - bind *:8080 - default_backend http_back - -frontend https_front - bind *:8443 ssl crt /tmp/haproxy-certs/ - default_backend http_back - -backend http_back - server local 127.0.0.1:9999 check - -# Program section for Data Plane API -program api - command dataplaneapi --host 0.0.0.0 --port 5555 --haproxy-bin "$(which haproxy)" --config-file /tmp/haproxy-test/haproxy.cfg --reload-cmd "kill -SIGUSR2 1" --userlist dataplaneapi - no option start-on-reload diff --git a/test/integration/run-tests.sh b/test/integration/run-tests.sh deleted file mode 100755 index 14fae4c..0000000 --- a/test/integration/run-tests.sh +++ /dev/null @@ -1,377 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" -TEST_DIR="/tmp/haproxy-test" -CERTS_DIR="/tmp/haproxy-certs" - -# PIDs for cleanup -HAPROXY_PID="" -DATAPLANEAPI_PID="" - -log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } -log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } -log_error() { echo -e "${RED}[ERROR]${NC} $1"; } - -cleanup() { - log_info "Cleaning up..." - - if [[ -n "${DATAPLANEAPI_PID}" ]] && kill -0 "${DATAPLANEAPI_PID}" 2>/dev/null; then - log_info "Stopping Data Plane API (PID: ${DATAPLANEAPI_PID})..." - kill "${DATAPLANEAPI_PID}" 2>/dev/null || true - wait "${DATAPLANEAPI_PID}" 2>/dev/null || true - fi - - if [[ -n "${HAPROXY_PID}" ]] && kill -0 "${HAPROXY_PID}" 2>/dev/null; then - log_info "Stopping HAProxy (PID: ${HAPROXY_PID})..." - kill "${HAPROXY_PID}" 2>/dev/null || true - wait "${HAPROXY_PID}" 2>/dev/null || true - fi - - # Clean up test directories - rm -rf "${TEST_DIR}" "${CERTS_DIR}" 2>/dev/null || true - rm -f /tmp/haproxy.sock 2>/dev/null || true - - log_info "Cleanup complete" -} - -trap cleanup EXIT - -setup_directories() { - log_info "Setting up test directories..." - mkdir -p "${TEST_DIR}"/{maps,spoe,spoe-transactions,transactions,general,dataplane,backups} - mkdir -p "${CERTS_DIR}" -} - -generate_test_certificates() { - log_info "Generating test certificates..." - - # Generate CA - openssl genrsa -out "${CERTS_DIR}/ca.key" 2048 2>/dev/null - openssl req -x509 -new -nodes -key "${CERTS_DIR}/ca.key" \ - -sha256 -days 1 -out "${CERTS_DIR}/ca.crt" \ - -subj "/CN=Test CA" 2>/dev/null - - # Generate test certificates for different domains - for domain in "example.com" "api.example.com" "test.example.org"; do - log_info " Generating certificate for ${domain}..." - - # Generate private key - openssl genrsa -out "${CERTS_DIR}/${domain}.key" 2048 2>/dev/null - - # Generate CSR - openssl req -new -key "${CERTS_DIR}/${domain}.key" \ - -out "${CERTS_DIR}/${domain}.csr" \ - -subj "/CN=${domain}" 2>/dev/null - - # Sign with CA - openssl x509 -req -in "${CERTS_DIR}/${domain}.csr" \ - -CA "${CERTS_DIR}/ca.crt" -CAkey "${CERTS_DIR}/ca.key" \ - -CAcreateserial -out "${CERTS_DIR}/${domain}.crt" \ - -days 1 -sha256 2>/dev/null - - # Create combined PEM file (cert + key) for HAProxy - cat "${CERTS_DIR}/${domain}.crt" "${CERTS_DIR}/${domain}.key" > "${CERTS_DIR}/${domain}.pem" - - # Clean up intermediate files (HAProxy will try to load .crt files otherwise) - rm -f "${CERTS_DIR}/${domain}.csr" "${CERTS_DIR}/${domain}.crt" "${CERTS_DIR}/${domain}.key" - done - - # Also clean up CA files from the certs directory - rm -f "${CERTS_DIR}/ca.key" "${CERTS_DIR}/ca.crt" "${CERTS_DIR}/ca.srl" - - log_info "Test certificates generated" -} - -create_haproxy_config() { - log_info "Creating HAProxy configuration..." - - cat > "${TEST_DIR}/haproxy.cfg" << EOF -global - log stdout format raw local0 info - stats socket /tmp/haproxy.sock mode 660 level admin expose-fd listeners - stats timeout 30s - -defaults - log global - mode http - option httplog - option dontlognull - timeout connect 5000 - timeout client 50000 - timeout server 50000 - -frontend http_front - bind *:18080 - default_backend http_back - -frontend https_front - bind *:18443 ssl crt ${CERTS_DIR}/ - default_backend http_back - -backend http_back - server local 127.0.0.1:19999 check -EOF - - log_info "HAProxy configuration created" -} - -start_haproxy() { - log_info "Starting HAProxy..." - - # Start HAProxy with master-worker mode (-W) for runtime API support - haproxy -W -f "${TEST_DIR}/haproxy.cfg" -D -p "${TEST_DIR}/haproxy.pid" - sleep 2 - - if [[ -f "${TEST_DIR}/haproxy.pid" ]]; then - HAPROXY_PID=$(cat "${TEST_DIR}/haproxy.pid") - log_info "HAProxy started (PID: ${HAPROXY_PID})" - - # Verify socket is available - if [[ -S /tmp/haproxy.sock ]]; then - log_info "HAProxy socket is available" - else - log_warn "HAProxy socket not found at /tmp/haproxy.sock" - fi - else - log_error "Failed to start HAProxy" - exit 1 - fi -} - -start_dataplaneapi() { - log_info "Starting Data Plane API..." - - # Create Data Plane API configuration file - cat > "${TEST_DIR}/dataplaneapi.yaml" << EOF -config_version: 2 -name: test-dataplaneapi - -dataplaneapi: - host: 127.0.0.1 - port: 5555 - scheme: - - http - user: - - name: admin - password: adminpwd - insecure: true - resources: - maps_dir: ${TEST_DIR}/maps - ssl_certs_dir: ${CERTS_DIR} - spoe_dir: ${TEST_DIR}/spoe - spoe_transaction_dir: ${TEST_DIR}/spoe-transactions - general_storage_dir: ${TEST_DIR}/general - dataplane_storage_dir: ${TEST_DIR}/dataplane - backups_dir: ${TEST_DIR}/backups - transaction: - transaction_dir: ${TEST_DIR}/transactions - -haproxy: - config_file: ${TEST_DIR}/haproxy.cfg - haproxy_bin: $(which haproxy) - master_runtime: /tmp/haproxy.sock - reload: - reload_delay: 1 - reload_cmd: "echo reload" - restart_cmd: "echo restart" - reload_strategy: custom - -log: - log_to: stdout - log_level: debug -EOF - - # Start the Data Plane API with config file - dataplaneapi \ - -f "${TEST_DIR}/dataplaneapi.yaml" \ - > "${TEST_DIR}/dataplaneapi.log" 2>&1 & - - DATAPLANEAPI_PID=$! - log_info "Data Plane API starting (PID: ${DATAPLANEAPI_PID})..." - - # Wait for API to be ready - local retries=30 - while ! curl -sf http://127.0.0.1:5555/v2/services/haproxy/runtime/info -u admin:adminpwd > /dev/null 2>&1; do - retries=$((retries - 1)) - if [[ ${retries} -le 0 ]]; then - log_error "Data Plane API failed to start. Logs:" - cat "${TEST_DIR}/dataplaneapi.log" || true - exit 1 - fi - sleep 0.5 - done - - log_info "Data Plane API is ready" -} - -test_list_certs() { - log_info "Testing 'certificatee list-certs' command..." - - # Set required environment variables - export HAPROXY_DATAPLANE_API_URLS="http://127.0.0.1:5555" - export HAPROXY_DATAPLANE_API_USER="admin" - export HAPROXY_DATAPLANE_API_PASSWORD="adminpwd" - export HAPROXY_DATAPLANE_API_INSECURE="true" - - # Run list-certs and capture output - local output - output=$("${TEST_DIR}/certificatee" list-certs 2>&1) || { - log_error "certificatee list-certs failed" - echo "${output}" - return 1 - } - - log_info "Output from 'certificatee list-certs':" - echo "${output}" - echo "" - - # Verify expected certificates are listed - local expected_certs=("example.com.pem" "api.example.com.pem" "test.example.org.pem") - local found_count=0 - - for cert in "${expected_certs[@]}"; do - if echo "${output}" | grep -q "${cert}"; then - log_info " Found certificate: ${cert}" - found_count=$((found_count + 1)) - else - log_warn " Missing certificate: ${cert}" - fi - done - - if [[ ${found_count} -eq ${#expected_certs[@]} ]]; then - log_info "All expected certificates found!" - return 0 - else - log_error "Not all certificates were found (${found_count}/${#expected_certs[@]})" - return 1 - fi -} - -test_list_certs_verbose() { - log_info "Testing 'certificatee list-certs --verbose' command..." - - # Set required environment variables - export HAPROXY_DATAPLANE_API_URLS="http://127.0.0.1:5555" - export HAPROXY_DATAPLANE_API_USER="admin" - export HAPROXY_DATAPLANE_API_PASSWORD="adminpwd" - export HAPROXY_DATAPLANE_API_INSECURE="true" - - # Run list-certs with verbose flag - local output - output=$("${TEST_DIR}/certificatee" list-certs --verbose 2>&1) || { - log_error "certificatee list-certs --verbose failed" - echo "${output}" - return 1 - } - - log_info "Output from 'certificatee list-certs --verbose':" - echo "${output}" - echo "" - - # Verify verbose output contains expected columns - if echo "${output}" | grep -q "SUBJECT"; then - log_info " Verbose output contains SUBJECT column" - else - log_error " Missing SUBJECT column in verbose output" - return 1 - fi - - if echo "${output}" | grep -q "NOT AFTER"; then - log_info " Verbose output contains NOT AFTER column" - else - log_error " Missing NOT AFTER column in verbose output" - return 1 - fi - - log_info "Verbose output format is correct!" - return 0 -} - -test_api_connectivity() { - log_info "Testing Data Plane API connectivity..." - - # First, check API info endpoint - log_info "Checking API info..." - local info - info=$(curl -s http://127.0.0.1:5555/v2/info -u admin:adminpwd) || true - echo "API Info: ${info}" - - # Check available endpoints - log_info "Checking runtime info..." - local runtime_info - runtime_info=$(curl -s http://127.0.0.1:5555/v2/services/haproxy/runtime/info -u admin:adminpwd) || true - echo "Runtime Info: ${runtime_info}" - - # Try to list certificates via storage endpoint - log_info "Checking storage certs endpoint..." - local storage_certs - storage_certs=$(curl -s http://127.0.0.1:5555/v2/services/haproxy/storage/ssl_certificates -u admin:adminpwd) || true - echo "Storage Certs: ${storage_certs}" - - # Check runtime certs endpoint - log_info "Checking runtime certs endpoint..." - local runtime_certs - runtime_certs=$(curl -s http://127.0.0.1:5555/v2/services/haproxy/runtime/certs -u admin:adminpwd) || true - echo "Runtime Certs: ${runtime_certs}" - - if echo "${runtime_certs}" | grep -q "404"; then - log_warn "Runtime certs endpoint returned 404" - log_info "Data Plane API logs:" - tail -20 "${TEST_DIR}/dataplaneapi.log" || true - fi - - log_info "API connectivity test complete" - return 0 -} - -main() { - log_info "==========================================" - log_info " Certificatee Integration Tests" - log_info "==========================================" - echo "" - - setup_directories - generate_test_certificates - create_haproxy_config - start_haproxy - start_dataplaneapi - export BUILD_DIR="${TEST_DIR}" - build - - echo "" - log_info "Running tests..." - echo "" - - local failed=0 - - test_api_connectivity || failed=$((failed + 1)) - echo "" - - test_list_certs || failed=$((failed + 1)) - echo "" - - test_list_certs_verbose || failed=$((failed + 1)) - echo "" - - if [[ ${failed} -eq 0 ]]; then - log_info "==========================================" - log_info " All integration tests passed!" - log_info "==========================================" - exit 0 - else - log_error "==========================================" - log_error " ${failed} test(s) failed" - log_error "==========================================" - exit 1 - fi -} - -main "$@" From 67793e102b2160485204736c361dbeab9fd02335 Mon Sep 17 00:00:00 2001 From: Ananth Bhaskararaman Date: Thu, 22 Jan 2026 15:12:41 +0530 Subject: [PATCH 3/3] simplify --- cmd/certificatee/main.go | 249 +++++++++++++++++++++++++++++++++++++- cmd/certificatee/sync.go | 252 --------------------------------------- 2 files changed, 248 insertions(+), 253 deletions(-) delete mode 100644 cmd/certificatee/sync.go diff --git a/cmd/certificatee/main.go b/cmd/certificatee/main.go index e6ea765..5691282 100644 --- a/cmd/certificatee/main.go +++ b/cmd/certificatee/main.go @@ -1,9 +1,256 @@ package main +import ( + "crypto/x509" + "encoding/hex" + "errors" + "fmt" + "time" + + legoLog "github.com/go-acme/lego/v4/log" + "github.com/sirupsen/logrus" + "github.com/vinted/certificator/pkg/certificate" + "github.com/vinted/certificator/pkg/certmetrics" + "github.com/vinted/certificator/pkg/config" + "github.com/vinted/certificator/pkg/haproxy" + "github.com/vinted/certificator/pkg/vault" +) + var ( version = "dev" // GoReleaser will inject the Git tag here ) func main() { - syncCmd() + cfg, err := config.LoadConfig() + if err != nil { + cfg.Log.Logger.Fatal(err) + } + + logger := cfg.Log.Logger + legoLog.Logger = logger + + // Validate HAProxy Data Plane API configuration + if len(cfg.Certificatee.HAProxyDataPlaneAPIURLs) == 0 { + logger.Fatal("HAPROXY_DATAPLANE_API_URLS must be set (comma-separated list of Data Plane API URLs)") + } + + certmetrics.StartMetricsServer(logger, cfg.Metrics.ListenAddress) + defer certmetrics.PushMetrics(logger, cfg.Metrics.PushUrl) + + vaultClient, err := vault.NewVaultClient(cfg.Vault.ApproleRoleID, + cfg.Vault.ApproleSecretID, cfg.Environment, cfg.Vault.KVStoragePath, logger) + if err != nil { + logger.Fatal(err) + } + + haproxyClients, err := createHAProxyClients(cfg, logger) + if err != nil { + logger.Fatal(err) + } + + logger.Infof("Configured %d HAProxy endpoint(s)", len(haproxyClients)) + for _, client := range haproxyClients { + logger.Infof(" - %s", client.Endpoint()) + } + + ticker := time.NewTicker(cfg.Certificatee.UpdateInterval) + defer ticker.Stop() + + certmetrics.Up.WithLabelValues("certificatee", version, cfg.Hostname, cfg.Environment).Set(1) + defer certmetrics.Up.WithLabelValues("certificatee", version, cfg.Hostname, cfg.Environment).Set(0) + + // Initial run + if err := maybeUpdateCertificates(logger, cfg, vaultClient, haproxyClients); err != nil { + logger.Error(err) + } + + for range ticker.C { + if err := maybeUpdateCertificates(logger, cfg, vaultClient, haproxyClients); err != nil { + logger.Error(err) + } + } +} + +func maybeUpdateCertificates(logger *logrus.Logger, cfg config.Config, vaultClient *vault.VaultClient, haproxyClients []*haproxy.Client) error { + var allErrs []error + + for _, haproxyClient := range haproxyClients { + endpoint := haproxyClient.Endpoint() + logger.Infof("Processing HAProxy endpoint: %s", endpoint) + + if err := processHAProxyEndpoint(logger, cfg, vaultClient, haproxyClient); err != nil { + allErrs = append(allErrs, fmt.Errorf("endpoint %s: %w", endpoint, err)) + logger.Errorf("Failed to process endpoint %s: %v", endpoint, err) + } + } + + return errors.Join(allErrs...) +} + +func processHAProxyEndpoint(logger *logrus.Logger, cfg config.Config, vaultClient *vault.VaultClient, haproxyClient *haproxy.Client) error { + endpoint := haproxyClient.Endpoint() + + // Get list of certificates from HAProxy with file paths for lookups + certRefs, err := haproxyClient.ListCertificateRefs() + if err != nil { + certmetrics.HAProxyEndpointUp.WithLabelValues(endpoint).Set(0) + return fmt.Errorf("failed to list certificates: %w", err) + } + + // Mark endpoint as up and record sync timestamp + certmetrics.HAProxyEndpointUp.WithLabelValues(endpoint).Set(1) + certmetrics.LastSyncTimestamp.WithLabelValues(endpoint).SetToCurrentTime() + certmetrics.CertificatesTotal.WithLabelValues(endpoint).Set(float64(len(certRefs))) + + logger.Infof("[%s] %d certificates found", endpoint, len(certRefs)) + + var errs []error + var expiringCount int + + for _, ref := range certRefs { + certPath := ref.DisplayName + logger.Infof("[%s] Checking certificate: %s", endpoint, certPath) + + // Get certificate info from HAProxy using the file path + haproxyCertInfo, err := haproxyClient.GetCertificateInfoByRef(ref) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get certificate info for %s: %w", certPath, err)) + logger.Errorf("[%s] %v", endpoint, err) + continue + } + + // Extract domain name from certificate path + domain := haproxy.ExtractDomainFromPath(certPath) + logger.Debugf("[%s] Extracted domain '%s' from path '%s'", endpoint, domain, certPath) + + // Track expiring certificates + if haproxy.IsExpiring(haproxyCertInfo, cfg.Certificatee.RenewBeforeDays) { + expiringCount++ + } + + // Check if certificate needs update + shouldUpdate, reason, err := shouldUpdateCertificate(logger, haproxyCertInfo, domain, vaultClient, cfg.Certificatee.RenewBeforeDays) + if err != nil { + errs = append(errs, err) + logger.Errorf("[%s] %v", endpoint, err) + continue + } + + if shouldUpdate { + logger.Infof("[%s] Certificate %s needs update: %s", endpoint, certPath, reason) + + if err := updateCertificate(logger, certPath, domain, vaultClient, haproxyClient); err != nil { + errs = append(errs, err) + logger.Errorf("[%s] %v", endpoint, err) + certmetrics.CertificatesUpdateFailures.WithLabelValues(endpoint, domain).Inc() + } else { + certmetrics.CertificatesUpdated.WithLabelValues(endpoint, domain).Inc() + logger.Infof("[%s] Certificate %s updated successfully!", endpoint, certPath) + } + } else { + logger.Infof("[%s] Certificate %s is up to date", endpoint, certPath) + } + } + + // Record expiring certificates count + certmetrics.CertificatesExpiring.WithLabelValues(endpoint).Set(float64(expiringCount)) + + return errors.Join(errs...) +} + +func shouldUpdateCertificate(logger *logrus.Logger, haproxyCertInfo *haproxy.CertInfo, domain string, vaultClient *vault.VaultClient, renewBeforeDays int) (bool, string, error) { + // Get certificate from Vault + vaultCert, err := certificate.GetCertificate(domain, vaultClient) + if err != nil { + return false, "", fmt.Errorf("failed to get certificate %s from vault: %w", domain, err) + } + + if vaultCert == nil { + return false, "", fmt.Errorf("certificate for %s does not exist in vault", domain) + } + + // Check if HAProxy certificate is expiring + if haproxy.IsExpiring(haproxyCertInfo, renewBeforeDays) { + return true, fmt.Sprintf("certificate expires on %s (within %d days)", haproxyCertInfo.NotAfter.Format(time.RFC3339), renewBeforeDays), nil + } + + // Compare serial numbers + if serialsDiffer(haproxyCertInfo, vaultCert) { + return true, fmt.Sprintf("serial mismatch: HAProxy=%s, Vault=%s", + haproxyCertInfo.Serial, formatSerial(vaultCert.SerialNumber.Bytes())), nil + } + + return false, "", nil +} + +// serialsDiffer compares the serial numbers of HAProxy and Vault certificates +func serialsDiffer(haproxyCertInfo *haproxy.CertInfo, vaultCert *x509.Certificate) bool { + if haproxyCertInfo == nil || vaultCert == nil { + return true + } + + haproxySerial := haproxy.NormalizeSerial(haproxyCertInfo.Serial) + vaultSerial := haproxy.NormalizeSerial(formatSerial(vaultCert.SerialNumber.Bytes())) + + return haproxySerial != vaultSerial +} + +// formatSerial converts a certificate serial number to hex string +func formatSerial(serial []byte) string { + return hex.EncodeToString(serial) +} + +func updateCertificate(logger *logrus.Logger, certPath, domain string, vaultClient *vault.VaultClient, haproxyClient *haproxy.Client) error { + // Read certificate data from Vault + certificateSecrets, err := vaultClient.KVRead(certificate.VaultCertLocation(domain)) + if err != nil { + return fmt.Errorf("failed to read certificate data from vault for %s: %w", domain, err) + } + + // Build PEM bundle (certificate + private key) + pemData, err := buildPEMBundle(certificateSecrets) + if err != nil { + return fmt.Errorf("failed to build PEM bundle for %s: %w", domain, err) + } + + // Update certificate in HAProxy + if err := haproxyClient.UpdateCertificate(certPath, pemData); err != nil { + return fmt.Errorf("failed to update certificate %s in HAProxy: %w", certPath, err) + } + + return nil +} + +// buildPEMBundle creates a PEM bundle from Vault certificate secrets +func buildPEMBundle(secrets map[string]any) (string, error) { + var pemData string + + // Add certificate + if cert, ok := secrets["certificate"].(string); ok && cert != "" { + pemData += cert + } else { + return "", fmt.Errorf("certificate not found in vault secrets") + } + + // Add newline between cert and key + if !endsWith(pemData, "\n") { + pemData += "\n" + } + + // Add private key + if key, ok := secrets["private_key"].(string); ok && key != "" { + pemData += key + } else { + return "", fmt.Errorf("private_key not found in vault secrets") + } + + return pemData, nil +} + +// endsWith checks if a string ends with a suffix +func endsWith(s, suffix string) bool { + if len(s) < len(suffix) { + return false + } + return s[len(s)-len(suffix):] == suffix } diff --git a/cmd/certificatee/sync.go b/cmd/certificatee/sync.go deleted file mode 100644 index 4c04821..0000000 --- a/cmd/certificatee/sync.go +++ /dev/null @@ -1,252 +0,0 @@ -package main - -import ( - "crypto/x509" - "encoding/hex" - "errors" - "fmt" - "time" - - legoLog "github.com/go-acme/lego/v4/log" - "github.com/sirupsen/logrus" - "github.com/vinted/certificator/pkg/certificate" - "github.com/vinted/certificator/pkg/certmetrics" - "github.com/vinted/certificator/pkg/config" - "github.com/vinted/certificator/pkg/haproxy" - "github.com/vinted/certificator/pkg/vault" -) - -func syncCmd() { - cfg, err := config.LoadConfig() - if err != nil { - cfg.Log.Logger.Fatal(err) - } - - logger := cfg.Log.Logger - legoLog.Logger = logger - - // Validate HAProxy Data Plane API configuration - if len(cfg.Certificatee.HAProxyDataPlaneAPIURLs) == 0 { - logger.Fatal("HAPROXY_DATAPLANE_API_URLS must be set (comma-separated list of Data Plane API URLs)") - } - - certmetrics.StartMetricsServer(logger, cfg.Metrics.ListenAddress) - defer certmetrics.PushMetrics(logger, cfg.Metrics.PushUrl) - - vaultClient, err := vault.NewVaultClient(cfg.Vault.ApproleRoleID, - cfg.Vault.ApproleSecretID, cfg.Environment, cfg.Vault.KVStoragePath, logger) - if err != nil { - logger.Fatal(err) - } - - haproxyClients, err := createHAProxyClients(cfg, logger) - if err != nil { - logger.Fatal(err) - } - - logger.Infof("Configured %d HAProxy endpoint(s)", len(haproxyClients)) - for _, client := range haproxyClients { - logger.Infof(" - %s", client.Endpoint()) - } - - ticker := time.NewTicker(cfg.Certificatee.UpdateInterval) - defer ticker.Stop() - - certmetrics.Up.WithLabelValues("certificatee", version, cfg.Hostname, cfg.Environment).Set(1) - defer certmetrics.Up.WithLabelValues("certificatee", version, cfg.Hostname, cfg.Environment).Set(0) - - // Initial run - if err := maybeUpdateCertificates(logger, cfg, vaultClient, haproxyClients); err != nil { - logger.Error(err) - } - - for range ticker.C { - if err := maybeUpdateCertificates(logger, cfg, vaultClient, haproxyClients); err != nil { - logger.Error(err) - } - } -} - -func maybeUpdateCertificates(logger *logrus.Logger, cfg config.Config, vaultClient *vault.VaultClient, haproxyClients []*haproxy.Client) error { - var allErrs []error - - for _, haproxyClient := range haproxyClients { - endpoint := haproxyClient.Endpoint() - logger.Infof("Processing HAProxy endpoint: %s", endpoint) - - if err := processHAProxyEndpoint(logger, cfg, vaultClient, haproxyClient); err != nil { - allErrs = append(allErrs, fmt.Errorf("endpoint %s: %w", endpoint, err)) - logger.Errorf("Failed to process endpoint %s: %v", endpoint, err) - } - } - - return errors.Join(allErrs...) -} - -func processHAProxyEndpoint(logger *logrus.Logger, cfg config.Config, vaultClient *vault.VaultClient, haproxyClient *haproxy.Client) error { - endpoint := haproxyClient.Endpoint() - - // Get list of certificates from HAProxy with file paths for lookups - certRefs, err := haproxyClient.ListCertificateRefs() - if err != nil { - certmetrics.HAProxyEndpointUp.WithLabelValues(endpoint).Set(0) - return fmt.Errorf("failed to list certificates: %w", err) - } - - // Mark endpoint as up and record sync timestamp - certmetrics.HAProxyEndpointUp.WithLabelValues(endpoint).Set(1) - certmetrics.LastSyncTimestamp.WithLabelValues(endpoint).SetToCurrentTime() - certmetrics.CertificatesTotal.WithLabelValues(endpoint).Set(float64(len(certRefs))) - - logger.Infof("[%s] %d certificates found", endpoint, len(certRefs)) - - var errs []error - var expiringCount int - - for _, ref := range certRefs { - certPath := ref.DisplayName - logger.Infof("[%s] Checking certificate: %s", endpoint, certPath) - - // Get certificate info from HAProxy using the file path - haproxyCertInfo, err := haproxyClient.GetCertificateInfoByRef(ref) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get certificate info for %s: %w", certPath, err)) - logger.Errorf("[%s] %v", endpoint, err) - continue - } - - // Extract domain name from certificate path - domain := haproxy.ExtractDomainFromPath(certPath) - logger.Debugf("[%s] Extracted domain '%s' from path '%s'", endpoint, domain, certPath) - - // Track expiring certificates - if haproxy.IsExpiring(haproxyCertInfo, cfg.Certificatee.RenewBeforeDays) { - expiringCount++ - } - - // Check if certificate needs update - shouldUpdate, reason, err := shouldUpdateCertificate(logger, haproxyCertInfo, domain, vaultClient, cfg.Certificatee.RenewBeforeDays) - if err != nil { - errs = append(errs, err) - logger.Errorf("[%s] %v", endpoint, err) - continue - } - - if shouldUpdate { - logger.Infof("[%s] Certificate %s needs update: %s", endpoint, certPath, reason) - - if err := updateCertificate(logger, certPath, domain, vaultClient, haproxyClient); err != nil { - errs = append(errs, err) - logger.Errorf("[%s] %v", endpoint, err) - certmetrics.CertificatesUpdateFailures.WithLabelValues(endpoint, domain).Inc() - } else { - certmetrics.CertificatesUpdated.WithLabelValues(endpoint, domain).Inc() - logger.Infof("[%s] Certificate %s updated successfully!", endpoint, certPath) - } - } else { - logger.Infof("[%s] Certificate %s is up to date", endpoint, certPath) - } - } - - // Record expiring certificates count - certmetrics.CertificatesExpiring.WithLabelValues(endpoint).Set(float64(expiringCount)) - - return errors.Join(errs...) -} - -func shouldUpdateCertificate(logger *logrus.Logger, haproxyCertInfo *haproxy.CertInfo, domain string, vaultClient *vault.VaultClient, renewBeforeDays int) (bool, string, error) { - // Get certificate from Vault - vaultCert, err := certificate.GetCertificate(domain, vaultClient) - if err != nil { - return false, "", fmt.Errorf("failed to get certificate %s from vault: %w", domain, err) - } - - if vaultCert == nil { - return false, "", fmt.Errorf("certificate for %s does not exist in vault", domain) - } - - // Check if HAProxy certificate is expiring - if haproxy.IsExpiring(haproxyCertInfo, renewBeforeDays) { - return true, fmt.Sprintf("certificate expires on %s (within %d days)", haproxyCertInfo.NotAfter.Format(time.RFC3339), renewBeforeDays), nil - } - - // Compare serial numbers - if serialsDiffer(haproxyCertInfo, vaultCert) { - return true, fmt.Sprintf("serial mismatch: HAProxy=%s, Vault=%s", - haproxyCertInfo.Serial, formatSerial(vaultCert.SerialNumber.Bytes())), nil - } - - return false, "", nil -} - -// serialsDiffer compares the serial numbers of HAProxy and Vault certificates -func serialsDiffer(haproxyCertInfo *haproxy.CertInfo, vaultCert *x509.Certificate) bool { - if haproxyCertInfo == nil || vaultCert == nil { - return true - } - - haproxySerial := haproxy.NormalizeSerial(haproxyCertInfo.Serial) - vaultSerial := haproxy.NormalizeSerial(formatSerial(vaultCert.SerialNumber.Bytes())) - - return haproxySerial != vaultSerial -} - -// formatSerial converts a certificate serial number to hex string -func formatSerial(serial []byte) string { - return hex.EncodeToString(serial) -} - -func updateCertificate(logger *logrus.Logger, certPath, domain string, vaultClient *vault.VaultClient, haproxyClient *haproxy.Client) error { - // Read certificate data from Vault - certificateSecrets, err := vaultClient.KVRead(certificate.VaultCertLocation(domain)) - if err != nil { - return fmt.Errorf("failed to read certificate data from vault for %s: %w", domain, err) - } - - // Build PEM bundle (certificate + private key) - pemData, err := buildPEMBundle(certificateSecrets) - if err != nil { - return fmt.Errorf("failed to build PEM bundle for %s: %w", domain, err) - } - - // Update certificate in HAProxy - if err := haproxyClient.UpdateCertificate(certPath, pemData); err != nil { - return fmt.Errorf("failed to update certificate %s in HAProxy: %w", certPath, err) - } - - return nil -} - -// buildPEMBundle creates a PEM bundle from Vault certificate secrets -func buildPEMBundle(secrets map[string]interface{}) (string, error) { - var pemData string - - // Add certificate - if cert, ok := secrets["certificate"].(string); ok && cert != "" { - pemData += cert - } else { - return "", fmt.Errorf("certificate not found in vault secrets") - } - - // Add newline between cert and key - if !endsWith(pemData, "\n") { - pemData += "\n" - } - - // Add private key - if key, ok := secrets["private_key"].(string); ok && key != "" { - pemData += key - } else { - return "", fmt.Errorf("private_key not found in vault secrets") - } - - return pemData, nil -} - -// endsWith checks if a string ends with a suffix -func endsWith(s, suffix string) bool { - if len(s) < len(suffix) { - return false - } - return s[len(s)-len(suffix):] == suffix -}