Add Vault certificate rotation operator

This commit is contained in:
ms2k 2026-10-19 01:03:13 +09:00
commit 8009b5fd9c
10 changed files with 1013 additions and 0 deletions

7
cmd/ostension/main.go Normal file
View file

@ -0,0 +1,7 @@
package main
import "ostension/internal/rotator"
func main() {
rotator.Run()
}

202
internal/rotator/app.go Normal file
View 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
}

96
internal/rotator/http.go Normal file
View file

@ -0,0 +1,96 @@
package rotator
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
func tlsClient(certificatePaths []string, serverName string) (*http.Client, error) {
pool, err := certificatePool(certificatePaths)
if err != nil {
return nil, err
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
RootCAs: pool,
ServerName: serverName,
}
return &http.Client{Transport: transport, Timeout: 20 * time.Second}, nil
}
func certificatePool(certificatePaths []string) (*x509.CertPool, error) {
pool := x509.NewCertPool()
for _, path := range certificatePaths {
certificate, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read CA certificate %s: %w", path, err)
}
if !pool.AppendCertsFromPEM(certificate) {
return nil, fmt.Errorf("parse CA certificate %s", path)
}
}
return pool, nil
}
func jsonRequest(ctx context.Context, client *http.Client, method, endpoint string, body any, headers map[string]string, output any) (*http.Response, error) {
var payload io.Reader
if body != nil {
encoded, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("encode request: %w", err)
}
payload = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint, payload)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
for name, value := range headers {
request.Header.Set(name, value)
}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer func() { _ = response.Body.Close() }()
limited := io.LimitReader(response.Body, 1<<20)
if response.StatusCode < 200 || response.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, limited)
return response, fmt.Errorf("%s returned HTTP %d", request.URL.Path, response.StatusCode)
}
if output == nil {
_, err = io.Copy(io.Discard, limited)
return response, err
}
decoder := json.NewDecoder(limited)
if err := decoder.Decode(output); err != nil {
return response, fmt.Errorf("decode %s response: %w", request.URL.Path, err)
}
return response, nil
}
func readTrimmed(path string) (string, error) {
value, err := os.ReadFile(path)
if err != nil {
return "", err
}
trimmed := strings.TrimSpace(string(value))
if trimmed == "" {
return "", errors.New("file is empty")
}
return trimmed, nil
}

View file

@ -0,0 +1,105 @@
package rotator
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"testing"
"time"
)
func TestGenerateRequest(t *testing.T) {
addresses := []net.IP{net.ParseIP("10.0.0.1"), net.ParseIP("172.30.1.1")}
privateKey, encoded, err := generateRequest(addresses)
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode(encoded)
request, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
t.Fatal(err)
}
if err := request.CheckSignature(); err != nil {
t.Fatal(err)
}
if len(request.IPAddresses) != len(addresses) || !request.IPAddresses[0].Equal(addresses[0]) || !request.IPAddresses[1].Equal(addresses[1]) {
t.Fatalf("unexpected IP SANs: %v", request.IPAddresses)
}
publicKey := request.PublicKey.(*ecdsa.PublicKey)
if !publicKey.Equal(&privateKey.PublicKey) {
t.Fatal("request does not contain the generated public key")
}
}
func TestRequiredURL(t *testing.T) {
t.Setenv("OPNSENSE_URL", "https://172.30.1.1:8443")
if value, err := requiredURL("OPNSENSE_URL"); err != nil || value != "https://172.30.1.1:8443" {
t.Fatalf("unexpected URL result: value=%q error=%v", value, err)
}
for _, value := range []string{"", "http://172.30.1.1:8443", "https://[email protected]:8443", "https://172.30.1.1:8443/api"} {
t.Run(value, func(t *testing.T) {
t.Setenv("OPNSENSE_URL", value)
if _, err := requiredURL("OPNSENSE_URL"); err == nil {
t.Fatalf("accepted invalid URL %q", value)
}
})
}
}
func TestVerifySignedCertificate(t *testing.T) {
addresses := []net.IP{net.ParseIP("10.0.0.1"), net.ParseIP("172.30.1.1")}
now := time.Now()
rootKey, root := createCertificate(t, nil, nil, &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "root"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(24 * time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign,
})
leafKey, leaf := createCertificate(t, root, rootKey, &x509.Certificate{
SerialNumber: big.NewInt(2),
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(12 * time.Hour),
IPAddresses: addresses,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
})
rootPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: root.Raw}))
trustedRoots := x509.NewCertPool()
trustedRoots.AddCert(root)
if err := verifySignedCertificate(leaf, []string{rootPEM}, trustedRoots, addresses, leafKey); err != nil {
t.Fatal(err)
}
if err := verifySignedCertificate(leaf, []string{rootPEM}, trustedRoots, []net.IP{net.ParseIP("10.0.0.1"), net.ParseIP("172.30.1.2")}, leafKey); err == nil {
t.Fatal("wrong IP SAN was accepted")
}
}
func createCertificate(t *testing.T, parent *x509.Certificate, parentKey *ecdsa.PrivateKey, template *x509.Certificate) (*ecdsa.PrivateKey, *x509.Certificate) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
if parent == nil {
parent = template
parentKey = key
}
encoded, err := x509.CreateCertificate(rand.Reader, template, parent, &key.PublicKey, parentKey)
if err != nil {
t.Fatal(err)
}
certificate, err := x509.ParseCertificate(encoded)
if err != nil {
t.Fatal(err)
}
return key, certificate
}

