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
+2
View File
@@ -946,6 +946,8 @@ def main(argv=None):
print("%d Eintrag/Eintraege aufgeraeumt." % len(removed))
return 0
cleanup_leftovers(only_dead=True)
if not args.path:
if not pvesh_available():
print("'pvesh' nicht gefunden - der Explorer laeuft auf dem Proxmox-Host.\n"
+20 -2
View File
@@ -225,10 +225,28 @@ def _registry_remove(kind, value):
_registry_save(entries)
def cleanup_leftovers(verbose=False):
"""Raeumt auf, was ein abgestuerzter Lauf hinterlassen hat."""
def _pid_alive(pid):
if not pid:
return False
try:
os.kill(int(pid), 0)
return True
except ProcessLookupError:
return False
except OSError:
return True # existiert, gehoert uns nur nicht
def cleanup_leftovers(verbose=False, only_dead=False):
"""Raeumt auf, was ein abgestuerzter Lauf hinterlassen hat.
only_dead=True fasst nur an, was von einem nicht mehr laufenden Prozess
stammt - so stoert der Start eines Dienstes keine parallel offene Sitzung.
"""
removed = []
for entry in reversed(_registry_load()):
if only_dead and _pid_alive(entry.get("pid")):
continue
kind, value = entry.get("kind"), entry.get("value")
extra = entry.get("extra") or {}
try:
+32
View File
@@ -68,6 +68,15 @@ button:hover, .button:hover { filter: brightness(1.08); text-decoration: none; }
}
input[type=checkbox] { width: 1.05rem; height: 1.05rem; }
.empty { padding: 2rem 1rem; text-align: center; color: var(--muted); }
.login { max-width: 26rem; margin: 3rem auto; }
.login .card { padding: 1rem; display: flex; flex-direction: column; gap: .8rem; }
.login label { display: flex; flex-direction: column; gap: .3rem; font-size: .9rem;
color: var(--muted); }
.login input, .login select {
font: inherit; color: var(--fg); background: var(--bg);
border: 1px solid var(--line); border-radius: 7px; padding: .5rem .6rem;
}
.login button { margin-top: .3rem; padding: .55rem; }
@media (max-width: 640px) {
td.when, th.when { display: none; }
main { padding: .5rem; }
@@ -97,6 +106,29 @@ function pvesnapInit() {
document.addEventListener('DOMContentLoaded', pvesnapInit);
"""
LOGIN_FORM = """
<div class="login">
%(note)s
<h2>Anmeldung</h2>
<p class="muted">Mit den Benutzern von Proxmox VE - dieselben Zugangsdaten
wie im Proxmox-Webinterface.</p>
<form method="post" action="/login" class="card" autocomplete="on">
<label>Benutzername
<input name="username" value="%(username)s" autofocus required
autocomplete="username">
</label>
<label>Passwort
<input name="password" type="password" required
autocomplete="current-password">
</label>
<label>Realm
<select name="realm">%(realms)s</select>
</label>
<button class="primary" type="submit">Anmelden</button>
</form>
</div>
"""
PAGE = """<!doctype html>
<html lang="de">
<head>
+145
View File
@@ -0,0 +1,145 @@
"""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
+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)