diff --git a/README.md b/README.md index 08a2ebc..c674dba 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,60 @@ genauso wie in INPUT, weil DNAT nur das Ziel umschreibt, nicht die Quelle. > Ort. Kombiniere das am besten mit dem Reassert-Watchdog (Abschnitt oben), da > die Appliance auch die FORWARD-Chain bei jedem Reload neu baut. +## Dienst hinter Portweiterleitung/DNAT (`match_original_dport`) + +Noch eine Stolperfalle bei Firewalls/Routern mit Portweiterleitung — und zwar +eine gemeine, weil Chain und Regel-Position stimmen koennen und es **trotzdem** +nicht blockt: **der Zielport wird durch DNAT umgeschrieben, bevor der Filter +laeuft.** + +Reihenfolge im Kernel: + +``` +PREROUTING (nat/DNAT: schreibt Ziel-IP/Port um) -> Routing -> INPUT/FORWARD (filter: unsere DROP-Regel) +``` + +Beispiel Endian-OpenVPN: aussen kommt `udp/1194` an, die Appliance macht per DNAT +`1194 -> 6666-6667` (interner OpenVPN-Daemon). Wenn das Paket die FORWARD-/INPUT- +Chain erreicht, hat es als Zielport laengst **6666/6667**, nicht mehr 1194. Eine +Regel mit `ports = 1194` matcht dann **nie** — obwohl Set, Chain und Position +stimmen. (Gleiches Muster fuer Web: `443 -> 30443` o.ae.) + +Wie man das erkennt — auf dem Firewall-Host die DNAT-Regeln ansehen: + +```bash +sudo iptables -t nat -L PREROUTING -n -v # zeigt meist nur Spruenge in Sub-Chains +sudo iptables -t nat -L PORTFW -n -v # (Endian) Portweiterleitungen +sudo iptables -t nat -L PROXIES -n -v # (Endian) weitere DNAT/Proxy-Regeln +# gesucht: eine DNAT-Zeile mit 'dpt:' und 'to::' +``` + +**Zwei Loesungen:** + +1. **Den umgeschriebenen Port matchen** — `ports` auf den *internen* Port setzen + (im Beispiel `6666:6667`). Funktioniert, ist aber fragil: aendert die Appliance + den internen Port (Update/Umbau), musst du es nachziehen. +2. **Empfohlen: `match_original_dport = true`** — matcht per `conntrack + --ctorigdstport` den Port, den der **Client** gewaehlt hat (vor DNAT). Dann + traegst du in `ports` die **oeffentlichen** Ports ein und bist immun gegen den + internen Remap: + + ```ini + [geoblock] + chain = INPUT, FORWARD + ports = 1194, 443 + protocol = tcp, udp + match_original_dport = true + ``` + +Der `src`-Match (echte Client-IP) funktioniert in beiden Faellen, weil DNAT nur +das Ziel umschreibt, nicht die Quelle — vorausgesetzt es ist echtes DNAT und kein +terminierender L7-Proxy (der wuerde die Verbindung neu aufbauen, dann sieht der +Filter die Proxy-IP statt der des Clients; siehe Reverse-Proxy-Abschnitt). + +`--status` zeigt den aktiven Match-Modus an (`Zielport (dports)` vs. +`Original-Zielport vor DNAT (conntrack)`). + ## Verbindungstest (Test-Webserver) `geoblock_testserver.py` ist ein winziger HTTP-Server ohne @@ -482,6 +536,11 @@ Siehe Kommentare in der Datei selbst — kurz: einzelner Ports" unten. - `protocol`: nur relevant wenn `ports` gesetzt ist — `tcp` (Default), `udp` oder `tcp,udp` +- `match_original_dport`: `true`/`false` (Default `false`), nur relevant wenn + `ports` gesetzt ist. Fuer Dienste hinter einer Portweiterleitung/DNAT: matcht + den **urspruenglichen** Zielport (vor DNAT) statt des umgeschriebenen. Dann in + `ports` die oeffentlichen Ports eintragen. Siehe Abschnitt "INPUT vs FORWARD" + bzw. "Dienst hinter Portweiterleitung/DNAT" unten. - `proxy`: optional, HTTP(S)-Proxy fuer den Abruf der Zonefiles von ipdeny.com (z.B. `http://10.0.0.5:3128`, mit Auth `http://user:pass@host:port`). Nur noetig, wenn der Host selbst keine direkte Internetverbindung hat, aber diff --git a/geoblock.ini.example b/geoblock.ini.example index 663a95d..f7cd61a 100644 --- a/geoblock.ini.example +++ b/geoblock.ini.example @@ -46,6 +46,16 @@ ; oder "tcp,udp" fuer beides. Fuer die meisten Web-/App-Ports ; reicht "tcp". ; +; match_original_dport: +; optional, true/false (Default false). Nur relevant wenn "ports" +; gesetzt ist. Fuer Dienste hinter einer Portweiterleitung/DNAT +; (z.B. Endian: public 1194/udp -> intern 6666): iptables sieht im +; Filter sonst den UMGESCHRIEBENEN Port, nicht den, den der Client +; gewaehlt hat -- die Regel greift dann nicht. Mit true wird per +; conntrack der ORIGINAL-Zielport (vor DNAT) gematcht. Dann in "ports" +; die OEFFENTLICHEN Ports eintragen (z.B. 1194, 443), nicht die +; internen -- immun gegen den Remap. Meist zusammen mit chain=FORWARD. +; ; proxy: optional, HTTP(S)-Proxy-URL, ueber den das Script ipdeny.com ; erreicht -- fuer Hosts, die zwar aus dem Internet erreichbar ; sind (z.B. hinter Reverse-Proxy/Firewall), selbst aber KEINE diff --git a/geoblock.py b/geoblock.py index 0fef7d7..f3725ad 100755 --- a/geoblock.py +++ b/geoblock.py @@ -178,6 +178,12 @@ def load_config(path): chains_raw = sec.get("chain", "INPUT").strip() chains = [c.strip() for c in chains_raw.split(",") if c.strip()] or ["INPUT"] + # match_original_dport: bei Diensten hinter einer Portweiterleitung/DNAT matcht + # 'ports' sonst den UMGESCHRIEBENEN Port (nach DNAT), nicht den, den der Client + # gewaehlt hat. Mit true wird per conntrack der ORIGINAL-Zielport gematcht -> in + # 'ports' die oeffentlichen Ports (z.B. 1194, 443) eintragen, immun gegen Remap. + match_orig_dport = sec.getboolean("match_original_dport", fallback=False) + return { "countries": countries, "chains": chains, @@ -188,6 +194,7 @@ def load_config(path): "log_file": sec.get("log_file", "").strip(), "ports": ports, "protocols": protocols, + "match_orig_dport": match_orig_dport, "proxy": sec.get("proxy", "").strip(), } @@ -206,14 +213,25 @@ def build_rule_variants(cfg): """Baut die Liste der (proto, dports)-Kombinationen fuer iptables-Regeln. Ohne 'ports' in der .ini: eine Variante ohne Proto-/Port-Einschraenkung - (= klassisches Geoblocking, alle Ports/Protokolle). Mit 'ports': eine - Variante pro konfiguriertem Protokoll (multiport braucht -p tcp ODER -p udp, - nicht beides gleichzeitig). + (= klassisches Geoblocking, alle Ports/Protokolle). + + Mit 'ports' und match_original_dport=False (Standard): eine Variante pro + Protokoll, jeweils mit allen Ports als multiport --dports. + + Mit 'ports' und match_original_dport=True: gematcht wird der URSPRUENGLICHE + Zielport (vor DNAT) per 'conntrack --ctorigdstport'. Das ist einwertig (kein + multiport), daher eine Variante pro (Protokoll, Port). Fuer Dienste hinter + einer Portweiterleitung/DNAT (z.B. Endian: public 1194/udp -> intern 6666), + wo im Filter der umgeschriebene Port ankommt -- so bleibt die Config auf dem + oeffentlichen Port und ist immun gegen den internen Remap. """ if not cfg["ports"]: - return [{"proto": None, "dports": None}] + return [{"proto": None, "dports": None, "orig": False}] + if cfg.get("match_orig_dport"): + return [{"proto": proto, "dports": port, "orig": True} + for proto in cfg["protocols"] for port in cfg["ports"]] dports = ",".join(cfg["ports"]) - return [{"proto": proto, "dports": dports} for proto in cfg["protocols"]] + return [{"proto": proto, "dports": dports, "orig": False} for proto in cfg["protocols"]] def _variant_match_args(variant): @@ -224,15 +242,19 @@ def _variant_match_args(variant): def _variant_port_args(variant): - if variant["dports"]: - return ["-m", "multiport", "--dports", variant["dports"]] - return [] + if not variant["dports"]: + return [] + if variant.get("orig"): + # conntrack matcht den ORIGINAL-Zielport (vor DNAT); einwertig, kein multiport. + return ["-m", "conntrack", "--ctorigdstport", variant["dports"]] + return ["-m", "multiport", "--dports", variant["dports"]] def _variant_label(variant): if not variant["dports"]: return "alle Ports" - return f"{variant['proto']}/{variant['dports']}" + base = f"{variant['proto']}/{variant['dports']}" + return base + " [orig-dport]" if variant.get("orig") else base def _build_url_opener(proxy): @@ -524,7 +546,10 @@ def show_status(cfg): print(f"Konfigurierte Laender: {', '.join(c.upper() for c in cfg['countries'])}") if cfg["ports"]: - print(f"Port-Einschraenkung: {', '.join(cfg['ports'])} ({'/'.join(cfg['protocols'])})") + modus = "Original-Zielport vor DNAT (conntrack)" if cfg.get("match_orig_dport") \ + else "Zielport (dports)" + print(f"Port-Einschraenkung: {', '.join(cfg['ports'])} ({'/'.join(cfg['protocols'])}) " + f"-- gematcht: {modus}") else: print("Port-Einschraenkung: keine (alle Ports/Protokolle werden geblockt)")