View file

@ -0,0 +1,137 @@
package rotator
import (
"context"
"crypto/x509"
"fmt"
"net/http"
"net/url"
"strings"
)
type opnsenseClient struct {
baseURL string
apiKey string
apiSecret string
client *http.Client
}
type activationStatus struct {
Status string `json:"status"`
Phase string `json:"phase"`
Transaction string `json:"transaction"`
PreviousRefID string `json:"previous_refid"`
CandidateRefID string `json:"candidate_refid"`
RefID string `json:"refid"`
Deadline int64 `json:"deadline"`
Message string `json:"message"`
}
type addCertificateResponse struct {
Result string `json:"result"`
UUID string `json:"uuid"`
Validations map[string]string `json:"validations"`
}
type getCertificateResponse struct {
Certificate struct {
RefID string `json:"refid"`
} `json:"cert"`
}
func (o opnsenseClient) authenticatedRequest(ctx context.Context, method, path string, body any, output any) (*http.Response, error) {
endpoint, err := url.JoinPath(o.baseURL, path)
if err != nil {
return nil, err
}
client := roundTripperClient(o.client, func(request *http.Request) {
request.SetBasicAuth(o.apiKey, o.apiSecret)
})
return jsonRequest(ctx, client, method, endpoint, body, nil, output)
}
func roundTripperClient(source *http.Client, mutate func(*http.Request)) *http.Client {
clone := *source
clone.Transport = mutatingRoundTripper{next: source.Transport, mutate: mutate}
return &clone
}
type mutatingRoundTripper struct {
next http.RoundTripper
mutate func(*http.Request)
}
func (m mutatingRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
clone := request.Clone(request.Context())
m.mutate(clone)
return m.next.RoundTrip(clone)
}
func (o opnsenseClient) status(ctx context.Context) (activationStatus, *x509.Certificate, error) {
var status activationStatus
response, err := o.authenticatedRequest(ctx, http.MethodGet, "/api/webguicertificate/service/status", nil, &status)
if err != nil {
return activationStatus{}, nil, err
}
if status.Status == "failed" {
return activationStatus{}, nil, fmt.Errorf("OPNsense activation status failed: %s", status.Message)
}
if response.TLS == nil || len(response.TLS.PeerCertificates) == 0 {
return activationStatus{}, nil, fmt.Errorf("OPNsense response has no TLS peer certificate")
}
return status, response.TLS.PeerCertificates[0], nil
}
func (o opnsenseClient) stage(ctx context.Context, certificate signedCertificate) (string, error) {
var added addCertificateResponse
description := "Vault-managed OPNsense WebGUI " + strings.ToLower(certificate.leaf.SerialNumber.Text(16))
body := map[string]any{"cert": map[string]any{
"action": "import",
"descr": description,
"cert_type": "server_cert",
"private_key_location": "firewall",
"key_type": "prime256v1",
"digest": "sha256",
"lifetime": "90",
"country": "KR",
"crt_payload": string(certificate.chain),
"prv_payload": string(certificate.key),
}}
if _, err := o.authenticatedRequest(ctx, http.MethodPost, "/api/trust/cert/add", body, &added); err != nil {
return "", err
}
if added.Result != "saved" || added.UUID == "" {
return "", fmt.Errorf("OPNsense rejected certificate: %v", added.Validations)
}
var stored getCertificateResponse
if _, err := o.authenticatedRequest(ctx, http.MethodGet, "/api/trust/cert/get/"+added.UUID, nil, &stored); err != nil {
return "", err
}
if stored.Certificate.RefID == "" {
return "", fmt.Errorf("OPNsense stored certificate has no reference")
}
return stored.Certificate.RefID, nil
}
func (o opnsenseClient) activate(ctx context.Context, reference string) (activationStatus, error) {
var status activationStatus
if _, err := o.authenticatedRequest(ctx, http.MethodPost, "/api/webguicertificate/service/activate", map[string]string{"refid": reference}, &status); err != nil {
return activationStatus{}, err
}
if status.Status != "pending" || status.Transaction == "" {
return activationStatus{}, fmt.Errorf("OPNsense activation failed: %s", status.Message)
}
return status, nil
}
func (o opnsenseClient) confirm(ctx context.Context, transaction string) error {
var status activationStatus
if _, err := o.authenticatedRequest(ctx, http.MethodPost, "/api/webguicertificate/service/confirm", map[string]string{"transaction": transaction}, &status); err != nil {
return err
}
if status.Status != "confirmed" {
return fmt.Errorf("OPNsense confirmation failed: %s", status.Message)
}
return nil
}

