Files
felhom-agent/internal/localapi/cert.go
T
admin 3fecf4c713 slice 8A (agent half): local-API server + provisioning back-half (v0.10.0)
internal/localapi: per-guest local-API server (doc 03 §6) — 7 self-scoped
endpoints, hashed per-guest token store, persisted self-signed leaf with stable
SHA-256 pin, optional 6th daemon goroutine. internal/provision: back-half —
mint token, render bootstrap.json (no registry cred), write 0600, chown
100000:100000, attach pct-set bind mount (host-side, F3, no pct exec).
--selftest=provision. build-golden.sh bakes the controller image + bootstrap
unit. sudoers FELHOM_PROVISION; firewall narrowing artifact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:47:42 +02:00

127 lines
4.7 KiB
Go

package localapi
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"time"
)
// certValidity is the self-signed leaf lifetime. It is long because the leaf's SHA-256
// fingerprint is baked into every guest's bootstrap for pinning — rotating the cert means
// re-issuing bootstraps, so this is deliberately decoupled from short-lived TLS norms. Cert
// rotation is an operator action (slice 10+), not an automatic expiry event.
const certValidity = 10 * 365 * 24 * time.Hour
// EnsureLeaf loads the agent's local-API leaf from certPath/keyPath, generating and persisting
// a fresh self-signed ECDSA-P256 leaf (SAN = bridgeHost, when it is an IP/host) if either file
// is absent. It returns the tls.Certificate to serve and the leaf's SHA-256 fingerprint (the
// agent's pin convention: lowercase hex of the leaf DER) for baking into bootstraps.
//
// Persisting the generated pair keeps the fingerprint STABLE across agent restarts — a fresh
// cert each boot would silently invalidate every already-issued bootstrap's pin.
func EnsureLeaf(certPath, keyPath, bridgeHost string) (tls.Certificate, string, error) {
if fileExists(certPath) && fileExists(keyPath) {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: load leaf %s: %w", certPath, err)
}
fp, err := leafFingerprint(cert)
if err != nil {
return tls.Certificate{}, "", err
}
return cert, fp, nil
}
return generateLeaf(certPath, keyPath, bridgeHost)
}
// generateLeaf creates a self-signed ECDSA-P256 leaf, writes the cert (0644) + key (0600) to
// disk, and returns the loaded pair + its fingerprint.
func generateLeaf(certPath, keyPath, bridgeHost string) (tls.Certificate, string, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: gen key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: gen serial: %w", err)
}
notBefore := time.Now().Add(-1 * time.Hour) // small backdate for clock skew
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "felhom-agent local-api"},
NotBefore: notBefore,
NotAfter: notBefore.Add(certValidity),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// SAN: the bridge host (an IP in practice). The controller pins the leaf SHA-256, so the
// SAN is not load-bearing for trust, but a correct SAN keeps standard tooling happy.
if host := bridgeHost; host != "" {
if ip := net.ParseIP(host); ip != nil {
tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
} else {
tmpl.DNSNames = append(tmpl.DNSNames, host)
}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: create cert: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: marshal key: %w", err)
}
if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: cert dir: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: write cert: %w", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: write key: %w", err)
}
syncDir(filepath.Dir(certPath))
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return tls.Certificate{}, "", fmt.Errorf("localapi: reload generated leaf: %w", err)
}
fp, err := leafFingerprint(cert)
if err != nil {
return tls.Certificate{}, "", err
}
return cert, fp, nil
}
// leafFingerprint returns the lowercase-hex SHA-256 of the leaf certificate DER — the same pin
// convention the agent uses for the Proxmox/PBS host certs.
func leafFingerprint(cert tls.Certificate) (string, error) {
if len(cert.Certificate) == 0 {
return "", fmt.Errorf("localapi: certificate has no leaf")
}
sum := sha256.Sum256(cert.Certificate[0]) // Certificate[0] is the leaf DER
return hex.EncodeToString(sum[:]), nil
}
func fileExists(p string) bool {
if p == "" {
return false
}
_, err := os.Stat(p)
return err == nil
}