202 lines
6.1 KiB
Go
202 lines
6.1 KiB
Go
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
|
|
}
|