From 117d16e597eb81e8e8f9c9aefeaf786d8a400bf4 Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 11 May 2026 22:02:54 +0200 Subject: [PATCH] fix(update): self-upgrade via sudo systemd-run + animiertes Modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handler: edgeguard-User darf systemd-run nicht direkt aufrufen ("Inter- active authentication required"). sudo -n + sudoers-Whitelist auf exakt die Unit-Form für edgeguard-upgrade.service. UI: UpdateBanner-Komponente neu — Pattern wie mail-gateway/enconf: Banner mit Force-Check-Button + Popconfirm. Beim Apply zeigt full- screen-Overlay mit animiertem Orbit (zwei Ringe + Dots), Versions- sprung, vier Step-Indicators (Download/Install/Restart/Verify) und Live-Timer. Poll auf /system/health detektiert Version-Flip ODER "sah down dann up" und window.reload nach 1.5s. Sicherheits-Timeout 2 min schickt sonst auch reload. Co-Authored-By: Claude Opus 4.7 (1M context) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 2 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- internal/handlers/system.go | 14 +- .../src/components/Layout/Sidebar.tsx | 2 +- management-ui/src/components/UpdateBanner.tsx | 327 +++++++++++------- management-ui/src/i18n/locales/de/common.json | 26 +- management-ui/src/i18n/locales/en/common.json | 26 +- .../debian/edgeguard-api/DEBIAN/postinst | 5 + 10 files changed, 247 insertions(+), 161 deletions(-) diff --git a/VERSION b/VERSION index 73b4678..46354d7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.51 +1.0.52 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 7bd6a53..de2d4d2 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -48,7 +48,7 @@ import ( wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.51" +var version = "1.0.52" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index fda2eb4..c330457 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.51" +var version = "1.0.52" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 91d56bc..a624bd3 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -24,7 +24,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.51" +var version = "1.0.52" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/handlers/system.go b/internal/handlers/system.go index 4c6f0f8..2dc5155 100644 --- a/internal/handlers/system.go +++ b/internal/handlers/system.go @@ -261,14 +261,22 @@ rm -f /tmp/edgeguard-upgrade.sh } const unitName = "edgeguard-upgrade.service" - _ = exec.Command("systemctl", "reset-failed", unitName).Run() - cmd := exec.Command("systemd-run", + // API läuft als edgeguard-User; systemd-run + systemctl reset-failed + // brauchen root. Sudoers-Whitelist in postinst lässt exakt diese + // beiden Aufrufe durch. Ohne sudo schlug das früher mit + // "Interactive authentication required" fehl und der Fallback + // (setsid bash als edgeguard) konnte kein apt-get update — das + // Modal blieb hängen und die Box nicht aktualisiert. + _ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run() + cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run", "--unit="+unitName, "--description=EdgeGuard self-upgrade", "--collect", "bash", "/tmp/edgeguard-upgrade.sh") if err := cmd.Run(); err != nil { - // systemd-run unavailable (dev env) — fall back to setsid + // systemd-run unavailable (dev env without sudo) — fall back + // to setsid. In Prod sollte das nie greifen. + slog.Warn("upgrade: sudo systemd-run failed, falling back to setsid", "error", err) fallback := exec.Command("setsid", "bash", "/tmp/edgeguard-upgrade.sh") fallback.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if err2 := fallback.Start(); err2 != nil { diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index b866f83..1fe0a07 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -77,7 +77,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.51' +const VERSION = '1.0.52' export default function Sidebar({ isOpen, onClose }: SidebarProps) { const { t } = useTranslation() diff --git a/management-ui/src/components/UpdateBanner.tsx b/management-ui/src/components/UpdateBanner.tsx index e103457..53aeca4 100644 --- a/management-ui/src/components/UpdateBanner.tsx +++ b/management-ui/src/components/UpdateBanner.tsx @@ -1,26 +1,19 @@ -import { Alert, Button, List, Modal, Tag, Typography, message } from 'antd' -import { CloudDownloadOutlined } from '@ant-design/icons' -import { useQuery, useMutation } from '@tanstack/react-query' +import { Alert, Button, Popconfirm, Space, message } from 'antd' +import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined } from '@ant-design/icons' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useState, useEffect } from 'react' import apiClient, { isEnvelope } from '../api/client' -const { Text } = Typography +interface PackageVersions { [key: string]: string } +interface SystemHealth { status: string; version: string } -interface PackageVersions { - [key: string]: string -} +interface PendingUpdate { pkg: string; installed: string; available: string } -interface PendingUpdate { - pkg: string - installed: string - available: string -} - -// allUpdates lists every package with installed != available. -// Match enconf/mail-gateway pattern of showing the multi-package -// list in a confirm-modal rather than just one banner-line. +// allUpdates parsed das flache map-Format ({pkg_installed,pkg_available}) +// das /system/package-versions zurückliefert. Eines davon ist meist +// das meta-Paket "edgeguard" → die "Ziel-Version". function allUpdates(v: PackageVersions): PendingUpdate[] { const out: PendingUpdate[] = [] for (const key of Object.keys(v)) { @@ -35,141 +28,209 @@ function allUpdates(v: PackageVersions): PendingUpdate[] { return out } -interface SystemHealth { status: string; version: string } - export default function UpdateBanner() { const { t } = useTranslation() - const [open, setOpen] = useState(false) - // applyingFor holds the version we expect AFTER the upgrade so the - // modal can poll system/health and detect the version-flip. Switches - // back to null + closes the modal once the new version is live. - const [applyingFor, setApplyingFor] = useState(null) + const [msg, msgCtx] = message.useMessage() - const { data } = useQuery({ + const pkgVersions = useQuery({ queryKey: ['system', 'package-versions'], queryFn: async () => { const r = await apiClient.get('/system/package-versions') - if (isEnvelope(r.data)) return r.data.data as PackageVersions - return {} as PackageVersions + return isEnvelope(r.data) ? (r.data.data as PackageVersions) : ({} as PackageVersions) }, - refetchInterval: 5 * 60 * 1000, + refetchInterval: 30_000, + refetchOnMount: 'always', + staleTime: 0, + gcTime: 0, }) - const { data: health } = useQuery({ - queryKey: ['system', 'health'], - queryFn: async () => { - const r = await apiClient.get('/system/health') - return isEnvelope(r.data) ? (r.data.data as SystemHealth) : null - }, - // poll faster while we're applying so the version-flip is caught - refetchInterval: applyingFor ? 2_000 : 30_000, - }) + const [upgrading, setUpgrading] = useState(false) + const [upgradeElapsed, setUpgradeElapsed] = useState(0) + const [forceChecking, setForceChecking] = useState(false) + const upgradePollRef = useRef | null>(null) + const upgradeTickRef = useRef | null>(null) + const installedRef = useRef('') + const targetRef = useRef('') - const upgrade = useMutation({ - mutationFn: async () => { await apiClient.post('/system/upgrade') }, - onError: (e: Error) => message.error(e.message), - }) + useEffect(() => () => { + if (upgradePollRef.current) clearInterval(upgradePollRef.current) + if (upgradeTickRef.current) clearInterval(upgradeTickRef.current) + }, []) - // Detect version flip: api came back with the new version after - // restart → upgrade succeeded, close modal + tell user. - useEffect(() => { - if (!applyingFor || !health) return - if (health.version === applyingFor) { - message.success(t('update.success', { version: applyingFor })) - setApplyingFor(null) - setOpen(false) - // page reload picks up the rebuilt UI bundle - setTimeout(() => window.location.reload(), 1500) - } - }, [applyingFor, health, t]) - - if (!data) return null + const data = pkgVersions.data ?? {} const updates = allUpdates(data) - if (updates.length === 0) return null - - // Anchor: prefer the meta package's available version as the - // "target" (since edgeguard depends on edgeguard-api/ui at the - // same version). Falls back to the first listed package. + const updateAvailable = updates.length > 0 const meta = updates.find(u => u.pkg === 'edgeguard') ?? updates[0] + const installedVersion = meta?.installed ?? '' + const targetVersion = meta?.available ?? '' + + const forceCheck = async () => { + setForceChecking(true) + try { + await pkgVersions.refetch() + const fresh = pkgVersions.data ?? {} + const found = allUpdates(fresh).length > 0 + msg[found ? 'success' : 'info']( + found ? t('update.checkDone') : t('update.noUpdate'), + ) + } catch { + msg.error(t('update.checkFailed')) + } finally { + setForceChecking(false) + } + } + + const startUpgrade = () => { + installedRef.current = installedVersion + targetRef.current = targetVersion + setUpgrading(true) + setUpgradeElapsed(0) + upgradeTickRef.current = setInterval(() => setUpgradeElapsed((e) => e + 1), 1000) + + apiClient.post('/system/upgrade') + .then(() => { + // Poll /healthz (kein Auth, robust auch wenn die API gerade + // restartet und Cookie ihre Session nicht erkennt). + let sawDown = false + upgradePollRef.current = setInterval(async () => { + try { + const res = await apiClient.get('/system/health') + const newV = (isEnvelope(res.data) ? (res.data.data as SystemHealth).version : '') + const flipped = newV && installedRef.current && newV !== installedRef.current + if (flipped || sawDown) { + if (upgradePollRef.current) clearInterval(upgradePollRef.current) + if (upgradeTickRef.current) clearInterval(upgradeTickRef.current) + setUpgrading(false) + msg.success(t('update.success', { version: targetRef.current })) + setTimeout(() => window.location.reload(), 1500) + } + } catch { + // Connection refused / 502 → API restartet. Beim nächsten + // erfolgreichen Poll erkennen wir den Version-Flip. + sawDown = true + } + }, 3000) + // Sicherheits-Timeout: nach 2 Min einfach reload — falls der + // Restart länger braucht als erwartet, kommt die UI in jedem + // Fall wieder hoch. + setTimeout(() => { + if (upgradePollRef.current) clearInterval(upgradePollRef.current) + if (upgradeTickRef.current) clearInterval(upgradeTickRef.current) + setUpgrading(false) + window.location.reload() + }, 120_000) + }) + .catch((e: Error) => { + if (upgradeTickRef.current) clearInterval(upgradeTickRef.current) + if (upgradePollRef.current) clearInterval(upgradePollRef.current) + setUpgrading(false) + msg.error(t('update.failed') + ': ' + e.message) + }) + } + + if (!updateAvailable && !upgrading) { + return <>{msgCtx} + } return ( <> - } - message={t('update.available', { pkg: meta.pkg, installed: meta.installed, available: meta.available })} - action={ - - } - /> + {msgCtx} - { if (!applyingFor) setOpen(false) }} - maskClosable={!applyingFor} - closable={!applyingFor} - width={620} - footer={ - applyingFor ? null : [ - , - , - ] - } - > - {applyingFor ? ( -
- -
- {t('update.applyingDetail', { current: health?.version ?? '—', target: applyingFor })} + {updateAvailable && !upgrading && ( + } + message={t('update.available', { version: targetVersion })} + description={updates.length > 1 + ? t('update.multiPackageHint', { count: updates.length }) + : undefined} + action={ + + + + + + + } + /> + )} + + {upgrading && ( +
+
+
+
+
+
+
+
+ +
+ +
{t('update.running')}
+
+ v{installedRef.current || '…'} → v{targetRef.current || '…'} +
+ +
+ = 5} + active={upgradeElapsed < 5} + label={t('update.stepDownload')} + /> + = 15} + active={upgradeElapsed >= 5 && upgradeElapsed < 15} + label={t('update.stepInstall')} + /> + = 25} + active={upgradeElapsed >= 15 && upgradeElapsed < 25} + label={t('update.stepRestart')} + /> + = 25} + label={t('update.stepVerify')} + /> +
+ +
{upgradeElapsed}s
+
{t('update.waitHint')}
- ) : ( - <> - {t('update.modalIntro')} - ( - - {u.pkg}} - description={ - - {u.installed}{u.available} - - } - /> - - )} - /> - - - )} - +
+ )} ) } + +function Step({ done, active, label }: { done: boolean; active: boolean; label: string }) { + const cls = + 'update-modal__step' + + (active ? ' update-modal__step--active' : '') + + (done ? ' update-modal__step--done' : '') + return ( +
+ + {label} +
+ ) +} diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 7696599..749732b 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -306,17 +306,23 @@ "setupCompleted": "Setup abgeschlossen" }, "update": { - "available": "Update verfügbar: {{pkg}} {{installed}} → {{available}}", - "viewDetails": "Details anzeigen", - "applyNow": "Jetzt aktualisieren", - "applying": "Update läuft …", - "applyingHint": "Pakete werden installiert + Services restartet. Verbindung kann kurz brechen — die UI lädt automatisch neu sobald Version {{version}} live ist.", - "applyingDetail": "aktuell {{current}} · Ziel {{target}}", - "started": "Update wurde gestartet — der Server wird in Kürze neu starten.", + "available": "Update verfügbar: Version {{version}}", + "multiPackageHint": "{{count}} Pakete werden aktualisiert.", + "applyNow": "Jetzt installieren", + "confirmTitle": "Update jetzt installieren?", + "confirmDesc": "Pakete werden auf Version {{version}} aktualisiert. edgeguard-api + scheduler restarten (~2-5s), HAProxy/nft/WG/Squid/Unbound/Chrony laufen durch.", + "checkNow": "Auf Updates prüfen", + "checkDone": "Update verfügbar", + "noUpdate": "Keine neuen Updates", + "checkFailed": "Update-Check fehlgeschlagen", + "running": "Update läuft …", + "waitHint": "Bitte warten — die Seite lädt automatisch neu sobald die neue Version live ist.", "success": "Update auf {{version}} abgeschlossen.", - "modalTitle": "EdgeGuard-Update", - "modalIntro": "Folgende Pakete werden aktualisiert:", - "modalWarn": "Während des Updates werden edgeguard-api + edgeguard-scheduler neu gestartet (~2-5 Sekunden Service-Unterbrechung). HAProxy + nft + WireGuard + Squid + Unbound + Chrony bleiben durchgehend aktiv." + "failed": "Update fehlgeschlagen", + "stepDownload": "Pakete laden", + "stepInstall": "Installation", + "stepRestart": "Service-Restart", + "stepVerify": "Verifizierung" }, "wg": { "title": "WireGuard", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index e6a01de..78eb875 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -306,17 +306,23 @@ "setupCompleted": "Setup completed" }, "update": { - "available": "Update available: {{pkg}} {{installed}} → {{available}}", - "viewDetails": "View details", - "applyNow": "Apply now", - "applying": "Update in progress …", - "applyingHint": "Packages are installed and services restarted. Connection may briefly break — the UI reloads automatically once version {{version}} is live.", - "applyingDetail": "current {{current}} · target {{target}}", - "started": "Update has started — the server will restart shortly.", + "available": "Update available: version {{version}}", + "multiPackageHint": "{{count}} packages will be updated.", + "applyNow": "Install now", + "confirmTitle": "Install update now?", + "confirmDesc": "Packages will be updated to version {{version}}. edgeguard-api + scheduler restart (~2-5 s), HAProxy/nft/WG/Squid/Unbound/Chrony stay running.", + "checkNow": "Check for updates", + "checkDone": "Update available", + "noUpdate": "No new updates", + "checkFailed": "Update check failed", + "running": "Update in progress …", + "waitHint": "Please wait — the page will reload automatically once the new version is live.", "success": "Updated to {{version}}.", - "modalTitle": "EdgeGuard update", - "modalIntro": "The following packages will be updated:", - "modalWarn": "During the update edgeguard-api + edgeguard-scheduler restart (~2-5 s service interruption). HAProxy + nft + WireGuard + Squid + Unbound + Chrony stay up the whole time." + "failed": "Update failed", + "stepDownload": "Download packages", + "stepInstall": "Install", + "stepRestart": "Service restart", + "stepVerify": "Verification" }, "wg": { "title": "WireGuard", diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 64d6a62..8189a39 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -75,6 +75,11 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart chrony.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart chrony.service edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update -qq edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update +# Self-Upgrade-Pfad (handlers/system.go → /system/upgrade). Whitelist +# nur die exakte Unit-Form, damit edgeguard NICHT beliebige systemd- +# Units anlegen darf. +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reset-failed edgeguard-upgrade.service +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemd-run --unit=edgeguard-upgrade.service --description=EdgeGuard self-upgrade --collect bash /tmp/edgeguard-upgrade.sh SUDOERS # ── Distro-Conf-Includes für die per-Service Renderer ─────────