View file

@ -0,0 +1,58 @@
package rotator
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestOPNsenseTransaction(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
key, secret, ok := request.BasicAuth()
if !ok || key != "key" || secret != "secret" {
http.Error(response, "unauthorized", http.StatusUnauthorized)
return
}
switch request.URL.Path {
case "/api/webguicertificate/service/status":
_ = json.NewEncoder(response).Encode(activationStatus{Status: "idle", RefID: "aaaaaaaaaaaaa"})
case "/api/trust/cert/add":
_ = json.NewEncoder(response).Encode(addCertificateResponse{Result: "saved", UUID: "uuid"})
case "/api/trust/cert/get/uuid":
_ = json.NewEncoder(response).Encode(map[string]any{"cert": map[string]string{"refid": "bbbbbbbbbbbbb"}})
case "/api/webguicertificate/service/activate":
_ = json.NewEncoder(response).Encode(activationStatus{Status: "pending", Transaction: "0123456789abcdef0123456789abcdef", CandidateRefID: "bbbbbbbbbbbbb"})
case "/api/webguicertificate/service/confirm":
_ = json.NewEncoder(response).Encode(activationStatus{Status: "confirmed", RefID: "bbbbbbbbbbbbb"})
default:
http.NotFound(response, request)
}
}))
defer server.Close()
client := opnsenseClient{baseURL: server.URL, apiKey: "key", apiSecret: "secret", client: server.Client()}
status, certificate, err := client.status(context.Background())
if err != nil {
t.Fatal(err)
}
if status.RefID != "aaaaaaaaaaaaa" || certificate == nil {
t.Fatalf("unexpected status: %#v", status)
}
reference, err := client.stage(context.Background(), signedCertificate{leaf: server.Certificate(), chain: []byte("certificate"), key: []byte("private key")})
if err != nil {
t.Fatal(err)
}
if reference != "bbbbbbbbbbbbb" {
t.Fatalf("unexpected reference: %s", reference)
}
activation, err := client.activate(context.Background(), reference)
if err != nil {
t.Fatal(err)
}
if err := client.confirm(context.Background(), activation.Transaction); err != nil {
t.Fatal(err)
}
}

146
internal/rotator/rotate.go Normal file
View file

