mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 02:12:12 +00:00
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:
@@ -12,6 +12,7 @@ import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
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 { fetchApi } from "../lib/api-config"
|
||||
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>;
|
||||
}
|
||||
|
||||
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 badgeTone = {
|
||||
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
|
||||
// header Refresh already reloads the page data.
|
||||
export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
// header Refresh already reloads the page data. Its tabs show one `view` each.
|
||||
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 { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
|
||||
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
|
||||
const [selected, setSelected] = useState("")
|
||||
const [manage, setManage] = useState(false)
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [method, setMethod] = useState<Method>("password")
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = 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 || "")
|
||||
}, [connections, selected])
|
||||
|
||||
const openManage = (next = emptyForm) => {
|
||||
setForm(next); setMethod(next.id ? "token" : "password"); setError(""); setMessage(""); setManage(true)
|
||||
}
|
||||
|
||||
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,
|
||||
}) })
|
||||
// A password is sent once to create a read-only token; only the token is kept.
|
||||
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()
|
||||
setSelected(result.id)
|
||||
setForm(emptyForm)
|
||||
setMessage(t("pbs.saved"))
|
||||
setManage(false)
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
@@ -130,7 +145,7 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
<Button variant="outline" size="sm" disabled={isLoading || !selected} onClick={() => mutate()}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />{t("pbs.refresh")}
|
||||
</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")}
|
||||
</Button>
|
||||
</div>}
|
||||
@@ -147,56 +162,83 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
<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>
|
||||
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700" onClick={() => {
|
||||
setForm({ ...emptyForm, name: row.name, url: row.url, fingerprint: row.fingerprint })
|
||||
setError(""); setMessage(""); setManage(true)
|
||||
}}>{t("pbs.connect")}</Button>
|
||||
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
onClick={() => openManage({ ...emptyForm, name: row.name, url: row.url, fingerprint: row.fingerprint })}>
|
||||
{t("pbs.connect")}
|
||||
</Button>
|
||||
</div>)}
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{manage && !localOnly && <Card>
|
||||
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.permissionsHelp")}</p>
|
||||
<Select value={form.id || NEW_CONNECTION} onValueChange={value => {
|
||||
const row = connections?.servers.find(item => item.id === value)
|
||||
setForm(row ? { ...row, token_secret: "" } : emptyForm)
|
||||
}}>
|
||||
<SelectTrigger aria-label={t("pbs.editConnection")} className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NEW_CONNECTION}>{t("pbs.add")}</SelectItem>
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field =>
|
||||
<div key={field} className="space-y-2">
|
||||
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
|
||||
<Input id={`pbs-${field}`} disabled={busy} type={field === "token_secret" ? "password" : field === "url" ? "url" : "text"}
|
||||
autoComplete={field === "token_secret" ? "new-password" : "off"}
|
||||
value={form[field]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
|
||||
</div>)}
|
||||
{!localOnly && <Dialog open={manage} onOpenChange={open => { if (!busy) setManage(open) }}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("pbs.manage")}</DialogTitle>
|
||||
<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)
|
||||
setForm(row ? { ...emptyForm, ...row, token_secret: "" } : emptyForm)
|
||||
setMethod(row ? "token" : "password"); setError("")
|
||||
}}>
|
||||
<SelectTrigger aria-label={t("pbs.editConnection")} className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NEW_CONNECTION}>{t("pbs.add")}</SelectItem>
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</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">
|
||||
{(["name", "url", ...(method === "password" && !form.id ? ["username", "password"] : ["token_id", "token_secret"]), "fingerprint"] as const).map(field => {
|
||||
const secret = field === "token_secret" || field === "password"
|
||||
return <div key={field} className="space-y-2">
|
||||
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
|
||||
<Input id={`pbs-${field}`} disabled={busy} type={secret ? "password" : field === "url" ? "url" : "text"}
|
||||
autoComplete={secret ? "new-password" : "off"} placeholder={field === "username" ? "root@pam" : undefined}
|
||||
value={form[field as keyof typeof form]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{form.id && <Button variant="outline" disabled={busy} className="mr-auto text-red-500 hover:text-red-500" onClick={remove}>
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={busy} onClick={save}>{busy ? t("pbs.testing") : t("pbs.save")}</Button>
|
||||
{form.id && <Button variant="destructive" disabled={busy} onClick={remove}>{t("pbs.remove")}</Button>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>}
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
{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>
|
||||
{Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active")
|
||||
? <Badge variant="outline" className={badgeTone.ok}>{t("pbs.servicesActive")}</Badge>
|
||||
: Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {state}</Badge>)}
|
||||
</div>
|
||||
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
|
||||
</div>}
|
||||
{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>
|
||||
<CardContent>
|
||||
{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} />)}
|
||||
</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>}
|
||||
|
||||
{showTasks && <>
|
||||
<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>
|
||||
<CardContent>
|
||||
@@ -261,6 +304,7 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Server,
|
||||
ListChecks,
|
||||
Menu,
|
||||
LayoutDashboard,
|
||||
HardDrive,
|
||||
@@ -116,11 +117,11 @@ function PBSDashboard() {
|
||||
}
|
||||
|
||||
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
|
||||
// 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 }) {
|
||||
const t = useT()
|
||||
@@ -421,6 +422,7 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
switch (activeTab) {
|
||||
case "overview": return t("navigation.overview")
|
||||
case "datastores": return t("pbs.datastores")
|
||||
case "pbs-tasks": return t("pbs.tasksTab")
|
||||
case "apps": return t("navigation.apps")
|
||||
case "vms": return t("navigation.virtualMachines")
|
||||
case "storage": return t("navigation.storage")
|
||||
@@ -717,6 +719,7 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
switch (id) {
|
||||
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
|
||||
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 "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines"))
|
||||
case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup"))
|
||||
@@ -726,7 +729,7 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
}
|
||||
}
|
||||
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)}
|
||||
</TabsList>
|
||||
)
|
||||
@@ -788,6 +791,7 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
switch (id) {
|
||||
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
|
||||
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 "vms": return btn("vms", Boxes, t("navigation.virtualMachines"))
|
||||
case "backup": return btn("backup", DatabaseBackup, t("navigation.backup"))
|
||||
@@ -842,10 +846,15 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
|
||||
{isPbs && (
|
||||
<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 />
|
||||
</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 && <>
|
||||
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">
|
||||
|
||||
@@ -7302,6 +7302,14 @@
|
||||
"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.",
|
||||
"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",
|
||||
"manage": "PBS-Verbindungen verwalten",
|
||||
"loadFailed": "PBS-Status konnte nicht geladen werden.",
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
"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.",
|
||||
"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",
|
||||
"manage": "Manage PBS connections",
|
||||
"loadFailed": "Could not load PBS status.",
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
"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.",
|
||||
"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",
|
||||
"manage": "Gestionar conexiones PBS",
|
||||
"loadFailed": "No se pudo consultar el estado de PBS.",
|
||||
|
||||
@@ -7302,6 +7302,14 @@
|
||||
"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.",
|
||||
"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",
|
||||
"manage": "Gérer les connexions PBS",
|
||||
"loadFailed": "Impossible de charger l'état de PBS.",
|
||||
|
||||
@@ -7302,6 +7302,14 @@
|
||||
"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.",
|
||||
"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",
|
||||
"manage": "Gestisci le connessioni PBS",
|
||||
"loadFailed": "Impossibile caricare lo stato di PBS.",
|
||||
|
||||
@@ -7302,6 +7302,14 @@
|
||||
"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.",
|
||||
"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",
|
||||
"manage": "Gerir ligações PBS",
|
||||
"loadFailed": "Não foi possível carregar o estado do PBS.",
|
||||
|
||||
@@ -7301,6 +7301,14 @@
|
||||
"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.",
|
||||
"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ť",
|
||||
"manage": "Spravovať pripojenia PBS",
|
||||
"loadFailed": "Nepodarilo sa načítať stav PBS.",
|
||||
|
||||
@@ -7302,6 +7302,14 @@
|
||||
"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.",
|
||||
"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",
|
||||
"manage": "Hantera PBS-anslutningar",
|
||||
"loadFailed": "Det gick inte att läsa in PBS-status.",
|
||||
|
||||
@@ -67,6 +67,20 @@ def save_server():
|
||||
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"])
|
||||
@require_admin_scope
|
||||
def delete_server(identifier):
|
||||
|
||||
@@ -17,7 +17,7 @@ import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import quote, urlencode, urlsplit
|
||||
|
||||
import auth_manager
|
||||
|
||||
@@ -111,12 +111,11 @@ def public_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):
|
||||
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")}
|
||||
row = {field: data.get(field, previous.get(field, "")) for field in fields}
|
||||
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()
|
||||
@@ -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
|
||||
if not row["name"] or len(row["name"]) > 128:
|
||||
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()
|
||||
if not re.fullmatch(r"[^\s:!]+@[^\s:!]+![^\s:!]+", row["token_id"]):
|
||||
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.")
|
||||
if not row["token_id"].isascii():
|
||||
raise PBSError("Invalid API token ID.")
|
||||
return _validated_fingerprint(row)
|
||||
|
||||
|
||||
def _validated_fingerprint(row):
|
||||
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).")
|
||||
@@ -148,6 +153,13 @@ def _validate_server(data, previous=None):
|
||||
|
||||
def _remote_get(server, endpoint):
|
||||
"""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"])
|
||||
fingerprint = server.get("fingerprint")
|
||||
if fingerprint:
|
||||
@@ -165,14 +177,15 @@ def _remote_get(server, endpoint):
|
||||
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"],
|
||||
})
|
||||
body = urlencode(form) if form is not None else None
|
||||
request_headers = {"Accept": "application/json", **headers}
|
||||
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()
|
||||
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.")
|
||||
raise PBSError(rejected)
|
||||
if response.status != 200 or len(content) > MAX_RESPONSE:
|
||||
raise PBSError("PBS returned an invalid response. Redirects are not followed.")
|
||||
document = json.loads(content)
|
||||
@@ -208,6 +221,43 @@ def save_server(data):
|
||||
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):
|
||||
with _lock:
|
||||
servers = _load_servers()
|
||||
|
||||
@@ -199,6 +199,46 @@ class PBSManagerTests(unittest.TestCase):
|
||||
self.assertEqual(response.json["detected"],
|
||||
[{"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):
|
||||
self.assertEqual(self.client.get("/api/pbs/servers").status_code, 401)
|
||||
with mock.patch.object(pbs, "_remote_get", return_value=[]):
|
||||
|
||||
Reference in New Issue
Block a user