Files
tesm/srv/tesm-license/templates/settings_nginx.html
T
alientimandClaude Sonnet 5 90e07aa310 Master-Lizenzserver Phase 2: neue Schwester-Anwendung srv/tesm-license (v1.0.0)
Fork von TESM (srv/tesm), auf Kunden-/Lizenzverwaltung reduziert statt
Geraete-/PoE-Management. Wiederverwendet unveraendert: Login/Session/
Benutzer- und Gruppenverwaltung, LDAP/AD, NGINX- und Systemeinstellungen,
Live-Log/Verlauf/Auditlog, Im-/Export-Grundgeruest, das komplette
PERMISSIONS/NAV_ITEMS/inject_nav()-Rechtesystem sowie das in Phase 1
gebaute Lizenzsystem selbst sowohl fuer die MASTER-eigene Bootstrap-Lizenz
als auch fuer das exakt gleiche licensing.py (byte-identisch zu TESM --
Signieren/Verifizieren muss zwischen beiden Seiten kompatibel bleiben).

Entfernt: Clients/Switche/Zugangsdaten/DHCP/Fileshare/Wartung/Papierkorb/
manueller PoE-Neustart/SSH-Terminal inkl. aller zugehoerigen Tabellen,
Routen, Permissions, Nav-Eintraege und Abhaengigkeiten (paramiko/Flask-Sock/
simple-websocket/PyNaCl/pyasn1 aus requirements.txt). Benutzer-/Gruppen-
Loeschung von Soft- auf Hard-Delete umgestellt (kein Papierkorb mehr).
Eigener Pfad-/Env-Var-Namespace (TESM_LICENSE_* statt TESM_*, /var/log/
tesm-license statt /var/log/tesm usw.), damit Master und TESM testweise
sogar auf demselben Host nebeneinander laufen koennen, ohne sich Log-/
Config-Pfade streitig zu machen.

Neu -- der eigentliche Lizenzserver:
- license_customers/licenses-Tabellen, Master-Signaturschluessel
  (master_signing_key.json, einmalig erzeugt, NIE automatisch rotiert --
  jede Kundenlizenz traegt den zum Ausstellungszeitpunkt aktuellen
  master_pubkey fest eingebettet).
- Dashboard ('/') als Kunden-/Lizenzuebersicht (Typ, Module, Ablauf,
  Status, letzter Heartbeat), eigene Kunden-Verwaltungsseite.
- Lizenz-Ausstellung (Typ/Module/Laufzeit -> signierte Datei via
  licensing.issue_license), Detailseite, Download, Widerruf.
- /api/activate, /api/deactivate, /api/heartbeat (unauthentifiziert per
  Design -- die Signatur der Anfrage IST der Berechtigungsnachweis) sowie
  eine manuelle Offline-Code-Seite, die dieselben drei Verarbeitungs-
  funktionen (_process_activate/_process_deactivate/_process_heartbeat)
  nutzt wie die Online-API -- ein Protokoll, zwei Transportwege.
- E-Mail-Versand ausgestellter Lizenzen per Microsoft Graph
  (Client-Credentials-Flow, reine Standardbibliothek/urllib, keine neue
  Abhaengigkeit) inkl. Einrichtungsanleitung und Verbindungstest.
- create_master_license.py: lokales Bootstrap-/Erneuerungs-Skript fuer
  die eigene Lizenz des Masters (kein externer Super-Master noetig).

Verifiziert auf dem Testsystem (192.168.82.51): App laeuft parallel zu der
dort laufenden echten TESM-Instanz (Port 5001 vs. 80/5000, eigene
Log-/DB-Pfade, TESM unangetastet). Kompletter ECHTER End-to-End-Rundlauf
per Playwright durchgespielt -- kein simulierter Gegenpart: Kunde anlegen
-> Custom-Lizenz (dhcp+fileshare) ausstellen -> herunterladen -> auf der
echten TESM-Instanz hochladen -> ECHTE Online-Aktivierung (Master
verzeichnet Fingerprint, TESM zeigt 'Aktiviert') -> ECHTE Online-
Deaktivierung (TESM zeigt 'lizenzlos, Export bleibt moeglich', Master
zeigt Status 'Deaktiviert'). Lokale Syntax-/Templatepruefung (py_compile,
pyflakes, jinja2-Parse aller Templates) sauber.

