Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13

- name: Run Checks (lint, vet, test)
run: nix develop --command check
run: nix flake check

- name: Generate Coverage Report
run: nix develop --command test-coverage
Expand Down
1 change: 0 additions & 1 deletion .pre-commit-config.yaml

This file was deleted.

5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ A daemon that synchronizes certificates from Vault to HAProxy using the HAProxy
| `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 |
| `CERTIFICATEE_LOCAL_CERTS_DIR` | | Directory containing `<domain>.pem` bundles (cert + key) to use instead of Vault |
| `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 |
Expand All @@ -37,6 +38,8 @@ A daemon that synchronizes certificates from Vault to HAProxy using the HAProxy
| `LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
| `ENVIRONMENT` | `prod` | Environment name for metrics labels |

If `CERTIFICATEE_LOCAL_CERTS_DIR` is set, Vault configuration is optional and certificates are read from local PEM bundles instead.

### Certificator Environment Variables

| Variable | Default | Description |
Expand All @@ -59,7 +62,7 @@ Certificatee uses the HAProxy Data Plane API to update certificates at runtime w
- **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 `/v2/services/haproxy/runtime/certs` endpoints
- **REST API**: Certificates are managed via the `/v2/services/haproxy/storage/ssl_certificates` endpoints

### HAProxy Data Plane API Configuration

Expand Down
117 changes: 117 additions & 0 deletions cmd/certificatee/certsource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package main

import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"

"github.com/vinted/certificator/pkg/certificate"
"github.com/vinted/certificator/pkg/vault"
)

type CertSource interface {
GetCertificate(domain string) (*x509.Certificate, error)
GetPEMBundle(domain string) (string, error)
}

type VaultCertSource struct {
client *vault.VaultClient
}

func (v VaultCertSource) GetCertificate(domain string) (*x509.Certificate, error) {
return certificate.GetCertificate(domain, v.client)
}

func (v VaultCertSource) GetPEMBundle(domain string) (string, error) {
certificateSecrets, err := v.client.KVRead(certificate.VaultCertLocation(domain))
if err != nil {
return "", fmt.Errorf("failed to read certificate data from vault for %s: %w", domain, err)
}

pemData, err := buildPEMBundle(certificateSecrets)
if err != nil {
return "", fmt.Errorf("failed to build PEM bundle for %s: %w", domain, err)
}

return pemData, nil
}

type LocalCertSource struct {
dir string
}

func (l LocalCertSource) GetCertificate(domain string) (*x509.Certificate, error) {
data, err := os.ReadFile(l.certPath(domain))
if err != nil {
return nil, fmt.Errorf("failed to read local certificate for %s: %w", domain, err)
}

cert, err := parseCertificateFromPEM(data)
if err != nil {
return nil, fmt.Errorf("failed to parse local certificate for %s: %w", domain, err)
}

return cert, nil
}

func (l LocalCertSource) GetPEMBundle(domain string) (string, error) {
data, err := os.ReadFile(l.certPath(domain))
if err != nil {
return "", fmt.Errorf("failed to read local PEM bundle for %s: %w", domain, err)
}

return string(data), nil
}

func (l LocalCertSource) certPath(domain string) string {
return filepath.Join(l.dir, domain+".pem")
}

func parseCertificateFromPEM(pemData []byte) (*x509.Certificate, error) {
rest := pemData
for len(rest) > 0 {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
return cert, nil
}

return nil, fmt.Errorf("no certificate PEM block found")
}

// buildPEMBundle creates a PEM bundle from Vault certificate secrets
func buildPEMBundle(secrets map[string]any) (string, error) {
var pemData string

// Add certificate
if cert, ok := secrets["certificate"].(string); ok && cert != "" {
pemData += cert
} else {
return "", fmt.Errorf("certificate not found in vault secrets")
}

// Add newline between cert and key
if !endsWith(pemData, "\n") {
pemData += "\n"
}

// Add private key
if key, ok := secrets["private_key"].(string); ok && key != "" {
pemData += key
} else {
return "", fmt.Errorf("private_key not found in vault secrets")
}

return pemData, nil
}
66 changes: 66 additions & 0 deletions cmd/certificatee/haproxy_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package main

import (
"fmt"
"path/filepath"
"strings"

"github.com/vinted/certificator/pkg/haproxy"
)

func sanitizeWildcardCertName(name string) string {
name = strings.ReplaceAll(name, "*", "_")
name = strings.ReplaceAll(name, "..", ".")
return name
}

func ensureStorageCertificate(haproxyClient *haproxy.Client, certName, pemData string) error {
refs, err := haproxyClient.ListCertificateRefs()
if err != nil {
return err
}

normalized := normalizeCertificateName(certName)
for _, ref := range refs {
if certRefMatches(ref, certName) || certRefMatches(ref, normalized) {
if err := haproxyClient.UpdateCertificate(certName, pemData); err == nil {
return nil
}
break
}
}

if err := haproxyClient.CreateCertificate(certName, pemData); err != nil {
// Data Plane API normalizes storage names (e.g., replaces '*' and other chars with '_'),
// so a create may return 409 even if the exact requested name wasn't found above.
if strings.Contains(err.Error(), "already exists") {
if err := haproxyClient.UpdateCertificate(certName, pemData); err == nil {
return nil
}
}
return fmt.Errorf("failed to create certificate %s: %w", certName, err)
}
return nil
}

func normalizeCertificateName(name string) string {
builder := strings.Builder{}
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' {
builder.WriteRune(r)
} else {
builder.WriteRune('_')
}
}
return builder.String()
}

func certRefMatches(ref haproxy.CertificateRef, name string) bool {
if ref.DisplayName == name {
return true
}
if ref.FilePath == name {
return true
}
return filepath.Base(ref.FilePath) == name
}
Loading
Loading