Web-Oberflaeche als Dienst, Anmeldung mit den Proxmox-Benutzern

install.sh --with-webexplorer --port <nummer> richtet pvesnap-web.service
ein. Fehlt der Port, bricht das Skript mit einer Erklaerung ab; --port
ohne --with-webexplorer, ein Port ausserhalb 1-65535, etwas anderes als
eine Zahl und Port 8006 (Proxmox selbst) werden ebenfalls abgefangen -
und zwar vor der root-Pruefung, damit ein Tippfehler sofort auffaellt.
--help beschreibt jetzt alle Optionen mit Beispielen.

Port, Adresse und Anmeldeart stehen in /etc/pvesnap/web.conf und lassen
sich dort aendern, ohne die Unit anzufassen.

Angemeldet wird mit den Benutzern von Proxmox VE: dieselbe Maske aus
Benutzer, Passwort und Realm-Auswahl, geprueft ueber POST /access/ticket
auf der lokalen API - also derselbe Weg wie im Proxmox-Webinterface. Alle
Realms funktionieren damit automatisch; Zwei-Faktor-Anmeldungen werden
abgewiesen statt halb durchgewinkt.

Ein gueltiges Passwort allein reicht nicht: zusaetzlich braucht der
Benutzer auf dem Gast das Recht VM.Snapshot (geprueft auf /, /vms,
/vms/<id> und dem Pool). Sonst koennte jeder Proxmox-Benutzer saemtliche
Dateien aller Gaeste lesen. In der Gastliste erscheinen nur erlaubte
Gaeste, root@pam sieht wie in Proxmox alles. Anpassbar ueber
--require-privilege und --allow-user.

Ausserdem: SIGTERM haengt einen offenen Snapshot wieder aus (ohne das
bliebe er bei "systemctl stop" eingebunden), und beim Start werden Reste
von Prozessen abgeraeumt, die es nicht mehr gibt - parallel laufende
Sitzungen bleiben dabei unangetastet.

