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,239 @@
// Package apt kapselt die APT-Operationen die der Update-Banner braucht:
// gedrosseltes `apt-get update`, locale-stabiler `apt-cache policy` und
// ein Background-Refresh-Timer der die Apt-Indexe warm hält.
//
// Pattern 1:1 von mail-gateway/internal/services/apt/apt.go übernommen
// (siehe dortige Doc-Kommentare für die Bug-Historie). Kurzfassung:
//
// - `LC_ALL=C` zwingt apt auf englischen Output — sonst gibt apt auf
// einem deutschen Locale „Installationskandidat:" und unser
// Prefix-Match auf „Candidate:" failt silent.
// - Eigener Timeout-Kontext für apt-cache policy, damit ein
// gecancelltes Request den Read nicht killt.
// - 5-min-Throttle für `apt-get update` damit der UI-Poll-Tick (30 s)
// nicht jedes Mal das Mirror-Update tritt. Operator kann via
// RefreshNow den Throttle überspringen.
// - Background-Refresh-Goroutine die alle 5 min einen apt-get update
// fährt, damit kurz nach `make publish` der Banner aufgeht ohne
// dass jemand erst die UI öffnen muss.
package apt
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"runtime/debug"
"strings"
"sync"
"time"
)
// edgePackages: die deb-Pakete deren Versionen der UI-Banner anzeigt.
// Reihenfolge bestimmt was der Banner als "primary" zeigt — `edgeguard`
// ist das Meta-Paket, das wir bevorzugen.
var edgePackages = []string{"edgeguard", "edgeguard-api", "edgeguard-ui"}
// PackageVersions liefert installed/available pro edgeguard-Paket im
// flachen Key-Format das die UI bereits konsumiert.
//
// Wenn force=true wird der 5-min-Throttle für apt-get update übersprungen.
func PackageVersions(ctx context.Context, force bool) map[string]string {
if force {
RefreshNow(ctx)
} else {
RefreshIfStale(ctx)
}
out := map[string]string{}
// Eigener Background-Kontext für apt-cache policy: wenn der
// Request canceln sollte (Gateway-Timeout etc), wollen wir den
// Cache-Read trotzdem durchziehen — Lists sind lokal, das dauert
// Millisekunden.
policyCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, pkg := range edgePackages {
installed, candidate := aptCachePolicy(policyCtx, pkg)
out[pkg+"_installed"] = installed
out[pkg+"_available"] = candidate
}
return out
}
// ── apt-get update Throttle ────────────────────────────────────────────
var (
aptUpdateMu sync.Mutex
aptUpdateLastAt time.Time
)
// aptUpdateThrottle: wie oft maximal `apt-get update` ausgeführt werden
// darf. Der Background-Timer (StartBackgroundRefresh) tickt im selben
// Intervall, das deckt den UI-Banner-Fall (30-s-Poll) bequem ab.
const aptUpdateThrottle = 5 * time.Minute
// RefreshIfStale: führt apt-get update aus wenn der letzte Lauf älter
// als aptUpdateThrottle ist; sonst No-Op.
func RefreshIfStale(ctx context.Context) { refreshInternal(ctx, false) }
// RefreshNow: erzwingt apt-get update sofort, bypassed den Throttle.
// Wird vom UI-„Jetzt prüfen"-Button via `?force=1` getriggert, damit
// der Operator nach einem `make publish` nicht 5 min warten muss.
func RefreshNow(ctx context.Context) { refreshInternal(ctx, true) }
func refreshInternal(ctx context.Context, force bool) {
aptUpdateMu.Lock()
doUpdate := force || time.Since(aptUpdateLastAt) >= aptUpdateThrottle
if doUpdate {
aptUpdateLastAt = time.Now()
}
aptUpdateMu.Unlock()
if !doUpdate {
return
}
// 25-s-Budget — kürzer als der typische 30-s-Gateway-Timeout, damit
// ein hängendes apt-get update einen WARN log gibt statt SIGKILL.
updCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
// edgeguard-api läuft als unprivilegierter User; /var/lib/apt/lists
// ist root-owned. postinst pinned `sudo -n /usr/bin/apt-get update -qq`
// in /etc/sudoers.d/edgeguard.
cmd := exec.CommandContext(updCtx, "sudo", "-n", "/usr/bin/apt-get", "update", "-qq")
out, err := cmd.CombinedOutput()
if err != nil {
slog.Warn("apt: update failed (best-effort, falling back to cached lists)",
"error", err,
"output", strings.TrimSpace(string(out)))
}
}
// StartBackgroundRefresh: Fire-and-Forget-Goroutine die einmal beim
// Start + dann periodisch apt-get update fährt, damit der Apt-Cache
// auch ohne UI-Traffic frisch bleibt. So zeigt der Banner kurz nach
// `make publish` ein verfügbares Update, statt 5 min auf die nächste
// UI-Poll-Welle zu warten.
func StartBackgroundRefresh(ctx context.Context) {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("apt background refresh panic",
"panic", r, "stack", string(debug.Stack()))
}
}()
RefreshIfStale(ctx) // Initial warm-up
slog.Info("apt: background refresh timer started",
"interval", aptUpdateThrottle.String())
t := time.NewTicker(aptUpdateThrottle)
defer t.Stop()
for {
select {
case <-ctx.Done():
slog.Info("apt: background refresh timer stopping")
return
case <-t.C:
RefreshIfStale(ctx)
}
}
}()
}
// ── apt-cache policy ──────────────────────────────────────────────────
// aptCachePolicy: pullt Installed:/Candidate: aus `apt-cache policy <pkg>`.
// (none) wird zu "" gemappt.
//
// LC_ALL=C zwingt englischen Output — auf de_DE.UTF-8 spuckt apt sonst
// „Installationskandidat:" und das Match unten failed silent. Bug-Befund
// aus mail-gateway 2026-05-03, siehe internal/services/apt/apt.go dort.
func aptCachePolicy(ctx context.Context, pkg string) (installed, candidate string) {
cmd := exec.CommandContext(ctx, "apt-cache", "policy", pkg)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
out, err := cmd.CombinedOutput()
if err != nil {
slog.Warn("apt-cache policy failed",
"pkg", pkg, "error", err,
"output", strings.TrimSpace(string(out)))
return "", ""
}
// Diagnose: wenn weder Installed: noch Candidate: gefunden wird,
// dump den raw output — sonst rätselt man warum die UI leer bleibt.
defer func() {
if installed == "" && candidate == "" {
slog.Warn("apt-cache policy returned without Installed/Candidate match — raw output follows",
"pkg", pkg, "raw_output", strings.TrimSpace(string(out)))
}
}()
for _, raw := range strings.Split(string(out), "\n") {
line := strings.TrimSpace(raw)
if strings.HasPrefix(line, "Installed:") {
v := strings.TrimSpace(strings.TrimPrefix(line, "Installed:"))
if v != "(none)" {
installed = v
}
}
if strings.HasPrefix(line, "Candidate:") {
v := strings.TrimSpace(strings.TrimPrefix(line, "Candidate:"))
if v != "(none)" {
candidate = v
}
}
}
return
}
// ── Auto-Update (unattended-upgrades-Whitelist) ───────────────────────
// AutoUpdateConfPath: APT-Preferences-File das die edgeguard-Pakete in
// die unattended-upgrades-Whitelist setzt. Setup unattended-upgrades
// + apt-listchanges sind nicht-Pflicht; ohne diese Pakete hat das File
// keinen Effekt, bricht aber auch nichts.
const AutoUpdateConfPath = "/etc/apt/apt.conf.d/52edgeguard-auto-updates"
// autoUpdateConfBody whitelisted die drei edgeguard-Paketnamen für die
// unattended-upgrades-Periodic. Pattern 1:1 aus mail-gateway; sicheres
// Default — nur edgeguard-* werden automatisch aktualisiert, andere
// Pakete bleiben unter manueller Kontrolle.
const autoUpdateConfBody = `// Generated by edgeguard-api. Toggle via Settings → "Automatische Updates".
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
Unattended-Upgrade::Allowed-Origins {
"origin=netcell-it.de,suite=trixie";
};
Unattended-Upgrade::Package-Whitelist {
"edgeguard";
"edgeguard-api";
"edgeguard-ui";
};
`
// SetAutoUpdate schreibt (enabled=true) oder entfernt (false) das
// /etc/apt/apt.conf.d-File. /etc/apt/apt.conf.d ist root-owned und die
// edgeguard-api läuft als non-root unter ProtectSystem=strict (siehe
// auch [[deploy/systemd/edgeguard-api.service]]). Daher geht der Write
// über sudo tee — der exakte Pfad ist in postinst-sudoers gepinnt.
func SetAutoUpdate(enabled bool) error {
if enabled {
cmd := exec.Command("sudo", "-n", "/usr/bin/tee", AutoUpdateConfPath)
cmd.Stdin = strings.NewReader(autoUpdateConfBody)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("sudo tee %s: %w: %s", AutoUpdateConfPath, err, strings.TrimSpace(string(out)))
}
return nil
}
out, err := exec.Command("sudo", "-n", "/bin/rm", "-f", AutoUpdateConfPath).CombinedOutput()
if err != nil {
return fmt.Errorf("sudo rm %s: %w: %s", AutoUpdateConfPath, err, strings.TrimSpace(string(out)))
}
return nil
}
// AutoUpdateEnabled liefert true wenn das Conf-File existiert.
func AutoUpdateEnabled() bool {
_, err := os.Stat(AutoUpdateConfPath)
return err == nil
}