3fecf4c713
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>
127 lines
4.7 KiB
Go
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
|
|
}
|