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:
239
internal/services/apt/apt.go
Normal file
239
internal/services/apt/apt.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user