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:
co-authored by
Claude Opus 5
parent
91bfdea76c
commit
963463a1a1
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 → 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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user