Compare commits

...
9 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
duffyduckandClaude Opus 4.8 867a8a1c6f README: Installations-Abschnitt um Watchdog ergaenzen, Default-Verhalten klarstellen
- --with-watchdog analog zu --with-testserver im Installations-Abschnitt
  dokumentiert (inkl. kombinierter Aufruf).
- Explizit klargestellt: Installer OHNE Optionen installiert weder Testserver
  noch Watchdog, nur das eigentliche Geoblocking (geoblock.py + geoblock.timer).
- Endian in die Liste der gemanagten Firewalls aufgenommen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:39:08 +02:00
duffyduckandClaude Opus 4.8 b170759657 geoblock: Regeln an den Chain-Anfang setzen + Reassert-Watchdog fuer gemanagte Firewalls
Problem: auf Hosts mit gemanagter Firewall (z.B. Endian-UTM) stand die DROP-Regel
am Ende der INPUT-Chain (-A) und wurde von einer ACCEPT-Regel davor abgefangen ->
Geoblocking griff nicht, obwohl Set/Config korrekt waren.

geoblock.py:
- DROP-Regel jetzt wie die LOG-Regel per 'iptables -I' an den ANFANG der Chain
  (Reihenfolge LOG -> DROP). Rule-Setzen in set_iptables_rules() refaktoriert.
- Neue Aktion --reassert: leichtgewichtiger Watchdog, der die Regel-Position
  prueft und die Regeln nur bei Bedarf wieder nach oben setzt -- OHNE Zonefiles
  von ipdeny nachzuladen (nutzt bestehendes ipset). Kein Eingriff, wenn kein
  ipset existiert (dann erst voller --apply noetig).
- geoblock_rules_on_top(): erkennt, ob die Regeln luueckenlos die ersten der
  Chain sind. --status zeigt die Position jetzt mit an.

systemd:
- geoblock-reassert.service + .timer (alle 2 Min, opt-in via install_geoblock.sh
  --with-watchdog). uninstall raeumt sie mit ab und stoppt sie vor --remove.

README: Abschnitt "Regeln bleiben nicht oben (gemanagte Firewall)" + tcpdump-
Diagnose (anonymisiert) + Fehlersuche-Abschnitt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:35:38 +02:00
Stefan HackerandClaude Opus 4.8 4fce0cd7f0 geoblock.py: vor Apply/Remove alle bestehenden geoblock-Regeln der Chain purgen
Neue Funktion purge_geoblock_rules() entfernt per 'iptables -S <chain>' ALLE
Regeln mit '--match-set <name> src' (LOG wie DROP), unabhaengig von
Proto/Port/Variante. Das Loeschkommando wird per shlex direkt aus der
-S-Ausgabe rekonstruiert, damit --log-prefix inkl. Leerzeichen exakt passt.

- --apply purged jetzt vor dem Neu-Setzen der Regeln, aber erst NACH dem
  atomaren Befuellen des ipsets -> kein Traffic-Loch waehrend des
  ipdeny-Downloads.
- --remove nutzt denselben Purge statt der bisherigen varianten-basierten
  Loeschung -> keine verwaisten Regeln mehr, wenn sich ports/protocol
  zwischen zwei Laeufen geaendert haben.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:20:50 +02:00
