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

View File

@@ -0,0 +1,72 @@
package cluster
// Phase-3.2: periodischer Heartbeat + Stale-Sweeper.
//
// Hintergrund: EnsureSelfRegistered schreibt last_seen einmal beim
// API-Boot. Ohne periodisches Re-Schreiben friert last_seen auf den
// Boot-Zeitpunkt ein — Peers (im Multi-Node-Setup) hätten keinen Weg
// zu erkennen ob dieser Node noch lebt. Die Heartbeat-Goroutine in der
// API bumpt das alle 30s; der Scheduler räumt mit SweepStaleNodes Peers
// die länger als <threshold> nicht gemeldet haben auf status='offline'.
//
// Single-Node-Effekt: Cluster-UI zeigt korrekt "last seen 12s" statt
// "last seen 3h" weil last_seen frisch ist. UI-Drift-Banner-Logik im
// ClusterHandler.Status nutzt die selben Felder.
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Heartbeat bumpt last_seen + status='online' für die eigene Node-Row
// und aktualisiert version + config_hash. Idempotent. UPDATE-only — die
// Row muss existieren (wird via EnsureSelfRegistered beim Boot angelegt).
//
// Hash-Berechnung läuft synchron — typisch <50ms auf einer realistischen
// DB-Größe; falls die compute-SQL fehlt (Migration im Flux) wird der
// vorhandene config_hash via COALESCE behalten.
func Heartbeat(ctx context.Context, pool *pgxpool.Pool, localID, version string) error {
if pool == nil || localID == "" {
return nil
}
hash, _ := ComputeConfigHash(ctx, pool)
_, err := pool.Exec(ctx, `
UPDATE ha_nodes SET
last_seen = NOW(),
status = 'online',
version = COALESCE(NULLIF($1, ''), version),
config_hash = COALESCE(NULLIF($2, ''), config_hash),
updated_at = NOW()
WHERE id = $3`, version, hash, localID)
return err
}
// SweepStaleNodes flippt status='online' → 'offline' für Peers deren
// last_seen älter als threshold ist. Liefert die Anzahl gefliptpter
// Rows zurück (für Logging). Idempotent — markiert keine Rows die
// schon offline sind.
//
// Threshold-Konvention: 4× Heartbeat-Intervall = 2 min bei 30s-Tick.
// Lässt Platz für eine verpasste API-Tick (Restart, GC-Pause, kurzer
// Network-Glitch) ohne false-positive Offline.
func SweepStaleNodes(ctx context.Context, pool *pgxpool.Pool, threshold time.Duration) (int64, error) {
if pool == nil || threshold <= 0 {
return 0, nil
}
// Wir bauen das Interval als String — pgx kann time.Duration nicht
// direkt als INTERVAL serialisieren.
interval := fmt.Sprintf("%d seconds", int(threshold.Seconds()))
tag, err := pool.Exec(ctx, `
UPDATE ha_nodes SET
status = 'offline',
updated_at = NOW()
WHERE last_seen < NOW() - $1::interval
AND status = 'online'`, interval)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}

View File

