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>
154 lines
5.5 KiB
Python
154 lines
5.5 KiB
Python
"""HTML-Geruest und Stylesheet der Web-Oberflaeche.
|
|
|
|
Alles ist eingebettet - die Seite laedt nichts aus dem Internet nach und
|
|
funktioniert damit auch in abgeschotteten Netzen.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
STYLE = """
|
|
:root {
|
|
color-scheme: light dark;
|
|
--bg: #f6f7f9; --fg: #1c1f24; --muted: #6b7280; --line: #d8dce3;
|
|
--card: #ffffff; --accent: #2563eb; --accent-fg: #ffffff; --hover: #eef2ff;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg: #16181d; --fg: #e6e8ec; --muted: #9aa1ac; --line: #2c313a;
|
|
--card: #1e2127; --accent: #4b82f0; --accent-fg: #0b0d10; --hover: #232a39;
|
|
}
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0; background: var(--bg); color: var(--fg);
|
|
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
}
|
|
header {
|
|
background: var(--card); border-bottom: 1px solid var(--line);
|
|
padding: .7rem 1rem; display: flex; gap: 1rem; align-items: center;
|
|
flex-wrap: wrap; position: sticky; top: 0; z-index: 5;
|
|
}
|
|
header h1 { font-size: 1rem; margin: 0; font-weight: 650; }
|
|
header .spacer { flex: 1 1 auto; }
|
|
main { padding: 1rem; max-width: 1200px; margin: 0 auto; }
|
|
a { color: var(--accent); text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
.muted { color: var(--muted); }
|
|
.card {
|
|
background: var(--card); border: 1px solid var(--line);
|
|
border-radius: 10px; padding: .25rem .75rem; margin-bottom: 1rem;
|
|
}
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { text-align: left; padding: .45rem .5rem; border-bottom: 1px solid var(--line); }
|
|
th { font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
|
|
tr:last-child td { border-bottom: 0; }
|
|
tbody tr:hover { background: var(--hover); }
|
|
td.size, th.size { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
td.when, th.when { white-space: nowrap; color: var(--muted); font-size: .9em; }
|
|
td.pick { width: 2.2rem; }
|
|
.name { display: flex; gap: .5rem; align-items: center; word-break: break-all; }
|
|
.icon { width: 1.2rem; text-align: center; flex: 0 0 auto; }
|
|
button, .button {
|
|
font: inherit; padding: .4rem .8rem; border-radius: 7px; cursor: pointer;
|
|
border: 1px solid var(--line); background: var(--card); color: var(--fg);
|
|
}
|
|
button.primary, .button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
|
|
button:hover, .button:hover { filter: brightness(1.08); text-decoration: none; }
|
|
.bar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin: .75rem 0; }
|
|
.crumbs { display: flex; gap: .3rem; flex-wrap: wrap; align-items: center; word-break: break-all; }
|
|
.crumbs span { color: var(--muted); }
|
|
.note {
|
|
border-left: 3px solid var(--accent); background: var(--card);
|
|
padding: .6rem .8rem; border-radius: 0 8px 8px 0; margin-bottom: 1rem;
|
|
}
|
|
.warn { border-left-color: #d9534f; }
|
|
.pill {
|
|
display: inline-block; padding: .1rem .5rem; border-radius: 999px;
|
|
background: var(--hover); font-size: .82em; color: var(--muted);
|
|
}
|
|
input[type=checkbox] { width: 1.05rem; height: 1.05rem; }
|
|
.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) {
|
|
td.when, th.when { display: none; }
|
|
main { padding: .5rem; }
|
|
}
|
|
"""
|
|
|
|
# Auswahl-Kaestchen: "alle" umschalten und Anzahl anzeigen.
|
|
SCRIPT = """
|
|
function pvesnapInit() {
|
|
var all = document.getElementById('pick-all');
|
|
var boxes = Array.prototype.slice.call(document.querySelectorAll('.pick-one'));
|
|
var info = document.getElementById('pick-info');
|
|
function update() {
|
|
var n = boxes.filter(function (b) { return b.checked; }).length;
|
|
if (info) { info.textContent = n ? (n + ' ausgewaehlt') : ''; }
|
|
if (all) { all.checked = n > 0 && n === boxes.length; }
|
|
}
|
|
if (all) {
|
|
all.addEventListener('change', function () {
|
|
boxes.forEach(function (b) { b.checked = all.checked; });
|
|
update();
|
|
});
|
|
}
|
|
boxes.forEach(function (b) { b.addEventListener('change', update); });
|
|
update();
|
|
}
|
|
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>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>%(title)s</title>
|
|
<style>%(style)s</style>
|
|
<script>%(script)s</script>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1><a href="%(home)s">pvesnap</a></h1>
|
|
<span class="muted">%(subtitle)s</span>
|
|
<span class="spacer"></span>
|
|
%(actions)s
|
|
</header>
|
|
<main>
|
|
%(body)s
|
|
</main>
|
|
</body>
|
|
</html>
|
|
"""
|