From 511bfe52ea22c5374d1ee4fa7b6b3b7b7cb87fd9 Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:46:26 +0200 Subject: [PATCH] feat: support PBS hosts and remote PBS monitoring --- .../tests/test_command_descriptions.py | 3 + .github/workflows/test-pbs.yml | 63 ++++ AppImage/components/backup-tab.tsx | 8 +- AppImage/components/pbs-dashboard.tsx | 44 +++ AppImage/components/pbs-panel.tsx | 149 +++++++++ AppImage/components/proxmox-dashboard.tsx | 9 + AppImage/components/security.tsx | 13 +- AppImage/messages/en/common.json | 53 ++++ AppImage/messages/es/common.json | 53 ++++ AppImage/scripts/auth_manager.py | 3 + AppImage/scripts/build_appimage.sh | 3 + AppImage/scripts/flask_pbs_routes.py | 71 +++++ AppImage/scripts/flask_server.py | 6 + AppImage/scripts/health_monitor.py | 44 ++- AppImage/scripts/pbs_manager.py | 288 +++++++++++++++++ AppImage/scripts/tests/test_pbs.py | 289 ++++++++++++++++++ AppImage/tests/pbs-navigation.cjs | 26 ++ docs/pbs.md | 66 ++++ scripts/global/platform-functions.sh | 16 + scripts/global/proxmox_platform.py | 56 ++++ scripts/global/repository-functions.sh | 9 + scripts/menus/main_menu.sh | 4 + scripts/menus/pbs_menu.sh | 46 +++ scripts/post_install/auto_post_install.sh | 5 + .../post_install/customizable_post_install.sh | 5 + scripts/utilities/pbs_update.sh | 38 +++ 26 files changed, 1355 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/test-pbs.yml create mode 100644 AppImage/components/pbs-dashboard.tsx create mode 100644 AppImage/components/pbs-panel.tsx create mode 100644 AppImage/scripts/flask_pbs_routes.py create mode 100644 AppImage/scripts/pbs_manager.py create mode 100644 AppImage/scripts/tests/test_pbs.py create mode 100644 AppImage/tests/pbs-navigation.cjs create mode 100644 docs/pbs.md create mode 100644 scripts/global/platform-functions.sh create mode 100644 scripts/global/proxmox_platform.py create mode 100644 scripts/menus/pbs_menu.sh create mode 100644 scripts/utilities/pbs_update.sh diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index ec35c3b2..1976095f 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -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")): diff --git a/.github/workflows/test-pbs.yml b/.github/workflows/test-pbs.yml new file mode 100644 index 00000000..2d60623c --- /dev/null +++ b/.github/workflows/test-pbs.yml @@ -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 diff --git a/AppImage/components/backup-tab.tsx b/AppImage/components/backup-tab.tsx index ae62e19c..e411f6f6 100644 --- a/AppImage/components/backup-tab.tsx +++ b/AppImage/components/backup-tab.tsx @@ -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) => ( ))} @@ -52,6 +53,7 @@ export function BackupTab() { {view === "guests" && } + {view === "pbs" && } ) } diff --git a/AppImage/components/pbs-dashboard.tsx b/AppImage/components/pbs-dashboard.tsx new file mode 100644 index 00000000..fd90b1b6 --- /dev/null +++ b/AppImage/components/pbs-dashboard.tsx @@ -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("/api/system", url => fetchApi(url), { refreshInterval: 10000 }) + return
+
+

ProxMenux · PBS

{data?.hostname || t("pbs.loading")}

+ + +
+ + {tab === "overview" && <> +
+ {[[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]) => +

{label}

{value}

)} +
+ + } + {tab === "logs" && } + {tab === "terminal" && } + {tab === "security" && } +
+} diff --git a/AppImage/components/pbs-panel.tsx b/AppImage/components/pbs-panel.tsx new file mode 100644 index 00000000..fb1354eb --- /dev/null +++ b/AppImage/components/pbs-panel.tsx @@ -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; + datastores: Datastore[] | null; tasks: Task[] | null; + sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record; +} + +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( + "/api/pbs/servers", url => fetchApi(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( + selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null, + url => fetchApi(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
+
+

{t("pbs.title")}

+ + + +
+ {(connectionError || statusError) &&

+ {String((connectionError || statusError)?.message || t("pbs.loadFailed"))}

} + {isLoading &&

{t("pbs.loading")}

} + {!selected && !connectionError && connections &&

{t("pbs.noServer")}

} + {manage && + {t("pbs.manage")} + +

{t("pbs.permissionsHelp")}

+ +
+ {(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field => +
+ + setForm({ ...form, [field]: event.target.value })} /> +
)} +
+

{t("pbs.tlsHelp")}

+ {error &&

{error}

} + {message &&

{message}

} +
+ + {form.id && } +
+
+
} + {data && <> +

{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}

+ {Object.keys(data.services).length > 0 &&
+ {Object.entries(data.services).map(([service, state]) => {service}: {state})} +
} + {Object.entries(data.errors).map(([section, detail]) =>

{t(`pbs.${section}`)}: {detail}

)} + {t("pbs.datastores")} + {data.datastores?.length === 0 &&

{t("pbs.noDatastores")}

} + {data.datastores && data.datastores.length > 0 &&
+ + {data.datastores.map(row => )} +
{t("pbs.name")}{t("pbs.total")}{t("pbs.used")}{t("pbs.free")}
{row.name}{row.error &&

{row.error}

}
{capacity(row.total)}{capacity(row.used)}{capacity(row.avail)}
} +
+ {t("pbs.tasks")} + {data.tasks?.length === 0 &&

{t("pbs.noTasks")}

} + {data.tasks && data.tasks.length > 0 &&
+ + {data.tasks.map(row => )} +
{t("pbs.task")}{t("pbs.started")}{t("pbs.status")}
{row.worker_type} {row.worker_id}{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}{row.status || (row.endtime ? "—" : t("pbs.running"))}
} +
+ {t("pbs.jobs")} +
+ + {(["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => + ))} +
{t("pbs.job")}{t("pbs.datastores")}{t("pbs.schedule")}{t("pbs.status")}
{t(`pbs.${kind}`)} · {row.id}{row.store || "—"}{row.schedule || "—"}{row["last-run-state"] || "—"}
+
+ } +
+} diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index c09ea165..679c9aaf 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -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

{t("app.loading")}

+ if (data?.product === "pbs") return + return +} + +function PVEDashboard() { const t = useT() useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false }) useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false }) diff --git a/AppImage/components/security.tsx b/AppImage/components/security.tsx index 35de9bba..9ad921ca 100644 --- a/AppImage/components/security.tsx +++ b/AppImage/components/security.tsx @@ -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) => 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" && <>

