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:
@@ -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")
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
var version = "1.0.51"
|
||||
var version = "1.0.52"
|
||||
|
||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string | null>(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<ReturnType<typeof setInterval> | null>(null)
|
||||
const upgradeTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const installedRef = useRef<string>('')
|
||||
const targetRef = useRef<string>('')
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Alert
|
||||
type="warning"
|
||||
banner
|
||||
showIcon
|
||||
icon={<CloudDownloadOutlined />}
|
||||
message={t('update.available', { pkg: meta.pkg, installed: meta.installed, available: meta.available })}
|
||||
action={
|
||||
<Button size="small" type="primary" onClick={() => setOpen(true)}>
|
||||
{t('update.viewDetails')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{msgCtx}
|
||||
|
||||
<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
|
||||
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 })}
|
||||
{updateAvailable && !upgrading && (
|
||||
<Alert
|
||||
type="warning"
|
||||
banner
|
||||
showIcon
|
||||
icon={<CloudDownloadOutlined />}
|
||||
message={t('update.available', { version: targetVersion })}
|
||||
description={updates.length > 1
|
||||
? t('update.multiPackageHint', { count: updates.length })
|
||||
: undefined}
|
||||
action={
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={forceChecking}
|
||||
onClick={forceCheck}
|
||||
>
|
||||
{t('update.checkNow')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('update.confirmTitle')}
|
||||
description={t('update.confirmDesc', { version: targetVersion })}
|
||||
okText={t('update.applyNow')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={startUpgrade}
|
||||
>
|
||||
<Button size="small" type="primary" icon={<CloudDownloadOutlined />}>
|
||||
{t('update.applyNow')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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')}
|
||||
/>
|
||||
<Step
|
||||
done={upgradeElapsed >= 15}
|
||||
active={upgradeElapsed >= 5 && upgradeElapsed < 15}
|
||||
label={t('update.stepInstall')}
|
||||
/>
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<Text>{t('update.modalIntro')}</Text>
|
||||
<List
|
||||
size="small"
|
||||
style={{ marginTop: 12 }}
|
||||
dataSource={updates}
|
||||
renderItem={u => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={<code>{u.pkg}</code>}
|
||||
description={
|
||||
<span>
|
||||
<Tag>{u.installed}</Tag> → <Tag color="blue">{u.available}</Tag>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message={t('update.modalWarn')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ─────────
|
||||
|
||||
Reference in New Issue
Block a user