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>
238 lines
7.0 KiB
Go
238 lines
7.0 KiB
Go
// 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]
|
|
}
|