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 }