Files
duffyduckandClaude Opus 5 963463a1a1 Web-Oberflaeche als Dienst, Anmeldung mit den Proxmox-Benutzern
install.sh --with-webexplorer --port <nummer> richtet pvesnap-web.service
ein. Fehlt der Port, bricht das Skript mit einer Erklaerung ab; --port
ohne --with-webexplorer, ein Port ausserhalb 1-65535, etwas anderes als
eine Zahl und Port 8006 (Proxmox selbst) werden ebenfalls abgefangen -
und zwar vor der root-Pruefung, damit ein Tippfehler sofort auffaellt.
--help beschreibt jetzt alle Optionen mit Beispielen.

Port, Adresse und Anmeldeart stehen in /etc/pvesnap/web.conf und lassen
sich dort aendern, ohne die Unit anzufassen.

Angemeldet wird mit den Benutzern von Proxmox VE: dieselbe Maske aus
Benutzer, Passwort und Realm-Auswahl, geprueft ueber POST /access/ticket
auf der lokalen API - also derselbe Weg wie im Proxmox-Webinterface. Alle
Realms funktionieren damit automatisch; Zwei-Faktor-Anmeldungen werden
abgewiesen statt halb durchgewinkt.

Ein gueltiges Passwort allein reicht nicht: zusaetzlich braucht der
Benutzer auf dem Gast das Recht VM.Snapshot (geprueft auf /, /vms,
/vms/<id> und dem Pool). Sonst koennte jeder Proxmox-Benutzer saemtliche
Dateien aller Gaeste lesen. In der Gastliste erscheinen nur erlaubte
Gaeste, root@pam sieht wie in Proxmox alles. Anpassbar ueber
--require-privilege und --allow-user.

Ausserdem: SIGTERM haengt einen offenen Snapshot wieder aus (ohne das
bliebe er bei "systemctl stop" eingebunden), und beim Start werden Reste
von Prozessen abgeraeumt, die es nicht mehr gibt - parallel laufende
Sitzungen bleiben dabei unangetastet.

Auf pvetest01 geprueft: Dienststart ueber systemd, Anmeldemaske mit den
Realms des Hosts, Abweisung ohne und mit falschen Zugangsdaten (401,
protokolliert mit Absender-IP), Zugriff ohne Recht (403 auf Snapshot-
Liste und Oeffnen), vollstaendiger Ablauf als Berechtigter bis zum
Datei-Download, Abmelden macht die Sitzung ungueltig, und SIGTERM
hinterlaesst weder rbd-Maps noch Mountpunkte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:15:56 +02:00

146 lines
5.4 KiB
Python