@ -0,0 +1,146 @@
package rotator
import (
"bytes"
"context"
"crypto/x509"
"fmt"
"log/slog"
"net"
"time"
)
func rotate(ctx context.Context, configuration config, logger *slog.Logger) error {
vaultHTTP, err := tlsClient([]string{configuration.vaultCAPath}, configuration.vaultServerName)
if err != nil {
return err
}
primaryHTTP, err := tlsClient([]string{configuration.opnsenseManagedCAPath}, "")
if err != nil {
return err
}
bootstrapHTTP, err := tlsClient([]string{configuration.opnsenseBootstrapCAPath}, configuration.opnsenseBootstrapName)
if err != nil {
return err
}
apiKey, err := readTrimmed(configuration.opnsenseAPIKeyPath)
if err != nil {
return fmt.Errorf("read OPNsense API key: %w", err)
}
apiSecret, err := readTrimmed(configuration.opnsenseAPISecretPath)
if err != nil {
return fmt.Errorf("read OPNsense API secret: %w", err)
}
primary := opnsenseClient{baseURL: configuration.opnsenseURL, apiKey: apiKey, apiSecret: apiSecret, client: primaryHTTP}
current := primary
status, certificate, primaryError := primary.status(ctx)
if primaryError == nil {
if status.Status == "pending" {
return fmt.Errorf("OPNsense has pending transaction %s", status.Transaction)
}
if certificateComplies(certificate, configuration.opnsenseIPSANs) && time.Until(certificate.NotAfter) > configuration.renewBefore {
logger.Info("certificate remains valid", "not_after", certificate.NotAfter.UTC().Format(time.RFC3339))
return nil
}
} else {
if !configuration.bootstrapMode {
return fmt.Errorf("connect to OPNsense with managed trust: %w", primaryError)
}
current = opnsenseClient{baseURL: configuration.opnsenseURL, apiKey: apiKey, apiSecret: apiSecret, client: bootstrapHTTP}
status, _, err = current.status(ctx)
if err != nil {
return fmt.Errorf("connect to OPNsense with managed and bootstrap trust: managed=%v bootstrap=%w", primaryError, err)
}
if status.Status == "pending" {
return fmt.Errorf("OPNsense has pending transaction %s", status.Transaction)
}
logger.Info("using bootstrap OPNsense trust")
}
jwt, err := readTrimmed(configuration.serviceAccountTokenPath)
if err != nil {
return fmt.Errorf("read service account token: %w", err)
}
vault := vaultClient{baseURL: configuration.vaultURL, client: vaultHTTP}
token, err := vault.login(ctx, configuration.vaultAuthRole, jwt)
if err != nil {
return err
}
privateKey, request, err := generateRequest(configuration.opnsenseIPSANs)
if err != nil {
return err
}
signed, err := vault.sign(ctx, configuration.vaultPKISignPath, token, configuration.opnsenseManagedCAPath, configuration.opnsenseIPSANs, configuration.certificateTTL, privateKey, request)
if err != nil {
return err
}
reference, err := current.stage(ctx, signed)
if err != nil {
return fmt.Errorf("stage certificate: %w", err)
}
logger.Info("certificate staged", "not_after", signed.leaf.NotAfter.UTC().Format(time.RFC3339))
activation, err := current.activate(ctx, reference)
if err != nil {
return err
}
logger.Info("certificate activation pending", "deadline", time.Unix(activation.Deadline, 0).UTC().Format(time.RFC3339))
current.client.CloseIdleConnections()
primary.client.CloseIdleConnections()
if err := waitForCandidate(ctx, primary, reference, activation.Transaction, signed.leaf.Raw); err != nil {
return err
}
logger.Info("certificate activation confirmed", "not_after", signed.leaf.NotAfter.UTC().Format(time.RFC3339))
return nil
}
func certificateComplies(certificate *x509.Certificate, addresses []net.IP) bool {
return len(certificate.DNSNames) == 0 && hasExactIPSANs(certificate, addresses)
}
type activationClient interface {
status(context.Context) (activationStatus, *x509.Certificate, error)
confirm(context.Context, string) error
}
func waitForCandidate(ctx context.Context, client activationClient, reference, transaction string, expectedLeaf []byte) error {
return waitForCandidateWithInterval(ctx, client, reference, transaction, expectedLeaf, 2*time.Second)
}
func waitForCandidateWithInterval(ctx context.Context, client activationClient, reference, transaction string, expectedLeaf []byte, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()
var lastError error
for {
status, certificate, err := client.status(ctx)
if err != nil {
lastError = err
} else if status.Status == "idle" || status.Status == "expired" {
return fmt.Errorf("certificate activation ended before confirmation: %s", status.Status)
} else if status.Transaction != transaction || status.CandidateRefID != reference {
return fmt.Errorf("certificate activation transaction changed")
} else if bytes.Equal(certificate.Raw, expectedLeaf) && status.Status == "pending" && status.Phase == "activated" {
if err := client.confirm(ctx, transaction); err != nil {
return err
}
confirmed, confirmedCertificate, err := client.status(ctx)
if err != nil {
return err
}
if confirmed.Status != "idle" || confirmed.RefID != reference || !bytes.Equal(confirmedCertificate.Raw, expectedLeaf) {
return fmt.Errorf("OPNsense did not retain confirmed certificate")
}
return nil
}
select {
case <-ctx.Done():
if lastError != nil {
return fmt.Errorf("wait for candidate certificate: %w: last status error: %v", ctx.Err(), lastError)
}
return fmt.Errorf("wait for candidate certificate: %w", ctx.Err())
case <-ticker.C:
}
}
}

