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:
237
internal/cluster/jointoken/jointoken.go
Normal file
237
internal/cluster/jointoken/jointoken.go
Normal 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]
|
||||
}
|
||||
Reference in New Issue
Block a user