// 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 }