Files
edgeguard-native/internal/cluster/jointoken/jointoken.go
Debian 35b7308ce2 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>
2026-05-19 16:18:41 +02:00

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