"""pvesnap-web - Snapshot-Inhalte im Browser durchsehen und herunterladen. pvesnap-web Gast und Snapshot im Browser waehlen pvesnap-web 802 diesen Snapshot gleich oeffnen pvesnap-web --path /verzeichnis beliebiges Verzeichnis (ohne Proxmox) Beim Start wird eine Adresse mit Zugangsschluessel ausgegeben. Nur wer diese Adresse kennt, kommt an die Daten - der Schluessel wandert danach in ein Cookie. Es wird ausschliesslich gelesen; ueber die Oberflaeche laesst sich nichts veraendern oder loeschen. """ from __future__ import annotations import argparse import html import io import logging import os import secrets import signal import socket import stat import sys import threading import time import urllib.parse import zipfile from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from .. import __version__ from ..proxmox import Proxmox, ProxmoxError, pvesh_available from ..snapfs import (Session, SnapfsError, cleanup_leftovers, human_bytes, list_snapshots) from .assets import LOGIN_FORM, PAGE, SCRIPT, STYLE from .auth import DEFAULT_PRIVILEGE, AuthError, authenticate, list_realms, may_access, permissions log = logging.getLogger("pvesnap.web") COOKIE = "pvesnap_token" # Zugangsschluessel (Modus "token") SESSION_COOKIE = "pvesnap_session" # Anmeldung (Modus "pve") CHUNK = 256 * 1024 SESSION_MAX_AGE = 8 * 3600 class Sessions: """Angemeldete Benutzer. Nur im Arbeitsspeicher - ein Neustart meldet alle ab.""" def __init__(self, max_age=SESSION_MAX_AGE): self.max_age = max_age self._entries = {} self._lock = threading.Lock() def create(self, userid): token = secrets.token_urlsafe(24) with self._lock: self._entries[token] = {"userid": userid, "until": time.time() + self.max_age, "permissions": None} return token def get(self, token): if not token: return None with self._lock: entry = self._entries.get(token) if entry is None: return None if entry["until"] < time.time(): del self._entries[token] return None return entry def drop(self, token): with self._lock: self._entries.pop(token, None) # --------------------------------------------------------------------------- # Zustand # --------------------------------------------------------------------------- class AppState: """Was gerade geoeffnet ist. Es ist immer hoechstens ein Snapshot offen.""" def __init__(self, token="", proxmox=None, fixed_path="", auth_mode="token", privilege=DEFAULT_PRIVILEGE, allowed_users=()): self.token = token self.proxmox = proxmox self.fixed_path = fixed_path self.auth_mode = auth_mode # pve | token | none self.privilege = privilege self.allowed_users = tuple(allowed_users or ()) self.sessions = Sessions() self.session = None self.guest = None self.snapname = "" self.roots = [] # [(Beschriftung, Pfad)] self.lock = threading.Lock() self.notes = [] self.opened_by = "" if fixed_path: self.roots = [(fixed_path, os.path.realpath(fixed_path))] def visible_guests(self, userid): """Nur die Gaeste, in die dieser Benutzer hineinsehen darf.""" guests = self.proxmox.inventory(refresh=True) if self.auth_mode != "pve": return guests tree = permissions(userid) return [g for g in guests if may_access(userid, g, self.privilege, tree, self.allowed_users)] def may_open(self, userid, guest): if self.auth_mode != "pve": return True return may_access(userid, guest, self.privilege, None, self.allowed_users) @property def open(self): return bool(self.roots) @property def subtitle(self): if self.fixed_path: return html.escape(self.fixed_path) if self.session: return "%s  ยท  Snapshot %s" % ( html.escape(self.guest.label), html.escape(self.snapname)) return "Snapshot-Explorer %s" % __version__ def open_snapshot(self, guest, snapname): with self.lock: self.close_locked() session = Session(self.proxmox, guest, snapname) session.open() session.prepare() self.session = session self.guest = guest self.snapname = snapname self.notes = list(session.notes) self.roots = [(label, mountpoint) for mountpoint, label in session.mount_entries()] def close_locked(self): if self.session: self.session.close() self.session = None self.guest = None self.snapname = "" self.notes = [] if not self.fixed_path: self.roots = [] def close(self): with self.lock: self.close_locked() def root(self, index): if not self.roots: return "" index = max(0, min(index, len(self.roots) - 1)) return self.roots[index][1] def resolve(self, index, relative): """Relativen Pfad in einen echten Pfad innerhalb des Snapshots umsetzen. Alles ausserhalb der eingebundenen Wurzel wird abgewiesen - damit laesst sich ueber die Adresszeile nichts anderes vom Host lesen. """ root = self.root(index) if not root: raise ValueError("Es ist kein Snapshot geoeffnet.") candidate = os.path.realpath(os.path.join(root, (relative or "").lstrip("/"))) root_real = os.path.realpath(root) if candidate != root_real and not candidate.startswith(root_real + os.sep): raise ValueError("Pfad liegt ausserhalb des Snapshots.") return candidate def relative(self, index, path): root = os.path.realpath(self.root(index)) path = os.path.realpath(path) if path == root: return "" return os.path.relpath(path, root) # --------------------------------------------------------------------------- # Hilfsfunktionen fuer HTML # --------------------------------------------------------------------------- def esc(text): return html.escape(str(text), quote=True) def link(path_part, **params): query = urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) return path_part + (("?" + query) if query else "") def page(title, state, body, actions=""): return PAGE % { "title": esc(title), "style": STYLE, "script": SCRIPT, "home": "/", "subtitle": state.subtitle, "actions": actions, "body": body, } def icon_for(entry_is_dir, is_link, is_special): if is_special: return "โš™" if is_link: return "โ†ช" return "๐Ÿ“" if entry_is_dir else "๐Ÿ“„" # --------------------------------------------------------------------------- # HTTP # --------------------------------------------------------------------------- class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" server_version = "pvesnap/" + __version__ state = None # wird beim Start gesetzt # -- Grundlagen ------------------------------------------------------- def log_message(self, fmt, *args): log.info("%s %s", self.address_string(), fmt % args) def _send(self, body, status=200, content_type="text/html; charset=utf-8", extra_headers=None): if isinstance(body, str): body = body.encode("utf-8") self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("X-Content-Type-Options", "nosniff") self.send_header("Referrer-Policy", "no-referrer") for key, value in (extra_headers or {}).items(): self.send_header(key, value) self.end_headers() if self.command != "HEAD": self.wfile.write(body) def _redirect(self, target, cookie=None): self.send_response(303) self.send_header("Location", target) self.send_header("Content-Length", "0") if cookie: name, value = cookie age = 0 if value == "" else SESSION_MAX_AGE self.send_header("Set-Cookie", "%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=%d" % (name, value, age)) self.end_headers() def _error(self, message, status=400): body = page("Fehler", self.state, '
Das ging nicht:
%s
' '

