TESM-Lizenzserver 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger: * Keine doppelte licensing.py -- ein Paket, das beide Anwendungen installieren, statt zweier Dateien, die byte-identisch bleiben sollen. * 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. * Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. * Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als "Aktivierung offen" markiert, bis sie zurueckkommt. * Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung, seq gegen das Zurueckrollen auf eine aeltere Lizenz. * Kein Hostname im Maschinen-Fingerabdruck. * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
"""Prueft das Repository selbst -- die Fehlerklassen, die erst beim Ausrollen auffallen.
|
||||
|
||||
Der Vorgaenger hat sich hier zweimal die Finger verbrannt: CRLF-Zeilenenden aus
|
||||
einer Windows-Buildmaschine machten Shell-Skripte auf dem Ziel unbrauchbar
|
||||
("bad interpreter"), und Schluesseldateien landeten versehentlich im Repo.
|
||||
Beides faellt hier auf, bevor ein Paket gebaut wird.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
SKIP_DIRS = {
|
||||
".git",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"instance",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
"node_modules",
|
||||
}
|
||||
TEXT_SUFFIXES = {
|
||||
".py", ".sh", ".html", ".css", ".js", ".json", ".toml", ".md",
|
||||
".yaml", ".yml", ".cfg", ".ini", ".txt", ".service", ".conf",
|
||||
}
|
||||
#: Dateien ohne Endung, die trotzdem Text sind.
|
||||
EXTENSIONLESS_TEXT = {"tesm-helper", "tesm", "tesm-license"}
|
||||
|
||||
SECRET_NAMES = {
|
||||
"data.keys",
|
||||
"secret.key",
|
||||
"license.json",
|
||||
"license_key.json",
|
||||
"master_signing_key.json",
|
||||
"fernet.key",
|
||||
"known_hosts",
|
||||
}
|
||||
SECRET_SUFFIXES = {".db", ".db-wal", ".db-shm", ".pem", ".p12", ".pfx"}
|
||||
|
||||
|
||||
def _repo_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for path in REPO_ROOT.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if any(part in SKIP_DIRS for part in path.relative_to(REPO_ROOT).parts):
|
||||
continue
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def _text_files() -> list[Path]:
|
||||
return [
|
||||
path
|
||||
for path in _repo_files()
|
||||
if path.suffix in TEXT_SUFFIXES or path.name in EXTENSIONLESS_TEXT
|
||||
]
|
||||
|
||||
|
||||
def test_no_crlf_line_endings():
|
||||
"""CRLF im Paket macht Shell-Skripte auf dem Linux-Ziel unbrauchbar."""
|
||||
offenders = [
|
||||
str(path.relative_to(REPO_ROOT))
|
||||
for path in _text_files()
|
||||
if b"\r\n" in path.read_bytes()
|
||||
]
|
||||
assert not offenders, (
|
||||
"Diese Dateien haben CRLF-Zeilenenden und wuerden auf dem Zielsystem brechen:\n "
|
||||
+ "\n ".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
def test_no_secrets_committed():
|
||||
"""Schluessel, Datenbanken und Lizenzdateien gehoeren nie ins Repo."""
|
||||
offenders = [
|
||||
str(path.relative_to(REPO_ROOT))
|
||||
for path in _repo_files()
|
||||
if path.name in SECRET_NAMES or path.suffix in SECRET_SUFFIXES
|
||||
]
|
||||
assert not offenders, "Geheimnisse im Repository:\n " + "\n ".join(offenders)
|
||||
|
||||
|
||||
def test_shell_scripts_have_shebang_and_strict_mode():
|
||||
scripts = [path for path in _repo_files() if path.suffix == ".sh" or path.name == "tesm-helper"]
|
||||
assert scripts, "Es wurden keine Shell-Skripte gefunden -- Test prueft ins Leere."
|
||||
for script in scripts:
|
||||
text = script.read_text(encoding="utf-8")
|
||||
assert text.startswith("#!"), f"{script.name}: Shebang fehlt"
|
||||
assert "set -euo pipefail" in text, f"{script.name}: 'set -euo pipefail' fehlt"
|
||||
|
||||
|
||||
def test_helper_validates_every_verb():
|
||||
"""Jedes Verb des privilegierten Helfers muss im case-Block auftauchen.
|
||||
|
||||
Der Helfer ist die einzige Stelle mit Root-Rechten. Ein Verb, das die
|
||||
Anwendung kennt, der Helfer aber nicht, waere entweder tot -- oder,
|
||||
schlimmer, ein Hinweis auf eine ungeprueft durchgereichte Aktion.
|
||||
"""
|
||||
from tesm_core.sysops import HELPER_VERBS
|
||||
|
||||
helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
|
||||
# case-Muster koennen mehrere Verben mit | buendeln.
|
||||
patterns: set[str] = set()
|
||||
for line in helper.splitlines():
|
||||
match = re.match(r"^\s{2,4}([a-z|-]+)\)\s*$", line)
|
||||
if match:
|
||||
patterns.update(match.group(1).split("|"))
|
||||
missing = [verb for verb in HELPER_VERBS if verb not in patterns]
|
||||
assert not missing, f"Verben fehlen im Helfer: {missing}"
|
||||
|
||||
|
||||
def test_helper_has_no_unquoted_argument_passthrough():
|
||||
"""Der Helfer darf Argumente nie ungeprueft an eine Shell weiterreichen."""
|
||||
helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
|
||||
for forbidden in ("eval ", "bash -c", "sh -c"):
|
||||
assert forbidden not in helper, f"Der Helfer enthaelt {forbidden!r}"
|
||||
|
||||
|
||||
def test_helper_blocks_path_traversal_everywhere():
|
||||
"""Jede Pfadpruefung im Helfer muss ".." abweisen.
|
||||
|
||||
Ohne diese Sperre laesst sich eine Positivliste ueber den Basisnamen
|
||||
unterlaufen: ``/etc/nginx/sites-available/../../../root/tesm`` hat den
|
||||
zulaessigen Basisnamen ``tesm``, liest aber ``/root/tesm``.
|
||||
"""
|
||||
helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
|
||||
checks = re.findall(
|
||||
r"^check_\w*(?:path|target|webroot)\w*\(\) \{(.*?)^\}", helper, re.S | re.M
|
||||
)
|
||||
assert checks, "Es wurden keine Pfadpruefungen gefunden -- Test prueft ins Leere."
|
||||
for body in checks:
|
||||
assert '*".."*' in body, "Pfadpruefung ohne ..-Sperre: " + body
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash nicht verfuegbar")
|
||||
def test_helper_accepts_instances_but_no_invented_keys():
|
||||
"""Der Namensraum einer Instanz ist eng begrenzt.
|
||||
|
||||
Er landet ungefiltert in Pfaden wie ``/etc/nginx/sites-available/<key>``.
|
||||
"""
|
||||
script = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
|
||||
start = script.index("check_app_key() {")
|
||||
end = script.index(chr(10) + "}", start) + 2
|
||||
prelude = 'APP_KEYS_ALLOWED=("tesm" "tesm-license")' + chr(10) + "fail() { exit 1; }" + chr(10)
|
||||
snippet = prelude + script[start:end]
|
||||
|
||||
def accepted(key: str) -> bool:
|
||||
command = snippet + chr(10) + 'check_app_key "' + key + '"'
|
||||
return subprocess.run(["bash", "-c", command], capture_output=True).returncode == 0
|
||||
|
||||
for good in ("tesm", "tesm-license", "tesm-opus", "tesm-license-opus", "tesm-test2"):
|
||||
assert accepted(good), good
|
||||
for bad in ("", "foo", "tesm-", "tesm-../etc", "../tesm", "tesm-GROSS", "tesm-" + "x" * 20):
|
||||
assert not accepted(bad), bad
|
||||
|
||||
|
||||
|
||||
def test_systemd_units_are_hardened():
|
||||
units = list((REPO_ROOT / "deploy" / "systemd").glob("*.service"))
|
||||
assert units
|
||||
for unit in units:
|
||||
text = unit.read_text(encoding="utf-8")
|
||||
assert "User=root" not in text, f"{unit.name} laeuft als root"
|
||||
# ProtectSystem=full, nicht strict: strict sperrt auch den privilegierten
|
||||
# Helfer aus /etc aus -- gemessen, siehe test_core_security.py.
|
||||
for directive in ("ProtectSystem=full", "PrivateTmp=yes"):
|
||||
assert directive in text, f"{unit.name}: {directive} fehlt"
|
||||
# NoNewPrivileges waere hier kontraproduktiv: es schaltet setuid ab und
|
||||
# damit sudo -- der Dienst koennte den privilegierten Helfer nicht mehr
|
||||
# aufrufen, und jede Systemaktion aus der Oberflaeche waere tot. Genau
|
||||
# so war es, und die Diagnoseseite meldete trotzdem "Helfer erreichbar".
|
||||
assert "NoNewPrivileges=yes" not in text, (
|
||||
f"{unit.name}: NoNewPrivileges=yes verhindert sudo und damit den Helfer."
|
||||
)
|
||||
|
||||
|
||||
def test_sudoers_grants_only_the_helper():
|
||||
for name in ("tesm", "tesm-license"):
|
||||
raw = (REPO_ROOT / "deploy" / "sudoers" / name).read_text(encoding="utf-8")
|
||||
# Kommentare erklaeren, was bewusst *nicht* getan wird -- sie duerfen den
|
||||
# Test nicht ausloesen.
|
||||
rules = [line for line in raw.splitlines() if line.strip() and not line.startswith("#")]
|
||||
body = "\n".join(rules)
|
||||
assert "NOPASSWD: ALL" not in body, f"{name}: zu weite sudo-Regel"
|
||||
assert "/usr/local/lib/tesm/tesm-helper" in body
|
||||
for line in rules:
|
||||
if "NOPASSWD" in line:
|
||||
assert line.strip().endswith("/usr/local/lib/tesm/tesm-helper"), (
|
||||
f"{name}: NOPASSWD-Regel erlaubt mehr als den Helfer: {line}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("app", ["tesm", "tesm-license"])
|
||||
def test_nginx_template_serves_static_from_app_root(app: str):
|
||||
"""Der alias-Pfad ist genau der Fehler, der im Vorgaenger jahrelang ueberlebte."""
|
||||
text = (REPO_ROOT / "deploy" / "nginx" / f"{app}.conf").read_text(encoding="utf-8")
|
||||
assert f"alias /srv/{app}/static/;" in text
|
||||
assert f"alias /srv/{app}/static-core/;" in text
|
||||
|
||||
|
||||
def test_documentation_referenced_by_the_units_exists():
|
||||
"""``Documentation=`` in der Unit muss auf eine Datei zeigen, die es gibt.
|
||||
|
||||
Sonst laeuft ``systemctl help tesm`` ins Leere -- und schlimmer: der
|
||||
Verweis suggeriert eine Anleitung, die niemand geschrieben hat.
|
||||
"""
|
||||
for unit in (REPO_ROOT / "deploy" / "systemd").glob("*.service"):
|
||||
for line in unit.read_text(encoding="utf-8").splitlines():
|
||||
if not line.startswith("Documentation=file:"):
|
||||
continue
|
||||
target = line.split("file:", 1)[1].strip()
|
||||
# /srv/<app>/docs/X -> docs/X im Repository
|
||||
relative = target.split("/docs/", 1)[1]
|
||||
assert (REPO_ROOT / "docs" / relative).is_file(), (
|
||||
f"{unit.name} verweist auf {target}, docs/{relative} fehlt"
|
||||
)
|
||||
|
||||
|
||||
def test_readme_links_point_to_existing_files():
|
||||
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
||||
for target in re.findall(r"\]\((docs/[^)#]+)\)", readme):
|
||||
assert (REPO_ROOT / target).is_file(), f"README verweist auf {target}"
|
||||
|
||||
|
||||
def _templates() -> list[Path]:
|
||||
return [
|
||||
path
|
||||
for path in _repo_files()
|
||||
if path.suffix == ".html" and "templates" in path.parts
|
||||
]
|
||||
|
||||
|
||||
def test_no_html_entities_inside_escaped_macro_calls():
|
||||
"""Makros escapen ihren Text -- Entities darin erscheinen woertlich.
|
||||
|
||||
Auf der Wartungsseite stand deshalb ``„Linux-Client“`` als
|
||||
sichtbarer Text. Der Fehler ist unauffaellig, weil dieselbe Schreibweise
|
||||
im uebrigen Vorlagentext voellig richtig ist.
|
||||
"""
|
||||
# Genau bis zum schliessenden "}}" des Aufrufs -- ein greedy Ausdruck
|
||||
# verschluckte sonst den nachfolgenden Seiteninhalt und meldete dessen
|
||||
# Tabellenmarkup als Treffer.
|
||||
pattern = re.compile(
|
||||
r"\{\{\s*(?:alert|empty_state|pill|stat)\((?:[^{}]|\{[^{}]*\})*?\)\s*"
|
||||
r"(?:\|\s*safe\s*)?\}\}",
|
||||
re.S,
|
||||
)
|
||||
offenders: list[str] = []
|
||||
for path in _templates():
|
||||
for match in pattern.finditer(path.read_text(encoding="utf-8")):
|
||||
call = match.group(0)
|
||||
# Frueher stand hier eine Ausnahme fuer "| safe". Sie war falsch:
|
||||
# das Filter wirkt auf das *Ergebnis* des Makros, escaped wurde aber
|
||||
# schon beim Einsetzen des Arguments. Auf der Geraeteseite stand
|
||||
# deshalb woertlich "<em>und</em>" im Text.
|
||||
if re.search(r"&[a-zA-Z]{2,10};|</?[a-z]{1,10}>", call):
|
||||
offenders.append(
|
||||
f"{path.relative_to(REPO_ROOT)}: {' '.join(call.split())[:100]}"
|
||||
)
|
||||
assert not offenders, (
|
||||
"HTML im escapten Makrotext -- das erscheint woertlich auf der Seite:\n "
|
||||
+ "\n ".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
def test_refresh_regions_never_point_at_a_whole_page():
|
||||
"""``data-refresh-url`` schreibt die Antwort in ``innerHTML``.
|
||||
|
||||
Zeigt die URL auf eine vollstaendige Seite, landet die ganze Seite in
|
||||
einem Ausschnitt -- bei jedem Durchlauf eine Ebene tiefer. Genau das ist
|
||||
auf der Wartungsseite passiert: die Karte "Auftraege" holte sich
|
||||
``maintenance.overview``, also sich selbst.
|
||||
|
||||
Erlaubt sind nur Endpunkte, deren Name sie als Ausschnitt ausweist.
|
||||
"""
|
||||
fragment_markers = ("fragment", "tiles", "output", "status", "partial", "ausschnitt")
|
||||
pattern = re.compile(r"data-refresh-url=\"\{\{\s*url_for\('([^']+)'")
|
||||
found = 0
|
||||
for path in _templates():
|
||||
for match in pattern.finditer(path.read_text(encoding="utf-8")):
|
||||
endpoint = match.group(1)
|
||||
found += 1
|
||||
assert any(marker in endpoint.lower() for marker in fragment_markers), (
|
||||
f"{path.relative_to(REPO_ROOT)}: data-refresh-url zeigt auf {endpoint!r} -- "
|
||||
"das sieht nach einer ganzen Seite aus, nicht nach einem Ausschnitt."
|
||||
)
|
||||
assert found, "Keine Aktualisierungsbereiche gefunden -- Test prueft ins Leere."
|
||||
|
||||
|
||||
def test_refresh_loop_treats_zero_as_off():
|
||||
"""``Number(x || 15000)`` macht aus einer 0 klammheimlich 15000."""
|
||||
script = (
|
||||
REPO_ROOT
|
||||
/ "packages"
|
||||
/ "tesm-core"
|
||||
/ "src"
|
||||
/ "tesm_core"
|
||||
/ "static"
|
||||
/ "tesm_core"
|
||||
/ "js"
|
||||
/ "app.js"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "refreshInterval || " not in script
|
||||
assert "interval <= 0" in script, "Ein Intervall von 0 muss die Aktualisierung abschalten."
|
||||
|
||||
|
||||
def test_licensing_package_is_shared_not_copied():
|
||||
"""Es darf keine zweite Kopie des Lizenzprotokolls geben.
|
||||
|
||||
Genau diese Doppelung -- zwei byte-identisch zu haltende ``licensing.py`` --
|
||||
war im Vorgaenger die gefaehrlichste Invariante.
|
||||
"""
|
||||
copies = [
|
||||
str(path.relative_to(REPO_ROOT))
|
||||
for path in _repo_files()
|
||||
if path.name == "licensing.py"
|
||||
]
|
||||
assert not copies, f"Kopien des Lizenzmoduls gefunden: {copies}"
|
||||
Reference in New Issue
Block a user