Bewusst NICHT nach main gemergt/getaggt/ausgerollt -- folgt zusammen mit
Phase 3 (Infrastruktur-Umzug + POETEST-Enterprise-Lizenz), siehe Plan
toasty-twirling-hickey.md (Phase 2 von 3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 02:07:30 +02:00

213 lines
12 KiB
HTML

{% extends "base.html" %}
{% set active_page = "settings_nginx" %}
{% set can_edit = current_user.has_permission('settings_nginx.edit') and license_active() %}
{% block page_title %}NGINX{% endblock %}
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid settings-grid--wide">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Reverse-Proxy</h2>
<div class="hint">Domain (server_name), Ports und HTTPS/HSTS für den nginx-Reverse-Proxy vor der App.</div>
</div>
<button type="button" class="btn btn-secondary btn-sm"
onclick="openRawLogModal('{{ url_for('settings_nginx_raw') }}', 'Aktuelle nginx-Konfiguration (RAW)')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
RAW-Konfiguration
</button>
</div>
{% if pending_nginx_token %}
<div class="card-pad" style="background:var(--warning-dim); border-radius:var(--radius-sm); margin-bottom:16px;">
<p style="margin:0 0 12px; font-size:13px;">
Neue nginx-Konfiguration wurde angewendet. Wenn diese Seite gerade noch lädt, funktioniert die Verbindung —
bitte bestätigen, bevor automatisch zurückgerollt wird (nach {{ nginx_revert_seconds }}s ohne Bestätigung).
</p>
<form method="post">
<input type="hidden" name="confirm_nginx" value="{{ pending_nginx_token }}">
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Verbindung funktioniert — bestätigen
</button>
</form>
</div>
{% elif can_edit %}
<form method="post"
data-confirm="nginx-Konfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ nginx_revert_seconds }} Sekunden wiederhergestellt.">
<div class="field">
<label for="server_name">Domain (server_name) {{ hi.hint_icon("\"_\" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.", "Domain (server_name)") }}</label>
<input type="text" name="server_name" id="server_name" value="{{ server_name }}" placeholder="_ (kein bestimmter Hostname) oder z.B. tesm.example.com">
</div>
<div class="flex gap-2">
<div class="field field--half">
<label for="http_port">HTTP-Port</label>
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
</div>
<div class="field field--half">
<label for="https_port">HTTPS-Port {{ hi.hint_icon("Let's Encrypt validiert IMMER über Port 80 (protokollbedingt, unabhängig vom hier eingestellten HTTP-Port) — weicht der HTTP-Port von 80 ab, wird dafür automatisch zusätzlich ein minimaler Port-80-Block mitgeschrieben.", "HTTP-Port / HTTPS-Port") }}</label>
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
</div>
</div>
<div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}>
<span class="track"></span>
<span>SSL/HTTPS aktivieren</span>
</label>
{% if not cert_info %}<div class="field-hint">Erst nach Hochladen oder Anfordern eines Zertifikats verfügbar.</div>{% endif %}
</div>
<div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}>
<span class="track"></span>
<span>HSTS (Strict-Transport-Security) {{ hi.hint_icon("Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — bleibt auch bei einem späteren Zurückschalten auf HTTP im Browser für 1 Jahr bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.", "HSTS") }}</span>
</label>
</div>
<button type="submit" name="apply_nginx" value="1" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12l2 2 4-4"/><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/></svg>
Anwenden
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">
{% if current_user.has_permission('settings_nginx.edit') %}
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% else %}
Für Änderungen fehlt das Recht „NGINX ändern“.
{% endif %}
</p>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Zertifikat</h2>
<div class="hint">Hochladen (PEM) oder automatisch per Let's Encrypt anfordern — mit automatischer Verlängerung über certbots eigenen Timer.</div>
</div>
</div>
{% if cert_info %}
<div class="detail-list" style="margin-bottom:18px;">
<div class="detail-row"><span class="k">Quelle</span><span class="v">{{ "Let's Encrypt" if cert_info.source == "letsencrypt" else "Hochgeladen" }}</span></div>
<div class="detail-row"><span class="k">Subject</span><span class="v" style="text-align:right; word-break:break-all;">{{ cert_info.subject }}</span></div>
<div class="detail-row"><span class="k">Aussteller</span><span class="v" style="text-align:right; word-break:break-all;">{{ cert_info.issuer }}</span></div>
<div class="detail-row">
<span class="k">Gültig bis</span>
<span class="v">
{{ cert_info.not_after.strftime('%d.%m.%Y') }}
{% if cert_info.days_left < 0 %}
<span class="pill offline" style="margin-left:6px;">abgelaufen</span>
{% elif cert_info.days_left <= 30 %}
<span class="pill unknown" style="margin-left:6px;">noch {{ cert_info.days_left }} Tage</span>
{% else %}
<span class="pill online" style="margin-left:6px;">noch {{ cert_info.days_left }} Tage</span>
{% endif %}
</span>
</div>
{% if cert_info.source == "letsencrypt" %}
<div class="detail-row">
<span class="k">Automatische Verlängerung</span>
<span class="v">
{% if certbot_timer and certbot_timer.active %}
<span class="pill online">aktiv</span>
{% if certbot_timer.next_run %}<span class="text-faint" style="font-size:11px; display:block; margin-top:2px;">nächster Lauf: {{ certbot_timer.next_run }}</span>{% endif %}
{% elif certbot_timer %}
<span class="pill offline">inaktiv</span>
{% else %}
<span class="pill unknown">unbekannt</span>
{% endif %}
</span>
</div>
{% endif %}
</div>
{% if cert_info.source == "letsencrypt" and can_edit %}
<div class="flex gap-2" style="margin-bottom:18px;">
<form method="post" style="flex:1;">
<button type="submit" name="test_renewal" value="1" class="btn btn-secondary btn-block btn-sm">Verlängerung testen (Dry-Run)</button>
</form>
<form method="post" style="flex:1;" data-confirm="Zertifikat jetzt sofort erneuern (zählt gegen Let's Encrypts Rate-Limits)?">
<button type="submit" name="force_renew" value="1" class="btn btn-secondary btn-block btn-sm">Jetzt manuell verlängern</button>
</form>
</div>
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Noch kein Zertifikat hinterlegt.</p>
{% endif %}
{% if can_edit %}
<div class="field">
<label style="font-weight:600; font-size:13px;">Let's Encrypt anfordern</label>
<div class="field-hint" style="margin-bottom:8px;">Setzt voraus, dass die Domain öffentlich auf diesen Host auflöst und Port 80 aus dem Internet erreichbar ist (HTTP-01-Validierung) — für rein interne Instanzen ohne öffentliche Domain nicht nutzbar, dann stattdessen hochladen. Verlängerung läuft danach automatisch über certbots eigenen Timer{% if certbot_timer %} ({{ "aktiv" if certbot_timer.active else "inaktiv!" }}){% endif %}, zweimal täglich.</div>
</div>
<form method="post" style="margin-bottom:18px;">
<div class="field">
<label for="le_domain">Domain</label>
<input type="text" name="le_domain" id="le_domain" placeholder="z.B. tesm.example.com" value="{{ server_name if server_name != '_' else '' }}">
</div>
<div class="field">
<label for="le_email">E-Mail (optional, für Ablauf-Erinnerungen)</label>
<input type="email" name="le_email" id="le_email" placeholder="admin@example.com">
</div>
<button type="submit" name="request_letsencrypt" value="1" class="btn btn-secondary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/></svg>
Zertifikat anfordern
</button>
</form>
<div class="field"><label style="font-weight:600; font-size:13px;">Manuell hochladen</label></div>
<form method="post" enctype="multipart/form-data" style="margin-bottom:10px;">
<div class="field">
<label for="cert_file">Zertifikat (PEM, .crt/.pem)</label>
<input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required>
</div>
<div class="field">
<label for="key_file">Privater Schlüssel (PEM, unverschlüsselt) {{ hi.hint_icon("Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.", "Privater Schlüssel") }}</label>
<input type="file" name="key_file" id="key_file" accept=".pem,.key" required>
</div>
<button type="submit" name="upload_cert" value="1" class="btn btn-secondary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
Zertifikat + Schlüssel hochladen
</button>
</form>
{% if cert_info and not ssl_enabled %}
<form method="post" data-confirm="Hinterlegtes Zertifikat wirklich entfernen?" data-no-unsaved-guard>
<button type="submit" name="remove_cert" value="1" class="btn btn-danger btn-block">Zertifikat entfernen</button>
</form>
{% elif cert_info %}
<p class="text-faint" style="font-size:11.5px;">SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.</p>
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px;">
{% if current_user.has_permission('settings_nginx.edit') %}
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% else %}
Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.
{% endif %}
</p>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", () => {
const sslCheck = document.getElementById("ssl_enabled_check");
const hstsCheck = document.getElementById("hsts_enabled_check");
if (sslCheck && hstsCheck) {
sslCheck.addEventListener("change", () => {
hstsCheck.disabled = !sslCheck.checked;
if (!sslCheck.checked) hstsCheck.checked = false;
});
}
});
</script>
{% endblock %}