Files
proxmox-snapshot-service/pvesnap/web/server.py
T
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

949 lines
37 KiB
Python

"""pvesnap-web - Snapshot-Inhalte im Browser durchsehen und herunterladen.
pvesnap-web Gast und Snapshot im Browser waehlen
pvesnap-web 802 <snapname> diesen Snapshot gleich oeffnen
pvesnap-web --path /verzeichnis beliebiges Verzeichnis (ohne Proxmox)
Beim Start wird eine Adresse mit Zugangsschluessel ausgegeben. Nur wer diese
Adresse kennt, kommt an die Daten - der Schluessel wandert danach in ein
Cookie. Es wird ausschliesslich gelesen; ueber die Oberflaeche laesst sich
nichts veraendern oder loeschen.
"""
from __future__ import annotations
import argparse
import html
import io
import logging
import os
import secrets
import signal
import socket
import stat
import sys
import threading
import time
import urllib.parse
import zipfile
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from .. import __version__
from ..proxmox import Proxmox, ProxmoxError, pvesh_available
from ..snapfs import (Session, SnapfsError, cleanup_leftovers, human_bytes,
list_snapshots)
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" # 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)
# ---------------------------------------------------------------------------
# Zustand
# ---------------------------------------------------------------------------
class AppState:
"""Was gerade geoeffnet ist. Es ist immer hoechstens ein Snapshot offen."""
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)
@property
def subtitle(self):
if self.fixed_path:
return html.escape(self.fixed_path)
if self.session:
return "%s &nbsp;·&nbsp; Snapshot <b>%s</b>" % (
html.escape(self.guest.label), html.escape(self.snapname))
return "Snapshot-Explorer %s" % __version__
def open_snapshot(self, guest, snapname):
with self.lock:
self.close_locked()
session = Session(self.proxmox, guest, snapname)
session.open()
session.prepare()
self.session = session
self.guest = guest
self.snapname = snapname
self.notes = list(session.notes)
self.roots = [(label, mountpoint)
for mountpoint, label in session.mount_entries()]
def close_locked(self):
if self.session:
self.session.close()
self.session = None
self.guest = None
self.snapname = ""
self.notes = []
if not self.fixed_path:
self.roots = []
def close(self):
with self.lock:
self.close_locked()
def root(self, index):
if not self.roots:
return ""
index = max(0, min(index, len(self.roots) - 1))
return self.roots[index][1]
def resolve(self, index, relative):
"""Relativen Pfad in einen echten Pfad innerhalb des Snapshots umsetzen.
Alles ausserhalb der eingebundenen Wurzel wird abgewiesen - damit
laesst sich ueber die Adresszeile nichts anderes vom Host lesen.
"""
root = self.root(index)
if not root:
raise ValueError("Es ist kein Snapshot geoeffnet.")
candidate = os.path.realpath(os.path.join(root, (relative or "").lstrip("/")))
root_real = os.path.realpath(root)
if candidate != root_real and not candidate.startswith(root_real + os.sep):
raise ValueError("Pfad liegt ausserhalb des Snapshots.")
return candidate
def relative(self, index, path):
root = os.path.realpath(self.root(index))
path = os.path.realpath(path)
if path == root:
return ""
return os.path.relpath(path, root)
# ---------------------------------------------------------------------------
# Hilfsfunktionen fuer HTML
# ---------------------------------------------------------------------------
def esc(text):
return html.escape(str(text), quote=True)
def link(path_part, **params):
query = urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")})
return path_part + (("?" + query) if query else "")
def page(title, state, body, actions=""):
return PAGE % {
"title": esc(title),
"style": STYLE,
"script": SCRIPT,
"home": "/",
"subtitle": state.subtitle,
"actions": actions,
"body": body,
}
def icon_for(entry_is_dir, is_link, is_special):
if is_special:
return "⚙"
if is_link:
return "↪"
return "📁" if entry_is_dir else "📄"
# ---------------------------------------------------------------------------
# HTTP
# ---------------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "pvesnap/" + __version__
state = None # wird beim Start gesetzt
# -- Grundlagen -------------------------------------------------------
def log_message(self, fmt, *args):
log.info("%s %s", self.address_string(), fmt % args)
def _send(self, body, status=200, content_type="text/html; charset=utf-8",
extra_headers=None):
if isinstance(body, str):
body = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
for key, value in (extra_headers or {}).items():
self.send_header(key, value)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _redirect(self, target, cookie=None):
self.send_response(303)
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=%d"
% (name, value, age))
self.end_headers()
def _error(self, message, status=400):
body = page("Fehler", self.state,
'<div class="note warn"><b>Das ging nicht:</b><br>%s</div>'
'<p><a class="button" href="/">Zurueck zum Anfang</a></p>'
% esc(message))
self._send(body, status=status)
# -- 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):
"""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 ---------------------------------------------------------
def do_HEAD(self):
self.do_GET()
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
route = parsed.path
if route == "/login" and self.state.auth_mode == "pve":
self._page_login()
return
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(user)
elif route == "/snapshots":
self._page_snapshots(params, user)
elif route == "/files":
self._page_files(params, user)
elif route == "/download":
self._download(params)
else:
self._error("Unbekannte Seite: %s" % route, status=404)
except (ValueError, SnapfsError, ProxmoxError) as exc:
self._error(str(exc))
except BrokenPipeError:
pass # Browser hat den Download abgebrochen
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
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, 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:
self._error("Unbekannte Aktion", status=404)
except (ValueError, SnapfsError, ProxmoxError) as exc:
self._error(str(exc))
except BrokenPipeError:
pass
# -- Seiten -----------------------------------------------------------
def _page_home(self, user=""):
state = self.state
if state.open:
self._redirect(link("/files", fs=0, p=""))
return
if not state.proxmox:
self._error("Kein Verzeichnis geoeffnet.")
return
guests = state.visible_guests(user)
rows = []
for guest in guests:
rows.append(
"<tr><td class='name'><span class='icon'>%s</span>"
"<a href='%s'>%s</a></td>"
"<td>%s</td><td>%s</td><td>%s</td><td class='muted'>%s</td></tr>"
% ("📦" if guest.type == "lxc" else "🖥",
esc(link("/snapshots", vmid=guest.vmid)),
esc(guest.name or ("VM %d" % guest.vmid)),
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 = (
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, actions=self._actions(user)))
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):
when = (datetime.fromtimestamp(snap.snaptime).strftime("%d.%m.%Y %H:%M")
if snap.snaptime else "?")
rows.append(
"<tr><td class='name'><span class='icon'>🕘</span>%s</td>"
"<td class='when'>%s</td><td class='muted'>%s</td>"
"<td><form method='post' action='/open'>"
"<input type='hidden' name='vmid' value='%d'>"
"<input type='hidden' name='snap' value='%s'>"
"<button class='primary' type='submit'>Oeffnen</button>"
"</form></td></tr>"
% (esc(snap.name), esc(when),
esc(snap.description.replace("\n", " ")[:120]),
guest.vmid, esc(snap.name)))
body = (
"<p><a href='/'>&larr; Gaeste</a></p>"
"<h2>Snapshots von %s</h2>"
"<div class='note'>Das Oeffnen bindet den Snapshot schreibgeschuetzt "
"ein - das dauert ein paar Sekunden.</div>"
"<div class='card'><table><thead><tr><th>Snapshot</th><th class='when'>"
"Erstellt</th><th>Beschreibung</th><th></th></tr></thead><tbody>%s</tbody>"
"</table></div>"
% (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,
actions=self._actions(user)))
def _open_snapshot(self, form, user=""):
state = self.state
vmid = int((form.get("vmid") or ["0"])[0])
snapname = (form.get("snap") or [""])[0]
guest = next((g for g in state.proxmox.inventory() if g.vmid == vmid), None)
if guest is None or not snapname:
self._error("Gast oder Snapshot fehlt.")
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
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, user=""):
state = self.state
if not state.open:
self._redirect("/")
return
fs = int((params.get("fs") or ["0"])[0])
relative = (params.get("p") or [""])[0]
path = state.resolve(fs, relative)
if not os.path.isdir(path):
self._redirect(link("/download", fs=fs, p=relative))
return
entries = []
try:
with os.scandir(path) as scan:
for item in scan:
entries.append(self._describe(item))
except OSError as exc:
self._error("%s: %s" % (path, exc.strerror or exc))
return
entries.sort(key=lambda e: (0 if e["dir"] else 1, e["name"].lower()))
body = [self._crumbs(fs, relative), self._filesystems(fs)]
for note in state.notes:
body.append("<div class='note'>%s</div>" % esc(note))
rows = []
if relative:
parent = os.path.dirname(relative.rstrip("/"))
rows.append("<tr><td class='pick'></td><td class='name'>"
"<span class='icon'>↩</span><a href='%s'>..</a></td>"
"<td class='size'></td><td class='when'></td></tr>"
% esc(link("/files", fs=fs, p=parent)))
for entry in entries:
child = os.path.join(relative, entry["name"]) if relative else entry["name"]
target = link("/files" if entry["dir"] else "/download", fs=fs, p=child)
rows.append(
"<tr><td class='pick'>%s</td>"
"<td class='name'><span class='icon'>%s</span><a href='%s'>%s</a></td>"
"<td class='size'>%s</td><td class='when'>%s</td></tr>"
% ("" if entry["special"] else
"<input class='pick-one' type='checkbox' name='p' value='%s' form='zipform'>"
% esc(child),
icon_for(entry["dir"], entry["link"], entry["special"]),
esc(target), esc(entry["name"]),
"&lt;DIR&gt;" if entry["dir"] else
("&lt;SPEZ&gt;" if entry["special"] else esc(human_bytes(entry["size"]))),
esc(entry["when"])))
body.append(
"<form id='zipform' method='post' action='/zip'>"
"<input type='hidden' name='fs' value='%d'>"
"<input type='hidden' name='base' value='%s'>"
"</form>" % (fs, esc(relative)))
body.append(
"<div class='bar'>"
"<label class='pill'><input type='checkbox' id='pick-all'> alle</label>"
"<span class='muted' id='pick-info'></span>"
"<button class='primary' type='submit' form='zipform'>"
"Auswahl als ZIP laden</button>"
"<span class='muted'>Einzelne Dateien: einfach anklicken.</span>"
"</div>")
body.append(
"<div class='card'><table><thead><tr><th class='pick'></th><th>Name</th>"
"<th class='size'>Groesse</th><th class='when'>Geaendert</th></tr></thead>"
"<tbody>%s</tbody></table></div>"
% ("".join(rows) or "<tr><td colspan='4' class='empty'>Leeres Verzeichnis</td></tr>"))
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):
try:
info = item.stat(follow_symlinks=False)
mode = info.st_mode
is_link = stat.S_ISLNK(mode)
if is_link:
try:
is_dir = stat.S_ISDIR(item.stat(follow_symlinks=True).st_mode)
except OSError:
is_dir = False
special = False
else:
is_dir = stat.S_ISDIR(mode)
special = not (is_dir or stat.S_ISREG(mode))
return {"name": item.name, "dir": is_dir, "link": is_link,
"special": special, "size": info.st_size,
"when": datetime.fromtimestamp(info.st_mtime).strftime("%d.%m.%y %H:%M")}
except OSError:
return {"name": item.name, "dir": False, "link": False, "special": True,
"size": 0, "when": ""}
def _crumbs(self, fs, relative):
parts = [p for p in (relative or "").split("/") if p]
crumbs = ["<a href='%s'>%s</a>" % (esc(link("/files", fs=fs, p="")), "⌂")]
walked = ""
for part in parts:
walked = os.path.join(walked, part)
crumbs.append("<span>/</span><a href='%s'>%s</a>"
% (esc(link("/files", fs=fs, p=walked)), esc(part)))
return "<div class='crumbs'>%s</div>" % "".join(crumbs)
def _filesystems(self, current):
state = self.state
if len(state.roots) < 2:
return ""
items = []
for index, (label, _path) in enumerate(state.roots):
if index == current:
items.append("<span class='pill'>%s</span>" % esc(label))
else:
items.append("<a class='button' href='%s'>%s</a>"
% (esc(link("/files", fs=index, p="")), esc(label)))
return "<div class='bar'><span class='muted'>Dateisystem:</span>%s</div>" % "".join(items)
# -- Herunterladen ----------------------------------------------------
def _download(self, params):
state = self.state
fs = int((params.get("fs") or ["0"])[0])
relative = (params.get("p") or [""])[0]
path = state.resolve(fs, relative)
if os.path.isdir(path):
self._zip_response([relative], fs, os.path.basename(relative) or "snapshot")
return
try:
info = os.stat(path)
except OSError as exc:
self._error("%s: %s" % (relative, exc.strerror or exc), status=404)
return
if not stat.S_ISREG(info.st_mode):
self._error("Nur regulaere Dateien lassen sich herunterladen.")
return
name = os.path.basename(path) or "datei"
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(info.st_size))
self.send_header("Content-Disposition",
"attachment; filename*=UTF-8''%s"
% urllib.parse.quote(name, safe=""))
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
if self.command == "HEAD":
return
with open(path, "rb") as handle:
while True:
chunk = handle.read(CHUNK)
if not chunk:
break
self.wfile.write(chunk)
def _zip(self, form):
fs = int((form.get("fs") or ["0"])[0])
base = (form.get("base") or [""])[0]
selected = form.get("p") or []
if not selected:
self._error("Nichts ausgewaehlt.")
return
name = os.path.basename(base) or "snapshot"
self._zip_response(selected, fs, name)
def _zip_response(self, relatives, fs, name):
"""ZIP direkt in die Verbindung schreiben - ohne Zwischendatei."""
state = self.state
paths = []
for relative in relatives:
path = state.resolve(fs, relative)
if os.path.lexists(path):
paths.append((relative, path))
if not paths:
self._error("Nichts gefunden.", status=404)
return
filename = "%s-%s.zip" % (name or "snapshot", time.strftime("%Y%m%d-%H%M%S"))
self.send_response(200)
self.send_header("Content-Type", "application/zip")
self.send_header("Transfer-Encoding", "chunked")
self.send_header("Content-Disposition",
"attachment; filename*=UTF-8''%s"
% urllib.parse.quote(filename, safe=""))
self.end_headers()
if self.command == "HEAD":
return
sink = _ChunkedSink(self.wfile)
skipped = 0
try:
with zipfile.ZipFile(sink, "w", compression=zipfile.ZIP_DEFLATED,
compresslevel=1, allowZip64=True) as archive:
for relative, path in paths:
skipped += self._add_to_zip(archive, path,
os.path.basename(relative.rstrip("/"))
or "inhalt")
sink.finish()
except BrokenPipeError:
log.info("Download abgebrochen: %s", filename)
if skipped:
log.info("%s: %d Spezialdatei(en) uebersprungen", filename, skipped)
def _add_to_zip(self, archive, path, arcname):
skipped = 0
try:
info = os.lstat(path)
except OSError:
return 1
if stat.S_ISLNK(info.st_mode):
entry = zipfile.ZipInfo(arcname)
entry.create_system = 3
entry.external_attr = (0xA000 | 0o777) << 16 # Symlink im ZIP
archive.writestr(entry, os.readlink(path))
return 0
if stat.S_ISDIR(info.st_mode):
empty = True
try:
for name in sorted(os.listdir(path)):
empty = False
skipped += self._add_to_zip(archive, os.path.join(path, name),
os.path.join(arcname, name))
except OSError:
skipped += 1
if empty:
archive.writestr(zipfile.ZipInfo(arcname.rstrip("/") + "/"), b"")
return skipped
if not stat.S_ISREG(info.st_mode):
return 1 # Geraetedateien, Sockets, FIFOs
try:
archive.write(path, arcname)
except OSError:
skipped += 1
return skipped
class _ChunkedSink:
"""Datei-aehnliches Objekt, das direkt in die HTTP-Verbindung schreibt.
zipfile kommt mit nicht positionierbaren Stroemen zurecht, braucht aber
tell(); deshalb wird die Position selbst mitgezaehlt.
"""
def __init__(self, wfile):
self.wfile = wfile
self.position = 0
self.closed = False
def write(self, data):
if not data:
return 0
self.wfile.write(b"%x\r\n" % len(data))
self.wfile.write(data)
self.wfile.write(b"\r\n")
self.position += len(data)
return len(data)
def tell(self):
return self.position
def flush(self):
try:
self.wfile.flush()
except (BrokenPipeError, ValueError):
pass
@staticmethod
def seekable():
return False
def finish(self):
if not self.closed:
self.closed = True
self.wfile.write(b"0\r\n\r\n")
self.flush()
# ---------------------------------------------------------------------------
# Start
# ---------------------------------------------------------------------------
def _addresses(port):
"""Erreichbare Adressen des Hosts - fuer die Ausgabe beim Start."""
found = []
try:
hostname = socket.gethostname()
for info in socket.getaddrinfo(hostname, port, proto=socket.IPPROTO_TCP):
address = info[4][0]
if address not in found and not address.startswith("127."):
found.append(address)
except OSError:
pass
return found or ["<IP-des-Hosts>"]
def build_parser():
parser = argparse.ArgumentParser(
prog="pvesnap-web",
description="Snapshot-Inhalte im Browser durchsehen und herunterladen.")
parser.add_argument("vmid", nargs="?", type=int, help="VMID des Gastes")
parser.add_argument("snapshot", nargs="?", help="Name des Snapshots")
parser.add_argument("-b", "--bind", default="0.0.0.0",
help="Adresse, auf der gelauscht wird (Vorgabe: %(default)s)")
parser.add_argument("-P", "--port", type=int, default=8823,
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="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")
parser.add_argument("-V", "--version", action="version",
version="pvesnap-web %s" % __version__)
return parser
def main(argv=None):
args = build_parser().parse_args(argv)
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S")
if args.cleanup:
removed = cleanup_leftovers(verbose=True)
print("%d Eintrag/Eintraege aufgeraeumt." % len(removed))
return 0
proxmox = None
if not args.path:
if not pvesh_available():
print("'pvesh' nicht gefunden - pvesnap-web laeuft auf dem Proxmox-Host.\n"
"Zum Ausprobieren: pvesnap-web --path /ein/verzeichnis", file=sys.stderr)
return 3
if os.geteuid() != 0:
print("pvesnap-web braucht root-Rechte (Snapshots einbinden und mounten).",
file=sys.stderr)
return 4
proxmox = Proxmox()
elif not os.path.isdir(args.path):
print("Kein Verzeichnis: %s" % args.path, file=sys.stderr)
return 2
# 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)
if guest is None:
print("Gast %d nicht gefunden." % args.vmid, file=sys.stderr)
return 2
log.info("Binde Snapshot %s von %s ein ...", args.snapshot, guest.label)
try:
state.open_snapshot(guest, args.snapshot)
except (SnapfsError, ProxmoxError) as exc:
print("Snapshot nicht zu oeffnen: %s" % exc, file=sys.stderr)
return 5
# 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.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)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nBeende ...")
finally:
server.server_close()
state.close()
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())