Erste Produktivversion
This commit is contained in:
139
tools/check-nginx.py
Normal file
139
tools/check-nginx.py
Normal 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))
|
||||
Reference in New Issue
Block a user