fix(update): self-upgrade via sudo systemd-run + animiertes Modal

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) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-11 22:02:54 +02:00
parent 26f321de9d
commit 117d16e597
10 changed files with 247 additions and 161 deletions

View File

@@ -1 +1 @@
1.0.51 1.0.52

View File

@@ -48,7 +48,7 @@ import (
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
) )
var version = "1.0.51" var version = "1.0.52"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -9,7 +9,7 @@ import (
"os" "os"
) )
var version = "1.0.51" var version = "1.0.52"
const usage = `edgeguard-ctl — EdgeGuard CLI const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -24,7 +24,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
) )
var version = "1.0.51" var version = "1.0.52"
const ( const (
// renewTickInterval — how often we re-evaluate expiring certs. // renewTickInterval — how often we re-evaluate expiring certs.

View File

@@ -261,14 +261,22 @@ rm -f /tmp/edgeguard-upgrade.sh
} }
const unitName = "edgeguard-upgrade.service" const unitName = "edgeguard-upgrade.service"
_ = exec.Command("systemctl", "reset-failed", unitName).Run() // API läuft als edgeguard-User; systemd-run + systemctl reset-failed
cmd := exec.Command("systemd-run", // 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, "--unit="+unitName,
"--description=EdgeGuard self-upgrade", "--description=EdgeGuard self-upgrade",
"--collect", "--collect",
"bash", "/tmp/edgeguard-upgrade.sh") "bash", "/tmp/edgeguard-upgrade.sh")
if err := cmd.Run(); err != nil { 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 := exec.Command("setsid", "bash", "/tmp/edgeguard-upgrade.sh")
fallback.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} fallback.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err2 := fallback.Start(); err2 != nil { if err2 := fallback.Start(); err2 != nil {

View File

@@ -77,7 +77,7 @@ const NAV: NavSection[] = [
}, },
] ]
const VERSION = '1.0.51' const VERSION = '1.0.52'
export default function Sidebar({ isOpen, onClose }: SidebarProps) { export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const { t } = useTranslation() const { t } = useTranslation()

View File

@@ -1,26 +1,19 @@
import { Alert, Button, List, Modal, Tag, Typography, message } from 'antd' import { Alert, Button, Popconfirm, Space, message } from 'antd'
import { CloudDownloadOutlined } from '@ant-design/icons' import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined } from '@ant-design/icons'
import { useQuery, useMutation } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useState, useEffect } from 'react'
import apiClient, { isEnvelope } from '../api/client' import apiClient, { isEnvelope } from '../api/client'
const { Text } = Typography interface PackageVersions { [key: string]: string }
interface SystemHealth { status: string; version: string }
interface PackageVersions { interface PendingUpdate { pkg: string; installed: string; available: string }
[key: string]: string
}
interface PendingUpdate { // allUpdates parsed das flache map-Format ({pkg_installed,pkg_available})
pkg: string // das /system/package-versions zurückliefert. Eines davon ist meist
installed: string // das meta-Paket "edgeguard" → die "Ziel-Version".
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.
function allUpdates(v: PackageVersions): PendingUpdate[] { function allUpdates(v: PackageVersions): PendingUpdate[] {
const out: PendingUpdate[] = [] const out: PendingUpdate[] = []
for (const key of Object.keys(v)) { for (const key of Object.keys(v)) {
@@ -35,141 +28,209 @@ function allUpdates(v: PackageVersions): PendingUpdate[] {
return out return out
} }
interface SystemHealth { status: string; version: string }
export default function UpdateBanner() { export default function UpdateBanner() {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false) const [msg, msgCtx] = message.useMessage()
// 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<string | null>(null)
const { data } = useQuery({ const pkgVersions = useQuery({
queryKey: ['system', 'package-versions'], queryKey: ['system', 'package-versions'],
queryFn: async () => { queryFn: async () => {
const r = await apiClient.get('/system/package-versions') const r = await apiClient.get('/system/package-versions')
if (isEnvelope(r.data)) return r.data.data as PackageVersions return isEnvelope(r.data) ? (r.data.data as PackageVersions) : ({} as PackageVersions)
return {} as PackageVersions
}, },
refetchInterval: 5 * 60 * 1000, refetchInterval: 30_000,
refetchOnMount: 'always',
staleTime: 0,
gcTime: 0,
}) })
const { data: health } = useQuery({ const [upgrading, setUpgrading] = useState(false)
queryKey: ['system', 'health'], const [upgradeElapsed, setUpgradeElapsed] = useState(0)
queryFn: async () => { const [forceChecking, setForceChecking] = useState(false)
const r = await apiClient.get('/system/health') const upgradePollRef = useRef<ReturnType<typeof setInterval> | null>(null)
return isEnvelope(r.data) ? (r.data.data as SystemHealth) : null const upgradeTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
}, const installedRef = useRef<string>('')
// poll faster while we're applying so the version-flip is caught const targetRef = useRef<string>('')
refetchInterval: applyingFor ? 2_000 : 30_000,
})
const upgrade = useMutation({ useEffect(() => () => {
mutationFn: async () => { await apiClient.post('/system/upgrade') }, if (upgradePollRef.current) clearInterval(upgradePollRef.current)
onError: (e: Error) => message.error(e.message), if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
}) }, [])
// Detect version flip: api came back with the new version after const data = pkgVersions.data ?? {}
// restart → upgrade succeeded, close modal + tell user. const updates = allUpdates(data)
useEffect(() => { const updateAvailable = updates.length > 0
if (!applyingFor || !health) return const meta = updates.find(u => u.pkg === 'edgeguard') ?? updates[0]
if (health.version === applyingFor) { const installedVersion = meta?.installed ?? ''
message.success(t('update.success', { version: applyingFor })) const targetVersion = meta?.available ?? ''
setApplyingFor(null)
setOpen(false) const forceCheck = async () => {
// page reload picks up the rebuilt UI bundle 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) setTimeout(() => window.location.reload(), 1500)
} }
}, [applyingFor, health, t]) } 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 (!data) return null if (!updateAvailable && !upgrading) {
const updates = allUpdates(data) return <>{msgCtx}</>
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 meta = updates.find(u => u.pkg === 'edgeguard') ?? updates[0]
return ( return (
<> <>
{msgCtx}
{updateAvailable && !upgrading && (
<Alert <Alert
type="warning" type="warning"
banner banner
showIcon showIcon
icon={<CloudDownloadOutlined />} icon={<CloudDownloadOutlined />}
message={t('update.available', { pkg: meta.pkg, installed: meta.installed, available: meta.available })} message={t('update.available', { version: targetVersion })}
description={updates.length > 1
? t('update.multiPackageHint', { count: updates.length })
: undefined}
action={ action={
<Button size="small" type="primary" onClick={() => setOpen(true)}> <Space>
{t('update.viewDetails')}
</Button>
}
/>
<Modal
title={t('update.modalTitle')}
open={open}
onCancel={() => { if (!applyingFor) setOpen(false) }}
maskClosable={!applyingFor}
closable={!applyingFor}
width={620}
footer={
applyingFor ? null : [
<Button key="cancel" onClick={() => setOpen(false)}>{t('common.cancel')}</Button>,
<Button <Button
key="apply"
type="primary"
icon={<CloudDownloadOutlined />}
loading={upgrade.isPending}
onClick={() => { setApplyingFor(meta.available); upgrade.mutate() }}
>
{t('update.applyNow')}
</Button>,
]
}
>
{applyingFor ? (
<div>
<Alert
type="info"
showIcon
message={t('update.applying')}
description={t('update.applyingHint', { version: applyingFor })}
/>
<div style={{ marginTop: 12, fontSize: 12, color: '#64748B' }}>
{t('update.applyingDetail', { current: health?.version ?? '—', target: applyingFor })}
</div>
</div>
) : (
<>
<Text>{t('update.modalIntro')}</Text>
<List
size="small" size="small"
style={{ marginTop: 12 }} icon={<ReloadOutlined />}
dataSource={updates} loading={forceChecking}
renderItem={u => ( onClick={forceCheck}
<List.Item> >
<List.Item.Meta {t('update.checkNow')}
title={<code>{u.pkg}</code>} </Button>
description={ <Popconfirm
<span> title={t('update.confirmTitle')}
<Tag>{u.installed}</Tag> <Tag color="blue">{u.available}</Tag> description={t('update.confirmDesc', { version: targetVersion })}
</span> okText={t('update.applyNow')}
cancelText={t('common.cancel')}
onConfirm={startUpgrade}
>
<Button size="small" type="primary" icon={<CloudDownloadOutlined />}>
{t('update.applyNow')}
</Button>
</Popconfirm>
</Space>
} }
/> />
</List.Item>
)} )}
{upgrading && (
<div className="update-modal-overlay">
<div className="update-modal">
<div className="update-modal__orbit">
<div className="update-modal__ring" />
<div className="update-modal__ring update-modal__ring--2" />
<div className="update-modal__dot" />
<div className="update-modal__dot update-modal__dot--2" />
<div className="update-modal__center">
<RocketOutlined className="update-modal__icon" />
</div>
</div>
<div className="update-modal__title">{t('update.running')}</div>
<div className="update-modal__version">
v{installedRef.current || '…'} v{targetRef.current || '…'}
</div>
<div className="update-modal__steps">
<Step
done={upgradeElapsed >= 5}
active={upgradeElapsed < 5}
label={t('update.stepDownload')}
/> />
<Alert <Step
type="warning" done={upgradeElapsed >= 15}
showIcon active={upgradeElapsed >= 5 && upgradeElapsed < 15}
style={{ marginTop: 12 }} label={t('update.stepInstall')}
message={t('update.modalWarn')}
/> />
</> <Step
done={upgradeElapsed >= 25}
active={upgradeElapsed >= 15 && upgradeElapsed < 25}
label={t('update.stepRestart')}
/>
<Step
done={false}
active={upgradeElapsed >= 25}
label={t('update.stepVerify')}
/>
</div>
<div className="update-modal__timer">{upgradeElapsed}s</div>
<div className="update-modal__hint">{t('update.waitHint')}</div>
</div>
</div>
)} )}
</Modal>
</> </>
) )
} }
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 (
<div className={cls}>
<span className="update-modal__step-dot" />
<span>{label}</span>
</div>
)
}

View File

@@ -306,17 +306,23 @@
"setupCompleted": "Setup abgeschlossen" "setupCompleted": "Setup abgeschlossen"
}, },
"update": { "update": {
"available": "Update verfügbar: {{pkg}} {{installed}} → {{available}}", "available": "Update verfügbar: Version {{version}}",
"viewDetails": "Details anzeigen", "multiPackageHint": "{{count}} Pakete werden aktualisiert.",
"applyNow": "Jetzt aktualisieren", "applyNow": "Jetzt installieren",
"applying": "Update läuft …", "confirmTitle": "Update jetzt installieren?",
"applyingHint": "Pakete werden installiert + Services restartet. Verbindung kann kurz brechen — die UI lädt automatisch neu sobald Version {{version}} live ist.", "confirmDesc": "Pakete werden auf Version {{version}} aktualisiert. edgeguard-api + scheduler restarten (~2-5s), HAProxy/nft/WG/Squid/Unbound/Chrony laufen durch.",
"applyingDetail": "aktuell {{current}} · Ziel {{target}}", "checkNow": "Auf Updates prüfen",
"started": "Update wurde gestartet — der Server wird in Kürze neu starten.", "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.", "success": "Update auf {{version}} abgeschlossen.",
"modalTitle": "EdgeGuard-Update", "failed": "Update fehlgeschlagen",
"modalIntro": "Folgende Pakete werden aktualisiert:", "stepDownload": "Pakete laden",
"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." "stepInstall": "Installation",
"stepRestart": "Service-Restart",
"stepVerify": "Verifizierung"
}, },
"wg": { "wg": {
"title": "WireGuard", "title": "WireGuard",

View File

@@ -306,17 +306,23 @@
"setupCompleted": "Setup completed" "setupCompleted": "Setup completed"
}, },
"update": { "update": {
"available": "Update available: {{pkg}} {{installed}} → {{available}}", "available": "Update available: version {{version}}",
"viewDetails": "View details", "multiPackageHint": "{{count}} packages will be updated.",
"applyNow": "Apply now", "applyNow": "Install now",
"applying": "Update in progress …", "confirmTitle": "Install update now?",
"applyingHint": "Packages are installed and services restarted. Connection may briefly break — the UI reloads automatically once version {{version}} is live.", "confirmDesc": "Packages will be updated to version {{version}}. edgeguard-api + scheduler restart (~2-5 s), HAProxy/nft/WG/Squid/Unbound/Chrony stay running.",
"applyingDetail": "current {{current}} · target {{target}}", "checkNow": "Check for updates",
"started": "Update has started — the server will restart shortly.", "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}}.", "success": "Updated to {{version}}.",
"modalTitle": "EdgeGuard update", "failed": "Update failed",
"modalIntro": "The following packages will be updated:", "stepDownload": "Download packages",
"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." "stepInstall": "Install",
"stepRestart": "Service restart",
"stepVerify": "Verification"
}, },
"wg": { "wg": {
"title": "WireGuard", "title": "WireGuard",

View File

@@ -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: /bin/systemctl restart chrony.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update -qq edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update -qq
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update 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 SUDOERS
# ── Distro-Conf-Includes für die per-Service Renderer ───────── # ── Distro-Conf-Includes für die per-Service Renderer ─────────