mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 02:12:12 +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.
|
||||
if lang != "en":
|
||||
messages = json.loads(path.read_text())
|
||||
# PBS is a new section; model the bot's future output only
|
||||
# in disposable catalogs, preserving the shipped locales.
|
||||
messages.setdefault("pbs", catalog("en")["pbs"])
|
||||
english = catalog("en")
|
||||
for section, key in (("encryption", "pbsHelp"),
|
||||
("keyfileActions", "pveKeyDescription")):
|
||||
|
||||
@@ -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 { HostBackup } from "./host-backup"
|
||||
import { VmBackupJobs } from "./vm-backup-jobs"
|
||||
import { PBSPanel } from "./pbs-panel"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
type BackupView = "host" | "guests"
|
||||
type BackupView = "host" | "guests" | "pbs"
|
||||
|
||||
export function BackupTab() {
|
||||
const t = useT()
|
||||
@@ -29,7 +30,7 @@ export function BackupTab() {
|
||||
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
|
||||
sm:inline-flex sm:w-auto"
|
||||
>
|
||||
{(["host", "guests"] as const).map((key) => (
|
||||
{(["host", "guests", "pbs"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
@@ -41,7 +42,7 @@ export function BackupTab() {
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||
}`}
|
||||
>
|
||||
{t(`backup.viewSwitch.${key}`)}
|
||||
{key === "pbs" ? t("pbs.title") : t(`backup.viewSwitch.${key}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -52,6 +53,7 @@ export function BackupTab() {
|
||||
<HostBackup />
|
||||
</div>
|
||||
{view === "guests" && <VmBackupJobs />}
|
||||
{view === "pbs" && <PBSPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { About } from "./about"
|
||||
import { BackupTab } from "./backup-tab"
|
||||
import { PBSDashboard } from "./pbs-dashboard"
|
||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import { HealthStatusModal } from "./health-status-modal"
|
||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||
@@ -96,6 +97,14 @@ interface FlaskSystemInfo {
|
||||
const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
|
||||
|
||||
export function ProxmoxDashboard() {
|
||||
const t = useT()
|
||||
const { data, error } = useSWR<{ product: string }>("/api/platform", url => fetchApi<{ product: string }>(url), { revalidateOnFocus: false })
|
||||
if (!data && !error) return <p role="status" className="p-8">{t("app.loading")}</p>
|
||||
if (data?.product === "pbs") return <PBSDashboard />
|
||||
return <PVEDashboard />
|
||||
}
|
||||
|
||||
function PVEDashboard() {
|
||||
const t = useT()
|
||||
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
|
||||
@@ -66,7 +66,7 @@ function validatePasswordStrength(pw: string, t: (key: string) => string): strin
|
||||
return null
|
||||
}
|
||||
|
||||
export function Security() {
|
||||
export function Security({ hostType = "pve" }: { hostType?: "pve" | "pbs" } = {}) {
|
||||
const { language, t } = useI18n()
|
||||
const st = (key: string, params?: Record<string, string | number>) => t(`securityPage.${key}`, params)
|
||||
const interfaceTypeLabel = (type: string) =>
|
||||
@@ -292,9 +292,11 @@ export function Security() {
|
||||
checkAuthStatus()
|
||||
loadApiTokens()
|
||||
loadSslStatus()
|
||||
loadFirewallStatus()
|
||||
loadNetworkInterfaces()
|
||||
loadSecurityTools()
|
||||
if (hostType === "pve") {
|
||||
loadFirewallStatus()
|
||||
loadNetworkInterfaces()
|
||||
loadSecurityTools()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadFirewallStatus = async () => {
|
||||
@@ -2778,6 +2780,7 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
)}
|
||||
|
||||
{/* ── Proxmox VE Security Group ── */}
|
||||
{hostType === "pve" && <>
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-orange-500">Proxmox VE</h2>
|
||||
<div className="flex-1 h-px bg-orange-500/20" />
|
||||
@@ -4833,6 +4836,8 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>}
|
||||
|
||||
<TwoFactorSetup
|
||||
open={show2FASetup}
|
||||
onClose={() => setShow2FASetup(false)}
|
||||
|
||||
@@ -1,4 +1,57 @@
|
||||
{
|
||||
"pbs": {
|
||||
"title": "Proxmox Backup Server",
|
||||
"server": "PBS server",
|
||||
"chooseServer": "Choose a PBS server",
|
||||
"local": "Local PBS",
|
||||
"refresh": "Refresh",
|
||||
"manage": "Manage PBS connections",
|
||||
"loadFailed": "Could not load PBS status.",
|
||||
"loading": "Loading PBS data…",
|
||||
"noServer": "Add a PBS connection to view datastores, tasks and schedules.",
|
||||
"permissionsHelp": "Enable Monitor authentication before saving credentials. Use a dedicated PBS API token with the Audit role on / for both its user and token ACLs, with propagation. This panel only reads PBS data.",
|
||||
"editConnection": "Connection to edit",
|
||||
"add": "Add a new connection",
|
||||
"name": "Name",
|
||||
"url": "HTTPS URL (include :8007 for the native PBS port)",
|
||||
"token_id": "API token ID (user@realm!token)",
|
||||
"token_secret": "API token secret (leave empty to keep the stored secret)",
|
||||
"fingerprint": "SHA256 certificate fingerprint (optional)",
|
||||
"tlsHelp": "Leave the fingerprint empty for normal CA and hostname verification. For a self-signed certificate, copy its SHA256 fingerprint from the PBS console or another trusted channel. The pin is checked before sending credentials.",
|
||||
"saved": "PBS connection verified and saved.",
|
||||
"saveFailed": "Could not save the PBS connection.",
|
||||
"testing": "Verifying connection…",
|
||||
"save": "Verify and save",
|
||||
"remove": "Remove connection",
|
||||
"removeConfirm": "Remove this saved PBS connection and its token from Monitor? PBS data and jobs are unaffected.",
|
||||
"sampled": "Sampled at",
|
||||
"datastores": "Datastores",
|
||||
"total": "Filesystem capacity",
|
||||
"used": "Used",
|
||||
"free": "Available",
|
||||
"noDatastores": "No accessible datastores. Check datastore configuration and the user/token permissions.",
|
||||
"tasks": "Recent tasks",
|
||||
"noTasks": "No recent tasks were returned.",
|
||||
"task": "Task",
|
||||
"started": "Started",
|
||||
"status": "Status",
|
||||
"running": "Running",
|
||||
"jobs": "Maintenance schedules",
|
||||
"job": "Job",
|
||||
"schedule": "Schedule",
|
||||
"sync": "Sync",
|
||||
"verify": "Verification",
|
||||
"prune": "Prune",
|
||||
"overview": "Overview",
|
||||
"logs": "System logs",
|
||||
"terminal": "Terminal",
|
||||
"security": "Security",
|
||||
"logout": "Log out",
|
||||
"navigation": "PBS navigation",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
"uptime": "Uptime"
|
||||
},
|
||||
"app": {
|
||||
"title": "ProxMenux Monitor",
|
||||
"description": "Proxmox System Dashboard",
|
||||
|
||||
@@ -1,4 +1,57 @@
|
||||
{
|
||||
"pbs": {
|
||||
"title": "Proxmox Backup Server",
|
||||
"server": "Servidor PBS",
|
||||
"chooseServer": "Selecciona un servidor PBS",
|
||||
"local": "PBS local",
|
||||
"refresh": "Actualizar",
|
||||
"manage": "Gestionar conexiones PBS",
|
||||
"loadFailed": "No se pudo consultar el estado de PBS.",
|
||||
"loading": "Cargando datos de PBS…",
|
||||
"noServer": "Añade una conexión PBS para ver datastores, tareas y programaciones.",
|
||||
"permissionsHelp": "Activa la autenticación de Monitor antes de guardar credenciales. Usa un token API dedicado de PBS con el rol Audit en / para el usuario y el token, con propagación. Este panel solo consulta datos de PBS.",
|
||||
"editConnection": "Conexión que deseas editar",
|
||||
"add": "Añadir una conexión",
|
||||
"name": "Nombre",
|
||||
"url": "URL HTTPS (incluye :8007 para el puerto nativo de PBS)",
|
||||
"token_id": "ID del token API (usuario@realm!token)",
|
||||
"token_secret": "Secreto del token API (vacío para conservar el guardado)",
|
||||
"fingerprint": "Huella SHA256 del certificado (opcional)",
|
||||
"tlsHelp": "Deja la huella vacía para verificar la CA y el nombre del servidor. Si el certificado es autofirmado, copia su huella SHA256 desde la consola de PBS u otro canal fiable. Se verifica antes de enviar las credenciales.",
|
||||
"saved": "Conexión PBS verificada y guardada.",
|
||||
"saveFailed": "No se pudo guardar la conexión PBS.",
|
||||
"testing": "Verificando conexión…",
|
||||
"save": "Verificar y guardar",
|
||||
"remove": "Eliminar conexión",
|
||||
"removeConfirm": "¿Eliminar de Monitor esta conexión PBS y su token guardado? Los datos y las tareas de PBS no se modifican.",
|
||||
"sampled": "Consulta realizada a las",
|
||||
"datastores": "Datastores",
|
||||
"total": "Capacidad del sistema de archivos",
|
||||
"used": "Usado",
|
||||
"free": "Disponible",
|
||||
"noDatastores": "No hay datastores accesibles. Revisa su configuración y los permisos del usuario y del token.",
|
||||
"tasks": "Tareas recientes",
|
||||
"noTasks": "No se han devuelto tareas recientes.",
|
||||
"task": "Tarea",
|
||||
"started": "Inicio",
|
||||
"status": "Estado",
|
||||
"running": "En ejecución",
|
||||
"jobs": "Programaciones de mantenimiento",
|
||||
"job": "Tarea",
|
||||
"schedule": "Programación",
|
||||
"sync": "Sincronización",
|
||||
"verify": "Verificación",
|
||||
"prune": "Prune",
|
||||
"overview": "Resumen",
|
||||
"logs": "Registros del sistema",
|
||||
"terminal": "Terminal",
|
||||
"security": "Seguridad",
|
||||
"logout": "Cerrar sesión",
|
||||
"navigation": "Navegación de PBS",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memoria",
|
||||
"uptime": "Tiempo de actividad"
|
||||
},
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values.",
|
||||
"app": {
|
||||
"title": "ProxMenux Monitor",
|
||||
|
||||
@@ -1115,6 +1115,9 @@ def _detect_proxmox_certificate_paths():
|
||||
return PROXMOX_CUSTOM_CERT_PATH, PROXMOX_CUSTOM_KEY_PATH
|
||||
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
|
||||
return PROXMOX_CERT_PATH, PROXMOX_KEY_PATH
|
||||
pbs_cert, pbs_key = "/etc/proxmox-backup/proxy.pem", "/etc/proxmox-backup/proxy.key"
|
||||
if os.path.isfile(pbs_cert) and os.path.isfile(pbs_key):
|
||||
return pbs_cert, pbs_key
|
||||
return "", ""
|
||||
|
||||
|
||||
|
||||
@@ -160,6 +160,9 @@ cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || ech
|
||||
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
|
||||
cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ temperature_sensor_resolver.py not found"
|
||||
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
|
||||
cp "$SCRIPT_DIR/pbs_manager.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/flask_pbs_routes.py" "$APP_DIR/usr/bin/"
|
||||
cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
|
||||
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
|
||||
cp "$SCRIPT_DIR/flask_security_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_security_routes.py not found"
|
||||
|
||||
@@ -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_auth_routes import auth_bp # noqa: E402
|
||||
from flask_proxmenux_routes import proxmenux_bp # noqa: E402
|
||||
from flask_pbs_routes import pbs_bp # noqa: E402
|
||||
from pbs_manager import get_platform, is_pbs_host # noqa: E402
|
||||
from flask_security_routes import security_bp # noqa: E402
|
||||
from flask_notification_routes import notification_bp # noqa: E402
|
||||
from flask_oci_routes import oci_bp # noqa: E402
|
||||
@@ -193,6 +195,7 @@ post_install_versions.scan_at_startup()
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(proxmenux_bp)
|
||||
app.register_blueprint(pbs_bp)
|
||||
app.register_blueprint(security_bp)
|
||||
app.register_blueprint(notification_bp)
|
||||
app.register_blueprint(oci_bp)
|
||||
@@ -3474,6 +3477,8 @@ def get_coral_info():
|
||||
def get_proxmox_version():
|
||||
"""Get Proxmox version if available. Cached for 6 hours."""
|
||||
global _system_info_cache
|
||||
if is_pbs_host():
|
||||
return get_platform().get('version')
|
||||
|
||||
now = time.time()
|
||||
if _system_info_cache['proxmox_version'] is not None and \
|
||||
@@ -9863,6 +9868,7 @@ def api_system():
|
||||
'cpu_cores': cpu_cores,
|
||||
'cpu_threads': cpu_threads,
|
||||
'proxmox_version': proxmox_version,
|
||||
'platform': get_platform(),
|
||||
'kernel_version': kernel_version,
|
||||
'available_updates': available_updates
|
||||
})
|
||||
|
||||
@@ -51,6 +51,7 @@ DEBUG_PERF = False
|
||||
# ─── Startup Grace Period ────────────────────────────────────────────────────
|
||||
# Import centralized startup grace management for consistent behavior
|
||||
import startup_grace
|
||||
from pbs_manager import get_platform, PBS_SERVICES
|
||||
|
||||
def _is_startup_health_grace() -> bool:
|
||||
"""Check if we're within the startup health grace period (5 min).
|
||||
@@ -466,6 +467,7 @@ class HealthMonitor:
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize health monitor with state tracking"""
|
||||
self.platform_type = get_platform().get("product")
|
||||
self.state_history = defaultdict(list)
|
||||
self.last_check_times = {}
|
||||
self.cached_results = {}
|
||||
@@ -919,9 +921,9 @@ class HealthMonitor:
|
||||
_perf_log("services", (time.time() - _t) * 1000)
|
||||
details['services'] = services_status
|
||||
if services_status['status'] == 'CRITICAL':
|
||||
critical_issues.append(f"PVE Services: {services_status.get('reason', 'Service failure')}")
|
||||
critical_issues.append(f"Proxmox Services: {services_status.get('reason', 'Service failure')}")
|
||||
elif services_status['status'] == 'WARNING':
|
||||
warning_issues.append(f"PVE Services: {services_status.get('reason', 'Service issue')}")
|
||||
warning_issues.append(f"Proxmox Services: {services_status.get('reason', 'Service issue')}")
|
||||
|
||||
# Priority 1.5: Proxmox Storage Check (External Module)
|
||||
_t = time.time()
|
||||
@@ -2347,6 +2349,8 @@ class HealthMonitor:
|
||||
# The new _check_proxmox_storage function handles this logic better.
|
||||
def _check_proxmox_storages(self) -> Dict[str, Any]:
|
||||
"""Check Proxmox-specific storages (only report problems)"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
|
||||
storages = {}
|
||||
|
||||
try:
|
||||
@@ -3515,6 +3519,8 @@ class HealthMonitor:
|
||||
Optimized VM/CT check - detects qmp failures and startup errors from logs.
|
||||
Improved detection of container and VM errors from journalctl.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
|
||||
try:
|
||||
# First: auto-resolve any persisted VM/CT errors where the guest
|
||||
# is now running. This clears stale "Failed to start" / QMP
|
||||
@@ -3678,6 +3684,8 @@ class HealthMonitor:
|
||||
Check VMs/CTs with persistent error tracking.
|
||||
Errors persist until VM starts or 48h elapsed.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
|
||||
try:
|
||||
issues = []
|
||||
vm_details = {}
|
||||
@@ -3919,8 +3927,18 @@ class HealthMonitor:
|
||||
"""
|
||||
try:
|
||||
# Build service list: base PVE services + corosync if clustered
|
||||
services_to_check = list(self.PVE_SERVICES)
|
||||
is_cluster = os.path.exists('/etc/corosync/corosync.conf')
|
||||
pbs_host = getattr(self, "platform_type", "pve") == "pbs"
|
||||
service_prefix = "pbs" if pbs_host else "pve"
|
||||
product_name = "PBS" if pbs_host else "PVE"
|
||||
services_to_check = list(PBS_SERVICES) if pbs_host else list(self.PVE_SERVICES)
|
||||
if pbs_host:
|
||||
# Old Monitor versions incorrectly treated absent PVE services
|
||||
# as PBS failures; remove only those known inapplicable errors.
|
||||
for service in self.PVE_SERVICES:
|
||||
old_key = f'pve_service_{service}'
|
||||
if health_persistence.is_error_active(old_key):
|
||||
health_persistence.clear_error(old_key)
|
||||
is_cluster = not pbs_host and os.path.exists('/etc/corosync/corosync.conf')
|
||||
if is_cluster and 'corosync' not in services_to_check:
|
||||
services_to_check.append('corosync')
|
||||
|
||||
@@ -3952,14 +3970,14 @@ class HealthMonitor:
|
||||
# cleanup loop). Audit Tier 5 — Health stack.
|
||||
for svc in services_to_check:
|
||||
if svc not in failed_services:
|
||||
error_key = f'pve_service_{svc}'
|
||||
error_key = f'{service_prefix}_service_{svc}'
|
||||
if health_persistence.is_error_active(error_key):
|
||||
health_persistence.clear_error(error_key)
|
||||
|
||||
# Build checks dict with status per service
|
||||
checks = {}
|
||||
for svc in services_to_check:
|
||||
error_key = f'pve_service_{svc}'
|
||||
error_key = f'{service_prefix}_service_{svc}'
|
||||
if svc in failed_services:
|
||||
state = service_details.get(svc, 'inactive')
|
||||
checks[svc] = {
|
||||
@@ -3987,12 +4005,12 @@ class HealthMonitor:
|
||||
# Record each failed service in persistence, respecting dismiss
|
||||
active_failed = []
|
||||
for svc in failed_services:
|
||||
error_key = f'pve_service_{svc}'
|
||||
error_key = f'{service_prefix}_service_{svc}'
|
||||
rec_result = health_persistence.record_error(
|
||||
error_key=error_key,
|
||||
category='pve_services',
|
||||
severity='CRITICAL',
|
||||
reason=f'PVE service {svc} is {service_details.get(svc, "inactive")}',
|
||||
reason=f'{product_name} service {svc} is {service_details.get(svc, "inactive")}',
|
||||
details={'service': svc, 'state': service_details.get(svc, 'inactive')}
|
||||
)
|
||||
if rec_result and rec_result.get('type') == 'skipped_acknowledged':
|
||||
@@ -5816,6 +5834,8 @@ class HealthMonitor:
|
||||
- No persistent errors are recorded
|
||||
This prevents false positives when NFS/PBS/remote storage is still mounting.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return None
|
||||
if not PROXMOX_STORAGE_AVAILABLE:
|
||||
return None
|
||||
|
||||
@@ -6205,6 +6225,8 @@ class HealthMonitor:
|
||||
Notification cooldown is the standard 1h via the storage
|
||||
category, so a CT teetering at 96% won't spam the channel.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return None
|
||||
# Cheap pre-check: bail out when there are no running LXCs. On
|
||||
# nodes without CTs, the previous version still paid ~860ms per
|
||||
# cycle for a `pvesh /cluster/resources` call that produced no
|
||||
@@ -6365,6 +6387,8 @@ class HealthMonitor:
|
||||
path, so a slow / dead guest agent can't stretch the health
|
||||
cycle.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return None
|
||||
try:
|
||||
import flask_server # deferred — avoids circular import
|
||||
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
||||
@@ -6524,6 +6548,8 @@ class HealthMonitor:
|
||||
check (already covered) because those are usually large data
|
||||
mounts whose fill rate is driven by user content, not log spam.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return None
|
||||
if not MOUNT_MONITOR_AVAILABLE:
|
||||
return None
|
||||
try:
|
||||
@@ -6627,6 +6653,8 @@ class HealthMonitor:
|
||||
check via _check_filesystem. Re-alerting on the same condition
|
||||
through two channels just clutters the inbox.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return None
|
||||
if not PROXMOX_STORAGE_AVAILABLE:
|
||||
return None
|
||||
try:
|
||||
|
||||
@@ -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.
|
||||
# Proxmox's own repository API parses .list/.sources and edits individual
|
||||
# entries. No shell rewriting of operator-maintained APT files.
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/platform-functions.sh"
|
||||
repository_policy() {
|
||||
python3 "$(dirname "${BASH_SOURCE[0]}")/repository_policy.py" "$@"
|
||||
}
|
||||
|
||||
ensure_repositories() {
|
||||
local version suite decision refresh_status
|
||||
if pmx_is_pbs; then
|
||||
if [[ "$(pmx_platform_field supported)" != true ]]; then
|
||||
msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')"
|
||||
return 1
|
||||
fi
|
||||
msg_info "$(translate 'PBS: using the configured APT repositories without changing sources.')"
|
||||
return 0
|
||||
fi
|
||||
version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
||||
case "$version" in
|
||||
8) suite=bookworm ;;
|
||||
|
||||
@@ -128,4 +128,8 @@ show_menu() {
|
||||
done
|
||||
}
|
||||
|
||||
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
|
||||
if pmx_is_pbs; then
|
||||
exec bash "$LOCAL_SCRIPTS/menus/pbs_menu.sh"
|
||||
fi
|
||||
show_menu
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
# PBS exposes compatible host tools; PVE guest/repository optimizations stay in the PVE menu.
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
LOCAL_SCRIPTS="$BASE_DIR/scripts"
|
||||
source "$BASE_DIR/utils.sh"
|
||||
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
if ! pmx_is_pbs; then
|
||||
msg_error "$(translate 'This menu requires Proxmox Backup Server.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pbs_show() {
|
||||
local output
|
||||
output=$(mktemp)
|
||||
if "$@" > "$output" 2>&1; then
|
||||
dialog --backtitle "ProxMenux · PBS" --title "Proxmox Backup Server" --textbox "$output" 24 100
|
||||
else
|
||||
dialog --backtitle "ProxMenux · PBS" --title "$(translate 'Command failed')" --textbox "$output" 24 100
|
||||
fi
|
||||
rm -f "$output"
|
||||
}
|
||||
|
||||
while true; do
|
||||
choice=$(dialog --clear --backtitle "ProxMenux · PBS" --title "Proxmox Backup Server" \
|
||||
--menu "$(translate 'Select an option:')" 20 80 10 \
|
||||
status "$(translate 'PBS version and packages')" \
|
||||
stores "$(translate 'PBS datastores')" \
|
||||
tasks "$(translate 'Recent PBS tasks')" \
|
||||
utilities "$(translate 'System Utilities Installer')" \
|
||||
update "$(translate 'Update PBS using configured repositories')" \
|
||||
settings "$(translate 'Settings')" \
|
||||
exit "$(translate 'Exit')" 2>&1 >/dev/tty) || break
|
||||
case "$choice" in
|
||||
status) pbs_show proxmox-backup-manager versions --verbose --output-format json-pretty ;;
|
||||
stores) pbs_show proxmox-backup-manager datastore list --output-format json-pretty ;;
|
||||
tasks) pbs_show proxmox-backup-manager task list --all true --limit 20 --output-format json-pretty ;;
|
||||
utilities) bash "$LOCAL_SCRIPTS/utilities/system_utils.sh" ;;
|
||||
update) bash "$LOCAL_SCRIPTS/utilities/pbs_update.sh" ;;
|
||||
settings) bash "$LOCAL_SCRIPTS/menus/config_menu.sh" ;;
|
||||
exit) break ;;
|
||||
esac
|
||||
done
|
||||
clear
|
||||
@@ -57,6 +57,11 @@ fi
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then
|
||||
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
|
||||
pmx_require_pve || exit 1
|
||||
fi
|
||||
|
||||
# Global variables
|
||||
OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
|
||||
RAM_SIZE_GB=$(awk '/^MemTotal:/{print int($2/1024/1024)}' /proc/meminfo)
|
||||
|
||||
@@ -51,6 +51,11 @@ fi
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then
|
||||
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
|
||||
pmx_require_pve || exit 1
|
||||
fi
|
||||
|
||||
# Load shared global functions
|
||||
if [[ -f "$LOCAL_SCRIPTS/global/common-functions.sh" ]]; then
|
||||
source "$LOCAL_SCRIPTS/global/common-functions.sh"
|
||||
|
||||
@@ -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