252 lines
9.0 KiB
Python
Executable File
252 lines
9.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
geoblock_testserver.py — kleiner Test-Webserver fuer geoblock
|
|
|
|
ZWECK:
|
|
Ein minimaler HTTP-Server (nur Python-Stdlib, keine Abhaengigkeiten),
|
|
gegen den man von AUSSEN (Handy im Mobilfunknetz, VPN mit anderem Land,
|
|
Freund im Ausland, ...) connecten kann, um zu pruefen ob die Geoblocking-
|
|
Regeln (ipset/iptables aus geoblock.py) wirklich greifen.
|
|
|
|
Die Logik ist bewusst simpel: der Server selbst weiss NICHTS von Laendern
|
|
oder Blocklisten. Er beantwortet einfach jeden Request, der ihn erreicht,
|
|
mit einer kleinen HTML-Seite (Client-IP, Zeitstempel, User-Agent) und
|
|
loggt den Request in eine Datei.
|
|
|
|
Der eigentliche TEST ist der Netzwerkeffekt: iptables droppt Pakete
|
|
geblockter Laender bereits VOR dem TCP-Handshake. Kommt eine Anfrage aus
|
|
einem geblockten Land, sieht dieser Server sie also gar nicht erst -
|
|
der Client bekommt stattdessen ein Timeout. Aus einem NICHT geblockten
|
|
Land/Netz laedt die Seite ganz normal.
|
|
|
|
Testablauf (siehe auch README.md):
|
|
1. Testserver starten (systemd-Service geoblock-testserver, oder
|
|
manuell: siehe unten).
|
|
2. Von einem Client aus einem ERLAUBTEN Land/Netz aufrufen:
|
|
curl http://<host>:<port>/
|
|
-> sollte die Test-Seite liefern.
|
|
3. Ziel-Land testweise in geoblock.ini eintragen (z.B. den eigenen
|
|
Ländercode DE), geoblock.py --apply ausfuehren, dann von einem
|
|
Client MIT dieser Landes-IP erneut connecten
|
|
-> sollte jetzt in ein Timeout laufen (kein TCP-Connect möglich).
|
|
4. Land wieder aus der .ini entfernen, --apply, Zugriff geht wieder.
|
|
|
|
BENUTZUNG:
|
|
python3 geoblock_testserver.py --config geoblock.ini
|
|
-> Port/Bind-Adresse/Log-Pfad kommen aus dem Abschnitt [testserver]
|
|
der .ini (siehe geoblock.ini im gleichen Repo)
|
|
|
|
python3 geoblock_testserver.py --port 8899 --bind 0.0.0.0 \
|
|
--log-file /var/log/geoblock/testserver.log
|
|
-> alternativ komplett ohne .ini, per CLI-Argumenten
|
|
|
|
CLI-Argumente ueberschreiben, falls angegeben, die Werte aus der .ini.
|
|
|
|
LOGGING:
|
|
Jeder eingehende Request wird als eine Zeile geloggt (Zeitstempel,
|
|
Client-IP, Methode, Pfad, User-Agent, Statuscode). Ohne --log-file/
|
|
ohne log_file in der .ini wird nur nach stderr (systemd-Journal)
|
|
geloggt.
|
|
|
|
AUTOSTART:
|
|
Siehe geoblock-testserver.service im gleichen Repo sowie
|
|
install_geoblock.sh, das den Service optional mit einrichtet.
|
|
"""
|
|
|
|
import argparse
|
|
import configparser
|
|
import datetime
|
|
import html
|
|
import ipaddress
|
|
import socket
|
|
import sys
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
DEFAULT_PORT = 8899
|
|
DEFAULT_BIND = "0.0.0.0"
|
|
|
|
# Wird in main() aus CLI/.ini gesetzt. None = keine Datei-Logs.
|
|
LOG_FILE = None
|
|
|
|
|
|
def _timestamp():
|
|
return datetime.datetime.now().astimezone().isoformat(timespec="seconds")
|
|
|
|
|
|
def _write_log_file(line):
|
|
if not LOG_FILE:
|
|
return
|
|
try:
|
|
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
|
f.write(f"{_timestamp()} {line}\n")
|
|
except OSError as e:
|
|
print(f"[geoblock-testserver] WARNUNG: konnte Log-Datei nicht schreiben "
|
|
f"({LOG_FILE}): {e}", file=sys.stderr)
|
|
|
|
|
|
def log(msg):
|
|
line = f"[geoblock-testserver] {msg}"
|
|
print(line, file=sys.stderr, flush=True)
|
|
_write_log_file(line)
|
|
|
|
|
|
def load_config(path):
|
|
"""Liest [testserver] aus der geoblock.ini. Fehlt der Abschnitt
|
|
komplett, werden die Defaults benutzt (die .ini ist fuer den Testserver
|
|
also optional, im Gegensatz zu geoblock.py)."""
|
|
cfg = configparser.ConfigParser()
|
|
read_ok = cfg.read(path)
|
|
if not read_ok:
|
|
raise FileNotFoundError(f"Konnte .ini nicht lesen: {path}")
|
|
|
|
if "testserver" not in cfg:
|
|
log("Abschnitt [testserver] nicht in der .ini gefunden - benutze Defaults "
|
|
f"(port={DEFAULT_PORT}, bind={DEFAULT_BIND}, kein log_file)")
|
|
return {"port": DEFAULT_PORT, "bind": DEFAULT_BIND, "log_file": ""}
|
|
|
|
sec = cfg["testserver"]
|
|
return {
|
|
"port": sec.getint("port", fallback=DEFAULT_PORT),
|
|
"bind": sec.get("bind", fallback=DEFAULT_BIND).strip(),
|
|
"log_file": sec.get("log_file", fallback="").strip(),
|
|
}
|
|
|
|
|
|
def _client_ip(handler):
|
|
"""Bevorzugt X-Forwarded-For (falls hinter einem Reverse-Proxy), sonst
|
|
die direkte Peer-IP der TCP-Verbindung."""
|
|
xff = handler.headers.get("X-Forwarded-For")
|
|
if xff:
|
|
# erster Eintrag = urspruenglicher Client
|
|
candidate = xff.split(",")[0].strip()
|
|
try:
|
|
ipaddress.ip_address(candidate)
|
|
return candidate
|
|
except ValueError:
|
|
pass
|
|
return handler.client_address[0]
|
|
|
|
|
|
PAGE_TMPL = """<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Geoblock — Verbindungstest</title>
|
|
<style>
|
|
body {{ font-family: -apple-system, Segoe UI, Roboto, sans-serif;
|
|
background: #0b1220; color: #e6ebf5; margin: 0;
|
|
display: flex; align-items: center; justify-content: center;
|
|
min-height: 100vh; }}
|
|
.card {{ background: #131c2e; border: 1px solid #24304a; border-radius: 12px;
|
|
padding: 2rem 2.5rem; max-width: 520px; box-shadow: 0 8px 24px rgba(0,0,0,.4); }}
|
|
h1 {{ font-size: 1.3rem; margin-top: 0; color: #6ee7a8; }}
|
|
table {{ width: 100%; border-collapse: collapse; margin-top: 1rem; }}
|
|
td {{ padding: .4rem 0; border-bottom: 1px solid #24304a; font-size: .92rem; }}
|
|
td.k {{ color: #8fa1c7; width: 40%; }}
|
|
.ok {{ color: #6ee7a8; font-weight: 600; }}
|
|
p.hint {{ color: #8fa1c7; font-size: .82rem; margin-top: 1.2rem; line-height: 1.4; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1>✓ Verbindung erfolgreich</h1>
|
|
<p class="ok">Diese Anfrage kam beim Server an — die Quelle ist also NICHT geblockt.</p>
|
|
<table>
|
|
<tr><td class="k">Client-IP</td><td>{client_ip}</td></tr>
|
|
<tr><td class="k">Zeitstempel</td><td>{ts}</td></tr>
|
|
<tr><td class="k">Methode / Pfad</td><td>{method} {path}</td></tr>
|
|
<tr><td class="k">User-Agent</td><td>{ua}</td></tr>
|
|
<tr><td class="k">Host-Header</td><td>{host}</td></tr>
|
|
</table>
|
|
<p class="hint">Kommt eine Anfrage aus einem in geoblock.ini geblockten Land,
|
|
sieht dieser Server sie gar nicht erst — iptables droppt sie vorher.
|
|
Der Client bekommt dann ein Timeout statt dieser Seite.</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
class TestHandler(BaseHTTPRequestHandler):
|
|
server_version = "Geoblock-Testserver/1.0"
|
|
|
|
def log_message(self, format, *args):
|
|
# Default-Logging von BaseHTTPRequestHandler unterdruecken,
|
|
# wir loggen selbst strukturiert in do_GET/do_HEAD.
|
|
pass
|
|
|
|
def _handle(self, send_body):
|
|
client_ip = _client_ip(self)
|
|
ua = self.headers.get("User-Agent", "-")
|
|
host_hdr = self.headers.get("Host", "-")
|
|
log(f'request client_ip={client_ip} method={self.command} path={self.path} '
|
|
f'host="{host_hdr}" ua="{ua}"')
|
|
|
|
body = PAGE_TMPL.format(
|
|
client_ip=html.escape(client_ip),
|
|
ts=html.escape(_timestamp()),
|
|
method=html.escape(self.command),
|
|
path=html.escape(self.path),
|
|
ua=html.escape(ua),
|
|
host=html.escape(host_hdr),
|
|
).encode("utf-8")
|
|
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.end_headers()
|
|
if send_body:
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self):
|
|
self._handle(send_body=True)
|
|
|
|
def do_HEAD(self):
|
|
self._handle(send_body=False)
|
|
|
|
|
|
class ThreadingHTTPServerV6Fallback(ThreadingHTTPServer):
|
|
"""Erlaubt Bind auf IPv6-Adressen (z.B. '::'), faellt aber sauber
|
|
auf IPv4 zurueck wenn eine IPv4-Adresse konfiguriert ist."""
|
|
def server_bind(self):
|
|
if ":" in self.server_address[0]:
|
|
self.address_family = socket.AF_INET6
|
|
super().server_bind()
|
|
|
|
|
|
def main():
|
|
global LOG_FILE
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Kleiner Test-Webserver um Geoblocking-Regeln von aussen zu pruefen")
|
|
parser.add_argument("--config", help="Pfad zur geoblock.ini (Abschnitt [testserver])")
|
|
parser.add_argument("--port", type=int, help="TCP-Port (ueberschreibt .ini)")
|
|
parser.add_argument("--bind", help="Bind-Adresse (ueberschreibt .ini)")
|
|
parser.add_argument("--log-file", help="Pfad zur Request-Log-Datei (ueberschreibt .ini)")
|
|
args = parser.parse_args()
|
|
|
|
if args.config:
|
|
cfg = load_config(args.config)
|
|
else:
|
|
cfg = {"port": DEFAULT_PORT, "bind": DEFAULT_BIND, "log_file": ""}
|
|
|
|
port = args.port if args.port is not None else cfg["port"]
|
|
bind = args.bind if args.bind else cfg["bind"]
|
|
LOG_FILE = args.log_file if args.log_file else (cfg["log_file"] or None)
|
|
|
|
log(f"Starte Test-Webserver auf {bind}:{port} "
|
|
f"(log_file={'aus' if not LOG_FILE else LOG_FILE})")
|
|
|
|
httpd = ThreadingHTTPServerV6Fallback((bind, port), TestHandler)
|
|
try:
|
|
httpd.serve_forever()
|
|
except KeyboardInterrupt:
|
|
log("Beende (Strg+C).")
|
|
finally:
|
|
httpd.server_close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|