Compare commits

..
29 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 5.5 9b25187084 feat(vpn): WireGuard-Server — Kunden waehlen sich per UDP 443 ein, ein Verteiler fuer alle
Einstellungen → VPN → WireGuard → Server: Einwahl anlegen (Name,
Kundennetz, LAN-IP der FritzBox); ARIA erzeugt beide Schluesselpaare + PSK,
⬇ liefert die Datei fuer die Gegenseite (FritzBox-Netzwerk-Kopplung oder
Standard-WireGuard). Jede Einwahl = eigener VPN-Satellit-Container mit
fester IP im Netz aria-vpn (10.213.213.0/24, ausserhalb von Dockers Pools).

Neuer Verteiler (satellite/vpn_relay.py, aria-vpn-relay, Host-Netz,
VPN_RELAY_PORT 51820): ordnet jedes Paket per WireGuard-mac1 bzw.
Sitzungsnummer dem richtigen Container zu, ohne zu entschluesseln; Fremdes
wird still verworfen. Anleitung NPM-Stream / ohne Nginx / ufw im ℹ des
Server-Reiters und im satellite/README.

Lokal Ende-zu-Ende getestet: zwei Kunden mit demselben Netz 192.168.178.0/24
gleichzeitig ueber einen Port, jeder erreicht nur sein eigenes LAN; kein Weg
Kunde → ARIA, Kunde 1 → Einwahl 2, Container → Internet; fremder Schluessel
und Muell am Verteiler ohne Antwort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:14:34 +01:00
duffyduckandClaude Opus 5.5 627b826226 feat(vpn): VPN-Satelliten — WireGuard-Clients als Satelliten ohne Installation beim Kunden
Einstellungen → VPN → WireGuard → Clients: WireGuard-Datei des Kunden
hochladen/einfuegen (editierbar), je Verbindung Schalter (an = auch nach
Neustart, aus = bleibt aus), bearbeiten, Download, ℹ-Verlauf, loeschen;
Log-Aufbewahrung im Reiter Einstellungen. Server-Reiter mit Anleitung fuer
UDP 443 (NPM-Stream bzw. ohne Nginx), Server selbst folgt.

Jede Verbindung ist ein eigener Container (aria-vpn-<id>, Image aus
satellite/Dockerfile.vpn) mit eigenem Netz-Namensraum: WireGuard + normaler
satellite.py (kind=vpn). Firewall im Container: Tunnel nur ausgehend, keine
Verbindung Kundennetz → ARIA; ins ARIA-Netz nur RVS + Endpunkt. Routen ohne
wg-quick, RVS-Weg nie durch den Tunnel (auch bei 0.0.0.0/0). Scan ueber den
Tunnel aktiv per TCP (kein mDNS/ARP), FritzBox an Port 49000 erkannt.
Satelliten-Ansicht: eigene Gruppe VPN-Satelliten.

Lokal Ende-zu-Ende getestet (Kunde mit WG-Server + LAN, Fake-RVS):
Tunnel, Verlauf, Scan, Sperren in beide Richtungen, Volltunnel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:30:10 +01:00
duffyduckandClaude Opus 5.5 ca35f5f1e0 feat(diagnostic): Knopf nach oben — schwebt unten rechts beim Scrollen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:41:29 +01:00
duffyduckandClaude Opus 5.5 68686de9dc feat(llm): llama.cpp/llama-swap aus der Diagnostic aktualisieren
Modell-Katalog → ⚙ llama.cpp zeigt je LLM-Box die installierte Version,
sucht nach Updates (Pruefsumme des Image-Tags in der Registry + letztes
llama-swap-Release) und aktualisiert auf Knopfdruck: altes Image sichern,
neues ziehen, Container per Compose neu anlegen, Selbsttest (llama.cpp
sieht die Karten, vorgeladene Modelle wieder bereit) — sonst automatisch
zurueck. Zuruecksetzen auf die Vorversion per Knopf.

Docker-Zugriff hat nur der neue llm-updater (feste Aktionen fuer
aria-llama-swap, kein Port nach aussen, Token in /models). Der llm-adapter
reicht llm_runtime weiter und meldet den Fortschritt. RVS-Allow-List um
llm_runtime/_result/_progress ergaenzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:39:52 +01:00
duffyduck 3773178726 release: bump version to 0.2.6.2 2026-11-01 00:17:38 +01:00
duffyduckandClaude Opus 5.5 3fbe2dd090 feat(diagnostic): Beliebtheit im Modell-Katalog — Downloads und Likes von HuggingFace
HF hat keine Sternebewertung; es gibt Downloads (30 Tage, gesamt) und
Likes. Beide sagen 'viel benutzt', nicht 'gut' (viele Downloads sind
Automatik, Likes oft beim Entdecken). Angezeigt unter dem Modellnamen mit
Hinweis im Tooltip; beim Hinzufuegen sofort, beim taeglichen Abgleich
aufgefrischt. ℹ: Zeile Beliebtheit mit Einordnung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:19:24 +01:00
duffyduckandClaude Opus 5.5 237a216945 feat(diagnostic): Sicherheit im Modell-Katalog — Anbieter, HF-Scan, Skripte im Repo
Die Box laedt nur die GGUF-Datei (llama.cpp -hf / paralleler Download per
sha256); Skripte, Pickle u.ae. im selben Repo kommen nie auf die Box. Liegen
sie dort, ist das trotzdem ein Warnsignal fuer den Anbieter.

- hfRepoSafety: HF-Dateiliste mit securityFileStatus (ProtectAI, AV,
  Pickle-Import, VirusTotal) → Befunde (unsafe/suspicious/caution …),
  Anzahl geprueft-sicher, riskante Dateien (.py/.sh/.exe/.pkl/.pt …),
  bekannter Anbieter (Hersteller + bekannte GGUF-Umwandler).
- Katalog: "⚠ Befund" / "⚠ Skripte" / "✓" je Eintrag; beim Hinzufuegen
  sofort, Bestand beim naechsten Aktualisieren.
- ℹ: Abschnitt "Sicherheit".
- Laden/Nur downloaden: bei Befunden erst Rueckfrage.

Getestet gegen HuggingFace: unsloth/mradermacher bekannt und ohne Befund;
Virtue-AI-HUB/VulnLLM-R-7B (Original) mit Befund "caution" an
training_args.bin (Pickle mit Imports) — das GGUF von mradermacher ist sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:15:48 +01:00
duffyduckandClaude Opus 5.5 9529bb0b16 feat(app): Aufnahme verwerfen — Muelleimer neben dem Aufnahmeknopf
Bisher hiess "Stopp" immer "senden"; wer es sich anders ueberlegt hatte,
konnte die Aufnahme nicht zuruecknehmen.

- App: waehrend der Aufnahme 🗑 links neben dem roten Knopf. Verwirft ueber
  ariaStopRecording('user-discard') → cancelStreamingRecording; das leere
  Endpoint raeumt die Sprechblase ab, zurueck aufs Wake-Word.
- Fund dabei: "cancel" schickte der STT-Bridge nur stt_stream_end — die
  transkribierte trotzdem und der Text ging an ARIA (fiel nur nicht auf,
  weil cancel bisher bei Stille kam). Jetzt:
  - Whisper- und Voxtral-Bridge: stream_end mit reason "cancel:*" →
    discard, kein Transcribe, leerer Text (reason "cancelled").
  - aria-bridge merkt sich verworfene requestIds (2 min) und wirft einen
    trotzdem ankommenden Text weg — Schutz auch bei alter STT-Bridge.

Getestet: Whisper-Bridge mit nachgebautem Runner (Stopp → Text, Muelleimer
→ kein Transcribe, leer). App: TypeScript ohne Fehler; auf dem Handy noch
nicht ausprobiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:05:35 +01:00
duffyduckandClaude Opus 5.5 91619d9307 feat(diagnostic): Katalog zeigt, ob ein Modell Werkzeuge kann (🔧) + Filter
Erkennung: Chat-Vorlage des GGUF (HF expand[]=gguf) kennt ein Werkzeug-
Format, oder Schlagworte wie function-calling/tool-use. Zeigt, OB das
Format da ist — nicht, wie gut das Modell damit umgeht.
- Neue und hinzugefuegte Eintraege bekommen tools: true/false/null,
  bestehende beim naechsten "Von HuggingFace aktualisieren".
- Liste: 🔧-Abzeichen; Haken "nur Modelle mit Werkzeugen" (Standard an)
  blendet Modelle ohne Werkzeug-Format aus (unbekannt bleibt sichtbar).
- ℹ: Zeile "Werkzeuge: ja / nein / unbekannt".

Getestet gegen HuggingFace (7 Modelle, u.a. Hermes 3 ueber das Schlagwort,
weil dessen offizielle Vorlage minimal ist).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:41:24 +01:00
duffyduckandClaude Opus 5.5 c8911316d7 feat(brain): lokale Modelle bewusst waehlen — pro Chat (🧠) oder "@lokal …"
Bisher gab es nur den Schalter "leichte Fragen lokal / alles lokal". Jetzt
waehlt Stefan selbst, wo ein lokales Modell antwortet:
- Pro Chat: der Modell-Picker (🧠, context-model) bietet zusaetzlich die
  gerade geladenen lokalen Modelle als "local:<modell>" (Gruppe "Lokal").
  Steht ein Chat darauf, laeuft JEDER Turn dort lokal — z.B. ein Pentest-
  Projekt auf einem unzensierten Modell. Claude ist in dem Chat raus.
- Eine Frage: "@lokal …" (geladenes Modell) oder "@lokal:<modell> …".
- Lehnt Claude ab, nennt ARIA beide Wege; sie leitet NICHT selbst weiter.

Lokaler Turn: Identitaet + Hinweis "keine Werkzeuge, nichts behaupten",
Notizzettel + Fenster nach Kontext (local_compact), <think> entfernt,
Metrik. Fehler (Modell nicht geladen …) werden angezeigt, aber nicht als
ARIAs Antwort in den Verlauf geschrieben. Antwort-Quelle "local" mit
Modellname und Grund ("pro-chat" / "einmalig").

Getestet: Erkennung von @lokal (auch mit Modell, nach GPS-Hinweis, nicht
bei "@lokalisierung"). Durchlauf mit echtem Modell noch nicht (Karte im
Gegenlauf belegt, Brain nicht neu gebaut).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:33:23 +01:00
duffyduckandClaude Opus 5.5 d289d9404a feat(diagnostic): Katalog von HuggingFace — Trends, Code, unzensiert; taeglich; aufraeumen
"Von HuggingFace aktualisieren" holte nur die 40 meistgeladenen GGUF
aller Zeiten — neue und unzensierte Modelle kamen so kaum vor. Jetzt:
- Blickwinkel: beliebt, im Trend (trendingScore), Code, unzensiert
  (uncensored/heretic/abliterated).
- Nur Sprachmodelle (pipeline_tag + Namensfilter gegen Bild/Audio/Musik/
  Video/Embedding) und hoechstens LLM_CATALOG_MAX_GB (30).
- Markierungen in der Liste: "unzensiert", "neu" (letzte 7 Tage).
- Einmal am Tag automatisch (LLM_CATALOG_REFRESH_HOURS, 0 = aus).
- Aufraeumen: HF-Eintraege aelter als LLM_CATALOG_KEEP_DAYS (30), auf
  keiner Box und nicht mehr gelistet, fliegen raus; kuratierte und von
  Hand hinzugefuegte bleiben.

Getestet gegen HuggingFace mit Test-Katalog: 43 neue in 2 s, keine
Bild-/Audio-Modelle mehr, groesstes 29,2 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:26:11 +01:00
duffyduckandClaude Opus 5.5 faa4868ee2 feat(diagnostic): ℹ im Modell-Katalog — was kann das Modell, ist es zensiert?
Neben jedem Katalog-Eintrag ℹ → Fenster mit:
- Kurzbeschreibung auf Deutsch, beim ersten Oeffnen vom LOKALEN Modell aus
  der Modellkarte (Grundmodell + GGUF-Repo) erzeugt und im Katalog
  gespeichert (summary_de) — so hat jedes neue Modell eine, ohne Pflege.
  Hinweis "kann Fehler enthalten". Laeuft kein Modell: Meldung.
- Feste deutsche Texte je Modellfamilie (Qwen3-Coder, Foundation-Sec,
  VulnLLM, Dolphin, Hermes, Lexi, Gemma, Qwen, Llama, Mistral, DeepSeek,
  Phi), erkannt als ganzes Wort in Name/Grundmodell/Schlagworten.
- "unzensiert" / "mit Schutzfiltern" (uncensored/abliterated/heretic/
  dolphin/lexi … in Name oder Schlagworten).
- Fakten von HF: Grundmodell, Autor, Lizenz, Beliebtheit, Stand,
  Schlagworte; Auszug der Modellkarte (Link-Absaetze uebersprungen).

Server: /api/llm-model-info, /api/llm-model-summary, rvsRequest() als
Promise-Variante fuer eigene RVS-Anfragen.

