Files
tesm-license/tests/test_webserver.py
T
alientimandClaude Opus 5 f7805a2180 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>
2026-09-02 18:01:23 +02:00

522 lines
19 KiB
Python

"""Die nginx-Vorlage und die Trennung mehrerer Instanzen auf einem Host.
Zwei Fehlerklassen werden hier festgenagelt:
1. Der alias-Fehler des Vorgaengers -- statische Dateien zeigten auf ein
Verzeichnis, das es nicht gab, und die Oberflaeche kam jahrelang ohne
Design. Die erzeugte Datei muss die richtigen Pfade enthalten.
2. Zwei Installationen derselben Anwendung auf einem Host. Solange alles
ueber den ``app_key`` benannt wurde, hat eine Testinstanz beim Speichern
der Webserver-Einstellungen die Site-Datei der Produktion ueberschrieben
-- und beide teilten sich dasselbe Sitzungscookie, weil Cookies keine
Ports unterscheiden.
"""
from __future__ import annotations
import re
import pytest
from tesm_core.system import webserver
from tesm_core.sysops import HELPER_VERBS, Result, SysOps
def make_config(**overrides) -> webserver.WebConfig:
base = {
"app_key": "tesm",
"upstream": "127.0.0.1:5000",
"static_root": "/srv/tesm/static",
}
return webserver.WebConfig(**{**base, **overrides})
# --------------------------------------------------------------------------- #
# Nur HTTP
# --------------------------------------------------------------------------- #
def test_http_only_has_no_tls_block():
text = webserver.render(make_config())
assert "listen 80 default_server;" in text
assert "ssl_certificate" not in text
assert "return 308" not in text
def test_static_aliases_point_into_the_installation():
text = webserver.render(make_config())
assert "alias /srv/tesm/static/;" in text
assert "alias /srv/tesm/static-core/;" in text
def test_proxy_passes_the_host_including_port():
"""``$host`` verwirft den Port -- damit scheitert die Origin-Pruefung.
Genau daran ist die Anmeldung hinter dem Reverse Proxy auf einem
abweichenden Port real gescheitert.
"""
text = webserver.render(make_config())
assert "proxy_set_header Host $http_host;" in text
assert "proxy_set_header Host $host;" not in text
assert "proxy_set_header X-Forwarded-Port $server_port;" in text
def test_acme_path_is_always_reachable():
text = webserver.render(
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
)
# Auch bei aktiver Umleitung -- sonst kann Let's Encrypt nie erneuern.
assert "/.well-known/acme-challenge/" in text
challenge = text.index("acme-challenge")
redirect = text.index("return 308")
assert challenge < redirect
# --------------------------------------------------------------------------- #
# HTTPS
# --------------------------------------------------------------------------- #
def test_https_listens_on_443_and_redirects_80():
text = webserver.render(
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
)
assert "listen 443 ssl default_server http2;" in text
assert "listen [::]:443 ssl default_server http2;" in text
assert "return 308 https://$host$request_uri;" in text
assert "ssl_protocols TLSv1.2 TLSv1.3;" in text
def test_hsts_only_when_asked():
without = webserver.render(
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
)
assert "Strict-Transport-Security" not in without
with_hsts = webserver.render(
make_config(
https_enabled=True, hsts_enabled=True, cert_path="/c.pem", key_path="/k.pem"
)
)
assert "Strict-Transport-Security" in with_hsts
# HSTS ohne TLS waere ein Selbstschuss: der Browser merkt sich, dass er die
# Seite nur noch ueber HTTPS aufrufen darf.
assert "ssl_certificate" in with_hsts
def test_hsts_is_ignored_without_https():
text = webserver.render(make_config(hsts_enabled=True))
assert "Strict-Transport-Security" not in text
def test_redirect_can_be_switched_off():
text = webserver.render(
make_config(
https_enabled=True, redirect_http=False, cert_path="/c.pem", key_path="/k.pem"
)
)
assert "return 308" not in text
# Dann muss Port 80 die Anwendung weiter bedienen.
assert text.count("proxy_pass http://127.0.0.1:5000;") >= 2
def test_deviating_ports_are_rendered_and_redirect_keeps_the_port():
text = webserver.render(
make_config(
https_enabled=True,
cert_path="/c.pem",
key_path="/k.pem",
http_port=8080,
https_port=8443,
)
)
assert "listen 8080 default_server;" in text
assert "listen 8443 ssl default_server http2;" in text
# Ohne den Port im Ziel liefe die Umleitung ins Leere.
assert "return 308 https://$host:8443$request_uri;" in text
def test_standard_port_leaves_no_suffix():
assert make_config(https_port=443).https_suffix == ""
assert make_config(https_port=8443).https_suffix == ":8443"
def test_websocket_location_only_when_configured():
assert "proxy_set_header Upgrade" not in webserver.render(make_config())
text = webserver.render(make_config(websocket_path="/ws/"))
assert "location /ws/ {" in text
assert "proxy_read_timeout 3600s;" in text
def _location_blocks(text: str) -> list[list[str]]:
"""Zerlegt die erzeugte Datei in ihre ``location``-Bloecke."""
blocks: list[list[str]] = []
current: list[str] | None = None
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("location ") and stripped.endswith("{"):
current = []
elif stripped == "}" and current is not None:
blocks.append(current)
current = None
elif current is not None and stripped and not stripped.startswith("#"):
current.append(stripped)
return blocks
@pytest.mark.parametrize(
"config",
[
make_config(websocket_path="/ws/"),
make_config(
websocket_path="/ws/", https_enabled=True, cert_path="/c.pem", key_path="/k.pem"
),
make_config(https_enabled=True, hsts_enabled=True, cert_path="/c.pem", key_path="/k.pem"),
],
)
def test_no_directive_appears_twice_in_one_block(config: webserver.WebConfig):
"""nginx weist eine doppelte Direktive im selben Block ab.
Auf POETEST real passiert: der Websocket-Block erbte ``proxy_read_timeout
120s`` aus dem gemeinsamen Teil und setzte danach ``3600s``. nginx meldete
"directive is duplicate" und liess sich nicht mehr starten -- nicht nur
fuer diese Site, sondern fuer alle.
"""
for block in _location_blocks(webserver.render(config)):
directives = [line.split()[0] for line in block if line.endswith(";")]
repeated = {
name
for name in directives
# Wiederholung ist nur bei den Direktiven zulaessig, die nginx als
# Liste versteht.
if directives.count(name) > 1 and name not in {"proxy_set_header", "add_header"}
}
assert not repeated, f"Mehrfach im selben Block: {sorted(repeated)}"
def test_websocket_block_keeps_the_long_timeout():
"""Die lange Wartezeit ist der Zweck des Blocks -- sie darf nicht verlorengehen."""
blocks = {
block[0]: block
for block in _location_blocks(webserver.render(make_config(websocket_path="/ws/")))
}
websocket = next(b for b in blocks.values() if "proxy_set_header Upgrade $http_upgrade;" in b)
assert "proxy_read_timeout 3600s;" in websocket
assert "proxy_read_timeout 120s;" not in websocket
def test_http2_uses_the_old_spelling_by_default():
"""``http2 on;`` kennt erst nginx 1.25.1.
Auf POETEST (nginx 1.24) fuehrte die neue Direktive zu "unknown directive"
-- und damit zu einem Webserver, der sich nicht mehr starten liess. Die
alte Schreibweise laeuft ueberall.
"""
text = webserver.render(
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
)
assert "listen 443 ssl default_server http2;" in text
assert "http2 on;" not in text
def test_http2_directive_on_request():
text = webserver.render(
make_config(
https_enabled=True, cert_path="/c.pem", key_path="/k.pem", http2_directive=True
)
)
assert " http2 on;" in text
assert "listen 443 ssl default_server;" in text
assert "ssl http2;" not in text
def test_stapling_only_with_a_real_certificate():
"""Ein selbstsigniertes Zertifikat hat keinen OCSP-Responder."""
self_signed = webserver.render(
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
)
assert "ssl_stapling" not in self_signed
public = webserver.render(
make_config(
https_enabled=True,
domain="tesm.example.org",
cert_path="/c.pem",
key_path="/k.pem",
)
)
assert "ssl_stapling on;" in public
# --------------------------------------------------------------------------- #
# Domain
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"value", ["tesm.example.org", "a.b.c.de", "TESM.Example.ORG", "tesm.example.org."]
)
def test_valid_domains(value: str):
assert webserver.validate_domain(value) == value.lower().rstrip(".")
@pytest.mark.parametrize(
"value", ["kein-punkt", "-start.de", "ende-.de", "raum .de", "a..b", "http://x.de"]
)
def test_invalid_domains(value: str):
with pytest.raises(ValueError):
webserver.validate_domain(value)
def test_empty_domain_becomes_catch_all():
assert make_config().server_name == "_"
assert make_config(domain="tesm.example.org").server_name == "tesm.example.org"
def test_only_the_nameless_site_claims_default_server():
"""Sonst entscheidet der Dateiname in sites-enabled, wer die Vorgabe wird.
``server_name _;`` ist bei nginx kein Sonderfall -- ohne
``default_server`` gewinnt schlicht die zuerst geladene Datei. Bei zwei
Anwendungen auf einem Host waere das reiner Zufall.
"""
nameless = webserver.render(make_config())
assert "listen 80 default_server;" in nameless
named = webserver.render(make_config(domain="lizenz.example.org"))
assert "default_server" not in named
assert "server_name lizenz.example.org;" in named
# --------------------------------------------------------------------------- #
# Selbstsigniertes Zertifikat
# --------------------------------------------------------------------------- #
class FakeSysOps:
"""Merkt sich den Aufruf, statt den Helfer wirklich zu starten."""
def __init__(self, result: Result) -> None:
self.result = result
self.calls: list[tuple[str, ...]] = []
self.available = True
self.site_key = "tesm-opus"
def issue_self_signed(self, common_name: str, *alt_names: str) -> Result:
self.calls.append((common_name, *alt_names))
return self.result
def test_self_signed_returns_both_paths():
sysops = FakeSysOps(Result(True, "/etc/tesm/certs/tesm/fullchain.pem\n/etc/tesm/certs/tesm/privkey.pem\n"))
result = webserver.create_self_signed(
sysops, common_name="poetest", alt_names=["poetest.local", "192.168.82.202"]
)
assert result["ok"]
assert result["cert_path"].endswith("fullchain.pem")
assert result["key_path"].endswith("privkey.pem")
assert sysops.calls == [("poetest", "poetest.local", "192.168.82.202")]
def test_self_signed_rejects_a_hostile_name():
"""Der Name landet in einem openssl-Argument -- er wird hier geprueft."""
sysops = FakeSysOps(Result(True, "a\nb\n"))
for hostile in ("a b", "a;rm -rf /", "$(whoami)", "../../etc", ""):
result = webserver.create_self_signed(sysops, common_name=hostile)
assert not result["ok"], hostile
assert sysops.calls == []
def test_self_signed_drops_unusable_alternative_names():
sysops = FakeSysOps(Result(True, "a\nb\n"))
webserver.create_self_signed(
sysops, common_name="poetest", alt_names=["poetest", "", "bad name", "ok.local"]
)
assert sysops.calls == [("poetest", "ok.local")]
def test_self_signed_reports_a_failing_helper():
sysops = FakeSysOps(Result(False, "", "openssl fehlt", 2))
result = webserver.create_self_signed(sysops, common_name="poetest")
assert not result["ok"]
assert "openssl fehlt" in result["detail"]
def test_self_signed_rejects_an_unexpected_answer():
sysops = FakeSysOps(Result(True, "nur eine Zeile\n"))
result = webserver.create_self_signed(sysops, common_name="poetest")
assert not result["ok"]
def test_certificate_info_survives_an_unreadable_file(tmp_path, monkeypatch):
"""Ein unlesbares Zertifikat darf die Einstellungsseite nicht abwerfen.
Auf POETEST real passiert: das Zertifikatsverzeichnis war 0750 root:root,
der Dienstbenutzer bekam beim blossen ``stat`` einen PermissionError -- und
die Seite "Webserver und TLS" antwortete mit HTTP 500.
"""
target = tmp_path / "fullchain.pem"
target.write_text("egal", encoding="utf-8")
def deny(*_args, **_kwargs):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr("pathlib.Path.is_file", deny)
info = webserver.certificate_info(str(target))
assert info["present"] is True
assert "nicht lesen" in info["error"]
def test_certificate_info_reports_a_missing_file():
assert webserver.certificate_info("/gibt/es/nicht.pem") == {"present": False}
def test_certificate_info_reports_garbage_without_raising(tmp_path):
target = tmp_path / "fullchain.pem"
target.write_text("kein Zertifikat", encoding="utf-8")
info = webserver.certificate_info(str(target))
assert info["present"] is True
assert info["error"]
def test_local_names_are_usable_in_a_certificate():
names = webserver.local_names()
assert names, "Es sollte mindestens der Hostname gefunden werden."
for name in names:
assert re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name), name
# --------------------------------------------------------------------------- #
# Instanztrennung
# --------------------------------------------------------------------------- #
def test_apply_config_is_one_call_with_rollback():
"""Getrennte Aufrufe liessen eine abgelehnte Konfiguration aktiviert liegen.
``nginx-write`` verlinkt sofort nach ``sites-enabled``. Schlug danach
``nginx -t`` fehl, blieb die kaputte Datei aktiv -- der laufende nginx
merkte nichts, aber der naechste Reload durch irgendwen legte den
Webserver lahm. Die Uebernahme muss deshalb *eine* privilegierte Operation
sein, die sich selbst zuruecknimmt.
"""
calls: list[str] = []
class Recording(SysOps):
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
calls.append(verb)
return Result(True, "uebernommen")
result = webserver.apply_config(Recording(app_key="tesm"), "server {}")
assert result["ok"]
assert calls == ["nginx-apply"]
assert "nginx-write" not in calls
def test_apply_config_reports_a_rejection_without_claiming_success():
class Failing(SysOps):
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
return Result(False, "", "unknown directive \"http2\"", 2)
result = webserver.apply_config(Failing(app_key="tesm"), "server {}")
assert not result["ok"]
assert "alte Stand" in result["message"]
assert "http2" in result["detail"]
def test_sysops_writes_system_files_under_the_site_key():
"""Sonst ueberschreibt eine Testinstanz die Systemdateien der Produktion."""
calls: list[tuple[str, ...]] = []
class Recording(SysOps):
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
calls.append((verb, *args))
return Result(True)
sysops = Recording(app_key="tesm", site_key="tesm-opus")
sysops.write_nginx("x")
sysops.write_netplan("x")
sysops.write_logrotate("x")
sysops.issue_self_signed("poetest")
assert calls == [
("nginx-write", "tesm-opus"),
("netplan-write", "tesm-opus"),
("logrotate-write", "tesm-opus"),
("selfsigned-issue", "tesm-opus", "poetest"),
]
def test_upstream_follows_the_configured_bind_address(monkeypatch):
"""Die nginx-Site muss auf *diese* Instanz zeigen, nicht auf Port 5000.
Mit fest verdrahteter 5000 erzeugte eine Instanz auf Port 5100 eine
Konfiguration, die Anfragen an die *andere* Installation weiterreichte --
auf POETEST reproduziert: HTTPS stand, lieferte aber die alte Anwendung.
"""
from tesm_core.config import load_core_config
monkeypatch.setenv("TESM_BIND", "127.0.0.1:5100")
config = load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_")
assert config.bind == "127.0.0.1:5100"
text = webserver.render(make_config(upstream=config.bind))
assert "proxy_pass http://127.0.0.1:5100;" in text
assert "127.0.0.1:5000" not in text
def test_bind_address_is_validated(monkeypatch):
from tesm_core.config import load_core_config
monkeypatch.setenv("TESM_BIND", "127.0.0.1:5100; rm -rf /")
with pytest.raises(ValueError):
load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_")
def test_site_key_is_validated(monkeypatch):
from tesm_core.config import load_core_config
monkeypatch.setenv("TESM_SITE_KEY", "../../etc/nginx")
with pytest.raises(ValueError):
load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_")
def test_site_key_defaults_to_the_app_key():
assert SysOps(app_key="tesm").site_key == "tesm"
def test_self_signed_verb_is_known():
assert "selfsigned-issue" in HELPER_VERBS
def test_service_state_finds_unit_without_suffix(monkeypatch):
"""``list-unit-files`` braucht ein Muster mit Endung.
Ohne sie fand die Abfrage nichts und der Zustand lautete
"installiert: nein" -- auch bei laufendem Dienst. Auf der DHCP-Seite lud
das zum Klick auf "Kea installieren" ein.
"""
import tesm_core.sysops as sysops_modul
from tesm_core.sysops import Result, SysOps
# Ohne systemctl bricht die Abfrage vorher ab -- auf dem Entwicklungsrechner
# gibt es keines.
monkeypatch.setattr(sysops_modul.shutil, "which", lambda name: "/bin/" + name)
gesehen: list[list[str]] = []
class Attrappe(SysOps):
def __init__(self):
pass
def read_command(self, argv):
gesehen.append(list(argv))
if "list-unit-files" in argv:
treffer = "kea-dhcp4-server.service" in argv
return Result(True, "kea-dhcp4-server.service enabled" if treffer else "", "")
return Result(True, "active", "")
zustand = Attrappe().service_state("kea-dhcp4-server")
assert zustand["installed"] == "ja", gesehen
# Eine bereits vollstaendige Unit bleibt unveraendert.
assert Attrappe().service_state("kea-dhcp4-server.service")["installed"] == "ja"