clavitor/operations/pop-sync/popmtls/popmtls.go

197 lines
5.2 KiB
Go

// Package popmtls provides mTLS configuration for Clavitor POPs.
//
// This package is used by the POP runtime to configure mutual TLS for
// POP-to-POP communication. It uses only Go stdlib (crypto/tls, crypto/x509).
//
// Usage:
//
// config, err := popmtls.LoadConfig("/opt/clavitor/certs", "use1")
// if err != nil {
// log.Fatal(err)
// }
//
// // As server
// listener, err := tls.Listen("tcp", ":8443", config)
//
// // As client
// conn, err := tls.Dial("tcp", "sg1.clavitor.ai:8443", config)
package popmtls
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
)
// Config holds TLS configuration for a POP.
type Config struct {
Region string
CertDir string
CertPath string
KeyPath string
ChainPath string
}
// LoadConfig loads POP certificate and CA chain from the specified directory.
//
// Parameters:
// - certDir: Directory containing <region>.crt, <region>.key, and ca-chain.crt
// - region: The POP region identifier (e.g., "use1", "sg1")
//
// Returns a tls.Config configured for mutual TLS with:
// - Client certificate for authentication
// - CA pool from ca-chain.crt for peer verification
// - RequireAndVerifyClientCert for mandatory mTLS
func LoadConfig(certDir, region string) (*tls.Config, error) {
certPath := filepath.Join(certDir, fmt.Sprintf("%s.crt", region))
keyPath := filepath.Join(certDir, fmt.Sprintf("%s.key", region))
chainPath := filepath.Join(certDir, "ca-chain.crt")
// Load POP certificate and key
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("read POP cert from %s: %w", certPath, err)
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("read POP key from %s: %w", keyPath, err)
}
// Load certificate (includes chain if present)
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("load X509 key pair: %w", err)
}
// Load CA chain for peer verification
caPEM, err := os.ReadFile(chainPath)
if err != nil {
return nil, fmt.Errorf("read CA chain from %s: %w", chainPath, err)
}
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("failed to parse CA chain from %s", chainPath)
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caPool,
ClientCAs: caPool,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS13,
CipherSuites: nil, // Let Go choose secure defaults for TLS 1.3
}, nil
}
// LoadConfigWithRootFallback loads mTLS config, falling back to just root CA if intermediate is missing.
//
// This is useful during CA rotation or when the intermediate cert might be rotated separately.
func LoadConfigWithRootFallback(certDir, region string) (*tls.Config, error) {
// Try standard config first
config, err := LoadConfig(certDir, region)
if err == nil {
return config, nil
}
// If chain is missing, try loading just root CA
certPath := filepath.Join(certDir, fmt.Sprintf("%s.crt", region))
keyPath := filepath.Join(certDir, fmt.Sprintf("%s.key", region))
rootPath := filepath.Join(certDir, "root-ca.crt")
certPEM, err1 := os.ReadFile(certPath)
if err1 != nil {
return nil, fmt.Errorf("read POP cert: %w", err1)
}
keyPEM, err1 := os.ReadFile(keyPath)
if err1 != nil {
return nil, fmt.Errorf("read POP key: %w", err1)
}
rootPEM, err1 := os.ReadFile(rootPath)
if err1 != nil {
return nil, fmt.Errorf("read root CA (fallback failed): %w", err1)
}
cert, err1 := tls.X509KeyPair(certPEM, keyPEM)
if err1 != nil {
return nil, fmt.Errorf("load key pair: %w", err1)
}
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM(rootPEM) {
return nil, fmt.Errorf("parse root CA")
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caPool,
ClientCAs: caPool,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS13,
}, nil
}
// ParseCertPool parses a PEM-encoded certificate bundle into a CertPool.
//
// This is useful when you need to manually construct a CA pool from
// embedded certificates or other sources.
func ParseCertPool(pemData []byte) (*x509.CertPool, error) {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pemData) {
return nil, fmt.Errorf("no valid certificates found")
}
return pool, nil
}
// ExtractCertsFromPEM parses PEM data and returns individual DER-encoded certificates.
func ExtractCertsFromPEM(pemData []byte) ([][]byte, error) {
var certs [][]byte
data := pemData
for {
block, rest := pem.Decode(data)
if block == nil {
break
}
if block.Type == "CERTIFICATE" {
certs = append(certs, block.Bytes)
}
data = rest
if len(data) == 0 {
break
}
}
if len(certs) == 0 {
return nil, fmt.Errorf("no certificates found in PEM data")
}
return certs, nil
}
// BuildChain builds a certificate pool from a list of PEM files.
//
// Each file can contain one or more certificates. All certificates
// are appended to a single pool.
func BuildChain(paths ...string) (*x509.CertPool, error) {
pool := x509.NewCertPool()
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
if !pool.AppendCertsFromPEM(data) {
return nil, fmt.Errorf("parse certificates from %s", path)
}
}
return pool, nil
}