Erster Schritt des golangci-lint-Rollouts (non-blocking): 44 misspell + 4 staticcheck automatisch behoben (32 Dateien, nur Tippfehler/mechanisch). build+test grün. Kein Runtime-Change → kein Deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
240 lines
8.7 KiB
Go
240 lines
8.7 KiB
Go
// 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 periodic 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
|
|
}
|