Add Vault certificate rotation operator
This commit is contained in:
parent
c685229437
commit
8009b5fd9c
1 changed files with 1013 additions and 0 deletions
202
internal/rotator/app.go
Normal file
202
internal/rotator/app.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
package rotator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
vaultURL string
|
||||
vaultServerName string
|
||||
vaultCAPath string
|
||||
vaultAuthRole string
|
||||
vaultPKISignPath string
|
||||
serviceAccountTokenPath string
|
||||
opnsenseURL string
|
||||
opnsenseIPSANs []net.IP
|
||||
opnsenseAPIKeyPath string
|
||||
opnsenseAPISecretPath string
|
||||
opnsenseBootstrapCAPath string
|
||||
opnsenseManagedCAPath string
|
||||
opnsenseBootstrapName string
|
||||
bootstrapMode bool
|
||||
renewBefore time.Duration
|
||||
certificateTTL time.Duration
|
||||
activationTimeout time.Duration
|
||||
}
|
||||
|
||||
func Run() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
configuration, err := loadConfig()
|
||||
if err != nil {
|
||||
logger.Error("configuration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), configuration.activationTimeout)
|
||||
defer cancel()
|
||||
if err := rotate(ctx, configuration, logger); err != nil {
|
||||
logger.Error("rotation failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() (config, error) {
|
||||
opnsenseURL, err := requiredURL("OPNSENSE_URL")
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
ipSANs, err := ipListEnv("OPNSENSE_IP_SANS")
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
|
||||
renewBefore, err := durationEnv("RENEW_BEFORE", 30*24*time.Hour)
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
certificateTTL, err := durationEnv("CERTIFICATE_TTL", 90*24*time.Hour)
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
activationTimeout, err := durationEnv("ACTIVATION_TIMEOUT", 3*time.Minute)
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
bootstrapMode, err := boolEnv("BOOTSTRAP_MODE", false)
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
if certificateTTL <= renewBefore {
|
||||
return config{}, errors.New("CERTIFICATE_TTL must exceed RENEW_BEFORE")
|
||||
}
|
||||
|
||||
return config{
|
||||
vaultURL: env("VAULT_URL", "https://vault-active-tls.vault.svc.cluster.local:443"),
|
||||
vaultServerName: env("VAULT_SERVER_NAME", "vault-active-direct.vault.svc.cluster.local"),
|
||||
vaultCAPath: env("VAULT_CA_PATH", "/vault-ca/ca.crt"),
|
||||
vaultAuthRole: env("VAULT_AUTH_ROLE", "argo-opnsense-certificate"),
|
||||
vaultPKISignPath: env("VAULT_PKI_SIGN_PATH", "pki_opnsense/sign/webgui"),
|
||||
serviceAccountTokenPath: env("SERVICE_ACCOUNT_TOKEN_PATH", "/var/run/secrets/kubernetes.io/serviceaccount/token"),
|
||||
opnsenseURL: opnsenseURL,
|
||||
opnsenseIPSANs: ipSANs,
|
||||
opnsenseAPIKeyPath: env("OPNSENSE_API_KEY_PATH", "/opnsense-credentials/key"),
|
||||
opnsenseAPISecretPath: env("OPNSENSE_API_SECRET_PATH", "/opnsense-credentials/secret"),
|
||||
opnsenseBootstrapCAPath: env("OPNSENSE_BOOTSTRAP_CA_PATH", "/opnsense-bootstrap-ca/ca.crt"),
|
||||
opnsenseManagedCAPath: env("OPNSENSE_MANAGED_CA_PATH", "/opnsense-managed-ca/ca.crt"),
|
||||
opnsenseBootstrapName: env("OPNSENSE_BOOTSTRAP_SERVER_NAME", "OPNsense.localdomain"),
|
||||
bootstrapMode: bootstrapMode,
|
||||
renewBefore: renewBefore,
|
||||
certificateTTL: certificateTTL,
|
||||
activationTimeout: activationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func requiredURL(name string) (string, error) {
|
||||
value := os.Getenv(name)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%s is required", name)
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", fmt.Errorf("%s must be an HTTPS origin", name)
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func boolEnv(name string, fallback bool) (bool, error) {
|
||||
value := os.Getenv(name)
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func ipListEnv(name string) ([]net.IP, error) {
|
||||
value := os.Getenv(name)
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("%s is required", name)
|
||||
}
|
||||
values := strings.Split(value, ",")
|
||||
addresses := make([]net.IP, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
address := net.ParseIP(strings.TrimSpace(value))
|
||||
if address == nil || address.To4() == nil {
|
||||
return nil, fmt.Errorf("%s contains a non-IPv4 address", name)
|
||||
}
|
||||
canonical := address.To4().String()
|
||||
if _, exists := seen[canonical]; exists {
|
||||
return nil, fmt.Errorf("%s contains duplicate address %s", name, canonical)
|
||||
}
|
||||
seen[canonical] = struct{}{}
|
||||
addresses = append(addresses, address.To4())
|
||||
}
|
||||
if len(addresses) == 0 {
|
||||
return nil, fmt.Errorf("%s must contain at least one address", name)
|
||||
}
|
||||
return addresses, nil
|
||||
}
|
||||
|
||||
func env(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
|
||||
value := os.Getenv(name)
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
if seconds, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
return time.Duration(seconds) * time.Second, nil
|
||||
}
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
func generateRequest(addresses []net.IP) (*ecdsa.PrivateKey, []byte, error) {
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate private key: %w", err)
|
||||
}
|
||||
request, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
|
||||
Subject: pkix.Name{},
|
||||
IPAddresses: addresses,
|
||||
}, privateKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create certificate request: %w", err)
|
||||
}
|
||||
return privateKey, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: request}), nil
|
||||
}
|
||||
|
||||
func marshalPrivateKey(privateKey *ecdsa.PrivateKey) ([]byte, error) {
|
||||
encoded, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal private key: %w", err)
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded}), nil
|
||||
}
|
||||
Loading…
Reference in a new issue