Compare commits

..

27 Commits

Author SHA1 Message Date
Sucukdeluxe
a8c4dcc69b Release v1.7.232 2026-06-23 16:13:49 +02:00
Sucukdeluxe
93a85a0255 HTTP 416: erschoepftes Range-Konflikt-Item macht bounded Clean-Restart statt dauerhaft zu haengen
Nutzer-Report (ultracode-Diagnose, 6-Agenten): ein Item mit "Fehler: HTTP 416"
(Range Not Satisfiable) wurde gar nicht mehr erneut versucht und blockierte das
ganze Paket ("Entpacken - Ausstehend" bei allen fertigen Geschwistern). Befund
code-belegt: nach dem In-Run-416-Budget (maxHttp416Retries) lief das Item in
einen Endzustand:
- retryLimit>0 (bare "HTTP 416", directLinkRetryMatch=false): direkt terminal
  "failed" (download-manager ~9442). Der einzige Re-Retry-Pfad
  recoverRetryableItems() laeuft NUR bei Boot/Start, nie mid-run -> mit
  autoReconnect=false bleibt es bis zum Neustart "failed". Und da jeder
  Extract-Zweig auf failed===0 gated, friert EIN failed-Item das ganze Paket
  ein -> exakt das Screenshot-Symptom.
- retryLimit=0 (wrapped "direct_link_retry_exhausted:HTTP 416"): faellt nach dem
  416-Budget in den generischen Retry (9349) mit maxGenericErrorRetries=
  MAX_SAFE_INTEGER -> unbegrenzte futile Re-Unrestricts (haemmert den Hoster,
  Item kommt nie weiter).

Fix: bei erschoepftem 416-Budget an BEIDEN Stellen (gewrappt @9320, bare @9442)
nicht mehr terminalisieren bzw. endlos generisch retrien, sondern via neuem
escalateHttp416OrFail() einen kompletten Neu-Download von 0 anstossen: Partial
loeschen (rmSync), Bytes/Progress/Budget zuruecksetzen, mit Delay re-queuen. Da
416 ein abgelehnter Resume-Range ist, ist der korrekte Weg ein frischer
Download ohne Range-Header (kein 416 mehr) statt erneuter Resume. Hart gedeckelt
ueber http416FreshRestartByItem (MAX_HTTP416_FRESH_RESTARTS=2, retryLimit-
unabhaengig) -> ein wirklich toter Link faellt nach begrenzten sauberen
Neustarts terminal, kein Endlos-Hammering. Zaehler wird in start() und beim
terminalen Fail geleert. Fresh-Restart-Delay via RD_HTTP416_FRESH_RESTART_DELAY_MS
testbar.

