TESM 2.0.0 -- Neubau

Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert
im Zweig SONNET5 erhalten.

Aufbau: apps/tesm (Anwendung), packages/tesm-core (gemeinsamer Kern),
packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd,
privilegierter Helfer), docs, tests. Der Lizenzserver liegt in seinem eigenen
Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich
jedes allein installieren laesst.

Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code
kommentiert, an der der Fehler entstanden ist:

* Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht ueber
  einen einzigen Helfer mit Positivlisten fuer jedes Argument.
* CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen.
* Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet.
* Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.
* Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea).
* Kein Hostname im Lizenz-Fingerabdruck.
* Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY).
* Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 18:00:06 +02:00
co-authored by Claude Opus 5
parent a193b9d6e9
commit 7354ff352b
227 changed files with 37605 additions and 21187 deletions
+279
View File
@@ -0,0 +1,279 @@
"""Der Weg vom alten TESM in dieses: Uebersetzer und Import zusammen.
Die Testdatei wird **genau so** gebaut, wie der Vorgaenger sie schreibt
(PBKDF2-HMAC-SHA256, 390000 Runden, Fernet, derselbe Umschlag). Damit prueft
der Test das echte Format und nicht meine Vorstellung davon.
"""
from __future__ import annotations
import base64
import json
from typing import Any
import pytest
pytest.importorskip("cryptography")
LEGACY_PASS = "alte-passphrase-2026"
NEW_PASS = "neue-passphrase-2026"
@pytest.fixture()
def tesm_app(instance_env: Any):
from tesm import create_app
return create_app(TESTING=True)
def _seal_legacy(payload: dict[str, Any], passphrase: str) -> str:
"""Baut einen Umschlag wie ``export_data()`` im alten TESM."""
import secrets
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
salt = secrets.token_bytes(16)
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=390000)
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
token = Fernet(key).encrypt(json.dumps(payload).encode("utf-8"))
return json.dumps(
{
"app": "tesm",
"version": 2,
"exported_at": "2026-09-02 12:00:00",
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
"payload": token.decode("utf-8"),
}
)
#: Ein Ausschnitt, der die Eigenheiten des alten Formats abdeckt: Switch- und
#: Zugangsdatenbezug per Name, geschachtelte DHCP-Optionswerte, "name" statt
#: "hostname" an der Reservierung, sowie Abschnitte, die nicht uebersetzt
#: werden koennen.
LEGACY_PAYLOAD: dict[str, Any] = {
"credentials": [
{"name": "Switch-Admin", "username": "manager", "password": "geheim-1", "category": "switch"},
{"name": "Linux-Root", "username": "root", "password": "geheim-2", "category": "linux"},
{"name": "Windows-Dienst", "username": "svc", "password": "geheim-3", "category": "windows"},
],
"switches": [
{"hostname": "sw-keller", "ip": "192.168.80.2", "ssh_port": 22, "credential_name": "Switch-Admin"},
],
"devices": [
{
"mac": "4c:52:62:25:b9:e4",
"ip": "192.168.80.137",
"port": "12",
"name": "Autodarts",
"switch_hostname": "sw-keller",
"is_active": 1,
"ssh_port": 22,
"credential_name": "Linux-Root",
},
{
"mac": "aa:bb:cc:00:00:02",
"ip": "192.168.80.50",
"port": "3",
"name": "Kasse",
"switch_hostname": "sw-keller",
"is_active": 1,
"ssh_port": 22,
"credential_name": "Windows-Dienst",
},
],
"dhcp": {
"settings": {"dhcp_domain": "firma.local", "dhcp_lease_default": "1200"},
"subnets": [
{
"interface": "eth0",
"range_start": "192.168.80.100",
"range_end": "192.168.80.200",
"gateway": "192.168.80.1",
"dns": "192.168.80.1",
"sort_order": 0,
"enabled": 1,
}
],
"reservations": [{"mac": "aa:bb:cc:00:00:09", "ip": "192.168.80.90", "name": "drucker"}],
"options": [
{
"code": 225,
"name": "url",
"type": "string",
"description": "Terminal-URL",
"is_standard": 0,
"values": [
{"device_mac": "4c:52:62:25:b9:e4", "value": "https://play.autodarts.com"},
{"device_mac": None, "value": "https://intranet.firma.local"},
],
},
# Ohne Wert gibt es nichts zu senden -- wird gemeldet, nicht uebernommen.
{"code": 226, "name": "leer", "type": "string", "description": "", "is_standard": 0, "values": []},
],
},
# Diese Abschnitte kann der Uebersetzer nicht abbilden.
"users": [{"username": "anna", "password_hash": "$2b$12$abcdefghijklmnopqrstuv"}],
"known_hosts": "192.168.80.2 ssh-ed25519 AAAA...\n",
}
def test_legacy_envelope_needs_the_right_passphrase():
from tesm.services.legacy_transfer import LegacyTransferError, open_legacy
datei = _seal_legacy({"credentials": []}, LEGACY_PASS)
assert open_legacy(datei, LEGACY_PASS) == {"credentials": []}
with pytest.raises(LegacyTransferError, match="Passphrase"):
open_legacy(datei, "falsch")
def test_legacy_envelope_of_another_app_is_refused():
from tesm.services.legacy_transfer import LegacyTransferError, open_legacy
fremd = json.dumps({"app": "etwas-anderes", "salt": "AAAA", "payload": "x"})
with pytest.raises(LegacyTransferError, match="nicht zu TESM"):
open_legacy(fremd, LEGACY_PASS)
def test_report_names_what_it_cannot_translate():
"""Nicht uebersetzbare Abschnitte muessen benannt werden, nicht verschwinden."""
from tesm.services.legacy_transfer import convert, open_legacy
_, bericht = convert(open_legacy(_seal_legacy(LEGACY_PAYLOAD, LEGACY_PASS), LEGACY_PASS))
text = bericht.as_text()
assert "bcrypt" in text, "der Hinweis zu lokalen Konten fehlt"
assert "Host-Schluessel" in text
assert "ohne Wert" in text, "die wertlose Option wurde nicht gemeldet"
assert bericht.counts["credentials"] == 3
assert bericht.counts["devices"] == 2
assert bericht.counts["dhcp-Optionswerte"] == 2
def test_missing_credentials_are_reported(tesm_app):
"""Ein zugeordneter, aber nicht exportierter Zugang bleibt beim Import leer."""
from tesm.services.legacy_transfer import convert, open_legacy
ohne = {key: value for key, value in LEGACY_PAYLOAD.items() if key != "credentials"}
_, bericht = convert(open_legacy(_seal_legacy(ohne, LEGACY_PASS), LEGACY_PASS))
hinweise = " ".join(bericht.notes)
assert "Switch-Admin" in hinweise and "Linux-Root" in hinweise
def test_full_round_trip_into_the_database(tesm_app):
"""Alte Datei -> Uebersetzer -> neues Format -> vorhandener Import."""
from tesm.services import transfer
from tesm.services.legacy_transfer import convert, open_legacy
from tesm_core.extension import core
ext = core(tesm_app)
nutzlast, _ = convert(open_legacy(_seal_legacy(LEGACY_PAYLOAD, LEGACY_PASS), LEGACY_PASS))
# Ueber Siegel und Entsiegeln, damit auch der neue Umschlag geprueft ist.
datei = transfer.seal(nutzlast, NEW_PASS)
entsiegelt, _kopf = transfer.unseal(datei, NEW_PASS)
with ext.database.session() as conn, ext.database.transaction(conn):
transfer.apply_import(
conn,
ext.keystore,
entsiegelt,
categories=("credentials", "switches", "devices", "dhcp", "settings"),
set_setting=lambda key, value: ext.settings.set(conn, key, value, actor="test"),
)
with ext.database.session() as conn:
# -- Zugangsdaten: Passwort muss wieder lesbar sein ------------------
from tesm.services import inventory
zugang = {c.name: c for c in inventory.list_credentials(conn)}
assert set(zugang) == {"Switch-Admin", "Linux-Root", "Windows-Dienst"}
assert zugang["Linux-Root"].category == "linux"
benutzer, passwort = inventory.credential_secret(
conn, ext.keystore, zugang["Linux-Root"].id
)
assert (benutzer, passwort) == ("root", "geheim-2")
# -- Switch und Verknuepfung ----------------------------------------
switch = conn.execute("SELECT * FROM switches WHERE hostname='sw-keller'").fetchone()
assert switch is not None
assert switch["ip"] == "192.168.80.2"
assert switch["credential_id"] == zugang["Switch-Admin"].id
# -- Clients: Switch, Port, Zugang, abgeleitete Kategorie ------------
geraet = conn.execute(
"SELECT * FROM devices WHERE mac='4c:52:62:25:b9:e4'"
).fetchone()
assert geraet["name"] == "Autodarts"
assert geraet["switch_id"] == switch["id"]
assert geraet["port"] == "12"
assert geraet["credential_id"] == zugang["Linux-Root"].id
assert geraet["category"] == "linux", "Kategorie aus den Zugangsdaten abgeleitet"
windows = conn.execute("SELECT * FROM devices WHERE mac='aa:bb:cc:00:00:02'").fetchone()
assert windows["category"] == "windows"
# -- DHCP: Subnetz, Reservierung, Optionswerte ----------------------
subnetz = conn.execute("SELECT * FROM dhcp_subnets").fetchone()
assert (subnetz["range_start"], subnetz["range_end"]) == (
"192.168.80.100",
"192.168.80.200",
)
reservierung = conn.execute("SELECT * FROM dhcp_reservations").fetchone()
assert reservierung["hostname"] == "drucker", "alt 'name' -> neu 'hostname'"
option = conn.execute("SELECT * FROM dhcp_options WHERE code=225").fetchone()
assert option is not None and option["name"] == "url"
werte = {
row["device_id"]: row["value"]
for row in conn.execute(
"SELECT device_id, value FROM dhcp_option_values WHERE option_id=?",
(option["id"],),
)
}
# Ein globaler Wert und einer, der an genau diesem Geraet haengt.
assert werte[None] == "https://intranet.firma.local"
assert werte[geraet["id"]] == "https://play.autodarts.com"
# -- Einstellungen: nur die DHCP-Schluessel -------------------------
assert ext.settings.get(conn, "dhcp_domain") == "firma.local"
def test_option_value_of_an_unknown_device_is_not_made_global(tesm_app):
"""Ein Wert fuer ein unbekanntes Geraet darf nicht an alle gehen.
Sonst wuerde aus einer geraetespezifischen Terminal-URL beim Import eine,
die jeder Client bekommt -- die stillste Art, eine Konfiguration zu
verfaelschen.
"""
from tesm.services import transfer
from tesm_core.extension import core
ext = core(tesm_app)
nutzlast = {
"dhcp": {
"options": [
{
"code": 225,
"name": "url",
"type": "string",
"description": "",
"is_standard": 0,
"value": "https://nur-fuer-eines",
"device_mac": "ff:ff:ff:ff:ff:ff",
}
]
}
}
with ext.database.session() as conn, ext.database.transaction(conn):
transfer.apply_import(
conn,
ext.keystore,
nutzlast,
categories=("dhcp",),
set_setting=lambda key, value: None,
)
with ext.database.session() as conn:
assert conn.execute("SELECT COUNT(*) FROM dhcp_option_values").fetchone()[0] == 0