Getestet: Info-Teil gegen HuggingFace (4 Modelle). Die Zusammenfassung
durch das lokale Modell ungetestet (Karte gerade im Gegenlauf belegt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:24:33 +01:00
duffyduckandClaude Opus 5.5 848eee5d1c feat(diagnostic): Modell per HuggingFace-Link in den Katalog — mit GGUF-Vorschlaegen
Bisher kamen Modelle nur ueber "Von HuggingFace aktualisieren" und die
Trends in den Katalog. Jetzt: Feld "HuggingFace-Link oder org/name".
Hat das Repo kein GGUF (z.B. nur safetensors wie Virtue-AI-HUB/VulnLLM-R-7B
oder fdtn-ai/Foundation-Sec-8B), sucht der Server GGUF-Fassungen desselben
Modells (HF-Suche, filter=gguf, nach Downloads) und bietet sie mit
"Diese nehmen" an. Hinzugefuegte stehen danach gefiltert in der Liste.

Getestet gegen HuggingFace: beide Beispiele ohne GGUF erkannt, je 5
ladbare GGUF-Fassungen gefunden (Q4_K_M ~4,7–4,9 GB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:14:24 +01:00
duffyduckandClaude Opus 5.5 2cd8f1a0de feat: Kennwort je FritzBox-Sicherung — gespeichert, per 👁 anzeigen, kopieren
Bisher merkte sich der Satellit nur die QUELLE des Kennworts. Wurde das
Sicherungs-Kennwort spaeter geaendert, passte es nicht mehr zu aelteren
Sicherungen — auch "Wiederherstellen" haette das falsche genommen.

- Satellit: das benutzte Kennwort liegt verschluesselt (Fernet, wie der
  Zugangsdaten-Speicher) in der Begleitdatei; Liste/Ansicht geben es nie
  heraus. Wiederherstellen nimmt genau dieses. fritzbox.backup_password
  liefert es (aeltere Sicherungen: aktuelles der Quelle + Hinweis).
  Feld "kennwort" im Sicherungs-Ergebnis heisst jetzt "verschluesselt_mit".
- Diagnostic-Server: Ergebnisse mit Kennwort gehen nur an den Browser,
  der gefragt hat; ohne Fragenden werden sie verworfen, nie an alle.
- Brain: fritzbox.backup_password ist fuer ARIA gesperrt (Kennwoerter
  gehen nicht ans Sprachmodell).
- Diagnostic: 👁 in der Kennwort-Spalte → Zeile mit dem Kennwort und
  "Kopieren" — klappt auch ohne https (Fallback ueber execCommand).

Getestet: Satellit (Kennwort nicht im Klartext gespeichert, nach
Kennwort-Aenderung zeigt 👁 und nimmt Wiederherstellen das alte), Fenster
in headless Chromium ohne Clipboard-API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:26:37 +01:00
duffyduckandClaude Opus 5.5 e44e2c2a70 fix(diagnostic): Sicherungsliste — Kennwort-Spalte mit 🔒 und Erklaerung
Die Spalte zeigt, mit WELCHEM Kennwort die Sicherung verschluesselt ist
(Quelle, nie der Wert) — ohne Erklaerung war das unverstaendlich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:17:50 +01:00
duffyduckandClaude Opus 5.5 c77526e92a feat: FritzBox-Sicherung herunterladen + maximale Anzahl je Box einstellbar
- Satellit: fritzbox.backup_get liefert eine vorhandene Sicherung (nur aus
  dem Sicherungsordner, bis 20 MB). Bei ARIA landet sie unter
  /shared/uploads/ und kann in den Chat.
- Zugangsdaten FritzBox: "Maximale Sicherungen" (1–100, leer = 10). Bei
  jeder neuen Sicherung fliegt die aelteste dieser Box raus; die Liste
  meldet die Zahl mit (Kopfzeile im 💾-Fenster).
- Diagnostic: ⬇ je Sicherung laedt die .export-Datei herunter.

Getestet: Satellit (Limit 2 → die zwei neuesten bleiben, Download, Pfad-
schutz), Diagnostic in headless Chromium (Download-Inhalt, Feld laden,
0 abgelehnt, 20 gespeichert).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:17:12 +01:00
duffyduckandClaude Opus 5.5 0a1e334169 fix(satellite): FritzBox-Tastenbestaetigung — Token im SOAP-Header mitschicken
An der Kellerbox kam nach dem Tastendruck "stopped" statt "authenticated".
Laut AVM "TR-064 Support – Authentication" (Kap. 6.4) muss der Token aus
X_AVM-DE_Auth SetConfig(start) bei GetState UND beim Wiederholen der
Aktion als <avm:token> im SOAP-Header stehen — ohne ihn ordnet die Box die
Bestaetigung nicht unserem Vorgang zu.

- _tr064(token=…) setzt den Header.
- GetState und die wiederholte Aktion laufen mit dem Token; Stop ebenso.
- Zustaende werden geloggt und erklaert (stopped/blocked/
  anotherauthprocess/failure); 867 (gesperrt, bis 60 min) und 868
  (beschaeftigt, ~2 min) mit verstaendlicher Meldung.

Getestet gegen eine nachgebaute Box, die sich wie in AVMs Doku verhaelt
(ohne Token "stopped", mit Token "authenticated").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:06:11 +01:00
duffyduckandClaude Opus 5.5 236e94da4a feat: alle FritzBoxen sichern (ueberspringt statt abzubrechen) + Tastenbestaetigung anzeigen/schalten
Satellit:
- fritzbox.backup_all: alle Boxen mit hinterlegtem Login nacheinander;
  nicht erreichbar (Port 49000), Fehler oder Taste noetig → notieren und
  weiter. Ergebnis: x von y gesichert + Grund je Box.
- fritzbox.second_factor: ist die Zusatzbestaetigung an, gibt es sie, und
  laesst sie sich per TR-064 schalten (nur wenn die Firmware eine Set-
  Aktion mit Enable-Argument im Auth-Dienst anbietet).
- fritzbox.second_factor_set: schaltet sie (confirmed, Taste per
  button_wait); sonst not_switchable + Weg ueber die Oberflaeche.

Diagnostic (💾-Fenster): Zeile "Tastenbestaetigung: an/aus" mit Aus-/
Einschalten (Rueckfrage, Countdown; nicht schaltbar → Hinweisfenster),
Knopf "Alle FritzBoxen sichern" mit Ergebnis je Box. Ausschalten/
Deaktivieren gilt jetzt als gefaehrliche Rueckfrage (roter Knopf).

Brain: Beschreibung fuer backup_all (je Satellit, weiter bei Fehlern) und
second_factor; Timeout 15 min fuer backup_all.

Getestet gegen nachgebaute Box (eine Box unerreichbar → uebersprungen,
Taste noetig ohne/mit Warten, Status, Schalten mit und ohne Setter) und das
Fenster in headless Chromium. Ob echte FRITZ!OS-Versionen das Schalten
per TR-064 anbieten, ist ungeprueft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:55:06 +01:00
duffyduckandClaude Opus 5.5 2bfa064ef6 feat: FritzBox-Sicherungen verwalten — Liste, Ansicht, Wiederherstellen, Loeschen
Satellit:
- sat_command laeuft als eigener Task (eine Aktion, die 2 min auf die
  Taste wartet, blockiert den Satelliten nicht mehr).
- Sicherung bekommt eine Begleitdatei (Box, Modell, Firmware, Seriennr.,
  Datum, QUELLE des Kennworts — nie der Wert).
- fritzbox.backups: Sicherungen je Box, neueste zuerst.
- fritzbox.backup_view: lesbar — Kopfdaten, wichtigste Einstellungen
  (Name, LAN/DHCP, WLAN, Kanal, DNS, SIP …), mit full alle Bereiche;
  verschluesselte Werte als «verschluesselt»; nur Dateien aus dem
  Sicherungsordner (kein Pfad von aussen).
- fritzbox.restore: X_AVM-DE_SetConfigFile — der Satellit stellt die Datei
  kurz per HTTP bereit (Zufallspfad, endet nach Abruf/10 min), die Box holt
  sie ab und startet neu. Nur mit confirmed, Taste per button_wait, mit dem
  Kennwort aus der Quelle der Sicherung; fremde Box nur mit other_box.
- fritzbox.backup_delete (mit confirmed). no_file fuer das Diagnostic.

Diagnostic: 💾 bei FritzBox-Geraeten → Sicherungen: Jetzt sichern,
ℹ (lesbar + Suche), Wiederherstellen, Loeschen; Sichern/Wiederherstellen
mit 2-Minuten-Countdown fuer die Taste, Ergebnis im Fenster (✓ / Taste
nicht gedrueckt / Grund), Erfolg blendet sich aus.

Brain: Beschreibung fuer Liste/Ansicht/Wiederherstellen/Loeschen.

Getestet: Satellit gegen nachgebaute Box (Sichern, Liste, Ansicht ohne
Geheimnisse, Pfadschutz, Wiederherstellen inkl. Abholen der Datei,
Loeschen), Diagnostic-Fenster in headless Chromium mit simuliertem
Satelliten. Gegen eine echte Box noch nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:52:04 +01:00
duffyduckandClaude Opus 5.5 5af03eedb9 fix(satellite): Geheimnisse aus FritzBox-Antworten erreichen ARIA nicht
Lesende TR-064-Aktionen geben teils Geheimnisse zurueck (GetSecurityKeys →
WLAN-Schluessel, Kennwoerter, PINs). Die gingen bisher im Ergebnis an ARIA
und damit ans Sprachmodell. Felder mit Passphrase/Password/PreSharedKey/
WEPKey/Secret/PIN werden jetzt als '•••• (verborgen)' geliefert. Schickt
ARIA den Platzhalter in einer Set-Aktion zurueck, lehnt der Satellit ab —
sonst setzte die Box ihn als neuen Schluessel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:43:04 +01:00
duffyduckandClaude Opus 5.5 c545a1cd77 feat(satellite): Sicherungs-Kennwort je FritzBox — hinterlegt, stark erzwungen, ARIA kennt es nicht
- Zugangsdaten FritzBox: neues Feld "Kennwort fuer Sicherungen"
  (backupPass, verschluesselt auf dem Satelliten, mit 👁 wie die anderen).
- fritzbox.backup nimmt: ausdruecklich angegebenes > hinterlegtes
  Sicherungs-Kennwort > FritzBox-Login. Das Ergebnis nennt nur die Quelle,
  nie das Kennwort. ARIAs Beschreibung: kein 'password' uebergeben.
- Kennwort-Regel (Formular UND Satellit): mind. 12 Zeichen, Gross- und
  Kleinbuchstabe, Ziffer, Sonderzeichen. Schwaches Kennwort wird beim
  Speichern abgelehnt (Meldung im Formular), und eine Sicherung mit
  schwachem Kennwort (auch ueber das Login) bricht mit Hinweis ab.
- Formular: Eingabefelder mit Auge gleich hoch wie die anderen.

Getestet im Satelliten: Regel, Speichern schwach/stark (Login bleibt),
Sicherung ohne/mit hinterlegtem Kennwort; das Kennwort taucht im Ergebnis
nicht auf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:42:24 +01:00
duffyduckandClaude Opus 5.5 e91435a9f6 feat(satellite): FritzBox-Bestaetigung per Taste (2 min) + Sicherung in den Chat
Der Export der Einstellungen scheiterte mit "HTTP 500" — dahinter steckt
UPnP-Fehler 866: die Box will die Aktion zusaetzlich am Geraet bestaetigt.

- _tr064 meldet jetzt den UPnP-Fehlercode statt nur "HTTP 500".
- 866 → ohne button_wait sofort zurueck mit needs_button (ARIA fragt Stefan,
  ob er an der Box steht). Mit button_wait=true: X_AVM-DE_Auth SetConfig
  (start), GetState alle 2 s bis FB_BUTTON_WAIT_SEC (120 s); "authenticated"
  → Aktion erneut, sonst Timeout "Taste nicht gedrueckt, nichts ausgefuehrt"
  (Bestaetigung wird gestoppt).
- fritzbox.backup: X_AVM-DE_GetConfigFile mit Kennwort (Standard: Login-
  Kennwort), Download mit Digest-Auth, Kopie auf dem Satelliten (die
  letzten 10 je Box) und Datei zurueck an ARIA.
- Brain: Datei aus dem Ergebnis landet in /shared/uploads/, ARIA legt sie
  mit [FILE: …] in den Chat; Timeout 200 s bei button_wait; Beschreibung
  erklaert den Tasten-Ablauf.

Getestet gegen nachgebaute Box: ohne Bestaetigung → needs_button, Taste
nach 3 s → Sicherung geladen, keine Taste → Timeout. Gegen eine echte Box
(X_AVM-DE_Auth-Ablauf) noch nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:40:42 +01:00
duffyduckandClaude Opus 5.5 a58756ddfd fix(satellite): Router als router erkennen, Docker-Netze nicht als Geraete
Auf ARIAs VM fand der neue Satellit 58 Geraete, darunter die Container der
Docker-Netze (docker0/br-*/veth) — die sind keine Geraete und fallen jetzt
aus der ARP-Liste. Die FRITZ!Box 5590 kam per SSDP als 'media-renderer';
InternetGatewayDevice / FRITZ!Box heisst jetzt type 'router'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:27:09 +01:00
duffyduckandClaude Opus 5.5 116a947a4a feat(satellite): FritzBox komplett steuern — describe/call/wlan ueber TR-064
Statt einzelner fest programmierter Befehle nutzt der Satellit die
Selbstbeschreibung der Box (tr64desc.xml + SCPD je Dienst, ohne Anmeldung
lesbar): was DIESE Firmware kann, steht dort — keine Tabellen pro Modell.

- fritzbox.describe: alle TR-064-Aktionen der Box, je mit [liest]/
  [aendert]/[GEFAEHRLICH]; filter grenzt ein (WLAN, Dect, Hosts …).
- fritzbox.call: beliebige Aktion (service kurz "WLANConfiguration:3",
  args ohne "New"-Praefix moeglich). Alles ausser Get… braucht
  confirmed=true, sonst needs_confirmation — ARIA muss vorher fragen.
- fritzbox.wlan: alle WLANs inkl. Gast (index/ssid/an), schalten mit
  index + enable (+ confirmed).
- FritzBox per Geraetename ("Fritzbox Keller") statt IP ansprechbar.
- Allowlist kennt Platzhalter "fritzbox.*" (Default + .env.example).
- Brain: Tool-Beschreibung mit Ablauf (describe → call) und Freigabe-Regel,
  fritzbox.* mit 60 s Timeout.

Getestet gegen eine nachgebaute TR-064-Box: describe/filter, lesen,
schreiben ohne/mit Freigabe (ohne kommt nichts an der Box an), WLAN
schalten, Namensaufloesung, Allowlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:11:58 +01:00
duffyduckandClaude Opus 5.5 0bf00f9fc4 feat(satellite): fritzbox.hosts mit LAN-Port, Verbindungsart und Tempo
ARIA konnte "an welchem Port der FritzBox Keller haengt X" nicht
beantworten: fritzbox.hosts warf alles ausser Name/IP/MAC/aktiv weg. Die
Host-Liste der Box (TR-064 X_AVM-DE_GetHostListPath) enthaelt je nach
FRITZ!OS auch InterfaceType, X_AVM-DE_Port, X_AVM-DE_Speed und
X_AVM-DE_Guest — die gehen jetzt als interface, lan_port, speed_mbit,
guest mit. params.find filtert nach Name/IP/MAC. Hinweis im Ergebnis:
lan_port ist die Buchse DIESER Box (hinter Switch/Repeater dessen Port).
Tool-Beschreibung im Brain nennt die neuen Felder.

Getestet mit nachgebauter AVM-Host-Liste; gegen eine echte Box noch nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:06:21 +01:00
duffyduckandClaude Opus 5.5 c934866bba feat(satellite): gespeicherte Zugangsdaten anzeigen, Passwort per Auge, FritzBox-Benutzer ermitteln
Das Zugangsdaten-Formular war beim erneuten Oeffnen leer, obwohl der
Satellit alles gespeichert hatte.

Satellit:
- sat_creds_list mit ip liefert je Typ die Felder ohne Geheimnisse
  (Benutzer, Port, SNMP-Version/v3-Stufe) und has_<feld> fuer Passwoerter;
  mit reveal (nur zusammen mit ip) auch die Geheimnisse dieses Geraets.
- sat_creds_set behaelt ein gespeichertes Geheimnis, wenn das Feld nicht
  mitgeschickt wird — Benutzer aendern, ohne das Passwort neu zu tippen.

Diagnostic-Server: Antworten mit Klartext (revealed) gehen nur an den
Browser, der gefragt hat, nicht an alle offenen Tabs.

Formular: Benutzer/Port/Version stehen beim Oeffnen drin, Passwoerter als
"•••••• gespeichert"; 👁 holt sie im Klartext bzw. schaltet um. Leeres
Passwortfeld = gespeichertes behalten. FritzBox: "Benutzer ermitteln"
(nur bei als FritzBox erkannten Geraeten) liest login_sid.lua?version=2 —
die Box nennt ihre Benutzer ohne Anmeldung; eingetragen wird der zuletzt
angemeldete.

Getestet: Satelliten-Logik per Unit-Test, Formular in headless Chromium
(Anzeige, Speichern ohne Passwort, Auge, Benutzer ermitteln).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:48:44 +01:00
duffyduckandClaude Opus 5.5 3a3ef81835 fix(satellite): Scan-Auftrag waehrend laufendem Scan wartet bis zum Ende
Kam sat_discover, waehrend schon ein Scan lief (z.B. der erste nach dem
Start, ~25 s), wartete der Satellit nur 6 s und lieferte die bisherige
Liste — nach einem Neustart leer → "Keine Geraete gefunden". Jetzt wartet
er, bis der laufende Scan fertig ist (hoechstens SCAN_MAX_SEC).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:14:40 +01:00
duffyduckandClaude Opus 5.5 2a0c8b8192 fix(satellite): Thread-Leck im mDNS-Scan — ServiceBrowser werden beendet
Jeder ServiceBrowser ist ein eigener Thread; zc.close() beendet ihn nicht
(zeroconf 0.151.5, lokal nachgestellt: +1 Thread pro Browser und Scan,
mit cancel() keiner). Bei 14 Diensttypen und einem Scan alle 5 Minuten
hatte der Satellit HomeOffice nach ~5 Tagen 18.841 Threads = Thread-Limit
des Containers. Seit 30.10. 12:19 scheiterte jeder Scan mit "[Errno 11]
Resource temporarily unavailable", auf sat_discover kam keine Antwort mehr
(auch die RVS-Verbindung brach zeitweise mit Errno 11 ab).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:10:45 +01:00
duffyduckandClaude Opus 5.5 aeabba10de fix(satellite): Scan haengt nicht mehr ewig und blockiert keine anderen Auftraege
Der Satellit HomeOffice war verbunden (Heartbeat lief), beantwortete
sat_discover aber nicht mehr — auch ein zweiter Scan blieb ohne Antwort.
Ursache: Nachrichten werden nacheinander abgearbeitet (async for … await
_handle), und der Scan lief im Loop. Haengt ein Schritt (mDNS/SSDP/ARP/SNMP/
ADB), steht der ganze Satellit — Tunnel und Befehle eingeschlossen.

- sat_discover laeuft als eigener Task, der Loop bleibt frei.
- Ein Scan ist auf SCAN_MAX_SEC (90 s) begrenzt; danach kommt die letzte
  bekannte Liste zurueck, und das Log nennt den haengenden Schritt.
- Diagnostic: "Scanne Netz …" endet nach 2 Minuten mit einem Hinweis statt
  fuer immer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:05:32 +01:00
24 changed files with 4330 additions and 145 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20601
versionName "0.2.6.1"
versionCode 20602
versionName "0.2.6.2"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.6.1",
"version": "0.2.6.2",
"private": true,
"scripts": {
"android": "react-native run-android",
+39
View File
@@ -35,6 +35,8 @@ interface VoiceButtonProps {
onTapStart: () => Promise<boolean>;
/** User hat nochmal getippt — ChatScreen soll stopStreamingRecording rufen. */
onTapStop: () => Promise<void>;
/** Muelleimer waehrend der Aufnahme — verwerfen, NICHTS an ARIA senden. */
onTapCancel?: () => Promise<void>;
/** Button deaktivieren */
disabled?: boolean;
/** Wake-Word-Modus aktiv (zeigt gruenen Indikator-Dot) */
@@ -46,6 +48,7 @@ interface VoiceButtonProps {
const VoiceButton: React.FC<VoiceButtonProps> = ({
onTapStart,
onTapStop,
onTapCancel,
disabled = false,
wakeWordActive = false,
}) => {
@@ -136,6 +139,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
}
}, [disabled, onTapStart, onTapStop]);
const handleCancel = useCallback(async () => {
if (!onTapCancel || tapBusy.current) return;
tapBusy.current = true;
try {
if (audioService.getRecordingState() === 'recording') await onTapCancel();
} finally {
tapBusy.current = false;
}
}, [onTapCancel]);
const formatDuration = (ms: number): string => {
const seconds = Math.floor(ms / 1000);
const tenths = Math.floor((ms % 1000) / 100);
@@ -154,6 +167,16 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
{wakeWordActive && !isRecording && (
<View style={styles.wakeWordDot} />
)}
{isRecording && onTapCancel && (
<TouchableOpacity
onPress={handleCancel}
accessibilityLabel="Aufnahme verwerfen"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
style={styles.cancelButton}
>
<Text style={styles.cancelIcon}>🗑</Text>
</TouchableOpacity>
)}
<Animated.View
style={[
styles.buttonOuter,
@@ -197,6 +220,22 @@ const styles = StyleSheet.create({
backgroundColor: '#34C759',
zIndex: 10,
},
// Muelleimer links neben dem Aufnahmeknopf — nur waehrend der Aufnahme
cancelButton: {
position: 'absolute',
left: -46,
top: 14,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(60, 60, 70, 0.9)',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
},
cancelIcon: {
fontSize: 18,
},
buttonOuter: {
width: 64,
height: 64,
+10
View File
@@ -2624,6 +2624,15 @@ const ChatScreen: React.FC = () => {
}
}, []);
// Muelleimer waehrend der Aufnahme: verwerfen. cancelStreamingRecording
// schickt stt_stream_end mit reason "cancel:*" — die STT-Bridge transkribiert
// dann nicht, die aria-bridge wirft einen spaeten Text weg, und das leere
// Endpoint raeumt die Sprechblase ab. Danach zurueck aufs Wake-Word.
const handleVoiceButtonCancel = useCallback(async (): Promise<void> => {
console.log('[Chat] Aufnahme verworfen (Muelleimer)');
await ariaStopRecording('user-discard');
}, [ariaStopRecording]);
// Datei auswaehlen → zur Pending-Liste hinzufuegen
const handleFileSelected = useCallback(async (file: FileData) => {
setShowFileUpload(false);
@@ -3796,6 +3805,7 @@ const ChatScreen: React.FC = () => {
<VoiceButton
onTapStart={handleVoiceButtonStart}
onTapStop={handleVoiceButtonStop}
onTapCancel={handleVoiceButtonCancel}
disabled={connectionState !== 'connected'}
wakeWordActive={wakeWordActive}
/>
+147 -3
View File
@@ -48,6 +48,11 @@ BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
# Modell-Info fuer Antworten, an denen kein Claude-Modell beteiligt war
# (Fast-Path, Wake-Befehle, lokales LLM). Leer statt geraten.
_NO_MODEL = {"model": "", "requested": "", "reason": ""}
# Lokale Modelle bewusst waehlen: pro Chat ("local:<modell>" im Modell-Override)
# oder fuer eine Frage ("@lokal …" / "@lokal:<modell> …").
LOCAL_MODEL_PREFIX = "local:"
_AT_LOCAL_RE = re.compile(r"^\s*@lokal(?::([\w.\-]+))?(?=[\s:,]|$)[\s:,]*(.*)$", re.IGNORECASE | re.DOTALL)
# SearXNG (self-hosted Meta-Suche) — Backend fuers web_search-Tool (B1b).
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://searxng:8080").rstrip("/")
# FLUX-Render kann bis ~90s dauern, beim ersten Render nach Container-Start
@@ -1292,8 +1297,51 @@ META_TOOLS = [
"existiert, weiss SNMP NICHT). 'snmp.get'/'snmp.walk' "
"(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige "
"SNMP-Werte. 'fritzbox.info' / 'fritzbox.hosts' (params={'ip':'<fritzbox>'}) "
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete (braucht "
"hinterlegten FritzBox-Login). 'ssh.exec' (params={'ip':'...','cmd':'...'}) "
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete MIT "
"Verbindungsart (LAN/WLAN), lan_port (an welcher LAN-Buchse der Box) und "
"speed_mbit; 'find' filtert nach Name/IP/MAC (braucht "
"hinterlegten FritzBox-Login). FRITZ!BOX VOLLSTAENDIG STEUERN: "
"'fritzbox.describe' (params={'ip':'<box oder Name>','filter':'WLAN'}) listet, "
"was DIESE Box per TR-064 kann (je Aktion [liest]/[aendert]/[GEFAEHRLICH]); "
"'fritzbox.call' (params={'ip':..,'service':'WLANConfiguration:3',"
"'action':'GetInfo','args':{...}}) ruft jede davon auf; 'fritzbox.wlan' "
"zeigt alle WLANs inkl. Gast (index, ssid, an) und schaltet sie "
"(index + enable). Unbekannte Funktion (WPS, DECT, Anrufliste, Internet "
"sperren …)? Erst describe mit passendem filter, dann call. REGEL: Alles "
"was AENDERT (Set…, WPS, Neustart …) braucht confirmed=true — und das "
"setzt Du NUR, nachdem Du Stefan gesagt hast, was genau passiert, und er "
"ausdruecklich ja gesagt hat. Ohne confirmed kommt needs_confirmation "
"zurueck: dann fragen, nicht raten. [GEFAEHRLICH] (Neustart, "
"Werkseinstellung, Portfreigabe, Passwort) besonders deutlich ankuendigen. "
"TASTE AN DER BOX: Manche Aktionen (z.B. Sicherung) muss man an der Box "
"bestaetigen — dann kommt 'muss an der FritzBox bestaetigt werden'. Frag "
"Stefan, ob er an der Box steht; nach seinem Ja erneut mit button_wait=true "
"aufrufen, dann hat er 2 Minuten fuer einen Tastendruck (sonst Timeout, "
"nichts passiert — einfach nachfragen und neu versuchen). 'fritzbox.backup' "
"(params={'ip':..,'button_wait':true}) sichert die Einstellungen — OHNE "
"'password': der Satellit nimmt das hinterlegte Sicherungs-Kennwort (das "
"Du nicht kennst und nicht kennen sollst); 'password' nur, wenn Stefan "
"ausdruecklich ein anderes will. SICHERUNGEN VERWALTEN: 'fritzbox.backups' "
"(params={'ip':..}) listet die Sicherungen der Box (datei, erstellt, modell, "
"firmware), neueste zuerst. 'fritzbox.backup_view' (params={'ip':..,'file':"
"'<datei>'}) zeigt die wichtigsten Einstellungen daraus (Kennwoerter bleiben "
"verschluesselt). 'fritzbox.restore' (params={'ip':..,'file':'<datei>',"
"'confirmed':true,'button_wait':true}) spielt eine Sicherung ein — GEFAEHRLICH: "
"Box uebernimmt alles und startet neu (1–3 min kein Netz); vorher Datum der "
"Sicherung nennen und Stefans ausdrueckliches Ja holen, ebenso ob er an der "
"Box steht. 'fritzbox.backup_delete' (params={'ip':..,'file':..,'confirmed':true}) "
"loescht eine Sicherung (nach Ja). 'fritzbox.backup_get' (params={'ip':..,"
"'file':..}) holt eine VORHANDENE Sicherung — sie liegt danach unter "
"/shared/uploads/ und kommt mit [FILE: <pfad>] in den Chat. ALLE BOXEN SICHERN: 'fritzbox.backup_all' "
"(params={} oder {'button_wait':true}) — pro Satellit einmal aufrufen (jeder "
"Satellit sichert seine Boxen mit hinterlegtem Login); nicht erreichbare oder "
"fehlerhafte werden uebersprungen, es geht mit der naechsten weiter — am Ende "
"die Liste je Box (✓/✗ + Grund) an Stefan. TASTENBESTAETIGUNG: "
"'fritzbox.second_factor' zeigt, ob sie an ist und ob sie sich per Schnittstelle "
"schalten laesst; 'fritzbox.second_factor_set' (params={'ip':..,'enable':false,"
"'confirmed':true,'button_wait':true}) schaltet sie — nur nach Stefans Ja; "
"geht es nicht, den Hinweis (Weg ueber die Oberflaeche) weitergeben. die Datei liegt danach unter /shared/uploads/ "
"und kommt mit [FILE: <pfad>] in den Chat. 'ssh.exec' (params={'ip':'...','cmd':'...'}) "
"— fuehrt EIN Shell-Kommando per SSH auf einem Geraet aus (Server, Pi, NAS) "
"und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
"stderr zurueck; braucht hinterlegte SSH-Credentials (Benutzer + Passwort "
@@ -2469,6 +2517,61 @@ class Agent:
converse = flags["converse"]
return speak, converse
def _local_area_route(self, user_message: str, project_id: str):
"""(modell, frage, begruendung) wenn dieser Turn bewusst lokal laufen
soll, sonst None. modell "" = das gerade geladene lokale Modell."""
msg = router_mod._strip_leading_hint_blocks(user_message)
m = _AT_LOCAL_RE.match(msg)
if m:
return (m.group(1) or "").strip(), (m.group(2) or "").strip() or msg, "einmalig"
ov = context_model.get(project_id)
if ov.startswith(LOCAL_MODEL_PREFIX):
return ov[len(LOCAL_MODEL_PREFIX):], msg, context_model.REASON_PER_CHAT
return None
def _local_area_turn(self, question: str, project_id: str, model: str) -> tuple:
"""Ein ganzer Turn auf dem lokalen Modell — ohne Werkzeuge, ohne Claude.
Gibt (antwort, benutztes_modell). Fehler werden NICHT als ARIAs Antwort
in den Verlauf geschrieben (nur angezeigt)."""
sys_prompt = "\n".join([
IDENTITY_ANCHOR.strip(), "",
"## LOKALES MODELL",
"Du laeufst gerade als lokales Modell auf Stefans eigener Hardware — er hat "
"dieses Modell fuer diese Frage bzw. diesen Chat bewusst gewaehlt. Antworte "
"vollstaendig und sachlich auf Deutsch, als ARIA. Du hast hier KEINE "
"Werkzeuge (kein Internet, kein Gedaechtnis, keine Aktionen): behaupte nie, "
"etwas nachgesehen oder ausgefuehrt zu haben. Weisst du etwas nicht sicher, "
"sag das. Normaler Text, kein <voice>, kein [FILE:], keine Tool-Aufrufe.",
])
notes = local_compact.summary_block(project_id)
if notes:
sys_prompt += "\n\n" + notes
all_turns = self.conversation.window(project_id=project_id)
ctx = local_compact.model_ctx(model) if model else local_compact.DEFAULT_CTX * 4
budget = int(ctx * 0.6) - 2500 - local_compact.est_tokens(sys_prompt)
window = local_compact.fit_window(all_turns, max(budget, 512))
messages = [{"role": "system", "content": sys_prompt}]
messages += [{"role": t.role, "content": t.content} for t in window]
if question and len(messages) > 1 and messages[-1]["role"] == "user":
messages[-1]["content"] = question # ohne "@lokal" davor
res = local_llm_chat(messages, max_tokens=2000, temperature=0.6,
model=model or None, timeout=600)
used = res.get("model") or model or "lokales Modell"
if not res.get("ok"):
logger.warning("[lokal] %s nicht erreichbar: %s", model or "(geladenes)", res.get("error"))
return (f"⚠ Das lokale Modell {model or ''} hat nicht geantwortet: "
f"{res.get('error', 'unbekannt')}. Liegt es auf einer Karte? "
"(Diagnostic → LLM-Karten)").replace(" ", " "), used
try:
metrics.log_local_call(used, messages, res.get("content") or "", res.get("usage"))
except Exception:
pass
reply = re.sub(r"<think>[\s\S]*?</think>", "", res.get("content") or "")
reply = re.sub(r"</?voice>", "", reply).strip() or "[Das lokale Modell lieferte keine Antwort.]"
self.conversation.add("assistant", reply, project_id=project_id)
logger.info("[lokal] %s beantwortet (%s)", used, project_id or "Hauptchat")
return reply, used
def _try_local_fast_lane(self, user_message: str,
active_project_id: str) -> Optional[str]:
cfg = router_mod.load_config()
@@ -2766,6 +2869,19 @@ class Agent:
if active_project_id:
projects_mod.touch_project(active_project_id)
# Lokales Modell bewusst gewaehlt — Claude ist fuer diesen Turn raus:
# * "@lokal …" / "@lokal:<modell> …" nur fuer diese eine Frage
# * Chat steht (🧠, pro Chat) auf "local:<modell>"
# Stefan entscheidet das selbst (z.B. ein Pentest-Projekt auf einem
# unzensierten Modell); nichts wird automatisch umgeleitet.
area = self._local_area_route(user_message, active_project_id)
if area is not None:
model, question, why = area
reply, used = self._local_area_turn(question, active_project_id, model)
converse = not _wants_end
return reply, "local", True, converse, False, {
"model": used, "requested": model or "lokal", "reason": why}
# Fast-Lane: lokales schnelles LLM (Plan B, B1a). Gated ueber
# /shared/config/local_llm.json (Default aus → alles laeuft wie bisher
# ueber Claude). Erledigt es den Turn: fertige Antwort zurueck, der
@@ -2838,6 +2954,14 @@ class Agent:
oauth_callback_host=oauth_host,
oauth_callback_port=oauth_port,
oauth_callback_tls=oauth_tls)
# Bei einer Ablehnung: Stefan kann es bewusst an ein lokales Modell geben.
system_prompt += (
"\n\n## LOKALE MODELLE ALS AUSWEG\n"
"Lehnst du eine Anfrage ab (Richtlinien), sag Stefan kurz, dass er sie selbst "
"an ein lokales Modell auf seiner eigenen Hardware geben kann: mit "
"`@lokal <Frage>` fuer diese eine Frage, oder indem er den Chat oben (🧠) "
"auf ein Modell unter „Lokal“ stellt. Leite NICHT selbst weiter und gib "
"den Inhalt nicht selbst an ein Werkzeug — die Entscheidung trifft er.")
# Rueckfrage-Signal: ARIA haengt [[AWAIT]] an, wenn ihre Antwort eine
# echte, blockierende Rueckfrage ist. Die App pausiert dann die Projekt-
# Queue und leitet Stefans naechste Eingabe als ANTWORT darauf weiter
@@ -3225,13 +3349,22 @@ class Agent:
if not sat or not action:
return "FEHLER: satellite und action sind Pflicht."
params = arguments.get("params") if isinstance(arguments.get("params"), dict) else {}
# Kennwoerter im Klartext gehen nie ans Sprachmodell — die zeigt nur
# das Diagnostic (👁) in Stefans Browser.
if action in ("fritzbox.backup_password",):
return ("FEHLER: Kennwoerter zeige ich nicht an — Stefan sieht es im Diagnostic "
"(Satelliten → Geraet → 💾 → 👁 bei der Sicherung).")
# Langlaeufer brauchen mehr als den Bridge-Default (20s): eine APK ueber
# WLAN zu installieren dauert 30-60s. Ohne das meldet ARIA "Timeout",
# obwohl die Installation noch laeuft.
# adb.*: Warten auf die Geraete-Autorisierung (bis 90s) + Download +
# Installation koennen zusammen mehrere Minuten dauern.
sat_timeout = (320.0 if action.startswith("adb.")
else 200.0 if action == "ssh.exec" else 20.0)
else 200.0 if action == "ssh.exec"
# button_wait: bis 2 min auf die Taste an der FritzBox
else 900.0 if action == "fritzbox.backup_all"
else 200.0 if action.startswith("fritzbox.") and params.get("button_wait")
else 60.0 if action.startswith("fritzbox.") else 20.0)
result = _post("/internal/satellite", {
"op": "command", "satellite": sat,
"device": arguments.get("device") or "", "action": action, "params": params,
@@ -3239,7 +3372,18 @@ class Agent:
}, sat_timeout + 20)
if result.get("ok"):
r = result.get("result")
if isinstance(r, dict) and r.get("file_b64"):
# Datei vom Satelliten (z.B. FritzBox-Sicherung) → /shared/uploads/
import base64
fname = re.sub(r"[^A-Za-z0-9._-]", "_", str(r.get("filename") or "satellit_datei"))
path = f"/shared/uploads/sat_{int(time.time())}_{fname}"
with open(path, "wb") as fh:
fh.write(base64.b64decode(r.pop("file_b64")))
r["im_chat_ablegbar"] = path
r["hinweis_chat"] = f"In den Chat legen mit [FILE: {path}]"
return f"OK — {r}" if r else "OK — Aktion ausgefuehrt."
if result.get("needs_button") or result.get("needs_confirmation") or result.get("timeout"):
return f"FEHLER: {result.get('error')}"
return f"FEHLER: {result.get('error') or 'Aktion fehlgeschlagen'}"
except Exception as exc:
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
+15 -1
View File
@@ -1343,11 +1343,25 @@ def _context_model_rows() -> dict:
"reason": reason,
"blocked": sorted(blocked),
})
choices = _model_choices()
choices = _model_choices() + _local_model_choices()
names = {c["id"]: c["name"] for c in choices}
return {"default": default, "rows": rows, "choices": choices, "names": names}
def _local_model_choices() -> list:
"""Lokale Modelle, die gerade auf einer Karte liegen (ARIA-Cluster ueber
die Bridge) — als "local:<modell>" waehlbar pro Chat. Fail-soft: []."""
try:
import httpx as _httpx
base = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090").rstrip("/")
data = _httpx.get(f"{base}/internal/llm-models", timeout=4).json()
return [{"id": "local:" + m["model"], "name": "🖥 lokal: " + m["model"], "section": "local"}
for m in (data.get("models") or []) if m.get("model")]
except Exception as exc:
logger.info("[context-model] lokale Modelle nicht abrufbar: %s", exc)
return []
@app.get("/context-model/list")
def context_model_list():
return _context_model_rows()
+16
View File
@@ -3449,6 +3449,16 @@ class ARIABridge:
# stt_endpoint-Handler popt selbst; hier nur ein verzoegerter Cleanup
# als Leak-Schutz, falls gar kein Endpoint mehr kommt.
req_id = payload.get("requestId", "") or ""
# Verworfen (Muelleimer in der App)? Dann darf ein trotzdem noch
# kommender Text NICHT an ARIA — auch wenn eine alte STT-Bridge ihn
# noch transkribiert.
if req_id and str(payload.get("reason", "")).startswith("cancel"):
if not hasattr(self, "_stt_cancelled"):
self._stt_cancelled = {}
self._stt_cancelled[req_id] = time.time()
for rid, t in list(self._stt_cancelled.items()):
if time.time() - t > 120:
self._stt_cancelled.pop(rid, None)
if req_id:
try:
loop = asyncio.get_event_loop()
@@ -3473,6 +3483,9 @@ class ARIABridge:
logger.info("[rvs] stt_endpoint mit leerem Text — ignoriert (reason=%s)",
payload.get("reason", ""))
return
if (payload.get("requestId") or "") in getattr(self, "_stt_cancelled", {}):
logger.info("[rvs] stt_endpoint einer verworfenen Aufnahme — ignoriert")
return
audio_request_id = payload.get("audioRequestId", "") or ""
voice = payload.get("voice", "") or ""
speed_raw = payload.get("speed")
@@ -3732,6 +3745,8 @@ class ARIABridge:
"location": payload.get("location") or sid,
"caps": payload.get("caps") or [],
"control": bool(payload.get("control")),
# "vpn" = VPN-Satellit (Kundennetz hinter WireGuard, laeuft auf ARIA)
"kind": payload.get("kind") or "lan",
"last_seen": time.time(),
}
cur = self._satellites[sid]
@@ -5216,6 +5231,7 @@ class ARIABridge:
"id": s["id"], "location": alias or own,
"agentName": own, "alias": alias,
"caps": s.get("caps") or [], "control": bool(s.get("control")),
"kind": s.get("kind") or "lan",
"online": (now - s.get("last_seen", 0)) < 300,
})
return out
+1118 -38
View File
File diff suppressed because it is too large Load Diff
+424 -29
View File
@@ -17,6 +17,7 @@ const { WebSocket, WebSocketServer } = require("ws");
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const vpn = require("./vpn");
// ── Konfiguration ───────────────────────────────────────
const HTTP_PORT = parseInt(process.env.DIAG_PORT || "3001", 10);
@@ -517,22 +518,46 @@ async function hfRepoQuant(repo, wanted = "Q4_K_M") {
// Katalog (kuratierte Eintraege + Beschreibungen bleiben erhalten). Fuer jedes
// HF-Repo wird die TATSAECHLICH vorhandene Quantisierung und Groesse
// eingetragen — auch bei schon vorhandenen HF-Eintraegen.
// "Von HuggingFace aktualisieren": mehrere Blickwinkel statt nur der
// meistgeladenen aller Zeiten (da gewinnen alte Modelle). Neu dazu:
// was gerade im Trend ist, Coder-Modelle und unzensierte Fassungen.
// Nur was eine Box laden kann (GGUF) und hoechstens LLM_CATALOG_MAX_GB.
const LLM_CATALOG_MAX_GB = Number(process.env.LLM_CATALOG_MAX_GB || 30);
const NON_LLM_RE = /image|audio|tts|speech|music|yue|whisper|diffusion|flux|sdxl|wan2|video|embed|rerank|ocr/i;
const HF_REFRESH_QUERIES = [
["beliebt", "search=GGUF&sort=downloads&direction=-1&limit=30"],
["im Trend", "filter=gguf&sort=trendingScore&direction=-1&limit=30"],
["Code", "search=coder&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
["unzensiert", "search=uncensored&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
["unzensiert", "search=heretic&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
["unzensiert", "search=abliterated&filter=gguf&sort=trendingScore&direction=-1&limit=12"],
];
async function refreshLlmCatalogFromHF() {
const url = "https://huggingface.co/api/models?search=GGUF&sort=downloads&direction=-1&limit=40";
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) throw new Error(`HF API ${r.status}`);
const list = await r.json();
const existing = loadLlmCatalog();
const byId = new Map(existing.map(m => [m.id, m]));
let added = 0;
for (const m of (Array.isArray(list) ? list : [])) {
const repo = m.id || m.modelId;
if (!repo || !/gguf/i.test(repo)) continue;
const fresh = new Map(); // repo → {cat, downloads}
for (const [cat, q] of HF_REFRESH_QUERIES) {
const list = await hfJson(`https://huggingface.co/api/models?${q}`);
for (const m of Array.isArray(list) ? list : []) {
const repo = m.id || m.modelId;
if (!repo || fresh.has(repo)) continue;
// Nur Sprachmodelle: Bild-, Audio-, Musik- und Sprach-Modelle kann die
// LLM-Box nicht als Chat-Modell laden.
const pt = String(m.pipeline_tag || "");
if (pt && !["text-generation", "image-text-to-text", "conversational"].includes(pt)) continue;
if (NON_LLM_RE.test(repo)) continue;
fresh.set(repo, { cat, downloads: m.downloads || 0 });
}
}
if (!fresh.size) throw new Error("HuggingFace hat nichts geliefert (erreichbar?)");
const known = new Set(existing.map(m => m.hfRepo));
for (const [repo, f] of fresh) {
if (known.has(repo)) continue;
const id = slugModelId(repo);
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
const entry = { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
description: `HuggingFace · ${(m.downloads || 0).toLocaleString("de")} Downloads`, source: "hf", _new: true };
byId.set(id, entry);
if (byId.has(id)) continue; // kuratierte/vorhandene nicht ueberschreiben
byId.set(id, { id, hfRepo: repo, quant: "Q4_K_M", ctx: 8192, sizeGB: 0,
description: `HuggingFace · ${f.cat} · ${(f.downloads).toLocaleString("de")} Downloads`, source: "hf",
uncensored: UNCENSORED_RE.test(repo), added_at: new Date().toISOString().slice(0, 10), _new: true });
}
// Quantisierung + Groesse pruefen: alle HF-Eintraege (kuratierte haben
// gepflegte Werte). Je 6 gleichzeitig, damit HF nicht drosselt.
@@ -540,18 +565,58 @@ async function refreshLlmCatalogFromHF() {
for (let i = 0; i < toCheck.length; i += 6) {
await Promise.all(toCheck.slice(i, i + 6).map(async e => {
const q = await hfRepoQuant(e.hfRepo, e.quant || "Q4_K_M");
if (!q) {
// Kein ladbares Modell im Repo: neue nicht aufnehmen, alte markieren.
if (e._new) byId.delete(e.id); else e.unusable = true;
if (!q || (e._new && q.sizeGB > LLM_CATALOG_MAX_GB)) {
// Nicht ladbar oder fuer unsere Karten viel zu gross: neue nicht
// aufnehmen, alte markieren.
if (e._new) byId.delete(e.id); else if (!q) e.unusable = true;
return;
}
e.quant = q.quant; e.sizeGB = q.sizeGB; e.quants = q.quants; delete e.unusable;
}));
}
// Werkzeug-Faehigkeit + Sicherheits-Scan nachtragen (wo noch keine Angabe),
// Beliebtheit bei jedem Abgleich frisch (aendert sich taeglich)
const today = new Date().toISOString().slice(0, 10);
const toEnrich = [...byId.values()].filter(e => e.hfRepo && (e.tools === undefined || e.safety === undefined
|| !e.popularity || e.popularity.at !== today));
for (let i = 0; i < toEnrich.length; i += 6) {
await Promise.all(toEnrich.slice(i, i + 6).map(async e => {
if (e.tools === undefined) e.tools = await hfToolSupport(e.hfRepo);
if (e.safety === undefined) e.safety = await hfRepoSafety(e.hfRepo);
if (!e.popularity || e.popularity.at !== today) e.popularity = await hfPopularity(e.hfRepo) || e.popularity;
}));
}
let added = 0;
for (const e of byId.values()) { if (e._new) { added++; delete e._new; } }
// Aufraeumen: von HF uebernommen, vor > LLM_CATALOG_KEEP_DAYS Tagen, auf
// keiner Box (Platte/Karte), heute nicht mehr gelistet → raus. Kuratierte
// und von Hand hinzugefuegte bleiben immer.
const keepDays = Number(process.env.LLM_CATALOG_KEEP_DAYS || 30);
const onBox = new Set();
for (const w of workers.values()) {
for (const d of ((w.disk && w.disk.models) || [])) onBox.add(d.repo);
for (const sl of (w.slots || [])) if (sl.model) onBox.add(sl.model);
}
let removed = 0;
for (const [id, e] of [...byId]) {
const age = e.added_at ? (Date.now() - new Date(e.added_at).getTime()) / 864e5 : 0;
if (e.source === "hf" && !/Hand/.test(e.description || "") && age > keepDays
&& !fresh.has(e.hfRepo) && !onBox.has(e.hfRepo) && !onBox.has(e.id)) {
byId.delete(id); removed++;
}
}
const merged = Array.from(byId.values());
saveLlmCatalog(merged);
return { models: merged, added };
return { models: merged, added, removed };
}
// Einmal am Tag von selbst (LLM_CATALOG_REFRESH_HOURS, 0 = aus)
const LLM_CATALOG_REFRESH_HOURS = Number(process.env.LLM_CATALOG_REFRESH_HOURS ?? 24);
if (LLM_CATALOG_REFRESH_HOURS > 0) {
setInterval(() => {
refreshLlmCatalogFromHF()
.then(r => log("info", "llm", `LLM-Katalog automatisch aktualisiert: +${r.added}, -${r.removed}`))
.catch(err => log("warn", "llm", `LLM-Katalog automatisch: ${err.message}`));
}, LLM_CATALOG_REFRESH_HOURS * 3600 * 1000);
}
// ── Belege: Werkzeug-Aufrufe zu einer ARIA-Antwort ─────────────────
@@ -640,17 +705,212 @@ async function discoverGguf(cat, trusted) {
}
// Ein HF-Repo in den Katalog uebernehmen (danach dort wie gewohnt "Laden")
async function addHfRepoToCatalog(repo) {
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo");
// Link oder "org/name" → "org/name" (huggingface.co/org/name/tree/main … geht auch)
function hfRepoFromInput(input) {
const t = String(input || "").trim().replace(/^https?:\/\/(www\.)?huggingface\.co\//i, "").replace(/[?#].*$/, "");
const m = t.match(/^([\w.-]+)\/([\w.-]+)/);
return m ? `${m[1]}/${m[2]}` : "";
}
// Repo ohne GGUF (z.B. nur safetensors): GGUF-Fassungen desselben Modells
// auf HuggingFace suchen — meist von mradermacher, bartowski, unsloth & Co.
async function hfGgufAlternatives(repo) {
const name = String(repo).split("/")[1] || "";
if (!name) return [];
try {
const r = await fetch(`https://huggingface.co/api/models?search=${encodeURIComponent(name)}&filter=gguf&sort=downloads&direction=-1&limit=12`,
{ headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) return [];
return (await r.json()).filter(m => m.id && m.id !== repo)
.map(m => ({ repo: m.id, downloads: m.downloads || 0, likes: m.likes || 0 })).slice(0, 8);
} catch { return []; }
}
// ── ℹ im Modell-Katalog: was kann das Modell, ist es zensiert? ──────
// HuggingFace liefert Grundmodell, Lizenz und Schlagworte, aber keine
// verstaendliche Beschreibung. Darum: kurze deutsche Texte je Modellfamilie
// (erkannt am Namen) + die Fakten von HF + Anfang der Modellkarte.
const MODEL_FAMILIES = [
["qwen3-coder", "Qwen3-Coder (Alibaba)", "Aufs Programmieren spezialisiert: Code schreiben, verstehen, umbauen, mit Werkzeugen arbeiten (Agenten). Die 30B-A3B-Fassung ist ein MoE-Modell: pro Wort rechnen nur ~3 Mrd. Parameter — schnell für seine Größe."],
["foundation-sec", "Foundation-Sec (Cisco Foundation AI)", "Auf IT-Sicherheit trainiert: Schwachstellen, Angriffstechniken (MITRE ATT&CK), Logs und Warnmeldungen auswerten. Die reine „Foundation-Sec-8B“ ist ein Basismodell (setzt nur Text fort) — zum Chatten die Instruct- oder Reasoning-Fassung nehmen."],
["vulnllm", "VulnLLM-R", "Sucht Sicherheitslücken in Quellcode und begründet Schritt für Schritt (Reasoning), warum eine Stelle verwundbar ist."],
["dolphin", "Dolphin (Cognitive Computations)", "Bewusst ohne Ablehnungen trainiert: macht, was man verlangt, und lässt sich stark über den Systemprompt steuern. Gut für Aufgaben, die große Anbieter verweigern — man ist aber selbst verantwortlich, was man damit tut."],
["hermes", "Hermes (Nous Research)", "Sehr gutes Befolgen von Anweisungen, Rollenspiel und Werkzeug-Aufrufe (Function Calling), wenige Ablehnungen. Hermes 4 kann zusätzlich „denken“ (Reasoning)."],
["lexi", "Lexi (Orenguteng)", "Unzensierter Llama-Ableger für allgemeinen Chat, beantwortet auch Fragen, die das Original ablehnt."],
["gemma", "Gemma (Google)", "Allround-Modell mit gutem Deutsch; neuere Fassungen verstehen auch Bilder. Eher vorsichtig, lehnt heikle Themen ab."],
["qwen", "Qwen (Alibaba)", "Starke Allround-Familie: Wissen, Logik, Mathe, Code, viele Sprachen inkl. Deutsch; neuere Fassungen mit Denkmodus. Standard-Fassungen haben Schutzfilter."],
["llama", "Llama (Meta)", "Bewährtes Allround-Modell und Basis für sehr viele Ableger; ordentliches Deutsch, solide Werkzeug-Aufrufe."],
["mistral", "Mistral (Mistral AI, Frankreich)", "Gut in europäischen Sprachen, effizient; Mistral Small 24B ist für seine Größe sehr stark."],
["deepseek", "DeepSeek", "Stark in Logik, Mathe und Code, oft mit ausführlichem Denkweg (Reasoning)."],
["phi", "Phi (Microsoft)", "Klein und schnell, erstaunlich gut in Logik für die Größe; eher Englisch."],
];
const UNCENSORED_RE = /uncensor|abliterat|heretic|obliterat|dolphin|lexi|unfiltered|nsfw|no.?refus/i;
async function hfJson(url) {
try {
const r = await fetch(url, { headers: { "User-Agent": "aria-diagnostic" } });
return r.ok ? await r.json() : null;
} catch { return null; }
}
async function hfReadmeIntro(repo) {
try {
const r = await fetch(`https://huggingface.co/${repo}/raw/main/README.md`, { headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) return "";
let t = await r.text();
t = t.replace(/^---[\s\S]*?\n---\n/, ""); // YAML-Kopf
const paras = t.split(/\n\s*\n/).map(p => p.trim())
.filter(p => p && !/^(#|!\[|<|\||```|\[!\[|>|-{3,})/.test(p) && p.replace(/[^A-Za-z]/g, "").length > 60)
// Absaetze, die fast nur Links sind (Website:, Twitter: …), ueberspringen
.filter(p => p.replace(/https?:\/\/\S+/g, "").replace(/[^A-Za-z]/g, "").length > 80);
return paras.slice(0, 2).join("\n\n").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").slice(0, 900);
} catch { return ""; }
}
// Ganze Modellkarte als Text (ohne YAML/Bilder/HTML), fuer die Zusammenfassung
async function hfReadmeText(repo, max = 7000) {
try {
const r = await fetch(`https://huggingface.co/${repo}/raw/main/README.md`, { headers: { "User-Agent": "aria-diagnostic" } });
if (!r.ok) return "";
return (await r.text()).replace(/^---[\s\S]*?\n---\n/, "").replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/<[^>]+>/g, "").replace(/\n{3,}/g, "\n\n").slice(0, max);
} catch { return ""; }
}
// Deutsche Kurzbeschreibung vom LOKALEN Modell aus der Modellkarte — einmal
// erzeugt, dann im Katalog gespeichert (summary_de). So hat jedes neue Modell
// eine, ohne dass jemand Texte pflegt.
async function llmModelSummary(repo) {
repo = hfRepoFromInput(repo);
const cat = loadLlmCatalog();
const entry = cat.find(m => m.hfRepo === repo);
if (entry && entry.summary_de) return { summary: entry.summary_de, by: entry.summary_by || "", cached: true };
const meta = await hfJson(`https://huggingface.co/api/models/${repo}`) || {};
let base = (meta.cardData || {}).base_model;
base = Array.isArray(base) ? base[0] : base || "";
const card = [await hfReadmeText(base) , await hfReadmeText(repo, 3000)].filter(Boolean).join("\n\n---\n\n");
if (!card.trim()) return { summary: "", error: "Keine Modellkarte auf HuggingFace." };
const tags = (meta.tags || []).filter(t => !/^(base_model|license|region|endpoints)/.test(t)).slice(0, 25).join(", ");
const res = await rvsRequest("llm_request", {
requestId: "modelinfo_" + Date.now(),
messages: [
{ role: "system", content: "Du fasst Modellkarten von KI-Sprachmodellen fuer einen Laien auf Deutsch zusammen. "
+ "Nur was in der Karte steht, nichts erfinden. 3 bis 5 kurze Saetze: wofuer das Modell gedacht ist, worin es "
+ "besonders gut ist, ob es Ablehnungen/Schutzfilter entfernt hat (uncensored/abliterated), und Besonderheiten "
+ "(Denkmodus, Werkzeuge, Sprachen, Groesse). Keine Ueberschrift, kein Markdown." },
{ role: "user", content: `Modell: ${repo}\nGrundmodell: ${base || "-"}\nSchlagworte: ${tags}\n\nModellkarte:\n${card}` },
],
max_tokens: 400, temperature: 0.2, model: "", targetInstance: "",
}, "llm_response", 180000);
const text = String(res.content || res.text || "").replace(/<think>[\s\S]*?<\/think>/g, "").trim();
if (!res.ok || !text) return { summary: "", error: res.error || "Das lokale Modell hat nicht geantwortet." };
const by = res.model || "lokales Modell";
if (entry) {
entry.summary_de = text; entry.summary_by = by; entry.summary_at = new Date().toISOString();
saveLlmCatalog(cat);
}
return { summary: text, by, cached: false };
}
async function llmModelInfo(repo) {
repo = hfRepoFromInput(repo);
if (!repo) throw new Error("ungueltiges Repo");
const meta = await hfJson(`https://huggingface.co/api/models/${repo}`) || {};
const card = meta.cardData || {};
let base = card.base_model;
base = Array.isArray(base) ? base[0] : base || "";
const baseMeta = base ? (await hfJson(`https://huggingface.co/api/models/${base}`) || {}) : {};
const tags = [...new Set([...(meta.tags || []), ...((card.tags) || [])])]
.filter(t => !/^(base_model|license|region|endpoints|autotrain|text-generation-inference|safetensors|transformers|gguf|conversational|imatrix)/.test(t));
const hay = [repo, base, ...tags].join(" ").toLowerCase();
const fams = [];
for (const [key, name, text] of MODEL_FAMILIES) {
// Ganzes Wort am Anfang ("phi" nicht in "dolphin" finden)
const re = new RegExp("(^|[^a-z0-9])" + key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
if (name && re.test(hay) && !fams.some(f => f.name === name)) fams.push({ name, text });
}
// Allgemeine Familien (Qwen/Llama …) nur, wenn keine speziellere passt
const special = fams.filter(f => !/^(Qwen|Llama|Mistral|Gemma) \(/.test(f.name));
const families = special.length ? [...special, ...fams.filter(f => !special.includes(f)).slice(0, 1)] : fams.slice(0, 1);
const unc = UNCENSORED_RE.exec(hay);
return {
repo, base, author: meta.author || repo.split("/")[0], license: card.license || "",
downloads: meta.downloads || 0, likes: meta.likes || 0,
created: meta.createdAt || "", updated: meta.lastModified || "",
tags: tags.slice(0, 20), families,
uncensored: !!unc,
tools: await hfToolSupport(repo),
safety: await hfRepoSafety(repo),
uncensoredWhy: unc ? `erkannt an „${unc[0]}“ in Name/Schlagworten` : "",
intro: await hfReadmeIntro(base || repo) || await hfReadmeIntro(repo),
introFrom: base || repo,
};
}
// Kann das Modell Werkzeuge? Kennzeichen: die Chat-Vorlage im GGUF (HF liefert
// sie unter expand[]=gguf) kennt ein Werkzeug-Format, oder Schlagworte wie
// function-calling/tool-use. Zeigt nur, OB das Format da ist, nicht wie gut.
const TOOLS_TEMPLATE_RE = /\btools\b|tool_call|<tool|function_call/;
const TOOLS_TAG_RE = /function.?call|tool.?use|tool.?calling|agent/i;
async function hfToolSupport(repo) {
const d = await hfJson(`https://huggingface.co/api/models/${repo}?expand[]=gguf&expand[]=tags`);
if (!d) return null;
const tpl = String(((d.gguf || {}).chat_template) || "");
if (TOOLS_TEMPLATE_RE.test(tpl)) return true;
if ((d.tags || []).some(t => TOOLS_TAG_RE.test(t))) return true;
return tpl ? false : null; // keine Vorlage = unbekannt
}
// ── Sicherheit eines Repos ─────────────────────────────────────────
// Die Box laedt NUR die GGUF-Datei (llama.cpp -hf / paralleler Download per
// sha256) — Skripte, Pickle o.ae. daneben werden nie geholt oder ausgefuehrt.
// Liegen sie trotzdem im Repo, ist das ein Warnsignal fuer den Anbieter.
// HuggingFace scannt jede Datei (securityFileStatus) — das Ergebnis zeigen wir.
const EXTRA_KNOWN_GGUF_AUTHORS = new Set(["mradermacher", "thebloke", "maziyarpanahi", "nousresearch",
"dphn", "cognitivecomputations", "fdtn-ai", "quantfactory", "lmstudio-ai"]);
function knownGgufAuthor(a) { // Funktion: TRUSTED_GGUF_AUTHORS steht weiter unten in der Datei
return TRUSTED_GGUF_AUTHORS.has(a) || EXTRA_KNOWN_GGUF_AUTHORS.has(a);
}
const RISKY_FILE_RE = /\.(py|sh|bash|bat|cmd|ps1|exe|dll|so|dylib|pkl|pickle|pt|pth|ckpt|joblib|npy|zip|tar|gz|7z|jar|js|vbs)$|(^|\/)pytorch_model[^/]*\.bin$/i;
// Beliebtheit: HF hat keine Sterne — nur Downloads (30 Tage / gesamt) und Likes.
async function hfPopularity(repo) {
const d = await hfJson(`https://huggingface.co/api/models/${repo}?expand[]=downloads&expand[]=likes&expand[]=downloadsAllTime`);
if (!d) return null;
return { downloads: d.downloads || 0, downloadsAll: d.downloadsAllTime || 0, likes: d.likes || 0,
at: new Date().toISOString().slice(0, 10) };
}
async function hfRepoSafety(repo) {
const author = String(repo).split("/")[0].toLowerCase();
const out = { known: knownGgufAuthor(author), flagged: [], risky: [], scanned: 0 };
const tree = await hfJson(`https://huggingface.co/api/models/${repo}/tree/main?recursive=1&expand=true`);
for (const f of Array.isArray(tree) ? tree : []) {
if (f.type !== "file") continue;
const st = f.securityFileStatus || {};
const subs = Object.values(st).filter(v => v && typeof v === "object").map(v => String(v.status || ""));
const all = [String(st.status || ""), ...subs].map(x => x.toLowerCase());
if (all.some(x => /unsafe|suspicious|malicious|caution|infected/.test(x))) out.flagged.push({ path: f.path, status: all.filter(Boolean).join(", ") });
else if (all.includes("safe")) out.scanned++;
if (RISKY_FILE_RE.test(f.path)) out.risky.push(f.path);
}
out.risky = out.risky.slice(0, 20);
return out;
}
async function addHfRepoToCatalog(repo, description = "") {
repo = hfRepoFromInput(repo);
if (!/^[\w.-]+\/[\w.-]+$/.test(String(repo || ""))) throw new Error("ungueltiges Repo — Link oder org/name");
const q = await hfRepoQuant(repo, "Q4_K_M");
if (!q) throw new Error("kein ladbares GGUF-Modell in diesem Repo");
if (!q) {
const err = new Error("In diesem Repo gibt es kein GGUF — das braucht die Box (llama.cpp).");
err.alternatives = await hfGgufAlternatives(repo);
throw err;
}
const cat = loadLlmCatalog();
let id = slugModelId(repo);
const same = cat.find(m => m.hfRepo === repo);
if (same) return { models: cat, id: same.id, existed: true };
while (cat.some(m => m.id === id)) id += "-2";
cat.push({ id, hfRepo: repo, quant: q.quant, ctx: 8192, sizeGB: q.sizeGB, quants: q.quants,
description: "Aus den HuggingFace-Trends übernommen", source: "hf" });
description: description || "Aus den HuggingFace-Trends übernommen", source: "hf",
tools: await hfToolSupport(repo), uncensored: UNCENSORED_RE.test(repo),
safety: await hfRepoSafety(repo), popularity: await hfPopularity(repo),
added_at: new Date().toISOString().slice(0, 10) });
saveLlmCatalog(cat);
return { models: cat, id, existed: false };
}
@@ -831,11 +1091,70 @@ function satelliteList() {
return Array.from(satellites.values()).map(s => ({
id: s.id, location: alias[s.id] || s.location,
agentName: s.location, alias: alias[s.id] || "",
caps: s.caps, control: s.control, net: s.net || null,
caps: s.caps, control: s.control, net: s.net || null, kind: s.kind || "lan",
online: (now - (s.last_seen || 0)) < 300000,
}));
}
// requestId → Browser-Socket, der Klartext-Zugangsdaten angefordert hat
const credsRevealWaiters = new Map();
function vpnRvs() {
return { host: RVS_HOST, port: RVS_PORT, tls: RVS_TLS, tlsFallback: RVS_TLS_FALLBACK,
token: RVS_TOKEN, sni: process.env.RVS_SNI || "",
publicPort: process.env.VPN_PUBLIC_PORT || "443" };
}
async function broadcastVpn() {
try { broadcast({ type: "vpn_list", ...(await vpn.list()) }); }
catch (e) { log("warn", "vpn", "Liste: " + e.message); }
}
async function handleVpn(ws, msg) {
const reply = (body) => { try { ws.send(JSON.stringify({ type: "vpn_result", requestId: msg.requestId || "", action: msg.action, ...body })); } catch { /* weg */ } };
try {
switch (msg.action) {
case "vpn_list":
ws.send(JSON.stringify({ type: "vpn_list", ...(await vpn.list()) }));
return;
case "vpn_check":
reply({ ok: true, check: vpn.checkConf(msg.conf) });
return;
case "vpn_get":
reply({ ok: true, connection: vpn.getConf(String(msg.id || "")) });
return;
case "vpn_profile":
reply({ ok: true, profile: vpn.profile(String(msg.id || ""), msg.type === "fritz" ? "fritz" : "standard", vpnRvs()) });
return;
case "vpn_info":
reply({ ok: true, info: await vpn.info(String(msg.id || "")) });
return;
case "vpn_save":
reply({ ok: true, ...(await vpn.save(msg, vpnRvs())) });
log("info", "vpn", `Verbindung gespeichert: ${String(msg.name || "").slice(0, 60)}`);
break;
case "vpn_toggle":
reply({ ok: true, ...(await vpn.toggle(String(msg.id || ""), !!msg.enabled, vpnRvs())) });
log("info", "vpn", `Verbindung ${msg.id} ${msg.enabled ? "an" : "aus"}`);
break;
case "vpn_delete":
reply({ ok: true, ...(await vpn.remove(String(msg.id || ""))) });
log("info", "vpn", `Verbindung ${msg.id} geloescht`);
break;
case "vpn_settings":
reply({ ok: true, settings: vpn.setSettings(msg.settings || {}) });
break;
default:
reply({ ok: false, error: "unbekannte Aktion" });
return;
}
broadcastVpn();
} catch (e) {
reply({ ok: false, error: e.message });
broadcastVpn();
}
}
function broadcastSatellites() {
broadcast({ type: "sat_update", satellites: satelliteList() });
}
@@ -1382,6 +1701,7 @@ function connectRVS(forcePlain) {
satellites.set(p.id, {
id: p.id, location: p.location || p.id,
caps: p.caps || [], control: !!p.control, net: p.net || null,
kind: p.kind || "lan",
last_seen: Date.now(),
});
broadcastSatellites();
@@ -1410,9 +1730,19 @@ function connectRVS(forcePlain) {
broadcast({ type: "rooms_info", payload: msg.payload || {} });
} else if (msg.type === "llm_openai_config_result") {
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
} else if (msg.type === "llm_runtime_result" || msg.type === "llm_runtime_progress") {
broadcast({ type: msg.type, payload: msg.payload || {} });
} else if (msg.type === "sat_result") {
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
broadcast({ type: "sat_result", payload: msg.payload || {} });
// Klartext-Kennwoerter (fritzbox.backup_password) nur an den Fragenden.
const p = msg.payload || {};
const asker = credsRevealWaiters.get(p.requestId);
if (asker) {
credsRevealWaiters.delete(p.requestId);
if (asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: "sat_result", payload: p }));
} else if (!(p.result && typeof p.result === "object" && "kennwort" in p.result)) { // Kennwort ohne Fragenden: verwerfen
broadcast({ type: "sat_result", payload: p });
}
} else if (msg.type === "agent_history" || msg.type === "agent_history_list") {
// Live-Event (agent_history) bzw. Abfrage-Antwort (agent_history_list)
// der Bridge → an den Browser (Agent-Historie).
@@ -1425,7 +1755,16 @@ function connectRVS(forcePlain) {
location: p.location || "", devices: p.devices || [] });
} else if (msg.type === "sat_creds_list_result" || msg.type === "sat_creds_result") {
// Credential-Store-Antworten eines Satelliten → an den Browser.
broadcast({ type: msg.type, payload: msg.payload || {} });
// Mit Klartext-Passwoertern (Auge im Formular) NUR an den Browser, der
// gefragt hat — nie an alle offenen Diagnostic-Tabs.
const p = msg.payload || {};
if (p.revealed) {
const asker = credsRevealWaiters.get(p.requestId);
credsRevealWaiters.delete(p.requestId);
if (asker && asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: msg.type, payload: p }));
} else {
broadcast({ type: msg.type, payload: p });
}
} else if (msg.type === "worker_hello") {
// Ein Compute-Worker (GPU-Dienst) meldet sich mit seiner Identitaet.
const p = msg.payload || {};
@@ -1644,6 +1983,26 @@ function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs, tim
freshWs.on("error", () => {});
}
// Wie sendToRVS_withResponse, aber als Promise fuer den Server selbst:
// eigene Verbindung, wartet auf expectType mit passender requestId.
function rvsRequest(sendType, sendPayload, expectType, timeoutMs = 60000) {
return new Promise((resolve) => {
if (!RVS_HOST || !RVS_TOKEN) return resolve({ ok: false, error: "RVS nicht konfiguriert" });
const proto = RVS_TLS === "true" ? "wss" : "ws";
const ws2 = new WebSocket(`${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`);
const done = (v) => { clearTimeout(t); try { ws2.close(); } catch (_) {} resolve(v); };
const t = setTimeout(() => done({ ok: false, error: "Zeitüberschreitung" }), timeoutMs);
ws2.on("open", () => ws2.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() })));
ws2.on("message", (raw) => {
try {
const r = JSON.parse(raw.toString());
if (r.type === expectType && (r.payload || {}).requestId === sendPayload.requestId) done(r.payload || {});
} catch {}
});
ws2.on("error", (e) => done({ ok: false, error: e.message }));
});
}
function sendToRVS_raw(msgObj) {
if (!RVS_HOST || !RVS_TOKEN) return;
const payload = JSON.stringify(msgObj);
@@ -2429,7 +2788,7 @@ const server = http.createServer((req, res) => {
refreshLlmCatalogFromHF()
.then(r => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, models: r.models, added: r.added }));
res.end(JSON.stringify({ ok: true, models: r.models, added: r.added, removed: r.removed }));
log("info", "llm", `LLM-Katalog von HuggingFace aktualisiert: +${r.added} Modelle`);
})
.catch(err => {
@@ -2459,13 +2818,23 @@ const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message, items: [] }));
});
} else if (req.url.startsWith("/api/llm-model-info") && req.method === "GET") {
const repo = new URL(req.url, "http://x").searchParams.get("repo") || "";
llmModelInfo(repo)
.then(info => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, ...info })); })
.catch(err => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); });
} else if (req.url.startsWith("/api/llm-model-summary") && req.method === "GET") {
const repo = new URL(req.url, "http://x").searchParams.get("repo") || "";
llmModelSummary(repo)
.then(r => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: !!r.summary, ...r })); })
.catch(err => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); });
} else if (req.url === "/api/llm-catalog/add" && req.method === "POST") {
let body = "";
req.on("data", c => { body += c; if (body.length > 10000) req.destroy(); });
req.on("end", () => {
let repo = "";
try { repo = JSON.parse(body || "{}").hfRepo; } catch {}
addHfRepoToCatalog(repo)
let repo = "", manual = false;
try { const b = JSON.parse(body || "{}"); repo = b.hfRepo; manual = !!b.manual; } catch {}
addHfRepoToCatalog(repo, manual ? "Von Hand hinzugefügt" : "")
.then(r => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, ...r }));
@@ -2473,7 +2842,7 @@ const server = http.createServer((req, res) => {
})
.catch(err => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: err.message }));
res.end(JSON.stringify({ ok: false, error: err.message, alternatives: err.alternatives || [] }));
});
});
} else if (req.url === "/api/local-llm-config" && req.method === "GET") {
@@ -3358,6 +3727,10 @@ wss.on("connection", (ws) => {
params.rvs_tls = RVS_TLS === "true";
params.rvs_token = RVS_TOKEN;
}
if (String(msg.cmd || "") === "fritzbox.backup_password" && msg.requestId) {
credsRevealWaiters.set(String(msg.requestId), ws);
setTimeout(() => credsRevealWaiters.delete(String(msg.requestId)), 60000);
}
sendToRVS_raw({
type: "sat_command",
payload: {
@@ -3380,6 +3753,23 @@ wss.on("connection", (ws) => {
},
timestamp: Date.now(),
});
} else if (typeof msg.action === "string" && msg.action.startsWith("vpn_")) {
// VPN-Satelliten (Einstellungen → VPN). Antworten nur an DIESEN Browser —
// vpn_get enthaelt den privaten Schluessel. Aenderungen → Liste an alle.
handleVpn(ws, msg);
} else if (msg.action === "llm_runtime") {
// llama-swap/llama.cpp einer Box: Version pruefen, aktualisieren, zuruecksetzen.
const op = String(msg.op || "status");
if (!["status", "check", "update", "rollback"].includes(op)) return;
sendToRVS_raw({
type: "llm_runtime",
payload: {
instanceId: String(msg.instanceId || ""),
op,
requestId: String(msg.requestId || ""),
},
timestamp: Date.now(),
});
} else if (msg.action === "agent_history_query") {
// Browser will die gespeicherte Historie eines Agenten — die Bridge
// antwortet mit agent_history_list (auf derselben rvsWs).
@@ -3411,8 +3801,13 @@ wss.on("connection", (ws) => {
requestId: "dn_" + Date.now() },
timestamp: Date.now() });
} else if (msg.action === "sat_creds_list") {
const requestId = "dc_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8);
if (msg.reveal) {
credsRevealWaiters.set(requestId, ws);
setTimeout(() => credsRevealWaiters.delete(requestId), 30000);
}
sendToRVS_raw({ type: "sat_creds_list",
payload: { satellite: msg.satellite || "", requestId: "dc_" + Date.now() },
payload: { satellite: msg.satellite || "", ip: msg.ip || "", reveal: !!msg.reveal, requestId },
timestamp: Date.now() });
} else if (msg.action === "sat_creds_set") {
sendToRVS_raw({ type: "sat_creds_set",
+407
View File
@@ -0,0 +1,407 @@
// ── VPN-Satelliten (Einstellungen → VPN → WireGuard) ─────────────
//
// Jede VPN-Verbindung ist ein eigener Container (Image aria-vpn-satellite,
// satellite/Dockerfile.vpn): WireGuard-Tunnel + normaler Satellit dahinter,
// Kategorie "VPN-Satellit". Eigener Container = eigener Netz-Namensraum —
// ueberlappende Kundennetze stoeren sich nicht, und lokale Satelliten sehen
// die VPN-Netze nie.
//
// Ablage (Bind-Mount /data der Diagnostic):
// /data/vpn/connections.json Liste {id, kind, name, enabled, scanNets, …}
// /data/vpn/settings.json {keepDays}
// /data/vpn/<id>/tunnel/wg.conf die WireGuard-Datei (0600)
// /data/vpn/<id>/tunnel/status.json schreibt der Container (ℹ-Fenster)
// /data/vpn/<id>/tunnel/events.jsonl verbunden/getrennt/… (Log, rotiert hier)
// /data/vpn/<id>/data/ Satelliten-Daten (Zugangsdaten, Namen, …)
//
// Server-Verbindungen (Kunde waehlt sich ein, z.B. FritzBox): ARIA erzeugt
// beide Schluesselpaare + PSK, der Container lauscht auf 51820 mit fester IP
// im Netz aria-vpn; der Verteiler (satellite/vpn_relay.py, UDP-Port fuer
// alle) ordnet die Pakete per mac1 zu und liest dafuer /data/vpn/relay.json.
//
// An = Container mit restart unless-stopped (nach Neustart wieder da).
// Aus = Container weg, Dateien bleiben — Kundennetze nur bei Bedarf.
const http = require("http");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const DIR = "/data/vpn";
const IMAGE = process.env.VPN_IMAGE || "aria-vpn-satellite:latest";
const NETWORK = "aria-vpn";
const SELF = process.env.DIAG_CONTAINER || "aria-diagnostic";
const PREFIX = "aria-vpn-";
// Netz der VPN-Container: dynamische IPs (Clients) aus der unteren Haelfte,
// Server-Verbindungen bekommen feste IPs aus der oberen (.128 + n), damit der
// Verteiler sie auch nach einem Neustart findet.
// Bewusst ausserhalb von Dockers eigenen Pools (172.17–31.x, 192.168.x),
// sonst kollidiert es mit automatisch angelegten Compose-Netzen.
const SUBNET = process.env.VPN_SUBNET || "10.213.213.0/24";
const SUBNET_BASE = SUBNET.split("/")[0].split(".").slice(0, 3).join(".");
const SERVER_PORT = 51820; // WireGuard im Server-Container
const TUNNEL_NET = (n) => `10.222.${n}`; // je Server-Verbindung ein /24
function docker(method, apiPath, body) {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : "";
const req = http.request({
socketPath: "/var/run/docker.sock", path: apiPath, method,
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
}, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const buf = Buffer.concat(chunks);
let json = null;
try { json = JSON.parse(buf.toString() || "null"); } catch { /* Logs sind kein JSON */ }
resolve({ status: res.statusCode, json, buf });
});
});
req.on("error", reject);
req.setTimeout(30000, () => req.destroy(new Error("Docker antwortet nicht")));
if (data) req.write(data);
req.end();
});
}
// ── Ablage ──────────────────────────────────────────────────────
function readJson(p, fallback) {
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fallback; }
}
function writeJson(p, v, mode = 0o600) {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p + ".tmp", JSON.stringify(v, null, 2), { mode });
fs.renameSync(p + ".tmp", p);
}
const connsFile = () => path.join(DIR, "connections.json");
const loadConns = () => readJson(connsFile(), []);
const saveConns = (list) => writeJson(connsFile(), list);
const tunnelDir = (id) => path.join(DIR, id, "tunnel");
function settings() {
return { keepDays: 30, ...readJson(path.join(DIR, "settings.json"), {}) };
}
// Host-Pfad von /data — die VPN-Container brauchen Bind-Mounts mit HOST-Pfaden.
let _hostData = "";
async function hostDataDir() {
if (_hostData) return _hostData;
const r = await docker("GET", `/containers/${SELF}/json`);
const m = ((r.json && r.json.Mounts) || []).find((x) => x.Destination === "/data");
if (!m) throw new Error("Host-Pfad von /data unbekannt (Diagnostic ohne Bind-Mount?)");
_hostData = m.Source;
return _hostData;
}
// ── WireGuard-Datei pruefen ─────────────────────────────────────
function parseConf(text) {
const iface = {}; const peers = []; let cur = null;
for (const raw of String(text || "").split(/\r?\n/)) {
const line = raw.split("#")[0].trim();
if (!line) continue;
const sec = line.match(/^\[(\w+)\]$/);
if (sec) {
const n = sec[1].toLowerCase();
cur = n === "interface" ? iface : n === "peer" ? (peers.push({}), peers[peers.length - 1]) : null;
continue;
}
const i = line.indexOf("=");
if (!cur || i < 0) continue;
const k = line.slice(0, i).trim().toLowerCase(); const v = line.slice(i + 1).trim();
cur[k] = cur[k] && ["address", "allowedips", "dns"].includes(k) ? cur[k] + "," + v : v;
}
return { iface, peers };
}
function checkConf(text) {
const c = parseConf(text);
if (!c.iface.privatekey) return { error: "In [Interface] fehlt PrivateKey." };
if (!c.peers.length || c.peers.some((p) => !p.publickey)) return { error: "In [Peer] fehlt PublicKey." };
if (!c.peers.some((p) => p.endpoint)) return { error: "Kein Endpoint — wohin soll sich der Tunnel verbinden?" };
const nets = [];
for (const p of c.peers) {
for (const n of String(p.allowedips || "").split(",").map((s) => s.trim()).filter(Boolean)) {
if (!n.includes(":") && !n.endsWith("/0")) nets.push(n);
}
}
const all = c.peers.some((p) => /(^|,)\s*0\.0\.0\.0\/0/.test(p.allowedips || ""));
return { ok: true, nets, all, endpoint: c.peers.map((p) => p.endpoint).filter(Boolean).join(", ") };
}
// ── Container ───────────────────────────────────────────────────
async function ensureNetwork() {
const r = await docker("GET", `/networks/${NETWORK}`);
if (r.status === 200) return;
// ICC aus: VPN-Container sehen sich gegenseitig nicht.
const c = await docker("POST", "/networks/create", {
Name: NETWORK, Driver: "bridge",
Options: { "com.docker.network.bridge.enable_icc": "false" },
IPAM: { Config: [{ Subnet: SUBNET, IPRange: `${SUBNET_BASE}.0/25`, Gateway: `${SUBNET_BASE}.1` }] },
Labels: { "aria.vpn": "1" },
});
if (c.status >= 300) throw new Error(`Netz ${NETWORK} anlegen: ${JSON.stringify(c.json).slice(0, 200)}`);
}
async function removeContainer(id) {
await docker("DELETE", `/containers/${PREFIX}${id}?force=true`);
}
async function startContainer(conn, rvs) {
const img = await docker("GET", `/images/${encodeURIComponent(IMAGE)}/json`);
if (img.status !== 200) {
throw new Error(`Image ${IMAGE} fehlt. Auf der ARIA-VM: docker compose up -d --build vpn-satellite-image`);
}
await ensureNetwork();
const host = await hostDataDir();
fs.mkdirSync(path.join(DIR, conn.id, "data"), { recursive: true });
const env = {
VPN_ID: conn.id, VPN_NAME: conn.name, SATELLITE_ID: `vpn-${conn.id}`,
SATELLITE_LOCATION: conn.name, SCAN_NETS: conn.scanNets || "",
SCAN_MAX_SEC: "180", CONTROL_ENABLED: "true",
RVS_HOST: rvs.host, RVS_PORT: String(rvs.port), RVS_TLS: String(rvs.tls),
RVS_TLS_FALLBACK: String(rvs.tlsFallback), RVS_TOKEN: rvs.token,
...(rvs.sni ? { RVS_SNI: rvs.sni } : {}),
};
await removeContainer(conn.id);
const c = await docker("POST", `/containers/create?name=${PREFIX}${conn.id}`, {
Image: IMAGE,
Env: Object.entries(env).map(([k, v]) => `${k}=${v}`),
Labels: { "aria.vpn": conn.id, "aria.vpn.name": conn.name },
HostConfig: {
Binds: [`${host}/vpn/${conn.id}/tunnel:/vpn`, `${host}/vpn/${conn.id}/data:/data`],
CapAdd: ["NET_ADMIN"],
RestartPolicy: { Name: "unless-stopped" },
NetworkMode: NETWORK,
LogConfig: { Type: "json-file", Config: { "max-size": "5m", "max-file": "2" } },
},
...(conn.kind === "server" ? { NetworkingConfig: { EndpointsConfig: {
[NETWORK]: { IPAMConfig: { IPv4Address: serverIp(conn) } } } } } : {}),
});
if (c.status >= 300) throw new Error(`Container anlegen: ${JSON.stringify(c.json).slice(0, 300)}`);
const s = await docker("POST", `/containers/${PREFIX}${conn.id}/start`);
if (s.status >= 300) throw new Error(`Container starten: ${JSON.stringify(s.json).slice(0, 300)}`);
}
async function containerState(id) {
const r = await docker("GET", `/containers/${PREFIX}${id}/json`);
if (r.status !== 200) return { exists: false };
const st = r.json.State || {};
return { exists: true, running: !!st.Running, restarting: !!st.Restarting,
startedAt: st.StartedAt, exitCode: st.ExitCode };
}
async function containerLogs(id, lines = 60) {
const r = await docker("GET", `/containers/${PREFIX}${id}/logs?stdout=true&stderr=true&tail=${lines}&timestamps=true`);
if (r.status !== 200) return "";
// Docker-Multiplex-Format: 8 Byte Kopf je Block
const out = []; let i = 0; const b = r.buf;
while (i + 8 <= b.length) {
const len = b.readUInt32BE(i + 4);
out.push(b.slice(i + 8, i + 8 + len).toString());
i += 8 + len;
}
return out.join("");
}
// ── Server-Verbindungen ─────────────────────────────────────────
function wgKeypair() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
return {
priv: privateKey.export({ format: "der", type: "pkcs8" }).slice(-32).toString("base64"),
pub: publicKey.export({ format: "der", type: "spki" }).slice(-32).toString("base64"),
};
}
const serverIp = (conn) => `${SUBNET_BASE}.${128 + conn.n}`;
const keysFile = (id) => path.join(tunnelDir(id), "keys.json");
function parseLan(v) {
const nets = String(v || "").split(/[\s,]+/).filter(Boolean);
if (!nets.length) throw new Error("Kundennetz fehlt (z.B. 192.168.178.0/24).");
for (const n of nets) {
const m = n.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/);
if (!m || m.slice(1, 5).some((x) => +x > 255) || +m[5] < 8 || +m[5] > 32) {
throw new Error(`Kundennetz „${n}" ist kein Netz wie 192.168.178.0/24.`);
}
}
return nets;
}
// Server-seitige wg.conf (im Container): lauscht, Gegenseite = Kunde
function serverConf(conn, k) {
const t = TUNNEL_NET(conn.n);
return ["[Interface]", `PrivateKey = ${k.srvPriv}`, `ListenPort = ${SERVER_PORT}`, `Address = ${t}.1/24`, "",
"[Peer]", `PublicKey = ${k.peerPub}`, `PresharedKey = ${k.psk}`,
`AllowedIPs = ${[`${t}.2/32`, ...conn.lan].join(", ")}`, ""].join("\n");
}
// Datei fuer die Gegenseite. FritzBox (Netzwerk-Kopplung): Address = eigene
// LAN-Adresse der Box, AllowedIPs = unser Netz (kein 0.0.0.0/0 — lehnt die
// Box ab). Standard: eigene Tunnel-Adresse, die Gegenseite muss ihr LAN dann
// selbst routen/maskieren.
function peerProfile(conn, k, type, rvs) {
const t = TUNNEL_NET(conn.n);
const lan0 = conn.lan[0];
const prefix = lan0.split("/")[1];
const addr = type === "fritz" ? `${conn.routerIp}/${prefix}` : `${t}.2/24`;
return [`# ARIA VPN-Satellit „${conn.name}" — ${type === "fritz" ? "für FRITZ!Box (Netzwerk-Kopplung)" : "Standard-WireGuard"}`,
"[Interface]", `PrivateKey = ${k.peerPriv}`, `Address = ${addr}`, "",
"[Peer]", `PublicKey = ${k.srvPub}`, `PresharedKey = ${k.psk}`,
`AllowedIPs = ${t}.0/24`, `Endpoint = ${rvs.host}:${rvs.publicPort || 443}`,
"PersistentKeepalive = 25", ""].join("\n");
}
function writeRelayMap() {
const rows = [];
for (const c of loadConns()) {
if (c.kind !== "server" || !c.enabled) continue;
const k = readJson(keysFile(c.id), null);
if (k) rows.push({ id: c.id, ip: serverIp(c), port: SERVER_PORT, pub: k.srvPub });
}
writeJson(path.join(DIR, "relay.json"), rows, 0o644);
}
async function saveServer(msg, rvs) {
const name = String(msg.name || "").trim().slice(0, 60);
if (!name) throw new Error("Name fehlt.");
const lan = parseLan(msg.lan);
const routerIp = String(msg.routerIp || "").trim() ||
lan[0].split("/")[0].replace(/\.\d+$/, ".1");
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(routerIp)) throw new Error("Router-IP ist keine IP-Adresse.");
const conns = loadConns();
let conn = msg.id ? conns.find((c) => c.id === msg.id) : null;
if (!conn) {
const used = new Set(conns.filter((c) => c.kind === "server").map((c) => c.n));
let n = 1;
while (used.has(n)) n++;
if (n > 126) throw new Error("Höchstens 126 Server-Verbindungen.");
conn = { id: crypto.randomBytes(4).toString("hex"), kind: "server", n, enabled: false, createdAt: Date.now() };
conns.push(conn);
}
const scanNets = String(msg.scanNets || "").split(/[\s,]+/).filter(Boolean).join(",") || lan.join(",");
Object.assign(conn, { name, lan, routerIp, scanNets, updatedAt: Date.now() });
fs.mkdirSync(tunnelDir(conn.id), { recursive: true });
let k = readJson(keysFile(conn.id), null);
if (!k) {
const s = wgKeypair(); const p = wgKeypair();
k = { srvPriv: s.priv, srvPub: s.pub, peerPriv: p.priv, peerPub: p.pub,
psk: crypto.randomBytes(32).toString("base64") };
writeJson(keysFile(conn.id), k);
}
fs.writeFileSync(path.join(tunnelDir(conn.id), "wg.conf"), serverConf(conn, k), { mode: 0o600 });
saveConns(conns);
if (conn.enabled) await startContainer(conn, rvs);
writeRelayMap();
return { id: conn.id };
}
function profile(id, type, rvs) {
const conn = loadConns().find((c) => c.id === id);
if (!conn || conn.kind !== "server") throw new Error("Server-Verbindung unbekannt.");
const k = readJson(keysFile(id), null);
if (!k) throw new Error("Schlüssel fehlen.");
return { name: conn.name, type, conf: peerProfile(conn, k, type, rvs) };
}
// ── API (vom ws-Handler in server.js) ───────────────────────────
async function list() {
const conns = loadConns();
const res = [];
for (const c of conns) {
const st = readJson(path.join(tunnelDir(c.id), "status.json"), {});
res.push({ ...c, status: st, container: await containerState(c.id) });
}
return { connections: res, settings: settings() };
}
async function save(msg, rvs) {
if ((msg.kind || "") === "server" || (msg.id && (loadConns().find((c) => c.id === msg.id) || {}).kind === "server")) {
return saveServer(msg, rvs);
}
const name = String(msg.name || "").trim().slice(0, 60);
const conf = String(msg.conf || "").replace(/\r\n/g, "\n").trim() + "\n";
if (!name) throw new Error("Name fehlt.");
const chk = checkConf(conf);
if (chk.error) throw new Error(chk.error);
const scanNets = String(msg.scanNets || "").split(/[\s,]+/).filter(Boolean).join(",");
const conns = loadConns();
let conn = msg.id ? conns.find((c) => c.id === msg.id) : null;
if (!conn) {
conn = { id: crypto.randomBytes(4).toString("hex"), kind: msg.kind || "client",
enabled: false, createdAt: Date.now() };
conns.push(conn);
}
Object.assign(conn, { name, scanNets, updatedAt: Date.now() });
fs.mkdirSync(tunnelDir(conn.id), { recursive: true });
fs.writeFileSync(path.join(tunnelDir(conn.id), "wg.conf"), conf, { mode: 0o600 });
saveConns(conns);
if (conn.enabled) await startContainer(conn, rvs); // Aenderung greift sofort
return { id: conn.id };
}
async function toggle(id, enabled, rvs) {
const conns = loadConns();
const conn = conns.find((c) => c.id === id);
if (!conn) throw new Error("Verbindung unbekannt.");
conn.enabled = !!enabled;
saveConns(conns);
try {
if (conn.enabled) await startContainer(conn, rvs);
else await removeContainer(id);
} finally {
writeRelayMap();
}
return { id, enabled: conn.enabled };
}
async function remove(id) {
const conns = loadConns();
if (!conns.some((c) => c.id === id)) throw new Error("Verbindung unbekannt.");
await removeContainer(id);
saveConns(conns.filter((c) => c.id !== id));
fs.rmSync(path.join(DIR, id), { recursive: true, force: true });
writeRelayMap();
return { id };
}
function getConf(id) {
const conn = loadConns().find((c) => c.id === id);
if (!conn) throw new Error("Verbindung unbekannt.");
if (conn.kind === "server") return { ...conn }; // Schluessel bleiben auf ARIA
return { ...conn, conf: fs.readFileSync(path.join(tunnelDir(id), "wg.conf"), "utf8") };
}
async function info(id) {
const conn = loadConns().find((c) => c.id === id);
if (!conn) throw new Error("Verbindung unbekannt.");
const dir = tunnelDir(id);
let events = [];
try {
events = fs.readFileSync(path.join(dir, "events.jsonl"), "utf8").trim().split("\n")
.filter(Boolean).slice(-300).map((l) => { try { return JSON.parse(l); } catch { return null; } })
.filter(Boolean);
} catch { /* noch keine */ }
return { ...conn, status: readJson(path.join(dir, "status.json"), {}), events,
container: await containerState(id), logs: await containerLogs(id) };
}
function setSettings(s) {
const keepDays = Math.max(1, Math.min(3650, parseInt(s.keepDays, 10) || 30));
writeJson(path.join(DIR, "settings.json"), { keepDays });
rotate();
return { keepDays };
}
// Log-Rotation: Ereignisse aelter als keepDays fliegen raus (stuendlich).
function rotate() {
const cut = Date.now() / 1000 - settings().keepDays * 86400;
for (const c of loadConns()) {
const p = path.join(tunnelDir(c.id), "events.jsonl");
try {
const lines = fs.readFileSync(p, "utf8").split("\n").filter(Boolean);
const keep = lines.filter((l) => { try { return JSON.parse(l).ts >= cut; } catch { return false; } });
if (keep.length !== lines.length) fs.writeFileSync(p, keep.join("\n") + (keep.length ? "\n" : ""));
} catch { /* keine Datei */ }
}
}
setInterval(rotate, 3600 * 1000).unref();
module.exports = { list, save, toggle, remove, getConf, info, setSettings, checkConf, profile, writeRelayMap };
+36
View File
@@ -164,6 +164,42 @@ services:
- RVS_TOKEN=${RVS_TOKEN:-}
restart: unless-stopped
# ─── VPN-Satelliten: nur das Image bauen ───────────────
# Die eigentlichen Container (einer pro WireGuard-Verbindung, aria-vpn-<id>)
# legt die Diagnostic an (Einstellungen → VPN). Dieser Eintrag sorgt nur
# dafuer, dass `docker compose up -d --build` das Image mitbaut; der
# Container selbst beendet sich sofort wieder.
# Voraussetzung auf dem Host: WireGuard-Kernelmodul (modprobe wireguard).
vpn-satellite-image:
build:
context: ./satellite
dockerfile: Dockerfile.vpn
image: aria-vpn-satellite:latest
container_name: aria-vpn-satellite-image
entrypoint: ["true"]
network_mode: none
restart: "no"
# ─── VPN-Verteiler: EIN UDP-Port fuer alle WireGuard-Server-Verbindungen ─
# Kunden waehlen sich auf UDP 443 am RVS-Hostnamen ein; der NPM-Stream (oder
# der Router) leitet das hierher (VPN_RELAY_PORT, Standard 51820). Der
# Verteiler ordnet jedes Paket per WireGuard-mac1 der richtigen Verbindung
# (eigener Container) zu, ohne etwas zu entschluesseln; Unbekanntes wird
# still verworfen. Host-Netz, weil er die Container-IPs im Netz aria-vpn
# erreichen muss (dort ist Container-zu-Container gesperrt).
vpn-relay:
image: aria-vpn-satellite:latest
container_name: aria-vpn-relay
depends_on:
- vpn-satellite-image
network_mode: host
command: ["python", "-u", "vpn_relay.py"]
environment:
- VPN_RELAY_PORT=${VPN_RELAY_PORT:-51820}
volumes:
- ./aria-data/config/diag-state/vpn:/vpn:ro
restart: unless-stopped
networks:
aria-net:
driver: bridge
+3
View File
@@ -135,6 +135,9 @@ const ALLOWED_TYPES = new Set([
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
// immer aus der .env der Box — er ist ein Docker-Port-Mapping).
"llm_openai_config", "llm_openai_config_result",
// llama-swap/llama.cpp einer Box pruefen/aktualisieren/zuruecksetzen
// (Diagnostic → Modell-Katalog → ⚙ llama.cpp; ausgefuehrt vom llm-updater).
"llm_runtime", "llm_runtime_result", "llm_runtime_progress",
// Modell auf eine LLM-Box laden (Diagnostic → Modell-Katalog → „Laden").
// Fehlten hier seit Stage D: jeder Klick auf „Laden" starb still an dieser
// Liste, die Box hat nie davon erfahren. _progress meldet den Download in %.
+9 -1
View File
@@ -40,6 +40,14 @@ CONTROL_ENABLED=true
# snmp.info Modell/Seriennummer/Firmware-Version (params: ip)
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
# fritzbox.hosts FritzBox: verbundene Geraete (TR-064, braucht Login)
# fritzbox.describe FritzBox: was DIESE Box kann (alle TR-064-Aktionen, ohne Login)
# fritzbox.call FritzBox: beliebige TR-064-Aktion; aendernde nur mit confirmed=true
# fritzbox.wlan FritzBox: alle WLANs (inkl. Gast) anzeigen / schalten
# fritzbox.backup / backups / backup_view / restore / backup_delete
# FritzBox: Einstellungen sichern (Taste an der Box, 2 min),
# auflisten, lesbar ansehen, wieder einspielen, loeschen.
# Sicherungen liegen unter FB_BACKUP_DIR (/data/fritzbox-backups).
# (fritzbox.* in der Liste erlaubt alle fritzbox-Aktionen)
# ssh.exec Kommando per SSH ausfuehren (params: ip, cmd; Auth aus Creds 'ssh')
# adb.install APK auf ein Android-Geraet/Fire TV ausrollen (params: ip, url)
# adb.connect ADB-Verbindung aufbauen (params: ip)
@@ -52,7 +60,7 @@ CONTROL_ENABLED=true
# Mikro) — spart das Zusammenklicken mit der Fernbedienung
# ACHTUNG: Diese Liste ueberschreibt den Code-Default. Wird eine neue Aktion
# ergaenzt, MUSS sie hier nachgetragen werden — sonst lehnt der Satellit sie ab.
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.*,ssh.exec,adb.devices,adb.connect,adb.install,adb.shell,adb.provision,adb.status,adb.permissions
# ─── ADB / Android-Rollout (optional) ──────────────────────────────
# Fire TV Sticks & Android-Geraete im LAN: APK ausrollen ohne Datei-Kopieren.
+18
View File
@@ -0,0 +1,18 @@
# ARIA VPN-Satellit — WireGuard-Tunnel + Satellit in EINEM Container pro
# Verbindung (angelegt von der Diagnostic). Laeuft NICHT im Host-Netz, sondern
# im Docker-Netz aria-vpn; braucht nur NET_ADMIN (wg0, Routen, nftables).
# Das WireGuard-Kernelmodul kommt vom Host.
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends wireguard-tools iproute2 nftables snmp adb \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY satellite.py vpn_entry.py vpn_relay.py ./
CMD ["python", "-u", "vpn_entry.py"]
+74
View File
@@ -117,3 +117,77 @@ Der Satellit scannt ein Netz **und** ist über einen Cloud-Relay erreichbar —
Empfehlung: in vertrauenswürdigen Netzen `CONTROL_ENABLED=true` mit enger Allowlist;
sonst `false` und nur beobachten.
## VPN-Satelliten (WireGuard, ohne Installation beim Kunden)
Ein VPN-Satellit ist ein Satellit, der **nicht im Kundennetz** läuft, sondern auf ARIA —
verbunden über einen WireGuard-Tunnel. Angelegt wird er in der Diagnostic unter
**Einstellungen → VPN → WireGuard**; im Reiter Satelliten steht er unter „VPN-Satelliten 🔐“.
**Clients** (ARIA wählt sich beim Kunden ein): die WireGuard-Datei des Kunden hochladen
oder einfügen, Namen vergeben, einschalten.
- **An** = Container mit Neustart-Automatik (verbindet sich nach einem Neustart wieder).
**Aus** = Container weg, Datei und Log bleiben — Kundennetze nur bei Bedarf.
- ✏️ bearbeiten · ⬇ Datei wieder herunterladen · ℹ Verlauf (wann verbunden, wie lange,
Datenmenge) · 🗑 löschen. Log-Aufbewahrung im Reiter Einstellungen.
**Wie es gebaut ist** (`Dockerfile.vpn`, `vpn_entry.py`, `diagnostic/vpn.js`):
- **Ein Container pro Verbindung** (`aria-vpn-<id>`, Netz `aria-vpn`, nur `NET_ADMIN`).
Eigener Netz-Namensraum: überlappende Kundennetze (überall `192.168.178.0/24`) stören
sich nicht, und lokale Satelliten im Host-Netz sehen die VPN-Netze nie.
- **Firewall im Container:** durch den Tunnel nur ausgehend, zurück kommen nur Antworten —
auch wenn die Gegenseite den Tunnel aufgebaut hat, führt keine Verbindung vom Kundennetz
zu ARIA. Ins ARIA-Netz/Internet darf der Container nur zum RVS und zum Tunnel-Endpunkt.
- `wg0` wird selbst aufgesetzt, nicht per `wg-quick`: `DNS`/`PostUp` aus fremden Dateien
werden ignoriert, und bei `AllowedIPs = 0.0.0.0/0` bleibt der Weg zum RVS trotzdem
außerhalb des Tunnels. Endpunkte mit Hostnamen (DynDNS) werden neu aufgelöst, solange
keine Verbindung steht.
- **Scan:** über WireGuard gibt es kein mDNS/SSDP/ARP — der VPN-Satellit klopft die Netze
aktiv ab (`SCAN_NETS`, Standard = AllowedIPs ohne `0.0.0.0/0`; typische Ports, auch
„Verbindung abgelehnt“ zählt als Gerät). FritzBoxen erkennt er an Port 49000.
- Daten pro Verbindung unter `aria-data/config/diag-state/vpn/<id>/` (`tunnel/wg.conf`
mit privatem Schlüssel, `status.json`, `events.jsonl`, `data/` = Zugangsdaten usw.).
**Voraussetzungen auf der ARIA-VM:**
- WireGuard-Kernelmodul: `modprobe wireguard` und dauerhaft
`echo wireguard > /etc/modules-load.d/wireguard.conf`
- Image bauen: `docker compose up -d --build vpn-satellite-image` (baut nur das Image,
der Container beendet sich sofort wieder — das ist richtig so).
**Server** (Kunden wählen sich bei ARIA ein, z.B. per FritzBox): im Reiter Server
„+ Hinzufügen“, Namen, Kundennetz (z.B. `192.168.178.0/24`) und LAN-IP der FritzBox eintragen.
ARIA erzeugt beide Schlüsselpaare und einen Pre-Shared-Key; ⬇ liefert die Datei für die
Gegenseite:
- **FritzBox** (Netzwerk-Kopplung): `Address` = LAN-IP der Box, `AllowedIPs` = ARIAs
Tunnelnetz `10.222.<n>.0/24` (kein `0.0.0.0/0` — das lehnt die FritzBox ab). Import:
Internet → Freigaben → VPN (WireGuard) → Verbindung hinzufügen → Netzwerke koppeln →
Einstellungen aus Datei übernehmen. *Noch nicht an einer echten FritzBox getestet.*
- **Standard-WireGuard** (Linux, andere Router): eigene Tunnel-Adresse `10.222.<n>.2`; das
Kundennetz muss die Gegenseite dann selbst weiterleiten bzw. maskieren.
Alle Einwahlen kommen über **einen** Port: Der **Verteiler** (`vpn_relay.py`, Container
`aria-vpn-relay`, Host-Netz, `VPN_RELAY_PORT`, Standard 51820) ordnet jedes Paket anhand der
WireGuard-Prüfsumme `mac1` der richtigen Verbindung zu — jede Einwahl hat ihr eigenes
Server-Schlüsselpaar und ihren eigenen Container mit fester IP im Netz `aria-vpn`
(`10.213.213.0/24`, änderbar mit `VPN_SUBNET`). Entschlüsselt wird dabei nichts; fremde
Schlüssel und alles andere verwirft er still. Auch zwei Kunden mit demselben Netz
(`192.168.178.0/24`) funktionieren gleichzeitig.
**Erreichbar machen — UDP 443 auf dem RVS-Hostnamen** (TCP 443 bleibt beim RVS):
- *Mit Nginx Proxy Manager:* Hosts → **Streams** → Add Stream: Incoming Port `443`,
Forward Host = ARIA-VM, Forward Port `51820`, **TCP Forwarding aus, UDP Forwarding an**
(TCP würde mit den Proxy-Hosts kollidieren). Im docker-compose des NPM `- '443:443/udp'`
ergänzen und neu starten. Am Router UDP 443 ebenfalls auf den NPM-Rechner leiten.
Hinweis: ARIA sieht dann als Absender den NPM statt der Kunden-IP; die erzeugten Dateien
senden alle 25 s ein Lebenszeichen, damit nginx die Zuordnung behält.
- *Ohne Nginx:* den RVS-Port am Router zusätzlich als **UDP** weiterleiten — direkt auf die
ARIA-VM, Port `51820`.
- *Mit ufw als Firewall:*
```bash
# auf dem NPM-Rechner: UDP 443 aus dem Internet erlauben
ufw allow 443/udp comment 'WireGuard -> ARIA'
# auf der ARIA-VM: Verteiler-Port NUR vom NPM erlauben
ufw allow proto udp from <IP-des-NPM> to any port 51820 comment 'VPN-Verteiler nur vom NPM'
# ohne Nginx statt dessen (der Router leitet direkt auf die VM):
ufw allow 51820/udp
```
+972 -64
View File
File diff suppressed because it is too large Load Diff
+343
View File
@@ -0,0 +1,343 @@
"""
ARIA VPN-Satellit — ein WireGuard-Tunnel + ein ganz normaler Satellit dahinter.
Laeuft als eigener Container pro VPN-Verbindung (angelegt von der Diagnostic,
Einstellungen → VPN → WireGuard). Eigener Container = eigener Netz-Namensraum:
- ueberlappende Kundennetze (jeder hat 192.168.178.0/24) stoeren sich nicht
- lokale Satelliten (network_mode: host) sehen diese Netze nie
Ablauf:
1. /vpn/wg.conf lesen (Standard-WireGuard-Datei, wie man sie bekommt)
2. wg0 selbst aufsetzen (kein wg-quick: DNS/PostUp aus fremden Dateien
werden ignoriert, und der Weg zum RVS darf NIE durch den Tunnel)
3. Firewall (nftables, nur in diesem Container):
raus durch den Tunnel: alles; rein aus dem Tunnel: nur Antworten
raus ins ARIA-Netz/Internet: nur zum RVS und zum Tunnel-Endpunkt
4. satellite.py starten (SAT_KIND=vpn, Scan = SCAN_NETS aktiv abklopfen)
5. Tunnel beobachten: status.json + events.jsonl (verbunden/getrennt, Dauer)
fuer das ℹ-Fenster; Endpunkt mit Hostnamen (DynDNS) neu aufloesen
Env: VPN_ID, VPN_NAME, RVS_HOST/RVS_PORT/RVS_SNI (+ alles fuer satellite.py)
"""
from __future__ import annotations
import ipaddress
import json
import logging
import os
import re
import signal
import socket
import subprocess
import sys
import threading
import time
logging.basicConfig(level=logging.INFO, format="%(asctime)s [vpn] %(levelname)s %(message)s")
log = logging.getLogger("vpn")
VPN_DIR = os.environ.get("VPN_DIR", "/vpn")
CONF = os.path.join(VPN_DIR, "wg.conf")
STATUS = os.path.join(VPN_DIR, "status.json")
EVENTS = os.path.join(VPN_DIR, "events.jsonl")
IFACE = "wg0"
HANDSHAKE_OK_S = 180 # juenger = verbunden (WireGuard erneuert alle 2 min)
WATCH_S = 15
_stop = threading.Event()
_conn = {"connected": False, "since": 0, "rx": 0, "tx": 0} # fuer "getrennt" beim Stoppen
def sh(*args: str, check: bool = True, inp: str | None = None) -> str:
p = subprocess.run(list(args), capture_output=True, text=True, input=inp)
if check and p.returncode != 0:
raise RuntimeError(f"{' '.join(args[:4])}: {(p.stderr or p.stdout).strip()[:300]}")
return p.stdout
def event(kind: str, **extra) -> None:
rec = {"ts": int(time.time()), "event": kind, **extra}
try:
with open(EVENTS, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
except OSError as e:
log.warning("events.jsonl nicht schreibbar: %s", e)
# ── WireGuard-Datei lesen ───────────────────────────────────────
def parse_conf(text: str) -> dict:
"""[Interface] + beliebig viele [Peer]. Schluessel ohne Gross/Klein."""
iface: dict = {}
peers: list = []
cur = None
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
sec = re.match(r"^\[(\w+)\]$", line)
if sec:
name = sec.group(1).lower()
if name == "interface":
cur = iface
elif name == "peer":
cur = {}
peers.append(cur)
else:
cur = None
continue
if cur is None or "=" not in line:
continue
k, v = (s.strip() for s in line.split("=", 1))
k = k.lower()
if k in cur and k in ("address", "allowedips", "dns"):
cur[k] += "," + v
else:
cur[k] = v
if not iface.get("privatekey"):
raise ValueError("[Interface] PrivateKey fehlt")
if not peers or not all(p.get("publickey") for p in peers):
raise ValueError("[Peer] PublicKey fehlt")
return {"iface": iface, "peers": peers}
def _split(v: str) -> list[str]:
return [x.strip() for x in (v or "").split(",") if x.strip()]
def setconf_text(c: dict) -> str:
"""Nur was `wg setconf` versteht (Address/DNS/MTU/PostUp sind wg-quick)."""
out = ["[Interface]", f"PrivateKey = {c['iface']['privatekey']}"]
if c["iface"].get("listenport"):
out.append(f"ListenPort = {c['iface']['listenport']}")
for p in c["peers"]:
out += ["", "[Peer]", f"PublicKey = {p['publickey']}"]
for k, name in (("presharedkey", "PresharedKey"), ("endpoint", "Endpoint"),
("allowedips", "AllowedIPs")):
if p.get(k):
out.append(f"{name} = {p[k]}")
out.append(f"PersistentKeepalive = {p.get('persistentkeepalive') or 25}")
return "\n".join(out) + "\n"
def endpoint_host_port(ep: str) -> tuple[str, int]:
m = re.match(r"^\[?([^\]]+?)\]?:(\d+)$", ep.strip())
if not m:
raise ValueError(f"Endpoint unlesbar: {ep}")
return m.group(1), int(m.group(2))
def resolve4(host: str) -> list[str]:
try:
ipaddress.ip_address(host)
return [host]
except ValueError:
pass
try:
return sorted({a[4][0] for a in socket.getaddrinfo(host, None, socket.AF_INET)})
except OSError:
return []
# ── Netz + Firewall ─────────────────────────────────────────────
def default_gw() -> str:
m = re.search(r"default via (\S+)", sh("ip", "-4", "route", "show", "default"))
if not m:
raise RuntimeError("Container hat kein Default-Gateway")
return m.group(1)
def firewall(rvs_ips: list[str], rvs_port: int, ep_ips: list[str], listen: int = 0) -> None:
rvs = ", ".join(rvs_ips) if rvs_ips else "127.0.0.2"
eps = ", ".join(ep_ips) if ep_ips else "127.0.0.2"
# Server-Verbindung: der Verteiler (vpn_relay.py) reicht die Pakete der
# Gegenseite an diesen Port weiter — nur WireGuard, nur ueber eth0.
lin = f'iifname "eth0" udp dport {listen} accept' if listen else ""
lout = f'oifname "eth0" udp sport {listen} accept' if listen else ""
# Nur die eigene Tabelle — "flush ruleset" wuerde Dockers NAT-Regeln fuer
# den eingebauten DNS (127.0.0.11) im Container mit loeschen.
sh("nft", "delete", "table", "inet", "vpnsat", check=False)
rules = f"""
table inet vpnsat {{
set endpoints {{ type ipv4_addr; elements = {{ {eps} }} }}
chain input {{
type filter hook input priority 0; policy drop;
iif lo accept
ct state established,related accept
iifname "eth0" ip saddr @endpoints meta l4proto udp accept
{lin}
}}
chain forward {{
type filter hook forward priority 0; policy drop;
}}
chain output {{
type filter hook output priority 0; policy drop;
oif lo accept
ct state established,related accept
oifname "{IFACE}" accept
oifname "eth0" ip daddr {{ {rvs} }} tcp dport {rvs_port} accept
oifname "eth0" ip daddr @endpoints meta l4proto udp accept
{lout}
}}
}}
"""
sh("nft", "-f", "-", inp=rules)
log.info("Firewall aktiv: Tunnel nur ausgehend; ins ARIA-Netz nur RVS %s:%d", rvs, rvs_port)
def setup(c: dict) -> dict:
rvs_host = os.environ.get("RVS_HOST", "")
rvs_port = int(os.environ.get("RVS_PORT", "443") or 443)
rvs_ips = resolve4(rvs_host)
if not rvs_ips:
raise RuntimeError(f"RVS {rvs_host!r} nicht aufloesbar")
gw = default_gw()
ep_ips: list[str] = []
for p in c["peers"]:
if p.get("endpoint"):
h, _ = endpoint_host_port(p["endpoint"])
ep_ips += resolve4(h)
sh("ip", "link", "del", IFACE, check=False)
try:
sh("ip", "link", "add", IFACE, "type", "wireguard")
except RuntimeError as e:
raise RuntimeError(f"WireGuard im Kernel nicht verfuegbar (modprobe wireguard auf dem Host?): {e}")
sh("wg", "setconf", IFACE, "/dev/stdin", inp=setconf_text(c))
for a in _split(c["iface"].get("address", "")):
if ":" not in a:
sh("ip", "-4", "address", "add", a, "dev", IFACE)
sh("ip", "link", "set", IFACE, "mtu", c["iface"].get("mtu") or "1420", "up")
# RVS und Endpunkte immer am Tunnel vorbei
for ip in set(rvs_ips + ep_ips):
sh("ip", "route", "replace", f"{ip}/32", "via", gw, "dev", "eth0")
nets = []
for p in c["peers"]:
for n in _split(p.get("allowedips", "")):
if ":" in n:
continue
net = ipaddress.ip_network(n, strict=False)
if net.prefixlen == 0:
for half in ("0.0.0.0/1", "128.0.0.0/1"):
sh("ip", "route", "replace", half, "dev", IFACE)
else:
sh("ip", "route", "replace", str(net), "dev", IFACE)
nets.append(str(net))
firewall(rvs_ips, rvs_port, ep_ips, int(c["iface"].get("listenport") or 0))
log.info("Tunnel %s oben: Netze %s, Endpunkt(e) %s", IFACE, ", ".join(nets) or "-", ", ".join(ep_ips) or "-")
return {"nets": nets, "endpoints": ep_ips}
def scan_nets(c: dict) -> str:
"""Was der Satellit abklopft: SCAN_NETS (aus der Diagnostic) oder die
AllowedIPs ohne 0.0.0.0/0. Zu grosse Netze kuerzt satellite.py selbst."""
if os.environ.get("SCAN_NETS", "").strip():
return os.environ["SCAN_NETS"].strip()
nets = []
for p in c["peers"]:
for n in _split(p.get("allowedips", "")):
if ":" not in n and not n.endswith("/0"):
nets.append(n)
return ",".join(nets)
# ── Beobachten ──────────────────────────────────────────────────
def watch(c: dict) -> None:
connected = False
since = 0
last_resolve = 0.0
while not _stop.is_set():
try:
dump = sh("wg", "show", IFACE, "dump").splitlines()[1:]
now = int(time.time())
hs = max((int(l.split("\t")[4]) for l in dump if l.count("\t") >= 6), default=0)
rx = sum(int(l.split("\t")[5]) for l in dump if l.count("\t") >= 6)
tx = sum(int(l.split("\t")[6]) for l in dump if l.count("\t") >= 6)
ep = ", ".join(l.split("\t")[2] for l in dump if l.count("\t") >= 6)
ok = hs > 0 and now - hs < HANDSHAKE_OK_S
if ok and not connected:
since = now
event("verbunden", endpoint=ep)
log.info("verbunden (%s)", ep)
elif connected and not ok:
event("getrennt", dauer_s=now - since, rx=rx, tx=tx)
log.info("getrennt nach %d s", now - since)
connected = ok
_conn.update(connected=ok, since=since, rx=rx, tx=tx)
st = {"state": "verbunden" if ok else "getrennt", "since": since if ok else 0,
"handshake": hs, "rx": rx, "tx": tx, "endpoint": ep, "updated": now}
tmp = STATUS + ".tmp"
with open(tmp, "w") as f:
json.dump(st, f)
os.replace(tmp, STATUS)
# DynDNS beim Kunden: ohne Verbindung den Namen neu aufloesen
if not ok and time.time() - last_resolve > 120:
last_resolve = time.time()
for p in c["peers"]:
if not p.get("endpoint"):
continue
h, port = endpoint_host_port(p["endpoint"])
for ip in resolve4(h):
sh("nft", "add", "element", "inet", "vpnsat", "endpoints", f"{{ {ip} }}", check=False)
sh("ip", "route", "replace", f"{ip}/32", "via", default_gw(), "dev", "eth0", check=False)
sh("wg", "set", IFACE, "peer", p["publickey"], "endpoint", f"{h}:{port}", check=False)
except Exception as e:
log.warning("Beobachten: %s", e)
_stop.wait(WATCH_S)
def main() -> None:
def _term(*_):
_stop.set()
signal.signal(signal.SIGTERM, _term)
signal.signal(signal.SIGINT, _term)
try:
c = parse_conf(open(CONF, encoding="utf-8").read())
info = setup(c)
except Exception as e:
log.error("Tunnel nicht aufgebaut: %s", e)
event("fehler", meldung=str(e)[:300])
with open(STATUS, "w") as f:
json.dump({"state": "fehler", "error": str(e)[:300], "updated": int(time.time())}, f)
time.sleep(60) # nicht im Sekundentakt neu starten
sys.exit(1)
event("gestartet", netze=info["nets"])
env = dict(os.environ)
env.update({
"SAT_KIND": "vpn",
"SATELLITE_ID": env.get("SATELLITE_ID") or f"vpn-{env.get('VPN_ID', 'x')}",
"SATELLITE_LOCATION": env.get("VPN_NAME") or env.get("SATELLITE_LOCATION") or "VPN",
"SCAN_NETS": scan_nets(c),
"LIST_SELF": "false",
"CONTROL_ENABLED": env.get("CONTROL_ENABLED", "true"),
})
threading.Thread(target=watch, args=(c,), daemon=True).start()
proc = None
while not _stop.is_set():
proc = subprocess.Popen([sys.executable, "-u", "/app/satellite.py"], env=env)
while proc.poll() is None and not _stop.is_set():
time.sleep(1)
if not _stop.is_set():
log.warning("satellite.py beendet (rc=%s) — starte neu", proc.returncode)
_stop.wait(5)
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(10)
except subprocess.TimeoutExpired:
proc.kill()
if _conn["connected"]:
event("getrennt", dauer_s=int(time.time()) - _conn["since"], rx=_conn["rx"], tx=_conn["tx"])
event("gestoppt")
sh("ip", "link", "del", IFACE, check=False)
if __name__ == "__main__":
main()
+173
View File
@@ -0,0 +1,173 @@
"""
ARIA VPN-Verteiler — EIN UDP-Port fuer alle WireGuard-Server-Verbindungen.
Kunden waehlen sich alle auf denselben Port ein (UDP 443 am RVS-Hostnamen,
per NPM-Stream bzw. Router hierher weitergeleitet). Jede Server-Verbindung
laeuft aber in ihrem EIGENEN Container (eigener Netz-Namensraum, wegen
ueberlappender Kundennetze). Der Verteiler ordnet die Pakete zu, ohne etwas
zu entschluesseln:
Typ 1 (Handshake-Anfang): mac1 = BLAKE2s(Schluessel aus dem oeffentlichen
Schluessel des EMPFAENGERS, Nachricht). Jede Verbindung hat ein eigenes
Server-Schluesselpaar → der Verteiler rechnet mac1 fuer jede bekannte
Verbindung nach und weiss, wohin das Paket gehoert.
Typ 2 (Antwort des Containers): merkt sich dessen Sitzungsnummer.
Typ 3/4 (Cookie/Daten): nach Absender-Adresse, sonst nach Sitzungsnummer
(Kunde hat die Adresse gewechselt).
Jeder Absender bekommt einen eigenen Upstream-Socket zum Container, so finden
die Antworten zurueck. Unbekanntes wird still verworfen — wie WireGuard selbst
(ein Portscan sieht nichts).
Laeuft mit network_mode: host (erreicht die Container-IPs im Netz aria-vpn,
dort ist Container-zu-Container gesperrt). Liest /vpn/relay.json, das die
Diagnostic schreibt: [{id, ip, port, pub}].
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import logging
import os
import struct
import time
logging.basicConfig(level=logging.INFO, format="%(asctime)s [vpn-relay] %(levelname)s %(message)s")
log = logging.getLogger("vpn-relay")
LISTEN_PORT = int(os.environ.get("VPN_RELAY_PORT", "51820") or 51820)
MAP_FILE = os.environ.get("VPN_RELAY_MAP", "/vpn/relay.json")
IDLE_S = 600
MAX_CLIENTS = 2048
_targets: list[dict] = [] # [{id, addr:(ip,port), mac1key}]
_map_mtime = 0.0
_by_index: dict[int, str] = {} # Sitzungsnummer des Containers -> id
_clients: dict = {} # Kunden-Adresse -> Upstream
def _mac1_key(pub_b64: str) -> bytes:
pub = base64.b64decode(pub_b64)
return hashlib.blake2s(b"mac1----" + pub).digest()
def load_map() -> None:
global _targets, _map_mtime
try:
m = os.path.getmtime(MAP_FILE)
except OSError:
if _targets:
log.info("keine Zuordnung mehr (%s fehlt)", MAP_FILE)
_targets, _map_mtime = [], 0.0
return
if m == _map_mtime:
return
try:
rows = json.load(open(MAP_FILE))
_targets = [{"id": r["id"], "addr": (r["ip"], int(r.get("port", 51820))),
"mac1key": _mac1_key(r["pub"])} for r in rows if r.get("ip") and r.get("pub")]
_map_mtime = m
log.info("%d Server-Verbindung(en): %s", len(_targets),
", ".join(f"{t['id']}→{t['addr'][0]}" for t in _targets) or "-")
except Exception as e:
log.warning("relay.json unlesbar: %s", e)
def target_for_init(data: bytes) -> dict | None:
if len(data) != 148:
return None
for t in _targets:
mac = hashlib.blake2s(data[:116], key=t["mac1key"], digest_size=16).digest()
if mac == data[116:132]:
return t
return None
class Upstream(asyncio.DatagramProtocol):
"""Ein Socket je Kunde → Container; leitet Antworten zurueck."""
def __init__(self, front, client_addr, target):
self.front, self.client, self.target = front, client_addr, target
self.transport = None
self.last = time.time()
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
self.last = time.time()
if len(data) >= 8 and data[0] == 2: # Antwort: Sitzungsnummer merken
_by_index[struct.unpack_from("<I", data, 4)[0]] = self.target["id"]
self.front.sendto(data, self.client)
def error_received(self, exc):
pass
class Front(asyncio.DatagramProtocol):
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
asyncio.ensure_future(self.handle(data, addr))
async def handle(self, data: bytes, addr) -> None:
if len(data) < 4:
return
load_map()
kind = data[0]
up = _clients.get(addr)
target = None
if kind == 1:
target = target_for_init(data)
if not target:
return # fremder Schluessel → still verwerfen
elif up is None and kind == 4 and len(data) >= 8:
tid = _by_index.get(struct.unpack_from("<I", data, 4)[0])
target = next((t for t in _targets if t["id"] == tid), None)
if not target:
return
elif up is None:
return
if up and target and up.target["id"] != target["id"]:
up.transport.close()
up = None
if up is None or up.target["addr"] != (target or up.target)["addr"]:
if up:
up.transport.close()
if len(_clients) >= MAX_CLIENTS:
return
loop = asyncio.get_running_loop()
t = target or up.target
_, up = await loop.create_datagram_endpoint(
lambda: Upstream(self.transport, addr, t), remote_addr=t["addr"])
_clients[addr] = up
up.last = time.time()
up.transport.sendto(data)
async def janitor() -> None:
while True:
await asyncio.sleep(60)
now = time.time()
for a, up in list(_clients.items()):
if now - up.last > IDLE_S:
up.transport.close()
del _clients[a]
if len(_by_index) > 8192:
_by_index.clear()
async def main() -> None:
load_map()
loop = asyncio.get_running_loop()
await loop.create_datagram_endpoint(Front, local_addr=("0.0.0.0", LISTEN_PORT))
log.info("lausche auf UDP %d", LISTEN_PORT)
await janitor()
if __name__ == "__main__":
asyncio.run(main())
+17
View File
@@ -207,6 +207,23 @@ services:
- HF_TOKEN=${HF_TOKEN:-}
restart: unless-stopped
# ─── LLM-Updater — llama-swap/llama.cpp aktualisieren ─
# Der EINZIGE Dienst mit Docker-Socket (= root auf der Box). Darum kann er nur
# drei feste Dinge fuer aria-llama-swap: Version pruefen, aktualisieren (altes
# Image sichern, ziehen, neu anlegen, Selbsttest, sonst zurueck) und auf die
# gesicherte Version zuruecksetzen. Kein Port nach aussen; der llm-adapter
# spricht ihn im Compose-Netz mit einem Token an (/models/.updater_token).
# Ausgeloest wird das aus der Diagnostic (Modell-Katalog → ⚙ llama.cpp).
llm-updater:
build: ./llm-updater
container_name: aria-llm-updater
profiles: ["llm"]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- .:/stack:ro # Compose-Datei + .env zum Neu-Anlegen
- ./models:/models:ro # Token + welche Modelle vorgeladen sind
restart: unless-stopped
# ─── OpenHands (optional, Standalone) ────────────────
# Eigenstaendige Entwickler-Agenten-Oberflaeche, die das LOKALE LLM ueber den
# OpenAI-Endpunkt von llama-swap nutzt. Bewusst ein eigenes Profil: wer nur
+81 -1
View File
@@ -342,7 +342,7 @@ async def _reload_llama() -> None:
# Fragende (Schmiede, ARIA) wartet dann bis zu seiner Frist ins Leere (am
# 28.10. 11 Minuten). Jetzt: ueber die NEUE Verbindung senden, notfalls bis zum
# Wiederverbinden aufheben.
_RELIABLE_TYPES = {"llm_response"}
_RELIABLE_TYPES = {"llm_response", "llm_runtime_result"}
_OUTBOX_TTL_S = 300
_outbox: list = [] # [(ts, mtype, payload)]
_current_ws = None # die aktuell offene RVS-Verbindung
@@ -1002,6 +1002,83 @@ async def _do_llm_request(ws, payload: dict) -> None:
})
# ── llama-swap/llama.cpp aktualisieren (ueber den llm-updater) ──
# Der Adapter hat bewusst KEINEN Docker-Zugriff. Pruefen, Aktualisieren und
# Zuruecksetzen macht der llm-updater (eigener Container, nur im Compose-Netz);
# der Adapter reicht die Diagnostic-Anfrage weiter und meldet den Fortschritt.
UPDATER_URL = os.getenv("UPDATER_URL", "http://llm-updater:8090").rstrip("/")
_UPDATER_TOKEN_FILE = "/models/.updater_token"
def _updater_token() -> str:
"""Gemeinsamer Token mit dem llm-updater. Liegt in /models (der Updater
haengt es nur lesend ein); fehlt er, legt der Adapter ihn an."""
try:
tok = open(_UPDATER_TOKEN_FILE).read().strip()
if tok:
return tok
except OSError:
pass
import secrets
tok = secrets.token_hex(24)
try:
fd = os.open(_UPDATER_TOKEN_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write(tok)
except OSError as e:
logger.warning("Updater-Token nicht schreibbar: %s", e)
return tok
async def _updater(method: str, path: str, timeout: float = 60) -> dict:
try:
async with httpx.AsyncClient(timeout=timeout) as c:
r = await c.request(method, UPDATER_URL + path,
headers={"X-Updater-Token": _updater_token()})
return r.json()
except httpx.ConnectError:
return {"ok": False, "error": "Der Update-Dienst (llm-updater) laeuft auf dieser Box "
"nicht. Mit `docker compose up -d llm-updater` starten."}
except Exception as e:
return {"ok": False, "error": f"Update-Dienst: {str(e)[:150]}"}
async def _handle_runtime(ws, payload: dict) -> None:
"""op = status | check | update | rollback (aus der Diagnostic)."""
if (payload.get("instanceId") or INSTANCE_ID) != INSTANCE_ID:
return
op = payload.get("op", "status")
base = {"requestId": payload.get("requestId", ""), "instanceId": INSTANCE_ID,
"node": NODE_NAME, "op": op}
if op in ("status", "check"):
res = await _updater("GET", "/" + op, timeout=60)
await _send(ws, "llm_runtime_result", {**base, **res})
return
if op not in ("update", "rollback"):
await _send(ws, "llm_runtime_result", {**base, "ok": False, "error": "unbekannte Aktion"})
return
start = await _updater("POST", "/" + op)
if not start.get("ok"):
await _send(ws, "llm_runtime_result", {**base, **start})
return
logger.info("llama-swap %s gestartet (%s)", op, start.get("job"))
seen = 0
while True:
await asyncio.sleep(3)
job = await _updater("GET", "/job", timeout=20)
if job.get("error") and "state" not in job:
continue # Updater kurz nicht erreichbar — weiter warten
lines = job.get("log") or []
if len(lines) > seen:
await _send(ws, "llm_runtime_progress", {**base, "lines": lines[seen:]})
seen = len(lines)
if job.get("state") == "done":
await _send(ws, "llm_runtime_result", {**base, **(job.get("result") or {})})
logger.info("llama-swap %s fertig: %s", op, (job.get("result") or {}).get("ok"))
await _announce(ws)
return
async def _handle_openai_config(ws, payload: dict) -> None:
"""Setzt den API-Key des OpenAI-Endpunkts (aus der Diagnostic).
@@ -1886,6 +1963,9 @@ async def _run() -> None:
if mtype == "llm_openai_config":
asyncio.create_task(_handle_openai_config(ws, payload))
continue
if mtype == "llm_runtime":
asyncio.create_task(_handle_runtime(ws, payload))
continue
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model",
"llm_unload_model", "llm_models_request"):
continue
+8
View File
@@ -0,0 +1,8 @@
# Docker-CLI mit Compose-Plugin + Python (nur Standardbibliothek + PyYAML)
FROM docker:29-cli
RUN apk add --no-cache python3 py3-yaml
WORKDIR /app
COPY updater.py .
CMD ["python3", "-u", "updater.py"]
+398
View File
@@ -0,0 +1,398 @@
"""
ARIA LLM-Updater (AI-Box) — aktualisiert das llama-swap-Image (mit llama.cpp).
Der einzige Dienst mit Docker-Socket. Darum kann er bewusst NUR drei feste
Dinge, alle fuer genau einen Container (aria-llama-swap):
check installierte Version + Image-Pruefsumme, dazu die neueste aus der
Registry (Pruefsumme des Tags) und das letzte llama-swap-Release
update altes Image sichern (Tag vor-update-<Zeit>), neues ziehen,
Container neu anlegen, Selbsttest — scheitert der, automatisch zurueck
rollback das gesicherte Image wieder einsetzen, Container neu anlegen, Selbsttest
Kein freier Befehl, keine anderen Container. Erreichbar nur im Compose-Netz
(kein Port nach aussen), jede Anfrage braucht den Token, den der llm-adapter
in /models/.updater_token ablegt.
Neu angelegt wird ueber `docker compose … up --no-deps --force-recreate
llama-swap` mit der Compose-Datei des Stacks — so bleibt alles, was die
Compose-Datei festlegt (GPU-Runtime, Volumes, LD_LIBRARY_PATH …), gleich.
Relative Pfade loest Compose gegen --project-directory auf; das ist der
Host-Pfad aus dem Label des laufenden Containers, er muss im Container hier
nicht existieren.
"""
from __future__ import annotations
import http.server
import json
import logging
import os
import re
import subprocess
import threading
import time
import urllib.error
import urllib.request
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("llm-updater")
CONTAINER = os.getenv("LLAMA_CONTAINER", "aria-llama-swap")
SERVICE = os.getenv("LLAMA_SERVICE", "llama-swap")
STACK_DIR = os.getenv("STACK_DIR", "/stack")
TOKEN_FILE = os.getenv("TOKEN_FILE", "/models/.updater_token")
GEN_CONFIG = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama-swap:8080").rstrip("/")
RELEASES_URL = os.getenv("RELEASES_URL", "https://api.github.com/repos/mostlygeek/llama-swap/releases/latest")
BACKUP_REPO = "llama-swap" # gesicherte Images: llama-swap:vor-update-<Zeit>
KEEP_BACKUPS = 1
READY_TIMEOUT_S = int(os.getenv("READY_TIMEOUT_S", "900")) # Modell(e) wieder geladen
PORT = int(os.getenv("PORT", "8090"))
_job = {"id": "", "op": "", "state": "idle", "log": [], "result": None}
_job_lock = threading.Lock()
# ── kleine Helfer ───────────────────────────────────────────────
def _run(args: list[str], timeout: int = 120) -> tuple[int, str]:
p = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return p.returncode, (p.stdout + p.stderr).strip()
def _log(msg: str) -> None:
logger.info(msg)
with _job_lock:
_job["log"].append(f"{time.strftime('%H:%M:%S')} {msg}")
_job["log"] = _job["log"][-60:]
def _inspect(ref: str, kind: str = "container") -> dict:
rc, out = _run(["docker", kind, "inspect", ref])
if rc != 0:
return {}
try:
return json.loads(out)[0]
except Exception:
return {}
def _image_ref() -> str:
return (_inspect(CONTAINER).get("Config") or {}).get("Image", "")
def _local_digest(ref: str) -> str:
"""Pruefsumme, unter der das Image gezogen wurde (Index/Manifest des Tags)."""
repo = ref.rsplit(":", 1)[0] if ":" in ref.split("/")[-1] else ref
for d in _inspect(ref, "image").get("RepoDigests") or []:
if d.startswith(repo + "@"):
return d.split("@", 1)[1]
return ""
def _versions() -> dict:
v = {}
rc, out = _run(["docker", "exec", CONTAINER, "llama-swap", "-version"], 30)
m = re.search(r"version:\s*(\S+)", out)
if rc == 0 and m:
v["llamaSwap"] = m.group(1)
b = re.search(r"built at\s*(\S+)", out)
if b:
v["llamaSwapBuilt"] = b.group(1)
rc, out = _run(["docker", "exec", CONTAINER, "llama-server", "--version"], 30)
m = re.search(r"version:\s*(.+)", out)
if rc == 0 and m:
v["llamaCpp"] = m.group(1).strip()
return v
def _backups() -> list[dict]:
rc, out = _run(["docker", "images", BACKUP_REPO, "--format", "{{.Tag}}\t{{.ID}}\t{{.CreatedAt}}"])
res = []
for line in out.splitlines() if rc == 0 else []:
tag, iid, created = (line.split("\t") + ["", "", ""])[:3]
if tag.startswith("vor-update-"):
res.append({"tag": f"{BACKUP_REPO}:{tag}", "id": iid, "created": created})
return sorted(res, key=lambda b: b["tag"], reverse=True)
def _http(url: str, method: str = "GET", headers: dict | None = None, timeout: int = 20):
req = urllib.request.Request(url, method=method, headers=headers or {})
return urllib.request.urlopen(req, timeout=timeout)
def _remote_digest(ref: str) -> str:
"""Pruefsumme des Tags in der Registry (ohne Docker, anonymer Token)."""
name, tag = ref.rsplit(":", 1) if ":" in ref.split("/")[-1] else (ref, "latest")
parts = name.split("/")
if "." in parts[0] or ":" in parts[0]:
host, path = parts[0], "/".join(parts[1:])
else:
host, path = "registry-1.docker.io", name if "/" in name else f"library/{name}"
url = f"https://{host}/v2/{path}/manifests/{tag}"
accept = ", ".join([
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
])
try:
r = _http(url, "HEAD", {"Accept": accept})
return r.headers.get("Docker-Content-Digest", "")
except urllib.error.HTTPError as e:
if e.code != 401:
raise
chal = e.headers.get("WWW-Authenticate", "")
p = dict(re.findall(r'(\w+)="([^"]*)"', chal))
tok_url = f"{p['realm']}?service={p.get('service', '')}&scope={p.get('scope', f'repository:{path}:pull')}"
tok = json.load(_http(tok_url))
token = tok.get("token") or tok.get("access_token") or ""
r = _http(url, "HEAD", {"Accept": accept, "Authorization": f"Bearer {token}"})
return r.headers.get("Docker-Content-Digest", "")
def _latest_release() -> dict:
try:
d = json.load(_http(RELEASES_URL, headers={"Accept": "application/vnd.github+json"}))
return {"tag": d.get("tag_name", ""), "published": d.get("published_at", ""),
"url": d.get("html_url", "")}
except Exception as e:
return {"error": str(e)[:120]}
def _status(remote: bool) -> dict:
ref = _image_ref()
if not ref:
return {"ok": False, "error": f"Container {CONTAINER} nicht gefunden"}
img = _inspect(ref, "image")
st = {
"ok": True, "image": ref,
"installed": {**_versions(), "digest": _local_digest(ref),
"created": img.get("Created", "")},
"backups": _backups(),
"job": {k: _job[k] for k in ("id", "op", "state")},
}
if remote:
try:
st["latestDigest"] = _remote_digest(ref)
except Exception as e:
st["latestError"] = f"Registry nicht erreichbar: {str(e)[:120]}"
st["release"] = _latest_release()
if st.get("latestDigest") and st["installed"]["digest"]:
st["updateAvailable"] = st["latestDigest"] != st["installed"]["digest"]
return st
# ── Neu anlegen + Selbsttest ────────────────────────────────────
def _compose_args() -> list[str]:
labels = (_inspect(CONTAINER).get("Config") or {}).get("Labels") or {}
proj = labels.get("com.docker.compose.project", "")
wdir = labels.get("com.docker.compose.project.working_dir", "")
if not proj or not wdir:
raise RuntimeError("Container wurde nicht mit Compose angelegt — Labels fehlen")
args = ["docker", "compose", "--project-directory", wdir, "-p", proj,
"-f", os.path.join(STACK_DIR, "docker-compose.yml"), "--profile", "llm"]
env = os.path.join(STACK_DIR, ".env")
if os.path.exists(env):
args += ["--env-file", env]
return args
def _recreate() -> None:
rc, out = _run(_compose_args() + ["up", "-d", "--no-deps", "--force-recreate",
"--pull", "never", SERVICE], 300)
if rc != 0:
raise RuntimeError(f"Container neu anlegen fehlgeschlagen: {out[-400:]}")
_log("Container neu angelegt")
def _preloaded() -> list[str]:
try:
import yaml
cfg = yaml.safe_load(open(GEN_CONFIG)) or {}
return list(((cfg.get("hooks") or {}).get("on_startup") or {}).get("preload") or [])
except Exception:
return []
def _selftest() -> tuple[bool, str]:
"""Karten sichtbar (sonst rechnet llama.cpp still auf der CPU) und die
vorher geladenen Modelle wieder bereit."""
for _ in range(30):
if (_inspect(CONTAINER).get("State") or {}).get("Running"):
break
time.sleep(2)
else:
return False, "Container laeuft nicht"
rc, out = _run(["docker", "exec", CONTAINER, "llama-server", "--list-devices"], 60)
cards = [l.strip() for l in out.splitlines() if re.match(r"\s*CUDA\d+", l)]
if not cards:
return False, f"llama.cpp sieht keine Grafikkarte (wuerde auf der CPU rechnen): {out[-300:]}"
_log(f"Karten sichtbar: {len(cards)} ({'; '.join(c[:60] for c in cards)})")
want = _preloaded()
if not want:
return True, f"{len(cards)} Karte(n) sichtbar, kein Modell vorgeladen"
_log(f"warte, bis {', '.join(want)} geladen ist …")
deadline = time.time() + READY_TIMEOUT_S
last = ""
while time.time() < deadline:
try:
run = json.load(_http(f"{LLAMA_URL}/running", timeout=10)).get("running") or []
states = {r.get("model"): r.get("state") for r in run}
last = ", ".join(f"{m}={states.get(m, '-')}" for m in want)
if all(states.get(m) == "ready" for m in want):
return True, f"{len(cards)} Karte(n), Modell(e) bereit: {', '.join(want)}"
except Exception as e:
last = str(e)[:80]
time.sleep(5)
return False, f"Modell nach {READY_TIMEOUT_S // 60} min nicht bereit ({last})"
def _drop_untagged(image_id: str) -> None:
"""Ein Image, das nach dem Zuruecksetzen keinen Namen mehr hat, belegt nur
noch Platz (das llama-swap-Image hat ~14 GB)."""
if image_id and not (_inspect(image_id, "image").get("RepoTags") or []):
rc, _ = _run(["docker", "rmi", image_id])
if rc == 0:
_log("verworfene Version geloescht")
def _prune_backups() -> None:
for b in _backups()[KEEP_BACKUPS:]:
_run(["docker", "rmi", b["tag"]])
_log(f"alte Sicherung entfernt: {b['tag']}")
# ── Jobs ────────────────────────────────────────────────────────
def _do_update() -> dict:
ref = _image_ref()
before = _status(remote=False)
old_id = _inspect(ref, "image").get("Id", "")
backup = f"{BACKUP_REPO}:vor-update-{time.strftime('%Y%m%d-%H%M%S')}"
rc, out = _run(["docker", "tag", old_id, backup])
if rc != 0:
raise RuntimeError(f"Sicherung fehlgeschlagen: {out}")
_log(f"altes Image gesichert als {backup}")
_log(f"ziehe {ref} … (mehrere GB, dauert)")
rc, out = _run(["docker", "pull", ref], 3600)
if rc != 0:
raise RuntimeError(f"Ziehen fehlgeschlagen: {out[-400:]}")
new_id = _inspect(ref, "image").get("Id", "")
if new_id == old_id:
_run(["docker", "rmi", backup])
return {"ok": True, "changed": False, "message": "Schon aktuell — nichts geaendert."}
_recreate()
ok, why = _selftest()
if ok:
_prune_backups()
after = _status(remote=False)
return {"ok": True, "changed": True, "before": before.get("installed"),
"after": after.get("installed"), "selftest": why, "backup": backup}
_log(f"Selbsttest fehlgeschlagen: {why} — setze zurueck")
_run(["docker", "tag", backup, ref])
_recreate()
_drop_untagged(new_id)
ok2, why2 = _selftest()
return {"ok": False, "rolledBack": True, "error": f"Selbsttest fehlgeschlagen: {why}",
"rollbackSelftest": why2 if ok2 else f"AUCH die alte Version laeuft nicht: {why2}"}
def _do_rollback() -> dict:
bks = _backups()
if not bks:
raise RuntimeError("Keine gesicherte Version vorhanden")
ref = _image_ref()
cur_id = _inspect(ref, "image").get("Id", "")
b = bks[0]
rc, out = _run(["docker", "tag", b["tag"], ref])
if rc != 0:
raise RuntimeError(f"Zuruecksetzen fehlgeschlagen: {out}")
_log(f"{b['tag']} wieder als {ref} gesetzt")
_recreate()
_drop_untagged(cur_id)
ok, why = _selftest()
return {"ok": ok, "selftest": why, "restored": b["tag"],
"after": _status(remote=False).get("installed"),
**({} if ok else {"error": f"Selbsttest fehlgeschlagen: {why}"})}
def _start_job(op: str) -> dict:
with _job_lock:
if _job["state"] == "running":
return {"ok": False, "error": f"Es laeuft schon: {_job['op']}", "job": _job["id"]}
_job.update(id=f"{op}-{int(time.time())}", op=op, state="running", log=[], result=None)
jid = _job["id"]
def work():
try:
res = _do_update() if op == "update" else _do_rollback()
except Exception as e:
logger.exception("%s fehlgeschlagen", op)
res = {"ok": False, "error": str(e)[:500]}
with _job_lock:
_job["result"] = res
_job["state"] = "done"
threading.Thread(target=work, daemon=True).start()
return {"ok": True, "job": jid}
# ── HTTP ────────────────────────────────────────────────────────
def _token() -> str:
try:
return open(TOKEN_FILE).read().strip()
except OSError:
return ""
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *a):
pass
def _reply(self, code: int, body: dict) -> None:
data = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _authorized(self) -> bool:
tok = _token()
if not tok or self.headers.get("X-Updater-Token", "") != tok:
self._reply(403, {"ok": False, "error": "Token fehlt oder falsch"})
return False
return True
def do_GET(self):
if not self._authorized():
return
try:
if self.path == "/status":
self._reply(200, _status(remote=False))
elif self.path == "/check":
self._reply(200, _status(remote=True))
elif self.path == "/job":
with _job_lock:
self._reply(200, dict(_job))
else:
self._reply(404, {"ok": False, "error": "unbekannt"})
except Exception as e:
self._reply(500, {"ok": False, "error": str(e)[:300]})
def do_POST(self):
if not self._authorized():
return
if self.path in ("/update", "/rollback"):
self._reply(200, _start_job(self.path[1:]))
else:
self._reply(404, {"ok": False, "error": "unbekannt"})
if __name__ == "__main__":
logger.info("LLM-Updater auf :%d fuer Container %s", PORT, CONTAINER)
http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
+8 -2
View File
@@ -327,6 +327,7 @@ class StreamSession:
last_speech_check_at: float = 0.0 # Drossel fuer den Silero-Speech-Endpoint
noise_floor: float = 0.0
closed: bool = False
discard: bool = False # stream_end mit reason "cancel:*" → NICHT transkribieren
endpoint_sent: bool = False
# Einmaliges "Sprache erkannt"-Signal an die App gesendet? Voxtral schickt
# keine Live-Partials, aber der App-No-Speech-Watchdog wartet auf ein
@@ -396,10 +397,12 @@ class SessionManager:
sess.last_chunk_at = time.time()
return True
def end_session(self, request_id: str) -> None:
def end_session(self, request_id: str, reason: str = "") -> None:
sess = self._sessions.get(request_id)
if sess is not None:
sess.closed = True
if str(reason).startswith("cancel"):
sess.discard = True # Muelleimer in der App: verwerfen
def drop(self, request_id: str) -> None:
self._sessions.pop(request_id, None)
@@ -620,6 +623,9 @@ class SessionManager:
# Schwelle (voiced==0 = gar keine Stimme), damit ein kurzes bewusstes Wort
# ('ja', 'stopp') am Aufnahme-Button noch durchgeht, echte Stille aber nicht.
_min_voiced = STREAM_MIN_VOICED_FRAMES if reason != "stream_end" else 1
if sess.discard:
reason = "cancelled"
_min_voiced = 10 ** 9 # verworfen: nie transkribieren, leerer Text
if sess.voiced_frames < _min_voiced:
logger.info("Stream %s: no-speech (voiced_frames=%d<%d, reason=%s) — leeres Endpoint",
sess.request_id[:8], sess.voiced_frames, _min_voiced, reason)
@@ -810,7 +816,7 @@ async def run_loop(sessions: SessionManager) -> None:
elif mtype == "stt_audio_chunk":
sessions.feed_chunk(payload)
elif mtype == "stt_stream_end":
sessions.end_session(payload.get("requestId", ""))
sessions.end_session(payload.get("requestId", ""), payload.get("reason", ""))
elif mtype == "stt_transcribe_blob":
# One-Shot-Transkription eines PCM-Schnipsels (kein Live-
# Stream) — fuer die Wake-Wort-Bestaetigung: die App schickt
+11 -3
View File
@@ -390,6 +390,7 @@ class StreamSession:
voice_rms_min: float = STREAM_VOICE_RMS_MIN
voice_rms_max: float = STREAM_VOICE_RMS_MAX
closed: bool = False # nach stream_end gesetzt
discard: bool = False # stream_end mit reason "cancel:*" → NICHT transkribieren
endpoint_sent: bool = False # Endpoint nur einmal feuern
# Speaker-ID Gating: bei aktiviertem Fingerprint pruefen wir die ersten
# ~1.5s der Aufnahme. Bei mismatch wird die Session sofort beendet mit
@@ -483,13 +484,16 @@ class SessionManager:
session.last_chunk_at = time.time()
return True
def end_session(self, request_id: str) -> Optional[StreamSession]:
def end_session(self, request_id: str, reason: str = "") -> Optional[StreamSession]:
"""Markiert Session als geschlossen. Der Endpointer-Loop macht das
Final-Transcribe + Cleanup."""
Final-Transcribe + Cleanup. reason "cancel:*" (z.B. Muelleimer in der
App) = verwerfen: kein Transcribe, leerer Text — nichts geht an ARIA."""
session = self._sessions.get(request_id)
if session is None:
return None
session.closed = True
if str(reason).startswith("cancel"):
session.discard = True
return session
def drop(self, request_id: str) -> None:
@@ -807,6 +811,10 @@ class SessionManager:
if sess.partial_task is not None and not sess.partial_task.done():
sess.partial_task.cancel()
audio = pcm_s16le_to_float32(bytes(sess.pcm_buffer))
if sess.discard:
logger.info("Stream %s: verworfen (cancel) — kein Transcribe", sess.request_id[:8])
audio = audio[:0]
reason = "cancelled"
if audio.size == 0:
logger.info("Stream %s: leere Audio-Daten — final text leer", sess.request_id[:8])
final_text = ""
@@ -1080,7 +1088,7 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
req_id[:8], payload.get("reason", ""))
await _debug_log(ws, "stream.end",
f"received id={req_id[:12]} reason={payload.get('reason', '')}")
sessions.end_session(req_id)
sessions.end_session(req_id, payload.get("reason", ""))
elif mtype == "voice_id_status_request":
req_id = payload.get("requestId", "")