Skripte nach bin/ und lib/ umstrukturiert + WSL-Log-Analyse

Alle .bat-Skripte liegen jetzt in bin/, alle .py in lib/, .env(.example)
in bin/. setenv.bat setzt IWZ_* (inkl. IWZ_ENV) und PYTHONPATH; die
Launcher rufen setenv, aktivieren die .venv falls vorhanden und starten
das zugehoerige lib/-Skript. Die Python-Skripte lesen die .env ueber
IWZ_ENV (Fallback bin/.env).

Neu: Nutzungsanalyse per GoAccess (analyze.py/.bat) - laedt die
IONOS-Access-Logs per SFTP (paramiko) und erzeugt einen HTML-Report.
GoAccess laeuft ueber WSL (GOACCESS_MODE=wsl, Pfad-Uebersetzung nach
/mnt/c). install_py.bat prueft WSL+GoAccess und zeigt sonst den
Installationsweg. Doku in doc/analyse.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Michael Stangl
2026-08-28 16:44:56 +02:00
parent 025e68b3b9
commit 2379f4ac9f
21 changed files with 760 additions and 59 deletions
+226
View File
@@ -0,0 +1,226 @@
#!/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())
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""
IWZ Ferienwohnungen - Deploy Script
Baut die Hugo-Seite und deployt sie per FTP zur Bunny.net Storage Zone.
"""
import ftplib
import os
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent # Skript liegt in lib/
PUBLIC_DIR = PROJECT_ROOT / "public"
FTP_PORT = 21
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"
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 hugo_build():
print("\n-> Hugo Build...")
result = subprocess.run("hugo --gc --minify", shell=True)
if result.returncode != 0:
print("\nFEHLER: Hugo Build fehlgeschlagen!")
sys.exit(1)
def ftp_upload_dir(ftp, local_dir, remote_dir=""):
for entry in sorted(local_dir.iterdir()):
remote_path = f"{remote_dir}/{entry.name}" if remote_dir else entry.name
if entry.is_dir():
try:
ftp.mkd(remote_path)
except ftplib.error_perm:
pass # existiert bereits
ftp_upload_dir(ftp, entry, remote_path)
else:
with open(entry, "rb") as f:
ftp.storbinary(f"STOR {remote_path}", f)
print(f" {remote_path}")
def deploy_to_bunny(host, zone, password):
print(f"\n-> Deploy zu Bunny.net Storage Zone '{zone}'...")
with ftplib.FTP() as ftp:
ftp.connect(host, FTP_PORT)
ftp.login(zone, password)
ftp.set_pasv(True)
ftp_upload_dir(ftp, PUBLIC_DIR)
def purge_cdn_cache(pull_zone_url, api_key):
"""Leert den Bunny CDN-Cache der Pull Zone per Wildcard-URL-Purge.
Ohne Purge liefert das CDN unveraenderte Dateinamen (z.B. /css/style.css)
weiter aus dem Cache -> online sieht man die alte Version.
"""
if not pull_zone_url or not api_key:
print("\n-> CDN-Purge uebersprungen "
"(BUNNY_PULL_ZONE_URL / BUNNY_API_KEY nicht gesetzt).")
print(" Cache ggf. manuell im Bunny-Dashboard leeren.")
return
wildcard = pull_zone_url.rstrip("/") + "/*"
query = urllib.parse.urlencode({"url": wildcard, "async": "false"})
url = f"https://api.bunny.net/purge?{query}"
print(f"\n-> CDN-Cache leeren: {wildcard}")
req = urllib.request.Request(url, method="POST")
req.add_header("AccessKey", api_key)
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req, timeout=60) as resp:
if resp.status == 200:
print(" Cache geleert.")
else:
print(f" WARNUNG: unerwarteter Status {resp.status}")
except urllib.error.HTTPError as e:
print(f" WARNUNG: Purge fehlgeschlagen (HTTP {e.code}). "
"Ist BUNNY_API_KEY der Account-API-Key?")
except urllib.error.URLError as e:
print(f" WARNUNG: Purge fehlgeschlagen ({e.reason}).")
if __name__ == "__main__":
print("=== IWZ Deploy ===")
env = {**load_env(env_file()), **os.environ}
zone = env.get("BUNNY_STORAGE_ZONE")
host = env.get("BUNNY_STORAGE_HOST", "storage.bunnycdn.com")
password = env.get("BUNNY_STORAGE_PASSWORD")
pull_zone_url = env.get("BUNNY_PULL_ZONE_URL")
api_key = env.get("BUNNY_API_KEY")
if not zone or not password:
print("\nFEHLER: BUNNY_STORAGE_ZONE / BUNNY_STORAGE_PASSWORD fehlen.")
print("Siehe bin/.env.example -> bin/.env kopieren und befuellen.")
sys.exit(1)
hugo_build()
deploy_to_bunny(host, zone, password)
purge_cdn_cache(pull_zone_url, api_key)
print("\nFertig! Seite ist live.")
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
IWZ Ferienwohnungen - Deploy nur geaenderter Dateien
Baut die Hugo-Seite und laedt per FTP zur Bunny.net Storage Zone nur die
Dateien hoch, die sich seit dem letzten Deploy mit diesem Skript geaendert
haben (Vergleich per Datei-Hash gegen ein lokales Manifest).
"""
import ftplib
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent # Skript liegt in lib/
PUBLIC_DIR = PROJECT_ROOT / "public"
MANIFEST_PATH = PROJECT_ROOT / ".deploy_manifest.json"
FTP_PORT = 21
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"
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 hugo_build():
print("\n-> Hugo Build...")
result = subprocess.run("hugo --gc --minify", shell=True)
if result.returncode != 0:
print("\nFEHLER: Hugo Build fehlgeschlagen!")
sys.exit(1)
def hash_file(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def scan_public():
manifest = {}
for entry in PUBLIC_DIR.rglob("*"):
if entry.is_file():
rel = entry.relative_to(PUBLIC_DIR).as_posix()
manifest[rel] = hash_file(entry)
return manifest
def load_manifest():
if not MANIFEST_PATH.exists():
return {}
return json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
def save_manifest(manifest):
MANIFEST_PATH.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")
def diff_manifest(old, new):
changed = [rel for rel, digest in new.items() if old.get(rel) != digest]
deleted = [rel for rel in old if rel not in new]
return sorted(changed), sorted(deleted)
def ftp_mkdirs(ftp, remote_dir):
parts = remote_dir.split("/")
path = ""
for part in parts:
if not part:
continue
path = f"{path}/{part}" if path else part
try:
ftp.mkd(path)
except ftplib.error_perm:
pass # existiert bereits
def ftp_upload_files(ftp, rel_paths):
for rel in rel_paths:
local_path = PUBLIC_DIR / rel
remote_dir = "/".join(rel.split("/")[:-1])
if remote_dir:
ftp_mkdirs(ftp, remote_dir)
with open(local_path, "rb") as f:
ftp.storbinary(f"STOR {rel}", f)
print(f" {rel}")
def ftp_delete_files(ftp, rel_paths):
for rel in rel_paths:
try:
ftp.delete(rel)
print(f" gelöscht: {rel}")
except ftplib.error_perm as e:
print(f" konnte nicht löschen: {rel} ({e})")
def deploy_to_bunny(host, zone, password, changed, deleted):
print(f"\n-> Deploy zu Bunny.net Storage Zone '{zone}'...")
with ftplib.FTP() as ftp:
ftp.connect(host, FTP_PORT)
ftp.login(zone, password)
ftp.set_pasv(True)
if changed:
print(f"\n-> Lade {len(changed)} geänderte Datei(en) hoch:")
ftp_upload_files(ftp, changed)
if deleted:
print(f"\n-> Entferne {len(deleted)} gelöschte Datei(en):")
ftp_delete_files(ftp, deleted)
if __name__ == "__main__":
print("=== IWZ Deploy (nur Änderungen) ===")
env = {**load_env(env_file()), **os.environ}
zone = env.get("BUNNY_STORAGE_ZONE")
host = env.get("BUNNY_STORAGE_HOST", "storage.bunnycdn.com")
password = env.get("BUNNY_STORAGE_PASSWORD")
if not zone or not password:
print("\nFEHLER: BUNNY_STORAGE_ZONE / BUNNY_STORAGE_PASSWORD fehlen.")
print("Siehe bin/.env.example -> bin/.env kopieren und befuellen.")
sys.exit(1)
hugo_build()
old_manifest = load_manifest()
new_manifest = scan_public()
changed, deleted = diff_manifest(old_manifest, new_manifest)
if not changed and not deleted:
print("\nKeine Änderungen seit dem letzten Deploy. Nichts hochzuladen.")
sys.exit(0)
deploy_to_bunny(host, zone, password, changed, deleted)
save_manifest(new_manifest)
print("\nFertig! Änderungen sind live.")
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
IWZ Ferienwohnungen - erzeugt einen befristeten, signierten Vorschau-Link
fuer die per Bunny Token Authentication geschuetzte Pull Zone.
"""
import base64
import hashlib
import os
import sys
import time
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent # Skript liegt in lib/
GUELTIGKEIT_SEKUNDEN = 7 * 24 * 60 * 60 # 7 Tage
URL_PATH = "/"
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"
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 build_signed_url(pull_zone_url, auth_key, url_path, expires):
hashable = f"{auth_key}{url_path}{expires}"
token_hash = hashlib.sha256(hashable.encode()).digest()
token = base64.urlsafe_b64encode(token_hash).decode().rstrip("=")
return f"{pull_zone_url}{url_path}?token={token}&expires={expires}"
if __name__ == "__main__":
env = load_env(env_file())
pull_zone_url = env.get("BUNNY_PULL_ZONE_URL", "").rstrip("/")
auth_key = env.get("BUNNY_AUTH_KEY")
if not pull_zone_url or not auth_key:
print("\nFEHLER: BUNNY_PULL_ZONE_URL / BUNNY_AUTH_KEY fehlen in bin/.env.")
print("Siehe .env.example. Auth Key: Pull Zone -> Security -> Token Authentication.")
sys.exit(1)
expires = int(time.time()) + GUELTIGKEIT_SEKUNDEN
link = build_signed_url(pull_zone_url, auth_key, URL_PATH, expires)
gueltig_bis = time.strftime("%d.%m.%Y %H:%M", time.localtime(expires))
print("\n=== Vorschau-Link (Bunny Token Authentication) ===\n")
print(link)
print(f"\nGueltig bis: {gueltig_bis}\n")