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>
280 lines
11 KiB
Python
280 lines
11 KiB
Python
"""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
|