ostension/internal/rotator/app.go

202 lines
6.1 KiB
Go
Raw Normal View History

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
}