feat: umfangreiches UI+API-Polish (v1.1.36–1.1.42)

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>
This commit is contained in:
Debian
2026-05-19 16:18:41 +02:00
parent 3178e25e78
commit 35b7308ce2
82 changed files with 8408 additions and 392 deletions

View File

@@ -0,0 +1,438 @@
// 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}))
}