Files
rippy/docker/api/ratelimit.py
T
Hitonabi bfb13f44a5
Ampel / ampel (push) Successful in 30s
fix(ui+api): das "Neuladen" war HTTP 429 - und "Neu" komprimierte immer
Zwei Commander-Befunde, beide mit derselben Wurzel: Rippy hat sich selbst
ausgebremst und dann geschwiegen.

## "Wenn der Worker installiert ist, wird dieser Bereich oft neu geladen"

Gemessen statt geraten. Die Antworten von /jobs und /capabilities waren ueber
zwanzig Sekunden byteweise identisch, alle Endpunkte antworteten unter 30 ms -
es wurde also gar nichts neu geladen. Im nginx-Log standen dagegen 97 Antworten
mit HTTP 429.

Drei Fehler griffen ineinander:

1. Das Limit war zu klein fuer Rippy selbst: 100 Anfragen/min, waehrend ein
   offener Tab 111/min verursacht (Dashboard 75 + Log-Kasten 24 + Laufwerke 12)
   und der Windows-Tray weitere 12/min dazulegt.
2. Der nginx gab die Client-Adresse nicht weiter. Fuer die API kam damit ALLES
   von 172.19.0.6 - Browser, zweiter Tab und Tray teilten sich einen Eimer
   (812 von 876 Anfragen). Das erklaert die Kopplung an den Worker: tray.py
   fragt /api/jobs ueber Port 80, also durch denselben Proxy.
3. Ein abgewiesener Abruf leerte das UI. `catch(() => [])` heisst "es gibt
   keine Jobs" - richtig waere "ich weiss gerade nichts Neues". Fuer einen Takt
   stand "Keine Jobs", die Zaehler sprangen auf (0), vier Sekunden spaeter war
   alles zurueck.

Behoben: X-Real-IP im nginx, Grenze auf 600/min mit vorgerechneter Herleitung,
jeder Fehlschlag laesst den alten Stand stehen (null statt []), axios bekommt
eine Zeitgrenze, und das Dashboard trennt schnelle Daten (Jobs/Laufwerke, 4 s)
von langsamen (Hardware/Worker/Ablagen, 12 s) - 75/min werden zu 30/min.
Ein greifendes Limit steht ab jetzt im Log, gedrosselt auf eine Meldung pro
Client und Minute.

## "Hier gibt es den Button 'neu' aber WAS wird dann gemacht?"

Immer die Komprimierung - auch bei einem Job, dessen RIP abgebrochen war. Am
26.07.2026 waeren aus 5,1 GB Bruchstueck (von rund 40 GB) brav ein Film
geworden, der bei 12 % aufhoert.

Die Phase war nach `status = "failed"` nicht mehr feststellbar, also wird sie
jetzt vermerkt (rip_fertig in den Job-Metadaten: false beim Rip-Start, true bei
der Uebergabe an die Kompression). Daraus folgt die Beschriftung: "Neu
komprimieren", "Neu rippen" - oder bei Bestandsjobs ohne Vermerk ein Dialog,
der beide Wege erklaert und die Groesse der Rohdaten als Entscheidungshilfe
nennt. Geraten wird nicht. Fuer den Rip-Fall gibt es POST
/jobs/{id}/retry-rip: neuer Job mit neuer ID (sonst laege das Bruchstueck im
Roh-Verzeichnis des neuen Rips), Titel/Ablage/Sprachwahl uebernommen, mit
ehrlicher Absage wenn keine Disc im Laufwerk liegt.

10 neue Tests (289 gruen), darunter eine Kopplungspruefung: der Name der
Phasen-Marke muss in worker/tasks.py und api/phasen.py zusammenpassen - genau
diese Sorte Auseinanderdriften hat die Zombie-Erkennung ein Release lang blind
gemacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:49:10 +02:00

139 lines
5.3 KiB
Python

"""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] = []