Files
panel/controller/internal/ca/ca.go
T
dbledeez 295eb22826 panel public release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 01:26:41 -07:00

262 lines
8.2 KiB
Go

// Package ca implements the Controller's internal Certificate Authority:
// the root-of-trust that signs both the Controller's own server cert and
// every Agent's client cert during the pairing flow.
//
// Files on disk under caDir:
//
// ca.crt — self-signed root CA certificate (PEM)
// ca.key — CA private key (PEM, mode 0600)
// controller.crt — Controller's server cert, signed by ca.key (PEM)
// controller.key — Controller's server key (PEM, mode 0600)
//
// Bootstrapping is idempotent: on first start we generate both pairs; on
// every subsequent start we load them. Rotate by deleting the specific
// file(s) you want regenerated and restarting.
package ca
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"time"
)
const (
rsaBits = 2048
caValidity = 10 * 365 * 24 * time.Hour
serverValidity = 365 * 24 * time.Hour
agentValidity = 365 * 24 * time.Hour
caCommonName = "panel Internal CA"
serverCommonName = "panel controller"
)
// CA wraps the loaded root cert/key plus the Controller's server cert/key.
type CA struct {
Root *x509.Certificate
RootKey *rsa.PrivateKey
RootPEM []byte // cached ca.crt bytes
Server *x509.Certificate
ServerKey *rsa.PrivateKey
}
// Ensure loads (or initializes) the CA + server cert under dir. If
// hostnames is non-empty, it's added as SANs on the server cert (e.g.
// "mypanel.example.com"). localhost + 127.0.0.1 + the machine's
// hostname are always included.
func Ensure(dir string, extraHostnames []string) (*CA, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("mkdir %s: %w", dir, err)
}
rootCert, rootKey, rootPEM, err := ensureRoot(dir)
if err != nil {
return nil, err
}
serverCert, serverKey, err := ensureServer(dir, rootCert, rootKey, extraHostnames)
if err != nil {
return nil, err
}
return &CA{
Root: rootCert, RootKey: rootKey, RootPEM: rootPEM,
Server: serverCert, ServerKey: serverKey,
}, nil
}
func ensureRoot(dir string) (*x509.Certificate, *rsa.PrivateKey, []byte, error) {
certPath := filepath.Join(dir, "ca.crt")
keyPath := filepath.Join(dir, "ca.key")
if _, err := os.Stat(certPath); err == nil {
cert, pemBytes, err := loadCert(certPath)
if err != nil {
return nil, nil, nil, fmt.Errorf("load ca.crt: %w", err)
}
key, err := loadKey(keyPath)
if err != nil {
return nil, nil, nil, fmt.Errorf("load ca.key: %w", err)
}
return cert, key, pemBytes, nil
}
priv, err := rsa.GenerateKey(rand.Reader, rsaBits)
if err != nil {
return nil, nil, nil, fmt.Errorf("rsa key: %w", err)
}
tpl := &x509.Certificate{
SerialNumber: randomSerial(),
Subject: pkix.Name{CommonName: caCommonName, Organization: []string{"panel"}},
NotBefore: time.Now().Add(-1 * time.Minute),
NotAfter: time.Now().Add(caValidity),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLenZero: false,
}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
if err != nil {
return nil, nil, nil, fmt.Errorf("create ca cert: %w", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, nil, nil, fmt.Errorf("parse ca cert: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
return nil, nil, nil, err
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return nil, nil, nil, err
}
return cert, priv, certPEM, nil
}
func ensureServer(dir string, root *x509.Certificate, rootKey *rsa.PrivateKey, extraHostnames []string) (*x509.Certificate, *rsa.PrivateKey, error) {
certPath := filepath.Join(dir, "controller.crt")
keyPath := filepath.Join(dir, "controller.key")
if _, err := os.Stat(certPath); err == nil {
cert, _, err := loadCert(certPath)
if err != nil {
return nil, nil, fmt.Errorf("load controller.crt: %w", err)
}
key, err := loadKey(keyPath)
if err != nil {
return nil, nil, fmt.Errorf("load controller.key: %w", err)
}
return cert, key, nil
}
priv, err := rsa.GenerateKey(rand.Reader, rsaBits)
if err != nil {
return nil, nil, err
}
host, _ := os.Hostname()
ips := []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}
dns := []string{"localhost", host}
// Split extraHostnames into DNS vs IP — TLS validates IP dials against
// IPAddresses, not DNSNames, so any LAN IP an agent will dial must
// land in the IP set.
for _, h := range extraHostnames {
if ip := net.ParseIP(h); ip != nil {
ips = append(ips, ip)
} else {
dns = append(dns, h)
}
}
dns = uniq(dns)
tpl := &x509.Certificate{
SerialNumber: randomSerial(),
Subject: pkix.Name{CommonName: serverCommonName, Organization: []string{"panel"}},
NotBefore: time.Now().Add(-1 * time.Minute),
NotAfter: time.Now().Add(serverValidity),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: dns,
IPAddresses: ips,
}
der, err := x509.CreateCertificate(rand.Reader, tpl, root, &priv.PublicKey, rootKey)
if err != nil {
return nil, nil, err
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, nil, err
}
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
return nil, nil, err
}
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}), 0o600); err != nil {
return nil, nil, err
}
return cert, priv, nil
}
// SignAgentCSR validates a CSR from a pairing agent and returns the
// signed cert as PEM. The agent's CN becomes its identity. The cert
// is marked ExtKeyUsageClientAuth (mTLS client only).
func (c *CA) SignAgentCSR(csrPEM []byte, agentID string) (certPEM []byte, err error) {
block, _ := pem.Decode(csrPEM)
if block == nil || block.Type != "CERTIFICATE REQUEST" {
return nil, errors.New("bad CSR PEM")
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse CSR: %w", err)
}
if err := csr.CheckSignature(); err != nil {
return nil, fmt.Errorf("CSR signature: %w", err)
}
// Force our agent_id into CN + DNS SANs regardless of what the CSR asked for.
// (This is the panel's canonical identity for the cert; the agent can't
// claim to be someone else.)
tpl := &x509.Certificate{
SerialNumber: randomSerial(),
Subject: pkix.Name{CommonName: agentID, Organization: []string{"panel-agent"}},
NotBefore: time.Now().Add(-1 * time.Minute),
NotAfter: time.Now().Add(agentValidity),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
DNSNames: []string{agentID},
}
der, err := x509.CreateCertificate(rand.Reader, tpl, c.Root, csr.PublicKey, c.RootKey)
if err != nil {
return nil, fmt.Errorf("sign: %w", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), nil
}
// ---- helpers ----
func randomSerial() *big.Int {
max := new(big.Int).Lsh(big.NewInt(1), 128)
n, _ := rand.Int(rand.Reader, max)
return n
}
func loadCert(path string) (*x509.Certificate, []byte, error) {
body, err := os.ReadFile(path)
if err != nil {
return nil, nil, err
}
block, _ := pem.Decode(body)
if block == nil {
return nil, nil, errors.New("no PEM block")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, err
}
return cert, body, nil
}
func loadKey(path string) (*rsa.PrivateKey, error) {
body, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(body)
if block == nil {
return nil, errors.New("no PEM block")
}
return x509.ParsePKCS1PrivateKey(block.Bytes)
}
func uniq(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}