feat: support PBS hosts and remote PBS monitoring

This commit is contained in:
Jonatan
2026-10-16 21:46:26 +02:00
parent 3000c1cd66
commit 511bfe52ea
26 changed files with 1355 additions and 15 deletions
@@ -199,6 +199,9 @@ class CommandDescriptionsTests(unittest.TestCase):
# messages; keep repository locales and all other leaves intact.
if lang != "en":
messages = json.loads(path.read_text())
# PBS is a new section; model the bot's future output only
# in disposable catalogs, preserving the shipped locales.
messages.setdefault("pbs", catalog("en")["pbs"])
english = catalog("en")
for section, key in (("encryption", "pbsHelp"),
("keyfileActions", "pveKeyDescription")):
+63
View File
@@ -0,0 +1,63 @@
name: PBS support
on:
pull_request:
paths:
- '.github/workflows/test-pbs.yml'
- 'AppImage/scripts/*pbs*.py'
- 'AppImage/scripts/tests/test_pbs.py'
- 'AppImage/scripts/health_monitor.py'
- 'AppImage/scripts/auth_manager.py'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/components/pbs-*.tsx'
- 'AppImage/components/proxmox-dashboard.tsx'
- 'AppImage/tests/pbs-navigation.cjs'
- 'scripts/global/proxmox_platform.py'
- 'scripts/global/platform-functions.sh'
- 'scripts/global/repository-functions.sh'
- 'scripts/menus/pbs_menu.sh'
- 'scripts/utilities/pbs_update.sh'
push:
branches: [main, develop]
paths:
- 'AppImage/scripts/*pbs*.py'
- 'scripts/global/proxmox_platform.py'
- '.github/workflows/test-pbs.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
pbs:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python: ['3.11', '3.13']
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Install test dependencies
run: python -m pip install Flask PyJWT cryptography pyotp segno
- name: Platform, real TLS, APIs, health and AppImage packaging
run: |
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh; do
bash -n "$script"
done
- uses: actions/setup-node@v6
if: matrix.python == '3.11'
with:
node-version: '22.14.0'
- name: Dashboard platform routing
if: matrix.python == '3.11'
run: |
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs
+5 -3
View File
@@ -7,9 +7,10 @@ import { useState } from "react"
import { DatabaseBackup } from "lucide-react"
import { HostBackup } from "./host-backup"
import { VmBackupJobs } from "./vm-backup-jobs"
import { PBSPanel } from "./pbs-panel"
import { useT } from "../lib/i18n/provider"
type BackupView = "host" | "guests"
type BackupView = "host" | "guests" | "pbs"
export function BackupTab() {
const t = useT()
@@ -29,7 +30,7 @@ export function BackupTab() {
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
sm:inline-flex sm:w-auto"
>
{(["host", "guests"] as const).map((key) => (
{(["host", "guests", "pbs"] as const).map((key) => (
<button
key={key}
type="button"
@@ -41,7 +42,7 @@ export function BackupTab() {
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t(`backup.viewSwitch.${key}`)}
{key === "pbs" ? t("pbs.title") : t(`backup.viewSwitch.${key}`)}
</button>
))}
</div>
@@ -52,6 +53,7 @@ export function BackupTab() {
<HostBackup />
</div>
{view === "guests" && <VmBackupJobs />}
{view === "pbs" && <PBSPanel />}
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
"use client"
import { useState } from "react"
import useSWR from "swr"
import { PBSPanel } from "./pbs-panel"
import { Security } from "./security"
import { SystemLogs } from "./system-logs"
import { TerminalPanel } from "./terminal-panel"
import { ThemeToggle } from "./theme-toggle"
import { Button } from "./ui/button"
import { Card, CardContent } from "./ui/card"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface Vitals { hostname: string; cpu_usage: number; memory_usage: number; memory_used: number; memory_total: number; uptime: string }
export function PBSDashboard() {
const t = useT()
const [tab, setTab] = useState("overview")
const { data } = useSWR<Vitals>("/api/system", url => fetchApi<Vitals>(url), { refreshInterval: 10000 })
return <div className="container mx-auto max-w-7xl space-y-6 px-4 py-6">
<header className="flex flex-wrap items-center gap-3 border-b pb-4">
<div className="mr-auto"><h1 className="text-2xl font-bold">ProxMenux · PBS</h1><p className="text-sm text-muted-foreground">{data?.hostname || t("pbs.loading")}</p></div>
<ThemeToggle />
<Button variant="outline" onClick={() => { localStorage.removeItem("proxmenux-auth-token"); window.location.reload() }}>{t("pbs.logout")}</Button>
</header>
<nav aria-label={t("pbs.navigation")} className="flex flex-wrap gap-2">
{(["overview", "logs", "terminal", "security"] as const).map(key =>
<Button key={key} variant={tab === key ? "default" : "outline"} onClick={() => setTab(key)}>{t(`pbs.${key}`)}</Button>)}
</nav>
{tab === "overview" && <>
<div className="grid gap-4 sm:grid-cols-3">
{[[t("pbs.cpu"), data ? `${data.cpu_usage.toFixed(1)}%` : "—"],
[t("pbs.memory"), data ? `${data.memory_used.toFixed(1)} / ${data.memory_total.toFixed(1)} GiB (${data.memory_usage.toFixed(1)}%)` : "—"],
[t("pbs.uptime"), data?.uptime || "—"]].map(([label, value]) =>
<Card key={label}><CardContent className="p-4"><p className="text-xs text-muted-foreground">{label}</p><p className="mt-1 text-lg font-semibold">{value}</p></CardContent></Card>)}
</div>
<PBSPanel />
</>}
{tab === "logs" && <SystemLogs />}
{tab === "terminal" && <TerminalPanel />}
{tab === "security" && <Security hostType="pbs" />}
</div>
}
+149
View File
@@ -0,0 +1,149 @@
"use client"
import { useEffect, useState } from "react"
import useSWR from "swr"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { fetchApi } from "../lib/api-config"
import { formatBytes } from "../lib/utils"
import { useT } from "../lib/i18n/provider"
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
interface Connections { local_available: boolean; servers: Connection[] }
interface Datastore { name: string; path?: string; total?: number | null; used?: number | null; avail?: number | null; error?: string }
interface Task { upid: string; worker_type?: string; worker_id?: string; starttime?: number; endtime?: number; status?: string }
interface Job { id: string; store?: string; schedule?: string; "last-run-state"?: string }
interface PBSStatus {
name: string; version: string | null; fetched_at: number; services: Record<string, string>;
datastores: Datastore[] | null; tasks: Task[] | null;
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
}
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "" }
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm"
export function PBSPanel() {
const t = useT()
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
const [selected, setSelected] = useState("")
const [manage, setManage] = useState(false)
const [form, setForm] = useState(emptyForm)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState("")
const [error, setError] = useState("")
const { data, error: statusError, isLoading, mutate } = useSWR<PBSStatus>(
selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null,
url => fetchApi<PBSStatus>(url), { refreshInterval: 30000, revalidateOnFocus: false })
useEffect(() => {
if (!connections) return
if (selected === "local" && connections.local_available) return
if (connections.servers.some(row => row.id === selected)) return
setSelected(connections.local_available ? "local" : connections.servers[0]?.id || "")
}, [connections, selected])
const save = async () => {
setBusy(true); setError(""); setMessage("")
try {
const result = await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
...form, id: form.id || undefined, token_secret: form.token_secret || undefined,
}) })
await reloadConnections()
setSelected(result.id)
setForm(emptyForm)
setMessage(t("pbs.saved"))
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
finally { setBusy(false) }
}
const remove = async () => {
if (!form.id || !window.confirm(t("pbs.removeConfirm"))) return
setBusy(true); setError(""); setMessage("")
try {
await fetchApi(`/api/pbs/servers/${encodeURIComponent(form.id)}`, { method: "DELETE" })
setForm(emptyForm)
await reloadConnections()
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
finally { setBusy(false) }
}
const capacity = (value?: number | null) => value == null ? "—" : formatBytes(value)
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-xl font-semibold">{t("pbs.title")}</h2>
<select aria-label={t("pbs.server")} className={`${selectClass} sm:ml-auto`} value={selected}
onChange={event => setSelected(event.target.value)}>
{!selected && <option value="">{t("pbs.chooseServer")}</option>}
{connections?.local_available && <option value="local">{t("pbs.local")}</option>}
{connections?.servers.map(row => <option key={row.id} value={row.id}>{row.name}</option>)}
</select>
<Button variant="outline" disabled={isLoading || !selected} onClick={() => mutate()}>{t("pbs.refresh")}</Button>
<Button variant="outline" onClick={() => setManage(!manage)}>{t("pbs.manage")}</Button>
</div>
{(connectionError || statusError) && <p role="alert" className="text-sm text-red-500">
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
{manage && <Card>
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{t("pbs.permissionsHelp")}</p>
<select aria-label={t("pbs.editConnection")} className={`${selectClass} w-full`} value={form.id}
onChange={event => {
const row = connections?.servers.find(item => item.id === event.target.value)
setForm(row ? { ...row, token_secret: "" } : emptyForm)
}}>
<option value="">{t("pbs.add")}</option>
{connections?.servers.map(row => <option key={row.id} value={row.id}>{row.name}</option>)}
</select>
<div className="grid gap-4 md:grid-cols-2">
{(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field =>
<div key={field} className="space-y-2">
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
<Input id={`pbs-${field}`} disabled={busy} type={field === "token_secret" ? "password" : field === "url" ? "url" : "text"}
autoComplete={field === "token_secret" ? "new-password" : "off"}
value={form[field]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
</div>)}
</div>
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
<div className="flex gap-2">
<Button disabled={busy} onClick={save}>{busy ? t("pbs.testing") : t("pbs.save")}</Button>
{form.id && <Button variant="destructive" disabled={busy} onClick={remove}>{t("pbs.remove")}</Button>}
</div>
</CardContent>
</Card>}
{data && <>
<p className="text-sm text-muted-foreground">{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</p>
{Object.keys(data.services).length > 0 && <div className="flex flex-wrap gap-3 text-sm">
{Object.entries(data.services).map(([service, state]) => <span key={service} className={state === "active" ? "text-green-500" : "text-red-500"}>{service}: {state}</span>)}
</div>}
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
<Card><CardHeader><CardTitle>{t("pbs.datastores")}</CardTitle></CardHeader><CardContent>
{data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>}
{data.datastores && data.datastores.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.name")}</th><th>{t("pbs.total")}</th><th>{t("pbs.used")}</th><th>{t("pbs.free")}</th></tr></thead>
<tbody>{data.datastores.map(row => <tr key={row.name} className="border-b"><td className="p-2 font-medium">{row.name}{row.error && <p className="font-normal text-xs text-amber-500">{row.error}</p>}</td><td>{capacity(row.total)}</td><td>{capacity(row.used)}</td><td>{capacity(row.avail)}</td></tr>)}</tbody>
</table></div>}
</CardContent></Card>
<Card><CardHeader><CardTitle>{t("pbs.tasks")}</CardTitle></CardHeader><CardContent>
{data.tasks?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noTasks")}</p>}
{data.tasks && data.tasks.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.task")}</th><th>{t("pbs.started")}</th><th>{t("pbs.status")}</th></tr></thead>
<tbody>{data.tasks.map(row => <tr key={row.upid} className="border-b"><td className="p-2">{row.worker_type} {row.worker_id}</td><td>{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td><td>{row.status || (row.endtime ? "—" : t("pbs.running"))}</td></tr>)}</tbody>
</table></div>}
</CardContent></Card>
<Card><CardHeader><CardTitle>{t("pbs.jobs")}</CardTitle></CardHeader><CardContent>
<div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.job")}</th><th>{t("pbs.datastores")}</th><th>{t("pbs.schedule")}</th><th>{t("pbs.status")}</th></tr></thead>
<tbody>{(["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row =>
<tr key={`${kind}-${row.id}`} className="border-b"><td className="p-2">{t(`pbs.${kind}`)} · {row.id}</td><td>{row.store || "—"}</td><td>{row.schedule || "—"}</td><td>{row["last-run-state"] || "—"}</td></tr>))}</tbody>
</table></div>
</CardContent></Card>
</>}
</div>
}
@@ -18,6 +18,7 @@ import { Security } from "./security"
import { Profile } from "./profile"
import { About } from "./about"
import { BackupTab } from "./backup-tab"
import { PBSDashboard } from "./pbs-dashboard"
import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal"
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
@@ -96,6 +97,14 @@ interface FlaskSystemInfo {
const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
export function ProxmoxDashboard() {
const t = useT()
const { data, error } = useSWR<{ product: string }>("/api/platform", url => fetchApi<{ product: string }>(url), { revalidateOnFocus: false })
if (!data && !error) return <p role="status" className="p-8">{t("app.loading")}</p>
if (data?.product === "pbs") return <PBSDashboard />
return <PVEDashboard />
}
function PVEDashboard() {
const t = useT()
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
+9 -4
View File
@@ -66,7 +66,7 @@ function validatePasswordStrength(pw: string, t: (key: string) => string): strin
return null
}
export function Security() {
export function Security({ hostType = "pve" }: { hostType?: "pve" | "pbs" } = {}) {
const { language, t } = useI18n()
const st = (key: string, params?: Record<string, string | number>) => t(`securityPage.${key}`, params)
const interfaceTypeLabel = (type: string) =>
@@ -292,9 +292,11 @@ export function Security() {
checkAuthStatus()
loadApiTokens()
loadSslStatus()
loadFirewallStatus()
loadNetworkInterfaces()
loadSecurityTools()
if (hostType === "pve") {
loadFirewallStatus()
loadNetworkInterfaces()
loadSecurityTools()
}
}, [])
const loadFirewallStatus = async () => {
@@ -2778,6 +2780,7 @@ ${(report.sections && report.sections.length > 0) ? `
)}
{/* ── Proxmox VE Security Group ── */}
{hostType === "pve" && <>
<div className="flex items-center gap-3 mt-4">
<h2 className="text-sm font-semibold uppercase tracking-wider text-orange-500">Proxmox VE</h2>
<div className="flex-1 h-px bg-orange-500/20" />
@@ -4833,6 +4836,8 @@ ${(report.sections && report.sections.length > 0) ? `
</div>
)}
</>}
<TwoFactorSetup
open={show2FASetup}
onClose={() => setShow2FASetup(false)}
+53
View File
@@ -1,4 +1,57 @@
{
"pbs": {
"title": "Proxmox Backup Server",
"server": "PBS server",
"chooseServer": "Choose a PBS server",
"local": "Local PBS",
"refresh": "Refresh",
"manage": "Manage PBS connections",
"loadFailed": "Could not load PBS status.",
"loading": "Loading PBS data…",
"noServer": "Add a PBS connection to view datastores, tasks and schedules.",
"permissionsHelp": "Enable Monitor authentication before saving credentials. Use a dedicated PBS API token with the Audit role on / for both its user and token ACLs, with propagation. This panel only reads PBS data.",
"editConnection": "Connection to edit",
"add": "Add a new connection",
"name": "Name",
"url": "HTTPS URL (include :8007 for the native PBS port)",
"token_id": "API token ID (user@realm!token)",
"token_secret": "API token secret (leave empty to keep the stored secret)",
"fingerprint": "SHA256 certificate fingerprint (optional)",
"tlsHelp": "Leave the fingerprint empty for normal CA and hostname verification. For a self-signed certificate, copy its SHA256 fingerprint from the PBS console or another trusted channel. The pin is checked before sending credentials.",
"saved": "PBS connection verified and saved.",
"saveFailed": "Could not save the PBS connection.",
"testing": "Verifying connection…",
"save": "Verify and save",
"remove": "Remove connection",
"removeConfirm": "Remove this saved PBS connection and its token from Monitor? PBS data and jobs are unaffected.",
"sampled": "Sampled at",
"datastores": "Datastores",
"total": "Filesystem capacity",
"used": "Used",
"free": "Available",
"noDatastores": "No accessible datastores. Check datastore configuration and the user/token permissions.",
"tasks": "Recent tasks",
"noTasks": "No recent tasks were returned.",
"task": "Task",
"started": "Started",
"status": "Status",
"running": "Running",
"jobs": "Maintenance schedules",
"job": "Job",
"schedule": "Schedule",
"sync": "Sync",
"verify": "Verification",
"prune": "Prune",
"overview": "Overview",
"logs": "System logs",
"terminal": "Terminal",
"security": "Security",
"logout": "Log out",
"navigation": "PBS navigation",
"cpu": "CPU",
"memory": "Memory",
"uptime": "Uptime"
},
"app": {
"title": "ProxMenux Monitor",
"description": "Proxmox System Dashboard",
+53
View File
@@ -1,4 +1,57 @@
{
"pbs": {
"title": "Proxmox Backup Server",
"server": "Servidor PBS",
"chooseServer": "Selecciona un servidor PBS",
"local": "PBS local",
"refresh": "Actualizar",
"manage": "Gestionar conexiones PBS",
"loadFailed": "No se pudo consultar el estado de PBS.",
"loading": "Cargando datos de PBS…",
"noServer": "Añade una conexión PBS para ver datastores, tareas y programaciones.",
"permissionsHelp": "Activa la autenticación de Monitor antes de guardar credenciales. Usa un token API dedicado de PBS con el rol Audit en / para el usuario y el token, con propagación. Este panel solo consulta datos de PBS.",
"editConnection": "Conexión que deseas editar",
"add": "Añadir una conexión",
"name": "Nombre",
"url": "URL HTTPS (incluye :8007 para el puerto nativo de PBS)",
"token_id": "ID del token API (usuario@realm!token)",
"token_secret": "Secreto del token API (vacío para conservar el guardado)",
"fingerprint": "Huella SHA256 del certificado (opcional)",
"tlsHelp": "Deja la huella vacía para verificar la CA y el nombre del servidor. Si el certificado es autofirmado, copia su huella SHA256 desde la consola de PBS u otro canal fiable. Se verifica antes de enviar las credenciales.",
"saved": "Conexión PBS verificada y guardada.",
"saveFailed": "No se pudo guardar la conexión PBS.",
"testing": "Verificando conexión…",
"save": "Verificar y guardar",
"remove": "Eliminar conexión",
"removeConfirm": "¿Eliminar de Monitor esta conexión PBS y su token guardado? Los datos y las tareas de PBS no se modifican.",
"sampled": "Consulta realizada a las",
"datastores": "Datastores",
"total": "Capacidad del sistema de archivos",
"used": "Usado",
"free": "Disponible",
"noDatastores": "No hay datastores accesibles. Revisa su configuración y los permisos del usuario y del token.",
"tasks": "Tareas recientes",
"noTasks": "No se han devuelto tareas recientes.",
"task": "Tarea",
"started": "Inicio",
"status": "Estado",
"running": "En ejecución",
"jobs": "Programaciones de mantenimiento",
"job": "Tarea",
"schedule": "Programación",
"sync": "Sincronización",
"verify": "Verificación",
"prune": "Prune",
"overview": "Resumen",
"logs": "Registros del sistema",
"terminal": "Terminal",
"security": "Seguridad",
"logout": "Cerrar sesión",
"navigation": "Navegación de PBS",
"cpu": "CPU",
"memory": "Memoria",
"uptime": "Tiempo de actividad"
},
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values.",
"app": {
"title": "ProxMenux Monitor",
+3
View File
@@ -1115,6 +1115,9 @@ def _detect_proxmox_certificate_paths():
return PROXMOX_CUSTOM_CERT_PATH, PROXMOX_CUSTOM_KEY_PATH
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
return PROXMOX_CERT_PATH, PROXMOX_KEY_PATH
pbs_cert, pbs_key = "/etc/proxmox-backup/proxy.pem", "/etc/proxmox-backup/proxy.key"
if os.path.isfile(pbs_cert) and os.path.isfile(pbs_key):
return pbs_cert, pbs_key
return "", ""
+3
View File
@@ -160,6 +160,9 @@ cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || ech
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ temperature_sensor_resolver.py not found"
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
cp "$SCRIPT_DIR/pbs_manager.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_pbs_routes.py" "$APP_DIR/usr/bin/"
cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
cp "$SCRIPT_DIR/flask_security_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_security_routes.py not found"
+71
View File
@@ -0,0 +1,71 @@
"""Read-only PBS status and administrative connection management."""
from flask import Blueprint, jsonify, request
import auth_manager
import pbs_manager as pbs
from jwt_middleware import require_auth, require_admin_scope
pbs_bp = Blueprint("pbs", __name__)
@pbs_bp.after_request
def no_cache(response):
response.headers["Cache-Control"] = "no-store"
return response
@pbs_bp.route("/api/platform")
@require_auth
def platform():
return jsonify(pbs.get_platform())
@pbs_bp.route("/api/pbs/servers")
@require_auth
def servers():
try:
return jsonify(servers=pbs.public_servers(), local_available=pbs.is_pbs_host())
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 500
def _protected_configuration():
local = auth_manager.load_auth_config()
return local.get("enabled") and not local.get("declined") and local.get("password_hash")
@pbs_bp.route("/api/pbs/servers", methods=["POST"])
@require_admin_scope
def save_server():
if not _protected_configuration():
return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
try:
identifier = pbs.save_server(request.get_json(silent=True))
return jsonify(success=True, id=identifier, servers=pbs.public_servers())
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 400
except OSError:
return jsonify(error="Could not save PBS connection."), 500
@pbs_bp.route("/api/pbs/servers/<identifier>", methods=["DELETE"])
@require_admin_scope
def delete_server(identifier):
if not _protected_configuration():
return jsonify(error="Enable local authentication before changing PBS connections."), 409
try:
pbs.delete_server(identifier)
return jsonify(success=True)
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 400
except OSError:
return jsonify(error="Could not remove PBS connection."), 500
@pbs_bp.route("/api/pbs/status")
@require_auth
def status():
try:
return jsonify(pbs.get_status(request.args.get("server", "local")))
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 502
+6
View File
@@ -90,6 +90,8 @@ from flask_terminal_routes import ( # noqa: E402
from flask_health_routes import health_bp # noqa: E402
from flask_auth_routes import auth_bp # noqa: E402
from flask_proxmenux_routes import proxmenux_bp # noqa: E402
from flask_pbs_routes import pbs_bp # noqa: E402
from pbs_manager import get_platform, is_pbs_host # noqa: E402
from flask_security_routes import security_bp # noqa: E402
from flask_notification_routes import notification_bp # noqa: E402
from flask_oci_routes import oci_bp # noqa: E402
@@ -193,6 +195,7 @@ post_install_versions.scan_at_startup()
app.register_blueprint(auth_bp)
app.register_blueprint(health_bp)
app.register_blueprint(proxmenux_bp)
app.register_blueprint(pbs_bp)
app.register_blueprint(security_bp)
app.register_blueprint(notification_bp)
app.register_blueprint(oci_bp)
@@ -3474,6 +3477,8 @@ def get_coral_info():
def get_proxmox_version():
"""Get Proxmox version if available. Cached for 6 hours."""
global _system_info_cache
if is_pbs_host():
return get_platform().get('version')
now = time.time()
if _system_info_cache['proxmox_version'] is not None and \
@@ -9863,6 +9868,7 @@ def api_system():
'cpu_cores': cpu_cores,
'cpu_threads': cpu_threads,
'proxmox_version': proxmox_version,
'platform': get_platform(),
'kernel_version': kernel_version,
'available_updates': available_updates
})
+36 -8
View File
@@ -51,6 +51,7 @@ DEBUG_PERF = False
# ─── Startup Grace Period ────────────────────────────────────────────────────
# Import centralized startup grace management for consistent behavior
import startup_grace
from pbs_manager import get_platform, PBS_SERVICES
def _is_startup_health_grace() -> bool:
"""Check if we're within the startup health grace period (5 min).
@@ -466,6 +467,7 @@ class HealthMonitor:
def __init__(self):
"""Initialize health monitor with state tracking"""
self.platform_type = get_platform().get("product")
self.state_history = defaultdict(list)
self.last_check_times = {}
self.cached_results = {}
@@ -919,9 +921,9 @@ class HealthMonitor:
_perf_log("services", (time.time() - _t) * 1000)
details['services'] = services_status
if services_status['status'] == 'CRITICAL':
critical_issues.append(f"PVE Services: {services_status.get('reason', 'Service failure')}")
critical_issues.append(f"Proxmox Services: {services_status.get('reason', 'Service failure')}")
elif services_status['status'] == 'WARNING':
warning_issues.append(f"PVE Services: {services_status.get('reason', 'Service issue')}")
warning_issues.append(f"Proxmox Services: {services_status.get('reason', 'Service issue')}")
# Priority 1.5: Proxmox Storage Check (External Module)
_t = time.time()
@@ -2347,6 +2349,8 @@ class HealthMonitor:
# The new _check_proxmox_storage function handles this logic better.
def _check_proxmox_storages(self) -> Dict[str, Any]:
"""Check Proxmox-specific storages (only report problems)"""
if getattr(self, "platform_type", "pve") == "pbs":
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
storages = {}
try:
@@ -3515,6 +3519,8 @@ class HealthMonitor:
Optimized VM/CT check - detects qmp failures and startup errors from logs.
Improved detection of container and VM errors from journalctl.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
try:
# First: auto-resolve any persisted VM/CT errors where the guest
# is now running. This clears stale "Failed to start" / QMP
@@ -3678,6 +3684,8 @@ class HealthMonitor:
Check VMs/CTs with persistent error tracking.
Errors persist until VM starts or 48h elapsed.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
try:
issues = []
vm_details = {}
@@ -3919,8 +3927,18 @@ class HealthMonitor:
"""
try:
# Build service list: base PVE services + corosync if clustered
services_to_check = list(self.PVE_SERVICES)
is_cluster = os.path.exists('/etc/corosync/corosync.conf')
pbs_host = getattr(self, "platform_type", "pve") == "pbs"
service_prefix = "pbs" if pbs_host else "pve"
product_name = "PBS" if pbs_host else "PVE"
services_to_check = list(PBS_SERVICES) if pbs_host else list(self.PVE_SERVICES)
if pbs_host:
# Old Monitor versions incorrectly treated absent PVE services
# as PBS failures; remove only those known inapplicable errors.
for service in self.PVE_SERVICES:
old_key = f'pve_service_{service}'
if health_persistence.is_error_active(old_key):
health_persistence.clear_error(old_key)
is_cluster = not pbs_host and os.path.exists('/etc/corosync/corosync.conf')
if is_cluster and 'corosync' not in services_to_check:
services_to_check.append('corosync')
@@ -3952,14 +3970,14 @@ class HealthMonitor:
# cleanup loop). Audit Tier 5 — Health stack.
for svc in services_to_check:
if svc not in failed_services:
error_key = f'pve_service_{svc}'
error_key = f'{service_prefix}_service_{svc}'
if health_persistence.is_error_active(error_key):
health_persistence.clear_error(error_key)
# Build checks dict with status per service
checks = {}
for svc in services_to_check:
error_key = f'pve_service_{svc}'
error_key = f'{service_prefix}_service_{svc}'
if svc in failed_services:
state = service_details.get(svc, 'inactive')
checks[svc] = {
@@ -3987,12 +4005,12 @@ class HealthMonitor:
# Record each failed service in persistence, respecting dismiss
active_failed = []
for svc in failed_services:
error_key = f'pve_service_{svc}'
error_key = f'{service_prefix}_service_{svc}'
rec_result = health_persistence.record_error(
error_key=error_key,
category='pve_services',
severity='CRITICAL',
reason=f'PVE service {svc} is {service_details.get(svc, "inactive")}',
reason=f'{product_name} service {svc} is {service_details.get(svc, "inactive")}',
details={'service': svc, 'state': service_details.get(svc, 'inactive')}
)
if rec_result and rec_result.get('type') == 'skipped_acknowledged':
@@ -5816,6 +5834,8 @@ class HealthMonitor:
- No persistent errors are recorded
This prevents false positives when NFS/PBS/remote storage is still mounting.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return None
if not PROXMOX_STORAGE_AVAILABLE:
return None
@@ -6205,6 +6225,8 @@ class HealthMonitor:
Notification cooldown is the standard 1h via the storage
category, so a CT teetering at 96% won't spam the channel.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return None
# Cheap pre-check: bail out when there are no running LXCs. On
# nodes without CTs, the previous version still paid ~860ms per
# cycle for a `pvesh /cluster/resources` call that produced no
@@ -6365,6 +6387,8 @@ class HealthMonitor:
path, so a slow / dead guest agent can't stretch the health
cycle.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return None
try:
import flask_server # deferred — avoids circular import
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
@@ -6524,6 +6548,8 @@ class HealthMonitor:
check (already covered) because those are usually large data
mounts whose fill rate is driven by user content, not log spam.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return None
if not MOUNT_MONITOR_AVAILABLE:
return None
try:
@@ -6627,6 +6653,8 @@ class HealthMonitor:
check via _check_filesystem. Re-alerting on the same condition
through two channels just clutters the inbox.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return None
if not PROXMOX_STORAGE_AVAILABLE:
return None
try:
+288
View File
@@ -0,0 +1,288 @@
"""Local PBS maintenance information and remote, read-only PBS monitoring."""
from concurrent.futures import ThreadPoolExecutor
import hashlib
import hmac
import http.client
import importlib.util
import json
import os
from pathlib import Path
import re
import secrets
import shutil
import ssl
import subprocess
import tempfile
import threading
import time
from urllib.parse import urlsplit
import auth_manager
try:
from proxmox_platform import detect_platform
except ModuleNotFoundError:
# Development checkout; AppImage bundles the canonical shared detector.
_path = Path(__file__).resolve().parents[2] / "scripts/global/proxmox_platform.py"
_spec = importlib.util.spec_from_file_location("proxmox_platform", _path)
_platform_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_platform_module)
detect_platform = _platform_module.detect_platform
_lock = threading.RLock()
_platform_cache = (0, {})
_status_cache = {}
PBS_SERVICES = ("proxmox-backup", "proxmox-backup-proxy")
MAX_RESPONSE = 4 * 1024 * 1024
class PBSError(ValueError):
pass
def get_platform():
global _platform_cache
with _lock:
if _platform_cache[0] <= time.monotonic():
_platform_cache = (time.monotonic() + 60, detect_platform())
return dict(_platform_cache[1])
def is_pbs_host():
return get_platform().get("product") == "pbs"
def _servers_path():
return auth_manager.CONFIG_DIR / "pbs_servers.json"
def _load_servers():
try:
data = json.loads(_servers_path().read_text())
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
raise ValueError()
return data
except FileNotFoundError:
return []
except (OSError, ValueError) as exc:
raise PBSError("Could not read the PBS server configuration.") from exc
def _save_servers(servers):
auth_manager.ensure_config_dir()
descriptor, temporary = tempfile.mkstemp(prefix=".pbs-servers-", dir=auth_manager.CONFIG_DIR)
try:
with os.fdopen(descriptor, "w") as handle:
os.fchmod(handle.fileno(), 0o600)
json.dump(servers, handle, indent=2)
os.replace(temporary, _servers_path())
finally:
if os.path.exists(temporary):
os.unlink(temporary)
_status_cache.clear()
def public_servers():
fields = ("id", "name", "url", "token_id", "fingerprint")
with _lock:
return [{**{key: row.get(key, "") for key in fields}, "has_token": bool(row.get("token_secret"))}
for row in _load_servers()]
def _validate_server(data, previous=None):
if not isinstance(data, dict):
raise PBSError("Invalid PBS server configuration.")
previous = previous or {}
row = {field: data.get(field, previous.get(field, ""))
for field in ("name", "url", "token_id", "token_secret", "fingerprint")}
if any(not isinstance(value, str) or len(value) > 4096 for value in row.values()):
raise PBSError("Invalid PBS connection field.")
row["name"] = row["name"].strip()
row["url"] = row["url"].strip().rstrip("/")
try:
parts = urlsplit(row["url"])
parts.port
if (parts.scheme != "https" or not parts.hostname or parts.username or parts.password
or parts.query or parts.fragment):
raise ValueError()
except ValueError as exc:
raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.") from exc
if not row["name"] or len(row["name"]) > 128:
raise PBSError("A server name of at most 128 characters is required.")
row["token_id"] = row["token_id"].strip()
if not re.fullmatch(r"[^\s:!]+@[^\s:!]+![^\s:!]+", row["token_id"]):
raise PBSError("Enter a PBS API token ID in user@realm!token format.")
if (not row["token_secret"] or not row["token_secret"].isascii()
or any(char.isspace() or ord(char) < 32 for char in row["token_secret"])):
raise PBSError("A valid API token secret is required.")
if not row["token_id"].isascii():
raise PBSError("Invalid API token ID.")
fingerprint = row["fingerprint"].strip().lower().replace(":", "")
if fingerprint and not re.fullmatch("[a-f0-9]{64}", fingerprint):
raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).")
row["fingerprint"] = fingerprint
return row
def _remote_get(server, endpoint):
"""Validate the TLS peer BEFORE transmitting the PBS token (no redirects)."""
parts = urlsplit(server["url"])
fingerprint = server.get("fingerprint")
if fingerprint:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE # Authenticated by the explicit pin below.
else:
context = ssl.create_default_context()
connection = http.client.HTTPSConnection(parts.hostname, parts.port or 443,
timeout=5, context=context)
try:
connection.connect()
if fingerprint:
actual = hashlib.sha256(connection.sock.getpeercert(binary_form=True)).hexdigest()
if not hmac.compare_digest(actual, fingerprint):
raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.")
path = parts.path.rstrip("/") + "/api2/json" + endpoint
connection.request("GET", path, headers={
"Accept": "application/json",
"Authorization": "PBSAPIToken=" + server["token_id"] + ":" + server["token_secret"],
})
response = connection.getresponse()
content = response.read(MAX_RESPONSE + 1)
if response.status in (401, 403):
raise PBSError("PBS rejected the API token or its permissions. Check both the user and token ACLs.")
if response.status != 200 or len(content) > MAX_RESPONSE:
raise PBSError("PBS returned an invalid response. Redirects are not followed.")
document = json.loads(content)
if not isinstance(document, dict) or "data" not in document:
raise PBSError("PBS returned an invalid JSON document.")
return document["data"]
except PBSError:
raise
except (OSError, http.client.HTTPException, ValueError) as exc:
raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.") from exc
finally:
connection.close()
def save_server(data):
identifier = data.get("id") if isinstance(data, dict) else None
if identifier is not None and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", str(identifier)):
raise PBSError("Invalid PBS server ID.")
with _lock:
previous = next((row for row in _load_servers() if row["id"] == identifier), None)
row = _validate_server(data, previous)
# /version may be public; validate credentials on the protected status API.
usage = _remote_get(row, "/status/datastore-usage")
if not isinstance(usage, list):
raise PBSError("PBS returned an invalid datastore list.")
row.update(id=identifier or "pbs-" + secrets.token_hex(6), revision=secrets.token_hex(16))
with _lock:
servers = _load_servers()
if previous is None and len(servers) >= 32:
raise PBSError("At most 32 PBS connections may be configured.")
servers = [existing for existing in servers if existing["id"] != row["id"]]
_save_servers([*servers, row])
return row["id"]
def delete_server(identifier):
with _lock:
servers = _load_servers()
remaining = [row for row in servers if row["id"] != identifier]
if len(remaining) == len(servers):
raise PBSError("PBS server not found.")
_save_servers(remaining)
def _cli(*arguments):
try:
result = subprocess.run(["proxmox-backup-manager", *arguments, "--output-format", "json"],
check=True, text=True, capture_output=True, timeout=8)
data = json.loads(result.stdout)
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
raise ValueError()
return data
except (OSError, subprocess.SubprocessError, ValueError) as exc:
raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.") from exc
def _local_datastores():
stores = _cli("datastore", "list")
result = []
for store in stores:
row = {"name": store.get("name"), "path": store.get("path"),
"maintenance_mode": store.get("maintenance-mode"), "total": None, "used": None, "avail": None}
path = store.get("path")
# A removable datastore directory may survive unmounting. Do not report
# its underlying root filesystem as the missing datastore's capacity.
inactive = store.get("backing-device") and not (path and os.path.ismount(path))
if not path or inactive or store.get("maintenance-mode"):
row["error"] = "Datastore is offline, in maintenance or has no mounted backing device."
else:
try:
capacity = shutil.disk_usage(path)
row.update(total=capacity.total, used=capacity.used, avail=capacity.free)
except OSError:
row["error"] = "Datastore filesystem is not available."
result.append(row)
return result
def _local_services():
result = {}
for service in PBS_SERVICES:
try:
check = subprocess.run(["systemctl", "is-active", service], capture_output=True, text=True, timeout=3)
result[service] = check.stdout.strip() if check.returncode == 0 else "inactive"
except (OSError, subprocess.SubprocessError):
result[service] = "unknown"
return result
def get_status(identifier):
with _lock:
cached = _status_cache.get(identifier)
if cached and cached[0] > time.monotonic():
return cached[1]
errors = {}
if identifier == "local":
if not is_pbs_host():
raise PBSError("The local host is not a PBS server.")
name, version = "Local PBS", get_platform().get("version")
services = _local_services()
operations = {"datastores": _local_datastores,
"tasks": lambda: _cli("task", "list", "--all", "true", "--limit", "20"),
"sync": lambda: _cli("sync-job", "list"),
"verify": lambda: _cli("verify-job", "list"),
"prune": lambda: _cli("prune-job", "list")}
else:
with _lock:
server = next((row for row in _load_servers() if row["id"] == identifier), None)
if not server:
raise PBSError("PBS server not found.")
metadata = _remote_get(server, "/version")
name, version = server["name"], metadata.get("version") if isinstance(metadata, dict) else None
services = {}
endpoints = {"datastores": "/status/datastore-usage", "tasks": "/nodes/localhost/tasks?limit=20",
"sync": "/admin/sync", "verify": "/admin/verify", "prune": "/admin/prune"}
operations = {key: (lambda endpoint=path: _remote_get(server, endpoint)) for key, path in endpoints.items()}
result = {"server": identifier, "name": name, "version": version, "services": services,
"fetched_at": int(time.time()), "errors": errors}
with ThreadPoolExecutor(max_workers=5) as executor:
futures = {key: executor.submit(operation) for key, operation in operations.items()}
for key, future in futures.items():
try:
data = future.result()
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
raise PBSError("PBS returned an invalid list.")
result[key] = [{**row, "name": row.get("store", row.get("name"))} for row in data] if key == "datastores" else data
except PBSError as exc:
result[key] = None # Unknown/forbidden is never presented as zero.
errors[key] = str(exc)
with _lock:
if len(_status_cache) >= 33:
_status_cache.clear()
_status_cache[identifier] = (time.monotonic() + 15, result)
return result
+289
View File
@@ -0,0 +1,289 @@
"""PBS platform, HTTPS protocol, authorization and isolated AppImage regressions."""
import ast
from datetime import datetime, timedelta, timezone
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import ipaddress
import json
from pathlib import Path
import shlex
import ssl
import subprocess
import sys
import tempfile
import threading
from types import SimpleNamespace
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from flask import Flask
import jwt
import auth_manager
import pbs_manager as pbs
from flask_pbs_routes import pbs_bp
class PlatformTests(unittest.TestCase):
def test_supported_platforms_and_partial_dpkg_failure(self):
module = pbs.detect_platform.__globals__
with tempfile.TemporaryDirectory() as directory:
release = Path(directory) / "os-release"
for package, version, suite, product in (
("pve-manager", "8.4.15", "bookworm", "pve"),
("pve-manager", "9.2.1", "trixie", "pve"),
("proxmox-backup-server", "3.4.0-1", "bookworm", "pbs"),
("proxmox-backup-server", "4.2.5-1", "trixie", "pbs"),
):
release.write_text(f'VERSION_CODENAME="{suite}"\n')
output = f"{package}\tinstall ok installed\t{version}\n"
with self.subTest(product=product, version=version), \
mock.patch.dict(module, {"OS_RELEASE": release}), \
mock.patch.object(subprocess, "run", return_value=SimpleNamespace(stdout=output, returncode=1)):
detected = pbs.detect_platform()
self.assertEqual(detected["product"], product)
self.assertEqual(detected["version"], version)
self.assertTrue(detected["supported"])
def test_client_only_and_removed_server_are_not_pbs(self):
for output in ("proxmox-backup-client\tinstall ok installed\t4.2.5\n",
"proxmox-backup-server\tdeinstall ok config-files\t4.2.5\n", ""):
with self.subTest(output=output), mock.patch.object(subprocess, "run", return_value=SimpleNamespace(stdout=output)):
self.assertEqual(pbs.detect_platform()["product"], "unknown")
def test_pbs_utilities_preserve_sources_and_never_call_pve_policy(self):
helper = ROOT / "scripts/global/repository-functions.sh"
for supported in (True, False):
harness = f'''
source {shlex.quote(str(helper))}
pmx_platform_field() {{ if [[ "$1" == product ]]; then echo pbs; else echo {str(supported).lower()}; fi; }}
msg_info() {{ :; }}
msg_error() {{ :; }}
translate() {{ printf '%s' "$1"; }}
pveversion() {{ echo 'FORBIDDEN: pveversion'; return 90; }}
repository_policy() {{ echo 'FORBIDDEN: repository mutation'; return 90; }}
apt-get() {{ echo 'FORBIDDEN: apt'; return 90; }}
ensure_repositories
'''
result = subprocess.run(["bash"], input=harness, text=True, capture_output=True)
self.assertNotIn("FORBIDDEN", result.stdout + result.stderr)
self.assertEqual(result.returncode, 0 if supported else 1)
class PBSManagerTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
patch = mock.patch.multiple(auth_manager, CONFIG_DIR=self.root, AUTH_CONFIG_FILE=self.root / "auth.json")
patch.start(); self.addCleanup(patch.stop)
auth_manager.save_auth_config({"enabled": True, "username": "admin", "password_hash": "configured",
"jwt_secret": "s" * 48, "api_tokens": [], "revoked_tokens": []})
auth_manager._invalidate_revoked_cache()
pbs._status_cache.clear()
self.server = {"name": "Backup", "url": "https://pbs.test:8007", "token_id": "audit@pbs!monitor",
"token_secret": "fixture-token-secret", "fingerprint": "a" * 64}
app = Flask(__name__); app.register_blueprint(pbs_bp)
self.client = app.test_client()
self.headers = {"Authorization": "Bearer " + auth_manager.generate_token("admin")}
def test_save_redacts_secret_and_probes_authenticated_endpoint(self):
with mock.patch.object(pbs, "_remote_get", return_value=[]) as request:
identifier = pbs.save_server(self.server)
request.assert_called_once()
self.assertEqual(request.call_args.args[1], "/status/datastore-usage")
self.assertEqual((self.root / "pbs_servers.json").stat().st_mode & 0o777, 0o600)
public = pbs.public_servers()
self.assertEqual(public[0]["id"], identifier)
self.assertNotIn("fixture-token-secret", json.dumps(public))
self.assertTrue(public[0]["has_token"])
with mock.patch.object(pbs, "_remote_get", return_value=[]):
pbs.save_server({"id": identifier, "name": "Renamed"})
self.assertEqual(pbs._load_servers()[0]["token_secret"], "fixture-token-secret")
pbs.delete_server(identifier)
self.assertEqual(pbs.public_servers(), [])
def test_failed_credentials_or_invalid_config_do_not_replace_saved_connection(self):
with mock.patch.object(pbs, "_remote_get", return_value=[]):
identifier = pbs.save_server(self.server)
original = (self.root / "pbs_servers.json").read_bytes()
with mock.patch.object(pbs, "_remote_get", side_effect=pbs.PBSError("Rejected token")):
with self.assertRaises(pbs.PBSError):
pbs.save_server({**self.server, "id": identifier, "token_secret": "wrong"})
for changes in ({"url": "http://pbs.test"}, {"url": "https://user:pass@pbs.test"},
{"fingerprint": "bad-pin"}, {"token_id": "not-a-token"},
{"token_secret": "line\nbreak"}):
with self.subTest(changes=changes), self.assertRaises(pbs.PBSError):
pbs.save_server({**self.server, **changes, "id": identifier})
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), original)
def test_remote_partial_failure_is_unknown_instead_of_zero(self):
def remote(server, endpoint):
if endpoint == "/version": return {"version": "4.2.5"}
if endpoint == "/status/datastore-usage": return [{"store": "backup", "total": 100, "used": 30, "avail": 70}]
if endpoint == "/admin/verify": raise pbs.PBSError("Permission denied")
return []
with mock.patch.object(pbs, "_remote_get", side_effect=remote) as request:
identifier = pbs.save_server(self.server)
result = pbs.get_status(identifier)
count = request.call_count
self.assertEqual(pbs.get_status(identifier), result)
self.assertEqual(request.call_count, count)
self.assertEqual(result["datastores"][0]["name"], "backup")
self.assertIsNone(result["verify"])
self.assertIn("verify", result["errors"])
def test_unmounted_removable_datastore_has_no_fabricated_capacity(self):
with mock.patch.object(pbs, "_cli", return_value=[{"name": "removable", "path": "/not-mounted", "backing-device": "uuid"}]), \
mock.patch.object(pbs.os.path, "ismount", return_value=False), \
mock.patch.object(pbs.shutil, "disk_usage") as usage:
result = pbs._local_datastores()
usage.assert_not_called()
self.assertIsNone(result[0]["total"])
self.assertIn("error", result[0])
def test_local_status_reads_pbs_commands_and_filesystem_capacity(self):
def run(command, **kwargs):
self.assertNotIn("shell", kwargs)
self.assertEqual(command[0], "proxmox-backup-manager")
self.assertEqual(command[-2:], ["--output-format", "json"])
data = [{"name": "backup", "path": str(self.root)}] if command[1:3] == ["datastore", "list"] else []
return SimpleNamespace(stdout=json.dumps(data), returncode=0)
with mock.patch.object(pbs, "get_platform", return_value={"product": "pbs", "version": "4.2.5"}), \
mock.patch.object(pbs, "_local_services", return_value={key: "active" for key in pbs.PBS_SERVICES}), \
mock.patch.object(pbs.subprocess, "run", side_effect=run), \
mock.patch.object(pbs.shutil, "disk_usage", return_value=SimpleNamespace(total=100, used=30, free=70)):
result = pbs.get_status("local")
self.assertEqual(result["version"], "4.2.5")
self.assertEqual(result["errors"], {})
self.assertEqual(result["datastores"][0]["used"], 30)
self.assertEqual(set(result["services"]), set(pbs.PBS_SERVICES))
def test_connection_api_requires_full_admin_and_does_not_return_secret(self):
self.assertEqual(self.client.get("/api/pbs/servers").status_code, 401)
with mock.patch.object(pbs, "_remote_get", return_value=[]):
response = self.client.post("/api/pbs/servers", headers=self.headers, json=self.server)
self.assertEqual(response.status_code, 200)
self.assertNotIn("fixture-token-secret", response.text)
payload = jwt.decode(self.headers["Authorization"].split()[1], "s" * 48,
algorithms=["HS256"], audience=auth_manager.JWT_AUDIENCE)
payload["scope"] = "read_only"
read_only = {"Authorization": "Bearer " + jwt.encode(payload, "s" * 48, algorithm="HS256")}
self.assertEqual(self.client.post("/api/pbs/servers", headers=read_only, json=self.server).status_code, 403)
self.assertEqual(self.client.get("/api/pbs/servers", headers=read_only).status_code, 200)
auth_manager.save_auth_config({"enabled": False})
self.assertEqual(self.client.post("/api/pbs/servers", json=self.server).status_code, 409)
def test_health_uses_pbs_services_and_skips_pve_only_checks(self):
source = ast.parse((ROOT / "AppImage/scripts/health_monitor.py").read_text())
names = ("_check_pve_services", "_check_vms_cts_optimized", "_check_vms_cts_with_persistence",
"_check_proxmox_storages", "_check_proxmox_storage", "_check_lxc_disk_usage",
"_check_vm_disk_usage", "_check_lxc_mount_capacity", "_check_pve_storage_capacity")
functions = [node for node in ast.walk(source) if isinstance(node, ast.FunctionDef) and node.name in names]
persistence = mock.Mock(); persistence.is_error_active.return_value = False
run = mock.Mock(return_value=SimpleNamespace(returncode=0, stdout="active\n"))
namespace = {"Dict": dict, "Any": object, "Optional": __import__('typing').Optional,
"PBS_SERVICES": pbs.PBS_SERVICES, "health_persistence": persistence,
"subprocess": SimpleNamespace(run=run), "os": __import__('os')}
exec(compile(ast.Module(body=functions, type_ignores=[]), '<health-pbs-test>', 'exec'), namespace)
instance = SimpleNamespace(platform_type="pbs", PVE_SERVICES=["pveproxy", "pvedaemon", "pvestatd", "pve-cluster"])
services = namespace["_check_pve_services"](instance)
self.assertEqual(services["status"], "OK")
self.assertEqual([call.args[0][2] for call in run.call_args_list], list(pbs.PBS_SERVICES))
for name in names[1:]:
result = namespace[name](instance)
self.assertTrue(result is None or result.get("not_applicable"))
def test_appimage_copies_and_imports_pbs_dependencies_outside_source_tree(self):
builder = (ROOT / "AppImage/scripts/build_appimage.sh").read_text()
modules = ("pbs_manager", "flask_pbs_routes", "auth_manager", "jwt_middleware")
copies = []
for name in modules:
matches = [line for line in builder.splitlines() if line.startswith(f'cp "$SCRIPT_DIR/{name}.py"')]
self.assertEqual(len(matches), 1, name)
copies.extend(matches)
copies.extend(line for line in builder.splitlines() if line.startswith('cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py"'))
self.assertEqual(len(copies), len(modules) + 1)
app = self.root / 'AppDir'; (app / 'usr/bin').mkdir(parents=True)
q = shlex.quote
shell = f'APP_DIR={q(str(app))}\nSCRIPT_DIR={q(str(ROOT / "AppImage/scripts"))}\nAPPIMAGE_ROOT={q(str(ROOT / "AppImage"))}\n' + '\n'.join(copies)
subprocess.run(['bash', '-e', '-c', shell], check=True)
code = f'import sys; sys.path.insert(0, {str(app / "usr/bin")!r}); import pbs_manager, flask_pbs_routes, proxmox_platform'
subprocess.run([sys.executable, '-I', '-B', '-c', code], cwd=self.root, check=True, capture_output=True)
class TLSTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory()
root = Path(cls.temporary.name)
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
certificate = (x509.CertificateBuilder().subject_name(subject).issuer_name(subject).public_key(key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(datetime.now(timezone.utc) - timedelta(minutes=1))
.not_valid_after(datetime.now(timezone.utc) + timedelta(hours=1))
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False)
.sign(key, hashes.SHA256()))
cert_path, key_path = root / 'server.pem', root / 'server.key'
cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
cls.fingerprint = hashlib.sha256(certificate.public_bytes(serialization.Encoding.DER)).hexdigest()
cls.requests = []
cls.response_status = 200
class Handler(BaseHTTPRequestHandler):
def handle(self):
try:
super().handle()
except ConnectionResetError:
pass # A rejected TLS pin closes before any HTTP request.
def do_GET(self):
cls.requests.append((self.path, self.headers.get("Authorization")))
body = json.dumps({"data": []}).encode()
self.send_response(cls.response_status)
self.send_header('Content-Length', str(len(body)))
self.end_headers(); self.wfile.write(body)
def log_message(self, *args): pass
cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(cert_path, key_path)
cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True); cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown(); cls.server.server_close(); cls.thread.join(timeout=5); cls.temporary.cleanup()
def setUp(self):
self.requests.clear(); type(self).response_status = 200
self.connection = {"url": f'https://127.0.0.1:{self.server.server_port}',
"fingerprint": self.fingerprint, "token_id": "audit@pbs!monitor", "token_secret": "fake-secret"}
def test_real_tls_matching_pin_sends_correct_pbs_authorization(self):
self.assertEqual(pbs._remote_get(self.connection, '/status/datastore-usage'), [])
self.assertEqual(self.requests, [('/api2/json/status/datastore-usage', 'PBSAPIToken=audit@pbs!monitor:fake-secret')])
def test_bad_pin_and_untrusted_ca_send_no_http_credentials(self):
for fingerprint in ('0' * 64, ''):
with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError):
pbs._remote_get({**self.connection, "fingerprint": fingerprint}, '/status/datastore-usage')
self.assertEqual(self.requests, [])
def test_bad_credentials_and_redirects_are_rejected(self):
for status in (401, 403, 302):
type(self).response_status = status
with self.subTest(status=status), self.assertRaises(pbs.PBSError):
pbs._remote_get(self.connection, '/status/datastore-usage')
if __name__ == '__main__':
unittest.main()
+26
View File
@@ -0,0 +1,26 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const source = fs.readFileSync(path.join(__dirname, '../components/proxmox-dashboard.tsx'), 'utf8')
const tree = ts.createSourceFile('dashboard.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const wrapper = tree.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'ProxmoxDashboard')
assert.ok(wrapper, 'platform gate must remain separate from PVE hooks/prewarmers')
const code = ts.transpileModule(ts.createPrinter().printNode(ts.EmitHint.Unspecified, wrapper, tree), {
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
}).outputText
const PBS = () => {}, PVE = () => {}
for (const [data, error, expected] of [
[{ product: 'pbs' }, null, PBS], [{ product: 'pve' }, null, PVE],
[undefined, new Error('older API'), PVE], [undefined, null, 'p'],
]) {
const sandbox = {
exports: {}, PBSDashboard: PBS, PVEDashboard: PVE,
useT: () => key => key, useSWR: () => ({ data, error }),
require: () => ({ jsx: (type, props) => ({ type, props }) }),
}
vm.runInNewContext(code, sandbox)
assert.equal(sandbox.exports.ProxmoxDashboard().type, expected)
}
console.log('PASS: PBS dashboard routing and loading gate do not mount PVE prewarmers')
+66
View File
@@ -0,0 +1,66 @@
# Proxmox Backup Server support
ProxMenux detects PBS from the installed **proxmox-backup-server** package.
Installing proxmox-backup-client on PVE or another Debian system does not turn
that host into PBS. Supported maintenance platforms are PBS 3/Debian 12 and
PBS 4/Debian 13; PVE 8/9 detection remains separate. If both server products
are co-installed, PVE takes precedence.
## On a PBS host
The main CLI opens a PBS menu with package/version information, datastores,
recent tasks, the existing system-utility installer, settings and a PBS package
update. Utility installation preserves the operator's APT sources instead of
trying to find a PVE version or enabling PVE/Ceph repositories. Configure a
working subscription or no-subscription PBS repository in PBS itself if APT
reports an error.
The update refreshes configured indexes, displays the simulated full-upgrade
plan and requires confirmation before applying it. It does not run PVE repository
cleanup, major-version migration, kernel cleanup or guest scripts. The automatic
and customizable PVE post-install entry points reject PBS before adjustments.
Monitor opens a PBS dashboard with CPU, RAM, uptime, local PBS status,
datastores, recent tasks and maintenance schedules, plus logs, terminal and
Monitor authentication/HTTPS settings. PVE guest prewarmers, firewall controls
and guest-storage checks are not part of this dashboard. Health checks monitor
proxmox-backup and proxmox-backup-proxy rather than reporting absent PVE daemons.
Monitor can reuse the native PBS proxy.pem/proxy.key pair, including renewal.
Local data is read with proxmox-backup-manager JSON commands. Datastore capacity
is filesystem capacity, not the logical sum of backups; datastores sharing one
filesystem can show the same capacity. An unmounted removable datastore or a
failed check has unknown capacity, not zero/free space.
## Monitor a remote PBS from PVE
Open **Backup → Proxmox Backup Server → Manage PBS connections**. Enable local
Monitor authentication first, then add a named HTTPS endpoint (for example
https://pbs.example:8007), a dedicated API token ID (`user@pbs!monitor`) and its
secret. Give both the user and token the PBS **Audit** role on `/`, with
propagation, to view system tasks and datastore status. Token ACLs cannot exceed
the user's effective permissions. A restricted token may show only its permitted
data; denied sections are displayed as unavailable.
Use a CA-trusted certificate or copy the certificate's SHA256 fingerprint from
the PBS console/a trusted channel. With a pin, Monitor checks the peer certificate
before sending the token. With no pin, the normal system CA and hostname checks
apply. Connections go directly to the configured HTTPS endpoint and do not follow
redirects. A reverse-proxy prefix is supported; omit the port for HTTPS 443 or
include the native PBS port explicitly.
Saving tests an authenticated datastore-status endpoint. Secrets are stored
atomically with mode 0600 in `~/.config/proxmenux-monitor/pbs_servers.json`, never
returned by the API, and retained when the edit form's secret is left empty.
Connection changes require full administrator access; read-only Monitor API tokens
can read monitoring data but cannot change connections. Removing a connection
removes its saved credentials only. Monitoring does not start prune, garbage
collection, verification or sync jobs and does not modify PBS backups.
Status is cached for 15 seconds and the panel refreshes every 30 seconds. Task or
schedule permission failures are shown independently, without hiding successful
datastore data or presenting failed queries as empty healthy results.
References: [PBS command syntax](https://pbs.proxmox.com/docs/command-syntax.html),
[PBS API](https://pbs.proxmox.com/docs/api-viewer/index.html),
[PBS users, tokens and ACLs](https://pbs.proxmox.com/docs/user-management.html).
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Canonical detection is shared with the Monitor; never infer PBS from a client package.
pmx_platform_field() {
python3 "$(dirname "${BASH_SOURCE[0]}")/proxmox_platform.py" --field "$1"
}
pmx_is_pbs() {
[[ "$(pmx_platform_field product)" == pbs ]]
}
pmx_require_pve() {
if [[ "$(pmx_platform_field product)" != pve ]]; then
msg_error "$(translate 'This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.')"
return 1
fi
}
+56
View File
@@ -0,0 +1,56 @@
"""Shared PVE/PBS platform detection for the menus and the Monitor (stdlib only)."""
import argparse
import json
from pathlib import Path
import re
import subprocess
OS_RELEASE = Path("/etc/os-release")
SUPPORTED = {("pve", 8): "bookworm", ("pve", 9): "trixie",
("pbs", 3): "bookworm", ("pbs", 4): "trixie"}
def detect_platform():
codename = ""
try:
match = re.search(r'^VERSION_CODENAME=["\']?([a-z0-9-]+)', OS_RELEASE.read_text(), re.M)
codename = match.group(1) if match else ""
except OSError:
pass
installed = {}
try:
result = subprocess.run(
["dpkg-query", "-W", "-f=${Package}\t${Status}\t${Version}\n",
"pve-manager", "proxmox-backup-server"], capture_output=True, text=True, timeout=5,
)
# dpkg returns nonzero if ONE requested package is missing; its other
# installed rows remain authoritative. A PBS client alone is not PBS.
for line in result.stdout.splitlines():
columns = line.split("\t")
if len(columns) == 3 and columns[1] == "install ok installed":
installed[columns[0]] = columns[2]
except (OSError, subprocess.TimeoutExpired):
pass
for package, product in (("pve-manager", "pve"), ("proxmox-backup-server", "pbs")):
version = installed.get(package, "")
match = re.fullmatch(r"(?:[0-9]+:)?([0-9]+)\.[0-9]+(?:\.[0-9]+)?(?:[-+~.a-zA-Z0-9]*)", version)
if match:
major = int(match.group(1))
return {"product": product, "version": version, "major": major,
"codename": codename, "supported": SUPPORTED.get((product, major)) == codename}
return {"product": "unknown", "version": None, "major": None,
"codename": codename, "supported": False}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--field", choices=("product", "version", "major", "codename", "supported"))
arguments = parser.parse_args()
data = detect_platform()
value = data.get(arguments.field) if arguments.field else data
print(value if isinstance(value, str) else json.dumps(value))
if __name__ == "__main__":
main()
+9
View File
@@ -2,12 +2,21 @@
# Shared repository policy for package-install and safe-update flows.
# Proxmox's own repository API parses .list/.sources and edits individual
# entries. No shell rewriting of operator-maintained APT files.
source "$(dirname "${BASH_SOURCE[0]}")/platform-functions.sh"
repository_policy() {
python3 "$(dirname "${BASH_SOURCE[0]}")/repository_policy.py" "$@"
}
ensure_repositories() {
local version suite decision refresh_status
if pmx_is_pbs; then
if [[ "$(pmx_platform_field supported)" != true ]]; then
msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')"
return 1
fi
msg_info "$(translate 'PBS: using the configured APT repositories without changing sources.')"
return 0
fi
version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
case "$version" in
8) suite=bookworm ;;
+4
View File
@@ -128,4 +128,8 @@ show_menu() {
done
}
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
if pmx_is_pbs; then
exec bash "$LOCAL_SCRIPTS/menus/pbs_menu.sh"
fi
show_menu
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# PBS exposes compatible host tools; PVE guest/repository optimizations stay in the PVE menu.
BASE_DIR="/usr/local/share/proxmenux"
LOCAL_SCRIPTS="$BASE_DIR/scripts"
source "$BASE_DIR/utils.sh"
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
load_language
initialize_cache
if ! pmx_is_pbs; then
msg_error "$(translate 'This menu requires Proxmox Backup Server.')"
exit 1
fi
pbs_show() {
local output
output=$(mktemp)
if "$@" > "$output" 2>&1; then
dialog --backtitle "ProxMenux · PBS" --title "Proxmox Backup Server" --textbox "$output" 24 100
else
dialog --backtitle "ProxMenux · PBS" --title "$(translate 'Command failed')" --textbox "$output" 24 100
fi
rm -f "$output"
}
while true; do
choice=$(dialog --clear --backtitle "ProxMenux · PBS" --title "Proxmox Backup Server" \
--menu "$(translate 'Select an option:')" 20 80 10 \
status "$(translate 'PBS version and packages')" \
stores "$(translate 'PBS datastores')" \
tasks "$(translate 'Recent PBS tasks')" \
utilities "$(translate 'System Utilities Installer')" \
update "$(translate 'Update PBS using configured repositories')" \
settings "$(translate 'Settings')" \
exit "$(translate 'Exit')" 2>&1 >/dev/tty) || break
case "$choice" in
status) pbs_show proxmox-backup-manager versions --verbose --output-format json-pretty ;;
stores) pbs_show proxmox-backup-manager datastore list --output-format json-pretty ;;
tasks) pbs_show proxmox-backup-manager task list --all true --limit 20 --output-format json-pretty ;;
utilities) bash "$LOCAL_SCRIPTS/utilities/system_utils.sh" ;;
update) bash "$LOCAL_SCRIPTS/utilities/pbs_update.sh" ;;
settings) bash "$LOCAL_SCRIPTS/menus/config_menu.sh" ;;
exit) break ;;
esac
done
clear
@@ -57,6 +57,11 @@ fi
load_language
initialize_cache
if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
pmx_require_pve || exit 1
fi
# Global variables
OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
RAM_SIZE_GB=$(awk '/^MemTotal:/{print int($2/1024/1024)}' /proc/meminfo)
@@ -51,6 +51,11 @@ fi
load_language
initialize_cache
if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
pmx_require_pve || exit 1
fi
# Load shared global functions
if [[ -f "$LOCAL_SCRIPTS/global/common-functions.sh" ]]; then
source "$LOCAL_SCRIPTS/global/common-functions.sh"
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# Use PBS's existing repositories; never run PVE repo, Ceph or major-upgrade scripts.
BASE_DIR="/usr/local/share/proxmenux"
LOCAL_SCRIPTS="$BASE_DIR/scripts"
source "$BASE_DIR/utils.sh"
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
load_language
initialize_cache
if ! pmx_is_pbs || [[ "$(pmx_platform_field supported)" != true ]]; then
msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')"
exit 1
fi
[[ $EUID -eq 0 ]] || { msg_error "$(translate 'Run this operation as root.')"; exit 1; }
dialog --backtitle "ProxMenux · PBS" --title "$(translate 'PBS package update')" \
--yesno "$(translate 'Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.')" 10 80 || exit 0
if ! apt-get -o APT::Update::Error-Mode=any update; then
msg_error "$(translate 'APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.')"
read -r _
exit 1
fi
plan=$(mktemp)
trap 'rm -f "$plan"' EXIT
if ! apt-get -s full-upgrade > "$plan" 2>&1; then
dialog --textbox "$plan" 24 100
exit 1
fi
dialog --backtitle "ProxMenux · PBS" --title "$(translate 'Review package upgrade')" --textbox "$plan" 24 100
dialog --defaultno --title "$(translate 'Apply PBS package upgrade')" \
--yesno "$(translate 'Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.')" 11 80 || exit 0
if apt-get -y full-upgrade; then
msg_ok "$(translate 'PBS package upgrade completed. Check whether a reboot is required before your next backup window.')"
else
msg_error "$(translate 'PBS package upgrade failed. Review the APT output before retrying.')"
read -r _
exit 1
fi
read -r _