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

@@ -8,7 +8,6 @@ import (
"net/http"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"syscall"
@@ -16,20 +15,55 @@ import (
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
)
// SystemHandler covers /system/health, /system/package-versions and
// /system/upgrade. Wired from day 1 because the management UI carries
// an update banner that polls package-versions.
// SystemHandler covers /system/health, /system/package-versions,
// /system/upgrade, /system/auto-update + /system/maintenance.
//
// Setup + HAProxyReloader sind optional — werden für /maintenance
// gebraucht. Wenn nil sind die Maintenance-Endpoints noch verfügbar
// aber returnen 503 (Setup nicht abgeschlossen).
type SystemHandler struct {
Version string
Version string
Setup *setup.Store
HAProxyReloader func(stdcontext.Context) error
Audit *audit.Repo
NodeID string
Pool *pgxpool.Pool
}
func NewSystemHandler(version string) *SystemHandler {
return &SystemHandler{Version: version}
}
// WithMaintenance: setup + reloader injection. Aufrufer in main.go
// nach DB-Pool-Open.
func (h *SystemHandler) WithMaintenance(setupStore *setup.Store, reloader func(stdcontext.Context) error) *SystemHandler {
h.Setup = setupStore
h.HAProxyReloader = reloader
return h
}
// WithAudit: Audit-Repo + NodeID damit Toggle-Aktionen (Maintenance,
// Auto-Update) ins audit_log fließen.
func (h *SystemHandler) WithAudit(a *audit.Repo, nodeID string) *SystemHandler {
h.Audit = a
h.NodeID = nodeID
return h
}
// WithDB: Pool für DB-Size + andere PG-introspection-Endpoints.
func (h *SystemHandler) WithDB(pool *pgxpool.Pool) *SystemHandler {
h.Pool = pool
return h
}
func (h *SystemHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/system")
g.GET("/health", h.Health)
@@ -38,6 +72,32 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
g.GET("/interfaces", h.Interfaces)
g.GET("/services", h.Services)
g.GET("/resources", h.Resources)
g.GET("/auto-update", h.AutoUpdate)
g.POST("/auto-update", h.ToggleAutoUpdate)
g.GET("/maintenance", h.Maintenance)
g.POST("/maintenance", h.ToggleMaintenance)
g.GET("/backup-retention", h.BackupRetention)
g.POST("/backup-retention", h.SetBackupRetention)
g.GET("/audit-retention", h.AuditRetention)
g.POST("/audit-retention", h.SetAuditRetention)
g.GET("/db-size", h.DBSize)
g.POST("/haproxy-reload", h.HAProxyReload)
g.POST("/render-configs", h.RenderConfigs)
g.POST("/service-restart", h.ServiceRestart)
g.GET("/upgrade-status", h.UpgradeStatus)
}
// RegisterAgent mountet die read-only System-Endpoints auf der mTLS-
// Agent-Engine (Port :8443). Der Cluster-Aggregator auf der Main-API
// ruft diese Endpoints parallel auf allen Peers ab und kompiliert das
// Ergebnis für /cluster/system/load.
//
// Bewusst KEINE Mutations + KEIN /package-versions (würde apt-get update
// auf jedem Peer triggern), KEIN /upgrade.
func (h *SystemHandler) RegisterAgent(rg *gin.RouterGroup) {
g := rg.Group("/agent/system")
g.GET("/health", h.Health)
g.GET("/resources", h.Resources)
}
// servicesToCheck is the curated list shown on the dashboard
@@ -141,6 +201,14 @@ type resources struct {
// Resources reads /proc + statfs for the box-level metrics card.
// All best-effort — missing files just leave the field at zero.
func (h *SystemHandler) Resources(c *gin.Context) {
response.OK(c, computeLocalSystemResources())
}
// computeLocalSystemResources extrahiert die /proc + statfs reads aus
// dem Handler damit der Cluster-Aggregator denselben Snapshot ohne
// gin.Context erstellen kann. Best-effort: fehlende Quellen lassen
// die jeweiligen Felder einfach auf 0.
func computeLocalSystemResources() resources {
r := resources{}
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
f := strings.Fields(string(data))
@@ -195,7 +263,383 @@ func (h *SystemHandler) Resources(c *gin.Context) {
}
}
}
response.OK(c, r)
return r
}
// Maintenance liefert den aktuellen Whole-Box-Maintenance-Status.
// Wenn Setup nicht initialisiert: enabled=false.
func (h *SystemHandler) Maintenance(c *gin.Context) {
if h.Setup == nil {
response.OK(c, gin.H{"enabled": false, "message": ""})
return
}
st, err := h.Setup.Load()
if err != nil || st == nil {
response.OK(c, gin.H{"enabled": false, "message": ""})
return
}
response.OK(c, gin.H{
"enabled": st.MaintenanceMode,
"message": st.MaintenanceMessage,
})
}
// ToggleMaintenance schaltet whole-box maintenance an/aus.
// Persistiert in setup-State + triggert HAProxy-Render damit das
// 503-Block aktiv wird (oder verschwindet).
func (h *SystemHandler) ToggleMaintenance(c *gin.Context) {
if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable,
simpleErr("setup not initialised"))
return
}
var req struct {
Enabled bool `json:"enabled"`
Message string `json:"message"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.Setup.SetMaintenanceMode(req.Enabled, req.Message); err != nil {
response.Internal(c, err)
return
}
if h.HAProxyReloader != nil {
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
if err := h.HAProxyReloader(ctx); err != nil {
slog.Warn("system: haproxy reload after maintenance toggle failed", "error", err)
}
}
if h.Audit != nil {
action := "system.maintenance.off"
if req.Enabled {
action = "system.maintenance.on"
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), action,
"", gin.H{"enabled": req.Enabled, "message": req.Message}, h.NodeID)
}
response.OK(c, gin.H{"enabled": req.Enabled, "message": req.Message})
}
type simpleErr string
func (e simpleErr) Error() string { return string(e) }
// BackupRetention liefert keep_n. 0 = Default (backup.DefaultKeepN).
func (h *SystemHandler) BackupRetention(c *gin.Context) {
keep := 0
if h.Setup != nil {
if st, err := h.Setup.Load(); err == nil && st != nil {
keep = st.BackupRetentionKeep
}
}
response.OK(c, gin.H{"keep": keep, "default": 14})
}
// SetBackupRetention persistiert die Operator-gewählte Retention.
// keep=0 → wieder Default, keep=1..365 → custom.
func (h *SystemHandler) SetBackupRetention(c *gin.Context) {
if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
return
}
var req struct {
Keep int `json:"keep"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.Setup.SetBackupRetention(req.Keep); err != nil {
response.BadRequest(c, err)
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.backup_retention",
"", gin.H{"keep": req.Keep}, h.NodeID)
}
response.OK(c, gin.H{"keep": req.Keep})
}
// AuditRetention liefert die konfigurierte Retention in Tagen.
// 0 = Default (90 — siehe scheduler/main.go).
func (h *SystemHandler) AuditRetention(c *gin.Context) {
days := 0
if h.Setup != nil {
if st, err := h.Setup.Load(); err == nil && st != nil {
days = st.AuditRetentionDays
}
}
response.OK(c, gin.H{"days": days, "default": 90})
}
// SetAuditRetention setzt Audit-Retention in Tagen. 0..3650.
func (h *SystemHandler) SetAuditRetention(c *gin.Context) {
if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
return
}
var req struct {
Days int `json:"days"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.Setup.SetAuditRetention(req.Days); err != nil {
response.BadRequest(c, err)
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.audit_retention",
"", gin.H{"days": req.Days}, h.NodeID)
}
response.OK(c, gin.H{"days": req.Days})
}
// DBSize liefert pg_database_size + Top-N Tabellen-Größen für Capacity-
// Planning. Operator sieht so welche Tabellen Disk fressen (typisch
// audit_log + firewall_log → siehe Retention-Settings).
type dbSizeTable struct {
Name string `json:"name"`
Bytes int64 `json:"bytes"`
HumanSz string `json:"human_size"`
}
type dbSizeResponse struct {
TotalBytes int64 `json:"total_bytes"`
HumanTotal string `json:"human_total"`
Tables []dbSizeTable `json:"top_tables"`
}
func (h *SystemHandler) DBSize(c *gin.Context) {
if h.Pool == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("db pool unavailable"))
return
}
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
var total int64
var totalH string
if err := h.Pool.QueryRow(ctx, `
SELECT pg_database_size(current_database()),
pg_size_pretty(pg_database_size(current_database()))`).Scan(&total, &totalH); err != nil {
response.Internal(c, err)
return
}
// Top 10 user-tables nach total_relation_size (inkl. Indizes + TOAST).
rows, err := h.Pool.Query(ctx, `
SELECT c.relname,
pg_total_relation_size(c.oid),
pg_size_pretty(pg_total_relation_size(c.oid))
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r' AND n.nspname = 'public'
ORDER BY pg_total_relation_size(c.oid) DESC
LIMIT 10`)
if err != nil {
response.Internal(c, err)
return
}
defer rows.Close()
out := dbSizeResponse{TotalBytes: total, HumanTotal: totalH, Tables: []dbSizeTable{}}
for rows.Next() {
var t dbSizeTable
if err := rows.Scan(&t.Name, &t.Bytes, &t.HumanSz); err != nil {
response.Internal(c, err)
return
}
out.Tables = append(out.Tables, t)
}
response.OK(c, out)
}
// HAProxyReload zwingt ein systemctl reload haproxy.service — nützlich
// wenn der Operator manuell in /etc/edgeguard/tls/ geschrieben hat
// (z. B. eigenes PEM per SSH kopiert) und HAProxy das neue Cert sehen
// soll, ohne eine UI-Mutation zu triggern die das automatisch täte.
func (h *SystemHandler) HAProxyReload(c *gin.Context) {
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
if err != nil {
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.haproxy_reload",
"", gin.H{}, h.NodeID)
}
response.OK(c, gin.H{"ok": true})
}
// restartAllowlist sind die Dienste die der Operator über die UI neu
// starten darf. edgeguard-api selbst ist bewusst ausgeschlossen (würde
// die eigene HTTP-Response killen). postgresql ebenfalls (Datenpfad).
var restartAllowlist = map[string]bool{
"haproxy": true,
"squid": true,
"unbound": true,
"chrony": true,
"nftables": true,
"wireguard": true, // wireguard als Metadienst; einzelne wg-Ifaces über wg-quick@<name>
"edgeguard-scheduler": true,
}
// ServiceRestart startet einen Dienst aus der Allowlist via
// `systemctl restart`. Gibt 400 zurück wenn der Dienst nicht auf der
// Allowlist steht, 500 wenn systemctl fehlschlägt.
func (h *SystemHandler) ServiceRestart(c *gin.Context) {
var req struct {
Service string `json:"service" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Err(c, http.StatusBadRequest, simpleErr("service required"))
return
}
svc := strings.TrimSpace(req.Service)
if !restartAllowlist[svc] {
response.Err(c, http.StatusBadRequest, simpleErr("service not in allowlist: "+svc))
return
}
unitName := svc + ".service"
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "restart", unitName).CombinedOutput()
if err != nil {
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.service_restart",
svc, gin.H{"service": svc}, h.NodeID)
}
response.OK(c, gin.H{"ok": true, "service": svc})
}
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
// aktuellen DB-State. Wenn der HAProxyReloader gesetzt ist, läuft der
// (rendert haproxy.cfg + reload). Praktisch wenn ein Operator denkt
// dass die generierte Config nicht mehr mit der DB übereinstimmt
// (Drift, Manual-Edit, etc.).
//
// v1 macht NUR haproxy — weitere Renderer (firewall, dns, ntp, wg,
// squid) sind per Handler an die jeweiligen Mutations-Endpoints
// gekoppelt; für die fehlt aktuell ein generisches "render all".
func (h *SystemHandler) RenderConfigs(c *gin.Context) {
if h.HAProxyReloader == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("renderer not wired"))
return
}
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
if err := h.HAProxyReloader(ctx); err != nil {
response.Internal(c, err)
return
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.render_configs",
"", gin.H{}, h.NodeID)
}
response.OK(c, gin.H{"ok": true, "rendered": []string{"haproxy"}})
}
// UpgradeStatus liefert den Status des letzten Self-Upgrade-Versuchs.
// Reads systemctl show + journalctl der edgeguard-upgrade.service
// transient unit. Hilft beim Debuggen wenn der Update-Banner nach
// einem Click nicht weg geht — typische Ursachen (apt-Resolver-fail,
// dpkg-broken) sind direkt im Log sichtbar.
type upgradeStatusResponse struct {
State string `json:"state"` // inactive / activating / failed / ...
Result string `json:"result"` // success / exit-code / ...
ExecMainPID int `json:"exec_main_pid"` // 0 wenn nie gelaufen
ExitCode int `json:"exit_code"` // exit-status des letzten Laufs
StartedAt string `json:"started_at"` // RFC3339 oder leer
FinishedAt string `json:"finished_at"` // RFC3339 oder leer
Log []string `json:"log"` // letzte N Zeilen aus journalctl
}
func (h *SystemHandler) UpgradeStatus(c *gin.Context) {
out := upgradeStatusResponse{Log: []string{}}
// systemctl show liefert key=value pairs für die transient unit.
// Wenn die Unit nie existiert hat → leeres Output / inactive.
if data, err := exec.Command("systemctl", "show", "edgeguard-upgrade.service",
"--no-page",
"-p", "ActiveState",
"-p", "Result",
"-p", "ExecMainPID",
"-p", "ExecMainStatus",
"-p", "ExecMainStartTimestamp",
"-p", "ExecMainExitTimestamp",
).CombinedOutput(); err == nil {
for _, line := range strings.Split(string(data), "\n") {
kv := strings.SplitN(strings.TrimSpace(line), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "ActiveState":
out.State = kv[1]
case "Result":
out.Result = kv[1]
case "ExecMainPID":
out.ExecMainPID, _ = strconv.Atoi(kv[1])
case "ExecMainStatus":
out.ExitCode, _ = strconv.Atoi(kv[1])
case "ExecMainStartTimestamp":
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
out.StartedAt = t.UTC().Format(time.RFC3339)
}
case "ExecMainExitTimestamp":
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
out.FinishedAt = t.UTC().Format(time.RFC3339)
}
}
}
}
// Letzte 200 Zeilen Journal — reicht für apt-output + Stack-Traces.
if data, err := exec.Command("journalctl",
"-u", "edgeguard-upgrade.service",
"--no-pager", "-n", "200", "-o", "cat",
).CombinedOutput(); err == nil {
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
// Leere "no entries"-Antwort als leeres Log zurückgeben.
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) {
out.Log = lines
}
}
response.OK(c, out)
}
// AutoUpdate liefert den aktuellen Status (Conf-File existiert?).
func (h *SystemHandler) AutoUpdate(c *gin.Context) {
response.OK(c, gin.H{"enabled": aptsvc.AutoUpdateEnabled()})
}
// ToggleAutoUpdate schaltet automatische Updates an/aus. Schreibt
// /etc/apt/apt.conf.d/52edgeguard-auto-updates (sudo tee) bzw.
// entfernt es (sudo rm). Idempotent.
func (h *SystemHandler) ToggleAutoUpdate(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := aptsvc.SetAutoUpdate(req.Enabled); err != nil {
response.Internal(c, err)
return
}
if h.Audit != nil {
action := "system.auto_update.off"
if req.Enabled {
action = "system.auto_update.on"
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), action,
"", gin.H{"enabled": req.Enabled}, h.NodeID)
}
response.OK(c, gin.H{"enabled": req.Enabled})
}
func (h *SystemHandler) Health(c *gin.Context) {
@@ -207,29 +651,16 @@ func (h *SystemHandler) Health(c *gin.Context) {
// PackageVersions reports installed and available versions for the
// edgeguard-* APT packages. Called by the UI's update banner — it
// polls every few minutes and lights up when available > installed.
// polls every 30s and lights up when available > installed.
//
// `apt-get update -qq` is fired first (best-effort, no error if it
// fails — we'd still return the cached candidate). Then `apt-cache
// policy` is parsed for each package.
// Implementierung delegiert an internal/services/apt: dort sitzt der
// 5-min-Throttle für apt-get update, der LC_ALL=C-Fix für deutsche
// Locales und das Background-Refresh-Timer. `?force=1` (UI-Button
// „Jetzt prüfen") bypassed den Throttle, damit nach einem `make publish`
// nicht 5 min auf das nächste Tick gewartet werden muss.
func (h *SystemHandler) PackageVersions(c *gin.Context) {
// API läuft als edgeguard-User; ohne sudo schreibt apt-get update
// nicht in /var/lib/apt/lists und der candidate bleibt veraltet.
// Sudoers-Eintrag in postinst whitelisted exakt diese Zeile.
_ = exec.Command("sudo", "-n", "/usr/bin/apt-get", "update", "-qq").Run()
out := map[string]string{}
for _, pkg := range []string{"edgeguard-api", "edgeguard-ui", "edgeguard"} {
raw, err := exec.Command("apt-cache", "policy", pkg).CombinedOutput()
if err != nil {
out[pkg+"_installed"] = ""
out[pkg+"_available"] = ""
continue
}
installed, candidate := parseAptPolicy(string(raw))
out[pkg+"_installed"] = installed
out[pkg+"_available"] = candidate
}
force := c.Query("force") == "1" || c.Query("force") == "true"
out := aptsvc.PackageVersions(c.Request.Context(), force)
response.OK(c, out)
}
@@ -248,16 +679,42 @@ func (h *SystemHandler) Upgrade(c *gin.Context) {
// /var/lib/edgeguard ist edgeguard-owned + persistent + von
// beiden Namespaces aus zugänglich.
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
// Retry-Logik gegen Gitea-Packages.gz-Race: nach einem frischen
// Publish kann der Packages-Index für ein paar Sekunden inkonsistent
// sein (z. B. Meta uploaded, api/ui noch nicht in der regenerierten
// Index-Datei) → apt-resolver-fail mit "no choices are installable".
// Drei Versuche mit 15s/30s Backoff geben Gitea Zeit den Index
// nachzuziehen. Befund 2026-05-17.
const script = `#!/bin/bash
set -e
sleep 2
export DEBIAN_FRONTEND=noninteractive
echo "[upgrade] dpkg --configure -a"
dpkg --configure -a || true
echo "[upgrade] apt-get update"
apt-get update -qq
echo "[upgrade] apt-get install -y edgeguard-api edgeguard-ui edgeguard"
apt-get install -y -qq -o Dpkg::Options::=--force-confold edgeguard-api edgeguard-ui edgeguard
retry_apt() {
local attempt=0
local max=3
local wait_for=15
while [ $attempt -lt $max ]; do
attempt=$((attempt + 1))
echo "[upgrade] attempt $attempt/$max: apt-get update + install"
apt-get update -qq || true
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
edgeguard-api edgeguard-ui edgeguard; then
return 0
fi
if [ $attempt -lt $max ]; then
echo "[upgrade] failed (likely Packages-index race), waiting ${wait_for}s before retry"
sleep $wait_for
wait_for=$((wait_for * 2))
fi
done
echo "[upgrade] all $max attempts failed"
return 1
}
retry_apt
echo "[upgrade] complete"
rm -f /var/lib/edgeguard/upgrade.sh
`
@@ -401,27 +858,3 @@ func flagsToList(f net.Flags) []string {
return out
}
// parseAptPolicy extracts "Installed: x" and "Candidate: y" from
// apt-cache policy output. Both can be "(none)"; we normalise that to
// empty string.
var aptPolicyLine = regexp.MustCompile(`^\s+(Installed|Candidate):\s+(.+)\s*$`)
func parseAptPolicy(out string) (installed, candidate string) {
for _, line := range strings.Split(out, "\n") {
m := aptPolicyLine.FindStringSubmatch(line)
if m == nil {
continue
}
val := m[2]
if val == "(none)" {
val = ""
}
switch m[1] {
case "Installed":
installed = val
case "Candidate":
candidate = val
}
}
return installed, candidate
}