Compare commits

...
4 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.8 830e9a61f6 geoblock: eigene Block-IPs/CIDRs zusaetzlich zu den Laendern (block_ips)
Neue Option 'block_ips': komma-getrennte Liste eigener IPv4-IPs/CIDRs, die
zusaetzlich zu den ipdeny-Laender-Ranges ins selbe ipset kommen (z.B. bekannte
Angreifer, einzelne Hosts/Netze). Werden von denselben iptables-Regeln/Ports
erfasst.

- load_config validiert + normalisiert jede Angabe (ip_network strict=False,
  Host -> /32); IPv6 wird mit klarer Meldung abgelehnt (ipset ist IPv4).
- apply_geoblock nimmt sie nach den Laender-CIDRs mit ins Set auf; Fuell-Log und
  --status weisen die Anzahl/Liste aus.
- Doku: geoblock.ini.example (Kommentar + block_ips-Zeile) + README-Optionsliste.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 10:37:40 +02:00
duffyduckandClaude Opus 4.8 161d90de45 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>
2026-07-28 09:32:58 +02:00
duffyduckandClaude Opus 4.8 af2a75c443 geoblock: purge scannt zusaetzlich INPUT/FORWARD/OUTPUT (Chain-Wechsel raeumt Reste auf)
purge_geoblock_rules() scannt jetzt die konfigurierten Chains PLUS die Standard-
Filter-Chains INPUT/FORWARD/OUTPUT. Damit werden verwaiste geoblock-Regeln auch
dann entfernt, wenn 'chain' zwischen zwei Laeufen geaendert wurde (z.B. INPUT ->
FORWARD) -- vorher blieben die alten Regeln der nicht mehr konfigurierten Chain
stehen. Es werden nur Regeln geloescht, die auf das eigene ipset verweisen.

README: Abschnitt "Manuelle Nutzung" um die Aufraeum-Semantik ergaenzt (jeder
--apply/Service-Restart entfernt alle geoblock-Regeln und setzt sie frisch,
inkl. Reste aus geaenderter ports/protocol/chain-Config).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:05:15 +02:00
duffyduckandClaude Opus 4.8 da83f351d7 geoblock: mehrere iptables-Chains unterstuetzen (chain = INPUT, FORWARD)
'chain' akzeptiert jetzt eine komma-getrennte Liste. Noetig, wenn der geschuetzte
Dienst hinter einer Firewall/einem Router liegt (Portweiterleitung/DNAT): solcher
Traffic laeuft durch die FORWARD-Chain und wird von einer reinen INPUT-Regel nie
gesehen. "chain = INPUT, FORWARD" deckt beide Wege ab.

- load_config: cfg["chain"] -> cfg["chains"] (Liste), Default ["INPUT"].
- apply/purge/set_iptables_rules/reassert/status/Watchdog arbeiten ueber alle
  konfigurierten Chains. geoblock_rules_on_top aggregiert (on_top nur, wenn in
  JEDER Chain oben; present, wenn in mindestens einer vorhanden) -- neuer Helper
  _chain_rules_position() prueft je Chain. --status zeigt Position pro Chain.
- Doku: geoblock.ini.example + README-Abschnitt "INPUT vs FORWARD" (inkl.
  DNAT-Diagnose und Endian-Hinweis).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:02:30 +02:00
