Files
proxmox-snapshot-service/pvesnap/web/server.py
T
duffyduckandClaude Opus 5 f37a942fea Snapshot-Explorer: Dateien zurueckholen, im Terminal und im Browser
Snapshots anzulegen half bisher nur halb - an die Daten darin kam man nur
ueber einen Rollback. Dafuer jetzt zwei Werkzeuge auf gemeinsamer Basis.

snapfs.py bindet einen Snapshot schreibgeschuetzt ein und liefert einen
gewoehnlichen Pfad; die Oberflaechen wissen dadurch nichts ueber Storages:

  rbd (Ceph)   rbd map pool/image@snap, bei nicht unterstuetzten
               Image-Features faellt es auf rbd-nbd zurueck
  zfspool      Container ueber .zfs/snapshot, VMs ueber einen Klon
  lvmthin/lvm  die Snapshot-LV snap_<volume>_<snapname> aktivieren
  dir/nfs/cifs qemu-nbd --load-snapshot (nur qcow2)

Gemountet wird mit ro,noload bzw. ro,norecovery,nouuid - Snapshots
laufender Gaeste haben fast immer ein unsauberes Journal. Alles Angelegte
steht in /run/pvesnap/explorer.json und laesst sich nach einem Absturz mit
"--cleanup" wieder abraeumen.

pvesnap-explorer: zwei Fenster wie im Midnight Commander, links der
Snapshot, rechts der lokale Rechner. Markieren, F5, Fortschrittsbalken,
ESC bricht ab, vorhandene Dateien werden abgefragt. In den Snapshot hinein
kann nicht kopiert werden. Geraetedateien und Sockets werden
uebersprungen, symbolische Verweise bleiben Verweise.

pvesnap-web: derselbe Inhalt im Browser, auch vom anderen Rechner.
Einzelne Dateien direkt, Verzeichnisse und Mehrfachauswahl als ZIP, das im
Strom erzeugt wird - ohne Zwischendatei auf der Platte. Zugang nur mit dem
beim Start ausgegebenen Schluessel; Pfade ausserhalb des Snapshots werden
abgewiesen; es wird ausschliesslich gelesen.

Nebenbei: die curses-Bausteine sind aus tui.py nach curses_util.py
gewandert, damit Editor und Explorer sie teilen.

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

776 lines
29 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 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 PAGE, SCRIPT, STYLE
log = logging.getLogger("pvesnap.web")
COOKIE = "pvesnap_token"
CHUNK = 256 * 1024
# ---------------------------------------------------------------------------
# Zustand
# ---------------------------------------------------------------------------
class AppState:
"""Was gerade geoeffnet ist. Es ist immer hoechstens ein Snapshot offen."""
def __init__(self, token, proxmox=None, fixed_path=""):
self.token = token
self.proxmox = proxmox
self.fixed_path = fixed_path
self.session = None
self.guest = None
self.snapname = ""
self.roots = [] # [(Beschriftung, Pfad)]
self.lock = threading.Lock()
self.notes = []
if fixed_path:
self.roots = [(fixed_path, os.path.realpath(fixed_path))]
@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 = [(mountpoint, mountpoint)
for mountpoint in sorted(set(session.mounts.values()))]
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:
self.send_header("Set-Cookie",
"%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400"
% (COOKIE, cookie))
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 _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
# -- 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
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)
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)
return
try:
if route == "/":
self._page_home()
elif route == "/snapshots":
self._page_snapshots(params)
elif route == "/files":
self._page_files(params)
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)
ok, _ = self._authorized(urllib.parse.parse_qs(parsed.query))
if not ok:
self._send("Zugang verweigert", status=403, content_type="text/plain")
return
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)
try:
if parsed.path == "/open":
self._open_snapshot(form)
elif parsed.path == "/close":
self.state.close()
self._redirect("/")
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):
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.proxmox.inventory(refresh=True)
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)))
body = (
"<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))
def _page_snapshots(self, params):
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
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))
def _open_snapshot(self, form):
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
log.info("Oeffne Snapshot %s von %s", snapname, guest.label)
state.open_snapshot(guest, snapname)
self._redirect(link("/files", fs=0, p=""))
def _page_files(self, params):
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>"))
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))
@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("-t", "--token", help="Zugangsschluessel selbst vorgeben")
parser.add_argument("--no-token", action="store_true",
help="ohne Zugangsschluessel (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
token = "" if args.no_token else (args.token or secrets.token_urlsafe(18))
state = AppState(token=token, proxmox=proxmox, fixed_path=args.path or "")
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
if args.no_token:
Handler._authorized = lambda self, params: (True, "")
Handler.state = state
server = ThreadingHTTPServer((args.bind, args.port), Handler)
server.daemon_threads = True
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("")
# 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())