Sobald das Item den failed-Zustand verlaesst, entsperrt die bestehende
failed===0-Logik das Paket-Entpacken von selbst. (Die optionale Haertung
"fertige Archiv-Sets trotz failed-Geschwister entpacken" + "Voll-Datei-416 als
fertig werten statt neu laden" sind bewusst als Folgeschritt offen.)

Tests: Recovery (416 erschoepft -> Clean-Restart -> Item completed, retryLimit>0
bare) + Bounded-Fail (persistentes 416 -> begrenzte Neustarts -> failed,
retryLimit=0 wrapped, terminiert statt Endlos). Suite 945 gruen, tsc=6.
2026-06-23 16:12:52 +02:00
Sucukdeluxe
22926d63e3 Release v1.7.231 2026-06-23 13:43:04 +02:00
Sucukdeluxe
88993bccaa Backup-Export: Standard-Dateiname auf TT-MM-JJJJ-mdd-backup.mdd umgestellt
Bisher schlug der Speichern-Dialog "mdd-backup-2026-06-23.mdd" vor (Jahr-Monat-
Tag). Auf Nutzer-Wunsch jetzt "23-06-2026-mdd-backup.mdd" (Tag-Monat-Jahr vorn).
Gleiche UTC-Datumsquelle, nur Reihenfolge gedreht und das Datum nach vorn.
2026-06-23 13:42:16 +02:00
Sucukdeluxe
72b081d749 Release v1.7.230 2026-06-22 03:33:40 +02:00
Sucukdeluxe
c541f1cc92 Mega-Debrid: nicht mehr nach 3 transienten Leer-Antworten faelschlich aufs Tageslimit sperren
Live-Ferndiagnose zeigte: beide Mega-Accounts wurden nach ~363 Downloads
(~800GB) "bis zum Tagesreset" gesperrt, obwohl das echte Limit hoeher liegt
(Nutzer: frueher ~1.3-1.4TB ueber Mega-Web). Ursache: die Web-Aufloesung
liefert eine leere Antwort, wenn Rapidgator gerade keinen Server hat (transient)
ODER ein Link offline/tot ist - beides per-Link/per-Hoster, KEIN Account-
Tageslimit. Der Account loeste zwischendurch nachweislich weiter Links auf
(result=OK), war also nicht erschoepft. Schon 3 solcher Leer-Antworten in Folge
eskalierten aber zur harten 21h-Sperre ("Tageslimit erreicht"), die dann die
ganze Liste lahmlegte und den Account ~500GB vor seinem echten Limit stoppte.

Fix: die Schwelle fuer die "bis zum Tagesreset"-Sperre von 3 auf 10 aufeinander-
folgende Leer-Antworten angehoben. Unterhalb der Schwelle greift weiter der
bestehende 20s-Cooldown (schnelles Re-Probe), und jede erfolgreiche Aufloesung
setzt die Zaehlung zurueck - so ueberstehen verstreute transiente Leer-Antworten
bzw. einzelne tote Links die Sperre nicht mehr, und der Account laeuft bis zu
seinem echten Limit. Eine wirklich anhaltende Leere (10x ohne einen Erfolg
dazwischen = echte Erschoepfung) sperrt weiterhin bis zum Tagesreset.

OFFEN (Folgeschritt, bewusst getrennt): tote/offline Rapidgator-Links auf dem
Provider-Ketten-Pfad praezise als "Link offline" kennzeichnen statt als Hoster-/
Tageslimit, und fuer reine Per-Link-Fehler den Account gar nicht erst kuehlen
(braucht Per-Link-Kontext quer durch die Aufloesungs-Kette).
2026-06-22 03:33:00 +02:00
Sucukdeluxe
3acaf178ed Release v1.7.229 2026-06-22 02:37:47 +02:00
Sucukdeluxe
8e0ae77aee Mega-Debrid: schnelle API-Aufloesung darf langsame Web-Aufloesung ueberlappen (Single-Account-Durchsatz)
Bei nur einem Mega-Debrid-Account war die Link-Aufloesung global seriell:
getSerializedValidatingLimit = max(1, nutzbare Accounts) = 1. Da ~die Haelfte
der Rapidgator-Links von der Mega-API faelschlich als "Fichier supprimé"
abgelehnt wird (bestaetigt: die offizielle Mega-Debrid-Download-Station-
Integration nutzt dieselbe getLink-API voellig ohne Fallback), fallen sie auf
den Web-Pfad, der pro Account zwingend single-flight ist (eine Web-Session =
ein Request). Eine langsame/haengende Web-Aufloesung (live 11s gesehen, im
Extremfall bis zum 60s-Timeout) hielt so den einzigen Aufloesungs-Slot und
liess die Download-Slots leerlaufen (live gemessen: active faellt von 8 auf 2,
Tempo von ~183 auf 71 MB/s, obwohl hunderte Items warteten).

Fix: der Scheduler erlaubt jetzt pro Account EINE zusaetzliche gleichzeitige
Aufloesung, solange bereits eine im (langsamen) Web-Pfad steckt - so zieht eine
schnelle API-Aufloesung (~0.6s) an einer haengenden Web-Aufloesung vorbei statt
dahinter zu warten. Es entstehen NIE zwei gleichzeitige API-Aufrufe pro
Account: die Aufweitung greift nur, wenn megaDebridInFlight im Web-Modus aktiv
ist (der erste also nicht mehr in der API-Phase), und der synchron in startItem
gesetzte validating-Status begrenzt die Gesamtzahl race-frei auf 1 API + 1 Web
pro Account. Skaliert mit mehr Accounts auf N+N - der sauberste Hebel bleibt,
weitere Accounts hinzuzufuegen (parallele Web-Queues).

getMegaDebridInFlightCountForMode liest die bestehenden :web-Zaehler;
shouldDelayStartForItem nutzt sie nur zum Aufweiten der Obergrenze, nie zum
Verschaerfen.

Tests: 4 Faelle (Erststart frei / kein zweiter API waehrend API-Phase /
Overlap sobald Web-Phase aktiv / Deckel bei 1 API + 1 Web).
2026-06-22 02:37:03 +02:00
Sucukdeluxe
ce573fe7b6 Release v1.7.228 2026-06-21 23:19:18 +02:00
Sucukdeluxe
39fbc54818 Fix: Start-Konflikt-Waechter friert nach Update nicht mehr die ganze Liste ein
Nach einem Auto-Update-Neustart blieb die komplette Warteschlange stehen
(running=false, hunderte Items wartend), obwohl Auto-Resume aktiv war.
Ursache war der Start-Konflikt-Waechter:

- getStartConflicts() meldete jedes Paket als Konflikt, dessen
  paketspezifischer Entpack-Ordner bereits Dateien enthielt. Ein nur
  teilweise heruntergeladenes Paket hat dort aber seine EIGENEN fertigen
  Teile liegen (z.B. 15 von 26 fertig und entpackt) - das ist kein
  Konflikt, sondern Wiederaufnahme.
- Der Auto-Resume-Waechter war binaer: ein einziger gemeldeter Konflikt
  hat ALLE Pakete blockiert ("Auto-Resume uebersprungen: Start-Konflikte
  erkannt"). Zwei teilfertige Pakete haben so 30 Pakete eingefroren.

Fix:
- Diskriminator in getStartConflicts(): ein Paket mit eigenen fertigen
  ("completed") Items zaehlt nicht mehr als Konflikt - die Dateien im
  Entpack-Ordner sind seine eigene Ausgabe, kein Fremdbestand.
- Selektives Auto-Resume: start() akzeptiert excludePackageIds und laesst
  diese Pakete aus dem Lauf-Set (runPackageIds, auf das der Scheduler
  filtert). beginAutoResume() startet die sauberen Pakete und haelt nur
  echte Konflikte zurueck, statt alles zu blockieren; die zurueckgehaltenen
  Pakete werden im Log namentlich genannt.

Die beiden Aenderungen greifen ineinander: der Diskriminator garantiert,
dass ein als Konflikt gemeldetes Paket keine fertigen Teile hat, also auch
keine ausstehende Nachbearbeitung - das defensive runPackageIds.delete kann
daher keine Entpack-Arbeit haengen lassen.

Tests: Diskriminator (eigene Ausgabe nicht geflaggt / Fremdbestand
geflaggt) plus selektives Resume (ausgeschlossenes Paket bleibt aus dem
Lauf-Set, laeuft nicht an).
2026-06-21 23:17:56 +02:00
Sucukdeluxe
d73b446d68 Release v1.7.227 2026-06-21 21:42:08 +02:00
Sucukdeluxe
5bb04cb920 Test: /providers + /logs/conversion ueber authentifiziertes HTTP
Schliesst die Coverage-Luecke auf dem eigentlichen 227-Deliverable: startet den
Debug-Server, ruft /providers (200, Snapshot-Shape: capturedAtMs, megaDebrid.accounts,
rotationCursor, debridLink.keys) und /logs/conversion (200, lines[], available) ab und
prueft, dass /providers ohne Token 401 liefert. Beweist Endpoint-Wiring + Auth +
Serialisierung + den debrid->debug-server-Import zur Laufzeit.
2026-06-21 21:41:35 +02:00
Sucukdeluxe
4543ac3c1a Ferndiagnose (MCP) erweitert: Live-Provider-Laufzeitzustand + Conversion-Log
Motiviert durch die Mega-Debrid-Cooldown-Diagnose: der account-weite Cooldown musste
aus Log-Arithmetik rekonstruiert werden (alle Fehlerzeilen zeigten ms-genau auf
dieselbe Deadline). Genau diese Sicht-Luecke wird geschlossen.

Neu:
- GET /providers (debrid.ts getProviderRuntimeSnapshot): Live-Laufzeitzustand pro
  Mega-Account / Debrid-Link-Key — AKTIVER Cooldown (untilMs/remainingMs/Grund/
  Kategorie/untilRestart), in-flight-Tiefe, Mega-Rotationscursor + Sticky-Count,
  Empty-Response-Streaks, Debrid-Link Key-/Host-Cooldowns + Runtime-Status. Account-
  Keys sind nicht-umkehrbare Hashes (mda_<fnv1a64>), keine Logins/Tokens.
- GET /logs/conversion: der pro-Item Link-Aufloesungs-Lebenszyklus (Token, API
  getLink, Web, Account-Rotation, Abbrueche mit Zeiten) aus conversion.log.
- Beide auch im /diagnostics-Aggregat (providers + logs.conversion) -> rd_diagnostics
  zeigt die Cooldowns jetzt direkt.
- MCP-Bridge: neues Tool rd_providers + "conversion" in der rd_logs-Enum + README.

Test: getProviderRuntimeSnapshot spiegelt einen geprimten Account-Cooldown
(until/remaining/Grund) und ist vorher null. Suite 935 gruen, tsc 6.
2026-06-21 21:37:52 +02:00
Sucukdeluxe
471e40b87f Release v1.7.226 2026-06-21 21:22:41 +02:00
Sucukdeluxe
26df55f7ba Fix: Mega-Debrid Einzel-Account friert bei 60s-Timeout nicht mehr die ganze Liste ein
Symptom (per Ferndiagnose live verifiziert): Bei nur EINEM Mega-Debrid-Account lief
der Download eine Weile sauber, dann standen schlagartig ALLE Items ~120s im
"Mega-Debrid Cooldown" — obwohl der Account voellig gesund war (andere Links loesten
zeitgleich in 13-18s auf). Jede Cooldown-Zeile zeigte exakt dieselbe Deadline
(20:52:55.901) -> ein einziger account-weiter Cooldown, einmal gesetzt.

Ursache: Laeuft eine Web-Aufloesung laenger als das 60s-Gesamt-Timeout, feuert der
Abbruch-Pfad (debrid.ts) einen 120s-Account-Cooldown. Dessen einziger Zweck (laut
Code-Kommentar) ist, den Retry auf den NAECHSTEN Account rotieren zu lassen. Bei nur
einem Account gibt es keinen naechsten -> stattdessen findet jedes folgende Item den
einzigen Account im Cooldown und wird bis zu 120s geparkt -> die ganze Liste steht.
Ein 60s-Timeout ist ein Signal fuer einen LANGSAMEN LINK, nicht fuer einen
ungesunden Account.

Fix: Der Account-Cooldown wird nur noch gesetzt, wenn es tatsaechlich einen anderen
nutzbaren Account zum Rotieren gibt. Ohne Rotationsziel (Einzel-Account / alle
anderen belegt) wird der Account NICHT mehr eingefroren; stattdessen wird nur der
langsame Link selbst geparkt (mega_debrid_slow_link -> Item-Retry), waehrend alle
anderen Items weiter ueber den gesunden Account laufen. Das Mehr-Account-Verhalten
(Rotation per Account-Cooldown) bleibt unveraendert.

Der baugleiche Debrid-Link-Pfad (Einzel-Key, debrid.ts) ist derselbe Muster-Typ,
aber ein separater, hier nicht genutzter Provider mit eigener Nachbehandlung -
bewusst nicht mitgebuendelt.

Tests: debrid.test.ts (Einzel-Account-Abbruch parkt nur den Link, KEIN
Account-Cooldown, zweites Item loest weiter auf) + unrestrict-retry.test.ts
(parseMegaDebridSlowLinkRetry, keine Token-Kollision). Suite 934 gruen, tsc 6.
2026-06-21 21:21:34 +02:00
Sucukdeluxe
d594c5082b Release v1.7.225 2026-06-19 22:42:42 +02:00
Sucukdeluxe
6cde08dac3 Fix: Komplett leere Liste nach unsauberem Neustart (Datenverlust bei Stromausfall/Crash waehrend Download)
Ursache: Session-Writes (writeFile + atomic rename) liefen ohne fsync. Waehrend eines
Downloads feuert persistSoon alle 700ms-3s, die Session-Datei bleibt damit dauerhaft
dirty im OS-Cache. Bei hartem Stromausfall auf NTFS ist die rename-Metadatentransaktion
journaled (durable), aber die Datenbloecke der temp-Datei sind nicht geflusht -> primary
zeigt nach Reboot auf Null/Garbage. Die .bak-Kopie stammt per copyFileSync aus einer
ebenfalls ungeflushten primary -> ebenfalls korrupt. loadSession faellt durch
primary -> bak -> temp auf emptySession() durch, und der naechste persistSoon speichert
diese leere Session ueber die Platte -> dauerhaft leer. Tritt nur bei UNSAUBEREM Neustart
auf (sauberes Beenden flusht ohnehin).

Fix in drei Schichten:
- Durable atomic write: temp wird vor dem rename gefsynct. Reihenfolge zwingend
  write -> fsync -> close -> rename (NTFS kann eine Datei mit offenem Handle nicht renamen).
  Sync-Pfad via openSync/writeSync/fsyncSync/closeSync, Async-Pfad via FileHandle.sync()
  (laeuft auf dem libuv-Threadpool, blockiert den Hot-Path nicht). Kein Throttle: ein
  throttle-skip wuerde eine ungeflushte temp ueber die durable primary renamen und das
  Korruptionsfenster wieder oeffnen.
- Read-Retry: readSessionFile wiederholt bei transienten Sperren (EBUSY/EPERM/EAGAIN, z.B.
  Virenscanner/Disk-not-ready beim Boot) 5x mit Backoff. EACCES und JSON-Parse-Fehler
  werden nicht wiederholt.
- Empty-Clobber-Guard: loadSessionWithStatus meldet, ob alle Tiers unlesbar waren (Status
  empty-unreadable). In dem Fall blockiert der DownloadManager das Speichern einer leeren
  Session ueber vorhandene Daten, bis wieder echte Daten vorliegen; die erste nicht-leere
  Speicherung hebt den Schutz auf.

Tests: tests/session-restart-loss.test.ts um Status-Klassifizierung, fsync-Nachweis,
async-Roundtrip (close-before-rename), EBUSY-Retry und Guard-Clear-Pfad erweitert.
Suite 929 gruen, tsc unveraendert bei 6 Baseline-Fehlern.
2026-06-19 22:42:05 +02:00
Sucukdeluxe
8f3681b160 Release v1.7.224 2026-06-19 19:49:22 +02:00
Sucukdeluxe
d9657a5459 Ferndiagnose-Backup: auch Full-Backup-Import schreibt die MCP-Dateien
Advisor-Fund: der Settings-only-Import stellte die Ferndiagnose-Einstellungen wieder
her, der Full-Backup-Import (Download-Liste mitgesichert) aber NICHT — er liess die
debug_*-Dateien unangetastet, und der nachfolgende Relaunch bootete mit den alten
Werten. Damit waere "MCP-Settings im Backup" auf dem Full-Backup-Pfad still gescheitert.

Fix: restoreMcpRemoteFromBackup nimmt jetzt restartNow. Settings-only ruft mit true
(laufenden Server sofort neu laden), Full-Backup mit false (nur Dateien schreiben — der
Relaunch-Boot liest sie via startDebugServer). Beide Pfade tragen die Sektion jetzt.

Zusatz-Tests: normalizeSettings bewahrt backupIncludeMcp (der Dreh- und Angelpunkt —
ohne das wuerde der Schalter bei jedem Save auf false zuruecksetzen und das Feature
tot sein); Full-Backup-Write-Pfad schreibt debug_host/port/allowlist auf Platte ohne
Restart (In-Memory bleibt stale → Boot uebernimmt). Suite 919 gruen, tsc=6.
2026-06-19 19:48:44 +02:00
Sucukdeluxe
a35ddf68b2 Ferndiagnose: Allowlist/Port/Modus im Backup mitsichern (Token bleibt pro Server)
Neuer Schalter "Ferndiagnose-Einstellungen mitsichern" (Einstellungen, Default aus,
spiegelt "Download-Liste mitsichern"). Damit reisen die wiederkehrenden, server-
unabhaengigen MCP-Einstellungen im Backup-Export/Import mit, statt sie auf jedem der
5-6 Server neu einzutippen.

Mitgesichert wird NUR was identisch ueber alle Server ist und kein Geheimnis/keine
Identitaet traegt: Allowlist, Port, Freigabemodus (lokal/Netzwerk). Bewusst NICHT
mitgesichert:
- Token: wird pro Server automatisch frisch erzeugt (nie vom Nutzer getippt → kein
  Tipp-Aufwand gespart). Der Backup-Schluessel ist eine fest verdrahtete Konstante →
  ein geleaktes .mdd duerfte sonst Lesezugriff auf ALLE Server geben. Backstop: ohne
  Token ist ein frisch importierter Server inert (checkAuth weist alles ab), bis der
  Nutzer einmal "Aktivieren" drueckt und ein Token erzeugt — keine stille Freigabe.
- Oeffentliche Adresse + Name: pro Server verschieden (sonst zeigte der erzeugte
  Verbindungscode des Zielservers auf den falschen Host).

Mechanik:
- mcpRemote als eigene Top-Level-Sektion im BackupPayload (NICHT in settings —
  normalizeSettings ist ein Whitelist-Rebuild und wuerde unbekannte Keys verwerfen).
  Form {allowlist, port, hostMode} erzwingt die Policy strukturell (kein Token-Feld).
  Kein version-Bump (Import ist lenient, additiv vor-/rueckwaertskompatibel).
- Export sammelt aus getDebugServerRuntimeStatus()+getDebugAllowlist(), nur wenn der
  Schalter an ist; liest bewusst KEIN Token, KEINE Remote-Meta.
- Import wendet die Sektion NUR im Settings-only-Zweig an (writeDebugServerConfig +
  restartDebugServer, damit der laufende Server die Allowlist sofort uebernimmt). Im
  Full-Backup-Zweig nicht — die App relauncht dort, der Boot liest die Dateien neu.
- Sicherheitsguard: Netzwerk-Modus mit LEERER Allowlist bindet local (127.0.0.1) statt
  0.0.0.0 — nie stille Freigabe aus korrupten Daten.
- backupIncludeMcp in AppSettings + defaultSettings + normalizeSettings (sonst wuerde
  der Schalter beim Speichern verworfen).

Tests (backup-mcp.test.ts, 11): Export-Gating + Policy-Guard (Sektion traegt nur
allowlist/port/hostMode), resolveMcpRemoteRestore-Matrix (hostMode-Mapping, Port-
Grenzen, Allowlist-Filter, Netzwerk-ohne-Allowlist→local, null bei fehlend), und der
load-bearing Live-Round-Trip: Export → Resolve → Apply → restartDebugServer →
getDebugAllowlist()/Status spiegeln die importierten Werte (beweist dass der Restart
feuert), Token unangetastet, kein debug_remote.json. Volle Suite 917 gruen, tsc=6.
2026-06-19 19:40:39 +02:00
Sucukdeluxe
c79a031be8 todo: MCP-Ferndiagnose Review (v1.7.223 released, P5 = Nutzer-Acceptance) 2026-06-19 17:26:16 +02:00
Sucukdeluxe
6b52678e7e Bridge-README: MCP-Client-neutral formuliert (kein Tool-spezifischer Name) 2026-06-19 17:19:35 +02:00
Sucukdeluxe
41f2925b13 Release v1.7.223 2026-06-19 17:13:58 +02:00
Sucukdeluxe
95d284f687 Sicherheit: Allowlist anhand des echten Socket-Peers statt X-Forwarded-For
Die Allowlist-Pruefung nutzte extractDebugClientIp, das X-Forwarded-For zuerst
auswertet - ein angreiferkontrollierter Header. Damit konnte ein entfernter Client
auf einem 0.0.0.0-Bind die Allowlist komplett umgehen (X-Forwarded-For: 127.0.0.1 ->
als Loopback gewertet). Die Allowlist bot so keinerlei Schutz; nur das Token blieb.

Fix: Enforcement liest jetzt ausschliesslich req.socket.remoteAddress (getPeerIp),
das vom Kernel aus der TCP-Verbindung gesetzt wird und nicht per Header faelschbar ist.
X-Forwarded-For wird nur noch fuer das Trace-Log (Beobachtbarkeit) verwendet, nie fuer
die Zugriffsentscheidung. Reine, exportierte Matcher-Funktion evaluateClientAllowed.

Tests umgebaut auf den Threat-Model statt den faelschbaren Kanal: Unit-Tests fuer
Loopback/exakt/CIDR/fail-closed + expliziter Forged-XFF-Test (socket.remoteAddress
8.8.8.8 + X-Forwarded-For 127.0.0.1 -> verweigert). Integration: Loopback verbindet,
gespooftes X-Forwarded-For wird ignoriert, Auth bleibt erzwungen, Live-Reload.
2026-06-19 17:10:11 +02:00
Sucukdeluxe
c8beaf96ed Ferndiagnose (MCP): Verbindungscode + abgesicherter Fernzugriff + stdio-Bridge
Neue Funktion, um Diagnose eines laufenden Servers aus der Ferne zu ermoeglichen:
Hilfe -> Remote-Support -> "Ferndiagnose (MCP)". Erzeugt einen Verbindungscode,
den der Assistent nutzt, um Status, Logs, Fehler und Accounts read-only zu lesen.

App-Seite:
- Live (re)startbarer Debug-Server ohne App-Neustart (restartDebugServer wartet auf
  'close' + closeAllConnections, behandelt EADDRINUSE).
- IP-Allowlist (debug_allowlist.txt, exakte IP + CIDR), erzwungen VOR der Auth.
  Fail-closed: Netzwerk-Bind (0.0.0.0) ohne Allowlist akzeptiert nur Loopback.
- One-Click Aktivieren/Aktualisieren/Deaktivieren + Token-Rotation (alter Code sofort
  ungueltig). Sichtbarkeit waehlbar: "Nur lokal" (Tunnel-Empfehlung) vs "Im Netzwerk".
- Verbindungscode rddiag:v1:base64url({v,h,p,t,n?,fp?,s?}); oeffentlicher Host frei
  waehlbar, Netzwerk-IPs als Schnellauswahl.
- Neue IPC: get/enable/disable/rotate Remote-Diagnostics; Controller-Methoden; Typen.

Bridge (tools/rd-diagnostics-mcp, standalone, KEINE App-Dependency):
- stdio MCP-Server (@modelcontextprotocol/sdk) mit 14 Tools, proxyt die bestehende
  HTTP-Debug-API. Multi-Server ueber code/server/RDDIAG_CODE/RDDIAG_SERVERS.
- TLS-Fingerprint-Pinning auf secureConnect (vor Token-Versand), falls https genutzt.
- test/harness.mjs: faehrt einen Fake-Debug-Server hoch und treibt die Bridge als
  echten stdio-Child per JSON-RPC -> voller Protokollpfad gruen.

Sicherheit (Audit): keine persistenten Secrets in den Logs der Debug-API (Passwoerter
redigiert, keine Debrid-Keys/aufgeloesten Download-URLs geloggt; settings/accounts
redigiert). Empfohlener Transport: Loopback + privater Tunnel; Direkt-Bind nur mit
Allowlist in vertrauenswuerdigen Netzen.

Tests: connection-code-Cross-Check (App-Encoder <-> Bridge-Decoder), Allowlist-Matrix
(Loopback, exakt, CIDR, fail-closed, Live-Restart). Volle Suite 905 gruen, tsc=6.
2026-06-19 17:05:28 +02:00
Sucukdeluxe
838bd2ee7a Release v1.7.222 2026-06-17 20:39:09 +02:00
Sucukdeluxe
3d82d00e54 Fix: Mega-Debrid 'Linkgenerierung lieferte kein Ergebnis' nicht mehr als 30s-Cooldown (schneller Transient)
Eine leere Mega-Debrid-API-Antwort (success, aber kein Download-Link ->
debrid.ts:1927 'Linkgenerierung lieferte kein Ergebnis') fiel in
classifyAccountFailure in den Default-Zweig -> 30s Account-Cooldown
('Mega-Debrid Cooldown, neuer Versuch in 31s'). Das ist KEIN Limit-/Quota-Signal,
sondern ein transienter API-Blip, der praktisch sofort wieder funktioniert (vom
Nutzer live beobachtet: danach ging es). Fix: eigener Zweig klassifiziert die leere
Antwort wie die anderen transienten Resolve-Fehler (cooldownMs 0, kein
Account-Cooldown) -> der Download-Manager nimmt den schnellen Transient-Retry-Pfad
(3s, eskaliert auf max 10s) statt 31s zu warten. Kein limitSignal (kein
until-restart-Park). Rot-bewiesener Test (ohne Fix cooldownMs 30000; mit Fix 0 +
Meldung trifft isMegaDebridTransientResolveFailure). Volle Suite 894 gruen, tsc=6.
2026-06-17 20:38:32 +02:00
33 changed files with 4039 additions and 142 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.221", "version": "1.7.232",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -1,4 +1,5 @@
import path from "node:path"; import path from "node:path";
import os from "node:os";
import v8 from "node:v8"; import v8 from "node:v8";
import { app } from "electron"; import { app } from "electron";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
@ -9,9 +10,11 @@ import {
DebridAccountStatus, DebridAccountStatus,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
EnableRemoteDiagnosticsInput,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
ParsedPackageInput, ParsedPackageInput,
RemoteDiagnosticsInfo,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
StartConflictResolutionResult, StartConflictResolutionResult,
@ -36,12 +39,13 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
import { MegaWebFallback } from "./mega-web-fallback"; import { MegaWebFallback } from "./mega-web-fallback";
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { runInstallWithResume } from "./update-install-flow"; import { runInstallWithResume } from "./update-install-flow";
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
import { encryptBackup, decryptBackup } from "./backup-crypto"; import { encryptBackup, decryptBackup } from "./backup-crypto";
import { buildBackupPayload, planBackupImport } from "./backup-payload"; import { buildBackupPayload, planBackupImport, resolveMcpRemoteRestore, BackupMcpRemote } from "./backup-payload";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log"; import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
import { initConversionLog, shutdownConversionLog } from "./conversion-trace"; import { initConversionLog, shutdownConversionLog } from "./conversion-trace";
@ -108,7 +112,8 @@ export class AppController {
initTraceLog(this.storagePaths.baseDir); initTraceLog(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths); this.settings = loadSettings(this.storagePaths);
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
const session = loadSession(this.storagePaths); const loadResult = loadSessionWithStatus(this.storagePaths);
const session = loadResult.session;
this.megaWebFallback = new MegaWebFallback(() => ({ this.megaWebFallback = new MegaWebFallback(() => ({
login: this.settings.megaLogin, login: this.settings.megaLogin,
password: this.settings.megaPassword password: this.settings.megaPassword
@ -122,6 +127,7 @@ export class AppController {
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal), realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
protectEmptyClobber: loadResult.status === "empty-unreadable",
onHistoryEntry: (entry: HistoryEntry) => { onHistoryEntry: (entry: HistoryEntry) => {
addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits()); addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry, this.historyLimits());
} }
@ -174,21 +180,13 @@ export class AppController {
if (this.settings.autoResumeOnStart) { if (this.settings.autoResumeOnStart) {
const snapshot = this.manager.getSnapshot(); const snapshot = this.manager.getSnapshot();
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait"); const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
if (hasPending) { if (hasPending && this.hasAnyProviderToken(this.settings)) {
void this.manager.getStartConflicts().then((conflicts) => {
const hasConflicts = conflicts.length > 0;
if (this.hasAnyProviderToken(this.settings) && !hasConflicts) {
if (this.onStateHandler) { if (this.onStateHandler) {
logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)"); this.beginAutoResume();
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
} else { } else {
this.autoResumePending = true; this.autoResumePending = true;
logger.info("Auto-Resume beim Start vorgemerkt"); logger.info("Auto-Resume beim Start vorgemerkt");
} }
} else if (hasConflicts) {
logger.info("Auto-Resume übersprungen: Start-Konflikte erkannt");
}
}).catch((err) => logger.warn(`getStartConflicts Fehler (constructor): ${String(err)}`));
} }
} }
} }
@ -245,14 +243,27 @@ export class AppController {
handler(this.manager.getSnapshot()); handler(this.manager.getSnapshot());
if (this.autoResumePending) { if (this.autoResumePending) {
this.autoResumePending = false; this.autoResumePending = false;
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`)); this.beginAutoResume();
logger.info("Auto-Resume beim Start aktiviert");
} else { } else {
this.manager.triggerIdleExtractions(); this.manager.triggerIdleExtractions();
} }
} }
} }
private beginAutoResume(): void {
void this.manager.getStartConflicts().then((conflicts) => {
const excludePackageIds = new Set(conflicts.map((conflict) => conflict.packageId));
if (excludePackageIds.size > 0) {
const names = conflicts.map((conflict) => conflict.packageName).join(", ");
logger.info(`Auto-Resume: ${excludePackageIds.size} Paket(e) mit Start-Konflikt zurückgehalten (${names}); übrige Pakete starten`);
} else {
logger.info("Auto-Resume beim Start aktiviert (keine Start-Konflikte)");
}
void this.manager.start(excludePackageIds.size > 0 ? { excludePackageIds } : undefined)
.catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
}).catch((err) => logger.warn(`Auto-Resume Konflikt-Check Fehler: ${String(err)}`));
}
public getSnapshot(): UiSnapshot { public getSnapshot(): UiSnapshot {
return this.manager.getSnapshot(); return this.manager.getSnapshot();
} }
@ -291,6 +302,100 @@ export class AppController {
return rotated; return rotated;
} }
private getSuggestedRemoteHosts(): string[] {
const hosts: string[] = [];
try {
const interfaces = os.networkInterfaces();
for (const entry of Object.values(interfaces)) {
for (const net of entry || []) {
if (net.family === "IPv4" && !net.internal && net.address) {
hosts.push(net.address);
}
}
}
} catch {
}
return [...new Set(hosts)];
}
public getRemoteDiagnostics(): RemoteDiagnosticsInfo {
const status = getDebugServerRuntimeStatus();
const meta = loadRemoteMeta(this.storagePaths.baseDir);
const token = getActiveDebugToken();
const allowlist = getDebugAllowlist();
const suggestedHosts = this.getSuggestedRemoteHosts();
const host = meta.publicHost
|| (status.localOnly ? "127.0.0.1" : (suggestedHosts[0] || status.host));
const code = (status.hasToken && token && host)
? encodeConnectionCode({ host, port: status.port, token, name: meta.name || undefined })
: null;
return {
status,
code,
publicHost: meta.publicHost,
name: meta.name,
allowlist,
suggestedHosts
};
}
public async enableRemoteDiagnostics(input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> {
const baseDir = this.storagePaths.baseDir;
const port = input.port && Number.isInteger(input.port) && input.port >= 1024 && input.port <= 65535
? input.port
: 9868;
const bindHost = input.hostMode === "network" ? "0.0.0.0" : "127.0.0.1";
const allowlist = (input.allowlist || []).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
if (input.hostMode === "network" && allowlist.length === 0) {
throw new Error("Netzwerk-Freigabe erfordert mindestens eine erlaubte IP oder CIDR in der Allowlist.");
}
let token = getActiveDebugToken();
if (!token || input.rotateToken) {
token = rotateDebugToken(baseDir).token;
}
writeDebugServerConfig({ host: bindHost, port, allowlist });
saveRemoteMeta(baseDir, { publicHost: (input.publicHost || "").trim(), name: (input.name || "").trim() });
await restartDebugServer();
this.audit("WARN", "Ferndiagnose aktiviert", {
host: bindHost,
port,
allowlistCount: allowlist.length,
localOnly: input.hostMode === "local"
});
return this.getRemoteDiagnostics();
}
public async disableRemoteDiagnostics(): Promise<RemoteDiagnosticsInfo> {
clearDebugToken();
await restartDebugServer();
this.audit("WARN", "Ferndiagnose deaktiviert (Token entfernt)");
return this.getRemoteDiagnostics();
}
public async rotateRemoteDiagnosticsToken(): Promise<RemoteDiagnosticsInfo> {
rotateDebugToken(this.storagePaths.baseDir);
await restartDebugServer();
this.audit("WARN", "Ferndiagnose-Token rotiert");
return this.getRemoteDiagnostics();
}
private restoreMcpRemoteFromBackup(section: unknown, restartNow: boolean): void {
const restore = resolveMcpRemoteRestore(section);
if (!restore) {
return;
}
writeDebugServerConfig({ host: restore.host, port: restore.port, allowlist: restore.allowlist });
if (restartNow) {
void restartDebugServer().catch(() => {});
}
this.audit("INFO", "Ferndiagnose-Einstellungen aus Backup wiederhergestellt", {
port: restore.port ?? null,
allowlistCount: restore.allowlist?.length ?? 0,
host: restore.host ?? "unveraendert",
restartNow
});
}
public getDebugSetupCheck(): DebugSetupCheckResult { public getDebugSetupCheck(): DebugSetupCheckResult {
return getDebugSetupCheck(this.storagePaths.baseDir); return getDebugSetupCheck(this.storagePaths.baseDir);
} }
@ -643,13 +748,22 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
} }
public exportBackup(): Buffer { public exportBackup(): Buffer {
const includeDownloads = Boolean(this.settings.backupIncludeDownloads); let mcpRemote: BackupMcpRemote | undefined;
if (Boolean(this.settings.backupIncludeMcp)) {
const status = getDebugServerRuntimeStatus();
mcpRemote = {
allowlist: getDebugAllowlist(),
port: status.port,
hostMode: status.host === "0.0.0.0" ? "network" : "local"
};
}
const payloadObj = buildBackupPayload({ const payloadObj = buildBackupPayload({
settings: { ...this.settings }, settings: { ...this.settings },
appVersion: APP_VERSION, appVersion: APP_VERSION,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
session: this.manager.getSession(), session: this.manager.getSession(),
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()) history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()),
mcpRemote
}); });
this.audit("INFO", "Backup exportiert", { this.audit("INFO", "Backup exportiert", {
kind: payloadObj.kind, kind: payloadObj.kind,
@ -723,6 +837,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
this.settings = restoredSettings; this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true }); this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, true);
this.audit("INFO", "Backup importiert (nur Einstellungen)", { this.audit("INFO", "Backup importiert (nur Einstellungen)", {
accountSummary: buildAccountSummary(this.settings) accountSummary: buildAccountSummary(this.settings)
}); });
@ -759,6 +874,8 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, false);
this.manager.skipShutdownPersist = true; this.manager.skipShutdownPersist = true;
this.manager.blockAllPersistence = true; this.manager.blockAllPersistence = true;
logger.info("Backup wiederhergestellt — App startet automatisch neu"); logger.info("Backup wiederhergestellt — App startet automatisch neu");

View File

@ -2,6 +2,12 @@ import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
export type BackupKind = "full" | "settings-only"; export type BackupKind = "full" | "settings-only";
export interface BackupMcpRemote {
allowlist: string[];
port: number;
hostMode: "local" | "network";
}
export interface BackupPayload { export interface BackupPayload {
version: 2; version: 2;
kind: BackupKind; kind: BackupKind;
@ -10,6 +16,7 @@ export interface BackupPayload {
settings: AppSettings; settings: AppSettings;
session?: SessionState; session?: SessionState;
history?: HistoryEntry[]; history?: HistoryEntry[];
mcpRemote?: BackupMcpRemote;
} }
export interface BuildBackupInput { export interface BuildBackupInput {
@ -19,6 +26,7 @@ export interface BuildBackupInput {
/** Only bundled when includeDownloads is true. */ /** Only bundled when includeDownloads is true. */
session: SessionState; session: SessionState;
history: HistoryEntry[]; history: HistoryEntry[];
mcpRemote?: BackupMcpRemote;
} }
/** /**
@ -40,9 +48,39 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
base.session = input.session; base.session = input.session;
base.history = input.history; base.history = input.history;
} }
if (Boolean(input.settings.backupIncludeMcp) && input.mcpRemote) {
base.mcpRemote = input.mcpRemote;
}
return base; return base;
} }
export interface McpRemoteRestore {
host?: "127.0.0.1" | "0.0.0.0";
port?: number;
allowlist?: string[];
}
export function resolveMcpRemoteRestore(section: unknown): McpRemoteRestore | null {
if (!section || typeof section !== "object") {
return null;
}
const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown };
const allowlist = Array.isArray(s.allowlist)
? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim())
: undefined;
const port = (typeof s.port === "number" && Number.isInteger(s.port) && s.port >= 1024 && s.port <= 65535) ? s.port : undefined;
let host: "127.0.0.1" | "0.0.0.0" | undefined;
if (s.hostMode === "network") {
host = allowlist && allowlist.length > 0 ? "0.0.0.0" : "127.0.0.1";
} else if (s.hostMode === "local") {
host = "127.0.0.1";
}
if (host === undefined && port === undefined && allowlist === undefined) {
return null;
}
return { host, port, allowlist };
}
export interface ImportPlan { export interface ImportPlan {
valid: boolean; valid: boolean;
/** Restore the download list (session + history) and relaunch. */ /** Restore the download list (session + history) and relaunch. */

View File

@ -0,0 +1,59 @@
import fs from "node:fs";
import path from "node:path";
const PREFIX = "rddiag:v1:";
function base64urlEncode(value: string): string {
return Buffer.from(value, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export interface ConnectionCodeInput {
host: string;
port: number;
token: string;
name?: string;
scheme?: "http" | "https";
fingerprint?: string;
}
export function encodeConnectionCode(input: ConnectionCodeInput): string {
const host = String(input.host || "").trim();
if (!host) throw new Error("Host fehlt fuer Verbindungscode");
const port = Number(input.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port ungueltig fuer Verbindungscode");
if (!input.token) throw new Error("Token fehlt fuer Verbindungscode");
const payload: Record<string, unknown> = { v: 1, h: host, p: port, t: input.token };
if (input.name) payload.n = String(input.name);
if (input.fingerprint) payload.fp = String(input.fingerprint);
if (input.scheme && input.scheme !== "http") payload.s = String(input.scheme);
return PREFIX + base64urlEncode(JSON.stringify(payload));
}
export interface RemoteMeta {
publicHost: string;
name: string;
}
function remoteMetaPath(baseDir: string): string {
return path.join(baseDir, "debug_remote.json");
}
export function loadRemoteMeta(baseDir: string): RemoteMeta {
try {
const parsed = JSON.parse(fs.readFileSync(remoteMetaPath(baseDir), "utf8"));
return {
publicHost: String(parsed.publicHost || ""),
name: String(parsed.name || "")
};
} catch {
return { publicHost: "", name: "" };
}
}
export function saveRemoteMeta(baseDir: string, meta: RemoteMeta): void {
fs.writeFileSync(remoteMetaPath(baseDir), JSON.stringify({ publicHost: meta.publicHost, name: meta.name }, null, 2), "utf8");
}

View File

@ -109,6 +109,7 @@ export function defaultSettings(): AppSettings {
hideExtractedItems: true, hideExtractedItems: true,
confirmDeleteSelection: true, confirmDeleteSelection: true,
backupIncludeDownloads: false, backupIncludeDownloads: false,
backupIncludeMcp: false,
notifyUrl: "", notifyUrl: "",
notifyMention: "", notifyMention: "",
notifyOnPackageCompleted: false, notifyOnPackageCompleted: false,

View File

@ -288,6 +288,7 @@ type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporar
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean }; type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>(); const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
const MEGA_DEBRID_SLOW_LINK_RETRY_MS = 120_000;
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000; const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
// A Mega-Web account abort (the shared unrestrict timeout firing while this // A Mega-Web account abort (the shared unrestrict timeout firing while this
@ -301,7 +302,7 @@ function getMegaDebridAbortMinRunMs(): number {
} }
const megaDebridEmptyResponseStreaks = new Map<string, number>(); const megaDebridEmptyResponseStreaks = new Map<string, number>();
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3; export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10;
let megaDebridRotationCursor = 0; let megaDebridRotationCursor = 0;
let megaDebridStickyCount = 0; let megaDebridStickyCount = 0;
@ -338,6 +339,25 @@ export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridInFlight.clear(); megaDebridInFlight.clear();
} }
export function getMegaDebridInFlightCountForMode(mode: "api" | "web"): number {
const suffix = `:${mode}`;
let total = 0;
for (const [key, count] of megaDebridInFlight) {
if (key.endsWith(suffix)) {
total += count;
}
}
return total;
}
export function primeMegaDebridInFlightForTests(key: string, count: number): void {
if (count <= 0) {
megaDebridInFlight.delete(key);
return;
}
megaDebridInFlight.set(key, count);
}
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number { export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
let removed = 0; let removed = 0;
const grace = 60 * 60 * 1000; const grace = 60 * 60 * 1000;
@ -422,6 +442,106 @@ export function getMegaDebridAccountCooldownState(
}; };
} }
export interface ProviderRuntimeCooldown {
untilMs: number;
remainingMs: number;
message: string;
category: string;
untilRestart?: boolean;
}
export interface ProviderRuntimeSnapshot {
capturedAtMs: number;
megaDebrid: {
rotationCursor: number;
stickyCount: number;
accounts: Array<{
key: string;
cooldown: ProviderRuntimeCooldown | null;
inFlight: number;
emptyResponseStreak: number;
}>;
};
debridLink: {
keys: Array<{
keyId: string;
cooldown: ProviderRuntimeCooldown | null;
runtimeStatus: { state: string; detail: string; updatedAt: number } | null;
}>;
hostCooldowns: Array<{ key: string; cooldown: ProviderRuntimeCooldown }>;
};
}
export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSnapshot {
const megaKeys = new Set<string>([
...megaDebridAccountCooldowns.keys(),
...megaDebridInFlight.keys(),
...megaDebridEmptyResponseStreaks.keys()
]);
const megaAccounts = [...megaKeys].sort().map((key) => {
const detail = megaDebridAccountCooldowns.get(key);
return {
key,
cooldown: detail
? {
untilMs: detail.until,
remainingMs: Math.max(0, detail.until - now),
message: detail.message,
category: detail.category,
untilRestart: detail.untilRestart === true
}
: null,
inFlight: megaDebridInFlight.get(key) ?? 0,
emptyResponseStreak: megaDebridEmptyResponseStreaks.get(key) ?? 0
};
});
const dlKeyIds = new Set<string>([
...debridLinkKeyCooldowns.keys(),
...debridLinkKeyRuntimeStatuses.keys()
]);
const dlKeys = [...dlKeyIds].sort().map((keyId) => {
const until = Number(debridLinkKeyCooldowns.get(keyId) || 0);
const detail = debridLinkKeyCooldownDetails.get(keyId);
const status = debridLinkKeyRuntimeStatuses.get(keyId) || null;
return {
keyId,
cooldown: until > 0
? {
untilMs: until,
remainingMs: Math.max(0, until - now),
message: detail?.message ?? "",
category: detail?.category ?? "temporary"
}
: null,
runtimeStatus: status ? { state: status.state, detail: status.detail, updatedAt: status.updatedAt } : null
};
});
const dlHostCooldowns = [...debridLinkKeyHostCooldowns].map(([key, until]) => {
const detail = debridLinkKeyHostCooldownDetails.get(key);
return {
key,
cooldown: {
untilMs: until,
remainingMs: Math.max(0, until - now),
message: detail?.message ?? "",
category: detail?.category ?? "temporary"
}
};
});
return {
capturedAtMs: now,
megaDebrid: {
rotationCursor: megaDebridRotationCursor,
stickyCount: megaDebridStickyCount,
accounts: megaAccounts
},
debridLink: { keys: dlKeys, hostCooldowns: dlHostCooldowns }
};
}
const LINKSNAPPY_API_BASE = "https://linksnappy.com/api"; const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";
const PROVIDER_LABELS: Record<DebridProvider, string> = { const PROVIDER_LABELS: Record<DebridProvider, string> = {
@ -2068,15 +2188,33 @@ class MegaDebridClient {
} catch (error) { } catch (error) {
const elapsedMs = Date.now() - testStartedAt; const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
// Timeout/abort on THIS account (the shared unrestrict signal fired). Cool // Timeout/abort on THIS account (the shared unrestrict timeout fired). The
// the account down — if it actually ran, not a quick user-cancel — so the // account-wide cooldown exists ONLY to make the retry rotate to another
// download-manager's retry rotates to the NEXT account instead of hammering // account — so it is set only when another usable account actually exists.
// this one. The shared signal is now aborted, so we stop this pass; the // With no rotation target (single account / all others busy), cooling the
// retry runs the rotation fresh with this account skipped. A genuine cancel // sole account would freeze EVERY queued item while the account is healthy;
// is not retried by the caller, so the cooldown is harmless there. // a >60s timeout is a slow-LINK signal, not an unhealthy-account signal, so
// we park just this link (mega_debrid_slow_link) and leave the account free
// for other items. A quick user-cancel (below the min run) parks nothing.
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
if (ranLongEnough) { const otherUsableAccounts = orderedEntries.reduce((count, candidate) => {
if (candidate.account.id === account.id) {
return count;
}
if (isMegaDebridAccountDisabled(settings, candidate.account.id)) {
return count;
}
if (isMegaDebridAccountDailyLimitReached(settings, candidate.account.id)) {
return count;
}
if (getMegaDebridAccountCooldownState(`${candidate.account.id}:${mode}`)) {
return count;
}
return count + 1;
}, 0);
const rotateToAnotherAccount = ranLongEnough && otherUsableAccounts > 0;
if (rotateToAnotherAccount) {
setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary"); setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
} }
traceConversionPhase({ traceConversionPhase({
@ -2085,15 +2223,18 @@ class MegaDebridClient {
account: rotationLabel, account: rotationLabel,
workMs: elapsedMs, workMs: elapsedMs,
outcome: "aborted", outcome: "aborted",
detail: `${abortText}${ranLongEnough ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ""}` detail: `${abortText}${rotateToAnotherAccount ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ranLongEnough ? ` slowlink=${Math.ceil(MEGA_DEBRID_SLOW_LINK_RETRY_MS / 1000)}s` : ""}`
}); });
failures.push(`Mega-Debrid${accountLabel}: ${abortText}`); failures.push(`Mega-Debrid${accountLabel}: ${abortText}`);
logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", { logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", {
elapsedMs, elapsedMs,
reason: abortText, reason: abortText,
cooldownSec: ranLongEnough ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0, cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
next: "naechster Account beim Retry" next: rotateToAnotherAccount ? "naechster Account beim Retry" : "Einzel-Retry (Account bleibt fuer andere Items frei)"
}); });
if (ranLongEnough && !rotateToAnotherAccount) {
throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`);
}
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`); throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
} }
const failure = MegaDebridClient.classifyAccountFailure(error); const failure = MegaDebridClient.classifyAccountFailure(error);
@ -2113,7 +2254,7 @@ class MegaDebridClient {
const streak = recordMegaDebridEmptyResponseStreak(cooldownKey); const streak = recordMegaDebridEmptyResponseStreak(cooldownKey);
if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) { if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) {
parkUntilRestart = true; parkUntilRestart = true;
parkMessage = `Tageslimit erreicht (${streak}x kein Server/leere Antwort) — bis zum Tagesreset gesperrt`; parkMessage = `Tageslimit erreicht (${streak}x leere Antwort in Folge) — bis zum Tagesreset gesperrt`;
} }
} else { } else {
clearMegaDebridEmptyResponseStreak(cooldownKey); clearMegaDebridEmptyResponseStreak(cooldownKey);
@ -2207,6 +2348,10 @@ class MegaDebridClient {
return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" }; return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" };
} }
if (/lieferte kein ergebnis|linkgenerierung[^.]*kein ergebnis/i.test(errorText)) {
return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" };
}
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) { if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) {
return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" }; return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" };
} }

View File

@ -15,6 +15,8 @@ import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log"; import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log";
import { getConversionLogPath } from "./conversion-trace";
import { getProviderRuntimeSnapshot } from "./debrid";
import { getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager"; import type { DownloadManager } from "./download-manager";
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
@ -43,12 +45,14 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ method: "GET", path: "/logs/rename", queryExample: "lines=100&grep=keyword", description: "Reads the dedicated rename and MKV move log." }, { method: "GET", path: "/logs/rename", queryExample: "lines=100&grep=keyword", description: "Reads the dedicated rename and MKV move log." },
{ method: "GET", path: "/logs/trace", queryExample: "lines=100&grep=keyword", description: "Reads the optional support trace log." }, { method: "GET", path: "/logs/trace", queryExample: "lines=100&grep=keyword", description: "Reads the optional support trace log." },
{ method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." }, { method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." },
{ method: "GET", path: "/logs/conversion", queryExample: "lines=100&grep=keyword", description: "Reads the per-item link conversion/unrestrict lifecycle log (token, API getLink, web, account rotation, aborts with timings)." },
{ method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." }, { method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." },
{ method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." }, { method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." },
{ method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." }, { method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." },
{ method: "GET", path: "/trace/config", queryExample: "enable=1&note=support&durationMinutes=120", description: "Reads or updates the support trace configuration." }, { method: "GET", path: "/trace/config", queryExample: "enable=1&note=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." }, { method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." }, { method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
{ method: "GET", path: "/providers", description: "Live provider runtime state: per-account/key cooldowns (until/remaining/reason/category), in-flight depth, Mega rotation cursor, empty-response streaks. The 'why is it cooling down right now' view." },
{ method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." }, { method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." },
{ method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." }, { method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." },
{ method: "GET", path: "/status", description: "Returns a live high-level status overview." }, { method: "GET", path: "/status", description: "Returns a live high-level status overview." },
@ -65,6 +69,16 @@ let authToken = "";
let bindHost = DEFAULT_HOST; let bindHost = DEFAULT_HOST;
let bindPort = DEFAULT_PORT; let bindPort = DEFAULT_PORT;
let runtimeBaseDir = ""; let runtimeBaseDir = "";
let allowlist: string[] = [];
export interface DebugServerRuntimeStatus {
running: boolean;
host: string;
port: number;
hasToken: boolean;
localOnly: boolean;
allowlistCount: number;
}
function getStoragePaths() { function getStoragePaths() {
return createStoragePaths(runtimeBaseDir); return createStoragePaths(runtimeBaseDir);
@ -140,6 +154,94 @@ function getHost(baseDir: string): string {
return DEFAULT_HOST; return DEFAULT_HOST;
} }
function getAllowlistPath(baseDir: string = runtimeBaseDir): string {
return path.join(baseDir, "debug_allowlist.txt");
}
function loadAllowlist(baseDir: string): string[] {
try {
return fs.readFileSync(getAllowlistPath(baseDir), "utf8")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"));
} catch {
return [];
}
}
function normalizeIp(ip: string): string {
return String(ip || "").trim().replace(/^::ffff:/i, "").toLowerCase();
}
function isLoopbackIp(ip: string): boolean {
const x = normalizeIp(ip);
return x === "::1" || x === "localhost" || x.startsWith("127.");
}
function ipv4ToInt(ip: string): number | null {
const parts = ip.split(".");
if (parts.length !== 4) {
return null;
}
let result = 0;
for (const part of parts) {
const value = Number(part);
if (!Number.isInteger(value) || value < 0 || value > 255) {
return null;
}
result = (result * 256) + value;
}
return result >>> 0;
}
function matchIpRule(clientIp: string, rule: string): boolean {
const client = normalizeIp(clientIp);
const r = rule.trim().toLowerCase();
if (!r) {
return false;
}
if (r === "*" || r === "0.0.0.0/0") {
return true;
}
if (r === client) {
return true;
}
const slash = r.indexOf("/");
if (slash > 0) {
const baseInt = ipv4ToInt(r.slice(0, slash));
const clientInt = ipv4ToInt(client);
const bits = Number(r.slice(slash + 1));
if (baseInt === null || clientInt === null || !Number.isInteger(bits) || bits < 0 || bits > 32) {
return false;
}
if (bits === 0) {
return true;
}
const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0;
return (clientInt & mask) === (baseInt & mask);
}
return false;
}
export function evaluateClientAllowed(clientIp: string, rules: string[]): boolean {
const client = normalizeIp(clientIp);
if (isLoopbackIp(client) || client === "") {
return true;
}
if (rules.length === 0) {
return false;
}
return rules.some((rule) => matchIpRule(client, rule));
}
export function getPeerIp(req: http.IncomingMessage): string {
return normalizeIp(req.socket?.remoteAddress || "");
}
function isClientAllowed(clientIp: string): boolean {
return evaluateClientAllowed(clientIp, allowlist);
}
function checkAuth(req: http.IncomingMessage): boolean { function checkAuth(req: http.IncomingMessage): boolean {
if (!authToken) { if (!authToken) {
return false; return false;
@ -258,6 +360,7 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
"Call /meta first to confirm the server is reachable and to re-read the endpoint list.", "Call /meta first to confirm the server is reachable and to re-read the endpoint list.",
"Use /self-check or /debug/setup to quickly verify whether token, host, manifest, trace, disk space, and log sizes are in a good support state.", "Use /self-check or /debug/setup to quickly verify whether token, host, manifest, trace, disk space, and log sizes are in a good support state.",
"Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /logs/rename, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.", "Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /logs/rename, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.",
"For provider stalls/cooldowns, call /providers for the live cooldown state (until/remaining/reason per account/key) and /logs/conversion for the per-item resolve lifecycle (token, API, web, rotation, aborts with timings).",
"If a full handoff is needed, download /support/bundle as a ZIP." "If a full handoff is needed, download /support/bundle as a ZIP."
], ],
auth: { auth: {
@ -461,6 +564,19 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; return;
} }
const peerIp = getPeerIp(req);
if (!isClientAllowed(peerIp)) {
if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("WARN", "debug-http", "Durch Allowlist blockiert", {
peerIp,
forwardedFor: extractDebugClientIp(req),
url: sanitizeRequestUrlForTrace(req.url || "/")
});
}
jsonResponse(res, 403, { error: "Forbidden", reason: "Client-IP nicht in Allowlist", clientIp: peerIp });
return;
}
if (!checkAuth(req)) { if (!checkAuth(req)) {
if (traceConfig.enabled && traceConfig.logDebugRequests) { if (traceConfig.enabled && traceConfig.logDebugRequests) {
logTraceEvent("WARN", "debug-http", "Unauthorized request", { logTraceEvent("WARN", "debug-http", "Unauthorized request", {
@ -596,6 +712,25 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; return;
} }
if (pathname === "/logs/conversion") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
const logPath = getConversionLogPath();
const lines = logPath ? filterLines(readLogTailFromFile(logPath, count), grep) : [];
jsonResponse(res, 200, {
path: logPath,
available: Boolean(logPath),
lines,
count: lines.length
});
return;
}
if (pathname === "/providers") {
jsonResponse(res, 200, getProviderRuntimeSnapshot());
return;
}
if (pathname === "/trace/config") { if (pathname === "/trace/config") {
const patch: Record<string, unknown> = {}; const patch: Record<string, unknown> = {};
const enabled = toBooleanQuery(url.searchParams.get("enable")); const enabled = toBooleanQuery(url.searchParams.get("enable"));
@ -881,6 +1016,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
settings: buildRedactedSettingsPayload(readSupportSettings()), settings: buildRedactedSettingsPayload(readSupportSettings()),
stats: buildStatsPayload(snapshot), stats: buildStatsPayload(snapshot),
accounts: buildAccountSummary(readSupportSettings()), accounts: buildAccountSummary(readSupportSettings()),
providers: getProviderRuntimeSnapshot(),
history: { history: {
total: readSupportHistory().length, total: readSupportHistory().length,
recent: readSupportHistory() recent: readSupportHistory()
@ -912,6 +1048,10 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
path: sessionLogPath, path: sessionLogPath,
lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep) lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep)
}, },
conversion: {
path: getConversionLogPath(),
lines: getConversionLogPath() ? filterLines(readLogTailFromFile(getConversionLogPath() as string, lineCount), grep) : []
},
package: selectedPackage ? { package: selectedPackage ? {
path: packageLogPath, path: packageLogPath,
lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep) lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep)
@ -927,32 +1067,126 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
}); });
} }
export function startDebugServer(mgr: DownloadManager, baseDir: string): void { function openServerSocket(): Promise<void> {
runtimeBaseDir = baseDir; return new Promise((resolve) => {
authToken = loadToken(baseDir); authToken = loadToken(runtimeBaseDir);
bindPort = getPort(baseDir); bindPort = getPort(runtimeBaseDir);
bindHost = getHost(baseDir); bindHost = getHost(runtimeBaseDir);
writeAiManifest(baseDir); allowlist = loadAllowlist(runtimeBaseDir);
writeAiManifest(runtimeBaseDir);
if (!authToken) { if (!authToken) {
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet"); logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
resolve();
return; return;
} }
if (bindHost === "0.0.0.0" && allowlist.length === 0) {
manager = mgr; logger.warn("Debug-Server: Netzwerk-Bind ohne Allowlist - nur Loopback-Clients werden akzeptiert (fail-closed)");
}
server = http.createServer(handleRequest); const srv = http.createServer(handleRequest);
server.listen(bindPort, bindHost, () => { let settled = false;
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`); const settle = (): void => {
}); if (!settled) {
server.on("error", (err) => { settled = true;
resolve();
}
};
srv.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
logger.warn(`Debug-Server: Port ${bindPort} belegt (EADDRINUSE) - Server nicht gestartet`);
} else {
logger.warn(`Debug-Server Fehler: ${String(err)}`); logger.warn(`Debug-Server Fehler: ${String(err)}`);
}
if (server === srv) {
server = null; server = null;
}
settle();
}); });
srv.listen(bindPort, bindHost, () => {
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort} (Allowlist: ${allowlist.length})`);
settle();
});
server = srv;
});
}
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
runtimeBaseDir = baseDir;
manager = mgr;
void openServerSocket();
}
export async function restartDebugServer(): Promise<DebugServerRuntimeStatus> {
const old = server;
if (old) {
server = null;
await new Promise<void>((resolve) => {
let settled = false;
const done = (): void => {
if (!settled) {
settled = true;
resolve();
}
};
old.close(() => done());
try {
old.closeAllConnections?.();
} catch {
}
setTimeout(done, 1500);
});
}
await openServerSocket();
return getDebugServerRuntimeStatus();
}
export function getDebugServerRuntimeStatus(): DebugServerRuntimeStatus {
return {
running: Boolean(server && server.listening),
host: bindHost,
port: bindPort,
hasToken: Boolean(authToken),
localOnly: isLoopbackIp(bindHost) || bindHost === "127.0.0.1",
allowlistCount: allowlist.length
};
}
export function getActiveDebugToken(): string {
return authToken || loadToken(runtimeBaseDir);
}
export function getDebugAllowlist(): string[] {
return [...allowlist];
}
export function writeDebugServerConfig(opts: { host?: string; port?: number; allowlist?: string[] }): void {
if (opts.host !== undefined) {
fs.writeFileSync(path.join(runtimeBaseDir, "debug_host.txt"), `${opts.host}\n`, "utf8");
}
if (opts.port !== undefined) {
fs.writeFileSync(path.join(runtimeBaseDir, "debug_port.txt"), `${opts.port}\n`, "utf8");
}
if (opts.allowlist !== undefined) {
const body = opts.allowlist.length > 0 ? opts.allowlist.join("\n") + "\n" : "";
fs.writeFileSync(getAllowlistPath(), body, "utf8");
}
}
export function clearDebugToken(): void {
try {
fs.unlinkSync(getDebugTokenPath());
} catch {
}
authToken = "";
writeAiManifest(runtimeBaseDir);
} }
export function stopDebugServer(): void { export function stopDebugServer(): void {
if (server) { if (server) {
server.close(); server.close();
try {
server.closeAllConnections?.();
} catch {
}
server = null; server = null;
logger.info("Debug-Server gestoppt"); logger.info("Debug-Server gestoppt");
} }

View File

@ -52,7 +52,7 @@ function releaseTlsSkip(): void {
} }
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity"; import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error"; import { classifyDiskError } from "./fs-error";
@ -131,6 +131,17 @@ const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000;
const MAX_SAME_DIRECT_URL_ATTEMPTS = 3; const MAX_SAME_DIRECT_URL_ATTEMPTS = 3;
const MAX_HTTP416_FRESH_RESTARTS = 2;
const HTTP416_FRESH_RESTART_DELAY_MS = 8000;
function getHttp416FreshRestartDelayMs(): number {
const fromEnv = Number(process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 0 && fromEnv <= 600000) {
return Math.floor(fromEnv);
}
return HTTP416_FRESH_RESTART_DELAY_MS;
}
const RESUME_REWIND_BYTES = 256 * 1024; const RESUME_REWIND_BYTES = 256 * 1024;
const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024; const REALDEBRID_TOTAL_MISMATCH_TOLERANCE_BYTES = 64 * 1024;
@ -363,6 +374,7 @@ type DownloadManagerOptions = {
bestDebridWebUnrestrict?: BestDebridWebUnrestrictor; bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
invalidateMegaSession?: () => void; invalidateMegaSession?: () => void;
onHistoryEntry?: HistoryEntryCallback; onHistoryEntry?: HistoryEntryCallback;
protectEmptyClobber?: boolean;
}; };
function generateHistoryId(): string { function generateHistoryId(): string {
@ -659,6 +671,20 @@ export function parseMegaDebridCooldownRetry(errorText: string): { delayMs: numb
return { delayMs, detail: text.replace(/mega_debrid_cooldown:\d+:/i, "").trim() }; return { delayMs, detail: text.replace(/mega_debrid_cooldown:\d+:/i, "").trim() };
} }
export function parseMegaDebridSlowLinkRetry(errorText: string): { delayMs: number; detail: string } | null {
const text = String(errorText || "");
const match = text.match(/mega_debrid_slow_link:(\d+)/i);
if (!match) {
return null;
}
const raw = Number(match[1]);
if (!Number.isFinite(raw) || raw <= 0) {
return null;
}
const delayMs = Math.max(1000, Math.min(15 * 60 * 1000, raw));
return { delayMs, detail: text.replace(/mega_debrid_slow_link:\d+:/i, "").trim() };
}
export function parseMegaDebridResetPark(errorText: string): { delayMs: number; detail: string } | null { export function parseMegaDebridResetPark(errorText: string): { delayMs: number; detail: string } | null {
const match = String(errorText || "").match(/mega_debrid_reset_park:(\d+):(.*)$/is); const match = String(errorText || "").match(/mega_debrid_reset_park:(\d+):(.*)$/is);
if (!match) { if (!match) {
@ -1688,6 +1714,10 @@ export class DownloadManager extends EventEmitter {
public blockAllPersistence = false; public blockAllPersistence = false;
private protectAgainstEmptyClobber = false;
private emptyClobberProtectionLogged = false;
private debridService: DebridService; private debridService: DebridService;
private invalidateMegaSessionFn?: () => void; private invalidateMegaSessionFn?: () => void;
@ -1810,6 +1840,8 @@ export class DownloadManager extends EventEmitter {
unrestrictRetries: number; unrestrictRetries: number;
}>(); }>();
private http416FreshRestartByItem = new Map<string, number>();
private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>(); private providerFailures = new Map<string, { count: number; lastFailAt: number; cooldownUntil: number }>();
private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>(); private allDebridHostInfoCache = new Map<string, { info: AllDebridHostInfo; cachedAt: number }>();
@ -1831,6 +1863,10 @@ export class DownloadManager extends EventEmitter {
this.session = session; this.session = session;
this.itemCount = Object.keys(this.session.items).length; this.itemCount = Object.keys(this.session.items).length;
this.storagePaths = storagePaths; this.storagePaths = storagePaths;
this.protectAgainstEmptyClobber = Boolean(options.protectEmptyClobber);
if (this.protectAgainstEmptyClobber) {
logger.warn("Session-Schutz aktiv: Start mit unlesbarer Session — leere Speicherungen blockiert, bis echte Daten vorliegen");
}
this.debridService = new DebridService(settings, { this.debridService = new DebridService(settings, {
megaWebUnrestrict: options.megaWebUnrestrict, megaWebUnrestrict: options.megaWebUnrestrict,
allDebridWebUnrestrict: options.allDebridWebUnrestrict, allDebridWebUnrestrict: options.allDebridWebUnrestrict,
@ -2988,6 +3024,14 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
const hasOwnCompletedOutput = pkg.itemIds.some((itemId) => {
const item = this.session.items[itemId];
return Boolean(item && item.status === "completed");
});
if (hasOwnCompletedOutput) {
continue;
}
if (!this.isPackageSpecificExtractDir(pkg)) { if (!this.isPackageSpecificExtractDir(pkg)) {
continue; continue;
} }
@ -5539,7 +5583,7 @@ export class DownloadManager extends EventEmitter {
}); });
} }
public async start(): Promise<void> { public async start(options?: { excludePackageIds?: ReadonlySet<string> }): Promise<void> {
if (this.session.running) { if (this.session.running) {
return; return;
} }
@ -5580,6 +5624,9 @@ export class DownloadManager extends EventEmitter {
if (item.status !== "queued" && item.status !== "reconnect_wait") { if (item.status !== "queued" && item.status !== "reconnect_wait") {
return false; return false;
} }
if (options?.excludePackageIds?.has(item.packageId)) {
return false;
}
const pkg = this.session.packages[item.packageId]; const pkg = this.session.packages[item.packageId];
return Boolean(pkg && !pkg.cancelled && pkg.enabled); return Boolean(pkg && !pkg.cancelled && pkg.enabled);
}); });
@ -5644,9 +5691,15 @@ export class DownloadManager extends EventEmitter {
this.providerStartReservations.clear(); this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear(); this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear(); this.retryStateByItem.clear();
this.http416FreshRestartByItem.clear();
this.itemContributedBytes.clear(); this.itemContributedBytes.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
if (options?.excludePackageIds) {
for (const excluded of options.excludePackageIds) {
this.runPackageIds.delete(excluded);
}
}
this.session.running = true; this.session.running = true;
this.session.paused = false; this.session.paused = false;
@ -5821,7 +5874,9 @@ export class DownloadManager extends EventEmitter {
const itemCount = Object.keys(this.session.items).length; const itemCount = Object.keys(this.session.items).length;
logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`); logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`);
this.foldRuntimeIntoSettings(nowMs()); this.foldRuntimeIntoSettings(nowMs());
if (!this.guardBlocksSessionSave()) {
saveSession(this.storagePaths, this.session); saveSession(this.storagePaths, this.session);
}
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
} else { } else {
logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`); logger.info(`Shutdown-Save übersprungen: skipShutdownPersist=${this.skipShutdownPersist}, blockAllPersistence=${this.blockAllPersistence}`);
@ -6124,10 +6179,29 @@ export class DownloadManager extends EventEmitter {
}, delay); }, delay);
} }
private guardBlocksSessionSave(): boolean {
if (!this.protectAgainstEmptyClobber) {
return false;
}
const isEmpty = Object.keys(this.session.packages).length === 0 && Object.keys(this.session.items).length === 0;
if (isEmpty) {
if (!this.emptyClobberProtectionLogged) {
logger.warn("Leere Session-Speicherung uebersprungen (Schutz nach unlesbarem Start) — vorhandene Datei bleibt unangetastet");
this.emptyClobberProtectionLogged = true;
}
return true;
}
this.protectAgainstEmptyClobber = false;
logger.info("Session-Schutz aufgehoben: nicht-leere Session wird wieder normal gespeichert");
return false;
}
private persistNow(): void { private persistNow(): void {
const now = nowMs(); const now = nowMs();
this.lastPersistAt = now; this.lastPersistAt = now;
if (!this.guardBlocksSessionSave()) {
void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`)); void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`));
}
if (now - this.lastSettingsPersistAt >= 30000) { if (now - this.lastSettingsPersistAt >= 30000) {
this.foldRuntimeIntoSettings(now); this.foldRuntimeIntoSettings(now);
this.lastSettingsPersistAt = now; this.lastSettingsPersistAt = now;
@ -6141,7 +6215,9 @@ export class DownloadManager extends EventEmitter {
const itemCount = Object.keys(this.session.items).length; const itemCount = Object.keys(this.session.items).length;
logger.info(`Pre-Update Sync-Save: ${pkgCount} Pakete, ${itemCount} Items`); logger.info(`Pre-Update Sync-Save: ${pkgCount} Pakete, ${itemCount} Items`);
this.foldRuntimeIntoSettings(nowMs()); this.foldRuntimeIntoSettings(nowMs());
if (!this.guardBlocksSessionSave()) {
saveSession(this.storagePaths, this.session); saveSession(this.storagePaths, this.session);
}
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
} }
@ -8150,7 +8226,13 @@ export class DownloadManager extends EventEmitter {
const provider = resolveMegaDebridProvider(this.settings, this.getExpectedProviderForItem(item)); const provider = resolveMegaDebridProvider(this.settings, this.getExpectedProviderForItem(item));
const serializedValidatingLimit = this.getSerializedValidatingLimit(provider); const serializedValidatingLimit = this.getSerializedValidatingLimit(provider);
if (provider && Number.isFinite(serializedValidatingLimit) && serializedValidatingLimit < Number.MAX_SAFE_INTEGER) { if (provider && Number.isFinite(serializedValidatingLimit) && serializedValidatingLimit < Number.MAX_SAFE_INTEGER) {
return this.getProviderValidatingTaskCount(provider, item.id) >= serializedValidatingLimit; const validating = this.getProviderValidatingTaskCount(provider, item.id);
if (provider === "megadebrid-api") {
const webInFlight = getMegaDebridInFlightCountForMode("web");
const overlapAllowance = Math.min(serializedValidatingLimit, webInFlight);
return validating >= serializedValidatingLimit + overlapAllowance;
}
return validating >= serializedValidatingLimit;
} }
if (provider !== "alldebrid") { if (provider !== "alldebrid") {
return false; return false;
@ -8659,6 +8741,53 @@ export class DownloadManager extends EventEmitter {
this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`); this.queueRetry(item, active, delayMs, `HTTP 416 erkannt, Retry ${active.genericErrorRetries}/${retryDisplayLimit}`);
} }
private escalateHttp416OrFail(item: DownloadItem, active: ActiveTask, claimedTargetPath: string, errorText: string): void {
const freshRestarts = this.http416FreshRestartByItem.get(item.id) || 0;
if (freshRestarts < MAX_HTTP416_FRESH_RESTARTS) {
this.http416FreshRestartByItem.set(item.id, freshRestarts + 1);
const resetTargetPath = claimedTargetPath || String(item.targetPath || "").trim();
if (resetTargetPath) {
try {
fs.rmSync(resetTargetPath, { force: true });
} catch {
}
}
this.releaseTargetPath(item.id);
this.dropItemContribution(item.id);
item.retries += 1;
item.downloadedBytes = 0;
item.totalBytes = null;
item.progressPercent = 0;
item.speedBps = 0;
item.lastError = "";
active.genericErrorRetries = 0;
active.freshRetryUsed = false;
active.resumeHardResetUsed = false;
logger.warn(
`HTTP 416 Budget erschöpft: item=${item.fileName || item.id}, ` +
`kompletter Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS} (Partial verworfen, kein Resume), provider=${item.provider || "?"}`
);
this.queueRetry(item, active, getHttp416FreshRestartDelayMs(), `Range-Konflikt (HTTP 416): Neu-Download ${freshRestarts + 1}/${MAX_HTTP416_FRESH_RESTARTS}`);
this.persistSoon();
this.emitState();
return;
}
this.http416FreshRestartByItem.delete(item.id);
item.status = "failed";
this.recordRunOutcome(item.id, "failed");
item.lastError = errorText;
item.fullStatus = `Fehler: ${item.lastError}`;
item.speedBps = 0;
item.updatedAt = nowMs();
const failPkg = this.session.packages[item.packageId];
if (failPkg) {
this.refreshPackageStatus(failPkg);
}
this.persistSoon();
this.emitState();
this.retryStateByItem.delete(item.id);
}
private startItem(packageId: string, itemId: string): void { private startItem(packageId: string, itemId: string): void {
const item = this.session.items[itemId]; const item = this.session.items[itemId];
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
@ -9249,12 +9378,16 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
} }
if (isHttp416Text(exhaustedReason) && active.genericErrorRetries < maxHttp416Retries) { if (isHttp416Text(exhaustedReason)) {
if (active.genericErrorRetries < maxHttp416Retries) {
this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath); this.scheduleHttp416Retry(item, active, retryDisplayLimit, exhaustedReason, claimedTargetPath);
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
return; return;
} }
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
return;
}
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) { if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
active.resumeHardResetUsed = true; active.resumeHardResetUsed = true;
item.retries += 1; item.retries += 1;
@ -9309,17 +9442,7 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
return; return;
} }
item.status = "failed"; this.escalateHttp416OrFail(item, active, claimedTargetPath, errorText);
this.recordRunOutcome(item.id, "failed");
item.lastError = errorText;
item.fullStatus = `Fehler: ${item.lastError}`;
item.speedBps = 0;
item.updatedAt = nowMs();
const failPkg416 = this.session.packages[item.packageId];
if (failPkg416) this.refreshPackageStatus(failPkg416);
this.persistSoon();
this.emitState();
this.retryStateByItem.delete(item.id);
return; return;
} }
if (shouldFreshRetry) { if (shouldFreshRetry) {
@ -9435,6 +9558,24 @@ export class DownloadManager extends EventEmitter {
} }
const megaRawError = error instanceof Error ? String(error.message || "") : String(error || ""); const megaRawError = error instanceof Error ? String(error.message || "") : String(error || "");
const megaSlowLinkRetry = parseMegaDebridSlowLinkRetry(megaRawError);
if (megaSlowLinkRetry && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1;
item.retries += 1;
item.provider = null;
logger.warn(`Mega-Debrid Link langsam (Timeout): item=${item.fileName || item.id}, retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${megaSlowLinkRetry.delayMs}ms, link=${item.url.slice(0, 80)}`);
this.queueRetry(
item,
active,
megaSlowLinkRetry.delayMs,
`Mega-Debrid: Link zu langsam, Einzel-Retry in ${Math.ceil(megaSlowLinkRetry.delayMs / 1000)}s`
);
item.lastError = megaSlowLinkRetry.detail || errorText;
this.persistSoon();
this.emitState();
return;
}
const megaCooldownRetry = parseMegaDebridCooldownRetry(megaRawError); const megaCooldownRetry = parseMegaDebridCooldownRetry(megaRawError);
if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) { if (megaCooldownRetry && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1; active.unrestrictRetries += 1;

View File

@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron"; import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
import { AddLinksPayload, AppSettings, DebridProvider, UpdateInstallProgress } from "../shared/types"; import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, UpdateInstallProgress } from "../shared/types";
import { AppController } from "./app-controller"; import { AppController } from "./app-controller";
import { IPC_CHANNELS } from "../shared/ipc"; import { IPC_CHANNELS } from "../shared/ipc";
import { getLogFilePath, logger } from "./logger"; import { getLogFilePath, logger } from "./logger";
@ -571,7 +571,7 @@ function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => { ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
const options = { const options = {
defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.mdd`, defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`,
filters: [{ name: "MDD Backup", extensions: ["mdd"] }] filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
}; };
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
@ -671,6 +671,33 @@ function registerIpcHandlers(): void {
return { path: rotated.path }; return { path: rotated.path };
}); });
ipcMain.handle(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => {
return controller.getRemoteDiagnostics();
});
ipcMain.handle(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, async (_event: IpcMainInvokeEvent, input: EnableRemoteDiagnosticsInput) => {
if (!input || (input.hostMode !== "local" && input.hostMode !== "network")) {
throw new Error("hostMode muss 'local' oder 'network' sein");
}
const allowlist = Array.isArray(input.allowlist) ? input.allowlist.map((entry) => String(entry)) : [];
return controller.enableRemoteDiagnostics({
hostMode: input.hostMode,
publicHost: String(input.publicHost || ""),
port: input.port ? Number(input.port) : undefined,
allowlist,
name: input.name ? String(input.name) : undefined,
rotateToken: Boolean(input.rotateToken)
});
});
ipcMain.handle(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS, async () => {
return controller.disableRemoteDiagnostics();
});
ipcMain.handle(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => {
return controller.rotateRemoteDiagnosticsToken();
});
ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => { ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
validateString(itemId, "itemId"); validateString(itemId, "itemId");
const logPath = controller.getItemLogPath(itemId); const logPath = controller.getItemLogPath(itemId);

View File

@ -461,6 +461,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems, hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection, confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads, backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
backupIncludeMcp: settings.backupIncludeMcp !== undefined ? Boolean(settings.backupIncludeMcp) : defaults.backupIncludeMcp,
notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl, notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl,
notifyMention: asText(settings.notifyMention) || defaults.notifyMention, notifyMention: asText(settings.notifyMention) || defaults.notifyMention,
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted, notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,
@ -889,21 +890,50 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
return session; return session;
} }
const TRANSIENT_READ_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]);
function sleepSyncMs(ms: number): void {
if (ms <= 0) {
return;
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function readSessionFile(filePath: string): SessionState | null { function readSessionFile(filePath: string): SessionState | null {
let raw: string | null = null;
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try { try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; raw = fs.readFileSync(filePath, "utf8");
break;
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code || "";
if (TRANSIENT_READ_CODES.has(code) && attempt < maxAttempts) {
const backoffMs = 100 * 2 ** (attempt - 1);
logger.warn(`Session-Datei vorübergehend gesperrt (${code}), Versuch ${attempt}/${maxAttempts}, warte ${backoffMs}ms: ${filePath}`);
sleepSyncMs(backoffMs);
continue;
}
if (code === "EACCES" || code === "EPERM") {
logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
} else {
logger.error(`Session-Datei nicht lesbar (${code || "?"}): ${filePath}: ${String(error)}`);
}
return null;
}
}
if (raw === null) {
return null;
}
try {
const parsed = JSON.parse(raw) as unknown;
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed)); const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
const pkgCount = Object.keys(session.packages).length; const pkgCount = Object.keys(session.packages).length;
const itemCount = Object.keys(session.items).length; const itemCount = Object.keys(session.items).length;
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`); logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
return session; return session;
} catch (error) { } catch (error) {
const code = (error as NodeJS.ErrnoException)?.code || ""; logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`);
if (code === "EACCES" || code === "EPERM") {
logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
} else {
logger.error(`Session-Datei nicht lesbar: ${filePath}: ${String(error)}`);
}
return null; return null;
} }
} }
@ -1003,17 +1033,31 @@ export function emptySession(): SessionState {
}; };
} }
export function loadSession(paths: StoragePaths): SessionState { export type SessionLoadStatus =
| "ok"
| "recovered-backup"
| "recovered-temp"
| "empty-fresh"
| "empty-unreadable";
export interface SessionLoadResult {
session: SessionState;
status: SessionLoadStatus;
}
export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
const backupFile = sessionBackupPath(paths.sessionFile); const backupFile = sessionBackupPath(paths.sessionFile);
const syncTempFile = sessionTempPath(paths.sessionFile, "sync");
const asyncTempFile = sessionTempPath(paths.sessionFile, "async");
const primaryExists = fs.existsSync(paths.sessionFile); const primaryExists = fs.existsSync(paths.sessionFile);
const backupExists = fs.existsSync(backupFile);
const anyTempExists = fs.existsSync(syncTempFile) || fs.existsSync(asyncTempFile);
if (!primaryExists) { if (!primaryExists) {
const hasRecoverable = fs.existsSync(backupFile) if (!backupExists && !anyTempExists) {
|| fs.existsSync(sessionTempPath(paths.sessionFile, "sync"))
|| fs.existsSync(sessionTempPath(paths.sessionFile, "async"));
if (!hasRecoverable) {
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session"); logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
return emptySession(); return { session: emptySession(), status: "empty-fresh" };
} }
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht"); logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
} }
@ -1022,7 +1066,7 @@ export function loadSession(paths: StoragePaths): SessionState {
if (primary) { if (primary) {
const primaryPkgCount = Object.keys(primary.packages).length; const primaryPkgCount = Object.keys(primary.packages).length;
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) { if (primaryPkgCount === 0 && backupExists) {
const backup = readSessionFile(backupFile); const backup = readSessionFile(backupFile);
if (backup) { if (backup) {
const backupPkgCount = Object.keys(backup.packages).length; const backupPkgCount = Object.keys(backup.packages).length;
@ -1030,29 +1074,27 @@ export function loadSession(paths: StoragePaths): SessionState {
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`); logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
try { try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer); const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
const tempPath = sessionTempPath(paths.sessionFile, "sync"); fs.writeFileSync(syncTempFile, payload, "utf8");
fs.writeFileSync(tempPath, payload, "utf8"); syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch { } catch {
} }
return backup; return { session: backup, status: "recovered-backup" };
} }
} }
} }
return primary; return { session: primary, status: "ok" };
} }
const backup = fs.existsSync(backupFile) ? readSessionFile(backupFile) : null; const backup = backupExists ? readSessionFile(backupFile) : null;
if (backup) { if (backup) {
logger.warn("Session defekt, Backup-Datei wird verwendet"); logger.warn("Session defekt, Backup-Datei wird verwendet");
try { try {
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer); const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
const tempPath = sessionTempPath(paths.sessionFile, "sync"); fs.writeFileSync(syncTempFile, payload, "utf8");
fs.writeFileSync(tempPath, payload, "utf8"); syncRenameWithExdevFallback(syncTempFile, paths.sessionFile);
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch { } catch {
} }
return backup; return { session: backup, status: "recovered-backup" };
} }
for (const kind of ["sync", "async"] as const) { for (const kind of ["sync", "async"] as const) {
@ -1066,13 +1108,21 @@ export function loadSession(paths: StoragePaths): SessionState {
fs.writeFileSync(paths.sessionFile, payload, "utf8"); fs.writeFileSync(paths.sessionFile, payload, "utf8");
} catch { } catch {
} }
return tmpSession; return { session: tmpSession, status: "recovered-temp" };
} }
} }
} }
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen)"); if (primaryExists || backupExists || anyTempExists) {
return emptySession(); logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen) — Schutz gegen leeres Ueberschreiben aktiv");
return { session: emptySession(), status: "empty-unreadable" };
}
return { session: emptySession(), status: "empty-fresh" };
}
export function loadSession(paths: StoragePaths): SessionState {
return loadSessionWithStatus(paths).session;
} }
export function saveSession(paths: StoragePaths, session: SessionState): void { export function saveSession(paths: StoragePaths, session: SessionState): void {
@ -1087,7 +1137,13 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer); const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
const tempPath = sessionTempPath(paths.sessionFile, "sync"); const tempPath = sessionTempPath(paths.sessionFile, "sync");
try { try {
fs.writeFileSync(tempPath, payload, "utf8"); const fd = fs.openSync(tempPath, "w");
try {
fs.writeSync(fd, payload);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
syncRenameWithExdevFallback(tempPath, paths.sessionFile); syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch (error) { } catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { } try { fs.rmSync(tempPath, { force: true }); } catch { }
@ -1103,7 +1159,13 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
await fs.promises.mkdir(paths.baseDir, { recursive: true }); await fs.promises.mkdir(paths.baseDir, { recursive: true });
await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {}); await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {});
const tempPath = sessionTempPath(paths.sessionFile, "async"); const tempPath = sessionTempPath(paths.sessionFile, "async");
await fsp.writeFile(tempPath, payload, "utf8"); const handle = await fsp.open(tempPath, "w");
try {
await handle.writeFile(payload, "utf8");
await handle.sync();
} finally {
await handle.close();
}
if (generation < syncSaveGeneration) { if (generation < syncSaveGeneration) {
await fsp.rm(tempPath, { force: true }).catch(() => {}); await fsp.rm(tempPath, { force: true }).catch(() => {});
return; return;

View File

@ -7,8 +7,10 @@ import {
DebridLinkHostLimitInfo, DebridLinkHostLimitInfo,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
EnableRemoteDiagnosticsInput,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
RemoteDiagnosticsInfo,
RendererErrorReport, RendererErrorReport,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
@ -74,6 +76,10 @@ const api: ElectronApi = {
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG), getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes), setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN), rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
getRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS),
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input),
disableRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS),
rotateRemoteDiagnosticsToken: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN),
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN), openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),