3 changed files with 294 additions and 66 deletions
+123 -1
View File
@@ -158,6 +158,18 @@ unten.
das ipset atomar per `swap`, damit waehrend der Aktualisierung kein Loch
im Blocking entsteht.
**Aufraeumen bei jedem Lauf:** `--apply` (und damit auch jeder Neustart von
`geoblock.service`) entfernt zuerst **alle** bestehenden geoblock-Regeln und
setzt sie danach frisch. Gescannt werden dabei die konfigurierten Chains **plus**
die Standard-Filter-Chains INPUT/FORWARD/OUTPUT — es werden ausschliesslich
Regeln geloescht, die auf das eigene `ipset` verweisen (fremde Regeln bleiben
unberuehrt). Dadurch verschwinden automatisch auch Reste aus frueheren Laeufen
mit **anderer Config**: geaenderte `ports`/`protocol` ebenso wie eine geaenderte
`chain`. Konkret heisst das: Stellst du z.B. von `chain = INPUT` auf
`chain = FORWARD` um und startest den Dienst neu, werden die alten INPUT-Regeln
automatisch entfernt — du musst nicht von Hand aufraeumen. (Gleiches gilt fuer
`--remove`, das ebenfalls diesen Purge nutzt.)
**Achtung:** iptables-Regeln sind nicht reboot-persistent. Wer das
zusaetzlich braucht: `iptables-persistent` / `netfilter-persistent save`
nutzen — der systemd-Timer sorgt aber ohnehin bei jedem Boot fuer einen
@@ -297,6 +309,103 @@ die Regel-Reihenfolge auch ohne den Watchdog stabil. Ohne bestehendes ipset
(z.B. bevor je ein `--apply` lief) macht `--reassert` bewusst nichts; das
Befuellen uebernimmt der taegliche `geoblock.timer`.
## INPUT vs FORWARD (Dienst hinter Firewall/Router)
`chain` entscheidet, welchen Traffic Geoblocking ueberhaupt zu sehen bekommt:
- **`INPUT`** — Pakete, die **an diesen Host selbst** gerichtet sind. Richtig,
wenn der geschuetzte Dienst (Webserver, VPN, …) **auf demselben Host** laeuft,
auf dem auch `geoblock.py` die Regeln setzt.
- **`FORWARD`** — Pakete, die dieser Host **weiterleitet/routet**, statt sie
selbst anzunehmen. Noetig, wenn `geoblock.py` auf einer **Firewall/einem
Router** laeuft und der eigentliche Dienst auf einem **dahinterliegenden Host**
sitzt (klassisch: Portweiterleitung/DNAT von der Firewall auf einen internen
Server). Solche Pakete laufen durch die FORWARD-Chain und werden von einer
INPUT-Regel **nie** gesehen.
**Typisches Symptom fuer den FORWARD-Fall:** Set stimmt (`ipset test` = is in),
die DROP-Regel steht in INPUT ganz oben — und trotzdem kommt die Verbindung
durch, weil das Paket gar nicht ueber INPUT laeuft. `tcpdump` zeigt dann als
Ziel eine **interne/private IP** (der Backend-Host), nicht die des Firewall-Hosts
selbst. Gegencheck auf dem Firewall-Host:
```bash
sudo iptables -t nat -L PREROUTING -n -v | grep <port> # DNAT-Portweiterleitung vorhanden?
sudo iptables -L FORWARD -n -v --line-numbers # laeuft der Traffic hier durch?
```
Gibt es eine DNAT-Weiterleitung, muss die Chain auf `FORWARD` (oder zur
Sicherheit beides):
```ini
[geoblock]
chain = INPUT, FORWARD
```
Bei mehreren Chains legt `--apply` die Regeln in **jeder** an, `--remove` raeumt
in jeder auf, und `--reassert`/`--status`/der Watchdog pruefen/korrigieren jede
Chain einzeln. Der `src`-Match (Quell-IP = echter Client) funktioniert in FORWARD
genauso wie in INPUT, weil DNAT nur das Ziel umschreibt, nicht die Quelle.
> **Endian & Co.:** Laeuft der Dienst (z.B. der OpenVPN-Server) hinter der
> Appliance und wird per Portweiterleitung erreicht, ist FORWARD der richtige
> 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
@@ -412,7 +521,10 @@ inkl. Perfdata (Alter in Sekunden, Anzahl geblockter IP-Bereiche).
Siehe Kommentare in der Datei selbst — kurz:
- `countries`: Komma-Liste ISO-3166-1-alpha-2 Codes (z.B. `RU, CN, KP, IR`)
- `chain`: iptables-Chain (`INPUT` = eingehender Traffic zu diesem Host)
- `chain`: iptables-Chain(s), eine oder komma-getrennt mehrere. `INPUT` =
Traffic zu diesem Host (Standard); `FORWARD` = durch diesen Host **gerouteter**
Traffic (Dienst hinter Firewall/Router, per Portweiterleitung/DNAT). Beides
zugleich: `chain = INPUT, FORWARD`. Siehe Abschnitt "INPUT vs FORWARD" unten.
- `interface`: optional, nur ein Interface pruefen
- `log`: iptables-LOG-Eintrag vor dem DROP (dmesg/kern.log) an/aus
- `ipset_name`: Name des ipset-Sets
@@ -424,6 +536,16 @@ 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.
- `block_ips`: optional, komma-getrennte Liste **eigener** IPs/CIDRs (IPv4), die
**zusaetzlich** zu den Laender-Ranges geblockt werden (z.B. bekannte Angreifer,
einzelne Hosts/Netze). Einzel-IP (`203.0.113.7`) oder CIDR (`203.0.113.0/24`).
Kommt ins selbe `ipset` wie die Laender, wird also von denselben Regeln/Ports
erfasst. Leer = keine.
- `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
+26 -2
View File
@@ -4,8 +4,13 @@
; grossgeschrieben, egal), die geblockt werden sollen.
; Liste der Codes: https://www.ipdeny.com/ipblocks/
;
; chain: iptables-Chain, in die die DROP-Regel eingehaengt wird.
; INPUT = Verbindungen ZU diesem Host (Standard-Anwendungsfall).
; chain: iptables-Chain(s), in die die DROP-Regel eingehaengt wird. Eine
; oder komma-getrennt mehrere.
; INPUT = Verbindungen ZU diesem Host (Standard-Anwendungsfall).
; FORWARD = durch diesen Host GEROUTETER Traffic -- noetig, wenn der
; zu schuetzende Dienst hinter einer Firewall/einem Router
; liegt (z.B. Portweiterleitung/DNAT auf einen internen
; Host). Beides zugleich: "chain = INPUT, FORWARD".
;
; interface: optional, z.B. eth0 -- nur Traffic auf diesem Interface pruefen.
; Leer lassen = alle Interfaces.
@@ -41,6 +46,22 @@
; 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.
;
; block_ips: optional, komma-getrennte Liste EIGENER IPs/CIDRs, die zusaetzlich
; zu den Laender-Ranges geblockt werden (z.B. bekannte Angreifer,
; einzelne Hosts/Netze). Einzel-IP ("203.0.113.7") oder CIDR
; ("203.0.113.0/24"). Nur IPv4. Kommt ins selbe ipset wie die Laender,
; wird also von denselben Regeln/Ports erfasst. Leer lassen = keine.
;
; 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
@@ -62,6 +83,9 @@ log_file = /var/log/geoblock/geoblock.log
; z.B. "8899" setzen (nur der Testserver wird geblockt, SSH bleibt offen).
ports =
protocol = tcp
; Eigene Block-IPs/CIDRs zusaetzlich zu den Laendern (komma-getrennt, IPv4).
; Leer lassen = keine. Beispiel: block_ips = 203.0.113.7, 198.51.100.0/24
block_ips =
; Leer lassen, ausser der Host hat keine direkte Internetverbindung.
proxy =
+145 -63
View File
@@ -78,6 +78,13 @@ import urllib.error
ZONE_URL_TMPL = "https://www.ipdeny.com/ipblocks/data/countries/{cc}.zone"
# Standard-Filter-Chains, die beim Aufraeumen IMMER mitgescannt werden -- auch
# wenn sie gerade nicht in 'chain' konfiguriert sind. So werden verwaiste
# geoblock-Regeln entfernt, wenn 'chain' zwischen zwei Laeufen geaendert wurde
# (z.B. von INPUT auf FORWARD umgestellt). Rein additiv zum Scan: es werden nur
# Regeln geloescht, die auf UNSER ipset verweisen -- fremde Regeln bleiben unberuehrt.
STANDARD_FILTER_CHAINS = ("INPUT", "FORWARD", "OUTPUT")
# Wird in main() aus der .ini gesetzt (log_file). None = keine Datei-Logs.
LOG_FILE = None
@@ -164,9 +171,35 @@ def load_config(path):
if pr not in ("tcp", "udp"):
raise ValueError(f"Ungueltiges Protokoll '{pr}' in 'protocol' (erlaubt: tcp, udp)")
# chain: eine oder (komma-getrennt) mehrere iptables-Chains. INPUT = Traffic ZU
# diesem Host, FORWARD = durch diesen Host GEROUTETER Traffic (z.B. Portweiter-
# leitung/DNAT auf einen internen Host hinter einer Firewall/Router). "INPUT, FORWARD"
# deckt beides ab. Default: INPUT.
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)
# block_ips: optionale, komma-getrennte Liste EIGENER IPs/CIDRs, die zusaetzlich
# zu den Laender-Ranges ins selbe ipset kommen (z.B. bekannte Angreifer, einzelne
# Hosts/Netze). Nur IPv4 (das ipset ist IPv4). Wird hier validiert + normalisiert.
block_ips = []
for entry in (e.strip() for e in sec.get("block_ips", "").split(",") if e.strip()):
try:
net = ipaddress.ip_network(entry, strict=False) # akzeptiert Host (->/32) und CIDR
except ValueError:
raise ValueError(f"Ungueltige IP/CIDR in 'block_ips': '{entry}'")
if net.version != 4:
raise ValueError(f"'block_ips': nur IPv4 unterstuetzt (ipset ist IPv4), ungueltig: '{entry}'")
block_ips.append(str(net))
return {
"countries": countries,
"chain": sec.get("chain", "INPUT").strip(),
"chains": chains,
"interface": sec.get("interface", "").strip(),
"log": sec.getboolean("log", fallback=False),
"log_prefix": sec.get("log_prefix", "GEOBLOCK-DROP:").strip(),
@@ -174,6 +207,8 @@ def load_config(path):
"log_file": sec.get("log_file", "").strip(),
"ports": ports,
"protocols": protocols,
"match_orig_dport": match_orig_dport,
"block_ips": block_ips,
"proxy": sec.get("proxy", "").strip(),
}
@@ -192,14 +227,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):
@@ -210,15 +256,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):
@@ -301,31 +351,40 @@ def _list_chain_rules(chain):
def purge_geoblock_rules(cfg, dry_run=False):
"""Loescht ALLE Regeln in der Chain, die auf das geoblock-ipset verweisen
"""Loescht in allen konfigurierten Chains PLUS den Standard-Filter-Chains
(INPUT/FORWARD/OUTPUT) alle Regeln, die auf das geoblock-ipset verweisen
(--match-set <name> src) -- unabhaengig von Proto/Port/Variante.
Dadurch werden auch alte Regeln aus frueheren --apply-Laeufen mit anderer
ini (z.B. andere Ports/Protokolle) sauber entfernt, die build_rule_variants(cfg)
fuer die AKTUELLE Config gar nicht mehr kennt. Wir rekonstruieren das Loesch-
Kommando direkt aus 'iptables -S' (per shlex, damit z.B. das --log-prefix mit
Leerzeichen erhalten bleibt), statt die Args selbst nachzubauen -- so passt das
-D immer exakt zur real existierenden Regel.
ini sauber entfernt: andere Ports/Protokolle (kennt build_rule_variants(cfg)
nicht mehr) UND andere Chains (z.B. nach Umstellung von INPUT auf FORWARD --
die verwaisten INPUT-Regeln werden dank Standard-Chain-Scan trotzdem gefunden).
Wir rekonstruieren das Loesch-Kommando direkt aus 'iptables -S' (per shlex,
damit z.B. das --log-prefix mit Leerzeichen erhalten bleibt), statt die Args
selbst nachzubauen -- so passt das -D immer exakt zur real existierenden Regel.
"""
name = cfg["ipset_name"]
chain = cfg["chain"]
needle = f"--match-set {name} src"
# Konfigurierte Chains PLUS die Standard-Filter-Chains scannen (dedupliziert,
# Reihenfolge erhalten). Faengt auch verwaiste Regeln ab, wenn 'chain' seit dem
# letzten Lauf geaendert wurde (z.B. INPUT -> FORWARD): die alten INPUT-Regeln
# werden trotzdem gefunden und entfernt.
scan_chains = list(dict.fromkeys(list(cfg["chains"]) + list(STANDARD_FILTER_CHAINS)))
removed = 0
for line in _list_chain_rules(chain):
if needle not in line:
continue
tokens = shlex.split(line) # z.B. ["-A", "INPUT", "-m", "set", ...]
# "-A <chain> <rest>" -> "iptables -D <chain> <rest>"
run(["iptables", "-D"] + tokens[1:], dry_run=dry_run, check=False)
removed += 1
if removed:
log(f"{removed} bestehende geoblock-Regel(n) aus Chain '{chain}' entfernt (Set '{name}')")
else:
log(f"Keine bestehenden geoblock-Regeln in Chain '{chain}' gefunden")
for chain in scan_chains:
n = 0
for line in _list_chain_rules(chain):
if needle not in line:
continue
tokens = shlex.split(line) # z.B. ["-A", "INPUT", "-m", "set", ...]
# "-A <chain> <rest>" -> "iptables -D <chain> <rest>"
run(["iptables", "-D"] + tokens[1:], dry_run=dry_run, check=False)
n += 1
if n:
log(f"{n} bestehende geoblock-Regel(n) aus Chain '{chain}' entfernt (Set '{name}')")
removed += n
if not removed:
log(f"Keine bestehenden geoblock-Regeln gefunden (gescannt: {', '.join(scan_chains)})")
return removed
@@ -344,40 +403,50 @@ def set_iptables_rules(cfg, dry_run=False):
name = cfg["ipset_name"]
set_match = ["-m", "set", "--match-set", name, "src"]
iface_opt = ["-i", cfg["interface"]] if cfg["interface"] else []
for variant in build_rule_variants(cfg):
proto_args = _variant_match_args(variant)
port_args = _variant_port_args(variant)
label = _variant_label(variant)
for chain in cfg["chains"]:
for variant in build_rule_variants(cfg):
proto_args = _variant_match_args(variant)
port_args = _variant_port_args(variant)
label = _variant_label(variant)
run(["iptables", "-I", cfg["chain"]] + iface_opt + proto_args + set_match +
port_args + ["-j", "DROP"], dry_run=dry_run)
log(f"DROP-Regel am Anfang von Chain '{cfg['chain']}' aktiv fuer Set '{name}' ({label})")
run(["iptables", "-I", chain] + iface_opt + proto_args + set_match +
port_args + ["-j", "DROP"], dry_run=dry_run)
log(f"DROP-Regel am Anfang von Chain '{chain}' aktiv fuer Set '{name}' ({label})")
if cfg["log"]:
run(["iptables", "-I", cfg["chain"]] + iface_opt + proto_args + set_match +
port_args + ["-j", "LOG", "--log-prefix", cfg["log_prefix"] + " "], dry_run=dry_run)
log(f"LOG-Regel am Anfang von Chain '{cfg['chain']}' aktiv ({label})")
if cfg["log"]:
run(["iptables", "-I", chain] + iface_opt + proto_args + set_match +
port_args + ["-j", "LOG", "--log-prefix", cfg["log_prefix"] + " "], dry_run=dry_run)
log(f"LOG-Regel am Anfang von Chain '{chain}' aktiv ({label})")
def geoblock_rules_on_top(cfg):
"""Prueft, ob die geoblock-Regeln (noch) ganz am Anfang der Chain stehen.
Gibt (on_top, present) zurueck:
def _chain_rules_position(chain, ipset_name):
"""(on_top, present) fuer eine EINZELNE Chain.
present : ob ueberhaupt geoblock-Regeln in der Chain vorhanden sind
on_top : ob sie luueckenlos die ERSTEN Regeln der Chain sind (Index 0..k-1)
Ist present=True aber on_top=False, sind unsere Regeln nach unten gerutscht
(z.B. weil eine gemanagte Firewall die Chain neu gebaut und eigene Regeln
davor eingehaengt hat) -- dann sollten sie neu an den Anfang gesetzt werden.
"""
needle = f"--match-set {cfg['ipset_name']} src"
rules = _list_chain_rules(cfg["chain"]) # geordnete Liste der '-A ...'-Regeln
needle = f"--match-set {ipset_name} src"
rules = _list_chain_rules(chain) # geordnete Liste der '-A ...'-Regeln
idx = [i for i, ln in enumerate(rules) if needle in ln]
present = bool(idx)
on_top = present and idx == list(range(len(idx)))
return on_top, present
def geoblock_rules_on_top(cfg):
"""Aggregiert die Regel-Position ueber ALLE konfigurierten Chains.
Gibt (on_top, present) zurueck:
on_top : True nur, wenn in JEDER konfigurierten Chain die Regeln vorhanden
UND ganz oben sind (Index 0..k-1). Sonst False -> --reassert sollte
korrigieren (fehlende neu setzen, verrutschte wieder nach oben).
present : True, wenn mindestens eine Chain geoblock-Regeln hat.
"""
results = [_chain_rules_position(ch, cfg["ipset_name"]) for ch in cfg["chains"]]
on_top = all(on for on, _ in results)
present = any(pr for _, pr in results)
return on_top, present
def reassert_rules(cfg, dry_run=False):
"""Watchdog: stellt sicher, dass die geoblock-Regeln am ANFANG der Chain stehen
-- OHNE die Zonefiles neu von ipdeny zu laden (nutzt das bestehende ipset).
@@ -425,6 +494,12 @@ def apply_geoblock(cfg, dry_run=False):
if not all_cidrs and not dry_run:
raise RuntimeError("Keine IP-Bereiche geladen — Abbruch, um kein leeres Set zu aktivieren")
# 2b) eigene Block-IPs/CIDRs aus der .ini zusaetzlich aufnehmen (bereits in
# load_config validiert/normalisiert). Kommen ins selbe Set wie die Laender.
n_block_ips = len(cfg.get("block_ips", []))
if n_block_ips:
all_cidrs.extend(cfg["block_ips"])
# 3) Set atomar neu befuellen: temp-Set bauen, dann swap (kein Traffic-Loch)
tmp_name = f"{name}_tmp"
run(["ipset", "create", tmp_name, "hash:net", "-exist"], dry_run=dry_run)
@@ -435,8 +510,9 @@ def apply_geoblock(cfg, dry_run=False):
run(["ipset", "swap", tmp_name, name], dry_run=dry_run)
run(["ipset", "destroy", tmp_name], dry_run=dry_run, check=False)
log(f"ipset '{name}' befuellt mit {len(all_cidrs)} Bereichen "
f"aus Laendern: {', '.join(c.upper() for c in cfg['countries'])}")
extra = f" + {n_block_ips} eigene(n) Block-IP(s)/CIDR(s)" if n_block_ips else ""
log(f"ipset '{name}' befuellt mit {len(all_cidrs)} Eintraegen "
f"(Laender: {', '.join(c.upper() for c in cfg['countries'])}{extra})")
# 4) Vor dem Neu-Setzen ALLE bestehenden geoblock-Regeln der Chain entfernen
# (auch verwaiste aus frueheren Laeufen mit anderer ini, z.B. andere Ports).
@@ -475,22 +551,28 @@ def show_status(cfg):
print(f"ipset '{name}' existiert nicht (noch nicht aktiv).")
variants = build_rule_variants(cfg)
for variant in variants:
active = iptables_rule_exists(cfg["chain"], name, cfg["interface"], variant=variant)
print(f"iptables DROP-Regel in Chain '{cfg['chain']}' ({_variant_label(variant)}) aktiv: {active}")
for chain in cfg["chains"]:
for variant in variants:
active = iptables_rule_exists(chain, name, cfg["interface"], variant=variant)
print(f"iptables DROP-Regel in Chain '{chain}' ({_variant_label(variant)}) aktiv: {active}")
on_top, present = geoblock_rules_on_top(cfg)
if not present:
print(f"Regel-Position in Chain '{cfg['chain']}': keine geoblock-Regeln vorhanden")
elif on_top:
print(f"Regel-Position in Chain '{cfg['chain']}': am Anfang (ok)")
else:
print(f"Regel-Position in Chain '{cfg['chain']}': NACH UNTEN GERUTSCHT "
f"-- '--reassert' oder '--apply' setzt sie wieder an den Anfang")
on_top, present = _chain_rules_position(chain, name)
if not present:
print(f"Regel-Position in Chain '{chain}': keine geoblock-Regeln vorhanden")
elif on_top:
print(f"Regel-Position in Chain '{chain}': am Anfang (ok)")
else:
print(f"Regel-Position in Chain '{chain}': NACH UNTEN GERUTSCHT "
f"-- '--reassert' oder '--apply' setzt sie wieder an den Anfang")
print(f"Konfigurierte Laender: {', '.join(c.upper() for c in cfg['countries'])}")
if cfg.get("block_ips"):
print(f"Eigene Block-IPs/CIDRs ({len(cfg['block_ips'])}): {', '.join(cfg['block_ips'])}")
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)")