Auf pvetest01 geprueft: Dienststart ueber systemd, Anmeldemaske mit den
Realms des Hosts, Abweisung ohne und mit falschen Zugangsdaten (401,
protokolliert mit Absender-IP), Zugriff ohne Recht (403 auf Snapshot-
Liste und Oeffnen), vollstaendiger Ablauf als Berechtigter bis zum
Datei-Download, Abmelden macht die Sitzung ungueltig, und SIGTERM
hinterlaesst weder rbd-Maps noch Mountpunkte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-07-31 10:15:56 +02:00
co-authored by Claude Opus 5
parent 91bfdea76c
commit 963463a1a1
9 changed files with 647 additions and 91 deletions
+62 -16
View File
@@ -25,9 +25,19 @@ Auf dem Proxmox-Host als root:
```bash ```bash
git clone <dieses-repo> pvesnap && cd pvesnap git clone <dieses-repo> pvesnap && cd pvesnap
./install.sh ./install.sh # nur der Snapshot-Dienst
./install.sh --with-webexplorer --port 8823 # zusätzlich die Web-Oberfläche
./install.sh --help # alle Optionen
``` ```
| Option | |
|---|---|
| `--with-webexplorer` | Web-Oberfläche als Dienst einrichten (**erfordert `--port`**) |
| `--port <nummer>` | Port der Web-Oberfläche |
| `--bind <adresse>` | Adresse, auf der sie lauscht (Vorgabe `0.0.0.0`) |
| `--no-start` | installieren, aber Dienste nicht starten |
| `--force` | auch ohne erkanntes Proxmox VE installieren |
Das Skript legt an: Das Skript legt an:
| Pfad | Inhalt | | Pfad | Inhalt |
@@ -38,7 +48,9 @@ Das Skript legt an:
| `/usr/local/bin/pvesnap-web` | Web-Oberfläche | | `/usr/local/bin/pvesnap-web` | Web-Oberfläche |
| `/etc/pvesnap/pvesnap.conf` | Konfiguration (wird bei Updates **nicht** überschrieben) | | `/etc/pvesnap/pvesnap.conf` | Konfiguration (wird bei Updates **nicht** überschrieben) |
| `/var/lib/pvesnap/state.json` | merkt sich die letzten Läufe | | `/var/lib/pvesnap/state.json` | merkt sich die letzten Läufe |
| `/etc/systemd/system/pvesnap.service` | systemd-Unit | | `/etc/systemd/system/pvesnap.service` | systemd-Unit des Snapshot-Dienstes |
| `/etc/systemd/system/pvesnap-web.service` | Unit der Web-Oberfläche (nur mit `--with-webexplorer`) |
| `/etc/pvesnap/web.conf` | Port und Anmeldeart der Web-Oberfläche |
In der mitgelieferten Beispielkonfiguration sind alle Gruppen auf `enabled = no` In der mitgelieferten Beispielkonfiguration sind alle Gruppen auf `enabled = no`
gesetzt — es passiert also erst etwas, wenn du eine Gruppe aktivierst. gesetzt — es passiert also erst etwas, wenn du eine Gruppe aktivierst.
@@ -389,37 +401,71 @@ bleiben Verweise.
### `pvesnap-web` — im Browser herunterladen ### `pvesnap-web` — im Browser herunterladen
Als Dienst (empfohlen, überlebt Neustarts):
```bash
./install.sh --with-webexplorer --port 8823
```
Danach läuft die Oberfläche dauerhaft auf `http://<host>:8823/`. Port, Adresse
und Anmeldeart stehen in `/etc/pvesnap/web.conf`:
```
PVESNAP_WEB_ARGS=--bind 0.0.0.0 --port 8823 --auth pve
```
Nach Änderungen `systemctl restart pvesnap-web`. Oder von Hand für einen
einzelnen Einsatz:
```bash ```bash
pvesnap-web # Gast und Snapshot im Browser wählen pvesnap-web # Gast und Snapshot im Browser wählen
pvesnap-web 802 <snapname> # diesen Snapshot gleich öffnen pvesnap-web 802 <snapname> # diesen Snapshot gleich öffnen
pvesnap-web --port 8080 --bind 10.0.0.5 pvesnap-web --path /verzeichnis # beliebiges Verzeichnis (ohne Proxmox)
``` ```
Beim Start wird eine Adresse mit Zugangsschlüssel ausgegeben: **Anmeldung mit den Proxmox-Benutzern.** Die Oberfläche zeigt dieselbe
Anmeldemaske wie Proxmox — Benutzername, Passwort, Realm-Auswahl. Geprüft wird
über `POST /access/ticket` auf der lokalen Proxmox-API, also genau den Weg, den
auch das Proxmox-Webinterface geht. Alle Realms (`pam`, `pve`, LDAP, AD …)
funktionieren damit automatisch. Zwei-Faktor-Anmeldungen werden abgewiesen statt
halb durchgewinkt.
**Wer darf was sehen?** Ein gültiges Passwort allein reicht nicht — sonst könnte
jeder Proxmox-Benutzer sämtliche Dateien aller Gäste lesen. Zusätzlich muss der
Benutzer auf dem jeweiligen Gast das Recht **`VM.Snapshot`** besitzen (geprüft
auf `/`, `/vms`, `/vms/<id>` und dem Pool des Gastes). In der Gastliste tauchen
nur Gäste auf, für die das zutrifft; `root@pam` sieht wie in Proxmox alles.
Anpassen mit:
``` ```
http://192.168.1.50:8823/?token=zGo-1RW7eYyf_YCPvTIOLtOp --require-privilege VM.Backup # anderes Recht verlangen
--allow-user backup@pve # einzelne Benutzer generell zulassen
--auth token # stattdessen Schlüssel in der Adresse
--auth none # ohne Anmeldung (nur im vertrauten Netz)
``` ```
Diese Adresse an einem anderen Rechner im Browser öffnen. Dort lässt sich der Ohne Proxmox (`--path`) gibt es keine Benutzer — dort wird automatisch ein
Snapshot durchklicken; **einzelne Dateien** werden direkt heruntergeladen, Zugangsschlüssel erzeugt und beim Start als Adresse ausgegeben.
**Verzeichnisse und Mehrfachauswahl** als ZIP (im Strom erzeugt, es wird nichts
zwischengespeichert). **Bedienung:** Snapshot anklicken, durchklicken; **einzelne Dateien** werden
direkt heruntergeladen, **Verzeichnisse und Mehrfachauswahl** als ZIP (im Strom
erzeugt, es wird nichts zwischengespeichert).
Zur Sicherheit: Zur Sicherheit:
* Ohne den Zugangsschlüssel gibt es nichts zu sehen (er wandert nach dem ersten
Aufruf in ein Cookie). `--no-token` schaltet das ab — nur in einem Netz
benutzen, dem du vertraust.
* Es wird ausschließlich **gelesen**. Die Oberfläche kann nichts ändern oder * Es wird ausschließlich **gelesen**. Die Oberfläche kann nichts ändern oder
löschen. löschen.
* Pfade außerhalb des eingebundenen Snapshots werden abgewiesen — über die * Pfade außerhalb des eingebundenen Snapshots werden abgewiesen — über die
Adresszeile kommt man nicht an den Rest des Hosts. Adresszeile kommt man nicht an den Rest des Hosts.
* Die Verbindung ist unverschlüsseltes HTTP. Über unsichere Netze besser einen * Fehlgeschlagene Anmeldungen landen mit Absender-IP im Journal
SSH-Tunnel legen: (`journalctl -u pvesnap-web`).
`ssh -L 8823:localhost:8823 root@pve` und dann `pvesnap-web --bind 127.0.0.1`. * Die Verbindung ist unverschlüsseltes HTTP — Passwörter gehen im Klartext über
das Netz. Über unsichere Netze deshalb besser einen SSH-Tunnel legen:
`ssh -L 8823:localhost:8823 root@pve`, dazu in `web.conf`
`--bind 127.0.0.1` setzen.
Beendet wird mit `Strg-C`; der Snapshot wird dabei wieder ausgehängt. Beim Beenden (`Strg-C` oder `systemctl stop pvesnap-web`) wird ein offener
Snapshot wieder ausgehängt.
--- ---
+111 -8
View File
@@ -2,9 +2,25 @@
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# pvesnap - Installation # pvesnap - Installation
# #
# ./install.sh installieren, Dienst aktivieren und starten # Aufruf: ./install.sh [Optionen]
# ./install.sh --no-start installieren, aber Dienst nicht starten #
# ./install.sh --force auch ohne erkanntes Proxmox VE installieren # --with-webexplorer Web-Oberflaeche zusaetzlich als Dienst
# einrichten (pvesnap-web.service).
# Erfordert --port.
# --port <nummer> Port fuer die Web-Oberflaeche, z.B. 8823
# --bind <adresse> Adresse, auf der sie lauscht
# (Vorgabe: 0.0.0.0 = alle)
# --no-start installieren, aber Dienste nicht starten
# --force auch ohne erkanntes Proxmox VE installieren
# -h, --help diese Hilfe
#
# Beispiele:
# ./install.sh
# ./install.sh --with-webexplorer --port 8823
# ./install.sh --with-webexplorer --port 8443 --bind 10.0.0.5
#
# Die Web-Oberflaeche meldet Benutzer gegen Proxmox VE an - dieselben
# Zugangsdaten und Realms wie im Proxmox-Webinterface.
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
set -euo pipefail set -euo pipefail
@@ -15,25 +31,64 @@ CONF="${CONF_DIR}/pvesnap.conf"
STATE_DIR="/var/lib/pvesnap" STATE_DIR="/var/lib/pvesnap"
DOC_DIR="/usr/share/doc/pvesnap" DOC_DIR="/usr/share/doc/pvesnap"
UNIT="/etc/systemd/system/pvesnap.service" UNIT="/etc/systemd/system/pvesnap.service"
WEB_UNIT="/etc/systemd/system/pvesnap-web.service"
WEB_CONF="${CONF_DIR}/web.conf"
SRC="$(cd "$(dirname "$0")" && pwd)" SRC="$(cd "$(dirname "$0")" && pwd)"
START=1 START=1
FORCE=0 FORCE=0
for arg in "$@"; do WEB=0
case "$arg" in WEB_PORT=""
WEB_BIND="0.0.0.0"
while [ $# -gt 0 ]; do
case "$1" in
--with-webexplorer) WEB=1 ;;
--port) WEB_PORT="${2:-}"; shift ;;
--port=*) WEB_PORT="${1#*=}" ;;
--bind) WEB_BIND="${2:-}"; shift ;;
--bind=*) WEB_BIND="${1#*=}" ;;
--no-start) START=0 ;; --no-start) START=0 ;;
--force) FORCE=1 ;; --force) FORCE=1 ;;
-h|--help) sed -n '2,8p' "$0" | sed 's/^#[[:space:]]\{0,1\}//'; exit 0 ;; -h|--help) sed -n '2,24p' "$0" | sed 's/^#[[:space:]]\{0,1\}//'; exit 0 ;;
*) echo "Unbekannte Option: $arg" >&2; exit 1 ;; *) echo "Unbekannte Option: $1" >&2
echo "Hilfe mit: $0 --help" >&2; exit 1 ;;
esac esac
shift
done done
info() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } info() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m==>\033[0m %s\n' "$*" >&2; } warn() { printf '\033[1;33m==>\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31m==>\033[0m %s\n' "$*" >&2; exit 1; } fail() { printf '\033[1;31m==>\033[0m %s\n' "$*" >&2; exit 1; }
# --- Web-Oberflaeche: Angaben pruefen ---------------------------------
if [ "$WEB" -eq 1 ]; then
if [ -z "$WEB_PORT" ]; then
fail "--with-webexplorer benoetigt einen Port.
Beispiel: ./install.sh --with-webexplorer --port 8823
Hilfe mit: $0 --help"
fi
case "$WEB_PORT" in
''|*[!0-9]*) fail "--port erwartet eine Zahl, bekommen: '$WEB_PORT'" ;;
esac
if [ "$WEB_PORT" -lt 1 ] || [ "$WEB_PORT" -gt 65535 ]; then
fail "--port muss zwischen 1 und 65535 liegen (bekommen: $WEB_PORT)"
fi
if [ "$WEB_PORT" -eq 8006 ]; then
fail "Port 8006 gehoert dem Proxmox-Webinterface - bitte einen anderen waehlen."
fi
if ss -ltn 2>/dev/null | grep -qE "[:.]${WEB_PORT}[[:space:]]"; then
warn "Auf Port ${WEB_PORT} lauscht bereits etwas - der Dienst wird sich "
warn "moeglicherweise nicht starten lassen."
fi
[ -n "$WEB_BIND" ] || fail "--bind erwartet eine Adresse"
elif [ -n "$WEB_PORT" ]; then
fail "--port wirkt nur zusammen mit --with-webexplorer."
fi
[ "$(id -u)" -eq 0 ] || fail "Bitte als root ausfuehren (sudo ./install.sh)." [ "$(id -u)" -eq 0 ] || fail "Bitte als root ausfuehren (sudo ./install.sh)."
command -v python3 >/dev/null 2>&1 || fail "python3 wird benoetigt." command -v python3 >/dev/null 2>&1 || fail "python3 wird benoetigt."
python3 - <<'PY' || fail "Python 3.7 oder neuer wird benoetigt." python3 - <<'PY' || fail "Python 3.7 oder neuer wird benoetigt."
import sys import sys
@@ -107,13 +162,45 @@ else
warn "Dienst konnte nicht aktiviert werden - bitte 'systemctl enable pvesnap' pruefen." warn "Dienst konnte nicht aktiviert werden - bitte 'systemctl enable pvesnap' pruefen."
fi fi
# --- Web-Oberflaeche als Dienst ---------------------------------------
if [ "$WEB" -eq 1 ]; then
info "Richte Web-Oberflaeche als Dienst ein (Port ${WEB_PORT})"
cat > "${WEB_CONF}" <<EOF
# Aufrufparameter von pvesnap-web.service
# Nach Aenderungen: systemctl restart pvesnap-web
#
# --auth pve Anmeldung mit den Benutzern von Proxmox VE (empfohlen)
# --auth token Zugangsschluessel in der Adresse
# --auth none ohne jede Anmeldung - nur in vertrauenswuerdigen Netzen
#
# Sichtbar ist ein Gast nur fuer Benutzer, die darauf das Recht aus
# --require-privilege besitzen (Vorgabe: VM.Snapshot). Mit --allow-user
# lassen sich einzelne Benutzer generell zulassen.
PVESNAP_WEB_ARGS=--bind ${WEB_BIND} --port ${WEB_PORT} --auth pve
EOF
chmod 0640 "${WEB_CONF}"
install -m 0644 "${SRC}/systemd/pvesnap-web.service" "${WEB_UNIT}"
systemctl daemon-reload
if systemctl enable pvesnap-web.service >/dev/null 2>&1; then
info "Web-Oberflaeche beim Systemstart aktiviert"
else
warn "pvesnap-web konnte nicht aktiviert werden."
fi
fi
if [ "$START" -eq 1 ]; then if [ "$START" -eq 1 ]; then
info "Starte Dienst" info "Starte Dienst"
systemctl restart pvesnap.service systemctl restart pvesnap.service
sleep 1 sleep 1
systemctl --no-pager --lines=5 status pvesnap.service || true systemctl --no-pager --lines=5 status pvesnap.service || true
if [ "$WEB" -eq 1 ]; then
info "Starte Web-Oberflaeche"
systemctl restart pvesnap-web.service
sleep 1
systemctl --no-pager --lines=3 status pvesnap-web.service || true
fi
else else
warn "Dienst wurde nicht gestartet." warn "Dienste wurden nicht gestartet."
fi fi
cat <<EOF cat <<EOF
@@ -134,6 +221,22 @@ Fertig.
pvesnap-web im Browser durchsehen und herunterladen pvesnap-web im Browser durchsehen und herunterladen
EOF EOF
if [ "$WEB" -eq 1 ]; then
cat <<EOF
Web-Oberflaeche laeuft als Dienst:
Adresse : http://<IP-dieses-Hosts>:${WEB_PORT}/
Anmeldung : Benutzer von Proxmox VE (gleiche Zugangsdaten wie dort)
Einstellungen : ${WEB_CONF}
Status : systemctl status pvesnap-web
Protokoll : journalctl -u pvesnap-web -f
Sichtbar ist ein Gast nur fuer Benutzer mit dem Recht VM.Snapshot darauf;
root@pam sieht alles. Anzupassen in ${WEB_CONF}.
EOF
fi
if [ "${NEW_CONFIG:-0}" -eq 1 ]; then if [ "${NEW_CONFIG:-0}" -eq 1 ]; then
cat <<EOF cat <<EOF
+2
View File
@@ -946,6 +946,8 @@ def main(argv=None):
print("%d Eintrag/Eintraege aufgeraeumt." % len(removed)) print("%d Eintrag/Eintraege aufgeraeumt." % len(removed))
return 0 return 0
cleanup_leftovers(only_dead=True)
if not args.path: if not args.path:
if not pvesh_available(): if not pvesh_available():
print("'pvesh' nicht gefunden - der Explorer laeuft auf dem Proxmox-Host.\n" print("'pvesh' nicht gefunden - der Explorer laeuft auf dem Proxmox-Host.\n"
+20 -2
View File
@@ -225,10 +225,28 @@ def _registry_remove(kind, value):
_registry_save(entries) _registry_save(entries)
def cleanup_leftovers(verbose=False): def _pid_alive(pid):
"""Raeumt auf, was ein abgestuerzter Lauf hinterlassen hat.""" if not pid:
return False
try:
os.kill(int(pid), 0)
return True
except ProcessLookupError:
return False
except OSError:
return True # existiert, gehoert uns nur nicht
def cleanup_leftovers(verbose=False, only_dead=False):
"""Raeumt auf, was ein abgestuerzter Lauf hinterlassen hat.
only_dead=True fasst nur an, was von einem nicht mehr laufenden Prozess
stammt - so stoert der Start eines Dienstes keine parallel offene Sitzung.
"""
removed = [] removed = []
for entry in reversed(_registry_load()): for entry in reversed(_registry_load()):
if only_dead and _pid_alive(entry.get("pid")):
continue
kind, value = entry.get("kind"), entry.get("value") kind, value = entry.get("kind"), entry.get("value")
extra = entry.get("extra") or {} extra = entry.get("extra") or {}
try: try:
+32
View File
@@ -68,6 +68,15 @@ button:hover, .button:hover { filter: brightness(1.08); text-decoration: none; }
} }
input[type=checkbox] { width: 1.05rem; height: 1.05rem; } input[type=checkbox] { width: 1.05rem; height: 1.05rem; }
.empty { padding: 2rem 1rem; text-align: center; color: var(--muted); } .empty { padding: 2rem 1rem; text-align: center; color: var(--muted); }
.login { max-width: 26rem; margin: 3rem auto; }
.login .card { padding: 1rem; display: flex; flex-direction: column; gap: .8rem; }
.login label { display: flex; flex-direction: column; gap: .3rem; font-size: .9rem;
color: var(--muted); }
.login input, .login select {
font: inherit; color: var(--fg); background: var(--bg);
border: 1px solid var(--line); border-radius: 7px; padding: .5rem .6rem;
}
.login button { margin-top: .3rem; padding: .55rem; }
@media (max-width: 640px) { @media (max-width: 640px) {
td.when, th.when { display: none; } td.when, th.when { display: none; }
main { padding: .5rem; } main { padding: .5rem; }
@@ -97,6 +106,29 @@ function pvesnapInit() {
document.addEventListener('DOMContentLoaded', pvesnapInit); document.addEventListener('DOMContentLoaded', pvesnapInit);
""" """
LOGIN_FORM = """
<div class="login">
%(note)s
<h2>Anmeldung</h2>
<p class="muted">Mit den Benutzern von Proxmox VE - dieselben Zugangsdaten
wie im Proxmox-Webinterface.</p>
<form method="post" action="/login" class="card" autocomplete="on">
<label>Benutzername
<input name="username" value="%(username)s" autofocus required
autocomplete="username">
</label>
<label>Passwort
<input name="password" type="password" required
autocomplete="current-password">
</label>
<label>Realm
<select name="realm">%(realms)s</select>
</label>
<button class="primary" type="submit">Anmelden</button>
</form>
</div>
"""
PAGE = """<!doctype html> PAGE = """<!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
+145
View File
@@ -0,0 +1,145 @@
"""Anmeldung an der Web-Oberflaeche mit den Benutzern von Proxmox VE.
Geprueft wird ueber dieselbe Schnittstelle, die auch das Proxmox-Webinterface
benutzt: POST /access/ticket auf der lokalen API. Passwoerter wandern also
nirgends anders hin als ohnehin schon, und alle Realms (pam, pve, LDAP, AD ...)
funktionieren automatisch mit.
Wer sich anmelden darf, ist damit noch nicht entschieden: zusaetzlich muss der
Benutzer auf der jeweiligen VM ein Recht besitzen (Vorgabe: VM.Snapshot). Sonst
koennte jeder Benutzer mit einem gueltigen Kennwort saemtliche Dateien aller
Gaeste lesen.
"""
from __future__ import annotations
import json
import logging
import ssl
import urllib.error
import urllib.parse
import urllib.request
log = logging.getLogger("pvesnap.web.auth")
API = "https://127.0.0.1:8006/api2/json"
DEFAULT_PRIVILEGE = "VM.Snapshot"
class AuthError(Exception):
"""Anmeldung fehlgeschlagen."""
def _unverified_context():
# Das Zertifikat von pveproxy ist selbstsigniert. Die Verbindung geht nur
# an 127.0.0.1 und verlaesst den Host nicht.
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def _pvesh_json(args):
from ..proxmox import Proxmox, ProxmoxError
try:
return Proxmox()._json(args)
except ProxmoxError as exc:
raise AuthError(str(exc))
# ---------------------------------------------------------------------------
# Realms
# ---------------------------------------------------------------------------
def list_realms():
"""[(realm, Beschriftung)] - wie in der Auswahlliste des Proxmox-Logins."""
try:
data = _pvesh_json(["get", "/access/domains"]) or []
except AuthError as exc:
log.warning("Realms nicht lesbar: %s", exc)
return [("pam", "Linux PAM standard authentication")]
realms = []
for entry in data:
realm = entry.get("realm")
if not realm:
continue
comment = entry.get("comment") or entry.get("type") or realm
realms.append((realm, "%s (%s)" % (realm, comment)))
realms.sort(key=lambda item: (item[0] != "pam", item[0]))
return realms or [("pam", "pam")]
# ---------------------------------------------------------------------------
# Anmeldung
# ---------------------------------------------------------------------------
def authenticate(username, realm, password):
"""Prueft die Zugangsdaten und gibt die vollstaendige Benutzerkennung zurueck."""
username = (username or "").strip()
if not username or not password:
raise AuthError("Benutzername und Passwort werden benoetigt.")
if "@" in username: # "root@pam" im Feld eingetippt
username, _, realm_from_name = username.partition("@")
realm = realm_from_name or realm
userid = "%s@%s" % (username, realm or "pam")
data = urllib.parse.urlencode({"username": userid, "password": password}).encode()
request = urllib.request.Request(API + "/access/ticket", data=data,
method="POST")
try:
with urllib.request.urlopen(request, timeout=20,
context=_unverified_context()) as response:
payload = json.loads(response.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as exc:
if exc.code == 401:
raise AuthError("Benutzername oder Passwort falsch.")
raise AuthError("Anmeldung fehlgeschlagen (HTTP %s)." % exc.code)
except urllib.error.URLError as exc:
raise AuthError("Der Proxmox-Dienst auf 127.0.0.1:8006 ist nicht "
"erreichbar (%s)." % exc.reason)
except (ValueError, OSError) as exc:
raise AuthError("Anmeldung fehlgeschlagen: %s" % exc)
result = (payload or {}).get("data") or {}
if not result.get("ticket"):
raise AuthError("Benutzername oder Passwort falsch.")
if result.get("NeedTFA"):
# Der zweite Faktor wird hier bewusst nicht abgefragt - lieber
# abweisen als eine halbe Anmeldung durchwinken.
raise AuthError("Fuer %s ist Zwei-Faktor-Authentifizierung aktiv. "
"Diese Oberflaeche unterstuetzt das nicht - bitte einen "
"Benutzer ohne zweiten Faktor verwenden." % userid)
return result.get("username") or userid
# ---------------------------------------------------------------------------
# Rechte
# ---------------------------------------------------------------------------
def permissions(userid):
"""Rechtebaum eines Benutzers: {Pfad: {Recht: 1}}."""
try:
data = _pvesh_json(["get", "/access/permissions", "--userid", userid])
except AuthError as exc:
log.warning("Rechte von %s nicht lesbar: %s", userid, exc)
return {}
return data if isinstance(data, dict) else {}
def may_access(userid, guest, privilege=DEFAULT_PRIVILEGE, tree=None,
allowed_users=()):
"""Darf `userid` in die Dateien dieses Gastes sehen?"""
if userid in (allowed_users or ()):
return True
if userid == "root@pam":
return True # in Proxmox grundsaetzlich uneingeschraenkt
tree = tree if tree is not None else permissions(userid)
paths = ["/", "/vms", "/vms/%d" % guest.vmid]
if getattr(guest, "pool", ""):
paths.append("/pool/%s" % guest.pool)
for path in paths:
if (tree.get(path) or {}).get(privilege):
return True
return False
+229 -56
View File
@@ -18,6 +18,7 @@ import io
import logging import logging
import os import os
import secrets import secrets
import signal
import socket import socket
import stat import stat
import sys import sys
@@ -32,12 +33,47 @@ from .. import __version__
from ..proxmox import Proxmox, ProxmoxError, pvesh_available from ..proxmox import Proxmox, ProxmoxError, pvesh_available
from ..snapfs import (Session, SnapfsError, cleanup_leftovers, human_bytes, from ..snapfs import (Session, SnapfsError, cleanup_leftovers, human_bytes,
list_snapshots) list_snapshots)
from .assets import PAGE, SCRIPT, STYLE 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") log = logging.getLogger("pvesnap.web")
COOKIE = "pvesnap_token" COOKIE = "pvesnap_token" # Zugangsschluessel (Modus "token")
SESSION_COOKIE = "pvesnap_session" # Anmeldung (Modus "pve")
CHUNK = 256 * 1024 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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -47,20 +83,40 @@ CHUNK = 256 * 1024
class AppState: class AppState:
"""Was gerade geoeffnet ist. Es ist immer hoechstens ein Snapshot offen.""" """Was gerade geoeffnet ist. Es ist immer hoechstens ein Snapshot offen."""
def __init__(self, token, proxmox=None, fixed_path=""): def __init__(self, token="", proxmox=None, fixed_path="", auth_mode="token",
privilege=DEFAULT_PRIVILEGE, allowed_users=()):
self.token = token self.token = token
self.proxmox = proxmox self.proxmox = proxmox
self.fixed_path = fixed_path 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.session = None
self.guest = None self.guest = None
self.snapname = "" self.snapname = ""
self.roots = [] # [(Beschriftung, Pfad)] self.roots = [] # [(Beschriftung, Pfad)]
self.lock = threading.Lock() self.lock = threading.Lock()
self.notes = [] self.notes = []
self.opened_by = ""
if fixed_path: if fixed_path:
self.roots = [(fixed_path, os.path.realpath(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 @property
def open(self): def open(self):
return bool(self.roots) return bool(self.roots)
@@ -197,9 +253,11 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Location", target) self.send_header("Location", target)
self.send_header("Content-Length", "0") self.send_header("Content-Length", "0")
if cookie: if cookie:
name, value = cookie
age = 0 if value == "" else SESSION_MAX_AGE
self.send_header("Set-Cookie", self.send_header("Set-Cookie",
"%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400" "%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=%d"
% (COOKIE, cookie)) % (name, value, age))
self.end_headers() self.end_headers()
def _error(self, message, status=400): def _error(self, message, status=400):
@@ -211,17 +269,69 @@ class Handler(BaseHTTPRequestHandler):
# -- Zugang ----------------------------------------------------------- # -- 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): def _authorized(self, params):
supplied = "" """Modus 'token': Schluessel aus Adresse oder Cookie."""
if "token" in params: supplied = params["token"][0] if "token" in params else self._cookie(COOKIE)
supplied = params["token"][0] return secrets.compare_digest(supplied, self.state.token or ""), supplied
else:
cookies = self.headers.get("Cookie") or "" def _user(self):
for part in cookies.split(";"): """Angemeldeter Benutzer im Modus 'pve', sonst None."""
name, _, value = part.strip().partition("=") entry = self.state.sessions.get(self._cookie(SESSION_COOKIE))
if name == COOKIE: return entry["userid"] if entry else None
supplied = value
return secrets.compare_digest(supplied, self.state.token), supplied 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 --------------------------------------------------------- # -- Anfragen ---------------------------------------------------------
@@ -233,28 +343,31 @@ class Handler(BaseHTTPRequestHandler):
params = urllib.parse.parse_qs(parsed.query) params = urllib.parse.parse_qs(parsed.query)
route = parsed.path route = parsed.path
ok, supplied = self._authorized(params) if route == "/login" and self.state.auth_mode == "pve":
if not ok: self._page_login()
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 return
# Schluessel aus der Adresse in ein Cookie umziehen ok, user = self._check_access(params, route)
if "token" in params and route not in ("/download", "/zip"): if ok is None:
clean = {k: v[0] for k, v in params.items() if k != "token"} return # Weiterleitung ist schon raus
self._redirect(link(route, **clean), cookie=supplied) 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 return
try: try:
if route == "/": if route == "/":
self._page_home() self._page_home(user)
elif route == "/snapshots": elif route == "/snapshots":
self._page_snapshots(params) self._page_snapshots(params, user)
elif route == "/files": elif route == "/files":
self._page_files(params) self._page_files(params, user)
elif route == "/download": elif route == "/download":
self._download(params) self._download(params)
else: else:
@@ -266,21 +379,32 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)
ok, _ = self._authorized(urllib.parse.parse_qs(parsed.query)) params = 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) length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length).decode("utf-8", "replace") if length else "" raw = self.rfile.read(length).decode("utf-8", "replace") if length else ""
form = urllib.parse.parse_qs(raw) 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: try:
if parsed.path == "/open": if parsed.path == "/open":
self._open_snapshot(form) self._open_snapshot(form, user)
elif parsed.path == "/close": elif parsed.path == "/close":
self.state.close() self.state.close()
self._redirect("/") self._redirect("/")
elif parsed.path == "/logout":
self.state.sessions.drop(self._cookie(SESSION_COOKIE))
self._redirect("/login", cookie=(SESSION_COOKIE, ""))
elif parsed.path == "/zip": elif parsed.path == "/zip":
self._zip(form) self._zip(form)
else: else:
@@ -292,7 +416,7 @@ class Handler(BaseHTTPRequestHandler):
# -- Seiten ----------------------------------------------------------- # -- Seiten -----------------------------------------------------------
def _page_home(self): def _page_home(self, user=""):
state = self.state state = self.state
if state.open: if state.open:
self._redirect(link("/files", fs=0, p="")) self._redirect(link("/files", fs=0, p=""))
@@ -301,7 +425,7 @@ class Handler(BaseHTTPRequestHandler):
self._error("Kein Verzeichnis geoeffnet.") self._error("Kein Verzeichnis geoeffnet.")
return return
guests = state.proxmox.inventory(refresh=True) guests = state.visible_guests(user)
rows = [] rows = []
for guest in guests: for guest in guests:
rows.append( rows.append(
@@ -314,21 +438,30 @@ class Handler(BaseHTTPRequestHandler):
guest.vmid, "LXC" if guest.type == "lxc" else "VM", guest.vmid, "LXC" if guest.type == "lxc" else "VM",
esc(guest.status), esc(guest.node))) 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 &rarr; Permissions vergeben.</div>" % esc(state.privilege))
body = ( body = (
"<h2>Gast auswaehlen</h2>" hint + "<h2>Gast auswaehlen</h2>"
"<div class='card'><table><thead><tr><th>Name</th><th>VMID</th>" "<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>" "<th>Typ</th><th>Status</th><th>Node</th></tr></thead><tbody>%s</tbody>"
"</table></div>" % ("".join(rows) or "</table></div>" % ("".join(rows) or
"<tr><td colspan='5' class='empty'>Keine Gaeste gefunden.</td></tr>")) "<tr><td colspan='5' class='empty'>Keine Gaeste gefunden.</td></tr>"))
self._send(page("pvesnap - Gaeste", state, body)) self._send(page("pvesnap - Gaeste", state, body, actions=self._actions(user)))
def _page_snapshots(self, params): def _page_snapshots(self, params, user=""):
state = self.state state = self.state
vmid = int((params.get("vmid") or ["0"])[0]) vmid = int((params.get("vmid") or ["0"])[0])
guest = next((g for g in state.proxmox.inventory() if g.vmid == vmid), None) guest = next((g for g in state.proxmox.inventory() if g.vmid == vmid), None)
if guest is None: if guest is None:
self._error("Gast %d nicht gefunden." % vmid, status=404) self._error("Gast %d nicht gefunden." % vmid, status=404)
return 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 = [] rows = []
for snap in list_snapshots(state.proxmox, guest): for snap in list_snapshots(state.proxmox, guest):
@@ -357,9 +490,10 @@ class Handler(BaseHTTPRequestHandler):
% (esc(guest.label), % (esc(guest.label),
"".join(rows) or "<tr><td colspan='4' class='empty'>" "".join(rows) or "<tr><td colspan='4' class='empty'>"
"Dieser Gast hat keine Snapshots.</td></tr>")) "Dieser Gast hat keine Snapshots.</td></tr>"))
self._send(page("Snapshots %s" % guest.label, state, body)) self._send(page("Snapshots %s" % guest.label, state, body,
actions=self._actions(user)))
def _open_snapshot(self, form): def _open_snapshot(self, form, user=""):
state = self.state state = self.state
vmid = int((form.get("vmid") or ["0"])[0]) vmid = int((form.get("vmid") or ["0"])[0])
snapname = (form.get("snap") or [""])[0] snapname = (form.get("snap") or [""])[0]
@@ -367,11 +501,16 @@ class Handler(BaseHTTPRequestHandler):
if guest is None or not snapname: if guest is None or not snapname:
self._error("Gast oder Snapshot fehlt.") self._error("Gast oder Snapshot fehlt.")
return return
log.info("Oeffne Snapshot %s von %s", snapname, guest.label) 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) state.open_snapshot(guest, snapname)
self._redirect(link("/files", fs=0, p="")) self._redirect(link("/files", fs=0, p=""))
def _page_files(self, params): def _page_files(self, params, user=""):
state = self.state state = self.state
if not state.open: if not state.open:
self._redirect("/") self._redirect("/")
@@ -442,11 +581,11 @@ class Handler(BaseHTTPRequestHandler):
"<tbody>%s</tbody></table></div>" "<tbody>%s</tbody></table></div>"
% ("".join(rows) or "<tr><td colspan='4' class='empty'>Leeres Verzeichnis</td></tr>")) % ("".join(rows) or "<tr><td colspan='4' class='empty'>Leeres Verzeichnis</td></tr>"))
actions = ("<form method='post' action='/close' style='margin:0'>" close_button = ("<form method='post' action='/close' style='margin:0'>"
"<button type='submit'>Snapshot schliessen</button></form>" "<button type='submit'>Snapshot schliessen</button></form>"
if not state.fixed_path else "") if not state.fixed_path else "")
self._send(page("pvesnap - %s" % (relative or "/"), state, self._send(page("pvesnap - %s" % (relative or "/"), state, "".join(body),
"".join(body), actions=actions)) actions=self._actions(user, close_button)))
@staticmethod @staticmethod
def _describe(item): def _describe(item):
@@ -688,9 +827,19 @@ def build_parser():
help="Port (Vorgabe: %(default)s)") help="Port (Vorgabe: %(default)s)")
parser.add_argument("-p", "--path", parser.add_argument("-p", "--path",
help="beliebiges Verzeichnis freigeben, ohne Proxmox zu fragen") 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("-t", "--token", help="Zugangsschluessel selbst vorgeben")
parser.add_argument("--no-token", action="store_true", parser.add_argument("--no-token", action="store_true",
help="ohne Zugangsschluessel (nur in vertrauenswuerdigen Netzen!)") help="Kurzform fuer --auth none (nur in vertrauenswuerdigen "
"Netzen!)")
parser.add_argument("--cleanup", action="store_true", parser.add_argument("--cleanup", action="store_true",
help="haengengebliebene Einbindungen aufraeumen und beenden") help="haengengebliebene Einbindungen aufraeumen und beenden")
parser.add_argument("-v", "--verbose", action="store_true") parser.add_argument("-v", "--verbose", action="store_true")
@@ -725,8 +874,18 @@ def main(argv=None):
print("Kein Verzeichnis: %s" % args.path, file=sys.stderr) print("Kein Verzeichnis: %s" % args.path, file=sys.stderr)
return 2 return 2
token = "" if args.no_token else (args.token or secrets.token_urlsafe(18)) # Auf einem Proxmox-Host wird ohne weitere Angabe die Anmeldung mit den
state = AppState(token=token, proxmox=proxmox, fixed_path=args.path or "") # 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: if args.vmid and args.snapshot and proxmox:
guest = next((g for g in proxmox.inventory() if g.vmid == args.vmid), None) guest = next((g for g in proxmox.inventory() if g.vmid == args.vmid), None)
@@ -740,23 +899,37 @@ def main(argv=None):
print("Snapshot nicht zu oeffnen: %s" % exc, file=sys.stderr) print("Snapshot nicht zu oeffnen: %s" % exc, file=sys.stderr)
return 5 return 5
if args.no_token: # Reste eines abgestuerzten Vorgaengers wegraeumen - laufende Sitzungen
Handler._authorized = lambda self, params: (True, "") # anderer Prozesse bleiben unangetastet.
cleanup_leftovers(only_dead=True)
Handler.state = state Handler.state = state
server = ThreadingHTTPServer((args.bind, args.port), Handler) server = ThreadingHTTPServer((args.bind, args.port), Handler)
server.daemon_threads = True 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 "" suffix = ("?token=%s" % token) if token else ""
lines = ["", "pvesnap-web laeuft. Im Browser oeffnen:", ""] lines = ["", "pvesnap-web laeuft. Im Browser oeffnen:", ""]
for address in _addresses(args.port): for address in _addresses(args.port):
lines.append(" http://%s:%d/%s" % (address, args.port, suffix)) lines.append(" http://%s:%d/%s" % (address, args.port, suffix))
if args.bind in ("127.0.0.1", "localhost", "::1"): if args.bind in ("127.0.0.1", "localhost", "::1"):
lines.append(" http://%s:%d/%s" % (args.bind, args.port, suffix)) 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("") 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 # flush: sonst haengt die Adresse mit dem Schluessel im Puffer, sobald die
# Ausgabe nicht direkt im Terminal landet (Pipe, systemd, tee). # Ausgabe nicht direkt im Terminal landet (Pipe, systemd, tee).
print("\n".join(lines), flush=True) print("\n".join(lines), flush=True)
+26
View File
@@ -0,0 +1,26 @@
[Unit]
Description=pvesnap-web - Snapshot-Explorer im Browser
Documentation=file:/usr/share/doc/pvesnap/README.md
After=network-online.target pve-cluster.service pvedaemon.service pveproxy.service
Wants=pve-cluster.service pveproxy.service
[Service]
Type=simple
# Port, Adresse und Anmeldeart stehen in /etc/pvesnap/web.conf und lassen sich
# dort aendern - danach: systemctl restart pvesnap-web
EnvironmentFile=/etc/pvesnap/web.conf
ExecStart=/usr/local/bin/pvesnap-web $PVESNAP_WEB_ARGS
Environment=PYTHONUNBUFFERED=1
Restart=on-failure
RestartSec=15
TimeoutStopSec=120
# Snapshots einbinden und mounten geht nur als root.
User=root
# ACHTUNG: Hier duerfen KEINE Sandbox-Optionen stehen - siehe pvesnap.service.
# ProtectSystem= wuerde /etc schreibgeschuetzt einhaengen, womit pmxcfs seine
# Sperren nicht mehr anlegen kann und jeder Zugriff im Lock-Timeout endet.
[Install]
WantedBy=multi-user.target
+20 -9
View File
@@ -17,6 +17,7 @@ CONF_DIR="/etc/pvesnap"
STATE_DIR="/var/lib/pvesnap" STATE_DIR="/var/lib/pvesnap"
DOC_DIR="/usr/share/doc/pvesnap" DOC_DIR="/usr/share/doc/pvesnap"
UNIT="/etc/systemd/system/pvesnap.service" UNIT="/etc/systemd/system/pvesnap.service"
WEB_UNIT="/etc/systemd/system/pvesnap-web.service"
PURGE=0 PURGE=0
YES=0 YES=0
@@ -48,18 +49,28 @@ if [ "$YES" -eq 0 ]; then
esac esac
fi fi
if systemctl list-unit-files 2>/dev/null | grep -q '^pvesnap\.service'; then for unit in pvesnap.service pvesnap-web.service; do
info "Stoppe und deaktiviere Dienst" if systemctl list-unit-files 2>/dev/null | grep -q "^${unit}"; then
systemctl stop pvesnap.service 2>/dev/null || true info "Stoppe und deaktiviere ${unit}"
systemctl disable pvesnap.service 2>/dev/null || true systemctl stop "$unit" 2>/dev/null || true
systemctl disable "$unit" 2>/dev/null || true
fi
done
# Eingebundene Snapshots loesen, bevor das Programm verschwindet.
if [ -x "${BIN}-explorer" ]; then
info "Haenge eventuell eingebundene Snapshots aus"
"${BIN}-explorer" --cleanup >/dev/null 2>&1 || true
fi fi
if [ -f "$UNIT" ]; then for unit in "$UNIT" "$WEB_UNIT"; do
info "Entferne $UNIT" if [ -f "$unit" ]; then
rm -f "$UNIT" info "Entferne $unit"
fi rm -f "$unit"
fi
done
systemctl daemon-reload 2>/dev/null || true systemctl daemon-reload 2>/dev/null || true
systemctl reset-failed pvesnap.service 2>/dev/null || true systemctl reset-failed pvesnap.service pvesnap-web.service 2>/dev/null || true
for command in "$BIN" "${BIN}-explorer" "${BIN}-web"; do for command in "$BIN" "${BIN}-explorer" "${BIN}-web"; do
if [ -e "$command" ]; then if [ -e "$command" ]; then