#!/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))