Zurueck zum Anfang

' % 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( "" % (esc(value), " selected" if value == realm else "", esc(label)) for value, label in realms) note = ("
%s
" % 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%s" "
" "
" % (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, '
Zugangsschluessel fehlt oder ' 'ist falsch.
Bitte die Adresse benutzen, die beim ' 'Start von pvesnap-web ausgegeben ' 'wurde.
'), 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( "%s" "%s" "%s%s%s%s" % ("๐Ÿ“ฆ" 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 = ("
Fuer keinen Gast liegt das noetige Recht " "%s vor. Ein Administrator kann es in Proxmox unter " "Datacenter → Permissions vergeben.
" % esc(state.privilege)) body = ( hint + "

Gast auswaehlen

" "
" "%s" "
NameVMIDTypStatusNode
" % ("".join(rows) or "Keine Gaeste gefunden.")) self._send(page("pvesnap - Gaeste", state, body, actions=self._actions(user))) def _page_snapshots(self, params, user=""): state = self.state vmid = int((params.get("vmid") or ["0"])[0]) guest = next((g for g in state.proxmox.inventory() if g.vmid == vmid), None) if guest is None: self._error("Gast %d nicht gefunden." % vmid, status=404) return if not state.may_open(user, guest): self._error("Fuer %s fehlt das Recht %s auf diesem Gast." % (user, state.privilege), status=403) return rows = [] for snap in list_snapshots(state.proxmox, guest): when = (datetime.fromtimestamp(snap.snaptime).strftime("%d.%m.%Y %H:%M") if snap.snaptime else "?") rows.append( "๐Ÿ•˜%s" "%s%s" "
" "" "" "" "
" % (esc(snap.name), esc(when), esc(snap.description.replace("\n", " ")[:120]), guest.vmid, esc(snap.name))) body = ( "

