140 lines
5.0 KiB
Python
140 lines
5.0 KiB
Python
#!/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))
|