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>
This commit is contained in:
duffyduck
2026-07-31 10:15:56 +02:00
co-authored by Claude Opus 5
parent 91bfdea76c
commit 963463a1a1
9 changed files with 647 additions and 91 deletions
+229 -56
View File
@@ -18,6 +18,7 @@ import io
import logging
import os
import secrets
import signal
import socket
import stat
import sys
@@ -32,12 +33,47 @@ from .. import __version__
from ..proxmox import Proxmox, ProxmoxError, pvesh_available
from ..snapfs import (Session, SnapfsError, cleanup_leftovers, human_bytes,
list_snapshots)
from .assets import PAGE, SCRIPT, STYLE
from .assets import LOGIN_FORM, PAGE, SCRIPT, STYLE
from .auth import DEFAULT_PRIVILEGE, AuthError, authenticate, list_realms, may_access, permissions
log = logging.getLogger("pvesnap.web")
COOKIE = "pvesnap_token"
COOKIE = "pvesnap_token" # Zugangsschluessel (Modus "token")
SESSION_COOKIE = "pvesnap_session" # Anmeldung (Modus "pve")
CHUNK = 256 * 1024
SESSION_MAX_AGE = 8 * 3600
class Sessions:
"""Angemeldete Benutzer. Nur im Arbeitsspeicher - ein Neustart meldet alle ab."""
def __init__(self, max_age=SESSION_MAX_AGE):
self.max_age = max_age
self._entries = {}
self._lock = threading.Lock()
def create(self, userid):
token = secrets.token_urlsafe(24)
with self._lock:
self._entries[token] = {"userid": userid, "until": time.time() + self.max_age,
"permissions": None}
return token
def get(self, token):
if not token:
return None
with self._lock:
entry = self._entries.get(token)
if entry is None:
return None
if entry["until"] < time.time():
del self._entries[token]
return None
return entry
def drop(self, token):
with self._lock:
self._entries.pop(token, None)
# ---------------------------------------------------------------------------
@@ -47,20 +83,40 @@ CHUNK = 256 * 1024
class AppState:
"""Was gerade geoeffnet ist. Es ist immer hoechstens ein Snapshot offen."""
def __init__(self, token, proxmox=None, fixed_path=""):
def __init__(self, token="", proxmox=None, fixed_path="", auth_mode="token",
privilege=DEFAULT_PRIVILEGE, allowed_users=()):
self.token = token
self.proxmox = proxmox
self.fixed_path = fixed_path
self.auth_mode = auth_mode # pve | token | none
self.privilege = privilege
self.allowed_users = tuple(allowed_users or ())
self.sessions = Sessions()
self.session = None
self.guest = None
self.snapname = ""
self.roots = [] # [(Beschriftung, Pfad)]
self.lock = threading.Lock()
self.notes = []
self.opened_by = ""
if fixed_path:
self.roots = [(fixed_path, os.path.realpath(fixed_path))]
def visible_guests(self, userid):
"""Nur die Gaeste, in die dieser Benutzer hineinsehen darf."""
guests = self.proxmox.inventory(refresh=True)
if self.auth_mode != "pve":
return guests
tree = permissions(userid)
return [g for g in guests
if may_access(userid, g, self.privilege, tree, self.allowed_users)]
def may_open(self, userid, guest):
if self.auth_mode != "pve":
return True
return may_access(userid, guest, self.privilege, None, self.allowed_users)
@property
def open(self):
return bool(self.roots)
@@ -197,9 +253,11 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Location", target)
self.send_header("Content-Length", "0")
if cookie:
name, value = cookie
age = 0 if value == "" else SESSION_MAX_AGE
self.send_header("Set-Cookie",
"%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400"
% (COOKIE, cookie))
"%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=%d"
% (name, value, age))
self.end_headers()
def _error(self, message, status=400):
@@ -211,17 +269,69 @@ class Handler(BaseHTTPRequestHandler):
# -- Zugang -----------------------------------------------------------
def _cookie(self, name):
for part in (self.headers.get("Cookie") or "").split(";"):
key, _, value = part.strip().partition("=")
if key == name:
return value
return ""
def _authorized(self, params):
supplied = ""
if "token" in params:
supplied = params["token"][0]
else:
cookies = self.headers.get("Cookie") or ""
for part in cookies.split(";"):
name, _, value = part.strip().partition("=")
if name == COOKIE:
supplied = value
return secrets.compare_digest(supplied, self.state.token), supplied
"""Modus 'token': Schluessel aus Adresse oder Cookie."""
supplied = params["token"][0] if "token" in params else self._cookie(COOKIE)
return secrets.compare_digest(supplied, self.state.token or ""), supplied
def _user(self):
"""Angemeldeter Benutzer im Modus 'pve', sonst None."""
entry = self.state.sessions.get(self._cookie(SESSION_COOKIE))
return entry["userid"] if entry else None
def _check_access(self, params, route):
"""(erlaubt, Benutzer) - kuemmert sich um alle drei Anmeldearten."""
mode = self.state.auth_mode
if mode == "none":
return True, ""
if mode == "token":
ok, supplied = self._authorized(params)
if ok and "token" in params and route not in ("/download", "/zip"):
clean = {k: v[0] for k, v in params.items() if k != "token"}
self._redirect(link(route, **clean), cookie=(COOKIE, supplied))
return None, "" # None = Antwort ist schon raus
return ok, ""
user = self._user()
return (user is not None), (user or "")
def _page_login(self, message_text="", username="", realm="pam", status=200):
realms = list_realms()
options = "".join(
"<option value='%s'%s>%s</option>"
% (esc(value), " selected" if value == realm else "", esc(label))
for value, label in realms)
note = ("<div class='note warn'>%s</div>" % esc(message_text)) if message_text else ""
body = LOGIN_FORM % {"note": note, "username": esc(username), "realms": options}
self._send(page("pvesnap - Anmeldung", self.state, body), status=status)
def _do_login(self, form):
username = (form.get("username") or [""])[0]
password = (form.get("password") or [""])[0]
realm = (form.get("realm") or ["pam"])[0]
try:
userid = authenticate(username, realm, password)
except AuthError as exc:
log.warning("Anmeldung abgelehnt (%s@%s von %s): %s",
username, realm, self.address_string(), exc)
self._page_login(str(exc), username, realm, status=401)
return
log.info("Anmeldung: %s von %s", userid, self.address_string())
token = self.state.sessions.create(userid)
self._redirect("/", cookie=(SESSION_COOKIE, token))
def _actions(self, user, extra=""):
if self.state.auth_mode != "pve" or not user:
return extra
return ("%s<span class='pill'>%s</span>"
"<form method='post' action='/logout' style='margin:0'>"
"<button type='submit'>Abmelden</button></form>" % (extra, esc(user)))
# -- Anfragen ---------------------------------------------------------
@@ -233,28 +343,31 @@ class Handler(BaseHTTPRequestHandler):
params = urllib.parse.parse_qs(parsed.query)
route = parsed.path
ok, supplied = self._authorized(params)
if not ok:
self._send(page("Zugang", self.state,
'<div class="note warn">Zugangsschluessel fehlt oder ist '
'falsch.<br>Bitte die Adresse benutzen, die beim Start von '
'<code>pvesnap-web</code> ausgegeben wurde.</div>'),
status=403)
if route == "/login" and self.state.auth_mode == "pve":
self._page_login()
return
# Schluessel aus der Adresse in ein Cookie umziehen
if "token" in params and route not in ("/download", "/zip"):
clean = {k: v[0] for k, v in params.items() if k != "token"}
self._redirect(link(route, **clean), cookie=supplied)
ok, user = self._check_access(params, route)
if ok is None:
return # Weiterleitung ist schon raus
if not ok:
if self.state.auth_mode == "pve":
self._page_login("Bitte anmelden.", status=401)
else:
self._send(page("Zugang", self.state,
'<div class="note warn">Zugangsschluessel fehlt oder '
'ist falsch.<br>Bitte die Adresse benutzen, die beim '
'Start von <code>pvesnap-web</code> ausgegeben '
'wurde.</div>'), status=403)
return
try:
if route == "/":
self._page_home()
self._page_home(user)
elif route == "/snapshots":
self._page_snapshots(params)
self._page_snapshots(params, user)
elif route == "/files":
self._page_files(params)
self._page_files(params, user)
elif route == "/download":
self._download(params)
else:
@@ -266,21 +379,32 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
ok, _ = self._authorized(urllib.parse.parse_qs(parsed.query))
if not ok:
self._send("Zugang verweigert", status=403, content_type="text/plain")
return
params = urllib.parse.parse_qs(parsed.query)
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length).decode("utf-8", "replace") if length else ""
form = urllib.parse.parse_qs(raw)
if parsed.path == "/login" and self.state.auth_mode == "pve":
self._do_login(form)
return
ok, user = self._check_access(params, parsed.path)
if ok is None:
return
if not ok:
self._send("Zugang verweigert", status=403, content_type="text/plain")
return
try:
if parsed.path == "/open":
self._open_snapshot(form)
self._open_snapshot(form, user)
elif parsed.path == "/close":
self.state.close()
self._redirect("/")
elif parsed.path == "/logout":
self.state.sessions.drop(self._cookie(SESSION_COOKIE))
self._redirect("/login", cookie=(SESSION_COOKIE, ""))
elif parsed.path == "/zip":
self._zip(form)
else:
@@ -292,7 +416,7 @@ class Handler(BaseHTTPRequestHandler):
# -- Seiten -----------------------------------------------------------
def _page_home(self):
def _page_home(self, user=""):
state = self.state
if state.open:
self._redirect(link("/files", fs=0, p=""))
@@ -301,7 +425,7 @@ class Handler(BaseHTTPRequestHandler):
self._error("Kein Verzeichnis geoeffnet.")
return
guests = state.proxmox.inventory(refresh=True)
guests = state.visible_guests(user)
rows = []
for guest in guests:
rows.append(
@@ -314,21 +438,30 @@ class Handler(BaseHTTPRequestHandler):
guest.vmid, "LXC" if guest.type == "lxc" else "VM",
esc(guest.status), esc(guest.node)))
hint = ""
if state.auth_mode == "pve" and not rows:
hint = ("<div class='note warn'>Fuer keinen Gast liegt das noetige Recht "
"<b>%s</b> vor. Ein Administrator kann es in Proxmox unter "
"Datacenter &rarr; Permissions vergeben.</div>" % esc(state.privilege))
body = (
"<h2>Gast auswaehlen</h2>"
hint + "<h2>Gast auswaehlen</h2>"
"<div class='card'><table><thead><tr><th>Name</th><th>VMID</th>"
"<th>Typ</th><th>Status</th><th>Node</th></tr></thead><tbody>%s</tbody>"
"</table></div>" % ("".join(rows) or
"<tr><td colspan='5' class='empty'>Keine Gaeste gefunden.</td></tr>"))
self._send(page("pvesnap - Gaeste", state, body))
self._send(page("pvesnap - Gaeste", state, body, actions=self._actions(user)))
def _page_snapshots(self, params):
def _page_snapshots(self, params, user=""):
state = self.state
vmid = int((params.get("vmid") or ["0"])[0])
guest = next((g for g in state.proxmox.inventory() if g.vmid == vmid), None)
if guest is None:
self._error("Gast %d nicht gefunden." % vmid, status=404)
return
if not state.may_open(user, guest):
self._error("Fuer %s fehlt das Recht %s auf diesem Gast."
% (user, state.privilege), status=403)
return
rows = []
for snap in list_snapshots(state.proxmox, guest):
@@ -357,9 +490,10 @@ class Handler(BaseHTTPRequestHandler):
% (esc(guest.label),
"".join(rows) or "<tr><td colspan='4' class='empty'>"
"Dieser Gast hat keine Snapshots.</td></tr>"))
self._send(page("Snapshots %s" % guest.label, state, body))
self._send(page("Snapshots %s" % guest.label, state, body,
actions=self._actions(user)))
def _open_snapshot(self, form):
def _open_snapshot(self, form, user=""):
state = self.state
vmid = int((form.get("vmid") or ["0"])[0])
snapname = (form.get("snap") or [""])[0]
@@ -367,11 +501,16 @@ class Handler(BaseHTTPRequestHandler):
if guest is None or not snapname:
self._error("Gast oder Snapshot fehlt.")
return
log.info("Oeffne Snapshot %s von %s", snapname, guest.label)
if not state.may_open(user, guest):
self._error("Fuer %s fehlt das Recht %s auf diesem Gast."
% (user, state.privilege), status=403)
return
log.info("%s oeffnet Snapshot %s von %s", user or "-", snapname, guest.label)
state.opened_by = user
state.open_snapshot(guest, snapname)
self._redirect(link("/files", fs=0, p=""))
def _page_files(self, params):
def _page_files(self, params, user=""):
state = self.state
if not state.open:
self._redirect("/")
@@ -442,11 +581,11 @@ class Handler(BaseHTTPRequestHandler):
"<tbody>%s</tbody></table></div>"
% ("".join(rows) or "<tr><td colspan='4' class='empty'>Leeres Verzeichnis</td></tr>"))
actions = ("<form method='post' action='/close' style='margin:0'>"
"<button type='submit'>Snapshot schliessen</button></form>"
if not state.fixed_path else "")
self._send(page("pvesnap - %s" % (relative or "/"), state,
"".join(body), actions=actions))
close_button = ("<form method='post' action='/close' style='margin:0'>"
"<button type='submit'>Snapshot schliessen</button></form>"
if not state.fixed_path else "")
self._send(page("pvesnap - %s" % (relative or "/"), state, "".join(body),
actions=self._actions(user, close_button)))
@staticmethod
def _describe(item):
@@ -688,9 +827,19 @@ def build_parser():
help="Port (Vorgabe: %(default)s)")
parser.add_argument("-p", "--path",
help="beliebiges Verzeichnis freigeben, ohne Proxmox zu fragen")
parser.add_argument("-a", "--auth", choices=("pve", "token", "none"),
help="Anmeldung: 'pve' = Benutzer von Proxmox VE (Vorgabe auf "
"einem PVE-Host), 'token' = Zugangsschluessel in der "
"Adresse, 'none' = ohne jede Anmeldung")
parser.add_argument("--require-privilege", default=DEFAULT_PRIVILEGE, metavar="RECHT",
help="Recht, das ein Benutzer auf einem Gast haben muss, um "
"dessen Dateien zu sehen (Vorgabe: %(default)s)")
parser.add_argument("--allow-user", action="append", default=[], metavar="USER@REALM",
help="dieser Benutzer darf alles sehen (mehrfach moeglich)")
parser.add_argument("-t", "--token", help="Zugangsschluessel selbst vorgeben")
parser.add_argument("--no-token", action="store_true",
help="ohne Zugangsschluessel (nur in vertrauenswuerdigen Netzen!)")
help="Kurzform fuer --auth none (nur in vertrauenswuerdigen "
"Netzen!)")
parser.add_argument("--cleanup", action="store_true",
help="haengengebliebene Einbindungen aufraeumen und beenden")
parser.add_argument("-v", "--verbose", action="store_true")
@@ -725,8 +874,18 @@ def main(argv=None):
print("Kein Verzeichnis: %s" % args.path, file=sys.stderr)
return 2
token = "" if args.no_token else (args.token or secrets.token_urlsafe(18))
state = AppState(token=token, proxmox=proxmox, fixed_path=args.path or "")
# Auf einem Proxmox-Host wird ohne weitere Angabe die Anmeldung mit den
# Proxmox-Benutzern verwendet; ohne Proxmox bleibt nur der Zugangsschluessel.
mode = args.auth or ("none" if args.no_token else ("token" if args.path else "pve"))
if mode == "pve" and not proxmox:
print("--auth pve braucht einen Proxmox-Host (mit --path nicht moeglich).",
file=sys.stderr)
return 2
token = (args.token or secrets.token_urlsafe(18)) if mode == "token" else ""
state = AppState(token=token, proxmox=proxmox, fixed_path=args.path or "",
auth_mode=mode, privilege=args.require_privilege,
allowed_users=args.allow_user)
if args.vmid and args.snapshot and proxmox:
guest = next((g for g in proxmox.inventory() if g.vmid == args.vmid), None)
@@ -740,23 +899,37 @@ def main(argv=None):
print("Snapshot nicht zu oeffnen: %s" % exc, file=sys.stderr)
return 5
if args.no_token:
Handler._authorized = lambda self, params: (True, "")
# Reste eines abgestuerzten Vorgaengers wegraeumen - laufende Sitzungen
# anderer Prozesse bleiben unangetastet.
cleanup_leftovers(only_dead=True)
Handler.state = state
server = ThreadingHTTPServer((args.bind, args.port), Handler)
server.daemon_threads = True
# Unter systemd kommt SIGTERM; ohne das bliebe der Snapshot eingebunden.
def _terminate(_signum, _frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, _terminate)
suffix = ("?token=%s" % token) if token else ""
lines = ["", "pvesnap-web laeuft. Im Browser oeffnen:", ""]
for address in _addresses(args.port):
lines.append(" http://%s:%d/%s" % (address, args.port, suffix))
if args.bind in ("127.0.0.1", "localhost", "::1"):
lines.append(" http://%s:%d/%s" % (args.bind, args.port, suffix))
lines += ["", " Beenden mit Strg-C."]
if not token:
lines.append(" ACHTUNG: ohne Zugangsschluessel gestartet.")
lines.append("")
if mode == "pve":
lines.append(" Anmeldung mit den Benutzern von Proxmox VE.")
lines.append(" Sichtbar ist ein Gast nur mit dem Recht '%s' darauf%s."
% (state.privilege,
(" (immer erlaubt: %s)" % ", ".join(state.allowed_users))
if state.allowed_users else ""))
elif mode == "token":
lines.append(" Anmeldung ueber den Zugangsschluessel in der Adresse.")
else:
lines.append(" ACHTUNG: ohne jede Anmeldung gestartet.")
lines += ["", " Beenden mit Strg-C.", ""]
# flush: sonst haengt die Adresse mit dem Schluessel im Puffer, sobald die
# Ausgabe nicht direkt im Terminal landet (Pipe, systemd, tee).
print("\n".join(lines), flush=True)