"""Rate-Limiting-Modul für Rippy API (pro Client-IP). Der API-Key-Store, der hier früher lebte, ist mit dem Auth-Rückbau (Commander-Entscheid 24.07.2026) entfernt — Heimnetz-only, siehe KONZEPT §10. ## Was diese Bremse ist — und was nicht Sie soll ein Amok-Skript stoppen (Endlosschleife, tausend Anfragen pro Sekunde). Sie ist KEIN Schutz gegen Angreifer; dafür wäre Rippy die falsche Stelle. Daraus folgt die Zahl unten: Sie muss deutlich über dem liegen, was Rippy im Normalbetrieb selbst verursacht. Am 26.07.2026 tat sie das nicht — und die Folgen waren dem Commander als „wird oft neu geladen" aufgefallen. """ import ipaddress import time from collections import defaultdict from typing import Dict # Wie viele Anfragen pro Minute und Client durchgehen. # # ⚠️ Bis zum 26.07.2026 stand hier 100 — WENIGER, als das eigene Dashboard # braucht. Nachgerechnet an den tatsächlichen Taktgebern im UI: # # Dashboard (Dashboard.tsx) 5 Endpunkte alle 4 s → 75/min # Log-Kasten (LiveLogSection.tsx) 2 Endpunkte alle 5 s → 24/min # Laufwerke (DeviceDiscovery.tsx) 1 Endpunkt alle 5 s → 12/min # Windows-Tray (tray.py) /jobs alle 5 s → 12/min je Worker # ───────── # EIN offener Tab plus ein Worker 123/min # # Das Limit war also im Normalbetrieb um ein Viertel überschritten: Etwa jede # vierte Anfrage bekam 429, und weil das UI einen Fehlschlag damals als „es gibt # keine Jobs" verbuchte, leerte sich die Liste im Sekundentakt. Zwei offene Tabs # hätten es verdoppelt. # # 600/min = 10 Anfragen pro Sekunde: Platz für mehrere Tabs und Worker, während # eine Endlosschleife (Hunderte pro Sekunde) weiterhin sofort gebremst wird. MAX_REQUESTS_PER_MINUTE = 600 # In-Memory Rate Limit Store (in Produktion mit Redis) rate_limit_store: Dict[str, list] = defaultdict(list) def _ist_privat(adresse: str) -> bool: """Steckt hinter dieser Adresse das eigene Netz (bzw. ein Container)?""" try: ip = ipaddress.ip_address(adresse) except ValueError: return False return ip.is_private or ip.is_loopback def client_kennung(peer: str, weitergegeben: str = None) -> str: """Welcher Eimer gilt für diese Anfrage? (pure Funktion, testbar) `peer` ist der direkte Absender, `weitergegeben` der Inhalt von `X-Real-IP` (setzt der nginx im UI-Container, siehe ui/nginx.conf). Warum überhaupt: Der Browser spricht nie direkt mit der API, sondern über den nginx — für die API sah deshalb JEDE Anfahrt aus dem UI gleich aus. Ein zweiter Tab und der Windows-Tray teilten sich den Eimer mit dem Dashboard, obwohl es drei unabhängige Clients sind. Der Kopfzeile wird nur geglaubt, wenn der direkte Absender aus dem privaten Netz kommt — also unser eigener Proxy. Das ist keine Härtung gegen Angreifer (Rippy ist Heimnetz-only, KONZEPT §10), sondern verhindert, dass eine beliebige Kopfzeile die Bremse aushebelt. """ peer = (peer or "").strip() kandidat = (weitergegeben or "").split(",")[0].strip() if kandidat and _ist_privat(peer) and _ist_privat(kandidat): return kandidat return peer or "unbekannt" # Wann wurde für einen Client zuletzt eine Rate-Limit-Meldung geschrieben? _letzte_meldung: Dict[str, float] = {} # Abstand zwischen zwei Meldungen pro Client. MELDE_ABSTAND_SEKUNDEN = 60 def darf_melden(client_id: str, jetzt: float = None) -> bool: """Soll dieser abgewiesene Aufruf ins Log? Höchstens einmal pro Minute. Ein 429 war bisher völlig unsichtbar — das UI verbuchte ihn als „nichts da", und niemand erfuhr, dass die Bremse greift. Jede Abweisung zu protokollieren wäre aber die Ecke ins Gegenteil: Genau der Fall, für den die Bremse gebaut ist (ein Skript in einer Endlosschleife), würde damit das Log-Fenster zumüllen. Also: eine Meldung pro Client und Minute. """ if jetzt is None: jetzt = time.time() vorher = _letzte_meldung.get(client_id, 0.0) if jetzt - vorher < MELDE_ABSTAND_SEKUNDEN: return False _letzte_meldung[client_id] = jetzt return True def check_rate_limit(client_id: str, max_requests: int = MAX_REQUESTS_PER_MINUTE, window_seconds: int = 60) -> bool: """Prüfe ob Client rate-limited ist.""" current_time = time.time() window_start = current_time - window_seconds # Bereinige alte Einträge rate_limit_store[client_id] = [ timestamp for timestamp in rate_limit_store[client_id] if timestamp > window_start ] # Prüfe ob Limit erreicht if len(rate_limit_store[client_id]) >= max_requests: return False # Füge neuen Request hinzu rate_limit_store[client_id].append(current_time) return True def get_rate_limit_remaining(client_id: str, max_requests: int = MAX_REQUESTS_PER_MINUTE) -> int: """Hole verbleibende Requests.""" current_time = time.time() window_start = current_time - 60 current_count = len([ timestamp for timestamp in rate_limit_store[client_id] if timestamp > window_start ]) return max(0, max_requests - current_count) def reset_rate_limit(client_id: str) -> None: """Setze Rate Limit für Client zurück.""" rate_limit_store[client_id] = []