Add Vault certificate rotation operator
This commit is contained in:
parent
c685229437
commit
8009b5fd9c
9 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
|
||||
}
|
||||
96
internal/rotator/http.go
Normal file
96
internal/rotator/http.go
Normal 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
|
||||
}
|
||||
105
internal/rotator/main_test.go
Normal file
105
internal/rotator/main_test.go
Normal 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
|
||||
}
|
||||
137
internal/rotator/opnsense.go
Normal file
137
internal/rotator/opnsense.go
Normal 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
|
||||
}
|
||||
58
internal/rotator/opnsense_test.go
Normal file
58
internal/rotator/opnsense_test.go
Normal 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
146
internal/rotator/rotate.go
Normal 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
78
internal/rotator/rotate_test.go
Normal file
78
internal/rotator/rotate_test.go
Normal 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
149
internal/rotator/vault.go
Normal 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
|
||||
}
|
||||
35
internal/rotator/vault_test.go
Normal file
35
internal/rotator/vault_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue