feat(monitor): sign in to a PBS with user and password and add a Tasks tab

- Connect a PBS with user and password, creating a read-only API token
- Never store the password; remove the token when the role cannot be granted
- Show the PBS connection form as a dialog with the project contrast and blue actions
- Move recent tasks and maintenance schedules of a PBS to a new Tasks tab
- Keep the Datastores tab for datastores, disks and pools
This commit is contained in:
MacRimi
2026-10-19 23:12:31 +02:00
parent cb585632c6
commit 77a6d1c397
13 changed files with 282 additions and 61 deletions
+77 -33
View File
@@ -12,6 +12,7 @@ import { Button } from "./ui/button"
import { Input } from "./ui/input" import { Input } from "./ui/input"
import { Label } from "./ui/label" import { Label } from "./ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage" import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider" import { useT } from "../lib/i18n/provider"
@@ -27,7 +28,8 @@ interface PBSStatus {
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>; sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
} }
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "" } const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "", username: "", password: "" }
type Method = "password" | "token"
const NEW_CONNECTION = "__new__" const NEW_CONNECTION = "__new__"
const badgeTone = { const badgeTone = {
ok: "bg-green-500/10 text-green-500 border-green-500/20", ok: "bg-green-500/10 text-green-500 border-green-500/20",
@@ -69,14 +71,17 @@ function formatDuration(seconds: number) {
} }
// `localOnly` is the PBS host's own Monitor: no connection picker, the // `localOnly` is the PBS host's own Monitor: no connection picker, the
// header Refresh already reloads the page data. // header Refresh already reloads the page data. Its tabs show one `view` each.
export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) { export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: boolean; view?: "all" | "datastores" | "tasks" } = {}) {
const showStores = view !== "tasks"
const showTasks = view !== "datastores"
const t = useT() const t = useT()
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>( const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false }) "/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
const [selected, setSelected] = useState("") const [selected, setSelected] = useState("")
const [manage, setManage] = useState(false) const [manage, setManage] = useState(false)
const [form, setForm] = useState(emptyForm) const [form, setForm] = useState(emptyForm)
const [method, setMethod] = useState<Method>("password")
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [message, setMessage] = useState("") const [message, setMessage] = useState("")
const [error, setError] = useState("") const [error, setError] = useState("")
@@ -91,16 +96,26 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
setSelected(connections.local_available ? "local" : connections.servers[0]?.id || "") setSelected(connections.local_available ? "local" : connections.servers[0]?.id || "")
}, [connections, selected]) }, [connections, selected])
const openManage = (next = emptyForm) => {
setForm(next); setMethod(next.id ? "token" : "password"); setError(""); setMessage(""); setManage(true)
}
const save = async () => { const save = async () => {
setBusy(true); setError(""); setMessage("") setBusy(true); setError(""); setMessage("")
try { try {
const result = await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({ // A password is sent once to create a read-only token; only the token is kept.
...form, id: form.id || undefined, token_secret: form.token_secret || undefined, const { username, password, ...connection } = form
const result = !form.id && method === "password"
? await fetchApi<{ id: string }>("/api/pbs/servers/login", { method: "POST", body: JSON.stringify({
name: form.name, url: form.url, fingerprint: form.fingerprint, username, password,
}) })
: await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
...connection, id: form.id || undefined, token_secret: form.token_secret || undefined,
}) }) }) })
await reloadConnections() await reloadConnections()
setSelected(result.id) setSelected(result.id)
setForm(emptyForm) setForm(emptyForm)
setMessage(t("pbs.saved")) setManage(false)
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) } } catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
finally { setBusy(false) } finally { setBusy(false) }
} }
@@ -130,7 +145,7 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
<Button variant="outline" size="sm" disabled={isLoading || !selected} onClick={() => mutate()}> <Button variant="outline" size="sm" disabled={isLoading || !selected} onClick={() => mutate()}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />{t("pbs.refresh")} <RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />{t("pbs.refresh")}
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => setManage(!manage)}> <Button variant="outline" size="sm" onClick={() => openManage()}>
<Settings2 className="mr-2 h-4 w-4" />{t("pbs.manage")} <Settings2 className="mr-2 h-4 w-4" />{t("pbs.manage")}
</Button> </Button>
</div>} </div>}
@@ -147,20 +162,26 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
<span className="block truncate text-sm font-medium text-foreground">{row.name}</span> <span className="block truncate text-sm font-medium text-foreground">{row.name}</span>
<span className="block break-all font-mono text-xs text-muted-foreground">{row.url}</span> <span className="block break-all font-mono text-xs text-muted-foreground">{row.url}</span>
</span> </span>
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700" onClick={() => { <Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700"
setForm({ ...emptyForm, name: row.name, url: row.url, fingerprint: row.fingerprint }) onClick={() => openManage({ ...emptyForm, name: row.name, url: row.url, fingerprint: row.fingerprint })}>
setError(""); setMessage(""); setManage(true) {t("pbs.connect")}
}}>{t("pbs.connect")}</Button> </Button>
</div>)} </div>)}
</CardContent> </CardContent>
</Card>} </Card>}
{manage && !localOnly && <Card> {!localOnly && <Dialog open={manage} onOpenChange={open => { if (!busy) setManage(open) }}>
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader> <DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
<CardContent className="space-y-4"> <DialogHeader>
<p className="text-sm text-muted-foreground">{t("pbs.permissionsHelp")}</p> <DialogTitle>{t("pbs.manage")}</DialogTitle>
<Select value={form.id || NEW_CONNECTION} onValueChange={value => { <DialogDescription>{method === "password" && !form.id ? t("pbs.passwordHelp") : t("pbs.permissionsHelp")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("pbs.editConnection")}</Label>
<Select value={form.id || NEW_CONNECTION} disabled={busy} onValueChange={value => {
const row = connections?.servers.find(item => item.id === value) const row = connections?.servers.find(item => item.id === value)
setForm(row ? { ...row, token_secret: "" } : emptyForm) setForm(row ? { ...emptyForm, ...row, token_secret: "" } : emptyForm)
setMethod(row ? "token" : "password"); setError("")
}}> }}>
<SelectTrigger aria-label={t("pbs.editConnection")} className="w-full"><SelectValue /></SelectTrigger> <SelectTrigger aria-label={t("pbs.editConnection")} className="w-full"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
@@ -168,35 +189,56 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)} {connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
{!form.id && <div className="space-y-2">
<Label>{t("pbs.signInWith")}</Label>
<div className="grid grid-cols-2 gap-2">
{(["password", "token"] as const).map(option =>
<Button key={option} type="button" variant="outline" disabled={busy} onClick={() => { setMethod(option); setError("") }}
className={method === option ? "border-blue-600 bg-blue-600/15 text-foreground hover:bg-blue-600/20" : "bg-background"}>
{t(option === "password" ? "pbs.methodPassword" : "pbs.methodToken")}
</Button>)}
</div>
</div>}
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field => {(["name", "url", ...(method === "password" && !form.id ? ["username", "password"] : ["token_id", "token_secret"]), "fingerprint"] as const).map(field => {
<div key={field} className="space-y-2"> const secret = field === "token_secret" || field === "password"
return <div key={field} className="space-y-2">
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label> <Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
<Input id={`pbs-${field}`} disabled={busy} type={field === "token_secret" ? "password" : field === "url" ? "url" : "text"} <Input id={`pbs-${field}`} disabled={busy} type={secret ? "password" : field === "url" ? "url" : "text"}
autoComplete={field === "token_secret" ? "new-password" : "off"} autoComplete={secret ? "new-password" : "off"} placeholder={field === "username" ? "root@pam" : undefined}
value={form[field]} onChange={event => setForm({ ...form, [field]: event.target.value })} /> value={form[field as keyof typeof form]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
</div>)} </div>
})}
</div> </div>
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p> <p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
{error && <p role="alert" className="text-sm text-red-500">{error}</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>} {message && <p role="status" className="text-sm text-green-500">{message}</p>}
<div className="flex gap-2"> <div className="flex flex-wrap justify-end gap-2">
<Button disabled={busy} onClick={save}>{busy ? t("pbs.testing") : t("pbs.save")}</Button> {form.id && <Button variant="outline" disabled={busy} className="mr-auto text-red-500 hover:text-red-500" onClick={remove}>
{form.id && <Button variant="destructive" disabled={busy} onClick={remove}>{t("pbs.remove")}</Button>} <Trash2 className="mr-2 h-4 w-4" />{t("pbs.remove")}
</Button>}
<Button variant="outline" disabled={busy} onClick={() => setManage(false)}>{t("pbs.cancel")}</Button>
<Button className="bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
disabled={busy || !form.url.trim() || !form.name.trim()} onClick={save}>
{busy ? t("pbs.testing") : t("pbs.save")}
</Button>
</div> </div>
</CardContent> </div>
</Card>} </DialogContent>
</Dialog>}
{data && <> {data && <>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> {!localOnly && <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })}</span> <span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })}</span>
{Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active") {Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active")
? <Badge variant="outline" className={badgeTone.ok}>{t("pbs.servicesActive")}</Badge> ? <Badge variant="outline" className={badgeTone.ok}>{t("pbs.servicesActive")}</Badge>
: Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) => : Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) =>
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {state}</Badge>)} <Badge key={service} variant="outline" className={badgeTone.error}>{service}: {state}</Badge>)}
</div> </div>}
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)} {Object.entries(data.errors).filter(([section]) => section === "datastores" ? showStores : showTasks)
.map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
<Card className="bg-card border-border"> {showStores && <Card className="bg-card border-border">
<CardHeader><CardTitle className="flex items-center text-foreground"><Database className="mr-2 h-5 w-5" />{t("pbs.datastores")}</CardTitle></CardHeader> <CardHeader><CardTitle className="flex items-center text-foreground"><Database className="mr-2 h-5 w-5" />{t("pbs.datastores")}</CardTitle></CardHeader>
<CardContent> <CardContent>
{data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>} {data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>}
@@ -204,8 +246,9 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
{data.datastores.map(store => <PBSDatastoreCard key={store.name} store={store} />)} {data.datastores.map(store => <PBSDatastoreCard key={store.name} store={store} />)}
</div>} </div>}
</CardContent> </CardContent>
</Card> </Card>}
{showTasks && <>
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader><CardTitle className="flex items-center text-foreground"><Activity className="mr-2 h-5 w-5" />{t("pbs.tasks")}</CardTitle></CardHeader> <CardHeader><CardTitle className="flex items-center text-foreground"><Activity className="mr-2 h-5 w-5" />{t("pbs.tasks")}</CardTitle></CardHeader>
<CardContent> <CardContent>
@@ -262,5 +305,6 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
</CardContent> </CardContent>
</Card> </Card>
</>} </>}
</>}
</div> </div>
} }
+13 -4
View File
@@ -32,6 +32,7 @@ import {
CheckCircle, CheckCircle,
XCircle, XCircle,
Server, Server,
ListChecks,
Menu, Menu,
LayoutDashboard, LayoutDashboard,
HardDrive, HardDrive,
@@ -116,11 +117,11 @@ function PBSDashboard() {
} }
type Product = "pve" | "pbs" type Product = "pve" | "pbs"
type TopTab = TabId | "datastores" type TopTab = TabId | "datastores" | "pbs-tasks"
// Proxmox Backup Server has no guests, apps or PVE storage: it keeps the // Proxmox Backup Server has no guests, apps or PVE storage: it keeps the
// same header, health and administration with its own top-level sections. // same header, health and administration with its own top-level sections.
const PBS_TABS: TopTab[] = ["overview", "datastores", "terminal", "admin"] const PBS_TABS: TopTab[] = ["overview", "datastores", "pbs-tasks", "terminal", "admin"]
function MonitorDashboard({ product }: { product: Product }) { function MonitorDashboard({ product }: { product: Product }) {
const t = useT() const t = useT()
@@ -421,6 +422,7 @@ function MonitorDashboard({ product }: { product: Product }) {
switch (activeTab) { switch (activeTab) {
case "overview": return t("navigation.overview") case "overview": return t("navigation.overview")
case "datastores": return t("pbs.datastores") case "datastores": return t("pbs.datastores")
case "pbs-tasks": return t("pbs.tasksTab")
case "apps": return t("navigation.apps") case "apps": return t("navigation.apps")
case "vms": return t("navigation.virtualMachines") case "vms": return t("navigation.virtualMachines")
case "storage": return t("navigation.storage") case "storage": return t("navigation.storage")
@@ -717,6 +719,7 @@ function MonitorDashboard({ product }: { product: Product }) {
switch (id) { switch (id) {
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview")) case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
case "datastores": return renderDirect("datastores", Database, t("pbs.datastores")) case "datastores": return renderDirect("datastores", Database, t("pbs.datastores"))
case "pbs-tasks": return renderDirect("pbs-tasks", ListChecks, t("pbs.tasksTab"))
case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps")) case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps"))
case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines")) case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup")) case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup"))
@@ -726,7 +729,7 @@ function MonitorDashboard({ product }: { product: Product }) {
} }
} }
return ( return (
<TabsList className={`hidden lg:grid w-full ${isPbs ? "grid-cols-4" : "grid-cols-7"} bg-card border border-border`}> <TabsList className={`hidden lg:grid w-full ${isPbs ? "grid-cols-5" : "grid-cols-7"} bg-card border border-border`}>
{tabOrder.map(renderTop)} {tabOrder.map(renderTop)}
</TabsList> </TabsList>
) )
@@ -788,6 +791,7 @@ function MonitorDashboard({ product }: { product: Product }) {
switch (id) { switch (id) {
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview")) case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
case "datastores": return btn("datastores", Database, t("pbs.datastores")) case "datastores": return btn("datastores", Database, t("pbs.datastores"))
case "pbs-tasks": return btn("pbs-tasks", ListChecks, t("pbs.tasksTab"))
case "apps": return btn("apps", Grid3x3, t("navigation.apps")) case "apps": return btn("apps", Grid3x3, t("navigation.apps"))
case "vms": return btn("vms", Boxes, t("navigation.virtualMachines")) case "vms": return btn("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return btn("backup", DatabaseBackup, t("navigation.backup")) case "backup": return btn("backup", DatabaseBackup, t("navigation.backup"))
@@ -842,10 +846,15 @@ function MonitorDashboard({ product }: { product: Product }) {
{isPbs && ( {isPbs && (
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0"> <TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
<PBSPanel key={`datastores-${componentKey}`} localOnly /> <PBSPanel key={`datastores-${componentKey}`} localOnly view="datastores" />
<StorageOverview key={`datastore-disks-${componentKey}`} disksOnly /> <StorageOverview key={`datastore-disks-${componentKey}`} disksOnly />
</TabsContent> </TabsContent>
)} )}
{isPbs && (
<TabsContent value="pbs-tasks" className="space-y-4 md:space-y-6 mt-0">
<PBSPanel key={`pbs-tasks-${componentKey}`} localOnly view="tasks" />
</TabsContent>
)}
{!isPbs && <> {!isPbs && <>
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0"> <TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">
+8
View File
@@ -7302,6 +7302,14 @@
"detectedTitle": "Im Proxmox-Speicher erkannte PBS", "detectedTitle": "Im Proxmox-Speicher erkannte PBS",
"detectedHelp": "Diese Proxmox Backup Server sind als Backup-Speicher eingerichtet. Verbinden Sie jeden mit einem schreibgeschützten API-Token, um hier seine Datastores, Aufgaben und Jobs zu sehen.", "detectedHelp": "Diese Proxmox Backup Server sind als Backup-Speicher eingerichtet. Verbinden Sie jeden mit einem schreibgeschützten API-Token, um hier seine Datastores, Aufgaben und Jobs zu sehen.",
"connect": "Verbinden", "connect": "Verbinden",
"tasksTab": "Aufgaben",
"signInWith": "Anmelden mit",
"methodPassword": "Benutzer und Passwort",
"methodToken": "API-Token",
"username": "Benutzer (benutzer@realm)",
"password": "Passwort",
"passwordHelp": "Das Passwort wird nur einmal verwendet, um ein schreibgeschütztes API-Token (Rolle Audit) für diesen Monitor zu erstellen. Gespeichert wird nur dieses Token, nie das Passwort. Aktivieren Sie zuerst die Monitor-Authentifizierung.",
"cancel": "Abbrechen",
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
"manage": "PBS-Verbindungen verwalten", "manage": "PBS-Verbindungen verwalten",
"loadFailed": "PBS-Status konnte nicht geladen werden.", "loadFailed": "PBS-Status konnte nicht geladen werden.",
+8
View File
@@ -10,6 +10,14 @@
"detectedTitle": "PBS detected in Proxmox storage", "detectedTitle": "PBS detected in Proxmox storage",
"detectedHelp": "These Proxmox Backup Servers are configured as backup storage. Connect each one with a read-only API token to see its datastores, tasks and jobs here.", "detectedHelp": "These Proxmox Backup Servers are configured as backup storage. Connect each one with a read-only API token to see its datastores, tasks and jobs here.",
"connect": "Connect", "connect": "Connect",
"tasksTab": "Tasks",
"signInWith": "Sign in with",
"methodPassword": "User and password",
"methodToken": "API token",
"username": "User (user@realm)",
"password": "Password",
"passwordHelp": "The password is used once to create a read-only API token (Audit role) for this Monitor. Only that token is saved, never the password. Enable Monitor authentication first.",
"cancel": "Cancel",
"refresh": "Refresh", "refresh": "Refresh",
"manage": "Manage PBS connections", "manage": "Manage PBS connections",
"loadFailed": "Could not load PBS status.", "loadFailed": "Could not load PBS status.",
+8
View File
@@ -10,6 +10,14 @@
"detectedTitle": "PBS detectados en el almacenamiento de Proxmox", "detectedTitle": "PBS detectados en el almacenamiento de Proxmox",
"detectedHelp": "Estos Proxmox Backup Server están configurados como almacenamiento de backups. Conecta cada uno con un token API de solo lectura para ver aquí sus datastores, tareas y trabajos.", "detectedHelp": "Estos Proxmox Backup Server están configurados como almacenamiento de backups. Conecta cada uno con un token API de solo lectura para ver aquí sus datastores, tareas y trabajos.",
"connect": "Conectar", "connect": "Conectar",
"tasksTab": "Tareas",
"signInWith": "Iniciar sesión con",
"methodPassword": "Usuario y contraseña",
"methodToken": "Token API",
"username": "Usuario (usuario@realm)",
"password": "Contraseña",
"passwordHelp": "La contraseña se usa una sola vez para crear un token API de solo lectura (rol Audit) para este Monitor. Solo se guarda ese token, nunca la contraseña. Activa antes la autenticación del Monitor.",
"cancel": "Cancelar",
"refresh": "Actualizar", "refresh": "Actualizar",
"manage": "Gestionar conexiones PBS", "manage": "Gestionar conexiones PBS",
"loadFailed": "No se pudo consultar el estado de PBS.", "loadFailed": "No se pudo consultar el estado de PBS.",
+8
View File
@@ -7302,6 +7302,14 @@
"detectedTitle": "PBS détectés dans le stockage Proxmox", "detectedTitle": "PBS détectés dans le stockage Proxmox",
"detectedHelp": "Ces Proxmox Backup Server sont configurés comme stockage de sauvegarde. Connectez chacun avec un jeton API en lecture seule pour voir ici ses datastores, tâches et travaux.", "detectedHelp": "Ces Proxmox Backup Server sont configurés comme stockage de sauvegarde. Connectez chacun avec un jeton API en lecture seule pour voir ici ses datastores, tâches et travaux.",
"connect": "Connecter", "connect": "Connecter",
"tasksTab": "Tâches",
"signInWith": "Se connecter avec",
"methodPassword": "Utilisateur et mot de passe",
"methodToken": "Jeton API",
"username": "Utilisateur (utilisateur@realm)",
"password": "Mot de passe",
"passwordHelp": "Le mot de passe n'est utilisé qu'une fois pour créer un jeton API en lecture seule (rôle Audit) pour ce Monitor. Seul ce jeton est enregistré, jamais le mot de passe. Activez d'abord l'authentification du Monitor.",
"cancel": "Annuler",
"refresh": "Actualiser", "refresh": "Actualiser",
"manage": "Gérer les connexions PBS", "manage": "Gérer les connexions PBS",
"loadFailed": "Impossible de charger l'état de PBS.", "loadFailed": "Impossible de charger l'état de PBS.",
+8
View File
@@ -7302,6 +7302,14 @@
"detectedTitle": "PBS rilevati nello storage di Proxmox", "detectedTitle": "PBS rilevati nello storage di Proxmox",
"detectedHelp": "Questi Proxmox Backup Server sono configurati come storage di backup. Collega ciascuno con un token API di sola lettura per vedere qui i suoi datastore, attività e job.", "detectedHelp": "Questi Proxmox Backup Server sono configurati come storage di backup. Collega ciascuno con un token API di sola lettura per vedere qui i suoi datastore, attività e job.",
"connect": "Collega", "connect": "Collega",
"tasksTab": "Attività",
"signInWith": "Accedi con",
"methodPassword": "Utente e password",
"methodToken": "Token API",
"username": "Utente (utente@realm)",
"password": "Password",
"passwordHelp": "La password viene usata una sola volta per creare un token API di sola lettura (ruolo Audit) per questo Monitor. Viene salvato solo quel token, mai la password. Attiva prima l'autenticazione del Monitor.",
"cancel": "Annulla",
"refresh": "Aggiorna", "refresh": "Aggiorna",
"manage": "Gestisci le connessioni PBS", "manage": "Gestisci le connessioni PBS",
"loadFailed": "Impossibile caricare lo stato di PBS.", "loadFailed": "Impossibile caricare lo stato di PBS.",
+8
View File
@@ -7302,6 +7302,14 @@
"detectedTitle": "PBS detetados no armazenamento do Proxmox", "detectedTitle": "PBS detetados no armazenamento do Proxmox",
"detectedHelp": "Estes Proxmox Backup Server estão configurados como armazenamento de backups. Ligue cada um com um token API só de leitura para ver aqui os seus datastores, tarefas e trabalhos.", "detectedHelp": "Estes Proxmox Backup Server estão configurados como armazenamento de backups. Ligue cada um com um token API só de leitura para ver aqui os seus datastores, tarefas e trabalhos.",
"connect": "Ligar", "connect": "Ligar",
"tasksTab": "Tarefas",
"signInWith": "Iniciar sessão com",
"methodPassword": "Utilizador e palavra-passe",
"methodToken": "Token API",
"username": "Utilizador (utilizador@realm)",
"password": "Palavra-passe",
"passwordHelp": "A palavra-passe é usada uma única vez para criar um token API só de leitura (função Audit) para este Monitor. Só esse token é guardado, nunca a palavra-passe. Ative primeiro a autenticação do Monitor.",
"cancel": "Cancelar",
"refresh": "Atualizar", "refresh": "Atualizar",
"manage": "Gerir ligações PBS", "manage": "Gerir ligações PBS",
"loadFailed": "Não foi possível carregar o estado do PBS.", "loadFailed": "Não foi possível carregar o estado do PBS.",
+8
View File
@@ -7301,6 +7301,14 @@
"detectedTitle": "PBS zistené v úložisku Proxmox", "detectedTitle": "PBS zistené v úložisku Proxmox",
"detectedHelp": "Tieto Proxmox Backup Servery sú nastavené ako úložisko záloh. Pripojte každý z nich API tokenom len na čítanie, aby ste tu videli jeho datastory, úlohy a joby.", "detectedHelp": "Tieto Proxmox Backup Servery sú nastavené ako úložisko záloh. Pripojte každý z nich API tokenom len na čítanie, aby ste tu videli jeho datastory, úlohy a joby.",
"connect": "Pripojiť", "connect": "Pripojiť",
"tasksTab": "Úlohy",
"signInWith": "Prihlásiť sa pomocou",
"methodPassword": "Používateľ a heslo",
"methodToken": "API token",
"username": "Používateľ (pouzivatel@realm)",
"password": "Heslo",
"passwordHelp": "Heslo sa použije iba raz na vytvorenie API tokenu len na čítanie (rola Audit) pre tento Monitor. Uloží sa iba tento token, nikdy nie heslo. Najprv zapnite overovanie v Monitore.",
"cancel": "Zrušiť",
"refresh": "Obnoviť", "refresh": "Obnoviť",
"manage": "Spravovať pripojenia PBS", "manage": "Spravovať pripojenia PBS",
"loadFailed": "Nepodarilo sa načítať stav PBS.", "loadFailed": "Nepodarilo sa načítať stav PBS.",
+8
View File
@@ -7302,6 +7302,14 @@
"detectedTitle": "PBS som hittats i Proxmox-lagringen", "detectedTitle": "PBS som hittats i Proxmox-lagringen",
"detectedHelp": "Dessa Proxmox Backup Server är konfigurerade som backuplagring. Anslut var och en med en skrivskyddad API-token för att se deras datastores, uppgifter och jobb här.", "detectedHelp": "Dessa Proxmox Backup Server är konfigurerade som backuplagring. Anslut var och en med en skrivskyddad API-token för att se deras datastores, uppgifter och jobb här.",
"connect": "Anslut", "connect": "Anslut",
"tasksTab": "Uppgifter",
"signInWith": "Logga in med",
"methodPassword": "Användare och lösenord",
"methodToken": "API-token",
"username": "Användare (användare@realm)",
"password": "Lösenord",
"passwordHelp": "Lösenordet används en gång för att skapa en skrivskyddad API-token (rollen Audit) för den här Monitorn. Bara den token sparas, aldrig lösenordet. Aktivera först autentiseringen i Monitor.",
"cancel": "Avbryt",
"refresh": "Uppdatera", "refresh": "Uppdatera",
"manage": "Hantera PBS-anslutningar", "manage": "Hantera PBS-anslutningar",
"loadFailed": "Det gick inte att läsa in PBS-status.", "loadFailed": "Det gick inte att läsa in PBS-status.",
+14
View File
@@ -67,6 +67,20 @@ def save_server():
return jsonify(error="Could not save PBS connection."), 500 return jsonify(error="Could not save PBS connection."), 500
@pbs_bp.route("/api/pbs/servers/login", methods=["POST"])
@require_admin_scope
def connect_with_password():
if not _protected_configuration():
return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
try:
identifier = pbs.connect_with_password(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"]) @pbs_bp.route("/api/pbs/servers/<identifier>", methods=["DELETE"])
@require_admin_scope @require_admin_scope
def delete_server(identifier): def delete_server(identifier):
+59 -9
View File
@@ -17,7 +17,7 @@ import subprocess
import tempfile import tempfile
import threading import threading
import time import time
from urllib.parse import urlsplit from urllib.parse import quote, urlencode, urlsplit
import auth_manager import auth_manager
@@ -111,12 +111,11 @@ def public_servers():
for row in _load_servers()] for row in _load_servers()]
def _validate_server(data, previous=None): def _validate_server(data, previous=None, fields=("name", "url", "token_id", "token_secret", "fingerprint")):
if not isinstance(data, dict): if not isinstance(data, dict):
raise PBSError("Invalid PBS server configuration.") raise PBSError("Invalid PBS server configuration.")
previous = previous or {} previous = previous or {}
row = {field: data.get(field, previous.get(field, "")) row = {field: data.get(field, previous.get(field, "")) for field in fields}
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()): if any(not isinstance(value, str) or len(value) > 4096 for value in row.values()):
raise PBSError("Invalid PBS connection field.") raise PBSError("Invalid PBS connection field.")
row["name"] = row["name"].strip() row["name"] = row["name"].strip()
@@ -131,6 +130,8 @@ def _validate_server(data, previous=None):
raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.") from 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: if not row["name"] or len(row["name"]) > 128:
raise PBSError("A server name of at most 128 characters is required.") raise PBSError("A server name of at most 128 characters is required.")
if "token_id" not in row:
return _validated_fingerprint(row)
row["token_id"] = row["token_id"].strip() row["token_id"] = row["token_id"].strip()
if not re.fullmatch(r"[^\s:!]+@[^\s:!]+![^\s:!]+", row["token_id"]): if not re.fullmatch(r"[^\s:!]+@[^\s:!]+![^\s:!]+", row["token_id"]):
raise PBSError("Enter a PBS API token ID in user@realm!token format.") raise PBSError("Enter a PBS API token ID in user@realm!token format.")
@@ -139,6 +140,10 @@ def _validate_server(data, previous=None):
raise PBSError("A valid API token secret is required.") raise PBSError("A valid API token secret is required.")
if not row["token_id"].isascii(): if not row["token_id"].isascii():
raise PBSError("Invalid API token ID.") raise PBSError("Invalid API token ID.")
return _validated_fingerprint(row)
def _validated_fingerprint(row):
fingerprint = row["fingerprint"].strip().lower().replace(":", "") fingerprint = row["fingerprint"].strip().lower().replace(":", "")
if fingerprint and not re.fullmatch("[a-f0-9]{64}", fingerprint): if fingerprint and not re.fullmatch("[a-f0-9]{64}", fingerprint):
raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).") raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).")
@@ -148,6 +153,13 @@ def _validate_server(data, previous=None):
def _remote_get(server, endpoint): def _remote_get(server, endpoint):
"""Validate the TLS peer BEFORE transmitting the PBS token (no redirects).""" """Validate the TLS peer BEFORE transmitting the PBS token (no redirects)."""
return _remote_request(server, "GET", endpoint, {
"Authorization": "PBSAPIToken=" + server["token_id"] + ":" + server["token_secret"]})
def _remote_request(server, method, endpoint, headers, form=None,
rejected="PBS rejected the API token or its permissions. Check both the user and token ACLs."):
"""One request to PBS; credentials leave only after the certificate is checked."""
parts = urlsplit(server["url"]) parts = urlsplit(server["url"])
fingerprint = server.get("fingerprint") fingerprint = server.get("fingerprint")
if fingerprint: if fingerprint:
@@ -165,14 +177,15 @@ def _remote_get(server, endpoint):
if not hmac.compare_digest(actual, fingerprint): if not hmac.compare_digest(actual, fingerprint):
raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.") raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.")
path = parts.path.rstrip("/") + "/api2/json" + endpoint path = parts.path.rstrip("/") + "/api2/json" + endpoint
connection.request("GET", path, headers={ body = urlencode(form) if form is not None else None
"Accept": "application/json", request_headers = {"Accept": "application/json", **headers}
"Authorization": "PBSAPIToken=" + server["token_id"] + ":" + server["token_secret"], if body is not None:
}) request_headers["Content-Type"] = "application/x-www-form-urlencoded"
connection.request(method, path, body=body, headers=request_headers)
response = connection.getresponse() response = connection.getresponse()
content = response.read(MAX_RESPONSE + 1) content = response.read(MAX_RESPONSE + 1)
if response.status in (401, 403): if response.status in (401, 403):
raise PBSError("PBS rejected the API token or its permissions. Check both the user and token ACLs.") raise PBSError(rejected)
if response.status != 200 or len(content) > MAX_RESPONSE: if response.status != 200 or len(content) > MAX_RESPONSE:
raise PBSError("PBS returned an invalid response. Redirects are not followed.") raise PBSError("PBS returned an invalid response. Redirects are not followed.")
document = json.loads(content) document = json.loads(content)
@@ -208,6 +221,43 @@ def save_server(data):
return row["id"] return row["id"]
def connect_with_password(data):
"""Sign in once and create a read-only token for this Monitor. The password
is used for that sign-in only and is never stored."""
endpoint = _validate_server(data, fields=("name", "url", "fingerprint"))
username = data.get("username") if isinstance(data, dict) else None
password = data.get("password") if isinstance(data, dict) else None
if not isinstance(username, str) or not re.fullmatch(r"[^\s:!@]+@[^\s:!@]+", username.strip()):
raise PBSError("Enter the PBS user as user@realm, for example root@pam.")
if not isinstance(password, str) or not password or len(password) > 4096:
raise PBSError("Enter the password of the PBS user.")
ticket = _remote_request(endpoint, "POST", "/access/ticket", {},
{"username": username.strip(), "password": password},
rejected="PBS rejected the user name or password.")
if not isinstance(ticket, dict) or not ticket.get("ticket") or not ticket.get("CSRFPreventionToken"):
raise PBSError("PBS returned an invalid sign-in response.")
session = {"Cookie": "PBSAuthCookie=" + quote(ticket["ticket"], safe=""),
"CSRFPreventionToken": ticket["CSRFPreventionToken"]}
user = str(ticket.get("username") or username.strip())
name = "proxmenux-" + secrets.token_hex(4)
token_path = f"/access/users/{quote(user, safe='')}/token/{name}"
created = _remote_request(endpoint, "POST", token_path, session, {"comment": "ProxMenux Monitor, read-only"},
rejected="This PBS user cannot create API tokens.")
if not isinstance(created, dict) or not created.get("tokenid") or not created.get("value"):
raise PBSError("PBS returned an invalid API token.")
try:
_remote_request(endpoint, "PUT", "/access/acl", session,
{"path": "/", "role": "Audit", "auth-id": created["tokenid"], "propagate": "1"},
rejected="This PBS user cannot grant the Audit role. Sign in with root@pam or an administrator.")
return save_server({**endpoint, "token_id": created["tokenid"], "token_secret": created["value"]})
except PBSError:
try:
_remote_request(endpoint, "DELETE", token_path, session)
except PBSError:
pass
raise
def delete_server(identifier): def delete_server(identifier):
with _lock: with _lock:
servers = _load_servers() servers = _load_servers()
+40
View File
@@ -199,6 +199,46 @@ class PBSManagerTests(unittest.TestCase):
self.assertEqual(response.json["detected"], self.assertEqual(response.json["detected"],
[{"name": "Lab", "url": "https://10.0.0.49:8007", "fingerprint": "AA:BB"}]) [{"name": "Lab", "url": "https://10.0.0.49:8007", "fingerprint": "AA:BB"}])
def test_password_sign_in_keeps_only_a_read_only_token(self):
calls = []
def request(server, method, endpoint, headers, form=None, rejected=""):
calls.append((method, endpoint, form))
if endpoint == "/access/ticket":
return {"ticket": "PBS:root@pam:ticket", "CSRFPreventionToken": "csrf", "username": "root@pam"}
if "/token/" in endpoint and method == "POST":
self.assertEqual(headers["CSRFPreventionToken"], "csrf")
return {"tokenid": "root@pam!proxmenux-1", "value": "generated-secret"}
return None
login = {"name": "Lab", "url": "https://pbs.test:8007", "fingerprint": "a" * 64,
"username": "root@pam", "password": "fixture-password"}
with mock.patch.object(pbs, "_remote_request", side_effect=request), \
mock.patch.object(pbs, "_remote_get", return_value=[]):
identifier = pbs.connect_with_password(login)
self.assertIn(("PUT", "/access/acl", {"path": "/", "role": "Audit", "auth-id": "root@pam!proxmenux-1", "propagate": "1"}),
calls)
stored = (self.root / "pbs_servers.json").read_text()
self.assertNotIn("fixture-password", stored)
self.assertIn("generated-secret", stored)
self.assertEqual(pbs.public_servers()[0]["id"], identifier)
def test_password_sign_in_removes_its_token_when_the_role_cannot_be_granted(self):
calls = []
def request(server, method, endpoint, headers, form=None, rejected=""):
calls.append((method, endpoint))
if endpoint == "/access/ticket":
return {"ticket": "t", "CSRFPreventionToken": "c"}
if method == "POST":
return {"tokenid": "audit@pbs!proxmenux-1", "value": "secret"}
if method == "PUT":
raise pbs.PBSError(rejected)
return None
with mock.patch.object(pbs, "_remote_request", side_effect=request):
with self.assertRaisesRegex(pbs.PBSError, "Audit"):
pbs.connect_with_password({"name": "Lab", "url": "https://pbs.test:8007", "fingerprint": "",
"username": "audit@pbs", "password": "x"})
self.assertEqual(calls[-1][0], "DELETE")
self.assertEqual(pbs.public_servers(), [])
def test_connection_api_requires_full_admin_and_does_not_return_secret(self): def test_connection_api_requires_full_admin_and_does_not_return_secret(self):
self.assertEqual(self.client.get("/api/pbs/servers").status_code, 401) self.assertEqual(self.client.get("/api/pbs/servers").status_code, 401)
with mock.patch.object(pbs, "_remote_get", return_value=[]): with mock.patch.object(pbs, "_remote_get", return_value=[]):