Auf dem Testhost (Ceph RBD) meldete lsblk fuer jede Partition eines
eingebundenen Snapshots "kein Dateisystem" - auch fuer eine 31,5-GB-
Partition, auf der offensichtlich ext4 liegt. Nichts liess sich einhaengen.
Grund: lsblk nimmt FSTYPE aus der udev-Datenbank, und die ist bei frisch
gemappten rbd-Geraeten leer, weil udev dort keine blkid-Probe faehrt. Ein
direktes "blkid -p" auf denselben Geraeten liefert dagegen sauber vfat und
ext4. Der Typ wird jetzt so ermittelt, wenn lsblk nichts weiss.
Ausserdem: die Auswahl der Dateisysteme zeigte nur abgeschnittene
Mountpfade, die sich alle glichen, und vorausgewaehlt war das erste
gefundene - in der Praxis gern die 200-MB-EFI-Partition. Jetzt stehen dort
Geraet, Typ, Groesse und ein Hinweis ("Linux-Wurzelverzeichnis",
"Startpartition"), und die wahrscheinlichste Wurzel steht oben.
Auf pvetest01 gegen echte Snapshots geprueft: einbinden, alle drei
Dateisysteme mounten, Explorer, Web-Oberflaeche mit Download und ZIP,
Ausbruchversuch abgewiesen, restloses Aufraeumen auch nach hartem Abbruch.
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 = [(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:
|
|
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())
|