#!/usr/bin/env python3 """ IWZ Ferienwohnungen - Nutzungsanalyse Laedt die Apache-Access-Logs per SFTP von IONOS herunter, entpackt die rotierten .gz-Dateien und erzeugt mit GoAccess einen HTML-Report. Kein JavaScript-Tracking, keine Cookies beim Besucher -> DSGVO-freundlich. Die Auswertung passiert rein aus den Server-Logs. Voraussetzungen: - pip install -r requirements.txt (paramiko) - GoAccess Windows-Build (Pfad in .env: GOACCESS_EXE) - .env mit IONOS_SFTP_* befuellt (siehe .env.example) Aufruf: python analyze.py # Logs laden + Report bauen + oeffnen python analyze.py --no-download # nur neu auswerten (schon geladene Logs) """ import gzip import os import shutil import stat import subprocess import sys import webbrowser from datetime import date from pathlib import Path try: import paramiko except ImportError: print("FEHLER: paramiko fehlt. Bitte 'pip install -r requirements.txt' ausfuehren.") sys.exit(1) PROJECT_ROOT = Path(__file__).resolve().parent.parent # Skript liegt in lib/ LOGS_DIR = PROJECT_ROOT / "logs" # heruntergeladene Rohlogs (in .gitignore) RESULTS_DIR = PROJECT_ROOT / "results" # erzeugte Reports (in .gitignore) SFTP_PORT = 22 def env_file(): """Pfad zur .env: bevorzugt IWZ_ENV (von setenv.bat), sonst bin/.env.""" from_var = os.environ.get("IWZ_ENV") if from_var: return Path(from_var) return PROJECT_ROOT / "bin" / ".env" # Nur diese Dateien aus dem IONOS /logs-Ordner interessieren uns. # Alles andere (sftp.log, traffic.db, forensic/, info.*) wird ignoriert. ACCESS_LOG_PREFIX = "access.log" def load_env(path): env = {} if not path.exists(): return env for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) env[key.strip()] = value.strip() return env def download_access_logs(host, port, user, password, remote_dir): """Laedt alle access.log*-Dateien aus dem IONOS /logs-Ordner nach LOGS_DIR.""" print(f"\n-> Verbinde per SFTP mit {host}:{port} ...") transport = paramiko.Transport((host, port)) try: transport.connect(username=user, password=password) sftp = paramiko.SFTPClient.from_transport(transport) sftp.chdir(remote_dir) LOGS_DIR.mkdir(exist_ok=True) count = 0 for attr in sftp.listdir_attr("."): name = attr.filename if not name.startswith(ACCESS_LOG_PREFIX): continue if stat.S_ISDIR(attr.st_mode): continue local_path = LOGS_DIR / name # Schon vorhanden und gleich gross? -> ueberspringen (rotierte Logs # aendern sich nicht mehr; nur .current / aktueller Tag waechst). if (local_path.exists() and local_path.stat().st_size == attr.st_size and not name.endswith(".current")): continue print(f" lade {name} ({attr.st_size} Bytes)") sftp.get(name, str(local_path)) count += 1 sftp.close() print(f" {count} Datei(en) geladen/aktualisiert.") finally: transport.close() def collect_log_lines(): """Fuehrt alle access.log*-Dateien (auch .gz) zu einem kombinierten Log zusammen. Gibt den Pfad zur kombinierten Klartext-Logdatei zurueck. """ combined = LOGS_DIR / "_combined.log" sources = sorted( p for p in LOGS_DIR.iterdir() if p.is_file() and p.name.startswith(ACCESS_LOG_PREFIX) and p.name != combined.name ) if not sources: print("\nFEHLER: keine access.log*-Dateien in ./logs/ gefunden.") print("Erst 'python analyze.py' (ohne --no-download) ausfuehren.") sys.exit(1) print(f"\n-> Fuehre {len(sources)} Logdatei(en) zusammen ...") with open(combined, "wb") as out: for src in sources: if src.suffix == ".gz": with gzip.open(src, "rb") as f: shutil.copyfileobj(f, out) else: with open(src, "rb") as f: shutil.copyfileobj(f, out) return combined def to_wsl_path(win_path): """Uebersetzt einen Windows-Pfad (C:\\...) in einen WSL-Pfad (/mnt/c/...).""" p = Path(win_path).resolve() drive = p.drive.rstrip(":").lower() # "C:" -> "c" rest = p.as_posix()[len(p.drive):] # Rest ohne Laufwerk, mit / return f"/mnt/{drive}{rest}" def goaccess_args(log_format): """Gemeinsame GoAccess-Optionen (ohne Ein-/Ausgabedateien).""" return [ "--log-format=" + log_format, "--anonymize-ip", # DSGVO: IPs im Report anonymisieren "--ignore-crawlers", # Bots aus den Besucherzahlen filtern "--html-report-title=IWZ Ferienwohnungen - Zugriffsstatistik", ] def run_goaccess(mode, goaccess_exe, log_file, report_file, log_format): """Ruft GoAccess auf und erzeugt einen eigenstaendigen HTML-Report. mode="wsl": GoAccess laeuft in der WSL (Standard), Pfade werden nach /mnt/c/... uebersetzt. mode="exe": nativer Windows-Build unter GOACCESS_EXE. """ print(f"\n-> GoAccess ({mode}) -> {report_file.name}") if mode == "wsl": cmd = [ "wsl", "goaccess", to_wsl_path(log_file), "-o", to_wsl_path(report_file), ] + goaccess_args(log_format) not_found_hint = ( "GoAccess ist in der WSL nicht installiert (oder WSL fehlt).\n" " In der WSL installieren: wsl -e sudo apt update ^&^& " "sudo apt install -y goaccess\n" "Siehe auch bin/install_py.bat (prueft WSL + GoAccess) und doc/analyse.md." ) else: cmd = [ goaccess_exe, str(log_file), "-o", str(report_file), ] + goaccess_args(log_format) not_found_hint = ( f"GoAccess-EXE nicht gefunden ('{goaccess_exe}').\n" " Windows-Build laden: https://goaccess.io/download, entpacken,\n" " dann GOACCESS_EXE in bin/.env auf die goaccess.exe setzen." ) try: result = subprocess.run(cmd) except FileNotFoundError: print("\nFEHLER: GoAccess konnte nicht gestartet werden.") print(not_found_hint) sys.exit(1) if result.returncode != 0: print("\nFEHLER: GoAccess fehlgeschlagen.") print("Pruefe die GoAccess-Installation und das Log-Format (LOG_FORMAT).") sys.exit(1) if __name__ == "__main__": print("=== IWZ Nutzungsanalyse ===") no_download = "--no-download" in sys.argv env = {**load_env(env_file()), **os.environ} host = env.get("IONOS_SFTP_HOST") port = int(env.get("IONOS_SFTP_PORT", SFTP_PORT)) user = env.get("IONOS_SFTP_USER") password = env.get("IONOS_SFTP_PASSWORD") remote_dir = env.get("IONOS_LOGS_DIR", "/logs") # GoAccess-Aufruf: "wsl" (Standard) oder "exe" (nativer Windows-Build). goaccess_mode = env.get("GOACCESS_MODE", "wsl").strip().lower() goaccess_exe = env.get("GOACCESS_EXE", "goaccess") # IONOS liefert i.d.R. Apache "Combined Log Format". Bei anderen # Formaten hier anpassen (siehe doc/analyse.md). log_format = env.get("LOG_FORMAT", "COMBINED") if not no_download: if not host or not user or not password: print("\nFEHLER: IONOS_SFTP_HOST / _USER / _PASSWORD fehlen in bin/.env.") print("Siehe bin/.env.example. Oder mit --no-download nur neu auswerten.") sys.exit(1) download_access_logs(host, port, user, password, remote_dir) else: print("\n-> Download uebersprungen (--no-download).") combined = collect_log_lines() RESULTS_DIR.mkdir(exist_ok=True) report_file = RESULTS_DIR / f"report_{date.today().isoformat()}.html" run_goaccess(goaccess_mode, goaccess_exe, combined, report_file, log_format) print(f"\nFertig! Report: {report_file}") webbrowser.open(report_file.as_uri())