ARIA b5ff887cea Testscript check_openvpn.py: prueft OpenVPN-Server-Erreichbarkeit (UDP-Handshake-Probe / TCP-Connect) 2026-07-21 12:36:27 +00:00
ARIA 1ed113a866 README: Abschnitt zu ALB vs. NLB (AWS Load Balancer) ergaenzen 2026-07-21 12:34:19 +00:00
8 changed files with 904 additions and 74 deletions
+371 -1
View File
@@ -18,11 +18,15 @@ und droppt Pakete bereits auf Netzwerk-Ebene.
| `geoblock.ini` | Eure eigene, lokale Konfiguration (Laenderliste, Chain, Logging, ...) — **nicht** im Repo/Git, siehe unten | | `geoblock.ini` | Eure eigene, lokale Konfiguration (Laenderliste, Chain, Logging, ...) — **nicht** im Repo/Git, siehe unten |
| `geoblock.service` | systemd-Unit, fuehrt `--apply` aus | | `geoblock.service` | systemd-Unit, fuehrt `--apply` aus |
| `geoblock.timer` | systemd-Timer: beim Booten + einmal taeglich | | `geoblock.timer` | systemd-Timer: beim Booten + einmal taeglich |
| `geoblock-reassert.service` | systemd-Unit, fuehrt `--reassert` aus (Watchdog, optional) |
| `geoblock-reassert.timer` | systemd-Timer: alle 2 Min pruefen ob die Regeln noch oben stehen (optional, nur bei `--with-watchdog`) |
| `install_geoblock.sh` | Installer: kopiert alles nach `/opt/geoblock`, richtet Timer ein (Testserver optional, siehe unten) | | `install_geoblock.sh` | Installer: kopiert alles nach `/opt/geoblock`, richtet Timer ein (Testserver optional, siehe unten) |
| `geoblock_checkmk` | checkmk local-check Plugin (manuell kopieren) | | `geoblock_checkmk` | checkmk local-check Plugin (manuell kopieren) |
| `geoblock_testserver.py` | Kleiner Test-Webserver, um die Regeln von aussen zu pruefen | | `geoblock_testserver.py` | Kleiner Test-Webserver, um die Regeln von aussen zu pruefen |
| `geoblock-testserver.service` | systemd-Unit, startet den Test-Webserver dauerhaft | | `geoblock-testserver.service` | systemd-Unit, startet den Test-Webserver dauerhaft |
| `uninstall_geoblock.sh` | Deinstaller: raeumt Timer/Service/Testserver/Regeln wieder ab (siehe unten) | | `uninstall_geoblock.sh` | Deinstaller: raeumt Timer/Service/Testserver/Regeln wieder ab (siehe unten) |
| `check_openvpn.py` | Eigenstaendiges Client-Tool: prueft ob ein OpenVPN-Server erreichbar ist (siehe unten) |
| `LAENDERCODES.md` | Referenzliste ISO-Laendercode -> ausgeschriebener Name fuer `countries` in `geoblock.ini` |
## Installation ## Installation
@@ -67,6 +71,27 @@ Nur dann wird `geoblock_testserver.py` kopiert und
8899 standardmaessig). Ohne die Option bleibt der Port zu — nachtraeglich 8899 standardmaessig). Ohne die Option bleibt der Port zu — nachtraeglich
installieren geht jederzeit per erneutem Aufruf mit `--with-testserver`. installieren geht jederzeit per erneutem Aufruf mit `--with-testserver`.
**Der Reassert-Watchdog wird standardmaessig ebenfalls NICHT installiert.** Er
wird nur auf Hosts mit einer gemanagten Firewall gebraucht (Endian, Shorewall,
firewalld, UFW, UTM-Appliances), die die iptables-Chain periodisch neu baut und
dabei fremde Regeln nach unten schiebt (siehe Abschnitt "Regeln bleiben nicht
oben" unten). Aktiviert per `--with-watchdog`:
```bash
sudo ./install_geoblock.sh --with-watchdog
# kombinierbar mit --with-testserver und Zielverzeichnis:
sudo ./install_geoblock.sh --with-testserver --with-watchdog /opt/geoblock
```
Nur dann werden `geoblock-reassert.service` + `geoblock-reassert.timer`
installiert und aktiviert (prueft alle 2 Min die Regel-Position). Auch das
laesst sich jederzeit per erneutem Aufruf mit `--with-watchdog` nachziehen.
> **Zusammengefasst:** `install_geoblock.sh` **ohne** Optionen installiert
> **weder** Testserver **noch** Watchdog — nur das eigentliche Geoblocking
> (`geoblock.py` + `geoblock.timer`, Apply beim Boot + taeglich). Testserver und
> Watchdog sind jeweils opt-in ueber ihre Option.
Danach `/opt/geoblock/geoblock.ini` anpassen (Laenderliste!) und Danach `/opt/geoblock/geoblock.ini` anpassen (Laenderliste!) und
einmal manuell testen: einmal manuell testen:
@@ -118,19 +143,269 @@ ipset list -n | grep -i geoblock
```bash ```bash
sudo python3 geoblock.py --config geoblock.ini --apply # aktivieren/aktualisieren sudo python3 geoblock.py --config geoblock.ini --apply # aktivieren/aktualisieren
sudo python3 geoblock.py --config geoblock.ini --apply --dry-run # nur simulieren sudo python3 geoblock.py --config geoblock.ini --apply --dry-run # nur simulieren
sudo python3 geoblock.py --config geoblock.ini --reassert # Regel-Position pruefen/korrigieren (ohne Download)
sudo python3 geoblock.py --config geoblock.ini --status # Zustand pruefen sudo python3 geoblock.py --config geoblock.ini --status # Zustand pruefen
sudo python3 geoblock.py --config geoblock.ini --remove # rueckgaengig sudo python3 geoblock.py --config geoblock.ini --remove # rueckgaengig
``` ```
`--reassert` ist ein leichtgewichtiger Watchdog: es laedt **keine** Zonefiles
von ipdeny (nutzt das bestehende ipset) und setzt die Block-Regeln nur dann neu
an den Anfang der Chain, wenn sie fehlen oder nach unten gerutscht sind. Gedacht
fuer Hosts mit gemanagter Firewall — siehe Abschnitt "Regeln bleiben nicht oben"
unten.
`--apply` ist idempotent (kein doppeltes Anlegen von Regeln) und tauscht `--apply` ist idempotent (kein doppeltes Anlegen von Regeln) und tauscht
das ipset atomar per `swap`, damit waehrend der Aktualisierung kein Loch das ipset atomar per `swap`, damit waehrend der Aktualisierung kein Loch
im Blocking entsteht. 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 **Achtung:** iptables-Regeln sind nicht reboot-persistent. Wer das
zusaetzlich braucht: `iptables-persistent` / `netfilter-persistent save` zusaetzlich braucht: `iptables-persistent` / `netfilter-persistent save`
nutzen — der systemd-Timer sorgt aber ohnehin bei jedem Boot fuer einen nutzen — der systemd-Timer sorgt aber ohnehin bei jedem Boot fuer einen
frischen `--apply`-Lauf, das reicht in der Praxis meist aus. frischen `--apply`-Lauf, das reicht in der Praxis meist aus.
## Fehlersuche: "es wird nicht geblockt, obwohl konfiguriert"
Typisches Symptom: Land ist in `countries`, `--apply` lief fehlerfrei — und
trotzdem kommt die Verbindung durch. Die Ursache laesst sich in drei Schritten
eindeutig eingrenzen (jeder Schritt schliesst eine Ursache aus):
**1. Ist die IP ueberhaupt im ipset?** Das Blocking matcht gegen den *Inhalt*
des Sets, nicht gegen das Zonefile. Mit der echten oeffentlichen Quell-IP des
Test-Clients:
```bash
sudo ipset test geoblock <public-ip-des-clients>
```
- `is NOT in set` / `set geoblock does not exist` → das Set ist leer/veraltet.
Meist erreicht der Server ipdeny.com nicht (kein direkter Internetzugang →
`proxy` in der `.ini` setzen, siehe unten). Die `--apply`-Ausgabe pruefen:
dort muss `ipset 'geoblock' befuellt mit <N> Bereichen ... <LAND>` stehen.
- `is in set` → Set ist ok, weiter mit Schritt 2.
**2. Erreichen die Pakete die DROP-Regel — oder faengt sie vorher eine
ACCEPT-Regel ab?** Zaehler nullen, testen, ansehen:
```bash
sudo iptables -Z INPUT # alle Paketzaehler auf 0
# jetzt vom Client aus verbinden (z.B. check_openvpn.py, siehe unten)
sudo iptables -L INPUT -n -v --line-numbers # Zaehler ablesen
```
- **DROP-Zeile (`... match-set geoblock src ... -j DROP`) zaehlt hoch** →
Blocking greift, alles korrekt.
- **DROP-Zeile bleibt 0, aber eine LOG-Zeile weiter oben zaehlt hoch** →
klassisches **Reihenfolge-Problem**: das Paket wird von einer ACCEPT-Regel
*ueber* der DROP-Regel akzeptiert, bevor es dort ankommt. `geoblock.py` setzt
seine LOG-/DROP-Regeln zwar per `iptables -I` an den **Anfang** der Chain,
damit genau das nicht passiert — aber auf Hosts mit einer **gemanagten
Firewall** (Shorewall, firewalld, UFW, UTM-Appliances mit eigenen Sub-Chains
wie `CUSTOMINPUT`/`PROXYIN`/…) generiert die Firewall-Software die Chain bei
jedem Reload komplett neu und schiebt die extern eingefuegte Regel wieder nach
unten (oder wirft sie raus). Dann hilft entweder, das Geoblocking **im Regelwerk
der Firewall-Software selbst** einzuhaengen — oder der mitgelieferte
**Reassert-Watchdog**, der die Regeln periodisch wieder nach oben setzt (siehe
Abschnitt "Regeln bleiben nicht oben" unten).
- **Auch die LOG-Zeile bleibt 0** → das Paket erreicht diese Chain gar nicht
mit der erwarteten Quell-IP/Port. Weiter mit Schritt 3.
**3. Kommt die echte Client-IP ueberhaupt am Server an? (tcpdump)** Auf dem
Geoblock-Host mitschneiden, waehrend der Client verbindet — hier am Beispiel
OpenVPN auf `1194/udp`, Port/Proto entsprechend anpassen:
```bash
sudo tcpdump -ni any udp port 1194
# fuer einen TCP-Dienst z.B.: sudo tcpdump -ni any tcp port 443
```
Beispiel-Ausgabe (IPs anonymisiert; `203.0.113.7` = oeffentliche Client-IP,
`10.0.0.10` = private Adresse des Servers selbst):
```
10:40:34.253787 IP 203.0.113.7.33807 > 10.0.0.10.1194: UDP, length 14 # Client-Handshake kommt an
10:40:34.254013 IP 10.0.0.10.1194 > 203.0.113.7.33807: UDP, length 26 # Server ANTWORTET -> nicht geblockt
```
Kommt die **echte** Client-IP als Quelle an *und* antwortet der Server (zweite
Zeile), obwohl das Land geblockt sein sollte, ist der Paketweg sauber — dann
liegt es an der Regel-Reihenfolge (Schritt 2) oder am Set (Schritt 1), nicht am
Netzwerkpfad.
Dann vom Client aus verbinden und die **Quell-IP** (`IP <quelle>.<port> > …`)
in der Ausgabe ansehen:
- **Es erscheint die echte oeffentliche Client-IP** → Paketweg ist sauber, das
Problem liegt bei der Regel (Schritt 2, Reihenfolge) oder am Set (Schritt 1).
- **Es erscheint eine *andere* IP** (private Adresse, die eines Reverse-Proxys/
Load Balancers) → davor sitzt ein NAT/Proxy, der die Quell-IP umschreibt.
Dann kann iptables/ipset per `src` prinzipiell nicht die echte Client-IP
filtern — Geoblocking muss dann eine Ebene weiter vorne greifen (siehe die
Abschnitte "Betrieb hinter einem Reverse-Proxy" und "…AWS Load Balancer"
unten).
- **Es erscheint gar nichts** → der Traffic laeuft nicht ueber diesen Host/Port
(falscher Server, anderer Port, oder eine vorgelagerte Firewall droppt schon
vorher).
## Regeln bleiben nicht oben (gemanagte Firewall) — Reassert-Watchdog
`geoblock.py` setzt seine Block-Regeln per `iptables -I` an den **Anfang** der
Chain, damit sie vor etwaigen ACCEPT-Regeln greifen. Auf Hosts mit einer
**gemanagten Firewall** (Endian, Shorewall, firewalld, UFW, UTM-Appliances mit
eigenen Sub-Chains) reicht das aber nicht dauerhaft: Die
Firewall-Software baut die Chain bei jedem eigenen Reload komplett neu und
schiebt fremde Regeln dabei nach unten — oder entfernt sie ganz. Ab dann greift
das Geoblocking nicht mehr (siehe Fehlersuche-Abschnitt oben, "Fall Reihenfolge").
Dagegen gibt es den **Reassert-Watchdog**: ein zusaetzlicher Timer, der
regelmaessig prueft, ob die Regeln noch ganz oben stehen, und sie ggf. wieder
nach oben setzt. Der Check ist billig — er laedt **nichts** von ipdeny nach,
sondern nutzt das bestehende ipset und fasst iptables nur an, wenn wirklich
etwas verrutscht ist.
**Manuell:**
```bash
sudo python3 geoblock.py --config geoblock.ini --reassert
# --status zeigt die aktuelle Position an:
sudo python3 geoblock.py --config geoblock.ini --status
# -> "Regel-Position in Chain 'INPUT': am Anfang (ok)"
# -> oder "... NACH UNTEN GERUTSCHT -- '--reassert' oder '--apply' setzt sie wieder an den Anfang"
```
**Automatisch (systemd-Timer, alle 2 Minuten):** bei der Installation die Option
`--with-watchdog` mitgeben:
```bash
sudo ./install_geoblock.sh --with-watchdog
# ggf. kombiniert:
sudo ./install_geoblock.sh --with-testserver --with-watchdog /opt/geoblock
```
Das installiert und aktiviert `geoblock-reassert.timer` (+ `.service`). Takt
anpassen: `OnUnitActiveSec` in `/etc/systemd/system/geoblock-reassert.timer`
(z.B. `1min` bei Firewalls, die oft reloaden). Kontrolle:
```bash
systemctl list-timers geoblock-reassert.timer
journalctl -u geoblock-reassert.service -n 20
```
**Hinweis:** Der Watchdog ist ein pragmatischer Reparaturmechanismus, kein
Ersatz fuer eine saubere Integration. Wenn moeglich, ist es robuster, das
Geoblocking direkt im Regelwerk der Firewall-Software einzuhaengen — dann bleibt
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) ## Verbindungstest (Test-Webserver)
`geoblock_testserver.py` ist ein winziger HTTP-Server ohne `geoblock_testserver.py` ist ein winziger HTTP-Server ohne
@@ -246,7 +521,10 @@ inkl. Perfdata (Alter in Sekunden, Anzahl geblockter IP-Bereiche).
Siehe Kommentare in der Datei selbst — kurz: Siehe Kommentare in der Datei selbst — kurz:
- `countries`: Komma-Liste ISO-3166-1-alpha-2 Codes (z.B. `RU, CN, KP, IR`) - `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 - `interface`: optional, nur ein Interface pruefen
- `log`: iptables-LOG-Eintrag vor dem DROP (dmesg/kern.log) an/aus - `log`: iptables-LOG-Eintrag vor dem DROP (dmesg/kern.log) an/aus
- `ipset_name`: Name des ipset-Sets - `ipset_name`: Name des ipset-Sets
@@ -258,6 +536,16 @@ Siehe Kommentare in der Datei selbst — kurz:
einzelner Ports" unten. einzelner Ports" unten.
- `protocol`: nur relevant wenn `ports` gesetzt ist — `tcp` (Default), `udp` - `protocol`: nur relevant wenn `ports` gesetzt ist — `tcp` (Default), `udp`
oder `tcp,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 - `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 (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 noetig, wenn der Host selbst keine direkte Internetverbindung hat, aber
@@ -314,6 +602,46 @@ zu Proxy-Ketten/CDN reinzumischen) ist Variante 1 — Testmaschine direkt mit
eigener oeffentlicher IP, Geoblocking direkt auf dieser Maschine — der eigener oeffentlicher IP, Geoblocking direkt auf dieser Maschine — der
einfachste und eindeutigste Weg. einfachste und eindeutigste Weg.
## Betrieb hinter einem AWS Load Balancer (ALB vs. NLB)
Gleiches Grundproblem wie beim Reverse-Proxy-Abschnitt oben, nur mit
AWS-spezifischer Antwort — **ob die echte Client-IP am Server ankommt, haengt
vom Load-Balancer-Typ ab:**
- **Application Load Balancer (ALB):** Arbeitet auf **Layer 7 (HTTP)**. Der
ALB terminiert die TCP-Verbindung vom Client komplett und baut eine
**neue** TCP-Verbindung zum Backend auf — mit der **IP des ALB-Nodes**
(private VPC-IP) als Absender, nicht der des Clients. Die echte Client-IP
steckt nur noch im HTTP-Header `X-Forwarded-For`. iptables/ipset sehen aber
ausschliesslich die TCP-Ebene, also die ALB-Node-IP — **Geoblocking auf dem
Backend bringt hinter einem ALB nichts**, es wuerde effektiv die eigene
AWS-Infrastruktur "blocken" bzw. gar nichts filtern. Optionen in diesem
Fall: AWS WAF mit Geo-Match-Regel direkt am ALB (die vorgesehene
AWS-Loesung dafuer), oder auf NLB wechseln, falls auf L7-Features wie
Pfad-Routing verzichtet werden kann.
- **Network Load Balancer (NLB):** Arbeitet auf **Layer 4**, terminiert die
TCP-Verbindung NICHT, sondern leitet sie durch. Die Quell-IP im TCP/IP-Paket,
das am Server ankommt, ist die **echte Client-IP** — genau das, was
iptables/ipset zur Filterung brauchen. `geoblock.py` funktioniert hinter
einem NLB in der Standard-Konfiguration also **wie auf einem Server ohne
Load Balancer davor**.
- **Standardfall (funktioniert direkt):** Targets liegen im selben VPC und
sind als **Instance-Target** registriert — die echte Client-IP kommt
transparent im TCP-Paket an, nichts weiter zu konfigurieren.
- **Vorbehalt:** Bei **IP-Targets** oder wenn der NLB ueber
**PrivateLink**/Cross-VPC/Cross-Account angesprochen wird, bewahrt AWS
die Client-IP nur, wenn am NLB **Proxy Protocol v2** aktiviert ist. Ist
das der Fall, kommt die IP nicht mehr "nackt" im TCP-Paket an, sondern
als PROXY-Header vor dem eigentlichen Payload — iptables sieht dann
weiterhin nur die NLB-Node-IP, die echte IP muesste applikationsseitig
aus dem PROXY-Header ausgewertet werden, nicht von iptables. In diesem
Fall vorher pruefen, ob Proxy Protocol aktiv ist.
**Kurz zusammengefasst:** ALB → Geoblocking auf dem Backend funktioniert
nicht (WAF am ALB nutzen). NLB mit Instance-Targets im selben VPC (Standard)
→ `geoblock.py` funktioniert unveraendert. NLB mit IP-Targets/PrivateLink →
vorher pruefen, ob Proxy Protocol v2 aktiv ist.
## Proxy fuer den Zonefile-Abruf (`proxy` in `geoblock.ini`) ## Proxy fuer den Zonefile-Abruf (`proxy` in `geoblock.ini`)
**Wichtig — das ist ein ANDERES Thema als der Reverse-Proxy-Abschnitt oben.** **Wichtig — das ist ein ANDERES Thema als der Reverse-Proxy-Abschnitt oben.**
@@ -345,3 +673,45 @@ ohne Proxy — das ist der Normalfall auf den meisten Servern.
Betroffen ist **nur** der Abruf der Zonefiles beim Ausfuehren von Betroffen ist **nur** der Abruf der Zonefiles beim Ausfuehren von
`--apply`/`--update`. iptables/ipset selbst und der Testserver brauchen dafuer `--apply`/`--update`. iptables/ipset selbst und der Testserver brauchen dafuer
keinen Proxy — die filtern nur lokal am Kernel-Paketfilter. keinen Proxy — die filtern nur lokal am Kernel-Paketfilter.
## Verbindungstest fuer OpenVPN-Server (`check_openvpn.py`)
Eigenstaendiges Client-Tool (nur Python-Stdlib, keine Abhaengigkeiten), um
zu pruefen ob ein OpenVPN-Server unter einer Host/Port/Protokoll-Kombination
erreichbar ist — ohne dafuer eine echte VPN-Verbindung mit Zertifikaten
aufzubauen. Das Gegenstueck zu `geoblock_testserver.py`, wenn ihr das
Geoblocking (oder eine sonstige Firewall-Regel) gegen einen echten
OpenVPN-Server statt gegen den Test-Webserver pruefen wollt — z.B. genau
fuer den Fall "Geoblocking auf dem VPN-Server hinter dem NLB" von eben.
**Wie es funktioniert:**
- **UDP** (Standard, meist Port `1194/udp`): schickt ein echtes, protokoll-
konformes OpenVPN-Handshake-Paket (`P_CONTROL_HARD_RESET_CLIENT_V2`,
der erste Schritt den auch ein echter Client macht). Antwortet der Server
mit seinem eigenen Hard-Reset-Paket, ist er zweifelsfrei erreichbar.
- **TCP** (falls der Server mit `proto tcp-server` laeuft): einfacher
TCP-Connect-Test.
**Benutzung:**
```bash
python3 check_openvpn.py --host vpn.example.com
# -> Standard: UDP, Port 1194, 3 Versuche, 3s Timeout je Versuch
python3 check_openvpn.py --host vpn.example.com --port 443 --proto tcp
python3 check_openvpn.py --host vpn.example.com --tries 5 --timeout 2
```
Exit-Codes fuer Skripte/Monitoring: `0` = erreichbar, `1` = nicht erreichbar/
Timeout, `2` = Aufruf-Fehler (z.B. Host nicht aufloesbar).
**Wichtiger Hinweis zu `tls-auth`/`tls-crypt`:** Ist das auf dem Server
konfiguriert (haeufige, empfohlene Haertung), verwirft er JEDES Paket ohne
gueltiges HMAC/Verschluesselung bereits VOR jeder Antwort — komplett
stillschweigend, identisch zum Verhalten bei einer Firewall-Blockade. Ein
Timeout bei diesem Tool bedeutet also nicht zwingend "nicht erreichbar",
sondern kann auch "erreichbar, aber tls-auth/tls-crypt aktiv" heissen. Wer
das eindeutig unterscheiden will: testweise tls-auth/tls-crypt kurz
deaktivieren und erneut testen, oder direkt mit einem echten OpenVPN-Client
gegentesten.
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
check_openvpn.py — prueft ob ein OpenVPN-Server erreichbar ist
ZWECK:
Eigenstaendiges Test-Tool (nur Python-Stdlib, keine Abhaengigkeiten),
um von einem Client aus zu pruefen, ob ein OpenVPN-Server unter einer
bestimmten Host/Port/Protokoll-Kombination erreichbar ist — ohne dafuer
eine echte VPN-Verbindung mit Zertifikaten aufzubauen. Nuetzlich als
Gegenstueck zu geoblock_testserver.py, wenn ihr das Geoblocking (oder
eine sonstige Firewall-Regel) gegen einen echten OpenVPN-Server statt
gegen den Test-Webserver pruefen wollt.
WIE ES FUNKTIONIERT:
UDP (Standard-Modus von OpenVPN, meist Port 1194/udp):
Es wird ein echtes, protokollkonformes OpenVPN-Paket geschickt
(P_CONTROL_HARD_RESET_CLIENT_V2 — der allererste Handshake-Schritt,
den auch ein echter OpenVPN-Client schickt). Antwortet der Server
mit seinem eigenen Hard-Reset-Paket, ist er zweifelsfrei erreichbar
UND antwortet als OpenVPN. Kommt gar nichts zurueck, ist entweder
der Port/Host blockiert (Firewall/Geoblocking dropped) ODER der
Server hat `tls-auth`/`tls-crypt` aktiv (siehe Hinweis unten).
TCP (falls der Server mit `proto tcp-server` laeuft):
Einfacher TCP-Connect-Test. Klappt der Connect, ist der Port offen
und erreichbar (OpenVPN-TCP-Server nehmen die Verbindung an und
warten dann auf den Handshake) — das reicht als Erreichbarkeits-Test.
WICHTIGER HINWEIS zu tls-auth / tls-crypt:
Ist auf dem Server `tls-auth` oder `tls-crypt` konfiguriert (haeufige,
empfohlene Haertung), verwirft er JEDES Paket ohne gueltiges HMAC/
Verschluesselung bereits VOR jeder Antwort — komplett stillschweigend,
identisch zum Verhalten bei einer Firewall-Blockade. Ein Timeout in
diesem Script bedeutet also NICHT zwingend "nicht erreichbar", sondern
kann auch "erreichbar, aber tls-auth/tls-crypt aktiv" heissen. Wer das
eindeutig unterscheiden will: testweise tls-auth/tls-crypt am Server
kurz deaktivieren, testen, wieder aktivieren — oder einfach einen
echten OpenVPN-Client mit der richtigen Config gegentesten.
BENUTZUNG:
python3 check_openvpn.py --host vpn.example.com
-> Standard: UDP, Port 1194, 3 Versuche, 3s Timeout je Versuch
python3 check_openvpn.py --host vpn.example.com --port 443 --proto tcp
-> TCP-Connect-Test auf Port 443
python3 check_openvpn.py --host vpn.example.com --tries 5 --timeout 2
-> mehr Versuche, kuerzerer Timeout je Versuch (UDP kann Paketverlust
haben, deshalb mehrere Versuche sinnvoll)
EXIT-CODES (fuer Skripte/Monitoring):
0 = erreichbar (Antwort erhalten bzw. TCP-Connect erfolgreich)
1 = nicht erreichbar / Timeout
2 = Aufruf-Fehler (falscher Host, ungueltige Argumente, ...)
"""
import argparse
import os
import socket
import struct
import sys
import time
def build_hard_reset_packet():
"""Baut ein echtes OpenVPN P_CONTROL_HARD_RESET_CLIENT_V2 Paket.
Format (siehe OpenVPN-Protokoll):
1 Byte : (opcode << 3) | key_id -- opcode 7 = HARD_RESET_CLIENT_V2
8 Bytes : Session-ID (frei waehlbar, hier zufaellig)
1 Byte : Laenge des Packet-ID-Arrays fuer ACKs (hier 0, keine ACKs)
4 Bytes : eigene Message-Packet-ID (big endian), hier 0
"""
opcode = 7 # P_CONTROL_HARD_RESET_CLIENT_V2
key_id = 0
op_byte = bytes([(opcode << 3) | key_id])
session_id = os.urandom(8)
ack_array_len = bytes([0])
packet_id = struct.pack(">I", 0)
return op_byte + session_id + ack_array_len + packet_id
def check_udp(host, port, timeout, tries):
packet = build_hard_reset_packet()
last_err = None
for attempt in range(1, tries + 1):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout)
try:
start = time.monotonic()
sock.sendto(packet, (host, port))
data, _addr = sock.recvfrom(4096)
elapsed_ms = (time.monotonic() - start) * 1000
looks_like_openvpn = bool(data) and (data[0] >> 3) in (7, 8, 9)
return True, elapsed_ms, looks_like_openvpn, attempt, None
except socket.timeout:
last_err = "Timeout"
except OSError as exc:
last_err = str(exc)
finally:
sock.close()
return False, None, False, tries, last_err
def check_tcp(host, port, timeout, tries):
last_err = None
for attempt in range(1, tries + 1):
try:
start = time.monotonic()
with socket.create_connection((host, port), timeout=timeout):
elapsed_ms = (time.monotonic() - start) * 1000
return True, elapsed_ms, None, attempt, None
except socket.timeout:
last_err = "Timeout"
except OSError as exc:
last_err = str(exc)
return False, None, None, tries, last_err
def main():
parser = argparse.ArgumentParser(
description="Prueft ob ein OpenVPN-Server erreichbar ist "
"(UDP-Handshake-Probe oder TCP-Connect-Test)."
)
parser.add_argument("--host", required=True, help="Hostname oder IP des OpenVPN-Servers")
parser.add_argument("--port", type=int, default=1194, help="Port (Default 1194)")
parser.add_argument(
"--proto", choices=["udp", "tcp"], default="udp",
help="Protokoll wie beim OpenVPN-Server konfiguriert (Default udp)",
)
parser.add_argument(
"--timeout", type=float, default=3.0,
help="Timeout je Versuch in Sekunden (Default 3.0)",
)
parser.add_argument(
"--tries", type=int, default=3,
help="Anzahl Versuche bei Timeout, v.a. bei UDP sinnvoll da paketverlust-anfaellig (Default 3)",
)
args = parser.parse_args()
try:
socket.getaddrinfo(args.host, args.port)
except socket.gaierror as exc:
print(f"FEHLER: Host '{args.host}' konnte nicht aufgeloest werden: {exc}", file=sys.stderr)
sys.exit(2)
print(f"Teste OpenVPN-Erreichbarkeit: {args.host}:{args.port}/{args.proto} "
f"(timeout={args.timeout}s, tries={args.tries}) ...")
if args.proto == "udp":
ok, elapsed_ms, looks_like_openvpn, attempt, err = check_udp(
args.host, args.port, args.timeout, args.tries
)
else:
ok, elapsed_ms, looks_like_openvpn, attempt, err = check_tcp(
args.host, args.port, args.timeout, args.tries
)
if ok:
latency = f"{elapsed_ms:.1f} ms"
if args.proto == "udp":
kind = "Antwort sieht wie gueltiges OpenVPN-Handshake-Paket aus" if looks_like_openvpn \
else "Antwort erhalten, aber kein erkennbares OpenVPN-Opcode (evtl. anderer Dienst auf dem Port?)"
print(f"ERREICHBAR — {kind}. Antwortzeit: {latency} (Versuch {attempt}/{args.tries}).")
else:
print(f"ERREICHBAR — TCP-Connect erfolgreich. Verbindungszeit: {latency} "
f"(Versuch {attempt}/{args.tries}).")
sys.exit(0)
else:
print(f"NICHT ERREICHBAR — kein Handshake/Connect nach {args.tries} Versuch(en). "
f"Letzter Fehler: {err}")
if args.proto == "udp":
print("Hinweis: bei UDP kann das auch bedeuten, dass der Server per "
"tls-auth/tls-crypt konfiguriert ist und unbekannte Pakete "
"stillschweigend verwirft (siehe Docstring oben) — nicht zwingend "
"ein Erreichbarkeits-/Firewall-Problem.")
sys.exit(1)
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Geoblocking Watchdog - Block-Regeln am Anfang der Chain halten (prueft/korrigiert Position, ohne ipdeny-Download)
Documentation=https://git.hacker-net.de/Aria-Software/geoblocking-python-script
ConditionPathExists=__INSTALL_DIR__/geoblock.ini
[Service]
Type=oneshot
User=root
# --reassert laedt KEINE Zonefiles (kein Netz noetig), nutzt das bestehende ipset
# und setzt die Regeln nur dann neu an den Anfang der Chain, wenn sie nach unten
# gerutscht sind oder fehlen -- z.B. weil eine gemanagte Firewall die Chain neu
# gebaut hat. Ist noch kein ipset vorhanden, tut es nichts (der geoblock.timer
# fuellt es beim naechsten --apply).
ExecStart=/usr/bin/python3 __INSTALL_DIR__/geoblock.py --config __INSTALL_DIR__/geoblock.ini --reassert
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Geoblocking Watchdog - regelmaessig pruefen, ob die Block-Regeln noch am Anfang der Chain stehen
[Timer]
# Kurz nach dem Booten einmal, danach alle 2 Minuten. Der Lauf ist billig
# (nur ein paar iptables-Reads, kein ipdeny-Download) und greift nur ein, wenn
# die Regeln tatsaechlich nach unten gerutscht sind. Wer schneller/traeger will,
# passt OnUnitActiveSec an (z.B. 1min fuer Firewalls, die oft reloaden).
OnBootSec=3min
OnUnitActiveSec=2min
AccuracySec=30s
Unit=geoblock-reassert.service
[Install]
WantedBy=timers.target
+26 -2
View File
@@ -4,8 +4,13 @@
; grossgeschrieben, egal), die geblockt werden sollen. ; grossgeschrieben, egal), die geblockt werden sollen.
; Liste der Codes: https://www.ipdeny.com/ipblocks/ ; Liste der Codes: https://www.ipdeny.com/ipblocks/
; ;
; chain: iptables-Chain, in die die DROP-Regel eingehaengt wird. ; chain: iptables-Chain(s), in die die DROP-Regel eingehaengt wird. Eine
; INPUT = Verbindungen ZU diesem Host (Standard-Anwendungsfall). ; 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. ; interface: optional, z.B. eth0 -- nur Traffic auf diesem Interface pruefen.
; Leer lassen = alle Interfaces. ; Leer lassen = alle Interfaces.
@@ -41,6 +46,22 @@
; oder "tcp,udp" fuer beides. Fuer die meisten Web-/App-Ports ; oder "tcp,udp" fuer beides. Fuer die meisten Web-/App-Ports
; reicht "tcp". ; 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 ; proxy: optional, HTTP(S)-Proxy-URL, ueber den das Script ipdeny.com
; erreicht -- fuer Hosts, die zwar aus dem Internet erreichbar ; erreicht -- fuer Hosts, die zwar aus dem Internet erreichbar
; sind (z.B. hinter Reverse-Proxy/Firewall), selbst aber KEINE ; 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). ; z.B. "8899" setzen (nur der Testserver wird geblockt, SSH bleibt offen).
ports = ports =
protocol = tcp 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. ; Leer lassen, ausser der Host hat keine direkte Internetverbindung.
proxy = proxy =
+255 -69
View File
@@ -49,8 +49,12 @@ AUTOMATISCH AKTUELL HALTEN (Systemstart + taeglich):
Siehe systemd/geoblock.service + geoblock.timer sowie Siehe systemd/geoblock.service + geoblock.timer sowie
install_geoblock.sh im gleichen Repo — das richtet Timer fuer install_geoblock.sh im gleichen Repo — das richtet Timer fuer
"beim Booten" + "einmal taeglich" automatisch ein. "beim Booten" + "einmal taeglich" automatisch ein.
--apply ist idempotent: es flusht das bestehende Set und befuellt es neu, --apply ist idempotent: es flusht das bestehende Set und befuellt es neu und
legt aber KEINE doppelte iptables-Regel an (wird vorher geprueft). entfernt vorher ALLE bestehenden geoblock-Regeln der Chain (auch verwaiste aus
frueheren Laeufen mit anderer ini), bevor es die Regeln frisch setzt. Die
DROP-/LOG-Regeln werden dabei per 'iptables -I' an den ANFANG der Chain gesetzt,
damit sie vor etwaigen ACCEPT-Regeln greifen (wichtig auf Hosts mit gemanagter
Firewall, die eigene ACCEPT-Regeln/Sub-Chains mitbringt).
PORT-EINSCHRAENKUNG (optional): PORT-EINSCHRAENKUNG (optional):
Per Default blockt das Script ALLE Ports/Protokolle fuer die gelisteten Per Default blockt das Script ALLE Ports/Protokolle fuer die gelisteten
@@ -65,6 +69,7 @@ import argparse
import configparser import configparser
import datetime import datetime
import ipaddress import ipaddress
import shlex
import subprocess import subprocess
import sys import sys
import time import time
@@ -73,6 +78,13 @@ import urllib.error
ZONE_URL_TMPL = "https://www.ipdeny.com/ipblocks/data/countries/{cc}.zone" 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. # Wird in main() aus der .ini gesetzt (log_file). None = keine Datei-Logs.
LOG_FILE = None LOG_FILE = None
@@ -159,9 +171,35 @@ def load_config(path):
if pr not in ("tcp", "udp"): if pr not in ("tcp", "udp"):
raise ValueError(f"Ungueltiges Protokoll '{pr}' in 'protocol' (erlaubt: 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 { return {
"countries": countries, "countries": countries,
"chain": sec.get("chain", "INPUT").strip(), "chains": chains,
"interface": sec.get("interface", "").strip(), "interface": sec.get("interface", "").strip(),
"log": sec.getboolean("log", fallback=False), "log": sec.getboolean("log", fallback=False),
"log_prefix": sec.get("log_prefix", "GEOBLOCK-DROP:").strip(), "log_prefix": sec.get("log_prefix", "GEOBLOCK-DROP:").strip(),
@@ -169,6 +207,8 @@ def load_config(path):
"log_file": sec.get("log_file", "").strip(), "log_file": sec.get("log_file", "").strip(),
"ports": ports, "ports": ports,
"protocols": protocols, "protocols": protocols,
"match_orig_dport": match_orig_dport,
"block_ips": block_ips,
"proxy": sec.get("proxy", "").strip(), "proxy": sec.get("proxy", "").strip(),
} }
@@ -187,14 +227,25 @@ def build_rule_variants(cfg):
"""Baut die Liste der (proto, dports)-Kombinationen fuer iptables-Regeln. """Baut die Liste der (proto, dports)-Kombinationen fuer iptables-Regeln.
Ohne 'ports' in der .ini: eine Variante ohne Proto-/Port-Einschraenkung Ohne 'ports' in der .ini: eine Variante ohne Proto-/Port-Einschraenkung
(= klassisches Geoblocking, alle Ports/Protokolle). Mit 'ports': eine (= klassisches Geoblocking, alle Ports/Protokolle).
Variante pro konfiguriertem Protokoll (multiport braucht -p tcp ODER -p udp,
nicht beides gleichzeitig). 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"]: 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"]) 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): def _variant_match_args(variant):
@@ -205,15 +256,19 @@ def _variant_match_args(variant):
def _variant_port_args(variant): def _variant_port_args(variant):
if variant["dports"]: if not variant["dports"]:
return ["-m", "multiport", "--dports", variant["dports"]] return []
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): def _variant_label(variant):
if not variant["dports"]: if not variant["dports"]:
return "alle Ports" 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): def _build_url_opener(proxy):
@@ -286,6 +341,142 @@ def iptables_rule_exists(chain, ipset_name, interface, variant=None, dry_run=Fal
return result.returncode == 0 return result.returncode == 0
def _list_chain_rules(chain):
"""Gibt die aktuellen Regeln einer iptables-Chain als '-A ...'-Zeilen zurueck
(Format von 'iptables -S <chain>', eine Zeile pro Regel)."""
result = subprocess.run(["iptables", "-S", chain], capture_output=True, text=True)
if result.returncode != 0:
return []
return [ln for ln in result.stdout.splitlines() if ln.startswith("-A ")]
def purge_geoblock_rules(cfg, dry_run=False):
"""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 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"]
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 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
def set_iptables_rules(cfg, dry_run=False):
"""Setzt die LOG-/DROP-Regel(n) mit -I an den ANFANG der Chain, damit sie VOR
etwaigen ACCEPT-Regeln greifen. Sonst akzeptiert eine weiter oben stehende
ACCEPT-Regel das Paket und eine ans Ende (-A) gehaengte DROP-Regel kommt nie
dran -- typisch auf Hosts mit gemanagter Firewall (Shorewall/firewalld/UFW/
UTM-Appliances mit eigenen Sub-Chains).
Setzt voraus, dass das ipset bereits existiert/befuellt ist UND die Chain
vorher per purge_geoblock_rules() bereinigt wurde (sonst entstehen Dubletten).
Pro Variante wird DROP zuerst an den Anfang gesetzt, dann LOG davor -> die
finale Reihenfolge in der Chain ist LOG vor DROP (erst loggen, dann droppen).
"""
name = cfg["ipset_name"]
set_match = ["-m", "set", "--match-set", name, "src"]
iface_opt = ["-i", cfg["interface"]] if cfg["interface"] else []
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", 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", 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 _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)
"""
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).
Gedacht fuer einen haeufigen Timer auf Hosts mit gemanagter Firewall, die die
Chain periodisch neu baut und dabei unsere Regeln nach unten schiebt oder
entfernt. Fehlt das ipset komplett, wird NICHT eingegriffen -- dann muss erst
ein voller --apply laufen, der das Set befuellt (der taegliche Timer erledigt das).
Rueckgabe: True, wenn korrigiert wurde; False, wenn nichts zu tun war.
"""
name = cfg["ipset_name"]
if not ipset_exists(name):
log(f"ipset '{name}' existiert nicht — reassert uebersprungen (erst voller --apply noetig).")
return False
on_top, present = geoblock_rules_on_top(cfg)
if on_top:
log("geoblock-Regeln stehen bereits am Anfang der Chain — nichts zu tun.")
return False
if present:
log("geoblock-Regeln nach unten gerutscht — setze sie neu an den Anfang der Chain.")
else:
log("geoblock-Regeln fehlen in der Chain — setze sie neu an den Anfang.")
purge_geoblock_rules(cfg, dry_run=dry_run) # evtl. verrutschte Reste einsammeln
set_iptables_rules(cfg, dry_run=dry_run)
return True
def apply_geoblock(cfg, dry_run=False): def apply_geoblock(cfg, dry_run=False):
name = cfg["ipset_name"] name = cfg["ipset_name"]
@@ -303,6 +494,12 @@ def apply_geoblock(cfg, dry_run=False):
if not all_cidrs and not dry_run: if not all_cidrs and not dry_run:
raise RuntimeError("Keine IP-Bereiche geladen — Abbruch, um kein leeres Set zu aktivieren") 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) # 3) Set atomar neu befuellen: temp-Set bauen, dann swap (kein Traffic-Loch)
tmp_name = f"{name}_tmp" tmp_name = f"{name}_tmp"
run(["ipset", "create", tmp_name, "hash:net", "-exist"], dry_run=dry_run) run(["ipset", "create", tmp_name, "hash:net", "-exist"], dry_run=dry_run)
@@ -313,36 +510,20 @@ def apply_geoblock(cfg, dry_run=False):
run(["ipset", "swap", tmp_name, name], dry_run=dry_run) run(["ipset", "swap", tmp_name, name], dry_run=dry_run)
run(["ipset", "destroy", tmp_name], dry_run=dry_run, check=False) run(["ipset", "destroy", tmp_name], dry_run=dry_run, check=False)
log(f"ipset '{name}' befuellt mit {len(all_cidrs)} Bereichen " extra = f" + {n_block_ips} eigene(n) Block-IP(s)/CIDR(s)" if n_block_ips else ""
f"aus Laendern: {', '.join(c.upper() for c in cfg['countries'])}") log(f"ipset '{name}' befuellt mit {len(all_cidrs)} Eintraegen "
f"(Laender: {', '.join(c.upper() for c in cfg['countries'])}{extra})")
# 4) optionale LOG-Regel(n) + DROP-Regel(n) in iptables, nur wenn noch nicht vorhanden. # 4) Vor dem Neu-Setzen ALLE bestehenden geoblock-Regeln der Chain entfernen
# Eine Variante pro Protokoll wenn 'ports' gesetzt ist, sonst eine Variante (alle Ports). # (auch verwaiste aus frueheren Laeufen mit anderer ini, z.B. andere Ports).
set_match = ["-m", "set", "--match-set", name, "src"] # Erst JETZT (nach dem Befuellen des Sets), damit waehrend des ipdeny-Downloads
iface_opt = ["-i", cfg["interface"]] if cfg["interface"] else [] # der bestehende Block aktiv bleibt und kein Traffic-Loch entsteht.
variants = build_rule_variants(cfg) purge_geoblock_rules(cfg, dry_run=dry_run)
for variant in variants: # 5) LOG-/DROP-Regel(n) frisch an den ANFANG der Chain setzen (siehe
proto_args = _variant_match_args(variant) # set_iptables_rules -- gleiche Logik nutzt auch --reassert). Der purge oben
port_args = _variant_port_args(variant) # hat die Chain vorher von alten geoblock-Regeln befreit, daher keine Dubletten.
label = _variant_label(variant) set_iptables_rules(cfg, dry_run=dry_run)
if cfg["log"]:
log_check = (["iptables", "-C", cfg["chain"]] + iface_opt + proto_args + set_match +
port_args + ["-j", "LOG", "--log-prefix", cfg["log_prefix"] + " "])
exists = False if dry_run else subprocess.run(log_check, capture_output=True).returncode == 0
if not exists:
run(["iptables", "-I", cfg["chain"]] + iface_opt + proto_args + set_match +
port_args + ["-j", "LOG", "--log-prefix", cfg["log_prefix"] + " "], dry_run=dry_run)
else:
log(f"LOG-Regel ({label}) existiert bereits, ueberspringe")
if iptables_rule_exists(cfg["chain"], name, cfg["interface"], variant=variant, dry_run=dry_run):
log(f"DROP-Regel ({label}) existiert bereits, ueberspringe (idempotent)")
else:
run(["iptables", "-A", cfg["chain"]] + iface_opt + proto_args + set_match +
port_args + ["-j", "DROP"], dry_run=dry_run)
log(f"DROP-Regel in Chain '{cfg['chain']}' aktiv fuer Set '{name}' ({label})")
log("Fertig. Hinweis: Regeln sind NICHT reboot-persistent — " log("Fertig. Hinweis: Regeln sind NICHT reboot-persistent — "
"fuer Persistenz z.B. 'iptables-persistent' bzw. 'netfilter-persistent save' nutzen, " "fuer Persistenz z.B. 'iptables-persistent' bzw. 'netfilter-persistent save' nutzen, "
@@ -353,30 +534,10 @@ def apply_geoblock(cfg, dry_run=False):
def remove_geoblock(cfg, dry_run=False): def remove_geoblock(cfg, dry_run=False):
name = cfg["ipset_name"] name = cfg["ipset_name"]
iface_opt = ["-i", cfg["interface"]] if cfg["interface"] else [] # Loescht ALLE geoblock-Regeln der Chain (auch verwaiste aus frueheren Laeufen
set_match = ["-m", "set", "--match-set", name, "src"] # mit anderer ini), nicht nur die aktuell konfigurierten Varianten -- so bleiben
# Entfernt fuer alle aktuell konfigurierten Varianten. Falls die .ini seit dem letzten # keine Reste stehen, egal wie sich 'ports'/'protocol' zwischenzeitlich geaendert haben.
# --apply geaendert wurde (z.B. ports entfernt/hinzugefuegt), koennen dadurch alte purge_geoblock_rules(cfg, dry_run=dry_run)
# Regeln mit anderer Variante stehen bleiben -- im Zweifel per 'iptables -L <chain> -n'
# pruefen und manuell aufraeumen.
for variant in build_rule_variants(cfg):
proto_args = _variant_match_args(variant)
port_args = _variant_port_args(variant)
for _ in range(5):
result = run(["iptables", "-D", cfg["chain"]] + iface_opt + proto_args + set_match +
port_args + ["-j", "DROP"], dry_run=dry_run, check=False)
if dry_run or result is None or result.returncode != 0:
break
if cfg["log"]:
for _ in range(5):
result = run(["iptables", "-D", cfg["chain"]] + iface_opt + proto_args + set_match +
port_args + ["-j", "LOG", "--log-prefix", cfg["log_prefix"] + " "],
dry_run=dry_run, check=False)
if dry_run or result is None or result.returncode != 0:
break
run(["ipset", "destroy", name], dry_run=dry_run, check=False) run(["ipset", "destroy", name], dry_run=dry_run, check=False)
log(f"Geoblock-Regeln entfernt, ipset '{name}' geloescht.") log(f"Geoblock-Regeln entfernt, ipset '{name}' geloescht.")
@@ -390,12 +551,28 @@ def show_status(cfg):
print(f"ipset '{name}' existiert nicht (noch nicht aktiv).") print(f"ipset '{name}' existiert nicht (noch nicht aktiv).")
variants = build_rule_variants(cfg) variants = build_rule_variants(cfg)
for variant in variants: for chain in cfg["chains"]:
active = iptables_rule_exists(cfg["chain"], name, cfg["interface"], variant=variant) for variant in variants:
print(f"iptables DROP-Regel in Chain '{cfg['chain']}' ({_variant_label(variant)}) aktiv: {active}") 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 = _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'])}") 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"]: 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: else:
print("Port-Einschraenkung: keine (alle Ports/Protokolle werden geblockt)") print("Port-Einschraenkung: keine (alle Ports/Protokolle werden geblockt)")
@@ -407,12 +584,17 @@ def main():
parser.add_argument("--config", required=True, help="Pfad zur .ini-Konfigurationsdatei") parser.add_argument("--config", required=True, help="Pfad zur .ini-Konfigurationsdatei")
group = parser.add_mutually_exclusive_group(required=True) group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--apply", action="store_true", help="Laender laden und Block-Regeln setzen/aktualisieren") group.add_argument("--apply", action="store_true", help="Laender laden und Block-Regeln setzen/aktualisieren")
group.add_argument("--reassert", action="store_true",
help="Nur pruefen ob die Block-Regeln noch am Anfang der Chain stehen und sie "
"ggf. wieder nach oben setzen -- OHNE Zonefiles neu zu laden (schneller Watchdog "
"fuer gemanagte Firewalls, die die Chain neu bauen). Setzt ein bestehendes ipset voraus.")
group.add_argument("--remove", action="store_true", help="Block-Regeln und ipset wieder entfernen") group.add_argument("--remove", action="store_true", help="Block-Regeln und ipset wieder entfernen")
group.add_argument("--status", action="store_true", help="Aktuellen Zustand anzeigen") group.add_argument("--status", action="store_true", help="Aktuellen Zustand anzeigen")
parser.add_argument("--dry-run", action="store_true", help="Nur anzeigen was passieren wuerde, nichts aendern") parser.add_argument("--dry-run", action="store_true", help="Nur anzeigen was passieren wuerde, nichts aendern")
args = parser.parse_args() args = parser.parse_args()
action = "apply" if args.apply else ("remove" if args.remove else "status") action = ("apply" if args.apply else "reassert" if args.reassert
else "remove" if args.remove else "status")
try: try:
cfg = load_config(args.config) cfg = load_config(args.config)
@@ -422,6 +604,10 @@ def main():
n_countries, n_entries = apply_geoblock(cfg, dry_run=args.dry_run) n_countries, n_entries = apply_geoblock(cfg, dry_run=args.dry_run)
log_result("OK", action, countries=n_countries, entries=n_entries, log_result("OK", action, countries=n_countries, entries=n_entries,
msg=f"Geoblock aktualisiert ({n_countries} Laender, {n_entries} IP-Bereiche)") msg=f"Geoblock aktualisiert ({n_countries} Laender, {n_entries} IP-Bereiche)")
elif args.reassert:
corrected = reassert_rules(cfg, dry_run=args.dry_run)
log_result("OK", action,
msg="Regeln neu an den Anfang gesetzt" if corrected else "Regeln bereits am Anfang, nichts zu tun")
elif args.remove: elif args.remove:
remove_geoblock(cfg, dry_run=args.dry_run) remove_geoblock(cfg, dry_run=args.dry_run)
log_result("OK", action, msg="Geoblock-Regeln entfernt") log_result("OK", action, msg="Geoblock-Regeln entfernt")
+36 -2
View File
@@ -7,7 +7,7 @@
# aktualisiert. # aktualisiert.
# #
# Nutzung (als root, im Verzeichnis mit allen Repo-Dateien): # Nutzung (als root, im Verzeichnis mit allen Repo-Dateien):
# sudo ./install_geoblock.sh [--with-testserver] [ZIELVERZEICHNIS] # sudo ./install_geoblock.sh [--with-testserver] [--with-watchdog] [ZIELVERZEICHNIS]
# #
# Der kleine Test-Webserver (geoblock_testserver.py + # Der kleine Test-Webserver (geoblock_testserver.py +
# geoblock-testserver.service), gegen den man von aussen connecten # geoblock-testserver.service), gegen den man von aussen connecten
@@ -16,10 +16,18 @@
# in Sachen Testserver gar nichts (kein Copy, kein systemd-Unit, kein # in Sachen Testserver gar nichts (kein Copy, kein systemd-Unit, kein
# offener Port). # offener Port).
# #
# Der Reassert-Watchdog (geoblock-reassert.service + .timer) prueft alle
# 2 Minuten, ob die Block-Regeln noch am Anfang der iptables-Chain stehen,
# und setzt sie ggf. wieder nach oben (ohne ipdeny-Download). Nur noetig auf
# Hosts mit einer gemanagten Firewall (Shorewall/firewalld/UFW/UTM-Appliance),
# die die Chain neu baut und dabei fremde Regeln nach unten schiebt. Wird NUR
# bei --with-watchdog installiert/aktiviert.
#
# Beispiele: # Beispiele:
# sudo ./install_geoblock.sh # sudo ./install_geoblock.sh
# sudo ./install_geoblock.sh --with-testserver # sudo ./install_geoblock.sh --with-testserver
# sudo ./install_geoblock.sh --with-testserver /opt/geoblock # sudo ./install_geoblock.sh --with-watchdog
# sudo ./install_geoblock.sh --with-testserver --with-watchdog /opt/geoblock
# sudo ./install_geoblock.sh /opt/geoblock # sudo ./install_geoblock.sh /opt/geoblock
# #
# Benoetigt im selben Verzeichnis: # Benoetigt im selben Verzeichnis:
@@ -30,6 +38,9 @@
# (nur bei --with-testserver:) # (nur bei --with-testserver:)
# geoblock_testserver.py # geoblock_testserver.py
# geoblock-testserver.service # geoblock-testserver.service
# (nur bei --with-watchdog:)
# geoblock-reassert.service
# geoblock-reassert.timer
# #
# Voraussetzungen auf dem Host: python3, iptables, ipset, systemd. # Voraussetzungen auf dem Host: python3, iptables, ipset, systemd.
@@ -43,12 +54,16 @@ fi
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WITH_TESTSERVER=0 WITH_TESTSERVER=0
WITH_WATCHDOG=0
INSTALL_DIR="" INSTALL_DIR=""
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--with-testserver) --with-testserver)
WITH_TESTSERVER=1 WITH_TESTSERVER=1
;; ;;
--with-watchdog)
WITH_WATCHDOG=1
;;
-h|--help) -h|--help)
grep '^# ' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' grep '^# ' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0 exit 0
@@ -65,6 +80,9 @@ REQUIRED_FILES=(geoblock.py geoblock.ini.example geoblock.service geoblock.timer
if [[ "$WITH_TESTSERVER" -eq 1 ]]; then if [[ "$WITH_TESTSERVER" -eq 1 ]]; then
REQUIRED_FILES+=(geoblock_testserver.py geoblock-testserver.service) REQUIRED_FILES+=(geoblock_testserver.py geoblock-testserver.service)
fi fi
if [[ "$WITH_WATCHDOG" -eq 1 ]]; then
REQUIRED_FILES+=(geoblock-reassert.service geoblock-reassert.timer)
fi
for f in "${REQUIRED_FILES[@]}"; do for f in "${REQUIRED_FILES[@]}"; do
if [[ ! -f "$SRC_DIR/$f" ]]; then if [[ ! -f "$SRC_DIR/$f" ]]; then
@@ -114,6 +132,15 @@ else
echo "==> Test-Webserver wird NICHT installiert (Option --with-testserver nicht angegeben)." echo "==> Test-Webserver wird NICHT installiert (Option --with-testserver nicht angegeben)."
fi fi
if [[ "$WITH_WATCHDOG" -eq 1 ]]; then
echo "==> --with-watchdog gesetzt: installiere Reassert-Watchdog (haelt die Regeln am Chain-Anfang)"
sed "s#__INSTALL_DIR__#$INSTALL_DIR#g" "$SRC_DIR/geoblock-reassert.service" \
> /etc/systemd/system/geoblock-reassert.service
cp "$SRC_DIR/geoblock-reassert.timer" /etc/systemd/system/geoblock-reassert.timer
else
echo "==> Reassert-Watchdog wird NICHT installiert (Option --with-watchdog nicht angegeben)."
fi
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now geoblock.timer systemctl enable --now geoblock.timer
@@ -121,12 +148,19 @@ if [[ "$WITH_TESTSERVER" -eq 1 ]]; then
systemctl enable --now geoblock-testserver.service systemctl enable --now geoblock-testserver.service
fi fi
if [[ "$WITH_WATCHDOG" -eq 1 ]]; then
systemctl enable --now geoblock-reassert.timer
fi
echo "" echo ""
echo "==> Fertig." echo "==> Fertig."
echo " Config anpassen: $INSTALL_DIR/geoblock.ini" echo " Config anpassen: $INSTALL_DIR/geoblock.ini"
echo " Jetzt sofort testen: systemctl start geoblock.service && journalctl -u geoblock.service -f" echo " Jetzt sofort testen: systemctl start geoblock.service && journalctl -u geoblock.service -f"
echo " Timer-Status: systemctl list-timers geoblock.timer" echo " Timer-Status: systemctl list-timers geoblock.timer"
echo " Naechste/letzte Läufe: systemctl status geoblock.timer" echo " Naechste/letzte Läufe: systemctl status geoblock.timer"
if [[ "$WITH_WATCHDOG" -eq 1 ]]; then
echo " Watchdog-Status: systemctl list-timers geoblock-reassert.timer"
fi
echo " Log-Datei: $LOG_DIR/geoblock.log" echo " Log-Datei: $LOG_DIR/geoblock.log"
if [[ "$WITH_TESTSERVER" -eq 1 ]]; then if [[ "$WITH_TESTSERVER" -eq 1 ]]; then
echo " Test-Webserver: systemctl status geoblock-testserver.service" echo " Test-Webserver: systemctl status geoblock-testserver.service"
+6
View File
@@ -84,6 +84,12 @@ echo "==> Deinstalliere Geoblocking komplett ($INSTALL_DIR)"
stop_disable_rm_unit "geoblock-testserver.service" stop_disable_rm_unit "geoblock-testserver.service"
rm -f "$INSTALL_DIR/geoblock_testserver.py" rm -f "$INSTALL_DIR/geoblock_testserver.py"
# Reassert-Watchdog (falls installiert) zuerst stoppen, damit er nicht direkt
# nach dem --remove die Regeln neu setzt. (stop_disable_rm_unit ignoriert
# nicht vorhandene Units.)
stop_disable_rm_unit "geoblock-reassert.timer"
stop_disable_rm_unit "geoblock-reassert.service"
# iptables/ipset-Regeln sauber abbauen, SOLANGE Script+Config noch da sind. # iptables/ipset-Regeln sauber abbauen, SOLANGE Script+Config noch da sind.
if [[ -f "$INSTALL_DIR/geoblock.py" && -f "$INSTALL_DIR/geoblock.ini" ]]; then if [[ -f "$INSTALL_DIR/geoblock.py" && -f "$INSTALL_DIR/geoblock.ini" ]]; then
echo "==> Entferne iptables/ipset-Regeln (geoblock.py --remove)" echo "==> Entferne iptables/ipset-Regeln (geoblock.py --remove)"