"""Anmeldung an der Web-Oberflaeche mit den Benutzern von Proxmox VE.
Geprueft wird ueber dieselbe Schnittstelle, die auch das Proxmox-Webinterface
benutzt: POST /access/ticket auf der lokalen API. Passwoerter wandern also
nirgends anders hin als ohnehin schon, und alle Realms (pam, pve, LDAP, AD ...)
funktionieren automatisch mit.
Wer sich anmelden darf, ist damit noch nicht entschieden: zusaetzlich muss der
Benutzer auf der jeweiligen VM ein Recht besitzen (Vorgabe: VM.Snapshot). Sonst
koennte jeder Benutzer mit einem gueltigen Kennwort saemtliche Dateien aller
Gaeste lesen.
"""
from __future__ import annotations
import json
import logging
import ssl
import urllib.error
import urllib.parse
import urllib.request
log = logging.getLogger("pvesnap.web.auth")
API = "https://127.0.0.1:8006/api2/json"
DEFAULT_PRIVILEGE = "VM.Snapshot"
class AuthError(Exception):
"""Anmeldung fehlgeschlagen."""
def _unverified_context():
# Das Zertifikat von pveproxy ist selbstsigniert. Die Verbindung geht nur
# an 127.0.0.1 und verlaesst den Host nicht.
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def _pvesh_json(args):
from ..proxmox import Proxmox, ProxmoxError
try:
return Proxmox()._json(args)
except ProxmoxError as exc:
raise AuthError(str(exc))
# ---------------------------------------------------------------------------
# Realms
# ---------------------------------------------------------------------------
def list_realms():
"""[(realm, Beschriftung)] - wie in der Auswahlliste des Proxmox-Logins."""
try:
data = _pvesh_json(["get", "/access/domains"]) or []
except AuthError as exc:
log.warning("Realms nicht lesbar: %s", exc)
return [("pam", "Linux PAM standard authentication")]
realms = []
for entry in data:
realm = entry.get("realm")
if not realm:
continue
comment = entry.get("comment") or entry.get("type") or realm
realms.append((realm, "%s (%s)" % (realm, comment)))
realms.sort(key=lambda item: (item[0] != "pam", item[0]))
return realms or [("pam", "pam")]
# ---------------------------------------------------------------------------
# Anmeldung
# ---------------------------------------------------------------------------
def authenticate(username, realm, password):
"""Prueft die Zugangsdaten und gibt die vollstaendige Benutzerkennung zurueck."""
username = (username or "").strip()
if not username or not password:
raise AuthError("Benutzername und Passwort werden benoetigt.")
if "@" in username: # "root@pam" im Feld eingetippt
username, _, realm_from_name = username.partition("@")
realm = realm_from_name or realm
userid = "%s@%s" % (username, realm or "pam")
data = urllib.parse.urlencode({"username": userid, "password": password}).encode()
request = urllib.request.Request(API + "/access/ticket", data=data,
method="POST")
try:
with urllib.request.urlopen(request, timeout=20,
context=_unverified_context()) as response:
payload = json.loads(response.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as exc:
if exc.code == 401:
raise AuthError("Benutzername oder Passwort falsch.")
raise AuthError("Anmeldung fehlgeschlagen (HTTP %s)." % exc.code)
except urllib.error.URLError as exc:
raise AuthError("Der Proxmox-Dienst auf 127.0.0.1:8006 ist nicht "
"erreichbar (%s)." % exc.reason)
except (ValueError, OSError) as exc:
raise AuthError("Anmeldung fehlgeschlagen: %s" % exc)
result = (payload or {}).get("data") or {}
if not result.get("ticket"):
raise AuthError("Benutzername oder Passwort falsch.")
if result.get("NeedTFA"):
# Der zweite Faktor wird hier bewusst nicht abgefragt - lieber
# abweisen als eine halbe Anmeldung durchwinken.
raise AuthError("Fuer %s ist Zwei-Faktor-Authentifizierung aktiv. "
"Diese Oberflaeche unterstuetzt das nicht - bitte einen "
"Benutzer ohne zweiten Faktor verwenden." % userid)
return result.get("username") or userid
# ---------------------------------------------------------------------------
# Rechte
# ---------------------------------------------------------------------------
def permissions(userid):
"""Rechtebaum eines Benutzers: {Pfad: {Recht: 1}}."""
try:
data = _pvesh_json(["get", "/access/permissions", "--userid", userid])
except AuthError as exc:
log.warning("Rechte von %s nicht lesbar: %s", userid, exc)
return {}
return data if isinstance(data, dict) else {}
def may_access(userid, guest, privilege=DEFAULT_PRIVILEGE, tree=None,
allowed_users=()):
"""Darf `userid` in die Dateien dieses Gastes sehen?"""
if userid in (allowed_users or ()):
return True
if userid == "root@pam":
return True # in Proxmox grundsaetzlich uneingeschraenkt
tree = tree if tree is not None else permissions(userid)
paths = ["/", "/vms", "/vms/%d" % guest.vmid]
if getattr(guest, "pool", ""):
paths.append("/pool/%s" % guest.pool)
for path in paths:
if (tree.get(path) or {}).get(privilege):
return True
return False