diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 420a479..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "Certificator", - "image": "docker.io/golang:latest", - "features": { - "ghcr.io/devcontainers/features/common-utils": { - "installZsh": true, - "installOhMyZsh": true, - "installOhMyZshConfig": true, - "configureZshAsDefaultShell": true, - "upgradePackages": true - }, - "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} - } -} diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..cc5c18b --- /dev/null +++ b/.envrc @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +export DIRENV_WARN_TIMEOUT=20s + +eval "$(devenv direnvrc)" + +# `use devenv` supports the same options as the `devenv shell` command. +# +# To silence all output, use `--quiet`. +# +# Example usage: use devenv --quiet --impure --option services.postgres.enable:bool true +use devenv diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e215b5a..08447ea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,34 +10,68 @@ on: jobs: test: - name: Test + name: Lint and Test runs-on: ubuntu-latest permissions: contents: read - pull-requests: read + pull-requests: write steps: - name: Checkout Code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - with: - go-version-file: go.mod - check-latest: false + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@c5a866b6ab867e88becbed4467b93592bce69f8a # v21 - - name: Run GolangCI Lint - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 - with: - version: v2.6 - args: --timeout=5m + - name: Setup Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13 + + - name: Install devenv + run: nix profile add nixpkgs#devenv + + - name: Run Checks (lint, vet, test) + run: devenv shell check - - name: Test And Build - env: - CGO_ENABLED: 0 + - name: Generate Coverage Report + run: devenv shell test-coverage + + - name: Extract Coverage Data + if: github.event_name == 'pull_request' + id: coverage run: | - go vet ./... - go test ./... - go build -o certificator ./cmd/certificator - go build -o certificatee ./cmd/certificatee + { + echo 'report<> "$GITHUB_OUTPUT" + { + echo 'details<> "$GITHUB_OUTPUT" + + - name: Post Coverage Comment + if: github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 + with: + header: coverage + message: | + ## Code Coverage Report + + **Total Coverage:** `${{ steps.coverage.outputs.report }}` + +
+ Coverage by function + + ``` + ${{ steps.coverage.outputs.details }} + ``` + +
+ + - name: Run Integration Tests + run: devenv shell integration-test + + - name: Build Binaries + run: devenv shell build diff --git a/.gitignore b/.gitignore index 5f1b799..bff8e2d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.dll *.so *.dylib +build/ # Test binary, built with `go test -c` *.test @@ -25,3 +26,14 @@ devenv.local.nix .pre-commit-config.yaml # Added by goreleaser init: dist/ + +# Devenv +.devenv* +devenv.local.nix +devenv.local.yaml + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/README.md b/README.md index c06d119..b5a1b0a 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,166 @@ This is a fork of [vinted's certificator](https://github.com/vinted/certificator As such this repository has been stripped down, removing various upstream tests which are no longer valid. These can be reintroduced if they are fixed, but there's no value to keeping them while they are not. -We have also added a devcontainer and a workflow for building the application container ready for use in nomad. \ No newline at end of file +## Components + +### Certificator + +The main certificate issuing tool that manages certificates through ACME (Let's Encrypt) and stores them in Vault. + +### 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: +- The certificate is expiring within the configured threshold (default: 30 days) +- The certificate serial number differs from the one stored in Vault + +## Configuration + +### Certificatee Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `HAPROXY_DATAPLANE_API_URLS` | (required) | Comma-separated list of HAProxy Data Plane API URLs. Example: `http://haproxy1:5555,http://haproxy2:5555` | +| `HAPROXY_DATAPLANE_API_USER` | (required) | Username for HAProxy Data Plane API authentication | +| `HAPROXY_DATAPLANE_API_PASSWORD` | (required) | Password for HAProxy Data Plane API authentication | +| `HAPROXY_DATAPLANE_API_INSECURE` | `false` | Skip TLS certificate verification for HTTPS connections | +| `CERTIFICATEE_UPDATE_INTERVAL` | `24h` | How often to check certificates for updates | +| `CERTIFICATEE_RENEW_BEFORE_DAYS` | `30` | Update certificates expiring within this many days | +| `VAULT_APPROLE_ROLE_ID` | (required) | Vault AppRole Role ID | +| `NOMAD_TOKEN` | (required) | Used as Vault AppRole Secret ID | +| `VAULT_KV_STORAGE_PATH` | `secret/data/certificator/` | Vault KV storage path for certificates | +| `METRICS_LISTEN_ADDRESS` | | Address for Prometheus metrics endpoint (e.g., `:9090`) | +| `METRICS_PUSH_URL` | | URL to push metrics on shutdown | +| `LOG_FORMAT` | `JSON` | Log format: `JSON` or `LOGFMT` | +| `LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARN`, `ERROR` | +| `ENVIRONMENT` | `prod` | Environment name for metrics labels | + +### Certificator Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `CERTIFICATOR_DOMAINS` | | Comma-separated list of domains to manage | +| `CERTIFICATOR_RENEW_BEFORE_DAYS` | `30` | Renew certificates expiring within this many days | +| `ACME_ACCOUNT_EMAIL` | | Email for ACME account | +| `ACME_DNS_CHALLENGE_PROVIDER` | | DNS challenge provider name | +| `ACME_DNS_PROPAGATION_REQUIREMENT` | `true` | Wait for DNS propagation | +| `ACME_SERVER_URL` | `https://acme-staging-v02.api.letsencrypt.org/directory` | ACME server URL | +| `EAB_KID` | | External Account Binding Key ID | +| `EAB_HMAC_KEY` | | External Account Binding HMAC Key | + +## HAProxy Data Plane API Integration + +Certificatee uses the HAProxy Data Plane API to update certificates at runtime without restarting HAProxy. It supports: + +- **Multiple endpoints**: Configure multiple HAProxy instances to update simultaneously +- **HTTPS with optional TLS verification**: Connect securely with configurable certificate verification +- **Basic authentication**: Authenticate using username/password credentials +- **Automatic retries**: Connections are retried with exponential backoff (default: 3 retries, 1-30s delays) +- **Graceful degradation**: If one HAProxy instance is unreachable, the tool continues updating reachable instances +- **REST API**: Certificates are managed via the `/v3/services/haproxy/runtime/certs` endpoints + +### HAProxy Data Plane API Configuration + +The HAProxy Data Plane API must be installed and configured separately. See the [HAProxy Data Plane API documentation](https://www.haproxy.com/documentation/dataplaneapi/latest/) for installation instructions. + +Example Data Plane API configuration (`dataplaneapi.yaml`): + +```yaml +dataplaneapi: + host: 0.0.0.0 + port: 5555 + user: + - name: admin + password: your-secure-password + insecure: false + haproxy: + config_file: /etc/haproxy/haproxy.cfg + haproxy_bin: /usr/sbin/haproxy +``` + +Certificate files must be named after the domain (e.g., `/etc/haproxy/certs/example.com.pem`). + +## Metrics + +Certificatee exposes Prometheus metrics for monitoring: + +### General Metrics + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `up` | Gauge | service, version, hostname, environment | Indicates if the service is running (1=up, 0=down) | +| `certificatee_certificates_updated_on_disk_total` | Gauge | domain | Certificates updated successfully | +| `certificatee_certificates_update_failures_total` | Counter | domain | Certificate update failures | + +### HAProxy-Specific Metrics + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `certificatee_haproxy_endpoint_up` | Gauge | endpoint | HAProxy endpoint reachability (1=up, 0=down) | +| `certificatee_haproxy_connections_total` | Counter | endpoint, status | Total connection attempts (status: success/failure) | +| `certificatee_haproxy_connection_retries_total` | Counter | endpoint | Connection retry attempts | +| `certificatee_haproxy_certificates_checked_total` | Counter | endpoint | Certificates checked per endpoint | +| `certificatee_haproxy_certificates_updated_total` | Counter | endpoint, domain | Certificates updated per endpoint/domain | +| `certificatee_haproxy_last_check_timestamp_seconds` | Gauge | endpoint | Unix timestamp of last successful check | +| `certificatee_haproxy_command_duration_seconds` | Histogram | endpoint, command | Duration of HAProxy Data Plane API requests | + +Not an exhaustive list; refer to the source code for all metrics. + +## Development + +### Using devenv + +The project includes a `devenv.nix` for development. +You can also just go build stuff. + +### Running Tests + +```bash +go test ./... +``` + +### Building + +```bash +go build ./cmd/certificatee +go build ./cmd/certificator +``` + +## Architecture + +```mermaid +flowchart TB + subgraph ACME["ACME Provider"] + LE[Let's Encrypt] + end + + subgraph Storage["Certificate Storage"] + Vault[(Vault)] + end + + subgraph Issuance["Certificate Issuance"] + Certificator[Certificator] + end + + subgraph Distribution["Certificate Distribution"] + Certificatee[Certificatee] + end + + subgraph HAProxyCluster["HAProxy Cluster"] + HAProxy1[HAProxy #1] + HAProxy2[HAProxy #2] + HAProxy3[HAProxy #3] + HAProxyN[HAProxy #N] + end + + LE -->|Issues certs| Certificator + Certificator -->|Stores certs| Vault + Vault -->|Reads certs| Certificatee + Certificatee -->|Data Plane API| HAProxy1 + Certificatee -->|Data Plane API| HAProxy2 + Certificatee -->|Data Plane API| HAProxy3 + Certificatee -.->|Data Plane API| HAProxyN +``` + +## License + +See the original [vinted/certificator](https://github.com/vinted/certificator) repository for license information. diff --git a/cmd/certificatee/helpers.go b/cmd/certificatee/helpers.go new file mode 100644 index 0000000..a2cc43b --- /dev/null +++ b/cmd/certificatee/helpers.go @@ -0,0 +1,21 @@ +package main + +import ( + "github.com/sirupsen/logrus" + "github.com/vinted/certificator/pkg/config" + "github.com/vinted/certificator/pkg/haproxy" +) + +func createHAProxyClients(cfg config.Config, logger *logrus.Logger) ([]*haproxy.Client, error) { + var clientConfigs []haproxy.ClientConfig + for _, url := range cfg.Certificatee.HAProxyDataPlaneAPIURLs { + clientConfigs = append(clientConfigs, haproxy.ClientConfig{ + BaseURL: url, + Username: cfg.Certificatee.HAProxyDataPlaneAPIUser, + Password: cfg.Certificatee.HAProxyDataPlaneAPIPassword, + InsecureSkipVerify: cfg.Certificatee.HAProxyDataPlaneAPIInsecure, + }) + } + + return haproxy.NewClients(clientConfigs, logger) +} diff --git a/cmd/certificatee/list_certs.go b/cmd/certificatee/list_certs.go new file mode 100644 index 0000000..e90fad8 --- /dev/null +++ b/cmd/certificatee/list_certs.go @@ -0,0 +1,125 @@ +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 86846a1..9a5968c 100644 --- a/cmd/certificatee/main.go +++ b/cmd/certificatee/main.go @@ -1,21 +1,8 @@ package main import ( - "bytes" - "errors" "fmt" "os" - "path/filepath" - "strings" - "time" - - "github.com/go-acme/lego/certcrypto" - 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/vault" ) var ( @@ -23,157 +10,38 @@ var ( ) func main() { - cfg, err := config.LoadConfig() - if err != nil { - cfg.Log.Logger.Fatal(err) - } - - logger := cfg.Log.Logger - legoLog.Logger = logger - - 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) - } - - 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); err != nil { - logger.Error(err) - } - - for range ticker.C { - if err := maybeUpdateCertificates(logger, cfg, vaultClient); err != nil { - logger.Error(err) - } - } -} - -func maybeUpdateCertificates(logger *logrus.Logger, cfg config.Config, vaultClient *vault.VaultClient) error { - certificateNames, err := getCertificateNames(cfg.Certificatee.CertificatePath, cfg.Certificatee.CertificateExtension) - if err != nil { - logger.Fatalf("Error: %v, Path: '%s', have you set CERTIFICATEE_CERTIFICATE_PATH?", err, cfg.Certificatee.CertificatePath) - } - - logger.Infof("%v Certificates found!", len(certificateNames)) - - var errs []error - for _, certificateName := range certificateNames { - logger.Infof("Comparing certificates for %s", certificateName) - certificateFullPath := filepath.Clean(filepath.Join(cfg.Certificatee.CertificatePath, certificateName+cfg.Certificatee.CertificateExtension)) - - shouldUpdateCertificate, err := shouldUpdateCertificate(logger, certificateFullPath, certificateName, vaultClient) - if err != nil { - errs = append(errs, err) - logger.Error(err) - } - - if shouldUpdateCertificate { - if err := updateCertificate(certificateFullPath, certificateName, vaultClient); err != nil { - errs = append(errs, err) - logger.Error(err) - certmetrics.CertificatesUpdateFailures.WithLabelValues(certificateName).Inc() - } else { - certmetrics.CertificatesUpdatedOnDisk.WithLabelValues(certificateName).Set(1) - logger.Infof("Certificate %s updated!", certificateName) - } - } - } - - return errors.Join(errs...) -} - -func getCertificateNames(path string, certificateExtension string) ([]string, error) { - var certificateNames []string - - certDirFiles, err := os.ReadDir(path) - if err != nil { - return certificateNames, err - } - - for _, certDirFile := range certDirFiles { - fileExtension := filepath.Ext(certDirFile.Name()) - if certificateExtension == fileExtension { - certificateName := strings.TrimSuffix(certDirFile.Name(), certificateExtension) - certificateNames = append(certificateNames, certificateName) - } - } - - return certificateNames, nil -} - -func shouldUpdateCertificate(logger *logrus.Logger, path string, certificateName string, vaultClient *vault.VaultClient) (bool, error) { - certificateFileInfo, err := os.Stat(path) - if err != nil { - return false, fmt.Errorf("error reading file at path %s - %w", path, err) - } - - if certificateFileInfo.Size() == 0 { - logger.Infof("Certificate file for %s is empty, deploying certificate from vault..", certificateName) - return true, nil - } - - certificateFileContents, err := os.ReadFile(path) // nolint:gosec - if err != nil { - return false, fmt.Errorf("error reading file at path %s - %w", path, err) - } - - parsedCertificateFile, err := certcrypto.ParsePEMBundle(certificateFileContents) - if err != nil { - return false, fmt.Errorf("error parsing PEM bundle - %w", err) - - } - - parsedVaultCert, err := certificate.GetCertificate(certificateName, vaultClient) - if err != nil { - return false, fmt.Errorf("error getting certificate %s from vault - %w", certificateName, err) - } - - if parsedVaultCert == nil { - return false, fmt.Errorf("certificate for %s does not exist in vault", certificateName) + // 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) } - - if bytes.Equal(parsedCertificateFile[0].RawTBSCertificate, parsedVaultCert.RawTBSCertificate) { - logger.Infof("Certificate %s matches!", certificateName) - return false, nil - } - - return true, nil } -func updateCertificate(path string, certificateName string, vaultClient *vault.VaultClient) error { - certificateSecrets, err := vaultClient.KVRead(certificate.VaultCertLocation(certificateName)) - if err != nil { - return fmt.Errorf("error reading data from vault key value storage %w", err) - } - - var newCertificateFile []byte - - if vaultCertificate, ok := certificateSecrets["certificate"].(string); ok { - newCertificateFile = append(newCertificateFile, []byte(vaultCertificate)...) - } - - // Add a new line between cert and key - newCertificateFile = append(newCertificateFile, []byte("\n")...) - - if vaultPrivateKey, ok := certificateSecrets["private_key"].(string); ok { - newCertificateFile = append(newCertificateFile, []byte(vaultPrivateKey)...) - } - - // Write key to file - err = os.WriteFile(path, newCertificateFile, 0600) - if err != nil { - return fmt.Errorf("error writing new certificate to file %w", err) - } - - return nil +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") } diff --git a/cmd/certificatee/main_test.go b/cmd/certificatee/main_test.go new file mode 100644 index 0000000..a1dda35 --- /dev/null +++ b/cmd/certificatee/main_test.go @@ -0,0 +1,392 @@ +package main + +import ( + "crypto/rand" + "crypto/x509" + "math/big" + "testing" + "time" + + "github.com/vinted/certificator/pkg/haproxy" +) + +func TestEndsWith(t *testing.T) { + tests := []struct { + name string + s string + suffix string + expected bool + }{ + {"empty strings", "", "", true}, + {"empty suffix", "hello", "", true}, + {"empty string with suffix", "", "x", false}, + {"exact match", "hello", "hello", true}, + {"suffix match", "hello", "lo", true}, + {"no match", "hello", "la", false}, + {"suffix longer than string", "lo", "hello", false}, + {"newline suffix", "hello\n", "\n", true}, + {"no newline suffix", "hello", "\n", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := endsWith(tt.s, tt.suffix) + if result != tt.expected { + t.Errorf("endsWith(%q, %q) = %v, want %v", tt.s, tt.suffix, result, tt.expected) + } + }) + } +} + +func TestFormatSerial(t *testing.T) { + tests := []struct { + name string + serial []byte + expected string + }{ + {"empty", []byte{}, ""}, + {"single byte", []byte{0x1f}, "1f"}, + {"multiple bytes", []byte{0x1f, 0x52, 0x02}, "1f5202"}, + {"leading zero byte", []byte{0x00, 0x1f}, "001f"}, + {"all zeros", []byte{0x00, 0x00}, "0000"}, + {"max bytes", []byte{0xff, 0xff}, "ffff"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatSerial(tt.serial) + if result != tt.expected { + t.Errorf("formatSerial(%v) = %q, want %q", tt.serial, result, tt.expected) + } + }) + } +} + +func TestSerialsDiffer(t *testing.T) { + // Create test certificates with specific serial numbers + serial1 := big.NewInt(0x1f5202) + serial2 := big.NewInt(0x1f5203) + + cert1 := &x509.Certificate{SerialNumber: serial1} + cert2 := &x509.Certificate{SerialNumber: serial2} + + tests := []struct { + name string + haproxyCert *haproxy.CertInfo + vaultCert *x509.Certificate + expectDiffer bool + }{ + { + name: "nil haproxy cert", + haproxyCert: nil, + vaultCert: cert1, + expectDiffer: true, + }, + { + name: "nil vault cert", + haproxyCert: &haproxy.CertInfo{Serial: "1F5202"}, + vaultCert: nil, + expectDiffer: true, + }, + { + name: "both nil", + haproxyCert: nil, + vaultCert: nil, + expectDiffer: true, + }, + { + name: "matching serials uppercase", + haproxyCert: &haproxy.CertInfo{Serial: "1F5202"}, + vaultCert: cert1, + expectDiffer: false, + }, + { + name: "matching serials lowercase", + haproxyCert: &haproxy.CertInfo{Serial: "1f5202"}, + vaultCert: cert1, + expectDiffer: false, + }, + { + name: "matching serials with colons", + haproxyCert: &haproxy.CertInfo{Serial: "1F:52:02"}, + vaultCert: cert1, + expectDiffer: false, + }, + { + name: "different serials", + haproxyCert: &haproxy.CertInfo{Serial: "1F5202"}, + vaultCert: cert2, + expectDiffer: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := serialsDiffer(tt.haproxyCert, tt.vaultCert) + if result != tt.expectDiffer { + t.Errorf("serialsDiffer() = %v, want %v", result, tt.expectDiffer) + } + }) + } +} + +func TestBuildPEMBundle(t *testing.T) { + tests := []struct { + name string + secrets map[string]interface{} + expectError bool + expectPEM string + }{ + { + name: "valid cert and key with newlines", + secrets: map[string]interface{}{ + "certificate": "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n", + }, + expectError: false, + expectPEM: "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n", + }, + { + name: "valid cert and key without trailing newline", + secrets: map[string]interface{}{ + "certificate": "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----", + }, + expectError: false, + expectPEM: "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----", + }, + { + name: "missing certificate", + secrets: map[string]interface{}{ + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----", + }, + expectError: true, + }, + { + name: "missing private_key", + secrets: map[string]interface{}{ + "certificate": "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----", + }, + expectError: true, + }, + { + name: "empty secrets", + secrets: map[string]interface{}{}, + expectError: true, + }, + { + name: "empty certificate value", + secrets: map[string]interface{}{ + "certificate": "", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----", + }, + expectError: true, + }, + { + name: "empty private_key value", + secrets: map[string]interface{}{ + "certificate": "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----", + "private_key": "", + }, + expectError: true, + }, + { + name: "wrong type for certificate", + secrets: map[string]interface{}{ + "certificate": 12345, + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----", + }, + expectError: true, + }, + { + name: "wrong type for private_key", + secrets: map[string]interface{}{ + "certificate": "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----", + "private_key": 12345, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := buildPEMBundle(tt.secrets) + if tt.expectError { + if err == nil { + t.Errorf("buildPEMBundle() expected error, got nil") + } + } else { + if err != nil { + t.Errorf("buildPEMBundle() unexpected error: %v", err) + } + if result != tt.expectPEM { + t.Errorf("buildPEMBundle() = %q, want %q", result, tt.expectPEM) + } + } + }) + } +} + +func TestBuildPEMBundleWithRealCertFormat(t *testing.T) { + // Test with realistic PEM format + cert := `-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAJC1HiIAZAiUMA0GCSqGSIb3Qw0BBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTExMjMxMDg1OTQ0WhcNMTIxMjMwMDg1OTQ0WjBF +-----END CERTIFICATE-----` + + //nolint:gosec // This is a test key, not a real credential + key := `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA0m59l2u9iDnMbrXHfqkOrn2dVQ3vfBJqcDuFUK03d+1PZGbV +-----END RSA PRIVATE KEY-----` + + secrets := map[string]interface{}{ + "certificate": cert, + "private_key": key, + } + + result, err := buildPEMBundle(secrets) + if err != nil { + t.Fatalf("buildPEMBundle() unexpected error: %v", err) + } + + // Verify structure + if !containsSubstring(result, "-----BEGIN CERTIFICATE-----") { + t.Error("result should contain certificate header") + } + if !containsSubstring(result, "-----END CERTIFICATE-----") { + t.Error("result should contain certificate footer") + } + if !containsSubstring(result, "-----BEGIN RSA PRIVATE KEY-----") { + t.Error("result should contain private key header") + } + if !containsSubstring(result, "-----END RSA PRIVATE KEY-----") { + t.Error("result should contain private key footer") + } +} + +// Helper function to check if string contains substring +func containsSubstring(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || findSubstring(s, substr)) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// Test that formatSerial works with actual x509 certificate serial numbers +func TestFormatSerialWithRandomSerial(t *testing.T) { + // Generate a random serial number like a real CA would + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + t.Fatalf("failed to generate serial number: %v", err) + } + + serialBytes := serialNumber.Bytes() + result := formatSerial(serialBytes) + + // Verify the result is a valid hex string + if len(result) == 0 && len(serialBytes) > 0 { + t.Error("formatSerial should return non-empty string for non-empty bytes") + } + + // Verify all characters are valid hex + for _, c := range result { + isDigit := c >= '0' && c <= '9' + isHexLetter := c >= 'a' && c <= 'f' + if !isDigit && !isHexLetter { + t.Errorf("formatSerial returned invalid hex character: %c", c) + } + } +} + +// Benchmark tests +func BenchmarkEndsWith(b *testing.B) { + s := "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n" + suffix := "\n" + + for i := 0; i < b.N; i++ { + endsWith(s, suffix) + } +} + +func BenchmarkFormatSerial(b *testing.B) { + serial := []byte{0x1f, 0x52, 0x02, 0xe0, 0x20, 0x83, 0x86, 0x1b, 0x30, 0x2f, 0xfa, 0x09, 0x04, 0x57, 0x21, 0xf0, 0x7c, 0x86, 0x5e, 0xfd} + + for i := 0; i < b.N; i++ { + formatSerial(serial) + } +} + +func BenchmarkBuildPEMBundle(b *testing.B) { + secrets := map[string]interface{}{ + "certificate": "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n", + } + + for i := 0; i < b.N; i++ { + _, _ = buildPEMBundle(secrets) + } +} + +// Test edge cases for time-based logic (used in shouldUpdateCertificate) +func TestCertificateExpiryLogic(t *testing.T) { + // Test IsExpiring function from haproxy package + now := time.Now() + + tests := []struct { + name string + notAfter time.Time + renewBeforeDays int + expectExpiring bool + }{ + { + name: "expired certificate", + notAfter: now.AddDate(0, 0, -1), + renewBeforeDays: 30, + expectExpiring: true, + }, + { + name: "expiring within threshold", + notAfter: now.AddDate(0, 0, 15), + renewBeforeDays: 30, + expectExpiring: true, + }, + { + name: "expiring at threshold", + notAfter: now.AddDate(0, 0, 30), + renewBeforeDays: 30, + expectExpiring: true, + }, + { + name: "not expiring", + notAfter: now.AddDate(0, 0, 60), + renewBeforeDays: 30, + expectExpiring: false, + }, + { + name: "zero threshold", + notAfter: now.AddDate(0, 0, 1), + renewBeforeDays: 0, + expectExpiring: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + certInfo := &haproxy.CertInfo{ + NotAfter: tt.notAfter, + } + result := haproxy.IsExpiring(certInfo, tt.renewBeforeDays) + if result != tt.expectExpiring { + t.Errorf("IsExpiring() = %v, want %v (notAfter: %v, threshold: %d days)", + result, tt.expectExpiring, tt.notAfter, tt.renewBeforeDays) + } + }) + } +} diff --git a/cmd/certificatee/sync.go b/cmd/certificatee/sync.go new file mode 100644 index 0000000..2c195b9 --- /dev/null +++ b/cmd/certificatee/sync.go @@ -0,0 +1,252 @@ +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(_ []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)") + } + + 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 +} diff --git a/cmd/certificator/main.go b/cmd/certificator/main.go index 0b4af2e..34736e6 100644 --- a/cmd/certificator/main.go +++ b/cmd/certificator/main.go @@ -68,19 +68,18 @@ func main() { cfg.DNSAddress, cfg.Acme.DNSChallengeProvider, cfg.Acme.DNSPropagationRequirement) if err != nil { failedDomains = append(failedDomains, mainDomain) + certmetrics.CertificatesRenewalFailures.WithLabelValues(mainDomain).Inc() logger.Error(err) continue } + certmetrics.CertificatesRenewed.WithLabelValues(mainDomain).Inc() + logger.Infof("certificate for %s renewed successfully", mainDomain) } else { - certmetrics.CertificatesCurrent.WithLabelValues(mainDomain).Set(1) logger.Infof("certificate for %s is up to date, skipping renewal", mainDomain) } } if len(failedDomains) > 0 { - for _, domain := range failedDomains { - certmetrics.CertificatesReissueFailures.WithLabelValues(domain).Inc() - } logger.Fatalf("Failed to renew certificates for: %v", failedDomains) } } diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000..4509510 --- /dev/null +++ b/devenv.lock @@ -0,0 +1,103 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1768272662, + "owner": "cachix", + "repo": "devenv", + "rev": "8851f6d40a61091a39076f55e2765ca2b15b733e", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "owner": "NixOS", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "flake-compat", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1767281941, + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "f0927703b7b1c8d97511c4116eb9b4ec6645a0fa", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1762808025, + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1767052823, + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "538a5124359f0b3d466e1160378c87887e3b51a4", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "git-hooks": "git-hooks", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": [ + "git-hooks" + ] + } + } + }, + "root": "root", + "version": 7 +} diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..c618031 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,225 @@ +{ pkgs, lib, config, inputs, ... }: + +let + # Read Go version from go.mod automatically + goModContent = builtins.readFile ./go.mod; + goVersionLine = lib.findFirst + (line: lib.hasPrefix "go " line) + "go 1.24" + (lib.splitString "\n" goModContent); + goVersion = lib.removePrefix "go " (lib.trim goVersionLine); + + # HAProxy Data Plane API - built from source + dataplaneapi = pkgs.buildGoModule rec { + pname = "dataplaneapi"; + version = "3.0.2"; + + src = pkgs.fetchFromGitHub { + owner = "haproxytech"; + repo = "dataplaneapi"; + rev = "v${version}"; + hash = "sha256-SFI7WKPxF31b97Q4EWbsTbp3laXHcUfdg4hlFUiml5A="; + }; + + vendorHash = "sha256-vm+NUf8OCW+jCiPY13d/MjQpy3/NxEwx7Zol2bP+eF4="; + + # Skip tests as they require network access + doCheck = false; + + ldflags = [ + "-s" "-w" + "-X main.GitRepo=https://github.com/haproxytech/dataplaneapi" + "-X main.GitTag=v${version}" + ]; + + meta = with lib; { + description = "HAProxy Data Plane API"; + homepage = "https://github.com/haproxytech/dataplaneapi"; + license = licenses.asl20; + }; + }; + +in +{ + env = { + CGO_ENABLED = "0"; + # Use the Go version from go.mod via GOTOOLCHAIN + # This allows the Go toolchain to download the exact version if needed + GOTOOLCHAIN = lib.mkForce "go${goVersion}+auto"; + }; + + packages = with pkgs; [ + git + go-tools # staticcheck, etc. + gotools # goimports, godoc, etc. + golangci-lint # Comprehensive linter + delve # Debugger + gopls # Language server + gomodifytags # Modify struct tags + impl # Generate interface stubs + gotests # Generate tests + gocover-cobertura # Coverage reports + goreleaser # Release automation + gotestsum # Better test output + jq # JSON processing + yq # YAML processing + curl # HTTP client + socat # Socket testing (useful for HAProxy runtime API testing) + haproxy # HAProxy load balancer + dataplaneapi # HAProxy Data Plane API + openssl # For generating test certificates + ]; + + languages.go = { + enable = true; + }; + + scripts = { + build.exec = '' + export BUILD_DIR="''${BUILD_DIR:-build}" + echo "Building certificator to $BUILD_DIR..." + clean + mkdir -p "$BUILD_DIR" + go build -o "$BUILD_DIR" -v ./cmd/certificator + go build -o "$BUILD_DIR" -v ./cmd/certificatee + echo "Build complete!" + ''; + + run-tests.exec = '' + echo "Running tests..." + go test -v ./... + ''; + + test-coverage.exec = '' + echo "Running tests with coverage..." + go test -v -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + echo "Coverage report: coverage.html" + ''; + + tidy.exec = '' + echo "Tidying dependencies..." + go mod tidy + go mod verify + ''; + + # Run all checks (format, lint, vet, test) + check.exec = '' + echo "=== Running all checks ===" + echo "" + echo ">>> Checking gofmt..." + GOFMT_OUTPUT=$(find . -name '*.go' -not -path './.devenv/*' -not -path './vendor/*' | xargs gofmt -l -s) + if [ -n "$GOFMT_OUTPUT" ]; then + echo "gofmt found formatting issues in:" + echo "$GOFMT_OUTPUT" + echo "" + echo "Run 'gofmt -w -s .' to fix" + exit 1 + fi + echo "gofmt: OK" + + echo "" + echo ">>> Checking goimports..." + GOIMPORTS_OUTPUT=$(find . -name '*.go' -not -path './.devenv/*' -not -path './vendor/*' | xargs goimports -l) + if [ -n "$GOIMPORTS_OUTPUT" ]; then + echo "goimports found issues in:" + echo "$GOIMPORTS_OUTPUT" + echo "" + echo "Run 'goimports -w .' to fix" + exit 1 + fi + echo "goimports: OK" + + echo "" + echo ">>> Running go vet..." + go vet ./... + + echo "" + echo ">>> Running golangci-lint..." + golangci-lint run ./... + + echo "" + echo ">>> Running tests..." + go test -v ./... + + echo "" + echo "=== All checks passed! ===" + ''; + + # Generate test stubs for a file + generate-tests.exec = '' + if [ -z "$1" ]; then + echo "Usage: generate-tests " + exit 1 + fi + gotests -all -w "$1" + ''; + + # Watch tests (requires watchexec) + test-watch.exec = '' + echo "Watching for changes and running tests..." + ${lib.getExe pkgs.watchexec} -e go -- go test -v ./... + ''; + + # Clean build artifacts + clean.exec = '' + BUILD_DIR="''${BUILD_DIR:-build}" + echo "Cleaning build artifacts in $BUILD_DIR..." + rm -f "$BUILD_DIR/certificator" "$BUILD_DIR/certificatee" + rm -f coverage.out coverage.html + go clean -cache -testcache + 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 + enterShell = '' + echo "" + echo "==========================================" + echo " Certificator Development Environment" + echo "==========================================" + echo "" + echo "Go version (from go.mod): ${goVersion}" + echo "Go version (active): $(go version | cut -d' ' -f3)" + echo "HAProxy version: $(haproxy -v | head -1)" + echo "Data Plane API: $(dataplaneapi --version 2>&1 | head -1 || echo 'available')" + echo "" + echo "Available commands:" + echo " build - Build certificator and certificatee" + echo " run-tests - Run unit tests" + echo " test-coverage - Run tests with coverage report" + echo " test-watch - Watch for changes and run tests" + 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 "" + ''; + + # Test configuration for `devenv test` + enterTest = '' + echo "Running devenv tests..." + go version + command go test -v ./... + + echo "" + echo "Running integration tests..." + bash ./test/integration/run-tests.sh + ''; + + git-hooks.hooks = { + gofmt.enable = true; + goimports = { + enable = true; + entry = "${pkgs.gotools}/bin/goimports -l -w"; + }; + govet.enable = true; + golangci-lint.enable = true; + }; +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..116a2ad --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json +inputs: + nixpkgs: + url: github:cachix/devenv-nixpkgs/rolling + +# If you're using non-OSS software, you can set allowUnfree to true. +# allowUnfree: true + +# If you're willing to use a package that's vulnerable +# permittedInsecurePackages: +# - "openssl-1.1.1w" + +# If you have more than one devenv you can merge them +#imports: +# - ./backend diff --git a/go.mod b/go.mod index df60e47..395e20b 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/kelseyhightower/envconfig v1.4.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.11.0 + github.com/prometheus/common v0.30.0 github.com/sirupsen/logrus v1.9.3 ) @@ -151,7 +152,6 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pquerna/otp v1.5.0 // indirect github.com/prometheus/client_model v0.2.0 // indirect - github.com/prometheus/common v0.30.0 // indirect github.com/prometheus/procfs v0.7.3 // indirect github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect diff --git a/pkg/certmetrics/metrics.go b/pkg/certmetrics/metrics.go index bfec1a1..4e52e3c 100644 --- a/pkg/certmetrics/metrics.go +++ b/pkg/certmetrics/metrics.go @@ -13,35 +13,49 @@ import ( ) var ( - // shared + // Shared metrics Up = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "up", - Help: "Indicates if Certificatee is running (1 = up, 0 = down)", + Help: "Indicates if the service is running (1 = up, 0 = down)", }, []string{"service", "version", "hostname", "environment"}) - CertificatesCurrent = promauto.NewGaugeVec(prometheus.GaugeOpts{ - Name: "certificator_certificates_current", - Help: "Current number of valid certificates managed by Certificator", - }, []string{"domain"}) - // certificator - CertificatesReissued = promauto.NewCounterVec(prometheus.CounterOpts{ - Name: "certificator_certificates_reissued_total", - Help: "Total number of certificates reissued by Certificator", + // Certificator metrics - certificate renewals + CertificatesRenewed = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "certificator_certificates_renewed_total", + Help: "Total number of certificates successfully renewed", }, []string{"domain"}) - CertificatesReissueFailures = promauto.NewCounterVec(prometheus.CounterOpts{ - Name: "certificator_certificates_reissue_failures_total", - Help: "Total number of certificate reissue failures by Certificator", + CertificatesRenewalFailures = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "certificator_certificates_renewal_failures_total", + Help: "Total number of certificate renewal failures", }, []string{"domain"}) - // certificatee - CertificatesUpdatedOnDisk = promauto.NewGaugeVec(prometheus.GaugeOpts{ - Name: "certificatee_certificates_updated_on_disk_total", - Help: "Total number of certificates updated on disk by Certificatee", - }, []string{"domain"}) + // Certificatee metrics - certificate updates and expiry + CertificatesUpdated = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "certificatee_certificates_updated_total", + Help: "Total number of certificates successfully updated in HAProxy", + }, []string{"endpoint", "domain"}) CertificatesUpdateFailures = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "certificatee_certificates_update_failures_total", - Help: "Total number of certificate update failures by Certificatee", - }, []string{"domain"}) + Help: "Total number of certificate update failures", + }, []string{"endpoint", "domain"}) + CertificatesExpiring = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "certificatee_certificates_expiring", + Help: "Number of certificates expiring within the renewal threshold", + }, []string{"endpoint"}) + CertificatesTotal = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "certificatee_certificates_total", + Help: "Total number of certificates managed per endpoint", + }, []string{"endpoint"}) + + // HAProxy endpoint health + HAProxyEndpointUp = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "certificatee_haproxy_endpoint_up", + Help: "Indicates if HAProxy endpoint is reachable (1 = up, 0 = down)", + }, []string{"endpoint"}) + LastSyncTimestamp = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "certificatee_last_sync_timestamp_seconds", + Help: "Unix timestamp of the last successful sync", + }, []string{"endpoint"}) ) func StartMetricsServer(logger *logrus.Logger, address string) { @@ -86,10 +100,10 @@ func PushMetrics(logger *logrus.Logger, pushUrl string) { } logger.Infof("pushing metrics to %s", pushUrl) - resp, err := http.Post(pushUrl, "text/plain", buf) + resp, err := http.Post(pushUrl, "text/plain", buf) //nolint:gosec // URL is from trusted configuration if err != nil { logger.Errorf("could not push metrics: %v", err) return } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() } diff --git a/pkg/config/config.go b/pkg/config/config.go index 91f1af1..16e0ba7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -55,9 +55,17 @@ type Config struct { // Configuration values specific to the certificatee tool type Certificatee struct { - CertificatePath string `envconfig:"CERTIFICATEE_CERTIFICATE_PATH" default:""` - CertificateExtension string `envconfig:"CERTIFICATEE_CERTIFICATE_EXTENSION" default:""` - UpdateInterval time.Duration `envconfig:"CERTIFICATEE_UPDATE_INTERVAL" default:"24h"` + UpdateInterval time.Duration `envconfig:"CERTIFICATEE_UPDATE_INTERVAL" default:"24h"` + 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:""` + // HAProxyDataPlaneAPIUser is the username for HAProxy Data Plane API basic auth + HAProxyDataPlaneAPIUser string `envconfig:"HAPROXY_DATAPLANE_API_USER" default:""` + // HAProxyDataPlaneAPIPassword is the password for HAProxy Data Plane API basic auth + HAProxyDataPlaneAPIPassword string `envconfig:"HAPROXY_DATAPLANE_API_PASSWORD" default:""` + // HAProxyDataPlaneAPIInsecure skips TLS certificate verification (not recommended for production) + HAProxyDataPlaneAPIInsecure bool `envconfig:"HAPROXY_DATAPLANE_API_INSECURE" default:"false"` } // LoadConfig loads configuration options to variable diff --git a/pkg/haproxy/client.go b/pkg/haproxy/client.go new file mode 100644 index 0000000..9aebbcb --- /dev/null +++ b/pkg/haproxy/client.go @@ -0,0 +1,560 @@ +package haproxy + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "math" + "mime/multipart" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +// Default retry configuration +const ( + DefaultMaxRetries = 3 + DefaultRetryBaseDelay = 1 * time.Second + 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"` + StorageName string `json:"storage_name"` + Status string `json:"status"` + Serial string `json:"serial"` + NotBefore time.Time `json:"-"` + NotBeforeStr string `json:"not_before"` + NotAfter time.Time `json:"-"` + NotAfterStr string `json:"not_after"` + Subject string `json:"subject"` + Issuer string `json:"issuer"` + Algorithm string `json:"algorithm"` + SHA1 string `json:"sha1_fingerprint"` + SANs []string `json:"subject_alternative_names"` +} + +// 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 +} + +// ClientConfig holds configuration for creating a new Client +type ClientConfig struct { + BaseURL string + Username string + Password string + InsecureSkipVerify bool + Timeout time.Duration +} + +// NewClient creates a new HAProxy Data Plane API client +func NewClient(cfg ClientConfig, logger *logrus.Logger) (*Client, error) { + if cfg.BaseURL == "" { + return nil, errors.New("baseURL must be provided") + } + + // Ensure baseURL doesn't have trailing slash + cfg.BaseURL = strings.TrimSuffix(cfg.BaseURL, "/") + + timeout := cfg.Timeout + if timeout == 0 { + timeout = 30 * time.Second + } + + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // User-configurable + }, + } + + return &Client{ + baseURL: cfg.BaseURL, + username: cfg.Username, + password: cfg.Password, + httpClient: &http.Client{ + Timeout: timeout, + Transport: transport, + }, + logger: logger, + retryConfig: DefaultRetryConfig(), + timeout: timeout, + }, nil +} + +// NewClients creates multiple HAProxy Data Plane API clients from a list of configurations +func NewClients(configs []ClientConfig, logger *logrus.Logger) ([]*Client, error) { + if len(configs) == 0 { + return nil, errors.New("at least one endpoint configuration must be provided") + } + + clients := make([]*Client, 0, len(configs)) + for _, cfg := range configs { + if cfg.BaseURL == "" { + continue + } + client, err := NewClient(cfg, logger) + if err != nil { + return nil, errors.Wrapf(err, "failed to create client for endpoint %s", cfg.BaseURL) + } + clients = append(clients, client) + } + + if len(clients) == 0 { + return nil, errors.New("no valid endpoint configurations provided") + } + + return clients, nil +} + +// Endpoint returns the endpoint address of this client +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) + } + + 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) + } + + return nil, errors.Wrapf(lastErr, "failed to connect to HAProxy Data Plane API at %s after %d attempts", c.baseURL, c.retryConfig.MaxRetries+1) +} + +// SSLCertificateEntry represents an SSL certificate entry from storage API +type SSLCertificateEntry struct { + File string `json:"file"` + StorageName string `json:"storage_name"` + Description string `json:"description"` +} + +// CertificateRef holds both display name and file path for a certificate +type CertificateRef struct { + // DisplayName is the storage_name or filename for display purposes + DisplayName string + // FilePath is the full file path used for API lookups + FilePath string +} + +// ListCertificates returns a list of certificates from HAProxy Data Plane API +func (c *Client) ListCertificates() ([]string, error) { + refs, err := c.ListCertificateRefs() + if err != nil { + return nil, err + } + + var certNames []string + for _, ref := range refs { + certNames = append(certNames, ref.DisplayName) + } + return certNames, nil +} + +// ListCertificateRefs returns a list of certificate references with both display names and file paths +func (c *Client) ListCertificateRefs() ([]CertificateRef, error) { + // Use storage API endpoint for listing SSL certificates + resp, err := c.doRequest("GET", "/v3/services/haproxy/storage/ssl_certificates", nil, "") + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, errors.Errorf("failed to list certificates: status %d, body: %s", resp.StatusCode, string(body)) + } + + var certs []SSLCertificateEntry + if err := json.NewDecoder(resp.Body).Decode(&certs); err != nil { + return nil, errors.Wrap(err, "failed to decode certificate list") + } + + var refs []CertificateRef + for _, cert := range certs { + ref := CertificateRef{ + FilePath: cert.File, + } + // Prefer storage_name for display, fall back to file path + if cert.StorageName != "" { + ref.DisplayName = cert.StorageName + } else if cert.File != "" { + ref.DisplayName = cert.File + } + if ref.DisplayName != "" || ref.FilePath != "" { + refs = append(refs, ref) + } + } + + return refs, nil +} + +// GetCertificateInfo retrieves detailed information about a specific certificate by name +func (c *Client) GetCertificateInfo(certName string) (*CertInfo, error) { + return c.GetCertificateInfoByPath(certName, certName) +} + +// GetCertificateInfoByPath retrieves detailed information about a certificate using its file path +func (c *Client) GetCertificateInfoByPath(filePath, displayName string) (*CertInfo, error) { + // URL-encode the file path for the API request + encodedPath := url.PathEscape(filePath) + path := fmt.Sprintf("/v3/services/haproxy/storage/ssl_certificates/%s", encodedPath) + resp, err := c.doRequest("GET", path, nil, "") + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, errors.Errorf("certificate %s not found", displayName) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, errors.Errorf("failed to get certificate info: status %d, body: %s", resp.StatusCode, string(body)) + } + + // The storage API returns the PEM content directly + pemData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to read certificate data") + } + + // Parse the PEM certificate to extract info + info, err := parsePEMCertificate(pemData, displayName) + if err != nil { + return nil, errors.Wrap(err, "failed to parse certificate") + } + + return info, nil +} + +// GetCertificateInfoByRef retrieves detailed information using a CertificateRef +func (c *Client) GetCertificateInfoByRef(ref CertificateRef) (*CertInfo, error) { + filePath := ref.FilePath + if filePath == "" { + filePath = ref.DisplayName + } + return c.GetCertificateInfoByPath(filePath, ref.DisplayName) +} + +// parsePEMCertificate parses a PEM certificate and extracts certificate info +func parsePEMCertificate(pemData []byte, certName string) (*CertInfo, error) { + block, _ := pem.Decode(pemData) + if block == nil { + return nil, errors.New("failed to decode PEM block") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, errors.Wrap(err, "failed to parse X.509 certificate") + } + + info := &CertInfo{ + StorageName: certName, + Subject: cert.Subject.String(), + Issuer: cert.Issuer.String(), + Serial: cert.SerialNumber.Text(16), + NotBefore: cert.NotBefore, + NotAfter: cert.NotAfter, + SANs: cert.DNSNames, + } + + return info, nil +} + +// parseDataPlaneAPITime parses time strings from Data Plane API +func parseDataPlaneAPITime(s string) (time.Time, error) { + // Data Plane API may return time in various formats + formats := []string{ + time.RFC3339, + "2006-01-02T15:04:05Z", + "Jan 2 15:04:05 2006 MST", + "Jan 02 15:04:05 2006 MST", + "Jan _2 15:04:05 2006 MST", + } + + for _, format := range formats { + t, err := time.Parse(format, s) + if err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("failed to parse time: %s", s) +} + +// UpdateCertificate uploads and commits a certificate update via Data Plane API +func (c *Client) UpdateCertificate(certName, pemData string) error { + // Create multipart form data + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + // Add file part + part, err := writer.CreateFormFile("file_upload", certName) + if err != nil { + return errors.Wrap(err, "failed to create form file") + } + if _, err := part.Write([]byte(pemData)); err != nil { + return errors.Wrap(err, "failed to write certificate data") + } + + if err := writer.Close(); err != nil { + return errors.Wrap(err, "failed to close multipart writer") + } + + // Send PUT request to replace certificate + path := fmt.Sprintf("/v3/services/haproxy/runtime/certs/%s", certName) + resp, err := c.doRequestWithBodyBuffer("PUT", path, buf.Bytes(), writer.FormDataContentType()) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + body, _ := io.ReadAll(resp.Body) + return errors.Errorf("failed to update certificate %s: status %d, body: %s", certName, resp.StatusCode, string(body)) + } + + c.logger.Debugf("Updated certificate %s", certName) + return nil +} + +// CreateCertificate creates a new certificate entry via Data Plane API +func (c *Client) CreateCertificate(certName, pemData string) error { + // Create multipart form data + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + // Add file part + part, err := writer.CreateFormFile("file_upload", certName) + if err != nil { + return errors.Wrap(err, "failed to create form file") + } + if _, err := part.Write([]byte(pemData)); err != nil { + return errors.Wrap(err, "failed to write certificate data") + } + + if err := writer.Close(); err != nil { + return errors.Wrap(err, "failed to close multipart writer") + } + + // Send POST request to create certificate + resp, err := c.doRequestWithBodyBuffer("POST", "/v3/services/haproxy/runtime/certs", buf.Bytes(), writer.FormDataContentType()) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return errors.Errorf("failed to create certificate %s: status %d, body: %s", certName, resp.StatusCode, string(body)) + } + + c.logger.Debugf("Created certificate %s", certName) + return nil +} + +// DeleteCertificate deletes a certificate entry via Data Plane API +func (c *Client) DeleteCertificate(certName string) error { + path := fmt.Sprintf("/v3/services/haproxy/runtime/certs/%s", certName) + resp, err := c.doRequest("DELETE", path, nil, "") + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return errors.Errorf("failed to delete certificate %s: status %d, body: %s", certName, resp.StatusCode, string(body)) + } + + c.logger.Debugf("Deleted certificate %s", certName) + return nil +} + +// ExtractDomainFromPath extracts the domain name from a certificate path +// Example: /etc/haproxy/certs/example.com.pem -> example.com +func ExtractDomainFromPath(certPath string) string { + // Get the filename + parts := strings.Split(certPath, "/") + filename := parts[len(parts)-1] + + // Remove common extensions + extensions := []string{".pem", ".crt", ".cert", ".cer"} + for _, ext := range extensions { + if strings.HasSuffix(filename, ext) { + filename = strings.TrimSuffix(filename, ext) + break + } + } + + return filename +} + +// IsExpiring checks if a certificate is expiring within the given number of days +func IsExpiring(certInfo *CertInfo, renewBeforeDays int) bool { + if certInfo == nil { + return true + } + + threshold := time.Now().AddDate(0, 0, renewBeforeDays) + return certInfo.NotAfter.Before(threshold) +} + +// NormalizeSerial normalizes a certificate serial number for comparison +// Removes colons, spaces, and converts to uppercase +func NormalizeSerial(serial string) string { + re := regexp.MustCompile(`[^a-fA-F0-9]`) + return strings.ToUpper(re.ReplaceAllString(serial, "")) +} diff --git a/pkg/haproxy/client_test.go b/pkg/haproxy/client_test.go new file mode 100644 index 0000000..4e99c8f --- /dev/null +++ b/pkg/haproxy/client_test.go @@ -0,0 +1,1139 @@ +package haproxy + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sirupsen/logrus" +) + +// ============================================================================= +// Unit Tests for Helper Functions +// ============================================================================= + +func TestExtractDomainFromPath(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"/etc/haproxy/certs/example.com.pem", "example.com"}, + {"/etc/haproxy/certs/example.com.crt", "example.com"}, + {"/etc/haproxy/certs/example.com.cert", "example.com"}, + {"/etc/haproxy/certs/example.com.cer", "example.com"}, + {"/etc/haproxy/certs/example.com", "example.com"}, + {"example.com.pem", "example.com"}, + {"/path/to/my-domain.org.pem", "my-domain.org"}, + {"/certs/wildcard.example.com.pem", "wildcard.example.com"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := ExtractDomainFromPath(tt.input) + if got != tt.want { + t.Errorf("ExtractDomainFromPath(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestIsExpiring(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + certInfo *CertInfo + renewBeforeDays int + want bool + }{ + { + name: "nil certInfo", + certInfo: nil, + renewBeforeDays: 30, + want: true, + }, + { + name: "expiring in 10 days, threshold 30", + certInfo: &CertInfo{ + NotAfter: now.AddDate(0, 0, 10), + }, + renewBeforeDays: 30, + want: true, + }, + { + name: "expiring in 60 days, threshold 30", + certInfo: &CertInfo{ + NotAfter: now.AddDate(0, 0, 60), + }, + renewBeforeDays: 30, + want: false, + }, + { + name: "already expired", + certInfo: &CertInfo{ + NotAfter: now.AddDate(0, 0, -5), + }, + renewBeforeDays: 30, + want: true, + }, + { + name: "expiring exactly at threshold", + certInfo: &CertInfo{ + NotAfter: now.AddDate(0, 0, 30), + }, + renewBeforeDays: 30, + want: true, // Before means strictly less than + }, + { + name: "expiring one day after threshold", + certInfo: &CertInfo{ + NotAfter: now.AddDate(0, 0, 31), + }, + renewBeforeDays: 30, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsExpiring(tt.certInfo, tt.renewBeforeDays) + if got != tt.want { + t.Errorf("IsExpiring() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNormalizeSerial(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"1F5202E0", "1F5202E0"}, + {"1f5202e0", "1F5202E0"}, + {"1F:52:02:E0", "1F5202E0"}, + {"1F 52 02 E0", "1F5202E0"}, + {"1f:52:02:e0", "1F5202E0"}, + {" 1F5202E0 ", "1F5202E0"}, + {"1F-52-02-E0", "1F5202E0"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := NormalizeSerial(tt.input) + if got != tt.want { + t.Errorf("NormalizeSerial(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParseDataPlaneAPITime(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + check func(t *testing.T, tm time.Time) + }{ + { + name: "RFC3339 format", + input: "2024-08-12T17:05:34Z", + wantErr: false, + check: func(t *testing.T, tm time.Time) { + if tm.Day() != 12 || tm.Month() != time.August || tm.Year() != 2024 { + t.Errorf("got %v, want Aug 12 2024", tm) + } + }, + }, + { + name: "RFC3339 without T", + input: "2025-01-15T00:00:00Z", + wantErr: false, + check: func(t *testing.T, tm time.Time) { + if tm.Month() != time.January || tm.Year() != 2025 { + t.Errorf("got %v, want Jan 2025", tm) + } + }, + }, + { + name: "HAProxy format double digit day", + input: "Aug 12 17:05:34 2020 GMT", + wantErr: false, + check: func(t *testing.T, tm time.Time) { + if tm.Day() != 12 || tm.Month() != time.August || tm.Year() != 2020 { + t.Errorf("got %v, want Aug 12 2020", tm) + } + }, + }, + { + name: "HAProxy format single digit day padded", + input: "Aug 02 17:05:34 2020 GMT", + wantErr: false, + check: func(t *testing.T, tm time.Time) { + if tm.Day() != 2 { + t.Errorf("Day = %d, want 2", tm.Day()) + } + }, + }, + { + name: "invalid format", + input: "not a date", + wantErr: true, + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tm, err := parseDataPlaneAPITime(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parseDataPlaneAPITime(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if !tt.wantErr && tt.check != nil { + tt.check(t, tm) + } + }) + } +} + +// ============================================================================= +// Unit Tests for Client Constructors +// ============================================================================= + +func TestNewClient(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) // Suppress logs in tests + + tests := []struct { + name string + config ClientConfig + wantErr bool + }{ + { + name: "empty baseURL", + config: ClientConfig{ + BaseURL: "", + }, + wantErr: true, + }, + { + name: "valid http URL", + config: ClientConfig{ + BaseURL: "http://localhost:5555", + Username: "admin", + Password: "secret", + }, + wantErr: false, + }, + { + name: "valid https URL", + config: ClientConfig{ + BaseURL: "https://haproxy.example.com:5555", + Username: "admin", + Password: "secret", + InsecureSkipVerify: true, + }, + wantErr: false, + }, + { + name: "URL with trailing slash", + config: ClientConfig{ + BaseURL: "http://localhost:5555/", + }, + wantErr: false, + }, + { + name: "custom timeout", + config: ClientConfig{ + BaseURL: "http://localhost:5555", + Timeout: 60 * time.Second, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(tt.config, logger) + if (err != nil) != tt.wantErr { + t.Errorf("NewClient() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + expectedURL := strings.TrimSuffix(tt.config.BaseURL, "/") + if client.baseURL != expectedURL { + t.Errorf("client.baseURL = %q, want %q", client.baseURL, expectedURL) + } + } + }) + } +} + +func TestNewClients(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + tests := []struct { + name string + configs []ClientConfig + wantErr bool + wantCount int + }{ + { + name: "empty slice", + configs: []ClientConfig{}, + wantErr: true, + }, + { + name: "nil slice", + configs: nil, + wantErr: true, + }, + { + name: "single endpoint", + configs: []ClientConfig{ + {BaseURL: "http://localhost:5555", Username: "admin", Password: "secret"}, + }, + wantErr: false, + wantCount: 1, + }, + { + name: "multiple endpoints", + configs: []ClientConfig{ + {BaseURL: "http://haproxy1:5555", Username: "admin", Password: "secret"}, + {BaseURL: "http://haproxy2:5555", Username: "admin", Password: "secret"}, + {BaseURL: "http://haproxy3:5555", Username: "admin", Password: "secret"}, + }, + wantErr: false, + wantCount: 3, + }, + { + name: "with empty baseURLs", + configs: []ClientConfig{ + {BaseURL: "http://haproxy1:5555", Username: "admin", Password: "secret"}, + {BaseURL: ""}, + {BaseURL: "http://haproxy2:5555", Username: "admin", Password: "secret"}, + }, + wantErr: false, + wantCount: 2, // empty baseURLs are skipped + }, + { + name: "only empty baseURLs", + configs: []ClientConfig{ + {BaseURL: ""}, + {BaseURL: ""}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clients, err := NewClients(tt.configs, logger) + if (err != nil) != tt.wantErr { + t.Errorf("NewClients() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && len(clients) != tt.wantCount { + t.Errorf("NewClients() returned %d clients, want %d", len(clients), tt.wantCount) + } + }) + } +} + +func TestClientEndpoint(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + client, err := NewClient(ClientConfig{ + BaseURL: "http://localhost:5555", + Username: "admin", + Password: "secret", + }, logger) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + if got := client.Endpoint(); got != "http://localhost:5555" { + t.Errorf("Endpoint() = %q, want %q", got, "http://localhost:5555") + } +} + +// ============================================================================= +// Mock HAProxy Data Plane API Server +// ============================================================================= + +// mockDataPlaneAPI simulates the HAProxy Data Plane API +type mockDataPlaneAPI struct { + server *httptest.Server + handlers map[string]http.HandlerFunc + authRequired bool + username string + password string + t *testing.T +} + +// newMockDataPlaneAPI creates a mock Data Plane API server +func newMockDataPlaneAPI(t *testing.T) *mockDataPlaneAPI { + m := &mockDataPlaneAPI{ + handlers: make(map[string]http.HandlerFunc), + t: t, + } + + m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check basic auth if required + if m.authRequired { + user, pass, ok := r.BasicAuth() + if !ok || user != m.username || pass != m.password { + w.WriteHeader(http.StatusUnauthorized) + return + } + } + + // Find matching handler by method + path + key := r.Method + " " + r.URL.Path + if handler, ok := m.handlers[key]; ok { + handler(w, r) + return + } + + // Try prefix matching for dynamic paths (e.g., /v3/services/haproxy/runtime/certs/example.com.pem) + for pattern, handler := range m.handlers { + if strings.HasPrefix(key, pattern) { + handler(w, r) + return + } + } + + // Default 404 + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "not found"}`)) + })) + + return m +} + +func (m *mockDataPlaneAPI) URL() string { + return m.server.URL +} + +func (m *mockDataPlaneAPI) Close() { + m.server.Close() +} + +func (m *mockDataPlaneAPI) SetAuth(username, password string) { + m.authRequired = true + m.username = username + m.password = password +} + +func (m *mockDataPlaneAPI) SetHandler(method, path string, handler http.HandlerFunc) { + m.handlers[method+" "+path] = handler +} + +// ============================================================================= +// Integration Tests +// ============================================================================= + +func TestListCertificates(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + tests := []struct { + name string + response []SSLCertificateEntry + statusCode int + want []string + wantErr bool + }{ + { + name: "normal response with multiple certs", + response: []SSLCertificateEntry{ + {File: "/etc/haproxy/certs/site1.pem", StorageName: "site1.pem"}, + {File: "/etc/haproxy/certs/site2.pem", StorageName: "site2.pem"}, + }, + statusCode: http.StatusOK, + want: []string{"site1.pem", "site2.pem"}, + wantErr: false, + }, + { + name: "empty response", + response: []SSLCertificateEntry{}, + statusCode: http.StatusOK, + want: nil, + wantErr: false, + }, + { + name: "certs with storage_name", + response: []SSLCertificateEntry{ + {StorageName: "example.com.pem"}, + {StorageName: "test.com.pem"}, + }, + statusCode: http.StatusOK, + want: []string{"example.com.pem", "test.com.pem"}, + wantErr: false, + }, + { + name: "single certificate", + response: []SSLCertificateEntry{ + {File: "/etc/haproxy/certs/only.pem", StorageName: "only.pem"}, + }, + statusCode: http.StatusOK, + want: []string{"only.pem"}, + wantErr: false, + }, + { + name: "server error", + response: nil, + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := newMockDataPlaneAPI(t) + defer mock.Close() + + mock.SetHandler("GET", "/v3/services/haproxy/storage/ssl_certificates", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + if tt.response != nil { + _ = json.NewEncoder(w).Encode(tt.response) + } else { + _, _ = w.Write([]byte(`{"message": "error"}`)) + } + }) + + client, err := NewClient(ClientConfig{BaseURL: mock.URL()}, logger) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + got, err := client.ListCertificates() + if (err != nil) != tt.wantErr { + t.Errorf("ListCertificates() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if len(got) != len(tt.want) { + t.Errorf("ListCertificates() = %v, want %v", got, tt.want) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("ListCertificates()[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestGetCertificateInfo(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + // Sample PEM certificate for testing (self-signed, CN=example.com) + validPEM := `-----BEGIN CERTIFICATE----- +MIIDDTCCAfWgAwIBAgIUe9mCIn9FkwgXLlsXK6cwCMbavacwDQYJKoZIhvcNAQEL +BQAwFjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wHhcNMjYwMTIwMTQwMTM0WhcNMjcw +MTIwMTQwMTM0WjAWMRQwEgYDVQQDDAtleGFtcGxlLmNvbTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAK9vQbb4mhM0EKzKF40tM4UtZNquBfAR4RwaJWme +WowIe/zBK8qZxSO8W+1LmJguR1CLlytfQ3iv5y4LdQ1tsn350EmmHKfD31NOHxr9 +F3GmsmSkHJBbukcpAl28ezTajtCImn6wciuui5ivUbKfuZXn4AEBNlaerGywQE2Y +0CNMKZ1/HnyrJymWyPb4tyJzfyYOdsPLwPt7GTAt4yqsRHnjIaIO2KD2OkmgFWMC +K5w64M8Zs7cg5Jk1zE0hFDKAE/3T78SYDGh+kHmDe68P75VACJBDgWYLWRAFWsOA +o8IrAUNYvCKHHXshEnR2HJSgoPT6nkNOgVzWTG52hnNuhLsCAwEAAaNTMFEwHQYD +VR0OBBYEFMljzE+9nN38vJhdB1ovTbiyuuZAMB8GA1UdIwQYMBaAFMljzE+9nN38 +vJhdB1ovTbiyuuZAMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB +ABI48y8xh9jQSYPmt9dIkMUmI8WyjkdVzBIs4vAqZ1DeOsxUJ3dLwmr1ImTTY7Sw +m6yDoTNInWsdjo5rjA9mgrkq5OTSVJNVe2bcNfZyFsTJ7B1OwGffCzBnFNwW/Zzf +OzZ53OaXmtWHeMP2cHhH7yEX7NVuB0HB/8CTu1F/jLuUTaaiCGbF+VCCHtLL5RAL +N4Vg0dt1Ls7qBpX/22o3cMNI15ixOOhW6Qug2at304/K0SJsXQifJ7SQiMRU84ov +FouJ5aRz+i5UvgFqDEMHY1PaEDXPAwHH+Kl3iC6L59McPRD3yRNlOMqquAOS2b8Y +kF7B68QUswmVK4Icz6zBgmo= +-----END CERTIFICATE-----` + + tests := []struct { + name string + certPath string + pemData string + statusCode int + wantErr bool + checkFunc func(t *testing.T, info *CertInfo) + }{ + { + name: "valid certificate info", + certPath: "example.com.pem", + pemData: validPEM, + statusCode: http.StatusOK, + wantErr: false, + checkFunc: func(t *testing.T, info *CertInfo) { + if info.Subject == "" { + t.Error("Subject should not be empty") + } + if info.NotAfter.IsZero() { + t.Error("NotAfter should be set") + } + }, + }, + { + name: "certificate not found", + certPath: "notfound.pem", + pemData: "", + statusCode: http.StatusNotFound, + wantErr: true, + }, + { + name: "server error", + certPath: "error.pem", + pemData: "", + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + { + name: "invalid PEM data", + certPath: "invalid.pem", + pemData: "not a valid PEM", + statusCode: http.StatusOK, + wantErr: true, // Should fail to parse + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := newMockDataPlaneAPI(t) + defer mock.Close() + + mock.SetHandler("GET", "/v3/services/haproxy/storage/ssl_certificates/"+tt.certPath, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + if tt.statusCode == http.StatusOK { + _, _ = w.Write([]byte(tt.pemData)) + } else { + _, _ = w.Write([]byte(`{"message": "error"}`)) + } + }) + + client, err := NewClient(ClientConfig{BaseURL: mock.URL()}, logger) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + info, err := client.GetCertificateInfo(tt.certPath) + if (err != nil) != tt.wantErr { + t.Errorf("GetCertificateInfo() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && tt.checkFunc != nil { + tt.checkFunc(t, info) + } + }) + } +} + +func TestUpdateCertificate(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + tests := []struct { + name string + certName string + pemData string + statusCode int + wantErr bool + }{ + { + name: "success - certificate updated", + certName: "example.com.pem", + pemData: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + statusCode: http.StatusOK, + wantErr: false, + }, + { + name: "success - accepted", + certName: "example.com.pem", + pemData: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + statusCode: http.StatusAccepted, + wantErr: false, + }, + { + name: "error - not found", + certName: "notfound.pem", + pemData: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + statusCode: http.StatusNotFound, + wantErr: true, + }, + { + name: "error - bad request", + certName: "bad.pem", + pemData: "invalid pem", + statusCode: http.StatusBadRequest, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := newMockDataPlaneAPI(t) + defer mock.Close() + + mock.SetHandler("PUT", "/v3/services/haproxy/runtime/certs/"+tt.certName, func(w http.ResponseWriter, r *http.Request) { + // Verify content type is multipart + contentType := r.Header.Get("Content-Type") + if !strings.Contains(contentType, "multipart/form-data") { + t.Errorf("Expected multipart/form-data content type, got %s", contentType) + } + + // Read the multipart form + err := r.ParseMultipartForm(10 << 20) // 10 MB + if err != nil { + t.Errorf("Failed to parse multipart form: %v", err) + } + + // Verify file was uploaded + file, _, err := r.FormFile("file_upload") + if err != nil { + t.Errorf("Failed to get file from form: %v", err) + } else { + defer func() { _ = file.Close() }() + data, _ := io.ReadAll(file) + if string(data) != tt.pemData { + t.Errorf("File data = %q, want %q", string(data), tt.pemData) + } + } + + w.WriteHeader(tt.statusCode) + }) + + client, err := NewClient(ClientConfig{BaseURL: mock.URL()}, logger) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.UpdateCertificate(tt.certName, tt.pemData) + if (err != nil) != tt.wantErr { + t.Errorf("UpdateCertificate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestCreateCertificate(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + tests := []struct { + name string + certName string + pemData string + statusCode int + wantErr bool + }{ + { + name: "success - certificate created", + certName: "new.example.com.pem", + pemData: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + statusCode: http.StatusCreated, + wantErr: false, + }, + { + name: "success - OK status", + certName: "new.example.com.pem", + pemData: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + statusCode: http.StatusOK, + wantErr: false, + }, + { + name: "error - conflict (already exists)", + certName: "existing.pem", + pemData: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + statusCode: http.StatusConflict, + wantErr: true, + }, + { + name: "error - bad request", + certName: "bad.pem", + pemData: "invalid pem", + statusCode: http.StatusBadRequest, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := newMockDataPlaneAPI(t) + defer mock.Close() + + mock.SetHandler("POST", "/v3/services/haproxy/runtime/certs", func(w http.ResponseWriter, r *http.Request) { + // Verify content type is multipart + contentType := r.Header.Get("Content-Type") + if !strings.Contains(contentType, "multipart/form-data") { + t.Errorf("Expected multipart/form-data content type, got %s", contentType) + } + + // Read the multipart form + err := r.ParseMultipartForm(10 << 20) // 10 MB + if err != nil { + t.Errorf("Failed to parse multipart form: %v", err) + } + + // Verify file was uploaded + file, header, err := r.FormFile("file_upload") + if err != nil { + t.Errorf("Failed to get file from form: %v", err) + } else { + defer func() { _ = file.Close() }() + if header.Filename != tt.certName { + t.Errorf("Filename = %q, want %q", header.Filename, tt.certName) + } + data, _ := io.ReadAll(file) + if string(data) != tt.pemData { + t.Errorf("File data = %q, want %q", string(data), tt.pemData) + } + } + + w.WriteHeader(tt.statusCode) + }) + + client, err := NewClient(ClientConfig{BaseURL: mock.URL()}, logger) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.CreateCertificate(tt.certName, tt.pemData) + if (err != nil) != tt.wantErr { + t.Errorf("CreateCertificate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestDeleteCertificate(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + tests := []struct { + name string + certName string + statusCode int + wantErr bool + }{ + { + name: "success - no content", + certName: "example.com.pem", + statusCode: http.StatusNoContent, + wantErr: false, + }, + { + name: "success - OK", + certName: "example.com.pem", + statusCode: http.StatusOK, + wantErr: false, + }, + { + name: "error - not found", + certName: "notfound.pem", + statusCode: http.StatusNotFound, + wantErr: true, + }, + { + name: "error - server error", + certName: "error.pem", + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := newMockDataPlaneAPI(t) + defer mock.Close() + + mock.SetHandler("DELETE", "/v3/services/haproxy/runtime/certs/"+tt.certName, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + }) + + client, err := NewClient(ClientConfig{BaseURL: mock.URL()}, logger) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.DeleteCertificate(tt.certName) + if (err != nil) != tt.wantErr { + t.Errorf("DeleteCertificate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// ============================================================================= +// Authentication Tests +// ============================================================================= + +func TestBasicAuth(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.PanicLevel) + + mock := newMockDataPlaneAPI(t) + defer mock.Close() + mock.SetAuth("admin", "secret") + + mock.SetHandler("GET", "/v3/services/haproxy/storage/ssl_certificates", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode([]SSLCertificateEntry{}) + }) + + t.Run("valid credentials", func(t *testing.T) { + client, err := NewClient(ClientConfig{ + BaseURL: mock.URL(), + Username: "admin", + Password: "secret", + }, logger) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + _, err = client.ListCertificates() + if err != nil { + t.Errorf("ListCertificates() with valid auth error = %v", err) + } + }) + + t.Run("invalid credentials", func(t *testing.T) { + client, err := NewClient(ClientConfig{ + BaseURL: mock.URL(), + Username: "admin", + Password: "wrong", + }, logger) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + _, err = client.ListCertificates() + if err == nil { + t.Error("ListCertificates() with invalid auth expected error, got nil") + } + }) + + t.Run("no credentials", func(t *testing.T) { + client, err := NewClient(ClientConfig{ + BaseURL: mock.URL(), + }, logger) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + _, err = client.ListCertificates() + if err == nil { + t.Error("ListCertificates() without auth expected error, got nil") + } + }) +} + +// ============================================================================= +// Connection Error Tests +// ============================================================================= + +func TestConnectionError(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:59999", + Timeout: 100 * time.Millisecond, + }, logger) + if err != nil { + 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) + + // Create client pointing to non-existent server + client, err := NewClient(ClientConfig{ + BaseURL: "http://127.0.0.1:59998", + Timeout: 50 * time.Millisecond, + }, logger) + if err != nil { + 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) + + // Should fail after retries + if err == nil { + t.Error("Expected connection error, got nil") + } + + // Should have taken some time for retries (at least 2 retries with 10ms delays) + if elapsed < 10*time.Millisecond { + t.Errorf("Retries should have taken longer, elapsed: %v", elapsed) + } + + // Error message should mention retry attempts + if !strings.Contains(err.Error(), "after") { + 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) + } +} diff --git a/test/integration/dataplaneapi.yaml b/test/integration/dataplaneapi.yaml new file mode 100644 index 0000000..daa8f95 --- /dev/null +++ b/test/integration/dataplaneapi.yaml @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..037b1d2 --- /dev/null +++ b/test/integration/haproxy.cfg @@ -0,0 +1,34 @@ +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 new file mode 100755 index 0000000..ede8840 --- /dev/null +++ b/test/integration/run-tests.sh @@ -0,0 +1,377 @@ +#!/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/v3/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/v3/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/v3/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/v3/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/v3/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 "$@"