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>
776 lines
29 KiB
Python
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 · 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='/'>← 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"]),
|
|
"<DIR>" if entry["dir"] else
|
|
("<SPEZ>" 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())
|