← Gaeste

" "

Snapshots von %s

" "
Das Oeffnen bindet den Snapshot schreibgeschuetzt " "ein - das dauert ein paar Sekunden.
" "
%s" "
Snapshot" "ErstelltBeschreibung
" % (esc(guest.label), "".join(rows) or "" "Dieser Gast hat keine Snapshots.")) 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("
%s
" % esc(note)) rows = [] if relative: parent = os.path.dirname(relative.rstrip("/")) rows.append("" "โ†ฉ.." "" % 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( "%s" "%s%s" "%s%s" % ("" if entry["special"] else "" % 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( "
" "" "" "
" % (fs, esc(relative))) body.append( "
" "" "" "" "Einzelne Dateien: einfach anklicken." "
") body.append( "
" "" "%s
NameGroesseGeaendert
" % ("".join(rows) or "Leeres Verzeichnis")) close_button = ("
" "
" 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 = ["%s" % (esc(link("/files", fs=fs, p="")), "โŒ‚")] walked = "" for part in parts: walked = os.path.join(walked, part) crumbs.append("/%s" % (esc(link("/files", fs=fs, p=walked)), esc(part))) return "
%s
" % "".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("%s" % esc(label)) else: items.append("%s" % (esc(link("/files", fs=index, p="")), esc(label))) return "
Dateisystem:%s
" % "".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 [""] def build_parser(): parser = argparse.ArgumentParser( prog="pvesnap-web", description="Snapshot-Inhalte im Browser durchsehen und herunterladen.") parser.add_argument("vmid", nargs="?", type=int, help="VMID des Gastes") parser.add_argument("snapshot", nargs="?", help="Name des Snapshots") parser.add_argument("-b", "--bind", default="0.0.0.0", help="Adresse, auf der gelauscht wird (Vorgabe: %(default)s)") parser.add_argument("-P", "--port", type=int, default=8823, help="Port (Vorgabe: %(default)s)") parser.add_argument("-p", "--path", help="beliebiges Verzeichnis freigeben, ohne Proxmox zu fragen") parser.add_argument("-a", "--auth", choices=("pve", "token", "none"), help="Anmeldung: 'pve' = Benutzer von Proxmox VE (Vorgabe auf " "einem PVE-Host), 'token' = Zugangsschluessel in der " "Adresse, 'none' = ohne jede Anmeldung") parser.add_argument("--require-privilege", default=DEFAULT_PRIVILEGE, metavar="RECHT", help="Recht, das ein Benutzer auf einem Gast haben muss, um " "dessen Dateien zu sehen (Vorgabe: %(default)s)") parser.add_argument("--allow-user", action="append", default=[], metavar="USER@REALM", help="dieser Benutzer darf alles sehen (mehrfach moeglich)") parser.add_argument("-t", "--token", help="Zugangsschluessel selbst vorgeben") parser.add_argument("--no-token", action="store_true", help="Kurzform fuer --auth none (nur in vertrauenswuerdigen " "Netzen!)") parser.add_argument("--cleanup", action="store_true", help="haengengebliebene Einbindungen aufraeumen und beenden") parser.add_argument("-v", "--verbose", action="store_true") parser.add_argument("-V", "--version", action="version", version="pvesnap-web %s" % __version__) return parser def main(argv=None): args = build_parser().parse_args(argv) logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S") if args.cleanup: removed = cleanup_leftovers(verbose=True) print("%d Eintrag/Eintraege aufgeraeumt." % len(removed)) return 0 proxmox = None if not args.path: if not pvesh_available(): print("'pvesh' nicht gefunden - pvesnap-web laeuft auf dem Proxmox-Host.\n" "Zum Ausprobieren: pvesnap-web --path /ein/verzeichnis", file=sys.stderr) return 3 if os.geteuid() != 0: print("pvesnap-web braucht root-Rechte (Snapshots einbinden und mounten).", file=sys.stderr) return 4 proxmox = Proxmox() elif not os.path.isdir(args.path): print("Kein Verzeichnis: %s" % args.path, file=sys.stderr) return 2 # Auf einem Proxmox-Host wird ohne weitere Angabe die Anmeldung mit den # Proxmox-Benutzern verwendet; ohne Proxmox bleibt nur der Zugangsschluessel. mode = args.auth or ("none" if args.no_token else ("token" if args.path else "pve")) if mode == "pve" and not proxmox: print("--auth pve braucht einen Proxmox-Host (mit --path nicht moeglich).", file=sys.stderr) return 2 token = (args.token or secrets.token_urlsafe(18)) if mode == "token" else "" state = AppState(token=token, proxmox=proxmox, fixed_path=args.path or "", auth_mode=mode, privilege=args.require_privilege, allowed_users=args.allow_user) if args.vmid and args.snapshot and proxmox: guest = next((g for g in proxmox.inventory() if g.vmid == args.vmid), None) if guest is None: print("Gast %d nicht gefunden." % args.vmid, file=sys.stderr) return 2 log.info("Binde Snapshot %s von %s ein ...", args.snapshot, guest.label) try: state.open_snapshot(guest, args.snapshot) except (SnapfsError, ProxmoxError) as exc: print("Snapshot nicht zu oeffnen: %s" % exc, file=sys.stderr) return 5 # Reste eines abgestuerzten Vorgaengers wegraeumen - laufende Sitzungen # anderer Prozesse bleiben unangetastet. cleanup_leftovers(only_dead=True) Handler.state = state server = ThreadingHTTPServer((args.bind, args.port), Handler) server.daemon_threads = True # Unter systemd kommt SIGTERM; ohne das bliebe der Snapshot eingebunden. def _terminate(_signum, _frame): raise KeyboardInterrupt signal.signal(signal.SIGTERM, _terminate) suffix = ("?token=%s" % token) if token else "" lines = ["", "pvesnap-web laeuft. Im Browser oeffnen:", ""] for address in _addresses(args.port): lines.append(" http://%s:%d/%s" % (address, args.port, suffix)) if args.bind in ("127.0.0.1", "localhost", "::1"): lines.append(" http://%s:%d/%s" % (args.bind, args.port, suffix)) lines.append("") if mode == "pve": lines.append(" Anmeldung mit den Benutzern von Proxmox VE.") lines.append(" Sichtbar ist ein Gast nur mit dem Recht '%s' darauf%s." % (state.privilege, (" (immer erlaubt: %s)" % ", ".join(state.allowed_users)) if state.allowed_users else "")) elif mode == "token": lines.append(" Anmeldung ueber den Zugangsschluessel in der Adresse.") else: lines.append(" ACHTUNG: ohne jede Anmeldung gestartet.") lines += ["", " Beenden mit Strg-C.", ""] # flush: sonst haengt die Adresse mit dem Schluessel im Puffer, sobald die # Ausgabe nicht direkt im Terminal landet (Pipe, systemd, tee). print("\n".join(lines), flush=True) try: server.serve_forever() except KeyboardInterrupt: print("\nBeende ...") finally: server.server_close() state.close() return 0 if __name__ == "__main__": # pragma: no cover sys.exit(main())