Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

139
tools/check-nginx.py Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Statische Prüfung der nginx-Konfiguration im web-Container.
Findet drei Fehlerklassen, die uns bereits begegnet sind und die nginx
erst beim Start bemerkt - oder gar nicht:
1. Doppelte Direktiven im selben Kontext.
nginx bricht mit "directive is duplicate" ab. Passiert leicht, wenn
eine location proxy_common.conf einbindet und etwas wiederholt, das
dort schon steht.
2. Fehlende Sicherheitskopfzeilen.
nginx vererbt add_header NUR, wenn die untergeordnete Ebene gar kein
add_header setzt. Ein einziges Cache-Control in einer location lässt
alle Kopfzeilen des server-Blocks verschwinden - ohne Fehlermeldung.
3. Ein types-Block im server-Kontext.
Der ersetzt die geerbte MIME-Tabelle vollständig, statt sie zu
ergänzen. CSS und JavaScript kommen dann als
application/octet-stream an und der Browser lehnt ES-Module ab.
Aufruf:
python3 tools/check-nginx.py # prüft web/
python3 tools/check-nginx.py pfad/zu/web
"""
import re
import sys
from pathlib import Path
# add_header darf mehrfach auftreten - jedes weitere hängt an, statt zu
# ersetzen. Alle anderen hier geprüften Direktiven dürfen es nicht.
REPEATABLE = {"add_header", "proxy_set_header", "try_files", "include",
"limit_req", "gzip_types", "error_page", "set"}
def directive_names(text: str) -> list[str]:
out = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
out.append(line.split()[0])
return out
def check(web_dir: Path) -> int:
conf_path = web_dir / "nginx.conf"
if not conf_path.is_file():
print(f"nginx.conf nicht gefunden unter {conf_path}", file=sys.stderr)
return 2
conf = conf_path.read_text(encoding="utf-8")
problems: list[str] = []
includes = {}
for name in ("proxy_common.conf", "security_headers.conf"):
path = web_dir / name
if path.is_file():
includes[name] = set(directive_names(path.read_text(encoding="utf-8")))
else:
problems.append(f"{name} fehlt, wird aber eingebunden")
includes[name] = set()
# ---- 3. types-Block ----
if re.search(r"^\s*types\s*\{", conf, re.M):
problems.append(
"types-Block gefunden: ersetzt im server-Kontext die gesamte "
"MIME-Tabelle. Stattdessen default_type in der betroffenen "
"location setzen."
)
# ---- Klammernbilanz ----
if conf.count("{") != conf.count("}"):
problems.append(
f"Geschweifte Klammern unausgeglichen: "
f"{conf.count('{')} auf, {conf.count('}')} zu"
)
# ---- pro location ----
for match in re.finditer(r"(location[^{]*)\{(.*?\n )\}", conf, re.S):
header = match.group(1).strip()
body = match.group(2)
lines = [
line.strip()
for line in body.splitlines()
if line.strip() and not line.strip().startswith("#")
]
included = {name for name in includes if any(name in line for line in lines)}
own = [line.split()[0] for line in lines if not line.startswith("include")]
for name in sorted(set(own)):
if own.count(name) > 1 and name not in REPEATABLE:
problems.append(f"{header}: {name} steht {own.count(name)}× im Block")
for inc in included:
if name in includes[inc] and name not in REPEATABLE:
problems.append(
f"{header}: {name} steht auch in {inc} nginx bricht "
"mit \"directive is duplicate\" ab"
)
if "add_header" in own and "security_headers.conf" not in included:
problems.append(
f"{header}: setzt add_header, bindet aber security_headers.conf "
"nicht ein die Kopfzeilen des server-Blocks gehen hier verloren"
)
# ---- Ereigniskanal ----
sse = re.search(r"location[^{]*events[^{]*\{(.*?\n )\}", conf, re.S)
if sse:
body = sse.group(1)
if "gzip off" not in body:
problems.append(
"Ereigniskanal: gzip off fehlt Komprimierung sammelt die "
"Ereignisse, statt sie einzeln durchzureichen"
)
common = (web_dir / "proxy_common.conf")
if common.is_file() and "proxy_buffering off" not in common.read_text():
problems.append(
"proxy_common.conf: proxy_buffering off fehlt ohne das "
"puffert nginx die Server-Sent Events"
)
else:
problems.append("Kein location-Block für den Ereigniskanal gefunden")
if problems:
print(f"{len(problems)} Problem(e) in {conf_path}:\n")
for p in problems:
print(f" - {p}")
return 1
print(f"{conf_path}: keine Probleme gefunden")
return 0
if __name__ == "__main__":
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("web")
raise SystemExit(check(target))