mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
feat: support PBS hosts and remote PBS monitoring
This commit is contained in:
@@ -199,6 +199,9 @@ class CommandDescriptionsTests(unittest.TestCase):
|
|||||||
# messages; keep repository locales and all other leaves intact.
|
# messages; keep repository locales and all other leaves intact.
|
||||||
if lang != "en":
|
if lang != "en":
|
||||||
messages = json.loads(path.read_text())
|
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")
|
english = catalog("en")
|
||||||
for section, key in (("encryption", "pbsHelp"),
|
for section, key in (("encryption", "pbsHelp"),
|
||||||
("keyfileActions", "pveKeyDescription")):
|
("keyfileActions", "pveKeyDescription")):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -7,9 +7,10 @@ import { useState } from "react"
|
|||||||
import { DatabaseBackup } from "lucide-react"
|
import { DatabaseBackup } from "lucide-react"
|
||||||
import { HostBackup } from "./host-backup"
|
import { HostBackup } from "./host-backup"
|
||||||
import { VmBackupJobs } from "./vm-backup-jobs"
|
import { VmBackupJobs } from "./vm-backup-jobs"
|
||||||
|
import { PBSPanel } from "./pbs-panel"
|
||||||
import { useT } from "../lib/i18n/provider"
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
type BackupView = "host" | "guests"
|
type BackupView = "host" | "guests" | "pbs"
|
||||||
|
|
||||||
export function BackupTab() {
|
export function BackupTab() {
|
||||||
const t = useT()
|
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
|
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
|
||||||
sm:inline-flex sm:w-auto"
|
sm:inline-flex sm:w-auto"
|
||||||
>
|
>
|
||||||
{(["host", "guests"] as const).map((key) => (
|
{(["host", "guests", "pbs"] as const).map((key) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -41,7 +42,7 @@ export function BackupTab() {
|
|||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`backup.viewSwitch.${key}`)}
|
{key === "pbs" ? t("pbs.title") : t(`backup.viewSwitch.${key}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -52,6 +53,7 @@ export function BackupTab() {
|
|||||||
<HostBackup />
|
<HostBackup />
|
||||||
</div>
|
</div>
|
||||||
{view === "guests" && <VmBackupJobs />}
|
{view === "guests" && <VmBackupJobs />}
|
||||||
|
{view === "pbs" && <PBSPanel />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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 { Profile } from "./profile"
|
||||||
import { About } from "./about"
|
import { About } from "./about"
|
||||||
import { BackupTab } from "./backup-tab"
|
import { BackupTab } from "./backup-tab"
|
||||||
|
import { PBSDashboard } from "./pbs-dashboard"
|
||||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||||
import { HealthStatusModal } from "./health-status-modal"
|
import { HealthStatusModal } from "./health-status-modal"
|
||||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||||
@@ -96,6 +97,14 @@ interface FlaskSystemInfo {
|
|||||||
const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
|
const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
|
||||||
|
|
||||||
export function ProxmoxDashboard() {
|
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()
|
const t = useT()
|
||||||
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||||
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function validatePasswordStrength(pw: string, t: (key: string) => string): strin
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Security() {
|
export function Security({ hostType = "pve" }: { hostType?: "pve" | "pbs" } = {}) {
|
||||||
const { language, t } = useI18n()
|
const { language, t } = useI18n()
|
||||||
const st = (key: string, params?: Record<string, string | number>) => t(`securityPage.${key}`, params)
|
const st = (key: string, params?: Record<string, string | number>) => t(`securityPage.${key}`, params)
|
||||||
const interfaceTypeLabel = (type: string) =>
|
const interfaceTypeLabel = (type: string) =>
|
||||||
@@ -292,9 +292,11 @@ export function Security() {
|
|||||||
checkAuthStatus()
|
checkAuthStatus()
|
||||||
loadApiTokens()
|
loadApiTokens()
|
||||||
loadSslStatus()
|
loadSslStatus()
|
||||||
loadFirewallStatus()
|
if (hostType === "pve") {
|
||||||
loadNetworkInterfaces()
|
loadFirewallStatus()
|
||||||
loadSecurityTools()
|
loadNetworkInterfaces()
|
||||||
|
loadSecurityTools()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadFirewallStatus = async () => {
|
const loadFirewallStatus = async () => {
|
||||||
@@ -2778,6 +2780,7 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Proxmox VE Security Group ── */}
|
{/* ── Proxmox VE Security Group ── */}
|
||||||
|
{hostType === "pve" && <>
|
||||||
<div className="flex items-center gap-3 mt-4">
|
<div className="flex items-center gap-3 mt-4">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-orange-500">Proxmox VE</h2>
|
<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" />
|
<div className="flex-1 h-px bg-orange-500/20" />
|
||||||
@@ -4833,6 +4836,8 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</>}
|
||||||
|
|
||||||
<TwoFactorSetup
|
<TwoFactorSetup
|
||||||
open={show2FASetup}
|
open={show2FASetup}
|
||||||
onClose={() => setShow2FASetup(false)}
|
onClose={() => setShow2FASetup(false)}
|
||||||
|
|||||||
@@ -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": {
|
"app": {
|
||||||
"title": "ProxMenux Monitor",
|
"title": "ProxMenux Monitor",
|
||||||
"description": "Proxmox System Dashboard",
|
"description": "Proxmox System Dashboard",
|
||||||
|
|||||||
@@ -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.",
|
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values.",
|
||||||
"app": {
|
"app": {
|
||||||
"title": "ProxMenux Monitor",
|
"title": "ProxMenux Monitor",
|
||||||
|
|||||||
@@ -1115,6 +1115,9 @@ def _detect_proxmox_certificate_paths():
|
|||||||
return PROXMOX_CUSTOM_CERT_PATH, PROXMOX_CUSTOM_KEY_PATH
|
return PROXMOX_CUSTOM_CERT_PATH, PROXMOX_CUSTOM_KEY_PATH
|
||||||
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
|
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
|
||||||
return PROXMOX_CERT_PATH, 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 "", ""
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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/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/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/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/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/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"
|
cp "$SCRIPT_DIR/flask_security_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_security_routes.py not found"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -90,6 +90,8 @@ from flask_terminal_routes import ( # noqa: E402
|
|||||||
from flask_health_routes import health_bp # noqa: E402
|
from flask_health_routes import health_bp # noqa: E402
|
||||||
from flask_auth_routes import auth_bp # noqa: E402
|
from flask_auth_routes import auth_bp # noqa: E402
|
||||||
from flask_proxmenux_routes import proxmenux_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_security_routes import security_bp # noqa: E402
|
||||||
from flask_notification_routes import notification_bp # noqa: E402
|
from flask_notification_routes import notification_bp # noqa: E402
|
||||||
from flask_oci_routes import oci_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(auth_bp)
|
||||||
app.register_blueprint(health_bp)
|
app.register_blueprint(health_bp)
|
||||||
app.register_blueprint(proxmenux_bp)
|
app.register_blueprint(proxmenux_bp)
|
||||||
|
app.register_blueprint(pbs_bp)
|
||||||
app.register_blueprint(security_bp)
|
app.register_blueprint(security_bp)
|
||||||
app.register_blueprint(notification_bp)
|
app.register_blueprint(notification_bp)
|
||||||
app.register_blueprint(oci_bp)
|
app.register_blueprint(oci_bp)
|
||||||
@@ -3474,6 +3477,8 @@ def get_coral_info():
|
|||||||
def get_proxmox_version():
|
def get_proxmox_version():
|
||||||
"""Get Proxmox version if available. Cached for 6 hours."""
|
"""Get Proxmox version if available. Cached for 6 hours."""
|
||||||
global _system_info_cache
|
global _system_info_cache
|
||||||
|
if is_pbs_host():
|
||||||
|
return get_platform().get('version')
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if _system_info_cache['proxmox_version'] is not None and \
|
if _system_info_cache['proxmox_version'] is not None and \
|
||||||
@@ -9863,6 +9868,7 @@ def api_system():
|
|||||||
'cpu_cores': cpu_cores,
|
'cpu_cores': cpu_cores,
|
||||||
'cpu_threads': cpu_threads,
|
'cpu_threads': cpu_threads,
|
||||||
'proxmox_version': proxmox_version,
|
'proxmox_version': proxmox_version,
|
||||||
|
'platform': get_platform(),
|
||||||
'kernel_version': kernel_version,
|
'kernel_version': kernel_version,
|
||||||
'available_updates': available_updates
|
'available_updates': available_updates
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ DEBUG_PERF = False
|
|||||||
# ─── Startup Grace Period ────────────────────────────────────────────────────
|
# ─── Startup Grace Period ────────────────────────────────────────────────────
|
||||||
# Import centralized startup grace management for consistent behavior
|
# Import centralized startup grace management for consistent behavior
|
||||||
import startup_grace
|
import startup_grace
|
||||||
|
from pbs_manager import get_platform, PBS_SERVICES
|
||||||
|
|
||||||
def _is_startup_health_grace() -> bool:
|
def _is_startup_health_grace() -> bool:
|
||||||
"""Check if we're within the startup health grace period (5 min).
|
"""Check if we're within the startup health grace period (5 min).
|
||||||
@@ -466,6 +467,7 @@ class HealthMonitor:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize health monitor with state tracking"""
|
"""Initialize health monitor with state tracking"""
|
||||||
|
self.platform_type = get_platform().get("product")
|
||||||
self.state_history = defaultdict(list)
|
self.state_history = defaultdict(list)
|
||||||
self.last_check_times = {}
|
self.last_check_times = {}
|
||||||
self.cached_results = {}
|
self.cached_results = {}
|
||||||
@@ -919,9 +921,9 @@ class HealthMonitor:
|
|||||||
_perf_log("services", (time.time() - _t) * 1000)
|
_perf_log("services", (time.time() - _t) * 1000)
|
||||||
details['services'] = services_status
|
details['services'] = services_status
|
||||||
if services_status['status'] == 'CRITICAL':
|
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':
|
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)
|
# Priority 1.5: Proxmox Storage Check (External Module)
|
||||||
_t = time.time()
|
_t = time.time()
|
||||||
@@ -2347,6 +2349,8 @@ class HealthMonitor:
|
|||||||
# The new _check_proxmox_storage function handles this logic better.
|
# The new _check_proxmox_storage function handles this logic better.
|
||||||
def _check_proxmox_storages(self) -> Dict[str, Any]:
|
def _check_proxmox_storages(self) -> Dict[str, Any]:
|
||||||
"""Check Proxmox-specific storages (only report problems)"""
|
"""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 = {}
|
storages = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -3515,6 +3519,8 @@ class HealthMonitor:
|
|||||||
Optimized VM/CT check - detects qmp failures and startup errors from logs.
|
Optimized VM/CT check - detects qmp failures and startup errors from logs.
|
||||||
Improved detection of container and VM errors from journalctl.
|
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:
|
try:
|
||||||
# First: auto-resolve any persisted VM/CT errors where the guest
|
# First: auto-resolve any persisted VM/CT errors where the guest
|
||||||
# is now running. This clears stale "Failed to start" / QMP
|
# is now running. This clears stale "Failed to start" / QMP
|
||||||
@@ -3678,6 +3684,8 @@ class HealthMonitor:
|
|||||||
Check VMs/CTs with persistent error tracking.
|
Check VMs/CTs with persistent error tracking.
|
||||||
Errors persist until VM starts or 48h elapsed.
|
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:
|
try:
|
||||||
issues = []
|
issues = []
|
||||||
vm_details = {}
|
vm_details = {}
|
||||||
@@ -3919,8 +3927,18 @@ class HealthMonitor:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Build service list: base PVE services + corosync if clustered
|
# Build service list: base PVE services + corosync if clustered
|
||||||
services_to_check = list(self.PVE_SERVICES)
|
pbs_host = getattr(self, "platform_type", "pve") == "pbs"
|
||||||
is_cluster = os.path.exists('/etc/corosync/corosync.conf')
|
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:
|
if is_cluster and 'corosync' not in services_to_check:
|
||||||
services_to_check.append('corosync')
|
services_to_check.append('corosync')
|
||||||
|
|
||||||
@@ -3952,14 +3970,14 @@ class HealthMonitor:
|
|||||||
# cleanup loop). Audit Tier 5 — Health stack.
|
# cleanup loop). Audit Tier 5 — Health stack.
|
||||||
for svc in services_to_check:
|
for svc in services_to_check:
|
||||||
if svc not in failed_services:
|
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):
|
if health_persistence.is_error_active(error_key):
|
||||||
health_persistence.clear_error(error_key)
|
health_persistence.clear_error(error_key)
|
||||||
|
|
||||||
# Build checks dict with status per service
|
# Build checks dict with status per service
|
||||||
checks = {}
|
checks = {}
|
||||||
for svc in services_to_check:
|
for svc in services_to_check:
|
||||||
error_key = f'pve_service_{svc}'
|
error_key = f'{service_prefix}_service_{svc}'
|
||||||
if svc in failed_services:
|
if svc in failed_services:
|
||||||
state = service_details.get(svc, 'inactive')
|
state = service_details.get(svc, 'inactive')
|
||||||
checks[svc] = {
|
checks[svc] = {
|
||||||
@@ -3987,12 +4005,12 @@ class HealthMonitor:
|
|||||||
# Record each failed service in persistence, respecting dismiss
|
# Record each failed service in persistence, respecting dismiss
|
||||||
active_failed = []
|
active_failed = []
|
||||||
for svc in failed_services:
|
for svc in failed_services:
|
||||||
error_key = f'pve_service_{svc}'
|
error_key = f'{service_prefix}_service_{svc}'
|
||||||
rec_result = health_persistence.record_error(
|
rec_result = health_persistence.record_error(
|
||||||
error_key=error_key,
|
error_key=error_key,
|
||||||
category='pve_services',
|
category='pve_services',
|
||||||
severity='CRITICAL',
|
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')}
|
details={'service': svc, 'state': service_details.get(svc, 'inactive')}
|
||||||
)
|
)
|
||||||
if rec_result and rec_result.get('type') == 'skipped_acknowledged':
|
if rec_result and rec_result.get('type') == 'skipped_acknowledged':
|
||||||
@@ -5816,6 +5834,8 @@ class HealthMonitor:
|
|||||||
- No persistent errors are recorded
|
- No persistent errors are recorded
|
||||||
This prevents false positives when NFS/PBS/remote storage is still mounting.
|
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:
|
if not PROXMOX_STORAGE_AVAILABLE:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -6205,6 +6225,8 @@ class HealthMonitor:
|
|||||||
Notification cooldown is the standard 1h via the storage
|
Notification cooldown is the standard 1h via the storage
|
||||||
category, so a CT teetering at 96% won't spam the channel.
|
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
|
# Cheap pre-check: bail out when there are no running LXCs. On
|
||||||
# nodes without CTs, the previous version still paid ~860ms per
|
# nodes without CTs, the previous version still paid ~860ms per
|
||||||
# cycle for a `pvesh /cluster/resources` call that produced no
|
# 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
|
path, so a slow / dead guest agent can't stretch the health
|
||||||
cycle.
|
cycle.
|
||||||
"""
|
"""
|
||||||
|
if getattr(self, "platform_type", "pve") == "pbs":
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
import flask_server # deferred — avoids circular import
|
import flask_server # deferred — avoids circular import
|
||||||
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
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
|
check (already covered) because those are usually large data
|
||||||
mounts whose fill rate is driven by user content, not log spam.
|
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:
|
if not MOUNT_MONITOR_AVAILABLE:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -6627,6 +6653,8 @@ class HealthMonitor:
|
|||||||
check via _check_filesystem. Re-alerting on the same condition
|
check via _check_filesystem. Re-alerting on the same condition
|
||||||
through two channels just clutters the inbox.
|
through two channels just clutters the inbox.
|
||||||
"""
|
"""
|
||||||
|
if getattr(self, "platform_type", "pve") == "pbs":
|
||||||
|
return None
|
||||||
if not PROXMOX_STORAGE_AVAILABLE:
|
if not PROXMOX_STORAGE_AVAILABLE:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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).
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -2,12 +2,21 @@
|
|||||||
# Shared repository policy for package-install and safe-update flows.
|
# Shared repository policy for package-install and safe-update flows.
|
||||||
# Proxmox's own repository API parses .list/.sources and edits individual
|
# Proxmox's own repository API parses .list/.sources and edits individual
|
||||||
# entries. No shell rewriting of operator-maintained APT files.
|
# entries. No shell rewriting of operator-maintained APT files.
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/platform-functions.sh"
|
||||||
repository_policy() {
|
repository_policy() {
|
||||||
python3 "$(dirname "${BASH_SOURCE[0]}")/repository_policy.py" "$@"
|
python3 "$(dirname "${BASH_SOURCE[0]}")/repository_policy.py" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_repositories() {
|
ensure_repositories() {
|
||||||
local version suite decision refresh_status
|
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)
|
version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
||||||
case "$version" in
|
case "$version" in
|
||||||
8) suite=bookworm ;;
|
8) suite=bookworm ;;
|
||||||
|
|||||||
@@ -128,4 +128,8 @@ show_menu() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
|
||||||
|
if pmx_is_pbs; then
|
||||||
|
exec bash "$LOCAL_SCRIPTS/menus/pbs_menu.sh"
|
||||||
|
fi
|
||||||
show_menu
|
show_menu
|
||||||
|
|||||||
@@ -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
|
load_language
|
||||||
initialize_cache
|
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
|
# Global variables
|
||||||
OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
|
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)
|
RAM_SIZE_GB=$(awk '/^MemTotal:/{print int($2/1024/1024)}' /proc/meminfo)
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ fi
|
|||||||
load_language
|
load_language
|
||||||
initialize_cache
|
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
|
# Load shared global functions
|
||||||
if [[ -f "$LOCAL_SCRIPTS/global/common-functions.sh" ]]; then
|
if [[ -f "$LOCAL_SCRIPTS/global/common-functions.sh" ]]; then
|
||||||
source "$LOCAL_SCRIPTS/global/common-functions.sh"
|
source "$LOCAL_SCRIPTS/global/common-functions.sh"
|
||||||
|
|||||||
@@ -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 _
|
||||||
Reference in New Issue
Block a user