diff --git a/AppImage/components/pbs-panel.tsx b/AppImage/components/pbs-panel.tsx index 5da63fcb..cb5a112a 100644 --- a/AppImage/components/pbs-panel.tsx +++ b/AppImage/components/pbs-panel.tsx @@ -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; } -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( "/api/pbs/servers", url => fetchApi(url), { revalidateOnFocus: false }) const [selected, setSelected] = useState("") const [manage, setManage] = useState(false) const [form, setForm] = useState(emptyForm) + const [method, setMethod] = useState("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 } = {}) { - } @@ -147,56 +162,83 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) { {row.name} {row.url} - + )} } - {manage && !localOnly && - {t("pbs.manage")} - -

{t("pbs.permissionsHelp")}

- -
- {(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field => -
- - setForm({ ...form, [field]: event.target.value })} /> -
)} + {!localOnly && { if (!busy) setManage(open) }}> + + + {t("pbs.manage")} + {method === "password" && !form.id ? t("pbs.passwordHelp") : t("pbs.permissionsHelp")} + +
+
+ + +
+ {!form.id &&
+ +
+ {(["password", "token"] as const).map(option => + )} +
+
} +
+ {(["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
+ + setForm({ ...form, [field]: event.target.value })} /> +
+ })} +
+

{t("pbs.tlsHelp")}

+ {error &&

{error}

} + {message &&

{message}

} +
+ {form.id && } + + +
-

{t("pbs.tlsHelp")}

- {error &&

{error}

} - {message &&

{message}

} -
- - {form.id && } -
- - } +
+
} {data && <> -
+ {!localOnly &&
{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })} {Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active") ? {t("pbs.servicesActive")} : Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) => {service}: {state})} -
- {Object.entries(data.errors).map(([section, detail]) =>

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

)} +
} + {Object.entries(data.errors).filter(([section]) => section === "datastores" ? showStores : showTasks) + .map(([section, detail]) =>

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

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

{t("pbs.noDatastores")}

} @@ -204,8 +246,9 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) { {data.datastores.map(store => )}
}
-
+ } + {showTasks && <> {t("pbs.tasks")} @@ -261,6 +304,7 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) { })()} + } } } diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index c4e62768..ef7dca2f 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -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 ( - + {tabOrder.map(renderTop)} ) @@ -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 && ( - + )} + {isPbs && ( + + + + )} {!isPbs && <> diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 3be56c1b..060ec0fa 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -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.", diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 91b899b4..6a14b8ba 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -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.", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 00127ee2..d80db724 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -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.", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index bbd434d6..9467544a 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -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.", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index e7c191bf..5c008de9 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -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.", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 371090c4..5fc5282c 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -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.", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index be43d02c..373a1b72 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -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.", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index f4d49723..7c4a1dd0 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -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.", diff --git a/AppImage/scripts/flask_pbs_routes.py b/AppImage/scripts/flask_pbs_routes.py index 83f9d07e..2c18f8b8 100644 --- a/AppImage/scripts/flask_pbs_routes.py +++ b/AppImage/scripts/flask_pbs_routes.py @@ -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/", methods=["DELETE"]) @require_admin_scope def delete_server(identifier): diff --git a/AppImage/scripts/pbs_manager.py b/AppImage/scripts/pbs_manager.py index 4e90f8be..7c93e6a1 100644 --- a/AppImage/scripts/pbs_manager.py +++ b/AppImage/scripts/pbs_manager.py @@ -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() diff --git a/AppImage/scripts/tests/test_pbs.py b/AppImage/scripts/tests/test_pbs.py index 0f1f49f3..7b60fc6e 100644 --- a/AppImage/scripts/tests/test_pbs.py +++ b/AppImage/scripts/tests/test_pbs.py @@ -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=[]):