Der Abschnitt zur Web-Oberflaeche beschreibt jetzt beides ausfuehrlich: die Einrichtung als Dienst ueber install.sh --with-webexplorer --port (mit Beispielen fuer eigene Adresse und --no-start), das Nachtraegliche Aendern ueber /etc/pvesnap/web.conf und die ueblichen systemctl-Befehle - sowie den Start von Hand mit einer Tabelle aller Parameter und Beispielen fuer die typischen Faelle: SSH-Tunnel, Backup-Team mit VM.Backup statt VM.Snapshot, einzelner Dienstleister ueber --allow-user, Zugangsschluessel statt Anmeldung, freies Verzeichnis, Aufraeumen. Der Explorer hat ebenfalls eine Parametertabelle bekommen. Dabei fiel auf, dass "pvesnap-web <vmid>" ohne Snapshot-Namen bisher gar nichts bewirkte - man bekam trotzdem die vollstaendige Gastliste. Jetzt schraenkt die Angabe die Oberflaeche wirklich auf diesen Gast ein, und zwar auch gegen Aufrufe ueber die Adresszeile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
976 lines
38 KiB
Python
976 lines
38 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 ..explorer import within
|
|
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=(), only_vmid=None):
|
|
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 ())
|
|
# Wurde beim Start eine VMID angegeben, gibt es auch nur diesen Gast -
|
|
# weder in der Liste noch ueber die Adresszeile.
|
|
self.only_vmid = only_vmid
|
|
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.only_vmid:
|
|
guests = [g for g in guests if g.vmid == self.only_vmid]
|
|
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.only_vmid and guest.vmid != self.only_vmid:
|
|
return False
|
|
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 · 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("/")))
|
|
if not within(root, candidate):
|
|
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 → 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 = []
|
|
broken = 0
|
|
for snap in list_snapshots(state.proxmox, guest):
|
|
when = (datetime.fromtimestamp(snap.snaptime).strftime("%d.%m.%Y %H:%M")
|
|
if snap.snaptime else "?")
|
|
if not snap.complete:
|
|
# Halbfertiger Eintrag aus einem abgebrochenen Lauf - dahinter
|
|
# liegt auf dem Storage nichts, was sich oeffnen liesse.
|
|
broken += 1
|
|
rows.append(
|
|
"<tr class='muted'><td class='name'><span class='icon'>⚠</span>%s</td>"
|
|
"<td class='when'>%s</td><td class='muted'>unvollstaendig (%s)</td>"
|
|
"<td class='muted'>nicht zu oeffnen</td></tr>"
|
|
% (esc(snap.name), esc(when), esc(snap.snapstate)))
|
|
continue
|
|
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)))
|
|
|
|
warning = ""
|
|
if broken:
|
|
warning = ("<div class='note warn'>%d Eintrag/Eintraege sind "
|
|
"unvollstaendig - sie stammen aus abgebrochenen Laeufen und "
|
|
"enthalten keine Daten. Aufraeumen auf dem Host mit: "
|
|
"<code>%s delsnapshot %d <name> --force</code></div>"
|
|
% (broken, "pct" if guest.type == "lxc" else "qm", guest.vmid))
|
|
|
|
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>"
|
|
"%s"
|
|
"<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), warning,
|
|
"".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"]),
|
|
"<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>"))
|
|
|
|
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, only_vmid=args.vmid)
|
|
|
|
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())
|