Backend: - Audit-Log: Search-Endpoint mit ILIKE-Filter (actor/action/subject/date) - NTP: /ntp/status via chronyc tracking (Stratum, Offset, Quelle) - System: /service-restart mit Allowlist (haproxy/squid/unbound/chrony/scheduler) - Domain-Response-Headers + Rate-Limit (Migration 0024) - Join-Tokens (Migration 0025), Cluster-mTLS, Aggregator-Fan-Out - apt-Service für Update-Banner (apt-get update + Versionsprüfung) - Backup-Retry mit exponential backoff (retry_apt 3×) - publish.sh fail-fast + cleanup-old.sh (max 10 Versionen) Frontend: - Audit-Log-Page (/audit) mit Filter + Pagination - ErrorBoundary an React-Root + Vite build-target festgenagelt (iOS 15+) - Storage-Schema-Stamp: auto-wipe bei Versions-Mismatch (blank-page-Fix) - EmptyState-Komponente überall ausgerollt - SSL: Aggregate-Karte (total/expiring/expired/errors) - Backups: Aggregate-Karte (letzter Backup/Größe/Fehlschläge 24h) + Backup-Now - NTP: Sync-Status-Karte (chronyc tracking live) - Domains: Backend-UP/DOWN-Chip aus HAProxy-Stats - Backends: HAProxy-Status-Spalte (UP/DEGRADED/DOWN) - Settings: Service-Neustart-Karte (haproxy/squid/unbound/chrony/scheduler) - Settings: Upgrade-Status-Card, Wartungsmodus, Auto-Update, Retention - Dashboard: Recent-Alerts, Cluster-Health, License-Chip, Onboarding-Hint - System-Regeln im Firewall als eigener Tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
439 lines
14 KiB
Go
439 lines
14 KiB
Go
// 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.
|
|
// organisation landet im Subject — typischerweise die FQDN-Domain.
|
|
func (s *Store) InitCA(organisation 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{organisation},
|
|
},
|
|
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}))
|
|
}
|