View file

@ -0,0 +1,78 @@
package rotator
import (
"context"
"crypto/x509"
"errors"
"testing"
"time"
)
type statusResult struct {
status activationStatus
certificate *x509.Certificate
err error
}
type scriptedActivationClient struct {
results []statusResult
confirmations int
}
func (s *scriptedActivationClient) status(context.Context) (activationStatus, *x509.Certificate, error) {
if len(s.results) == 0 {
return activationStatus{}, nil, errors.New("no scripted status")
}
result := s.results[0]
s.results = s.results[1:]
return result.status, result.certificate, result.err
}
func (s *scriptedActivationClient) confirm(context.Context, string) error {
s.confirmations++
return nil
}
func TestWaitForCandidateRequiresExpectedPeerCertificate(t *testing.T) {
oldCertificate := &x509.Certificate{Raw: []byte("old")}
newCertificate := &x509.Certificate{Raw: []byte("new")}
pending := activationStatus{
Status: "pending",
Phase: "activated",
Transaction: "0123456789abcdef0123456789abcdef",
CandidateRefID: "bbbbbbbbbbbbb",
}
client := &scriptedActivationClient{results: []statusResult{
{status: pending, certificate: oldCertificate},
{status: pending, certificate: newCertificate},
{status: activationStatus{Status: "idle", RefID: "bbbbbbbbbbbbb"}, certificate: newCertificate},
}}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := waitForCandidateWithInterval(ctx, client, "bbbbbbbbbbbbb", pending.Transaction, newCertificate.Raw, time.Millisecond); err != nil {
t.Fatal(err)
}
if client.confirmations != 1 {
t.Fatalf("unexpected confirmations: %d", client.confirmations)
}
}
func TestWaitForCandidateDoesNotConfirmOldPeerCertificate(t *testing.T) {
pending := activationStatus{
Status: "pending",
Phase: "activated",
Transaction: "0123456789abcdef0123456789abcdef",
CandidateRefID: "bbbbbbbbbbbbb",
}
client := &scriptedActivationClient{results: []statusResult{
{status: pending, certificate: &x509.Certificate{Raw: []byte("old")}},
}}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
defer cancel()
if err := waitForCandidateWithInterval(ctx, client, "bbbbbbbbbbbbb", pending.Transaction, []byte("new"), time.Millisecond); err == nil {
t.Fatal("old peer certificate was accepted")
}
if client.confirmations != 0 {
t.Fatalf("unexpected confirmations: %d", client.confirmations)
}
}

149
internal/rotator/vault.go Normal file
View file