View File

@ -16,6 +16,7 @@ import type {
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackageEntry, PackageEntry,
RemoteDiagnosticsInfo,
StartConflictEntry, StartConflictEntry,
UiSnapshot, UiSnapshot,
UpdateCheckResult, UpdateCheckResult,
@ -855,7 +856,7 @@ const emptySnapshot = (): UiSnapshot => ({
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeMcp: false,
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
accountListShowDetailedDebridLinkKeys: false, accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
@ -1761,6 +1762,14 @@ export function App(): ReactElement {
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null); const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null); const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null); const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null);
const [remoteDiagOpen, setRemoteDiagOpen] = useState(false);
const [remoteDiagBusy, setRemoteDiagBusy] = useState(false);
const [rdHostMode, setRdHostMode] = useState<"local" | "network">("local");
const [rdPublicHost, setRdPublicHost] = useState("");
const [rdPort, setRdPort] = useState("9868");
const [rdAllowlist, setRdAllowlist] = useState("");
const [rdName, setRdName] = useState("");
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null); const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]); const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null); const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
@ -4328,6 +4337,89 @@ export function App(): ReactElement {
}); });
}; };
const applyRemoteDiagInfo = (info: RemoteDiagnosticsInfo): void => {
setRemoteDiag(info);
setRdHostMode(info.status.localOnly ? "local" : "network");
setRdPublicHost(info.publicHost || (info.status.localOnly ? "" : (info.suggestedHosts[0] || "")));
setRdPort(String(info.status.port || 9868));
setRdAllowlist(info.allowlist.join("\n"));
setRdName(info.name || "");
};
const onOpenRemoteDiagnostics = async (): Promise<void> => {
closeMenus();
try {
const info = await window.rd.getRemoteDiagnostics();
applyRemoteDiagInfo(info);
setRemoteDiagOpen(true);
} catch (error) {
showToast(`Ferndiagnose-Status fehlgeschlagen: ${String(error)}`, 3000);
}
};
const onSubmitRemoteDiagnostics = async (): Promise<void> => {
const port = Number(rdPort) || 9868;
const allowlist = rdAllowlist.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
if (rdHostMode === "network" && allowlist.length === 0) {
showToast("Netzwerkmodus braucht mindestens eine IP oder CIDR in der Allowlist", 3600);
return;
}
setRemoteDiagBusy(true);
try {
const info = await window.rd.enableRemoteDiagnostics({
hostMode: rdHostMode,
publicHost: rdPublicHost,
port,
allowlist,
name: rdName
});
applyRemoteDiagInfo(info);
showToast(info.status.running ? "Ferndiagnose aktiv" : "Ferndiagnose konfiguriert", 2600);
} catch (error) {
showToast(`Aktivieren fehlgeschlagen: ${String(error)}`, 3600);
} finally {
setRemoteDiagBusy(false);
}
};
const onDisableRemoteDiagnostics = async (): Promise<void> => {
setRemoteDiagBusy(true);
try {
const info = await window.rd.disableRemoteDiagnostics();
applyRemoteDiagInfo(info);
showToast("Ferndiagnose deaktiviert", 2400);
} catch (error) {
showToast(`Deaktivieren fehlgeschlagen: ${String(error)}`, 3000);
} finally {
setRemoteDiagBusy(false);
}
};
const onRotateRemoteDiagnosticsToken = async (): Promise<void> => {
setRemoteDiagBusy(true);
try {
const info = await window.rd.rotateRemoteDiagnosticsToken();
applyRemoteDiagInfo(info);
showToast("Neues Token - alter Verbindungscode ist ungueltig", 3000);
} catch (error) {
showToast(`Token-Rotation fehlgeschlagen: ${String(error)}`, 3000);
} finally {
setRemoteDiagBusy(false);
}
};
const onCopyRemoteDiagnosticsCode = async (): Promise<void> => {
if (!remoteDiag?.code) {
return;
}
try {
await navigator.clipboard.writeText(remoteDiag.code);
showToast("Verbindungscode kopiert", 2200);
} catch {
showToast("Kopieren fehlgeschlagen", 2200);
}
};
const onMenuRestart = (): void => { const onMenuRestart = (): void => {
closeMenus(); closeMenus();
void window.rd.restart(); void window.rd.restart();
@ -4718,6 +4810,7 @@ export function App(): ReactElement {
<button className="menu-submenu-trigger">Remote-Support</button> <button className="menu-submenu-trigger">Remote-Support</button>
{openSubmenu === "hilfe-remote" && ( {openSubmenu === "hilfe-remote" && (
<div className="menu-submenu-dropdown"> <div className="menu-submenu-dropdown">
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose (MCP) </span></button>
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button> <button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button> <button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
</div> </div>
@ -5349,6 +5442,8 @@ export function App(): ReactElement {
<div className="setting-hint">Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.</div> <div className="setting-hint">Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste mitsichern</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste mitsichern</label>
<div className="setting-hint">Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.</div> <div className="setting-hint">Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeMcp} onChange={(e) => setBool("backupIncludeMcp", e.target.checked)} /> Ferndiagnose-Einstellungen mitsichern</label>
<div className="setting-hint">Allowlist, Port und Freigabemodus (lokal/Netzwerk) reisen mit. Verbindungs-Token und eigene Adresse bleiben pro Server nach dem Import einmal Aktivieren" drücken.</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => { <label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
const next = e.target.checked ? "light" : "dark"; const next = e.target.checked ? "light" : "dark";
settingsDraftRevisionRef.current += 1; settingsDraftRevisionRef.current += 1;
@ -5934,6 +6029,97 @@ export function App(): ReactElement {
</div> </div>
)} )}
{remoteDiagOpen && (
<div className="modal-backdrop" onClick={() => setRemoteDiagOpen(false)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<h3>Ferndiagnose (MCP)</h3>
<p>Aktiviert einen abgesicherten Lesezugriff auf Status, Logs und Fehler dieses Servers. Den Verbindungscode dem Assistenten geben - er verbindet sich, sieht alles und behebt Probleme.</p>
<div className="rd-status-line">
<span className={`rd-dot${remoteDiag?.status.running ? " on" : ""}`} />
<span>
{remoteDiag?.status.running
? `Aktiv auf ${remoteDiag.status.host}:${remoteDiag.status.port}${remoteDiag.status.localOnly ? " (nur lokal)" : ` (Allowlist: ${remoteDiag.status.allowlistCount})`}`
: "Inaktiv"}
</span>
</div>
<div className="rd-field">
<label>Sichtbarkeit</label>
<div className="rd-seg">
<button className={rdHostMode === "local" ? "active" : ""} onClick={() => setRdHostMode("local")}>Nur lokal</button>
<button className={rdHostMode === "network" ? "active" : ""} onClick={() => setRdHostMode("network")}>Im Netzwerk</button>
</div>
<span className="rd-hint">
{rdHostMode === "local"
? "Bindet nur an 127.0.0.1. Fernzugriff nur ueber einen Tunnel (z.B. Tailscale/SSH) - die sicherste Variante."
: "Bindet an 0.0.0.0. Erreichbar im Netzwerk, erfordert eine Allowlist. Nur in vertrauenswuerdigen Netzen/VPN nutzen."}
</span>
</div>
<div className="rd-field">
<label>Oeffentliche Adresse (fuer den Verbindungscode)</label>
<input
value={rdPublicHost}
placeholder={rdHostMode === "local" ? "127.0.0.1 oder Tunnel-Adresse" : "Server-IP oder DNS-Name"}
onChange={(event) => setRdPublicHost(event.target.value)}
/>
{remoteDiag && remoteDiag.suggestedHosts.length > 0 && (
<div className="rd-chips">
{remoteDiag.suggestedHosts.map((host) => (
<button key={host} className="rd-chip" onClick={() => setRdPublicHost(host)}>{host}</button>
))}
</div>
)}
</div>
<div className="rd-field rd-field-inline">
<div className="rd-field">
<label>Port</label>
<input value={rdPort} onChange={(event) => setRdPort(event.target.value.replace(/[^0-9]/g, ""))} />
</div>
<div className="rd-field">
<label>Name (optional)</label>
<input value={rdName} placeholder="z.B. Server-Berlin" onChange={(event) => setRdName(event.target.value)} />
</div>
</div>
{rdHostMode === "network" && (
<div className="rd-field">
<label>Allowlist - erlaubte IPs/CIDR (eine pro Zeile)</label>
<textarea
value={rdAllowlist}
placeholder={"203.0.113.5\n10.0.0.0/24"}
onChange={(event) => setRdAllowlist(event.target.value)}
/>
<span className="rd-hint">Pflicht im Netzwerkmodus. Nur diese Quell-IPs duerfen verbinden (zusaetzlich zum Token). Loopback ist immer erlaubt.</span>
</div>
)}
{remoteDiag?.status.running && remoteDiag.code && (
<div className="rd-field">
<label>Verbindungscode</label>
<div className="rd-code">{remoteDiag.code}</div>
<div className="rd-chips">
<button className="btn" onClick={() => { void onCopyRemoteDiagnosticsCode(); }}>Kopieren</button>
<button className="btn" onClick={() => { void onRotateRemoteDiagnosticsToken(); }} disabled={remoteDiagBusy}>Token neu (Code ungueltig machen)</button>
</div>
<span className="rd-hint">Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.</span>
</div>
)}
<div className="modal-actions">
<button className="btn" onClick={() => setRemoteDiagOpen(false)}>Schliessen</button>
{remoteDiag?.status.running && (
<button className="btn danger" onClick={() => { void onDisableRemoteDiagnostics(); }} disabled={remoteDiagBusy}>Deaktivieren</button>
)}
<button className="btn accent" onClick={() => { void onSubmitRemoteDiagnostics(); }} disabled={remoteDiagBusy}>
{remoteDiag?.status.running ? "Aktualisieren" : "Aktivieren"}
</button>
</div>
</div>
</div>
)}
{deleteConfirm && (() => { {deleteConfirm && (() => {
const itemCount = [...deleteConfirm.ids].filter((id) => snapshot.session.items[id]).length; const itemCount = [...deleteConfirm.ids].filter((id) => snapshot.session.items[id]).length;
const pkgCount = [...deleteConfirm.ids].filter((id) => snapshot.session.packages[id]).length; const pkgCount = [...deleteConfirm.ids].filter((id) => snapshot.session.packages[id]).length;

View File

@ -3360,3 +3360,110 @@ td {
.rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; } .rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; }
.rotation-event .rotation-body strong { font-weight: 600; } .rotation-event .rotation-body strong { font-weight: 600; }
.rotation-event .rotation-reason { color: var(--muted, #a59c8e); } .rotation-event .rotation-reason { color: var(--muted, #a59c8e); }
.rd-status-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text);
}
.rd-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--muted);
flex: 0 0 auto;
}
.rd-dot.on {
background: #3fb950;
}
.rd-field {
display: grid;
gap: 5px;
}
.rd-field > label {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.rd-field input,
.rd-field textarea {
width: 100%;
box-sizing: border-box;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
font-family: inherit;
}
.rd-field input:focus,
.rd-field textarea:focus {
outline: none;
border-color: var(--accent);
}
.rd-field textarea {
resize: vertical;
min-height: 62px;
}
.rd-field-inline {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.rd-hint {
font-size: 12px;
color: var(--muted);
line-height: 1.45;
}
.rd-seg {
display: flex;
width: fit-content;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.rd-seg button {
background: transparent;
color: var(--muted);
border: none;
padding: 7px 14px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.rd-seg button.active {
background: var(--accent);
color: #1a1206;
}
.rd-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rd-chip {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
color: var(--muted);
}
.rd-chip:hover {
color: var(--text);
border-color: var(--accent);
}
.rd-code {
font-family: ui-monospace, "Cascadia Code", monospace;
font-size: 12px;
word-break: break-all;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 10px;
max-height: 120px;
overflow-y: auto;
color: var(--text);
}

View File

@ -52,6 +52,10 @@ export const IPC_CHANNELS = {
GET_TRACE_CONFIG: "app:get-trace-config", GET_TRACE_CONFIG: "app:get-trace-config",
SET_TRACE_ENABLED: "app:set-trace-enabled", SET_TRACE_ENABLED: "app:set-trace-enabled",
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token", ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
GET_REMOTE_DIAGNOSTICS: "app:get-remote-diagnostics",
ENABLE_REMOTE_DIAGNOSTICS: "app:enable-remote-diagnostics",
DISABLE_REMOTE_DIAGNOSTICS: "app:disable-remote-diagnostics",
ROTATE_REMOTE_DIAGNOSTICS_TOKEN: "app:rotate-remote-diagnostics-token",
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login", OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login", OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies", IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",

View File

@ -7,8 +7,10 @@ import type {
DebridLinkHostLimitInfo, DebridLinkHostLimitInfo,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
EnableRemoteDiagnosticsInput,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
RemoteDiagnosticsInfo,
RendererErrorReport, RendererErrorReport,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
@ -71,6 +73,10 @@ export interface ElectronApi {
getTraceConfig: () => Promise<SupportTraceConfig>; getTraceConfig: () => Promise<SupportTraceConfig>;
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>; setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
rotateDebugToken: () => Promise<{ path: string }>; rotateDebugToken: () => Promise<{ path: string }>;
getRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise<RemoteDiagnosticsInfo>;
disableRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
rotateRemoteDiagnosticsToken: () => Promise<RemoteDiagnosticsInfo>;
openRealDebridLogin: () => Promise<void>; openRealDebridLogin: () => Promise<void>;
openAllDebridLogin: () => Promise<void>; openAllDebridLogin: () => Promise<void>;
importBestDebridCookies: () => Promise<number>; importBestDebridCookies: () => Promise<number>;

View File

@ -134,6 +134,7 @@ export interface AppSettings {
hideExtractedItems: boolean; hideExtractedItems: boolean;
confirmDeleteSelection: boolean; confirmDeleteSelection: boolean;
backupIncludeDownloads: boolean; backupIncludeDownloads: boolean;
backupIncludeMcp: boolean;
notifyUrl: string; notifyUrl: string;
notifyMention: string; notifyMention: string;
notifyOnPackageCompleted: boolean; notifyOnPackageCompleted: boolean;
@ -537,3 +538,30 @@ export interface RendererErrorReport {
column?: number; column?: number;
componentStack?: string; componentStack?: string;
} }
export interface RemoteDiagnosticsStatus {
running: boolean;
host: string;
port: number;
hasToken: boolean;
localOnly: boolean;
allowlistCount: number;
}
export interface RemoteDiagnosticsInfo {
status: RemoteDiagnosticsStatus;
code: string | null;
publicHost: string;
name: string;
allowlist: string[];
suggestedHosts: string[];
}
export interface EnableRemoteDiagnosticsInput {
hostMode: "local" | "network";
publicHost: string;
port?: number;
allowlist: string[];
name?: string;
rotateToken?: boolean;
}

View File

@ -1,37 +1,42 @@
# Massives Conversion-Logging (v1.7.213) + Failover-Fix # MCP-Ferndiagnose (Goal, ultracode) — v1.7.223
## Problem (live belegt, 2026-06-17) ## Ziel (Nutzer)
- Links haengen mit "Unrestrict Timeout nach 60s", R53/R64, halten Download-Slot → Stop-and-Go. "Baue massive Diagnose-Funktionen ein (MCP), so dass ich MCP auf nem Windows-Server aktivieren kann und du
- Web-first: 1 globaler 60s-Timeout um die GANZE Provider-Kette → Web frisst das Budget, auf den Server zugreifst und WIRKLICH ALLES siehst (State, Fehler, Logs, Probleme). 5-6 Server, ueber
API-Failover wird NIE versucht (debrid.ts 3801 `signal.aborted` → throw). Retry startet wieder bei Web. Verbindungscode. Du verbindest dich → liest alles → behebst Probleme direkt."
- API-first laeuft "um einiges fluessiger" (User bestaetigt: API resolved UND downloaded auf dem Server).
- ABER: API-Token-Fehler ("Token error, please log-in") + "Login oder Unrestrict fehlgeschlagen"
→ beide Accounts kriegen Cooldown → Doom-Loop. connectApi single-flightet Logins schon (pendingConnects),
also ist die Token-Ursache NICHT trivial → braucht Token-Lifecycle-Logging.
## Kern-Blindstelle ## Architektur (Advisor-bestaetigt v1)
Bestehende Logs (account-rotation.log) zeigen `elapsedMs` aber NICHT: - Standalone **stdio MCP-Bridge** auf MEINER (Claude-Code-)Maschine, proxyt zur bestehenden HTTP `debug-server.ts`
- queue-wait vs aktive Arbeit (war der 60s-Timeout Warten in der Queue oder echtes Arbeiten?) jedes Servers via **Verbindungscode**. Eine Bridge bedient alle 5-6 Server.
- Token-Lifecycle (cacheHit/freshLogin/invalidation) → woher "Token error"? - KEIN eingebettetes MCP-over-HTTP (verworfen: hand-rolled Protokoll auf Internet-Oberflaeche = mehr Risiko).
- Provider-Ketten-Entscheidung pro Item (welche Provider, welches Budget, warum Stopp) - Bridge-Code identisch fuer Direkt-IP vs spaeterer Tunnel (proxyt host:port).
- Was war in-flight als der Caller-60s-Timeout feuerte (Provider/Account/Phase) + Slot-Belegung - Verbindungscode: `rddiag:v1:<base64url(JSON {v,h:host,p:port,t:token,n?:name,fp?:certFp})>`. Nutzer liefert
oeffentlichen Host (nicht auto-detecten).
## Plan (Release 213 = NUR Logging, bewusst KEINE Verhaltensaenderung — damit das naechste Bundle das ECHTE aktuelle Verhalten zeigt) ## Sicherheitsmodell (Advisor, first-class)
- [x] `src/main/conversion-trace.ts`: AsyncLocalStorage-Trace + dedizierte `conversion.log`. EIN strukturierter Block pro Unrestrict-Versuch. Plain HTTP + Bearer ueber Internet = sniffbares Token mit Lesezugriff auf sensible Logs. Mitigations:
- [x] Wiring: init in app-controller, shutdown, support-bundle. - App-seitige IP-Allowlist (extractDebugClientIp existiert schon).
- [x] Instrumentiert (nur tracePhase-Calls, additiv, no-op ohne aktiven Trace): - `/trace/config` MUTIERT → "read-only"-Claim auditieren: Writes von Remote-Oberflaeche gaten oder umlabeln.
- download-manager unrestrict-Boundary: runWithConversionTrace + Caller-Timeout-Attribution + describeSlotOccupancy. - Opt-in + sofort widerrufbar (Token-Rotation killt Zugang).
- debrid.ts Provider-Kette: chain-try/chain-ok/chain-failed/chain-aborted (zeigt ob Failover feuert). - debug_token.txt in userData bestaetigen (ueberlebt Auto-Update).
- debrid.ts Mega-Rotation: mega-account workMs + outcome (ok/failed/fatal/aborted) + cooldown. - Optional self-signed Cert + Fingerprint im Code gepinnt (NICHT v1-blockierend).
- MegaDebridClient connectApi/doConnectApi/unrestrictViaApi: token cached/pending-join/fresh-login + connectMs + getLink response_code/text (DAS klaert "Token error").
- mega-web-fallback runExclusive: web-queue queueWaitMs + workMs (DAS klaert ob 60s = Warten oder Arbeit).
- [x] Test: conversion-trace.test.ts (Formatter + ALS-Kontext). tsc unveraendert 6.
- [ ] Build + Suite gruen. Release 213 (Gitea + Mirror).
## DEFERRED auf 214 (erst NACH Logs, kein Blind-Fix mehr) ## Phasen
- 60s global → per-Provider-Budget (Failover feuert) — proven, aber erst messen: tritt der 60s ueberhaupt bei API-first auf, und ist es Queue oder Arbeit? - [x] **P0 Vertical Slice (Diskriminator) — ERLEDIGT, harness ALL PASS:** Bridge → debug-server via stdio JSON-RPC, echte Daten zurueck.
- API Token-Error Doom-Loop ("Token error, please log-in") — Mechanismus per conversion.log verifizieren, DANN fixen (evtl. per-Account-Serialisierung fuer API wie bei Web). - [x] MCP SDK API holen (context7) → @modelcontextprotocol/sdk 1.29.0, registerTool(name,{inputSchema:zodShape},cb)
- Config-Realitaet an User: 2. Account (xe) lief abgelaufen/deaktiviert → 212-Parallelitaet griff nicht; API-first ist der schnelle Pfad. - [x] Bridge in `tools/rd-diagnostics-mcp/` (eigenes package.json, NICHT in App-Bundle): code.mjs/http.mjs/bridge.mjs/gen-code.mjs
- [x] 14 Tools: rd_servers/rd_ping/rd_diagnostics/rd_status/rd_items/rd_packages/rd_errors/rd_logs/rd_history/rd_accounts/rd_host/rd_self_check/rd_get + Multi-Server (code|server|RDDIAG_CODE|RDDIAG_SERVERS)
- [x] Verbindungscode-Codec rddiag:v1:base64url({v,h,p,t,n?,fp?,s?})
- [x] Test-Harness (test/harness.mjs): fake debug-server (auth+routes+query-echo) + Bridge als stdio-Child → 19 Checks gruen (handshake, tools/list, ping, diagnostics+query-passthrough, logs-mapping, errors, escape-hatch, 401, missing-code, unreachable+hint)
- [x] **P1 Security-Hardening — ERLEDIGT:** IP-Allowlist (exakt+CIDR), erzwungen VOR Auth am ECHTEN Socket-Peer (req.socket.remoteAddress), NICHT X-Forwarded-For (Advisor: XFF faelschbar → Bypass; gefixt+Threat-Test). Fail-closed. /trace/config belassen (zeitbegrenzt). userData/runtime verifiziert. Log-Audit: keine Secrets in /logs/*.
- [x] **P2 One-Click-Enable + Code — ERLEDIGT:** restartDebugServer ('close'+closeAllConnections, EADDRINUSE). IPC get/enable/disable/rotate + Controller + Typen. Flache Modal-UI (Hilfe→Remote-Support→"Ferndiagnose (MCP)"): Status, lokal/netzwerk, Public-Host+Chips, Allowlist, Code+Copy+Token-Rotation+Deaktivieren.
- [x] **P3 — durch bestehende Endpunkte abgedeckt:** /accounts (Cooldown/Rotation), /errors, /status, /diagnostics via Bridge. Kein neuer Endpunkt noetig.
- [x] **P4 Verify — ERLEDIGT:** Suite 906 gruen, tsc=6, Harness gruen, Advisor (fing XFF-Bypass). Release v1.7.223 Gitea (6b52678, 4 Assets) + Mirror (24485be, 4 Assets, Claude-frei). Bridge `claude mcp add` (user, ✔ Connected).
- [ ] **P5 Reachability (NUTZER):** Ferndiagnose auf 1 Server an → Code an mich → ich verbinde. Entscheid: Tunnel (sicherste, "Nur lokal") vs Direkt-Bind 0.0.0.0+Allowlist (nur vertrauenswuerdiges Netz/VPN; Token reist plain HTTP).
## Review ## Review
Logging-Release: pure Diagnose, null Verhaltensrisiko. Naechster Schritt: User reproduziert, schickt Bundle, conversion.log zeigt Queue-vs-Arbeit + Token-Lifecycle eindeutig → praeziser Fix in 214. Vertikaler Slice zuerst (Bridge gegen Fake-Debug-Server, JSON-RPC stdio), dann App-Seite load-bearing-first
(Backend+Tests vor UI). Advisor fing einen releaseblockierenden Bug: Allowlist nutzte extractDebugClientIp
(X-Forwarded-For zuerst = angreiferkontrolliert) → Bypass per `X-Forwarded-For: 127.0.0.1`; Tests maskierten es
(injizierten die IP per genau dem Header). Fix: Enforcement am Socket-Peer, XFF nur fuers Log; Threat-Test
(socket 8.8.8.8 + XFF 127.0.0.1 → denied). Empfohlener Transport: Loopback+Tunnel; Direkt-Bind nur mit Allowlist.

232
tests/backup-mcp.test.ts Normal file
View File

@ -0,0 +1,232 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import { buildBackupPayload, resolveMcpRemoteRestore, BackupMcpRemote } from "../src/main/backup-payload";
import { defaultSettings } from "../src/main/constants";
import { normalizeSettings } from "../src/main/storage";
import {
startDebugServer,
stopDebugServer,
restartDebugServer,
writeDebugServerConfig,
getDebugAllowlist,
getDebugServerRuntimeStatus
} from "../src/main/debug-server";
import type { DownloadManager } from "../src/main/download-manager";
import type { AppSettings, SessionState } from "../src/shared/types";
const tempDirs: string[] = [];
function input(settingsOverride: Partial<AppSettings>, mcpRemote?: BackupMcpRemote) {
return {
settings: { ...defaultSettings(), ...settingsOverride } as AppSettings,
appVersion: "1.7.224",
exportedAt: "2026-06-19T00:00:00.000Z",
session: {} as unknown as SessionState,
history: [],
mcpRemote
};
}
async function getFreePort(): Promise<number> {
const probe = http.createServer();
probe.listen(0, "127.0.0.1");
await once(probe, "listening");
const address = probe.address();
if (!address || typeof address === "string") {
throw new Error("port probe failed");
}
probe.close();
await once(probe, "close");
return address.port;
}
async function waitForReady(url: string): Promise<void> {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) {
return;
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`debug server not ready: ${url}`);
}
afterEach(() => {
stopDebugServer();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
describe("backup mcpRemote export gating", () => {
it("includes mcpRemote when backupIncludeMcp is on", () => {
const section: BackupMcpRemote = { allowlist: ["10.0.0.5", "192.168.1.0/24"], port: 9999, hostMode: "network" };
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, section));
expect(payload.mcpRemote).toEqual(section);
});
it("omits mcpRemote when the toggle is off even if a section is provided", () => {
const payload = buildBackupPayload(input({ backupIncludeMcp: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
expect(payload.mcpRemote).toBeUndefined();
});
it("omits mcpRemote when toggle on but no section gathered", () => {
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, undefined));
expect(payload.mcpRemote).toBeUndefined();
});
it("the mcpRemote section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
expect(payload.mcpRemote && Object.keys(payload.mcpRemote).sort()).toEqual(["allowlist", "hostMode", "port"]);
const sectionJson = JSON.stringify(payload.mcpRemote);
expect(sectionJson.toLowerCase()).not.toContain("token");
expect(sectionJson).not.toContain("publicHost");
expect(sectionJson.toLowerCase()).not.toContain("\"name\"");
});
});
describe("backupIncludeMcp settings persistence", () => {
it("normalizeSettings preserves backupIncludeMcp (the toggle survives save/load)", () => {
expect(normalizeSettings({ backupIncludeMcp: true } as unknown as AppSettings).backupIncludeMcp).toBe(true);
expect(normalizeSettings({ backupIncludeMcp: false } as unknown as AppSettings).backupIncludeMcp).toBe(false);
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeMcp).toBe(false);
});
});
describe("resolveMcpRemoteRestore", () => {
it("maps network + non-empty allowlist to 0.0.0.0", () => {
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
.toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] });
});
it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => {
expect(resolveMcpRemoteRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
});
it("maps local to 127.0.0.1", () => {
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
});
it("rejects an out-of-range or non-integer port", () => {
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
});
it("filters non-string and blank allowlist entries and trims", () => {
const r = resolveMcpRemoteRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" });
expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]);
});
it("returns null for missing or empty/invalid sections", () => {
expect(resolveMcpRemoteRestore(undefined)).toBeNull();
expect(resolveMcpRemoteRestore(null)).toBeNull();
expect(resolveMcpRemoteRestore("x")).toBeNull();
expect(resolveMcpRemoteRestore({})).toBeNull();
});
});
describe("backup mcpRemote live restore round-trip", () => {
it("export -> resolve -> apply is reflected in the running debug-server (proves restart fired)", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp-"));
tempDirs.push(baseDir);
const startPort = await getFreePort();
const restorePort = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt-secret", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt-secret`);
expect(getDebugAllowlist()).toEqual([]);
const payload = buildBackupPayload(input(
{ backupIncludeMcp: true },
{ allowlist: ["203.0.113.4", "10.0.0.0/24"], port: restorePort, hostMode: "network" }
));
const restore = resolveMcpRemoteRestore(payload.mcpRemote);
expect(restore).not.toBeNull();
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
const status = await restartDebugServer();
expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]);
expect(status.port).toBe(restorePort);
expect(status.host).toBe("0.0.0.0");
expect(status.allowlistCount).toBe(2);
expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret");
expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false);
await waitForReady(`http://127.0.0.1:${restorePort}/health?token=rt-secret`);
});
it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp2-"));
tempDirs.push(baseDir);
const startPort = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`);
const restore = resolveMcpRemoteRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0");
expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe("9100");
expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9");
expect(getDebugServerRuntimeStatus().port).toBe(startPort);
});
});
describe("debug-server live diagnostics endpoints", () => {
it("serves /providers (live cooldown/runtime snapshot) and /logs/conversion over authenticated HTTP", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-prov-"));
tempDirs.push(baseDir);
const port = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "prov-secret", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
startDebugServer({} as unknown as DownloadManager, baseDir);
await waitForReady(`http://127.0.0.1:${port}/health?token=prov-secret`);
const provRes = await fetch(`http://127.0.0.1:${port}/providers?token=prov-secret`);
expect(provRes.status).toBe(200);
const prov = await provRes.json();
expect(typeof prov.capturedAtMs).toBe("number");
expect(prov.megaDebrid).toBeTruthy();
expect(Array.isArray(prov.megaDebrid.accounts)).toBe(true);
expect(typeof prov.megaDebrid.rotationCursor).toBe("number");
expect(prov.debridLink).toBeTruthy();
expect(Array.isArray(prov.debridLink.keys)).toBe(true);
const unauth = await fetch(`http://127.0.0.1:${port}/providers`);
expect(unauth.status).toBe(401);
const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion?token=prov-secret`);
expect(convRes.status).toBe(200);
const conv = await convRes.json();
expect(Array.isArray(conv.lines)).toBe(true);
expect(conv).toHaveProperty("available");
});
});

View File

@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { encodeConnectionCode } from "../src/main/connection-code";
import { decodeConnectionCode } from "../tools/rd-diagnostics-mcp/src/code.mjs";
describe("connection-code", () => {
it("round-trips through the bridge decoder", () => {
const code = encodeConnectionCode({ host: "203.0.113.5", port: 9868, token: "deadbeef", name: "server-1" });
expect(code.startsWith("rddiag:v1:")).toBe(true);
const decoded = decodeConnectionCode(code);
expect(decoded.host).toBe("203.0.113.5");
expect(decoded.port).toBe(9868);
expect(decoded.token).toBe("deadbeef");
expect(decoded.name).toBe("server-1");
expect(decoded.scheme).toBe("http");
});
it("carries https scheme and fingerprint when set", () => {
const code = encodeConnectionCode({
host: "diag.example.com",
port: 8443,
token: "abc",
scheme: "https",
fingerprint: "AA:BB:CC"
});
const decoded = decodeConnectionCode(code);
expect(decoded.scheme).toBe("https");
expect(decoded.fingerprint).toBe("AA:BB:CC");
});
it("omits scheme key for plain http (default)", () => {
const code = encodeConnectionCode({ host: "10.0.0.2", port: 9868, token: "t" });
const json = JSON.parse(Buffer.from(code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
expect(json.s).toBeUndefined();
expect(json).toMatchObject({ v: 1, h: "10.0.0.2", p: 9868, t: "t" });
});
it("rejects invalid input", () => {
expect(() => encodeConnectionCode({ host: "", port: 9868, token: "t" })).toThrow();
expect(() => encodeConnectionCode({ host: "h", port: 0, token: "t" })).toThrow();
expect(() => encodeConnectionCode({ host: "h", port: 9868, token: "" })).toThrow();
});
});

View File

@ -3,7 +3,8 @@ import { defaultSettings, REQUEST_RETRIES } from "../src/main/constants";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
import { classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@ -2070,9 +2071,76 @@ describe("debrid service", () => {
expect(calls).toBeGreaterThanOrEqual(1); expect(calls).toBeGreaterThanOrEqual(1);
}, 20000); }, 20000);
it("getProviderRuntimeSnapshot surfaces a live Mega-Debrid account cooldown (until/remaining/reason) for the diagnostics endpoint", () => {
const accId = getMegaDebridAccountId("user");
const key = `${accId}:web`;
expect(getProviderRuntimeSnapshot().megaDebrid.accounts.find((a) => a.key === key)?.cooldown ?? null).toBeNull();
primeMegaDebridRuntimeCooldownForTests(key, 90_000, "Abbruch/Timeout nach 60s");
const snap = getProviderRuntimeSnapshot();
expect(typeof snap.capturedAtMs).toBe("number");
const acc = snap.megaDebrid.accounts.find((a) => a.key === key);
expect(acc).toBeTruthy();
expect(acc!.cooldown).not.toBeNull();
expect(acc!.cooldown!.remainingMs).toBeGreaterThan(0);
expect(acc!.cooldown!.remainingMs).toBeLessThanOrEqual(90_000);
expect(acc!.cooldown!.untilMs).toBeGreaterThan(snap.capturedAtMs);
expect(acc!.cooldown!.message).toContain("Abbruch");
});
it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user",
megaPassword: "pass",
megaCredentials: "user:pass",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const controller = new AbortController();
let calls = 0;
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
calls += 1;
if (calls === 1) {
controller.abort("simulated-60s-timeout");
return Promise.reject(new Error("aborted"));
}
return Promise.resolve({
fileName: "healthy.rar",
directUrl: "https://www11.unrestrict.link/download/file/ok/healthy.rar",
fileSize: null,
retriesUsed: 0
});
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e);
expect(err).toBeTruthy();
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
const key = `${getMegaDebridAccountId("user")}:web`;
expect(getMegaDebridAccountCooldownState(key)).toBeNull();
const second = await service.unrestrictLink("https://rapidgator.net/file/healthy.rar.html");
expect(second.provider).toBe("megadebrid");
expect(calls).toBeGreaterThanOrEqual(2);
}, 20000);
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => { it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
const key = `${getMegaDebridAccountId("user1")}:web`; const key = `${getMegaDebridAccountId("user1")}:web`;
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3); expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(10);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1); expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2); expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3); expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3);
@ -2100,6 +2168,14 @@ describe("debrid service", () => {
expect(genuineEmpty.limitSignal).toBe(true); expect(genuineEmpty.limitSignal).toBe(true);
}); });
it("classifies an empty Mega-Debrid API result ('Linkgenerierung lieferte kein Ergebnis') as a fast transient, not a 30s cooldown", () => {
const result = classifyMegaDebridAccountFailureForTests(new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis"));
expect(result.fatal).toBe(false);
expect(result.cooldownMs).toBe(0);
expect(result.limitSignal).toBeFalsy();
expect(isMegaDebridTransientResolveFailure(result.message)).toBe(true);
});
it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => { it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
@ -2189,8 +2265,9 @@ describe("debrid service", () => {
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const key = `${getMegaDebridAccountId("user1")}:web`; const key = `${getMegaDebridAccountId("user1")}:web`;
for (let i = 0; i < MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART - 1; i += 1) {
recordMegaDebridEmptyResponseStreak(key); recordMegaDebridEmptyResponseStreak(key);
recordMegaDebridEmptyResponseStreak(key); }
expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false); expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false);
const megaWeb = vi.fn(async () => null); const megaWeb = vi.fn(async () => null);

View File

@ -0,0 +1,146 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import {
startDebugServer,
stopDebugServer,
restartDebugServer,
writeDebugServerConfig,
getDebugServerRuntimeStatus,
evaluateClientAllowed,
getPeerIp
} from "../src/main/debug-server";
import type { DownloadManager } from "../src/main/download-manager";
const tempDirs: string[] = [];
const TOKEN = "allowlist-secret";
async function getFreePort(): Promise<number> {
const probe = http.createServer();
probe.listen(0, "127.0.0.1");
await once(probe, "listening");
const address = probe.address();
if (!address || typeof address === "string") {
throw new Error("port probe failed");
}
probe.close();
await once(probe, "close");
return address.port;
}
async function waitForReady(url: string): Promise<void> {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) {
return;
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`debug server not ready: ${url}`);
}
async function startWithAllowlist(allowlist: string[], host = "0.0.0.0"): Promise<{ baseUrl: string }> {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-allow-"));
tempDirs.push(baseDir);
const port = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), TOKEN, "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), host, "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), allowlist.join("\n"), "utf8");
const manager = {} as unknown as DownloadManager;
startDebugServer(manager, baseDir);
const baseUrl = `http://127.0.0.1:${port}`;
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
return { baseUrl };
}
afterEach(() => {
stopDebugServer();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
describe("debug-server allowlist matcher (pure)", () => {
it("always allows loopback regardless of rules", () => {
expect(evaluateClientAllowed("127.0.0.1", [])).toBe(true);
expect(evaluateClientAllowed("::1", [])).toBe(true);
expect(evaluateClientAllowed("::ffff:127.0.0.1", ["8.8.8.8"])).toBe(true);
});
it("matches an exact allowlisted IP and rejects others", () => {
expect(evaluateClientAllowed("8.8.8.8", ["8.8.8.8"])).toBe(true);
expect(evaluateClientAllowed("9.9.9.9", ["8.8.8.8"])).toBe(false);
});
it("matches inside a CIDR and rejects outside it", () => {
expect(evaluateClientAllowed("10.0.0.42", ["10.0.0.0/24"])).toBe(true);
expect(evaluateClientAllowed("10.0.1.42", ["10.0.0.0/24"])).toBe(false);
});
it("fail-closed: empty rules reject every non-loopback client", () => {
expect(evaluateClientAllowed("203.0.113.7", [])).toBe(false);
expect(evaluateClientAllowed("8.8.8.8", [])).toBe(false);
});
it("derives the client IP from the socket peer, never from X-Forwarded-For", () => {
const forgedLoopback = {
socket: { remoteAddress: "8.8.8.8" },
headers: { "x-forwarded-for": "127.0.0.1" }
} as unknown as http.IncomingMessage;
expect(getPeerIp(forgedLoopback)).toBe("8.8.8.8");
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), [])).toBe(false);
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["9.9.9.9"])).toBe(false);
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["8.8.8.8"])).toBe(true);
const ipv6Mapped = {
socket: { remoteAddress: "::ffff:10.0.0.5" },
headers: {}
} as unknown as http.IncomingMessage;
expect(getPeerIp(ipv6Mapped)).toBe("10.0.0.5");
});
});
describe("debug-server allowlist enforcement (wired)", () => {
it("allows a loopback connection and ignores a spoofed X-Forwarded-For", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
const plain = await fetch(`${baseUrl}/health?token=${TOKEN}`);
expect(plain.status).toBe(200);
const spoofed = await fetch(`${baseUrl}/health?token=${TOKEN}`, {
headers: { "X-Forwarded-For": "203.0.113.9" }
});
expect(spoofed.status).toBe(200);
});
it("still enforces the token for loopback clients", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
const res = await fetch(`${baseUrl}/health`);
expect(res.status).toBe(401);
});
it("reloads the allowlist live via restartDebugServer", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
expect(getDebugServerRuntimeStatus().allowlistCount).toBe(1);
writeDebugServerConfig({ allowlist: ["9.9.9.9", "10.0.0.0/24"] });
const status = await restartDebugServer();
expect(status.running).toBe(true);
expect(status.allowlistCount).toBe(2);
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
expect((await fetch(`${baseUrl}/health?token=${TOKEN}`)).status).toBe(200);
});
});

View File

@ -14,7 +14,7 @@ import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log"; import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
import { createStoragePaths, emptySession } from "../src/main/storage"; import { createStoragePaths, emptySession } from "../src/main/storage";
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests } from "../src/main/debrid"; import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
import { UnrestrictedLink } from "../src/main/realdebrid"; import { UnrestrictedLink } from "../src/main/realdebrid";
@ -2209,6 +2209,98 @@ describe("download manager", () => {
expect(fs.statSync(item.targetPath).size).toBe(binary.length); expect(fs.statSync(item.targetPath).size).toBe(binary.length);
}); });
it("recovers an HTTP 416 item with a clean fresh restart after the in-budget retries are exhausted", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-fresh-"));
tempDirs.push(root);
const binary = Buffer.alloc(160 * 1024, 19);
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
let downloadCalls = 0;
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/unrestrict/link")) {
return new Response(JSON.stringify({ download: "https://dummy/direct-416-recover", filename: "fresh-416.mkv", filesize: binary.length }), { status: 200, headers: { "Content-Type": "application/json" } });
}
throw new Error(`unexpected fetch ${url}`);
};
try {
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 2, autoExtract: false, autoReconnect: false },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
(manager as any).downloadToFile = async (_active: unknown, _directUrl: string, targetPath: string) => {
downloadCalls += 1;
if (downloadCalls <= 3) {
throw new Error("HTTP 416");
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, binary);
const item = Object.values((manager as any).session.items)[0] as { downloadedBytes: number; totalBytes: number; progressPercent: number } | undefined;
if (item) {
item.downloadedBytes = binary.length;
item.totalBytes = binary.length;
item.progressPercent = 100;
}
return { resumable: true };
};
manager.addPackages([{ name: "fresh-416", links: ["https://dummy/fresh-416"] }]);
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 20000);
const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item?.status).toBe("completed");
expect(downloadCalls).toBeGreaterThan(3);
} finally {
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
}
}, 25000);
it("bounds HTTP 416 clean restarts and finally fails instead of looping forever or stalling permanently", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-416-cap-"));
tempDirs.push(root);
const prevDelay = process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS;
process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = "0";
let downloadCalls = 0;
globalThis.fetch = async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/unrestrict/link")) {
return new Response(JSON.stringify({ download: "https://dummy/direct-416-forever", filename: "always-416.mkv", filesize: 1024 }), { status: 200, headers: { "Content-Type": "application/json" } });
}
throw new Error(`unexpected fetch ${url}`);
};
try {
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract"), retryLimit: 0, autoExtract: false, autoReconnect: false },
emptySession(),
createStoragePaths(path.join(root, "state"))
);
(manager as any).downloadToFile = async () => {
downloadCalls += 1;
throw new Error("direct_link_retry_exhausted:HTTP 416");
};
manager.addPackages([{ name: "always-416", links: ["https://dummy/always-416"] }]);
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 20000);
const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item?.status).toBe("failed");
expect(downloadCalls).toBeGreaterThan(4);
expect(downloadCalls).toBeLessThan(30);
expect((manager as any).http416FreshRestartByItem.get(item.id)).toBeUndefined();
} finally {
if (prevDelay === undefined) { delete process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS; } else { process.env.RD_HTTP416_FRESH_RESTART_DELAY_MS = prevDelay; }
}
}, 25000);
it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => { it("retries HTTP 416 in-session when using Debrid-Link API and then completes", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
@ -12244,3 +12336,200 @@ describe("download manager", () => {
} }
}); });
}); });
describe("start conflict guard + selective resume", () => {
function makeItem(id: string, packageId: string, status: string, fileName: string): any {
return {
id, packageId, url: `https://hoster.example/${id}`, provider: "realdebrid",
status, retries: 0, speedBps: 0, downloadedBytes: status === "completed" ? 100 : 0,
totalBytes: status === "completed" ? 100 : null, progressPercent: status === "completed" ? 100 : 0,
fileName, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "",
createdAt: Date.now(), updatedAt: Date.now()
};
}
it("does not flag a partially-downloaded package whose extract dir holds its own completed output", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-own-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
initPackageLogs(storagePaths.baseDir);
initItemLogs(storagePaths.baseDir);
const session = emptySession();
const packageId = "pkg-own-output";
const extractDir = path.join(root, "extract", "OwnOutput");
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(path.join(extractDir, "episode01.mkv"), Buffer.alloc(64, 7));
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId, name: "OwnOutput",
outputDir: path.join(root, "downloads", "OwnOutput"), extractDir,
status: "queued", itemIds: ["own-done", "own-pending"], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.items["own-done"] = makeItem("own-done", packageId, "completed", "done.rar");
session.items["own-pending"] = makeItem("own-pending", packageId, "queued", "pending.rar");
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
session, storagePaths
);
const conflicts = await manager.getStartConflicts();
expect(conflicts.map((c) => c.packageId)).not.toContain(packageId);
});
it("flags a fresh package when its package-specific extract dir already holds files and it has no completed items", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startconflict-fresh-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
initPackageLogs(storagePaths.baseDir);
initItemLogs(storagePaths.baseDir);
const session = emptySession();
const packageId = "pkg-fresh-conflict";
const extractDir = path.join(root, "extract", "FreshConflict");
fs.mkdirSync(extractDir, { recursive: true });
fs.writeFileSync(path.join(extractDir, "old-from-previous-run.mkv"), Buffer.alloc(64, 9));
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId, name: "FreshConflict",
outputDir: path.join(root, "downloads", "FreshConflict"), extractDir,
status: "queued", itemIds: ["fresh-pending"], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.items["fresh-pending"] = makeItem("fresh-pending", packageId, "queued", "fresh.rar");
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
session, storagePaths
);
const conflicts = await manager.getStartConflicts();
expect(conflicts.map((c) => c.packageId)).toContain(packageId);
});
it("start() holds excluded packages out of the run set and runs the rest", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-selective-resume-"));
tempDirs.push(root);
const storagePaths = createStoragePaths(path.join(root, "state"));
initPackageLogs(storagePaths.baseDir);
initItemLogs(storagePaths.baseDir);
const session = emptySession();
const runId = "pkg-run";
const holdId = "pkg-hold";
fs.mkdirSync(path.join(root, "downloads", "RunMe"), { recursive: true });
fs.mkdirSync(path.join(root, "downloads", "HoldMe"), { recursive: true });
session.packageOrder = [runId, holdId];
session.packages[runId] = {
id: runId, name: "RunMe",
outputDir: path.join(root, "downloads", "RunMe"), extractDir: path.join(root, "extract", "RunMe"),
status: "queued", itemIds: ["run-item"], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.packages[holdId] = {
id: holdId, name: "HoldMe",
outputDir: path.join(root, "downloads", "HoldMe"), extractDir: path.join(root, "extract", "HoldMe"),
status: "queued", itemIds: ["hold-item"], cancelled: false, enabled: true,
createdAt: Date.now(), updatedAt: Date.now()
} as any;
session.items["run-item"] = makeItem("run-item", runId, "queued", "run.rar");
session.items["hold-item"] = makeItem("hold-item", holdId, "queued", "hold.rar");
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", maxParallel: 2, outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract") },
session, storagePaths
);
(manager as any).debridService.unrestrictLink = () => new Promise(() => {});
await manager.start({ excludePackageIds: new Set([holdId]) });
expect((manager as any).runPackageIds.has(runId)).toBe(true);
expect((manager as any).runPackageIds.has(holdId)).toBe(false);
expect((manager as any).runItemIds.has("run-item")).toBe(true);
expect((manager as any).runItemIds.has("hold-item")).toBe(false);
expect(session.items["hold-item"].status).toBe("queued");
manager.stop();
});
});
describe("mega-debrid api/web resolution overlap gate", () => {
function megaApiSettings(root: string): any {
return {
...defaultSettings(),
megaLogin: "u", megaPassword: "p", megaCredentials: "u:p",
megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true,
outputDir: path.join(root, "downloads"), extractDir: path.join(root, "extract")
};
}
function megaItem(id: string, status: string): any {
return {
id, packageId: "pkg", url: `https://rapidgator.net/file/${id}`, provider: "megadebrid-api",
status, retries: 0, speedBps: 0, downloadedBytes: 0, totalBytes: null, progressPercent: 0,
fileName: `${id}.rar`, targetPath: "", resumable: true, attempts: 0, lastError: "", fullStatus: "",
createdAt: Date.now(), updatedAt: Date.now()
};
}
function addValidating(manager: DownloadManager, session: any, ids: string[]): void {
for (const id of ids) {
session.items[id] = megaItem(id, "validating");
(manager as any).activeTasks.set(id, {
itemId: id, packageId: "pkg", abortController: new AbortController(), abortReason: "none",
resumable: true, nonResumableCounted: false, blockedOnDiskWrite: false, blockedOnDiskSince: 0
});
}
}
function buildManager(root: string, session: any): DownloadManager {
return new DownloadManager(megaApiSettings(root), session, createStoragePaths(path.join(root, "state")));
}
it("lets the first mega resolve start when nothing is in flight", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-0-"));
tempDirs.push(root);
const session = emptySession();
const candidate = megaItem("cand", "queued");
session.items["cand"] = candidate;
const manager = buildManager(root, session);
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false);
});
it("serializes a second API resolve while the first is still in its API phase (no concurrent API)", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-1-"));
tempDirs.push(root);
const session = emptySession();
const candidate = megaItem("cand", "queued");
session.items["cand"] = candidate;
const manager = buildManager(root, session);
addValidating(manager, session, ["a"]);
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true);
});
it("allows one API resolve to overlap once the first has moved to its web phase", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-2-"));
tempDirs.push(root);
const session = emptySession();
const candidate = megaItem("cand", "queued");
session.items["cand"] = candidate;
const manager = buildManager(root, session);
addValidating(manager, session, ["a"]);
primeMegaDebridInFlightForTests("acc:web", 1);
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(false);
});
it("caps the overlap at one API plus one web (no third concurrent resolve)", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-overlap-3-"));
tempDirs.push(root);
const session = emptySession();
const candidate = megaItem("cand", "queued");
session.items["cand"] = candidate;
const manager = buildManager(root, session);
addValidating(manager, session, ["a", "b"]);
primeMegaDebridInFlightForTests("acc:web", 1);
expect((manager as any).shouldDelayStartForItem(candidate)).toBe(true);
});
});

Binary file not shown.

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark } from "../src/main/download-manager"; import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark, parseMegaDebridSlowLinkRetry } from "../src/main/download-manager";
describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => { describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => {
it("starts fast (<= 3s) instead of the 5s..120s exponential", () => { it("starts fast (<= 3s) instead of the 5s..120s exponential", () => {
@ -63,6 +63,31 @@ describe("parseMegaDebridCooldownRetry (honor the encoded account-cooldown delay
}); });
}); });
describe("parseMegaDebridSlowLinkRetry (park only the slow link, never the account)", () => {
it("parses the encoded delay from a slow-link error", () => {
const r = parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:120000:Mega-Debrid (Account 1/1, Su******e3): aborted");
expect(r).not.toBeNull();
expect(r!.delayMs).toBe(120000);
expect(r!.detail).toContain("Mega-Debrid");
});
it("parses it when embedded in the aggregated provider-chain error", () => {
const aggregated = "Provider-Kette: Mega-Debrid Web fehlgeschlagen (Error: mega_debrid_slow_link:90000:Mega-Debrid (Account 1/1): aborted)";
expect(parseMegaDebridSlowLinkRetry(aggregated)!.delayMs).toBe(90000);
});
it("clamps to [1s, 15min]", () => {
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:1:x")!.delayMs).toBe(1000);
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:99999999:x")!.delayMs).toBe(15 * 60 * 1000);
});
it("does not collide with the account-cooldown or reset-park tokens", () => {
expect(parseMegaDebridSlowLinkRetry("mega_debrid_cooldown:20330:x")).toBeNull();
expect(parseMegaDebridSlowLinkRetry("mega_debrid_reset_park:43200000:x")).toBeNull();
expect(parseMegaDebridCooldownRetry("mega_debrid_slow_link:120000:x")).toBeNull();
});
});
describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => { describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => {
it("parses the encoded until-reset delay from the park token", () => { it("parses the encoded until-reset delay from the park token", () => {
const r = parseMegaDebridResetPark("mega_debrid_reset_park:43200000:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)"); const r = parseMegaDebridResetPark("mega_debrid_reset_park:43200000:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)");

View File

@ -0,0 +1,62 @@
# rd-diagnostics-mcp
Standalone **stdio MCP bridge** to the Real-Debrid-Downloader debug-server. It runs on the machine where the
MCP client runs, takes a **connection code** for a downloader server, and exposes that server's
read-only HTTP diagnostics API (`/diagnostics`, `/status`, `/errors`, `/logs/*`, `/accounts`, …) as MCP tools.
One bridge serves all 56 servers; you pass a `code` (or a configured `server` name) per call.
This bridge is **not** bundled into the Electron app and adds **no** dependencies to it.
## Setup
```bash
cd tools/rd-diagnostics-mcp
npm install
```
Register it with your MCP client as a stdio server that launches the bridge:
```bash
node "<repo>/tools/rd-diagnostics-mcp/src/bridge.mjs"
```
Provide servers via environment variables (codes contain a token — treat like passwords):
- `RDDIAG_CODE` — a single default connection code (`rddiag:v1:...`)
- `RDDIAG_SERVERS` — JSON map of name → code, e.g. `{"berlin":"rddiag:v1:...","fra":"rddiag:v1:..."}`
Without env config, every tool simply takes a `code` argument.
## Tools
`rd_servers`, `rd_ping`, `rd_diagnostics`, `rd_status`, `rd_items`, `rd_packages`, `rd_errors`, `rd_logs`
(`main|audit|rename|trace|session|conversion|package|item`), `rd_history`, `rd_accounts`, `rd_providers`
(live per-account/key cooldown + in-flight + rotation state), `rd_host`, `rd_self_check`,
`rd_get` (raw escape-hatch, any read-only path).
Each tool accepts `code` or `server` to pick the target.
## Connection code
Format: `rddiag:v1:<base64url(JSON)>` with `{ v:1, h:host, p:port, t:token, n?:name, fp?:certFingerprint, s?:scheme }`.
Generated by the app (Hilfe → Remote-Support → Ferndiagnose (MCP)) or via `node src/gen-code.mjs --host H --port P --token T`.
## Security model (read before exposing a server)
- The debug surface is **read-only** for state/logs; the one control endpoint is `/trace/config` (toggles the
optional, time-bounded support trace). No persistent secrets are written into the logs it serves: passwords are
redacted, debrid API keys/tokens and resolved download URLs are never logged; `/settings` and `/accounts` are redacted.
- Auth is a bearer token (24 random bytes). Over plain HTTP on a public network the token is sniffable, so:
- **Preferred:** keep the server bound to `127.0.0.1` ("Nur lokal") and reach it through a private tunnel
(Tailscale / SSH / Cloudflare Tunnel). The tunnel encrypts and authenticates; no public exposure.
- **Direct network bind (`0.0.0.0`)** requires a non-empty **IP allowlist** (enforced fail-closed: with an empty
allowlist only loopback is accepted). Use only inside a trusted LAN/VPN.
- Revoke instantly from the app ("Token neu" or "Deaktivieren") — the old code stops working immediately.
- `fp` pins a self-signed cert fingerprint and is verified on `secureConnect` (before the token is sent). HTTPS is
not the v1 default; plain HTTP behind a tunnel is the recommended transport.
## Test
```bash
npm test # spins a fake debug-server, runs the bridge as a stdio child, asserts the full protocol path
```

1172
tools/rd-diagnostics-mcp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
{
"name": "rd-diagnostics-mcp",
"version": "1.0.0",
"private": true,
"description": "Standalone stdio MCP bridge to the Real-Debrid-Downloader debug-server. Connects via connection code, proxies the read-only HTTP diagnostics API as MCP tools.",
"type": "module",
"bin": {
"rd-diagnostics-mcp": "src/bridge.mjs"
},
"scripts": {
"start": "node src/bridge.mjs",
"test": "node test/harness.mjs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0",
"zod": "^3.23.8"
}
}

View File

@ -0,0 +1,332 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { decodeConnectionCode } from "./code.mjs";
import { debugGet } from "./http.mjs";
function loadServerMap() {
const map = new Map();
const raw = process.env.RDDIAG_SERVERS;
if (raw) {
try {
const parsed = JSON.parse(raw);
for (const [name, code] of Object.entries(parsed || {})) {
map.set(String(name), String(code));
}
} catch {
process.stderr.write("rd-diagnostics-mcp: RDDIAG_SERVERS ist kein gueltiges JSON, wird ignoriert\n");
}
}
return map;
}
const SERVER_MAP = loadServerMap();
const DEFAULT_CODE = process.env.RDDIAG_CODE ? String(process.env.RDDIAG_CODE) : "";
function listAvailableServers() {
const names = [...SERVER_MAP.keys()];
if (DEFAULT_CODE) names.push("(RDDIAG_CODE-Default)");
return names;
}
function resolveTarget(args) {
let code = "";
if (args && args.code) {
code = String(args.code);
} else if (args && args.server) {
const found = SERVER_MAP.get(String(args.server));
if (!found) {
throw new Error(
`Server "${args.server}" nicht konfiguriert. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
);
}
code = found;
} else if (DEFAULT_CODE) {
code = DEFAULT_CODE;
} else if (SERVER_MAP.size === 1) {
code = [...SERVER_MAP.values()][0];
} else {
throw new Error(
`Kein Verbindungscode. Uebergib "code" oder "server", oder setze RDDIAG_CODE/RDDIAG_SERVERS. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
);
}
return decodeConnectionCode(code);
}
function targetLabel(target) {
return target.name ? `${target.name} (${target.host}:${target.port})` : `${target.host}:${target.port}`;
}
function buildQuery(params) {
const usable = Object.entries(params || {}).filter(
([, v]) => v !== undefined && v !== null && String(v).length > 0
);
if (usable.length === 0) return "";
const sp = new URLSearchParams();
for (const [k, v] of usable) sp.set(k, String(v));
return "?" + sp.toString();
}
function prettyBody(body) {
try {
return JSON.stringify(JSON.parse(body), null, 2);
} catch {
return body;
}
}
function connectionHint(err) {
const m = String((err && err.code) || err && err.message || "");
if (/ECONNREFUSED/.test(m)) return "Debug-Server nicht erreichbar — auf dem Server aktiviert? Port/Firewall offen?";
if (/ENOTFOUND|EAI_AGAIN/.test(m)) return "Host nicht aufloesbar — stimmt die Adresse im Verbindungscode?";
if (/ETIMEDOUT|Zeitueberschreitung/.test(m)) return "Zeitueberschreitung — Server/Netz langsam oder Port geblockt.";
if (/ECONNRESET|EPIPE/.test(m)) return "Verbindung abgebrochen — falscher Port/Scheme (http vs https)?";
if (/Fingerprint/.test(m)) return "TLS-Fingerprint passt nicht — Code stammt evtl. von einem anderen Server.";
return "";
}
async function requestTool(args, path, params, opts = {}) {
let target;
try {
target = resolveTarget(args);
} catch (err) {
return { content: [{ type: "text", text: `# Verbindungsfehler\n${err.message}` }], isError: true };
}
const fullPath = path + buildQuery(params);
const label = targetLabel(target);
try {
const res = await debugGet(target, fullPath, { timeoutMs: opts.timeoutMs || 20000 });
const isError = res.status < 200 || res.status >= 300;
let extra = "";
if (res.status === 401) extra = "\n(401 = Token im Verbindungscode ist abgelaufen/rotiert. Neuen Code anfordern.)";
if (res.status === 503) extra = "\n(503 = Download-Manager nicht bereit. App laeuft, aber noch nicht initialisiert?)";
const head = `# ${label} ${fullPath} → HTTP ${res.status}${extra}`;
return { content: [{ type: "text", text: head + "\n" + prettyBody(res.body) }], isError };
} catch (err) {
const hint = connectionHint(err);
const text = `# ${label} ${fullPath} → FEHLER\n${err.message}${hint ? "\n→ " + hint : ""}`;
return { content: [{ type: "text", text }], isError: true };
}
}
const CODE_FIELD = {
code: z.string().optional().describe("Verbindungscode (rddiag:v1:...). Optional, wenn server/RDDIAG_CODE gesetzt ist."),
server: z.string().optional().describe("Name eines via RDDIAG_SERVERS konfigurierten Servers statt eines vollen Codes.")
};
const server = new McpServer({ name: "rd-diagnostics-mcp", version: "1.0.0" });
server.registerTool(
"rd_servers",
{
title: "Konfigurierte Server",
description: "Listet die in dieser Bridge konfigurierten Server (RDDIAG_SERVERS / RDDIAG_CODE). Verbindet sich nicht.",
inputSchema: {}
},
async () => {
const names = [...SERVER_MAP.keys()];
const lines = [];
lines.push(`Konfigurierte Server: ${names.length}`);
for (const n of names) lines.push(`- ${n}`);
lines.push(`Default (RDDIAG_CODE): ${DEFAULT_CODE ? "gesetzt" : "nicht gesetzt"}`);
lines.push("");
lines.push("Tools akzeptieren entweder code:<rddiag:v1:...> oder server:<name>.");
return { content: [{ type: "text", text: lines.join("\n") }] };
}
);
server.registerTool(
"rd_ping",
{
title: "Erreichbarkeit pruefen",
description: "Schneller Health-Check (GET /health): App-Version, Uptime, Speicher. Zuerst aufrufen, um Erreichbarkeit + Token zu pruefen.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/health", {}, { timeoutMs: 10000 })
);
server.registerTool(
"rd_diagnostics",
{
title: "Gesamtdiagnose",
description: "Aggregierter Zustand (GET /diagnostics): Meta, Status, Settings, Stats, Accounts, History, Host + die wichtigsten Logs. Der 'alles auf einen Blick'-Endpunkt.",
inputSchema: {
...CODE_FIELD,
lines: z.number().int().positive().optional().describe("Anzahl Log-Zeilen pro Log (Default 150)."),
grep: z.string().optional().describe("Filter fuer Log-Zeilen."),
package: z.string().optional().describe("Optional auf ein Paket fokussieren.")
}
},
async (args) => requestTool(args, "/diagnostics", { lines: args.lines, grep: args.grep, package: args.package }, { timeoutMs: 30000 })
);
server.registerTool(
"rd_status",
{
title: "Live-Status",
description: "Laufzeit-Status (GET /status): aktive Downloads, Queue, Provider-Zustand.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/status", {})
);
server.registerTool(
"rd_items",
{
title: "Download-Items",
description: "Einzelne Download-Items (GET /items), optional gefiltert nach Status/Paket.",
inputSchema: {
...CODE_FIELD,
status: z.string().optional().describe("Status-Filter (z.B. downloading, error, done)."),
package: z.string().optional().describe("Paket-Filter.")
}
},
async (args) => requestTool(args, "/items", { status: args.status, package: args.package })
);
server.registerTool(
"rd_packages",
{
title: "Pakete",
description: "Pakete (GET /packages), optional mit enthaltenen Items.",
inputSchema: {
...CODE_FIELD,
package: z.string().optional().describe("Bestimmtes Paket."),
includeItems: z.boolean().optional().describe("Items mitliefern.")
}
},
async (args) => requestTool(args, "/packages", { package: args.package, includeItems: args.includeItems ? "1" : "" })
);
server.registerTool(
"rd_errors",
{
title: "Letzte Fehler",
description: "Fehler-Ring (GET /errors): die letzten Fehler mit Level/Quelle. 'Was ist schiefgelaufen'.",
inputSchema: {
...CODE_FIELD,
level: z.string().optional().describe("Level-Filter (ERROR, WARN, ...)."),
limit: z.number().int().positive().optional().describe("Anzahl (Default 100).")
}
},
async (args) => requestTool(args, "/errors", { level: args.level, limit: args.limit })
);
const LOG_PATHS = {
main: "/logs/main",
audit: "/logs/audit",
rename: "/logs/rename",
trace: "/logs/trace",
session: "/logs/session",
conversion: "/logs/conversion",
package: "/logs/package",
item: "/logs/item"
};
server.registerTool(
"rd_logs",
{
title: "Log lesen",
description: "Liest das Ende eines Logs (GET /logs/<name>). name: main|audit|rename|trace|session|conversion|package|item. conversion = Pro-Item Link-Aufloesungs-Lebenszyklus (Token, API, Web, Rotation, Abbrueche mit Zeiten). Fuer package/item zusaetzlich package/item angeben.",
inputSchema: {
...CODE_FIELD,
name: z.enum(["main", "audit", "rename", "trace", "session", "conversion", "package", "item"]).describe("Welches Log."),
lines: z.number().int().positive().optional().describe("Anzahl Zeilen vom Ende (Default 100)."),
grep: z.string().optional().describe("Filter."),
package: z.string().optional().describe("Nur fuer name=package."),
item: z.string().optional().describe("Nur fuer name=item.")
}
},
async (args) => {
const path = LOG_PATHS[args.name];
return requestTool(args, path, { lines: args.lines, grep: args.grep, package: args.package, item: args.item });
}
);
server.registerTool(
"rd_history",
{
title: "Verlauf",
description: "Abgeschlossener Verlauf (GET /history), optional nach Status/Suchbegriff.",
inputSchema: {
...CODE_FIELD,
limit: z.number().int().positive().optional().describe("Anzahl (Default 50)."),
status: z.string().optional().describe("Status-Filter."),
grep: z.string().optional().describe("Suchbegriff.")
}
},
async (args) => requestTool(args, "/history", { limit: args.limit, status: args.status, grep: args.grep })
);
server.registerTool(
"rd_accounts",
{
title: "Accounts",
description: "Debrid-Accounts (GET /accounts, Token redigiert): Gueltigkeit, Premium, Cooldown/Rotation.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/accounts", {})
);
server.registerTool(
"rd_providers",
{
title: "Provider-Laufzeitzustand",
description: "Live Provider-Runtime (GET /providers): pro Mega-Account/Debrid-Link-Key der AKTIVE Cooldown (until/remainingMs/Grund/Kategorie), in-flight-Tiefe, Mega-Rotationscursor, Empty-Response-Streaks. Die 'warum kuehlt es JETZT ab'-Ansicht — beantwortet Cooldown-Fragen direkt statt aus Log-Arithmetik.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/providers", {})
);
server.registerTool(
"rd_host",
{
title: "Host-Diagnose",
description: "Windows-Host-Diagnose (GET /host/diagnostics): Laufwerke, Speicher, Pfade.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/host/diagnostics", {})
);
server.registerTool(
"rd_self_check",
{
title: "Self-Check",
description: "Setup/Self-Check (GET /self-check): erkennt Konfigurations-/Pfadprobleme.",
inputSchema: { ...CODE_FIELD }
},
async (args) => requestTool(args, "/self-check", {})
);
server.registerTool(
"rd_get",
{
title: "Roh-Endpunkt (Escape-Hatch)",
description: "Beliebigen Debug-Server-Pfad lesen (GET <path>), wenn kein spezialisiertes Tool passt. Pfad inkl. fuehrendem / und optionalem Query-String, z.B. /meta oder /stats.",
inputSchema: {
...CODE_FIELD,
path: z.string().describe("Pfad mit fuehrendem /, optional ?query. Nur GET, read-only.")
}
},
async (args) => {
const p = String(args.path || "");
if (!p.startsWith("/")) {
return { content: [{ type: "text", text: "# Fehler\npath muss mit / beginnen" }], isError: true };
}
return requestTool(args, p, {}, { timeoutMs: 30000 });
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
process.stderr.write(
`rd-diagnostics-mcp bereit. Server: ${listAvailableServers().join(", ") || "(keine vorkonfiguriert; code pro Aufruf uebergeben)"}\n`
);
}
main().catch((err) => {
process.stderr.write(`rd-diagnostics-mcp Startfehler: ${err && err.stack ? err.stack : err}\n`);
process.exit(1);
});

View File

@ -0,0 +1,20 @@
export interface DecodedConnectionCode {
host: string;
port: number;
token: string;
scheme: string;
name: string;
fingerprint: string;
}
export interface EncodeConnectionCodeInput {
host: string;
port: number;
token: string;
name?: string;
fingerprint?: string;
scheme?: string;
}
export function encodeConnectionCode(input: EncodeConnectionCodeInput): string;
export function decodeConnectionCode(code: string): DecodedConnectionCode;

View File

@ -0,0 +1,56 @@
const PREFIX = "rddiag:v1:";
function base64urlEncode(str) {
return Buffer.from(str, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function base64urlDecode(str) {
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
const b64 = str.replace(/-/g, "+").replace(/_/g, "/") + pad;
return Buffer.from(b64, "base64").toString("utf8");
}
export function encodeConnectionCode({ host, port, token, name, fingerprint, scheme }) {
if (!host || typeof host !== "string") throw new Error("host fehlt");
const p = Number(port);
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("port ungueltig");
if (!token || typeof token !== "string") throw new Error("token fehlt");
const payload = { v: 1, h: host, p, t: token };
if (name) payload.n = String(name);
if (fingerprint) payload.fp = String(fingerprint);
if (scheme && scheme !== "http") payload.s = String(scheme);
return PREFIX + base64urlEncode(JSON.stringify(payload));
}
export function decodeConnectionCode(code) {
const raw = String(code || "").trim();
if (!raw.startsWith(PREFIX)) {
throw new Error(`Verbindungscode muss mit "${PREFIX}" beginnen`);
}
let json;
try {
json = JSON.parse(base64urlDecode(raw.slice(PREFIX.length)));
} catch {
throw new Error("Verbindungscode ist beschaedigt (kein gueltiges base64url/JSON)");
}
if (!json || typeof json !== "object") throw new Error("Verbindungscode-Inhalt ungueltig");
const host = String(json.h || "").trim();
const port = Number(json.p);
const token = String(json.t || "");
if (!host) throw new Error("Verbindungscode ohne Host");
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Verbindungscode mit ungueltigem Port");
if (!token) throw new Error("Verbindungscode ohne Token");
const scheme = json.s === "https" ? "https" : "http";
return {
host,
port,
token,
scheme,
name: json.n ? String(json.n) : "",
fingerprint: json.fp ? String(json.fp) : ""
};
}

View File

@ -0,0 +1,22 @@
#!/usr/bin/env node
import { encodeConnectionCode } from "./code.mjs";
function arg(name, fallback) {
const i = process.argv.indexOf("--" + name);
if (i >= 0 && i + 1 < process.argv.length) return process.argv[i + 1];
return fallback;
}
const host = arg("host");
const port = arg("port");
const token = arg("token");
const name = arg("name");
const scheme = arg("scheme");
const fingerprint = arg("fp");
if (!host || !port || !token) {
process.stderr.write("Usage: node src/gen-code.mjs --host <h> --port <p> --token <t> [--name <n>] [--scheme https] [--fp <sha256>]\n");
process.exit(2);
}
process.stdout.write(encodeConnectionCode({ host, port, token, name, scheme, fingerprint }) + "\n");

View File

@ -0,0 +1,63 @@
import http from "node:http";
import https from "node:https";
function normalizeFp(fp) {
return String(fp || "").replace(/:/g, "").toLowerCase();
}
export function debugGet(target, path, { timeoutMs = 20000 } = {}) {
const scheme = target.scheme === "https" ? "https" : "http";
const lib = scheme === "https" ? https : http;
const rel = path.startsWith("/") ? path : "/" + path;
const url = new URL(rel, `${scheme}://${target.host}:${target.port}`);
const pinning = scheme === "https" && !!target.fingerprint;
return new Promise((resolve, reject) => {
const options = {
method: "GET",
headers: {
Authorization: `Bearer ${target.token}`,
Accept: "application/json"
},
timeout: timeoutMs
};
if (scheme === "https") {
options.rejectUnauthorized = !target.fingerprint;
}
const req = lib.request(url, options, (res) => {
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
resolve({ status: res.statusCode || 0, body: data, headers: res.headers });
});
});
req.on("timeout", () => {
req.destroy(new Error(`Zeitueberschreitung nach ${timeoutMs}ms`));
});
req.on("error", (err) => {
reject(err);
});
if (pinning) {
req.on("socket", (socket) => {
socket.on("secureConnect", () => {
const cert = typeof socket.getPeerCertificate === "function" ? socket.getPeerCertificate() : null;
const got = normalizeFp(cert && cert.fingerprint256);
const want = normalizeFp(target.fingerprint);
if (!got || got !== want) {
req.destroy(new Error(`TLS-Fingerprint stimmt nicht (erwartet ${want || "?"}, erhalten ${got || "?"})`));
return;
}
req.end();
});
});
} else {
req.end();
}
});
}

View File

@ -0,0 +1,175 @@
import http from "node:http";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { encodeConnectionCode } from "../src/code.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const BRIDGE = join(__dirname, "..", "src", "bridge.mjs");
const TOKEN = "test-token-abc123";
const failures = [];
function check(name, cond, detail) {
if (cond) {
process.stdout.write(` PASS ${name}\n`);
} else {
failures.push(name);
process.stdout.write(` FAIL ${name}${detail ? " — " + detail : ""}\n`);
}
}
function startFakeServer() {
return new Promise((resolve) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
const auth = req.headers.authorization || "";
const tokenOk = auth === `Bearer ${TOKEN}` || url.searchParams.get("token") === TOKEN;
if (!tokenOk) {
res.writeHead(401, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "Unauthorized" }));
return;
}
const p = url.pathname;
const q = Object.fromEntries(url.searchParams.entries());
const send = (obj) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(obj));
};
if (p === "/health") return send({ status: "ok", appVersion: "1.7.222", uptime: 42 });
if (p === "/diagnostics") return send({ meta: { appVersion: "1.7.222" }, status: { active: 1 }, query: q });
if (p === "/errors") return send({ errors: [{ level: "ERROR", message: "boom" }], query: q });
if (p === "/logs/main") return send({ lines: ["line1", "line2"], count: 2, query: q });
if (p === "/status") return send({ active: 1, queued: 3 });
if (p === "/items") return send({ items: [], query: q });
if (p === "/accounts") return send({ accounts: [{ name: "acc1", premium: true }] });
if (p === "/meta") return send({ appVersion: "1.7.222", endpoints: ["/health", "/diagnostics"] });
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found", path: p }));
});
server.listen(0, "127.0.0.1", () => resolve(server));
});
}
function startBridge() {
const child = spawn(process.execPath, [BRIDGE], { stdio: ["pipe", "pipe", "pipe"] });
child.stderr.on("data", (d) => process.stderr.write(`[bridge] ${d}`));
const pending = new Map();
let buf = "";
child.stdout.on("data", (chunk) => {
buf += chunk.toString("utf8");
let idx;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch {
continue;
}
if (msg.id !== undefined && pending.has(msg.id)) {
pending.get(msg.id)(msg);
pending.delete(msg.id);
}
}
});
let nextId = 1;
function rpc(method, params) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, resolve);
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`RPC timeout: ${method}`));
}
}, 15000);
});
}
function notify(method, params) {
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
}
return { child, rpc, notify };
}
function textOf(callResult) {
const c = callResult && callResult.result && callResult.result.content;
if (!Array.isArray(c)) return "";
return c.map((x) => x.text || "").join("\n");
}
async function run() {
const fake = await startFakeServer();
const port = fake.address().port;
const code = encodeConnectionCode({ host: "127.0.0.1", port, token: TOKEN, name: "testserver" });
const badCode = encodeConnectionCode({ host: "127.0.0.1", port, token: "WRONG", name: "testserver" });
const bridge = startBridge();
try {
const init = await bridge.rpc("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "harness", version: "1.0.0" }
});
check("initialize handshake", !!(init.result && init.result.serverInfo), JSON.stringify(init.error || {}));
check("server name reported", init.result && init.result.serverInfo && init.result.serverInfo.name === "rd-diagnostics-mcp");
bridge.notify("notifications/initialized", {});
const tools = await bridge.rpc("tools/list", {});
const names = (tools.result && tools.result.tools || []).map((t) => t.name);
check("tools/list returns tools", names.length >= 10, `got ${names.length}`);
for (const expected of ["rd_ping", "rd_diagnostics", "rd_errors", "rd_logs", "rd_get", "rd_servers"]) {
check(`tool present: ${expected}`, names.includes(expected));
}
const ping = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code } });
const pingText = textOf(ping);
check("rd_ping reaches server", /HTTP 200/.test(pingText) && /"status": "ok"/.test(pingText), pingText.slice(0, 200));
check("rd_ping shows server label", /testserver \(127\.0\.0\.1:/.test(pingText));
const diag = await bridge.rpc("tools/call", { name: "rd_diagnostics", arguments: { code, lines: 50, grep: "err" } });
const diagText = textOf(diag);
check("rd_diagnostics returns aggregate", /"appVersion": "1\.7\.222"/.test(diagText));
check("rd_diagnostics passes query params", /"lines": "50"/.test(diagText) && /"grep": "err"/.test(diagText), diagText.slice(0, 300));
const logs = await bridge.rpc("tools/call", { name: "rd_logs", arguments: { code, name: "main", lines: 5 } });
const logsText = textOf(logs);
check("rd_logs maps name→path + lines", /logs\/main\?lines=5/.test(logsText) && /"count": 2/.test(logsText), logsText.slice(0, 200));
const errs = await bridge.rpc("tools/call", { name: "rd_errors", arguments: { code, level: "ERROR" } });
check("rd_errors returns ring", /"message": "boom"/.test(textOf(errs)));
const raw = await bridge.rpc("tools/call", { name: "rd_get", arguments: { code, path: "/meta" } });
check("rd_get escape hatch hits arbitrary path", /"endpoints"/.test(textOf(raw)));
const unauthorized = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code: badCode } });
check("bad token → HTTP 401 + isError", /HTTP 401/.test(textOf(unauthorized)) && unauthorized.result.isError === true);
const noCode = await bridge.rpc("tools/call", { name: "rd_ping", arguments: {} });
check("missing code → graceful isError", noCode.result && noCode.result.isError === true && /Kein Verbindungscode/.test(textOf(noCode)));
const unreachable = await bridge.rpc("tools/call", {
name: "rd_ping",
arguments: { code: encodeConnectionCode({ host: "127.0.0.1", port: 1, token: TOKEN }) }
});
check("unreachable → isError + hint", unreachable.result.isError === true && /nicht erreichbar|abgebrochen|FEHLER/.test(textOf(unreachable)), textOf(unreachable).slice(0, 160));
} finally {
bridge.child.kill();
fake.close();
}
process.stdout.write("\n");
if (failures.length) {
process.stdout.write(`RESULT: ${failures.length} FAIL\n`);
process.exit(1);
}
process.stdout.write("RESULT: ALL PASS\n");
process.exit(0);
}
run().catch((err) => {
process.stderr.write(`harness error: ${err && err.stack ? err.stack : err}\n`);
process.exit(1);
});