Proxmox VE

@@ -4833,6 +4836,8 @@ ${(report.sections && report.sections.length > 0) ? `
)} + } + setShow2FASetup(false)} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index ca7e536d..d038616a 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -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", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 166bf6a5..ed316824 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -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", diff --git a/AppImage/scripts/auth_manager.py b/AppImage/scripts/auth_manager.py index dbfb96ae..c405fb5d 100644 --- a/AppImage/scripts/auth_manager.py +++ b/AppImage/scripts/auth_manager.py @@ -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 "", "" diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index b0df1302..a932ec90 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -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" diff --git a/AppImage/scripts/flask_pbs_routes.py b/AppImage/scripts/flask_pbs_routes.py new file mode 100644 index 00000000..4e41936f --- /dev/null +++ b/AppImage/scripts/flask_pbs_routes.py @@ -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/", 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 diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 13da0971..7ae21e38 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -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 }) diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py index 23b6d7f1..f7dbe5ad 100644 --- a/AppImage/scripts/health_monitor.py +++ b/AppImage/scripts/health_monitor.py @@ -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: diff --git a/AppImage/scripts/pbs_manager.py b/AppImage/scripts/pbs_manager.py new file mode 100644 index 00000000..83eedac8 --- /dev/null +++ b/AppImage/scripts/pbs_manager.py @@ -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 diff --git a/AppImage/scripts/tests/test_pbs.py b/AppImage/scripts/tests/test_pbs.py new file mode 100644 index 00000000..878debc7 --- /dev/null +++ b/AppImage/scripts/tests/test_pbs.py @@ -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=[]), '', '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() diff --git a/AppImage/tests/pbs-navigation.cjs b/AppImage/tests/pbs-navigation.cjs new file mode 100644 index 00000000..5ceac8dc --- /dev/null +++ b/AppImage/tests/pbs-navigation.cjs @@ -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') diff --git a/docs/pbs.md b/docs/pbs.md new file mode 100644 index 00000000..542f0472 --- /dev/null +++ b/docs/pbs.md @@ -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). diff --git a/scripts/global/platform-functions.sh b/scripts/global/platform-functions.sh new file mode 100644 index 00000000..1bfb3e49 --- /dev/null +++ b/scripts/global/platform-functions.sh @@ -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 +} diff --git a/scripts/global/proxmox_platform.py b/scripts/global/proxmox_platform.py new file mode 100644 index 00000000..f6744f43 --- /dev/null +++ b/scripts/global/proxmox_platform.py @@ -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() diff --git a/scripts/global/repository-functions.sh b/scripts/global/repository-functions.sh index 32dd8364..d6114085 100644 --- a/scripts/global/repository-functions.sh +++ b/scripts/global/repository-functions.sh @@ -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 ;; diff --git a/scripts/menus/main_menu.sh b/scripts/menus/main_menu.sh index fb3d806d..69e81bd3 100644 --- a/scripts/menus/main_menu.sh +++ b/scripts/menus/main_menu.sh @@ -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 diff --git a/scripts/menus/pbs_menu.sh b/scripts/menus/pbs_menu.sh new file mode 100644 index 00000000..4a04c2a4 --- /dev/null +++ b/scripts/menus/pbs_menu.sh @@ -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 diff --git a/scripts/post_install/auto_post_install.sh b/scripts/post_install/auto_post_install.sh index a158942d..25e6289f 100644 --- a/scripts/post_install/auto_post_install.sh +++ b/scripts/post_install/auto_post_install.sh @@ -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) diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh index 49ed0f3c..93db9dc1 100644 --- a/scripts/post_install/customizable_post_install.sh +++ b/scripts/post_install/customizable_post_install.sh @@ -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" diff --git a/scripts/utilities/pbs_update.sh b/scripts/utilities/pbs_update.sh new file mode 100644 index 00000000..05a453eb --- /dev/null +++ b/scripts/utilities/pbs_update.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 _