Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56974a4308 | ||
|
|
b69d0745dd | ||
|
|
7ab6358b9f | ||
|
|
f02db4c6b5 | ||
|
|
c7ad0d3d49 | ||
|
|
f09aaae8a9 |
+1
-1
@@ -134,7 +134,7 @@ sudo chmod 755 /var/log/tesm
|
||||
step "Deploying application to /srv/tesm"
|
||||
sudo mkdir -p /srv/tesm
|
||||
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
||||
--exclude 'known_hosts' \
|
||||
--exclude 'known_hosts' --exclude 'license.json' \
|
||||
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
|
||||
print_status "Application files copied"
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.2.1
|
||||
1.2.7
|
||||
|
||||
+71
-10
@@ -326,6 +326,10 @@ PERMISSIONS = {
|
||||
"label": "Systemeinstellungen",
|
||||
"rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
|
||||
},
|
||||
"settings_license": {
|
||||
"label": "Lizenz",
|
||||
"rows": {"view": "settings_license.view", "edit": "settings_license.edit"},
|
||||
},
|
||||
"settings_importexport": {
|
||||
"label": "Im-/Export",
|
||||
"rows": {
|
||||
@@ -392,6 +396,8 @@ PERMISSION_LABELS = {
|
||||
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
|
||||
"groups.edit": "Gruppen ändern (inkl. Löschen)",
|
||||
"settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern",
|
||||
"settings_license.view": "Lizenzstatus ansehen",
|
||||
"settings_license.edit": "Lizenzdatei hochladen sowie Lizenz aktivieren/deaktivieren (online oder per Code)",
|
||||
"settings_importexport.view": "Im-/Export-Seite ansehen",
|
||||
"settings_importexport.edit": "Daten importieren",
|
||||
"settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)",
|
||||
@@ -486,10 +492,11 @@ def _nav_key_visible(key, user):
|
||||
if key == "settings_system":
|
||||
return user.can_view_settings_system
|
||||
if key == "settings_license":
|
||||
# Immer erreichbar für jeden mit Systemeinstellungen-Leserecht --
|
||||
# muss auch OHNE Lizenz sichtbar bleiben, sonst könnte niemand
|
||||
# jemals eine erste Lizenz hochladen/aktivieren.
|
||||
return user.can_view_settings_system
|
||||
# Eigenes Recht (settings_license.view), unabhängig von
|
||||
# settings_system -- muss auch OHNE eine aktive Lizenz sichtbar
|
||||
# bleiben (das ist eine andere Prüfung, siehe license_active()),
|
||||
# sonst könnte niemand jemals eine erste Lizenz hochladen/aktivieren.
|
||||
return user.can_view_settings_license
|
||||
if key == "settings_importexport":
|
||||
return user.can_view_settings_importexport
|
||||
if key == "settings_dhcp":
|
||||
@@ -726,6 +733,10 @@ class User(UserMixin):
|
||||
def can_view_settings_system(self):
|
||||
return self.has_permission("settings_system.view")
|
||||
|
||||
@property
|
||||
def can_view_settings_license(self):
|
||||
return self.has_permission("settings_license.view")
|
||||
|
||||
@property
|
||||
def can_view_settings_importexport(self):
|
||||
return self.has_any_permission([
|
||||
@@ -1146,6 +1157,32 @@ def _ensure_schema():
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,)
|
||||
)
|
||||
|
||||
_migration_key_license_perm = "_migrated_settings_license_permission_v1"
|
||||
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_license_perm,)).fetchone():
|
||||
# "Lizenz" (settings_license) hatte bislang KEIN eigenes Recht --
|
||||
# die Seite lief komplett über settings_system.view/.edit mit.
|
||||
# Backfill: jede Gruppe, die settings_system.view/.edit bereits
|
||||
# hatte, bekommt automatisch das neue, gespiegelte
|
||||
# settings_license.view/.edit dazu -- ohne diesen Schritt würden
|
||||
# bestehende Gruppen beim Update stillschweigend den Zugriff auf
|
||||
# die Lizenzseite verlieren, den sie vorher implizit hatten.
|
||||
_system_to_license = {
|
||||
"settings_system.view": "settings_license.view",
|
||||
"settings_system.edit": "settings_license.edit",
|
||||
}
|
||||
for system_perm, license_perm in _system_to_license.items():
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (system_perm,)
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
|
||||
(row["group_id"], license_perm),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_license_perm,)
|
||||
)
|
||||
|
||||
_migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1"
|
||||
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone():
|
||||
for stale_log in glob.glob("/var/log/rpi-*.log"):
|
||||
@@ -4469,7 +4506,7 @@ def settings():
|
||||
@app.route("/settings/license")
|
||||
@login_required
|
||||
def settings_license():
|
||||
if not current_user.can_view_settings_system:
|
||||
if not current_user.can_view_settings_license:
|
||||
flash("Keine Berechtigung, die Lizenz anzusehen.", "danger")
|
||||
return redirect(url_for("index"))
|
||||
pending_raw = get_setting("license_pending_request", "")
|
||||
@@ -4492,7 +4529,7 @@ def settings_license():
|
||||
@app.route("/settings/license/upload", methods=["POST"])
|
||||
@login_required
|
||||
def license_upload():
|
||||
if not current_user.has_permission("settings_system.edit"):
|
||||
if not current_user.has_permission("settings_license.edit"):
|
||||
flash("Keine Berechtigung, die Lizenz zu ändern.", "danger")
|
||||
return redirect(url_for("settings_license"))
|
||||
uploaded = request.files.get("license_file")
|
||||
@@ -4585,7 +4622,7 @@ def _license_handshake_confirm(action, success_setting_fn, success_log_action, s
|
||||
@app.route("/settings/license/activate", methods=["POST"])
|
||||
@login_required
|
||||
def license_activate():
|
||||
if not current_user.has_permission("settings_system.edit"):
|
||||
if not current_user.has_permission("settings_license.edit"):
|
||||
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
||||
return redirect(url_for("settings_license"))
|
||||
if not _license_state.get("file") or not _license_state.get("valid"):
|
||||
@@ -4595,6 +4632,18 @@ def license_activate():
|
||||
def on_success():
|
||||
set_setting("license_activation_status", "active")
|
||||
set_setting("license_export_grace", "")
|
||||
# Sofort einen ersten Heartbeat auslösen, statt bis zu 6h auf den
|
||||
# nächsten planmäßigen Durchlauf zu warten (siehe
|
||||
# _license_heartbeat_loop) -- ohne das würde "Letzter Heartbeat"
|
||||
# direkt nach der Aktivierung fälschlich wie ein Verbindungsproblem
|
||||
# aussehen ("noch nie"), obwohl schlicht noch keine Zeit vergangen
|
||||
# ist. Ein Fehlschlag hier (z.B. Kunde offline) wird wie jeder
|
||||
# andere Heartbeat-Fehlschlag behandelt (nur vermerkt, nichts wird
|
||||
# deswegen abgeschaltet).
|
||||
try:
|
||||
_send_heartbeat()
|
||||
except Exception:
|
||||
app.logger.error("Erster Heartbeat nach Aktivierung fehlgeschlagen:\n%s", traceback.format_exc())
|
||||
|
||||
return _license_handshake("activate", "/api/activate", on_success, "license.activated", "Lizenz erfolgreich aktiviert.")
|
||||
|
||||
@@ -4602,13 +4651,25 @@ def license_activate():
|
||||
@app.route("/settings/license/activate/confirm", methods=["POST"])
|
||||
@login_required
|
||||
def license_activate_confirm():
|
||||
if not current_user.has_permission("settings_system.edit"):
|
||||
if not current_user.has_permission("settings_license.edit"):
|
||||
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
||||
return redirect(url_for("settings_license"))
|
||||
|
||||
def on_success():
|
||||
set_setting("license_activation_status", "active")
|
||||
set_setting("license_export_grace", "")
|
||||
# Sofort einen ersten Heartbeat auslösen, statt bis zu 6h auf den
|
||||
# nächsten planmäßigen Durchlauf zu warten (siehe
|
||||
# _license_heartbeat_loop) -- ohne das würde "Letzter Heartbeat"
|
||||
# direkt nach der Aktivierung fälschlich wie ein Verbindungsproblem
|
||||
# aussehen ("noch nie"), obwohl schlicht noch keine Zeit vergangen
|
||||
# ist. Ein Fehlschlag hier (z.B. Kunde offline) wird wie jeder
|
||||
# andere Heartbeat-Fehlschlag behandelt (nur vermerkt, nichts wird
|
||||
# deswegen abgeschaltet).
|
||||
try:
|
||||
_send_heartbeat()
|
||||
except Exception:
|
||||
app.logger.error("Erster Heartbeat nach Aktivierung fehlgeschlagen:\n%s", traceback.format_exc())
|
||||
|
||||
return _license_handshake_confirm("activate", on_success, "license.activated", "Lizenz erfolgreich aktiviert.")
|
||||
|
||||
@@ -4616,7 +4677,7 @@ def license_activate_confirm():
|
||||
@app.route("/settings/license/deactivate", methods=["POST"])
|
||||
@login_required
|
||||
def license_deactivate():
|
||||
if not current_user.has_permission("settings_system.edit"):
|
||||
if not current_user.has_permission("settings_license.edit"):
|
||||
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
||||
return redirect(url_for("settings_license"))
|
||||
if not _license_state.get("file") or not license_activated():
|
||||
@@ -4636,7 +4697,7 @@ def license_deactivate():
|
||||
@app.route("/settings/license/deactivate/confirm", methods=["POST"])
|
||||
@login_required
|
||||
def license_deactivate_confirm():
|
||||
if not current_user.has_permission("settings_system.edit"):
|
||||
if not current_user.has_permission("settings_license.edit"):
|
||||
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
||||
return redirect(url_for("settings_license"))
|
||||
|
||||
|
||||
+69
-2
@@ -39,6 +39,31 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,
|
||||
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
|
||||
ALL_MODULES = ("dhcp", "fileshare", "maintenance")
|
||||
|
||||
# Rolle der lizenzierten Instanz, eingebettet in JEDE ausgestellte
|
||||
# Lizenzdatei (signiert, siehe issue_license) -- NICHT nur DB-seitige
|
||||
# Buchfuehrung wie das gleichnamige, bislang ungenutzte licenses.role-Feld
|
||||
# in der Master-DB vorher. Ermoeglicht einer lizenzierten Instanz, anhand
|
||||
# ihrer EIGENEN, geladenen Lizenz strukturell zu erkennen, was sie darf:
|
||||
# "master" -- die Wurzel-Instanz. Wird AUSSCHLIESSLICH lokal per
|
||||
# create_master_license.py erzeugt, NIE ueber die
|
||||
# normale Ticket-/Ausstellungs-UI -- das waere sonst
|
||||
# eine Moeglichkeit, sich selbst zur Wurzel zu
|
||||
# erklaeren. Einzige Rolle, die weitere
|
||||
# "license_server"-Lizenzen ausstellen darf.
|
||||
# "license_server" -- ein vom Master (oder einer anderen "master"-Instanz)
|
||||
# ausgestellter Lizenzserver ("Sub-Lizenzserver").
|
||||
# Darf alles, was ein Lizenzserver koennen soll
|
||||
# (Kunden/Tickets/Lizenzen fuer eigene TESM-Kunden
|
||||
# ausstellen, Benutzer/Gruppen/Einstellungen
|
||||
# verwalten) -- AUSSER selbst weitere
|
||||
# "license_server"-Lizenzen ausstellen. Diese Sperre
|
||||
# ist strukturell (haengt an der eigenen Rolle, nicht
|
||||
# an einem vergebbaren Recht) und daher auch von einem
|
||||
# lokalen Admin dieser Instanz nicht aufhebbar.
|
||||
# "customer" -- eine gewoehnliche TESM-Kundeninstanz (Default,
|
||||
# unveraendertes Verhalten).
|
||||
LICENSE_ROLES = ("master", "license_server", "customer")
|
||||
|
||||
GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden
|
||||
EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige
|
||||
HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab)
|
||||
@@ -103,14 +128,40 @@ def _parse_iso(s: str) -> float:
|
||||
return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp()
|
||||
|
||||
|
||||
# Sentinel-Ablaufdatum fuer "Lifetime"-Lizenzen. Bewusst ein fixes, weit in
|
||||
# der Zukunft liegendes Datum statt eines Sonderwerts wie None/0 fuer
|
||||
# expires_at -- dadurch bleibt JEDE bestehende Datumsvergleichs-/
|
||||
# Differenzrechnung (hier, im Master-Dashboard, UND im TESM-Client) ohne
|
||||
# jede Sonderbehandlung korrekt: "in > 90 Tagen faellig" o.ae. ist fuer ein
|
||||
# Datum im Jahr 2099 schlicht immer wahr. Ob eine Lizenz als "Lifetime"
|
||||
# geflaggt ist, wird zusaetzlich explizit in der Master-DB gespeichert
|
||||
# (licenses.lifetime) -- dieses Sentinel-Datum ist nur die tatsaechlich in
|
||||
# die signierte Lizenzdatei eingebettete, fuer den Client sichtbare Reprae-
|
||||
# sentation davon.
|
||||
LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z"
|
||||
|
||||
|
||||
# ================================================================ Lizenz ==
|
||||
|
||||
def issue_license(*, customer, license_type, modules, valid_days,
|
||||
master_private_key_b64, master_public_key_b64,
|
||||
master_endpoint, vendor, license_id=None, now=None):
|
||||
master_endpoint, vendor, license_id=None, now=None, lifetime=False,
|
||||
role="customer"):
|
||||
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
|
||||
frischem Pro-Lizenz-Schlüsselpaar.
|
||||
|
||||
lifetime=True ignoriert valid_days komplett und setzt stattdessen das
|
||||
feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar.
|
||||
|
||||
role (siehe LICENSE_ROLES): "customer" (Default, unverändertes
|
||||
Verhalten) für gewöhnliche TESM-Kundeninstanzen, "license_server" für
|
||||
einen vom Aufrufer ausgestellten Sub-Lizenzserver. role="master" darf
|
||||
NUR von create_master_license.py verwendet werden (lokal, ohne diese
|
||||
Funktion über die normale Ticket-UI aufzurufen) -- diese Funktion prüft
|
||||
das nicht selbst, das Verbot ist stattdessen dadurch durchgesetzt, dass
|
||||
die App-seitige Ausstellungsfunktion role="master" gar nicht erst als
|
||||
Option anbietet (siehe app.py can_issue_license_server_licenses()).
|
||||
|
||||
Rückgabe: (license_file, license_pubkey) -- license_file ist die
|
||||
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
|
||||
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
|
||||
@@ -118,6 +169,8 @@ def issue_license(*, customer, license_type, modules, valid_days,
|
||||
dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
|
||||
if license_type not in LICENSE_TYPES:
|
||||
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
|
||||
if role not in LICENSE_ROLES:
|
||||
raise ValueError(f"Unbekannte Rolle: {role!r} (erlaubt: {LICENSE_ROLES})")
|
||||
unknown = set(modules) - set(ALL_MODULES)
|
||||
if unknown:
|
||||
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
|
||||
@@ -130,11 +183,12 @@ def issue_license(*, customer, license_type, modules, valid_days,
|
||||
"type": license_type,
|
||||
"modules": sorted(modules),
|
||||
"issued_at": _iso(now),
|
||||
"expires_at": _iso(now + valid_days * 86400),
|
||||
"expires_at": LIFETIME_EXPIRES_AT if lifetime else _iso(now + valid_days * 86400),
|
||||
"license_pubkey": license_pub,
|
||||
"master_pubkey": master_public_key_b64,
|
||||
"master_endpoint": master_endpoint,
|
||||
"vendor": vendor,
|
||||
"role": role,
|
||||
}
|
||||
signature = sign_payload(payload, master_private_key_b64)
|
||||
license_file = {**payload, "license_privkey": license_priv, "signature": signature}
|
||||
@@ -174,6 +228,12 @@ def license_status(license_file: dict, now=None) -> dict:
|
||||
"type": license_file.get("type"),
|
||||
"customer": license_file.get("customer"),
|
||||
"expires_at": license_file.get("expires_at"),
|
||||
# .get(..., "customer"): aeltere, vor Einfuehrung von role signierte
|
||||
# Lizenzdateien haben dieses Feld noch nicht -- fehlend bedeutet
|
||||
# unveraendert "gewoehnliche Kundeninstanz", nie "master"/
|
||||
# "license_server" (sonst koennte eine alte Datei rueckwirkend
|
||||
# unbeabsichtigt privilegiert erscheinen).
|
||||
"role": license_file.get("role", "customer"),
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +285,13 @@ def build_client_request(action, license_file, nonce=None) -> dict:
|
||||
payload = {
|
||||
"license_id": license_file["license_id"],
|
||||
"fingerprint": system_fingerprint(),
|
||||
# Rein informativ fuer die Dashboard-Kacheln des Masters (siehe
|
||||
# dortiges index()) -- fliesst NICHT in irgendeine Sicherheits-
|
||||
# entscheidung ein (die bleibt allein Sache des Fingerprints), wird
|
||||
# aber wie alle anderen Felder mitsigniert, damit ein Angreifer
|
||||
# ohne den privaten Lizenzschluessel keinen falschen Hostnamen
|
||||
# unterschieben kann.
|
||||
"hostname": socket.gethostname(),
|
||||
"action": action,
|
||||
"nonce": nonce or uuid.uuid4().hex,
|
||||
"timestamp": _iso(time.time()),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "settings_license" %}
|
||||
{% set can_edit = current_user.has_permission('settings_system.edit') %}
|
||||
{% set can_edit = current_user.has_permission('settings_license.edit') %}
|
||||
{% block page_title %}Lizenz{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
|
||||
|
||||
@@ -156,6 +156,11 @@
|
||||
<div class="hint">Kontakt für Rückfragen zu dieser Lizenz.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if license_file.vendor.logo_base64 %}
|
||||
<div class="field" style="text-align:center;">
|
||||
<img src="{{ license_file.vendor.logo_base64 }}" alt="{{ license_file.vendor.name or 'Anbieter' }}" style="max-width:220px; max-height:90px; margin:0 auto 8px;">
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="field">
|
||||
<label>Firma</label>
|
||||
<input type="text" value="{{ license_file.vendor.name or '' }}" disabled>
|
||||
|
||||
Reference in New Issue
Block a user