bfb13f44a5
Ampel / ampel (push) Successful in 30s
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>
139 lines
5.3 KiB
Python
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] = []
|