geoblock: match_original_dport (conntrack --ctorigdstport) fuer Dienste hinter DNAT

Bei Portweiterleitung/DNAT schreibt der Kernel den Zielport in PREROUTING um,
BEVOR der Filter laeuft -- eine Regel auf den oeffentlichen Port (z.B. 1194)
matcht dann nie, weil im Filter der interne Port (z.B. 6666/6667) ankommt.
Genau der Fall auf Endian-Appliances (public 1194 -> intern 6666-6667,
443 -> 30443).

Neue Option 'match_original_dport = true': matcht per 'conntrack --ctorigdstport'
den urspruenglichen Zielport (vor DNAT). Dann in 'ports' die OEFFENTLICHEN Ports
eintragen -- immun gegen den internen Remap.

- build_rule_variants: bei orig-dport eine Regel pro (proto, port), da conntrack
  --ctorigdstport einwertig ist (kein multiport). _variant_port_args nutzt dann
  conntrack statt multiport.
- load_config: match_original_dport (bool, default false).
- show_status zeigt den Match-Modus an.
- Doku: geoblock.ini.example + README-Abschnitt "Dienst hinter Portweiterleitung/DNAT".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-07-28 09:32:58 +02:00
co-authored by Claude Opus 4.8
parent af2a75c443
commit 161d90de45
3 changed files with 104 additions and 10 deletions
+59
View File
@@ -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:<oeffentlicher-port>' und 'to:<intern>:<anderer-port>'
```
**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
+10
View File
@@ -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
+35 -10
View File
@@ -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)")