// Package clustertls verwaltet die per-Cluster Certificate Authority // für Node-to-Node mTLS-Kommunikation. // // Layout on disk: // // /var/lib/edgeguard/cluster-tls/ca.crt (0644) // /var/lib/edgeguard/cluster-tls/ca.key (0600, edgeguard:edgeguard) // /var/lib/edgeguard/cluster-tls/peer.crt (0644) — diese Node // /var/lib/edgeguard/cluster-tls/peer.key (0600, edgeguard:edgeguard) // // Workflow: // - Erste Node (cluster founder): InitCA generiert CA, dann EnsureSelfSigned // erstellt + signiert ihren eigenen peer.crt mit eigener CA. // - Joining Node: lädt CA-Cert vom Primary, generiert lokal CSR, POSTet // ihn mit cluster-join-token zu /api/v1/cluster/issue-cert; Primary // signiert via SignCSR und liefert peer.crt zurück. (Phase 3.4.) // - Single-Node: InitCA + EnsureSelfSigned werden beim API-Boot // idempotent gerufen; Listener auf :8443 kann sofort hochfahren. // // Pattern 1:1 aus mail-gateway/internal/services/clustertls/clustertls.go, // nur Dir + Filenamen angepasst (kein /etc/nmg → /var/lib/edgeguard). package clustertls import ( "crypto/ed25519" "crypto/rand" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "errors" "fmt" "math/big" "net" "os" "path/filepath" "time" ) const ( DefaultDir = "/var/lib/edgeguard/cluster-tls" caCertFile = "ca.crt" caKeyFile = "ca.key" peerCertFile = "peer.crt" peerKeyFile = "peer.key" caValidity = 10 * 365 * 24 * time.Hour // 10 Jahre — Cluster-Lifetime peerValidity = 365 * 24 * time.Hour // 1 Jahr — Renewal künftig automatisch ) // Store kapselt die CA + Peer-Cert-Persistierung unter Dir. type Store struct{ Dir string } func New(dir string) *Store { if dir == "" { dir = DefaultDir } return &Store{Dir: dir} } // HasCA prüft ob ca.crt + ca.key existieren. func (s *Store) HasCA() bool { _, e1 := os.Stat(filepath.Join(s.Dir, caCertFile)) _, e2 := os.Stat(filepath.Join(s.Dir, caKeyFile)) return e1 == nil && e2 == nil } // HasPeer prüft ob peer.crt + peer.key existieren. func (s *Store) HasPeer() bool { _, e1 := os.Stat(filepath.Join(s.Dir, peerCertFile)) _, e2 := os.Stat(filepath.Join(s.Dir, peerKeyFile)) return e1 == nil && e2 == nil } // InitCA generiert die Cluster-CA falls noch keine existiert. Idempotent. // organization landet im Subject — typischerweise die FQDN-Domain. func (s *Store) InitCA(organization string, now func() time.Time) error { if s.HasCA() { return nil } if now == nil { now = time.Now } if err := os.MkdirAll(s.Dir, 0o700); err != nil { return err } pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return err } serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) tpl := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ CommonName: "EdgeGuard Cluster CA", Organization: []string{organization}, }, NotBefore: now().UTC(), NotAfter: now().Add(caValidity).UTC(), IsCA: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, BasicConstraintsValid: true, } der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, pub, priv) if err != nil { return err } if err := writePEM(filepath.Join(s.Dir, caCertFile), 0o644, "CERTIFICATE", der); err != nil { return err } keyDER, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { return err } return writePEM(filepath.Join(s.Dir, caKeyFile), 0o600, "PRIVATE KEY", keyDER) } // EnsureSelfSigned: erstellt peer.crt + peer.key signiert mit der eigenen // CA, falls noch nicht vorhanden. Verwendet für den "Cluster-Founder"-Pfad // (erste Node generiert CA + ihren eigenen Cert). commonName ist meist // die FQDN; dnsNames + ips landen in SubjectAlternativeName, damit der // TLS-Handshake gegen IP-Adressen + Hostnamen funktioniert. // // Idempotent — vorhandenes peer.crt wird NICHT überschrieben (sonst // würden andere Peers ihre Vertrauenskette verlieren nach jedem Boot). func (s *Store) EnsureSelfSigned(commonName string, dnsNames []string, ips []net.IP, now func() time.Time) error { if s.HasPeer() { return nil } if !s.HasCA() { return errors.New("clustertls: peer cert requested but no CA in place — run InitCA first") } if now == nil { now = time.Now } caCert, caKey, err := s.LoadCA() if err != nil { return err } pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return err } serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) tpl := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{CommonName: commonName}, DNSNames: dnsNames, IPAddresses: ips, NotBefore: now().UTC(), NotAfter: now().Add(peerValidity).UTC(), // Server-auth + Client-auth — ein Cert nutzbar für Listener und Outbound. ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, KeyUsage: x509.KeyUsageDigitalSignature, } der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, pub, caKey) if err != nil { return err } if err := writePEM(filepath.Join(s.Dir, peerCertFile), 0o644, "CERTIFICATE", der); err != nil { return err } keyDER, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { return err } return writePEM(filepath.Join(s.Dir, peerKeyFile), 0o600, "PRIVATE KEY", keyDER) } // LoadCA parst CA-Cert + Ed25519 Private Key vom Disk. func (s *Store) LoadCA() (*x509.Certificate, ed25519.PrivateKey, error) { certPEM, err := os.ReadFile(filepath.Join(s.Dir, caCertFile)) if err != nil { return nil, nil, fmt.Errorf("read CA cert: %w", err) } block, _ := pem.Decode(certPEM) if block == nil { return nil, nil, errors.New("ca cert: invalid PEM") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return nil, nil, fmt.Errorf("parse CA cert: %w", err) } keyPEM, err := os.ReadFile(filepath.Join(s.Dir, caKeyFile)) if err != nil { return nil, nil, fmt.Errorf("read CA key: %w", err) } keyBlock, _ := pem.Decode(keyPEM) if keyBlock == nil { return nil, nil, errors.New("ca key: invalid PEM") } raw, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes) if err != nil { return nil, nil, fmt.Errorf("parse CA key: %w", err) } priv, ok := raw.(ed25519.PrivateKey) if !ok { return nil, nil, fmt.Errorf("CA key is not ed25519 (got %T)", raw) } return cert, priv, nil } // SignCSR signiert einen joining-peer CSR. Caller hat den one-shot // cluster-join-Token bereits geprüft (Phase 3.4 — Aufrufer in handlers). func (s *Store) SignCSR(csrPEM string, now func() time.Time) (string, error) { if now == nil { now = time.Now } block, _ := pem.Decode([]byte(csrPEM)) if block == nil || block.Type != "CERTIFICATE REQUEST" { return "", errors.New("invalid CSR PEM") } csr, err := x509.ParseCertificateRequest(block.Bytes) if err != nil { return "", fmt.Errorf("parse CSR: %w", err) } if err := csr.CheckSignature(); err != nil { return "", fmt.Errorf("bad CSR signature: %w", err) } caCert, caKey, err := s.LoadCA() if err != nil { return "", err } serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) tpl := &x509.Certificate{ SerialNumber: serial, Subject: csr.Subject, DNSNames: csr.DNSNames, IPAddresses: csr.IPAddresses, NotBefore: now().UTC(), NotAfter: now().Add(peerValidity).UTC(), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, KeyUsage: x509.KeyUsageDigitalSignature, } der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, csr.PublicKey, caKey) if err != nil { return "", err } return pemString("CERTIFICATE", der), nil } // NewPeerKeyAndCSR generiert einen frischen Ed25519 Key + CSR. Verwendet // vom joining peer bevor er an /cluster/issue-cert POSTet. func NewPeerKeyAndCSR(commonName string, dnsNames []string, ips []net.IP) (keyPEM, csrPEM string, err error) { _, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return "", "", err } tpl := &x509.CertificateRequest{ Subject: pkix.Name{CommonName: commonName}, DNSNames: dnsNames, IPAddresses: ips, } csrDER, err := x509.CreateCertificateRequest(rand.Reader, tpl, priv) if err != nil { return "", "", err } keyDER, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { return "", "", err } return pemString("PRIVATE KEY", keyDER), pemString("CERTIFICATE REQUEST", csrDER), nil } // WriteOwnPeerMaterial persistiert peer.key + peer.crt. Wird vom // cluster-join-Flow gerufen wenn der Primary das Cert signiert hat. func (s *Store) WriteOwnPeerMaterial(keyPEM, certPEM string) error { if err := os.MkdirAll(s.Dir, 0o700); err != nil { return err } if err := os.WriteFile(filepath.Join(s.Dir, peerKeyFile), []byte(keyPEM), 0o600); err != nil { return err } return os.WriteFile(filepath.Join(s.Dir, peerCertFile), []byte(certPEM), 0o644) } // ServerTLSConfig: tls.Config für den mTLS-Listener (z.B. :8443). // peer.crt/peer.key als Identity, ca.crt als einzige ClientCA. // ClientAuth=RequireAndVerifyClientCert. func (s *Store) ServerTLSConfig() (*tls.Config, error) { pair, err := tls.LoadX509KeyPair(filepath.Join(s.Dir, peerCertFile), filepath.Join(s.Dir, peerKeyFile)) if err != nil { return nil, err } pool, err := s.caPool() if err != nil { return nil, err } return &tls.Config{ Certificates: []tls.Certificate{pair}, ClientCAs: pool, RootCAs: pool, ClientAuth: tls.RequireAndVerifyClientCert, MinVersion: tls.VersionTLS13, }, nil } // ClientTLSConfig: tls.Config für outbound peer-to-peer Calls. // Präsentiert peer.crt, verifiziert Server gegen ca.crt. func (s *Store) ClientTLSConfig() (*tls.Config, error) { pair, err := tls.LoadX509KeyPair(filepath.Join(s.Dir, peerCertFile), filepath.Join(s.Dir, peerKeyFile)) if err != nil { return nil, err } pool, err := s.caPool() if err != nil { return nil, err } return &tls.Config{ Certificates: []tls.Certificate{pair}, RootCAs: pool, MinVersion: tls.VersionTLS13, }, nil } // CACertPEM gibt die CA-Cert als PEM-String zurück (für Join-Token-Export). func (s *Store) CACertPEM() (string, error) { b, err := os.ReadFile(filepath.Join(s.Dir, caCertFile)) if err != nil { return "", err } return string(b), nil } // CertInfo: zusammengefasste Cert-Metadata für UI-Status. days_remaining // kann negativ sein wenn der Cert schon abgelaufen ist. type CertInfo struct { CommonName string `json:"common_name"` NotBefore time.Time `json:"not_before"` NotAfter time.Time `json:"not_after"` DaysRemaining int `json:"days_remaining"` IsCA bool `json:"is_ca"` SerialHex string `json:"serial_hex"` } // PeerCertInfo liefert die Metadata des eigenen peer.crt. Wenn keiner // existiert: (nil, error). func (s *Store) PeerCertInfo() (*CertInfo, error) { return parseCertInfo(filepath.Join(s.Dir, peerCertFile)) } // CACertInfo liefert die Metadata der Cluster-CA. Wenn keiner // existiert: (nil, error). func (s *Store) CACertInfo() (*CertInfo, error) { return parseCertInfo(filepath.Join(s.Dir, caCertFile)) } func parseCertInfo(path string) (*CertInfo, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } block, _ := pem.Decode(raw) if block == nil { return nil, errors.New("invalid PEM") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return nil, err } days := int(time.Until(cert.NotAfter) / (24 * time.Hour)) return &CertInfo{ CommonName: cert.Subject.CommonName, NotBefore: cert.NotBefore.UTC(), NotAfter: cert.NotAfter.UTC(), DaysRemaining: days, IsCA: cert.IsCA, SerialHex: cert.SerialNumber.Text(16), }, nil } // RenewSelfSigned überschreibt peer.crt + peer.key mit einem frisch // erzeugten Paar, signiert mit der eigenen CA. Nur sinnvoll auf dem // Founder/Primary — Joiner sollten ihren Cert über den /cluster/ // issue-cert-Flow ablösen, sobald wir Renewal-Tokens bauen. // // Anders als EnsureSelfSigned wird hier ÜBERSCHRIEBEN. Caller (Handler // + CLI) ist verantwortlich für vorherigen Restart-Hinweis bzw. // Service-Restart nach dem Call. func (s *Store) RenewSelfSigned(commonName string, dnsNames []string, ips []net.IP, now func() time.Time) error { if !s.HasCA() { return errors.New("clustertls: RenewSelfSigned requires own CA") } if now == nil { now = time.Now } caCert, caKey, err := s.LoadCA() if err != nil { return err } pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return err } serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) tpl := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{CommonName: commonName}, DNSNames: dnsNames, IPAddresses: ips, NotBefore: now().UTC(), NotAfter: now().Add(peerValidity).UTC(), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, KeyUsage: x509.KeyUsageDigitalSignature, } der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, pub, caKey) if err != nil { return err } if err := writePEM(filepath.Join(s.Dir, peerCertFile), 0o644, "CERTIFICATE", der); err != nil { return err } keyDER, err := x509.MarshalPKCS8PrivateKey(priv) if err != nil { return err } return writePEM(filepath.Join(s.Dir, peerKeyFile), 0o600, "PRIVATE KEY", keyDER) } func (s *Store) caPool() (*x509.CertPool, error) { caPEM, err := os.ReadFile(filepath.Join(s.Dir, caCertFile)) if err != nil { return nil, err } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(caPEM) { return nil, errors.New("failed to add CA to pool") } return pool, nil } func writePEM(path string, mode os.FileMode, kind string, der []byte) error { return os.WriteFile(path, []byte(pemString(kind, der)), mode) } func pemString(kind string, der []byte) string { return string(pem.EncodeToMemory(&pem.Block{Type: kind, Bytes: der})) }