@ -0,0 +1,149 @@
package rotator
import (
"context"
"crypto/ecdsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
)
type vaultClient struct {
baseURL string
client *http.Client
}
type vaultLoginResponse struct {
Auth struct {
ClientToken string `json:"client_token"`
} `json:"auth"`
}
type vaultSignResponse struct {
Data struct {
Certificate string `json:"certificate"`
CAChain []string `json:"ca_chain"`
} `json:"data"`
}
type signedCertificate struct {
leaf *x509.Certificate
chain []byte
key []byte
}
func (v vaultClient) login(ctx context.Context, role, jwt string) (string, error) {
var response vaultLoginResponse
_, err := jsonRequest(ctx, v.client, http.MethodPost, v.baseURL+"/v1/auth/kubernetes/login", map[string]string{
"role": role,
"jwt": jwt,
}, nil, &response)
if err != nil {
return "", fmt.Errorf("vault login: %w", err)
}
if response.Auth.ClientToken == "" {
return "", errors.New("vault login returned no token")
}
return response.Auth.ClientToken, nil
}
func (v vaultClient) sign(ctx context.Context, path, token, trustedRootPath string, addresses []net.IP, ttl time.Duration, privateKey *ecdsa.PrivateKey, csr []byte) (signedCertificate, error) {
ipSANs := make([]string, len(addresses))
for index, address := range addresses {
ipSANs[index] = address.String()
}
var response vaultSignResponse
_, err := jsonRequest(ctx, v.client, http.MethodPost, v.baseURL+"/v1/"+strings.TrimLeft(path, "/"), map[string]any{
"csr": string(csr),
"ip_sans": ipSANs,
"ttl": fmt.Sprintf("%ds", int64(ttl/time.Second)),
"format": "pem",
}, map[string]string{"X-Vault-Token": token}, &response)
if err != nil {
return signedCertificate{}, fmt.Errorf("sign certificate: %w", err)
}
if response.Data.Certificate == "" || len(response.Data.CAChain) == 0 {
return signedCertificate{}, errors.New("vault returned an incomplete certificate chain")
}
block, _ := pem.Decode([]byte(response.Data.Certificate))
if block == nil || block.Type != "CERTIFICATE" {
return signedCertificate{}, errors.New("vault returned an invalid leaf certificate")
}
leaf, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return signedCertificate{}, fmt.Errorf("parse leaf certificate: %w", err)
}
trustedRoots, err := certificatePool([]string{trustedRootPath})
if err != nil {
return signedCertificate{}, err
}
if err := verifySignedCertificate(leaf, response.Data.CAChain, trustedRoots, addresses, privateKey); err != nil {
return signedCertificate{}, err
}
key, err := marshalPrivateKey(privateKey)
if err != nil {
return signedCertificate{}, err
}
chain := []byte(response.Data.Certificate)
for _, certificate := range response.Data.CAChain[:len(response.Data.CAChain)-1] {
if !strings.HasSuffix(string(chain), "\n") {
chain = append(chain, '\n')
}
chain = append(chain, certificate...)
}
return signedCertificate{leaf: leaf, chain: chain, key: key}, nil
}
func verifySignedCertificate(leaf *x509.Certificate, chain []string, roots *x509.CertPool, addresses []net.IP, privateKey *ecdsa.PrivateKey) error {
intermediates := x509.NewCertPool()
for _, encoded := range chain {
block, _ := pem.Decode([]byte(encoded))
if block == nil || block.Type != "CERTIFICATE" {
return errors.New("vault returned an invalid CA chain")
}
certificate, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return fmt.Errorf("parse CA certificate: %w", err)
}
intermediates.AddCert(certificate)
}
if _, err := leaf.Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSName: addresses[0].String(),
}); err != nil {
return fmt.Errorf("verify signed certificate: %w", err)
}
if !hasExactIPSANs(leaf, addresses) || len(leaf.DNSNames) != 0 {
return errors.New("signed certificate does not contain the exact required IP SAN set")
}
publicKey, ok := leaf.PublicKey.(*ecdsa.PublicKey)
if !ok || !publicKey.Equal(&privateKey.PublicKey) {
return errors.New("signed certificate does not match the generated key")
}
return nil
}
func hasExactIPSANs(certificate *x509.Certificate, expected []net.IP) bool {
if len(certificate.IPAddresses) != len(expected) {
return false
}
actual := make(map[string]struct{}, len(certificate.IPAddresses))
for _, address := range certificate.IPAddresses {
actual[address.String()] = struct{}{}
}
for _, address := range expected {
if _, exists := actual[address.String()]; !exists {
return false
}
}
return true
}

View file

@ -0,0 +1,35 @@
package rotator
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestVaultLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/v1/auth/kubernetes/login" {
http.NotFound(response, request)
return
}
var body map[string]string
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["role"] != "role" || body["jwt"] != "jwt" {
t.Fatalf("unexpected login body: %#v", body)
}
_ = json.NewEncoder(response).Encode(map[string]any{"auth": map[string]string{"client_token": "token"}})
}))
defer server.Close()
token, err := (vaultClient{baseURL: server.URL, client: server.Client()}).login(context.Background(), "role", "jwt")
if err != nil {
t.Fatal(err)
}
if token != "token" {
t.Fatalf("unexpected token: %s", token)
}
}