@@ -0,0 +1,237 @@
// Package jointoken implementiert one-shot Cluster-Join-Token für
// Phase 3.4.
//
// Format: "eg-join-v1.<base64url(payload)>.<base64url(hmac)>"
//
// Payload (CBOR-ähnlich, kompakt JSON): {n: nonce(16B base64),
// e: expires_at_unix, f: ca_fingerprint_hex_16chars}
//
// HMAC: SHA-256 über payload-bytes, Key aus /var/lib/edgeguard/cluster-join-secret
// (auto-generiert beim ersten Generate-Call, 32B random, 0600).
//
// Lifecycle:
// 1. Admin POSTet /cluster/join-tokens → Server generiert Token, schickt
// ihn als Klartext-String (einmalig). Server speichert NICHTS
// (token ist self-contained).
// 2. Joiner POSTet /cluster/issue-cert mit {token, csr}.
// 3. Server Verify(token): HMAC ok? Nicht abgelaufen? CA-Fingerprint
// passt zum eigenen? Nonce nicht schon in join_tokens_used?
// 4. Bei Erfolg: nonce in join_tokens_used inserten (PK-Conflict =
// Re-Use → reject). Dann CSR signieren + zurückgeben.
//
// One-Shot-Semantik: PG-Insert mit ON CONFLICT DO NOTHING; wenn keine
// Row inserted wurde → schon konsumiert. Das ersetzt einen sonst
// nötigen Distributed-Lock.
package jointoken
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
// SecretPath: HMAC-Key. Auto-Generated beim ersten Generate.
DefaultSecretPath = "/var/lib/edgeguard/cluster-join-secret"
tokenPrefix = "eg-join-v1."
// DefaultTTL — wie lange ein generierter Token gültig bleibt.
DefaultTTL = 24 * time.Hour
)
// Service kapselt Token-Gen + -Verify mit DB-One-Shot-Tracking.
type Service struct {
Pool *pgxpool.Pool
SecretPath string
GetCAFinger func() (string, error) // liefert 16-hex-fingerprint der eigenen CA
TTL time.Duration
}
func New(pool *pgxpool.Pool, getCAFinger func() (string, error)) *Service {
return &Service{
Pool: pool,
SecretPath: DefaultSecretPath,
GetCAFinger: getCAFinger,
TTL: DefaultTTL,
}
}
// payload ist das JSON inside-the-token.
type payload struct {
Nonce string `json:"n"`
Expires int64 `json:"e"`
CAFinger16 string `json:"f"`
}
// Generate erzeugt einen frischen Token. Caller (Handler) muss bereits
// Admin-Auth geprüft haben.
func (s *Service) Generate() (string, time.Time, error) {
if s.GetCAFinger == nil {
return "", time.Time{}, errors.New("jointoken: GetCAFinger unset")
}
finger, err := s.GetCAFinger()
if err != nil {
return "", time.Time{}, fmt.Errorf("ca fingerprint: %w", err)
}
secret, err := s.ensureSecret()
if err != nil {
return "", time.Time{}, fmt.Errorf("load secret: %w", err)
}
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return "", time.Time{}, err
}
ttl := s.TTL
if ttl <= 0 {
ttl = DefaultTTL
}
exp := time.Now().Add(ttl).UTC()
p := payload{
Nonce: base64.RawURLEncoding.EncodeToString(nonceBytes),
Expires: exp.Unix(),
CAFinger16: finger,
}
pj, err := json.Marshal(p)
if err != nil {
return "", time.Time{}, err
}
mac := hmac.New(sha256.New, secret)
mac.Write(pj)
sig := mac.Sum(nil)
return tokenPrefix +
base64.RawURLEncoding.EncodeToString(pj) + "." +
base64.RawURLEncoding.EncodeToString(sig), exp, nil
}
// Consume verifiziert den Token UND markiert die nonce als verbraucht
// — wenn die nonce schon in join_tokens_used steht (oder das INSERT
// nichts geändert hat), lehnen wir ab. Erfolgreicher Consume liefert
// die CA-Fingerprint aus dem Payload (Caller kann gegen die eigene
// CA vergleichen).
//
// `consumedBy` ist freier String der zum Audit ins DB-Row landet
// (typisch: peer.fqdn oder peer.api_url).
func (s *Service) Consume(ctx context.Context, token, consumedBy string) (payload, error) {
var zero payload
if !strings.HasPrefix(token, tokenPrefix) {
return zero, errors.New("invalid token format")
}
rest := strings.TrimPrefix(token, tokenPrefix)
parts := strings.SplitN(rest, ".", 2)
if len(parts) != 2 {
return zero, errors.New("invalid token format")
}
pj, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return zero, fmt.Errorf("decode payload: %w", err)
}
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return zero, fmt.Errorf("decode sig: %w", err)
}
secret, err := s.loadSecret()
if err != nil {
return zero, fmt.Errorf("load secret: %w", err)
}
mac := hmac.New(sha256.New, secret)
mac.Write(pj)
want := mac.Sum(nil)
if !hmac.Equal(want, sig) {
return zero, errors.New("bad signature")
}
var p payload
if err := json.Unmarshal(pj, &p); err != nil {
return zero, fmt.Errorf("unmarshal payload: %w", err)
}
now := time.Now().UTC()
if now.Unix() > p.Expires {
return zero, errors.New("token expired")
}
if s.GetCAFinger != nil {
ownFinger, err := s.GetCAFinger()
if err == nil && ownFinger != p.CAFinger16 {
return zero, errors.New("ca fingerprint mismatch (wrong cluster?)")
}
}
// One-shot-INSERT — wenn die nonce schon da ist (Re-Use), schlägt
// das fehl und wir lehnen ab.
exp := time.Unix(p.Expires, 0).UTC()
tag, err := s.Pool.Exec(ctx, `
INSERT INTO join_tokens_used (nonce, expires_at, consumed_by)
VALUES ($1, $2, $3)
ON CONFLICT (nonce) DO NOTHING`, p.Nonce, exp, consumedBy)
if err != nil {
return zero, fmt.Errorf("track nonce: %w", err)
}
if tag.RowsAffected() == 0 {
return zero, errors.New("token already used")
}
return p, nil
}
// CleanupExpired löscht abgelaufene Nonce-Rows. Idempotent; Aufruf vom
// Scheduler einmal pro Stunde reicht.
func (s *Service) CleanupExpired(ctx context.Context) (int64, error) {
tag, err := s.Pool.Exec(ctx,
`DELETE FROM join_tokens_used WHERE expires_at < NOW() - INTERVAL '7 days'`)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// ── secret handling ───────────────────────────────────────────────────
func (s *Service) ensureSecret() ([]byte, error) {
if b, err := s.loadSecret(); err == nil && len(b) >= 32 {
return b, nil
}
// Generate fresh secret. Dir wird in postinst erstellt (0700,
// edgeguard-owned), wir schreiben direkt rein.
dir := filepath.Dir(s.path())
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
if err := os.WriteFile(s.path(), key, 0o600); err != nil {
return nil, err
}
return key, nil
}
func (s *Service) loadSecret() ([]byte, error) {
return os.ReadFile(s.path())
}
func (s *Service) path() string {
if s.SecretPath != "" {
return s.SecretPath
}
return DefaultSecretPath
}
// CAFingerprint16 liefert die ersten 16 Hex-Chars vom SHA-256 der CA-
// Certificate DER-Bytes. Eindeutig genug für den Cluster-Trust-Check
// (64 bit Entropie); kürzer als 64 Hex damit Token nicht aufbläst.
func CAFingerprint16(caCertDER []byte) string {
sum := sha256.Sum256(caCertDER)
return hex.EncodeToString(sum[:])[:16]
}

View File

@@ -100,6 +100,21 @@ RETURNING id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip,
return scanNode(row)
}
// Delete entfernt einen Peer aus ha_nodes. Caller (Handler) verhindert
// dass die lokale Node sich selbst löscht — sonst geht der nächste
// Heartbeat-Tick die Row wieder anlegen UND der Cluster-Status zeigt
// für 2 min "weg" obwohl der Node noch läuft.
func (s *Store) Delete(ctx context.Context, id string) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM ha_nodes WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// EnsureSelfRegistered mints the node-id if needed, builds the row
// from setup.json + os.Hostname + node.conf, and upserts it. Called
// on edgeguard-api boot AFTER the DB pool is reachable.