// 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) //nolint:contextcheck // detached by design — Cache-Read überlebt Request-Cancel 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 `. // (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 } // ── Update-Kanal (stable/testing) ────────────────────────────────────── // // Kanal-Modell 1:1 von enconf übernommen: Suite = OS-Codename (trixie), // Komponente = Kanal. Kein eigenes Config-File — die sources.list-Zeile // selbst ist die einzige Quelle der Wahrheit (siehe scripts/install.sh // setup_repo(), das dieselbe Zeile beim Erstinstall schreibt). // SourcesListPath: vom Installer angelegte apt-Quelle. Root-owned wie // AutoUpdateConfPath — Schreibzugriff nur via sudo tee (Sudoers-Pin im // postinst). const SourcesListPath = "/etc/apt/sources.list.d/edgeguard.list" const sourcesListTemplate = "deb [signed-by=/etc/apt/keyrings/nmg.asc] " + "https://git.netcell-it.de/api/packages/projekte/debian trixie %s\n" // Channel liest den aktuell konfigurierten Update-Kanal aus dem letzten // Feld der deb-Zeile. Default "stable" wenn die Datei fehlt oder das // letzte Feld kein bekannter Kanal ist (Fail-safe — nie stillschweigend // "testing" annehmen). func Channel() string { data, err := os.ReadFile(SourcesListPath) if err != nil { return "stable" } for _, raw := range strings.Split(string(data), "\n") { line := strings.TrimSpace(raw) if !strings.HasPrefix(line, "deb ") { continue } fields := strings.Fields(line) if len(fields) == 0 { continue } switch fields[len(fields)-1] { case "stable", "testing": return fields[len(fields)-1] } } return "stable" } // SetChannel schreibt die sources.list-Zeile mit dem neuen Kanal und // refresht den apt-Cache sofort — sonst zeigt der Update-Banner bis zum // nächsten 5-min-Throttle-Fenster noch den alten Kanal-Stand. func SetChannel(ctx context.Context, channel string) error { if channel != "stable" && channel != "testing" { return fmt.Errorf("apt: unknown channel %q (expected stable|testing)", channel) } body := fmt.Sprintf(sourcesListTemplate, channel) cmd := exec.Command("sudo", "-n", "/usr/bin/tee", SourcesListPath) cmd.Stdin = strings.NewReader(body) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("sudo tee %s: %w: %s", SourcesListPath, err, strings.TrimSpace(string(out))) } RefreshNow(ctx) return nil }