"use strict"; const { WebSocketServer } = require("ws"); const http = require("http"); const https = require("https"); const { execFileSync } = require("child_process"); const fs = require("fs"); const path = require("path"); // ── Konfiguration aus Umgebungsvariablen ──────────────────────────── const PORT = parseInt(process.env.PORT || "3000", 10); const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10); const UPDATES_DIR = process.env.UPDATES_DIR || "/updates"; // Host-Agent (Android) hat einen EIGENEN Ordner — sonst bekaeme die Cockpit-App // die Agent-APK angeboten (die Versions-Erkennung schaut nur auf den Dateinamen). const AGENT_UPDATES_DIR = path.join(UPDATES_DIR, "agent"); // HTTPS statt HTTP auf PORT — fuer die Strecke externer Reverse-Proxy → RVS. // Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes // (wird beim ersten Start erzeugt). NICHT zusammen mit dem Caddy im Stack // (der spricht den RVS per HTTP an). const HTTP_TLS = /^(1|true|yes|ja)$/i.test(process.env.RVS_HTTP_TLS || ""); const TLS_DIR = process.env.RVS_TLS_DIR || "/certs"; // Kein Polling — APK wird manuell per git pull bereitgestellt // Erlaubte Nachrichtentypen — alles andere wird verworfen const ALLOWED_TYPES = new Set([ "chat", "audio", "file", "location", "mode", "log", "event", "heartbeat", "file_request", "file_response", "file_saved", "stt_result", "config", "tts_request", "xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved", "update_check", "update_available", "update_none", "update_download", "update_data", "agent_activity", "cancel_request", "interject", "audio_pcm", "file_from_aria", "container_restart", "file_list_request", "file_list_response", "file_delete_request", "file_deleted", "xtts_export_voice", "xtts_voice_exported", "xtts_import_voice", "xtts_voice_imported", "skill_created", "trigger_created", "memory_saved", "location_update", "location_tracking", "chat_history_request", "chat_history_response", "chat_cleared", "delete_message_request", "chat_message_deleted", "brain_request", "brain_response", "app_log", "file_delete_batch_request", "file_delete_batch_response", "file_zip_request", "file_zip_response", "xtts_delete_voice", "voice_preload", "voice_ready", "stt_request", "stt_response", // Streaming-STT (Phase 1+2): App schickt PCM live an whisper-bridge, // die feuert stt_endpoint mit dem finalen Text — kein Audio-Roundtrip. "stt_stream_start", "stt_audio_chunk", "stt_stream_end", "stt_partial", "stt_endpoint", "stt_stream_done", // Speaker-ID / Voice-Enrollment (Phase 1+2): App schickt 5-10 Samples zur // whisper-bridge, die berechnet einen Voice-Fingerprint (Embedding-Vektor) // und nutzt ihn um nur Stefans Stimme an Whisper STT durchzulassen. "voice_id_status_request", "voice_id_status_response", "voice_id_enroll_request", "voice_id_enroll_response", "voice_id_delete_request", "voice_id_delete_response", // Projekte (Stefan-Konzept: Threads im Hauptchat verankert) — Side-Channel- // Event vom Brain → Bridge → App/Diagnostic, damit beide Clients ihren // aktiven-Projekt-Banner refreshen wenn ARIA via Tool was aendert. "project_changed", // File-Versioning (Datei-Manager in App): Versionen pro Datei listen, // alte Versionen herunterladen, Restore = non-destructive neuer Commit. "file_version_list_request", "file_version_list_response", "file_version_download_request", "file_version_download_response", "file_version_restore_request", "file_version_restore_response", "service_status", "config_request", "flux_request", "flux_response", "agent_stream", "oauth_callback", // Lokales LLM (Plan B) — Router im Brain schickt einfache Turns an das // Qwen3 auf der AI-Box (via Bridge → RVS → llm-adapter → llama.cpp). // llm_partial ist fuer B2 (Token-Streaming) reserviert, noch ungenutzt. "llm_request", "llm_response", "llm_partial", // Workspace-Desktop (Code-Projekte): Live-Code-Editor (CodeMirror in der App) // spiegelt ARIAs Datei-Writes, und QEMU-VNC wird als RFB-Bytes durch RVS // getunnelt (Base64-in-JSON wie audio_pcm — kein Binaer-Handling noetig). "code_file", "code_file_edit", // M1 Generatives Cockpit: ARIA komponiert via present_view eine View-Spec // (Orb + Karten), die App/Web/Diagnostic mit ihrem jeweiligen Renderer // materialisieren. Brain → Bridge → RVS → Clients. "aria_view", "check_desktop", "desktop_status", "vnc_open", "vnc_close", "vnc_data", "vnc_input", // Satelliten (Info-/Gateway-Aussenposten in fremden Netzen): melden sich mit // sat_hello, liefern Geraete-Inventar (sat_devices) auf sat_discover und // fuehren Aktionen aus (sat_command → sat_result). "sat_hello", "sat_discover", "sat_devices", "sat_command", "sat_result", // Satelliten-Credential-Store: Diagnostic legt pro Geraet Zugangsdaten ab // (SNMP/HTTP/FritzBox), der Satellit speichert sie verschluesselt. "sat_creds_set", "sat_creds_delete", "sat_creds_list", "sat_creds_result", "sat_creds_list_result", // Port-Tunnel: ARIA erreicht ueber den Satelliten einen beliebigen Port im // fremden LAN (FreeCAD-MCP, Web-App ...). Die Bridge macht daraus einen // lokalen Port. sat_tunnel_data laeuft in BEIDE Richtungen — unterschieden // wird ueber die tunnelId. Plus sat_name_* fuer vergebene Geraetenamen, // damit ARIA "duffy-desktop" sagen kann statt einer IP. "sat_tunnel_open", "sat_tunnel_opened", "sat_tunnel_data", "sat_tunnel_close", "sat_tunnel_closed", "sat_name_set", "sat_name_result", // Host-Agenten: ein Agent laeuft direkt auf einem Rechner, meldet sich mit // host_hello/host_ping und fuehrt host_command aus (exec/read/write/info/ // screenshot) -> host_result. ARIA steuert so Rechner auch hinter NAT. "host_hello", "host_ping", "host_command", "host_result", // Agent-Historie: Brain schickt pro Host-Aktion ein agent_history-Event (via // Bridge), Diagnostic fragt die gespeicherte Historie per agent_history_query // ab, die Bridge antwortet mit agent_history_list. OHNE diese Typen verwirft // der RVS sie still an der Allow-List und die Historie bleibt leer. "agent_history", "agent_history_query", "agent_history_list", // STT-Lease: die App fragt vor einer Aufnahme, WELCHE STT-Instanz sie nehmen // soll (stt_lease_request -> stt_lease). Fehlten diese Typen hier, lief die // Anfrage IMMER in den 250ms-Timeout: jede Aufnahme startete verspaetet (erste // Silben weg) und ohne targetInstance nahmen ALLE STT-Worker dieselbe Session // an -> doppelte stt_endpoint. chat_ack quittiert getippte Nachrichten; ohne // ihn liefen sie in der App nach 3 Versuchen auf "fehlgeschlagen", obwohl sie // laengst verarbeitet waren. stt_transcribe_* ist die Wake-Wort-Bestaetigung. "stt_lease", "stt_lease_request", "stt_transcribe_blob", "stt_transcribe_result", "chat_ack", // Raum-Diagnose: Diagnostic fragt die aktuellen RVS-Raeume ab (rooms_query), // RVS antwortet direkt mit rooms_info (Fingerprint + Laenge + Client-Zahl je // Raum). Deckt Token-Prefix-Kollisionen auf (2 Raeume, gleicher 8-Zeichen-Log). "rooms_query", "rooms_info", // Compute-Flotte (AI-Boxen): Worker (f5tts/whisper/voxtral/llm-adapter) melden // sich per worker_hello an und pingen per worker_ping; der Diagnostic-Server // aggregiert das und broadcastet worker_update/worker_list an die Browser-UI. // node_stats_* speisen den Auslastungs-Monitor (live nvidia-smi + Historie). // OHNE diese Typen verwirft der RVS die Meldungen an der Allow-List (Z. 312), // und die Box bleibt in der Flotte unsichtbar, obwohl sie sendet. "worker_hello", "worker_ping", "worker_update", "worker_list", // 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", // 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 %. "llm_provision_model", "llm_provision_result", "llm_provision_progress", "llm_remove_model", // Karten: Diagnostic leert eine LLM-Karte; Clients fragen, welche Modelle // auf welchen Boxen/Karten geladen sind (jede Box antwortet mit llm_models). // Schnittstelle fuer Drittprogramme: docs/LLM-SCHNITTSTELLE.md "llm_unload_model", "llm_models_request", "llm_models", // LLM-Cluster: Clients fragen MODELLE, die ARIA-Bridge waehlt die Box // (Last, Ausfall-Ersatz) und antwortet. Empfohlener Weg fuer Programme. "llm_cluster_models_request", "llm_cluster_models", "llm_cluster_request", "llm_cluster_response", // Wer ist im Raum? client_hello: Client stellt sich vor (bleibt beim RVS); // room_clients_query → room_clients: Liste NUR des eigenen Raums. "client_hello", "room_clients_query", "room_clients", // Host-Agent (Android): Update-Pruefung, beantwortet der RVS selbst "agent_update_check", "agent_update_available", "node_stats", "node_stats_stream_start", "node_stats_stream_stop", "node_stats_history_request", "node_stats_history", "node_stats_reset", "node_stats_reset_done", ]); // Token-Raum: token -> { clients: Set } const rooms = new Map(); // ── Punkt-zu-Punkt-Antworten ──────────────────────────────────────── // // Der RVS ist ein Broadcast-Relay: normalerweise bekommt JEDER Client im Raum // jede Nachricht. Bei Datei-Antworten ist das fatal. Ein 150-MB-Video wird als // base64 ~200 MB gross und ging bisher an ALLE Clients im Raum — auch an die // STT/TTS-Worker, die daran mit Code 1009 (message too big) gestorben sind und // die gerade laufende Sprachaufnahme mitgerissen haben ("erste Sprachnachricht // kommt nicht durch"). Die Worker-max_size-Angleichung verhindert den Absturz, // aber 200 MB an zehn Clients zu schicken bleibt Unsinn: nur EINER hat gefragt. // // Darum: wer eine Anfrage mit requestId stellt, wird gemerkt — die Antwort mit // derselben requestId geht nur an ihn zurueck. Unbekannte requestId → wie // bisher broadcasten (nichts geht verloren, falls ein Client mithoeren muss, // dessen Anfrage der RVS nie gesehen hat). const TARGETED = new Map([ ["file_request", "file_response"], ["file_zip_request", "file_zip_response"], ["file_version_download_request", "file_version_download_response"], ["brain_request", "brain_response"], ]); const TARGETED_REPLIES = new Set(TARGETED.values()); // `antwortTyp \0 requestId` -> { clients: Set, at } // Mehrere Clients koennen dieselbe requestId benutzen (die App nimmt die // Nachrichten-ID) — darum ein Set, sonst bekaeme nur der letzte die Antwort. const pendingReplies = new Map(); const PENDING_TTL_MS = 180_000; // Antworten bis zu dieser Groesse gehen an den Raum, wenn der Fragende nur // neu verbunden ist (siehe Weiterleitung). Dateien sind groesser. const REJOIN_BROADCAST_MAX = 2 * 1024 * 1024; function pendingKey(replyType, requestId) { return `${replyType}\u0000${requestId}`; } /** Merkt sich den Fragenden, wenn die Nachricht eine gezielte Anfrage ist. */ function rememberRequester(ws, msg) { const replyType = TARGETED.get(msg.type); if (!replyType) return; const rid = msg.payload && msg.payload.requestId; if (!rid || typeof rid !== "string") return; const key = pendingKey(replyType, rid); let entry = pendingReplies.get(key); if (!entry) { entry = { clients: new Set(), at: Date.now() }; pendingReplies.set(key, entry); } entry.clients.add(ws); entry.at = Date.now(); } /** * Empfaenger fuer eine Antwort-Nachricht. * null = keine gezielte Antwort → normal broadcasten. * [] = der Fragende ist weg → verwerfen (NICHT broadcasten, sonst waere * der ganze Sinn weg und die 200 MB gingen doch an alle). */ function targetedRecipients(msg) { if (!TARGETED_REPLIES.has(msg.type)) return null; const rid = msg.payload && msg.payload.requestId; if (!rid || typeof rid !== "string") return null; const key = pendingKey(msg.type, rid); const entry = pendingReplies.get(key); if (!entry) return null; pendingReplies.delete(key); return [...entry.clients].filter((c) => c.readyState === 1); } /** Abgelaufene/tote Eintraege wegwerfen (laeuft mit cleanupRooms). */ function cleanupPendingReplies() { const now = Date.now(); for (const [key, entry] of pendingReplies) { if (now - entry.at > PENDING_TTL_MS) { pendingReplies.delete(key); continue; } for (const c of entry.clients) { if (c.readyState > 1) entry.clients.delete(c); } if (entry.clients.size === 0) pendingReplies.delete(key); } } // ── Hilfsfunktionen ───────────────────────────────────────────────── function timestamp() { return new Date().toISOString(); } function log(msg) { console.log(`[${timestamp()}] ${msg}`); } // Leere Räume und tote Clients aufräumen function cleanupRooms() { for (const [token, room] of rooms) { // Tote Clients entfernen for (const client of room.clients) { if (client.readyState > 1) room.clients.delete(client); } // Raum löschen wenn leer if (room.clients.size === 0) { rooms.delete(token); log(`Leerer Raum entfernt: ${token.slice(0, 8)}...`); } } cleanupPendingReplies(); } // ── HTTP + WebSocket Server (hybrid) ──────────────────────────────── // // Der gleiche Port handelt jetzt sowohl WebSocket-Upgrades (App, Bridges, // Diagnostic) als auch normale HTTP-Requests (OAuth-Callbacks von Spotify, // Google etc.). TLS-Termination passiert wie bisher vor dem RVS-Container // (Caddy/Nginx); RVS selber bleibt plain HTTP. Wichtig fuer OAuth: aus // Provider-Sicht ist die Callback-URL `https://{RVS_HOST}:{PORT_oeffentlich} // /oauth/callback/{service}` — RVS schnappt den ?code=..&state=.., broadcastet // als WS-Message `oauth_callback` und antwortet dem Browser mit einer // schoenen "Tab schliessen"-Seite. // // maxPayload 1500MB: TTS-Streaming + Voice-Upload (WAV als base64) + // audio_pcm Chunks koennen die ws-Library Default 1MB ueberschreiten. // Plus: file_request/file_response fuer Re-Download von Anhaengen. // 40 MB MP4 → ~53 MB base64 → vorher mit 50 MB Limit zerschossen // (Code 1009 message too big, Bridge crashed im cleanup). 1500 MB // deckt bis ~1 GB binaer ab (mit base64 ~33% Overhead + WS-Frame- // Margin); groessere Files werden Bridge-seitig abgewiesen (siehe // file_request-Handler) bevor die WS abreisst. // // WICHTIG: Node-Default-Heap ist ~1.5 GB. Fuer 1 GB-Files muss der // Container mit --max-old-space-size=4096 (oder NODE_OPTIONS env var) // gestartet werden, sonst OOM-Crash beim Empfang. /** Zertifikat fuer den internen HTTPS-Port: vorhandenes aus TLS_DIR nehmen * (eigenes darf man dort ablegen), sonst einmalig selbstsigniert erzeugen. */ function loadInternalTls() { const key = path.join(TLS_DIR, "rvs.key"); const crt = path.join(TLS_DIR, "rvs.crt"); if (!fs.existsSync(key) || !fs.existsSync(crt)) { fs.mkdirSync(TLS_DIR, { recursive: true }); execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-sha256", "-days", "3650", "-subj", "/CN=aria-rvs-intern", "-keyout", key, "-out", crt], { stdio: "ignore" }); fs.chmodSync(key, 0o600); log(`Internes TLS-Zertifikat erzeugt: ${crt} (selbstsigniert, 10 Jahre)`); } return { key: fs.readFileSync(key), cert: fs.readFileSync(crt) }; } const httpServer = HTTP_TLS ? https.createServer(loadInternalTls(), handleHttpRequest) : http.createServer(handleHttpRequest); const wss = new WebSocketServer({ noServer: true, maxPayload: 1500 * 1024 * 1024 }); // HTTP-Upgrade-Pfad → an WebSocket-Server reichen httpServer.on("upgrade", (req, socket, head) => { wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); }); }); httpServer.listen(PORT, () => { log(`RVS läuft auf Port ${PORT} (${HTTP_TLS ? "HTTPS + WSS, internes Zertifikat" : "HTTP + WS"}) | Max Sessions: ${MAX_SESSIONS}`); // Beim Start pruefen ob eine APK da ist const apkInfo = getLatestAPK(); if (apkInfo) log(`APK bereit: v${apkInfo.version} (${(fs.statSync(apkInfo.path).size / 1024 / 1024).toFixed(1)}MB)`); }); // ── HTTP Route-Handler ────────────────────────────────────────────── function handleHttpRequest(req, res) { try { const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); const pathname = url.pathname; // OAuth-Callback: GET /oauth/callback/{service}?code=...&state=...&error=... // Pattern fuer Spotify, Google, Strava, GitHub, ... — alle OAuth2 Auth-Code-Flow. // Wir broadcasten an alle Raeume (App ist nicht im selben Raum wie Bridge, // aber Bridge schon — sie picks-up und forwardet ans Brain). const oauthMatch = pathname.match(/^\/oauth\/callback\/([a-zA-Z0-9_-]+)\/?$/); if (req.method === "GET" && oauthMatch) { const service = oauthMatch[1]; const code = url.searchParams.get("code") || ""; const state = url.searchParams.get("state") || ""; const err = url.searchParams.get("error") || ""; const errDesc = url.searchParams.get("error_description") || ""; log(`OAuth-Callback: service=${service} code=${code.slice(0, 8)}... state=${state.slice(0, 8)}... err=${err}`); const payload = { service, code, state }; if (err) { payload.error = err; if (errDesc) payload.errorDescription = errDesc; } // An alle Clients in allen Raeumen broadcasten — Bridge picks-up. const msg = JSON.stringify({ type: "oauth_callback", payload, timestamp: Date.now(), }); let receivers = 0; for (const [, room] of rooms) { for (const client of room.clients) { if (client.readyState === 1) { try { client.send(msg); receivers++; } catch (_) {} } } } log(`OAuth-Callback gebroadcastet an ${receivers} Client(s)`); // Browser-Antwort: schoene HTML-Seite (auch bei Error) const ok = !err; const title = ok ? "OAuth erfolgreich" : "OAuth fehlgeschlagen"; const bodyColor = ok ? "#34C759" : "#FF3B30"; const icon = ok ? "✅" : "❌"; const subtitle = ok ? "Du kannst dieses Tab schliessen — ARIA hat den Zugang erhalten." : `Fehler: ${escapeHtml(err)} ${errDesc ? "— " + escapeHtml(errDesc) : ""}`; const html = ` ${title} — ${escapeHtml(service)}
${icon}
${title}
${escapeHtml(service)}
${subtitle}
Du kannst zur ARIA-App zurueckkehren.
`; res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", }); res.end(html); return; } // Updates als normaler HTTP-Download (statt eines riesigen WS-Blocks): // Cockpit-App unter /update/latest.apk, Host-Agent (Android) unter // /update/agent/latest.apk. Nur mit gueltigem Raum-Token (X-RVS-Token). if ((req.method === "GET" || req.method === "HEAD") && (pathname === "/update/latest.apk" || pathname === "/update/agent/latest.apk")) { const tok = String(req.headers["x-rvs-token"] || ""); if (!tok || !rooms.has(tok)) { res.writeHead(403, { "Content-Type": "text/plain" }); res.end("Forbidden\n"); return; } const isAgent = pathname.startsWith("/update/agent/"); serveApk(req, res, isAgent ? getLatestAPK(AGENT_UPDATES_DIR) : getLatestAPK(), isAgent ? "aria-host-agent" : "ARIA"); return; } // Health-Endpoint if (req.method === "GET" && pathname === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, rooms: rooms.size })); return; } // Default: 404 res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not Found\n"); } catch (e) { log(`HTTP handler error: ${e.message}`); try { res.writeHead(500).end("Internal Server Error"); } catch (_) {} } } function escapeHtml(s) { return String(s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } wss.on("connection", (ws, req) => { // Fuer die Raum-Liste: Herkunft (hinter einem Reverse-Proxy steht die echte // Adresse in X-Forwarded-For) und Programm-Kennung const xff = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim(); ws._ip = (xff || req.socket.remoteAddress || "").replace(/^::ffff:/, ""); ws._ua = String(req.headers["user-agent"] || "").slice(0, 120); // Token aus URL-Query lesen: ws://host:port/?token=abc123 const url = new URL(req.url, `http://${req.headers.host}`); let token = url.searchParams.get("token"); // Wenn kein Token in der URL, auf erste Nachricht warten if (!token) { ws.once("message", (raw) => { try { const msg = JSON.parse(raw); if (msg.token) { registerClient(ws, msg.token); } else { ws.close(4000, "Kein Token angegeben"); } } catch { ws.close(4000, "Ungültige erste Nachricht — Token erwartet"); } }); return; } registerClient(ws, token); }); /** Wer steckt hinter einer Verbindung? Aus Nachrichten, die ohnehin fliessen * (sat_hello, host_hello, worker_hello, update_check der App, Cluster- * Anfragen) oder aus einem expliziten client_hello. Nur zur Anzeige. */ function learnIdentity(ws, msg) { const p = msg.payload || {}; const id = ws._ident; const set = (kind, name, detail, version) => { // client_hello gewinnt; abgeleitete Angaben ueberschreiben es nicht if (id.explicit && msg.type !== "client_hello") return; id.kind = kind; id.name = String(name || "").slice(0, 80); id.detail = String(detail || "").slice(0, 120); if (version) id.version = String(version).slice(0, 40); }; switch (msg.type) { case "client_hello": id.explicit = true; set(p.kind || "Client", p.name, p.detail, p.version); break; case "sat_hello": set("Satellit", p.id, p.location, ""); break; case "host_hello": set("Host-Agent", p.name || p.hostId, p.os, p.version); break; case "worker_hello": { const svc = p.service || "?"; if (!id.services) id.services = new Set(); id.services.add(svc); set("KI-Box", p.node || p.instanceId, [...id.services].join(", ") + (p.gpus ? ` · GPU ${p.gpus}` : ""), ""); break; } case "update_check": if (!id.kind) set("App", "", "", p.version); else if (p.version && !id.version) id.version = String(p.version).slice(0, 40); break; // Nachrichten, die nur die ARIA-App schickt — erkennt auch alte APKs // ohne client_hello (sobald sie irgendetwas davon senden) case "app_log": if (!id.kind && /^(android|ios)$/.test(String(p.platform || ""))) set("App", "", `${p.platform}`, ""); break; case "location_update": case "stt_lease_request": case "update_download": case "vnc_open": if (!id.kind) set("App", "", "", ""); break; case "llm_cluster_request": case "llm_cluster_models_request": if (!id.kind) { const rid = String(p.requestId || ""); set("LLM-Client", rid.includes("-") ? rid.split("-")[0] : "", "", ""); } break; } } function roomClients(ws) { const room = rooms.get(ws._token); const out = []; if (!room) return out; for (const c of room.clients) { if (c.readyState !== 1) continue; const id = c._ident || {}; out.push({ kind: id.kind || "", name: id.name || "", detail: id.detail || "", version: id.version || "", ip: c._ip || "", userAgent: c._ua || "", connectedAt: c._connectedAt || 0, lastSeen: c._lastSeen || 0, you: c === ws, }); } out.sort((a, b) => (a.kind || "~").localeCompare(b.kind || "~") || a.name.localeCompare(b.name)); return out; } function registerClient(ws, token) { // Maximale Anzahl aktiver Sessions prüfen if (!rooms.has(token) && rooms.size >= MAX_SESSIONS) { ws.close(4002, "Maximale Anzahl aktiver Sessions erreicht"); log(`Abgelehnt: Session-Limit (${MAX_SESSIONS}) erreicht`); return; } // Raum erstellen oder betreten if (!rooms.has(token)) { rooms.set(token, { clients: new Set() }); log(`Neuer Raum: ${token.slice(0, 8)}...`); } const room = rooms.get(token); room.clients.add(ws); ws._token = token; ws._ident = {}; ws._connectedAt = Date.now(); ws._lastSeen = ws._connectedAt; log(`Client verbunden: ${token.slice(0, 8)}... (${room.clients.size} im Raum)`); // Nachrichten an alle anderen Clients im selben Raum weiterleiten ws.on("message", (raw) => { let msg; try { msg = JSON.parse(raw); } catch { return; // Keine gültige JSON-Nachricht — ignorieren } // Nur erlaubte Nachrichtentypen durchlassen if (!msg.type || !ALLOWED_TYPES.has(msg.type)) { return; } ws._lastSeen = Date.now(); try { learnIdentity(ws, msg); } catch (_) {} // Vorstellung bleibt beim RVS, Raum-Liste nur an den Fragenden if (msg.type === "client_hello") return; if (msg.type === "room_clients_query") { ws.send(JSON.stringify({ type: "room_clients", payload: { requestId: (msg.payload && msg.payload.requestId) || "", clients: roomClients(ws), }, timestamp: Date.now() })); return; } if (msg.type === "room_clients") return; // kommt nur vom RVS // Update-Check: direkt an den anfragenden Client antworten (nicht relay'en) if (msg.type === "update_check") { const clientVersion = msg.payload?.version || "0.0.0.0"; const apkInfo = getLatestAPK(); if (apkInfo && compareVersions(apkInfo.version, clientVersion) > 0) { ws.send(JSON.stringify({ type: "update_available", payload: { version: apkInfo.version, downloadUrl: `/update/latest.apk`, size: fs.statSync(apkInfo.path).size, }, timestamp: Date.now(), })); } else { // Auch "nichts Neues" beantworten — die App zeigt dann, dass der // Update-Server erreichbar ist und welche Version er hat ws.send(JSON.stringify({ type: "update_none", payload: { latest: apkInfo ? apkInfo.version : "", current: clientVersion }, timestamp: Date.now(), })); } return; } // Host-Agent (Android) fragt nach einem Update — direkt antworten if (msg.type === "agent_update_check") { const have = String(msg.payload?.version || "0.0.0.0"); const apk = getLatestAPK(AGENT_UPDATES_DIR); if (apk && compareVersions(apk.version, have) > 0) { ws.send(JSON.stringify({ type: "agent_update_available", payload: { version: apk.version, downloadUrl: "/update/agent/latest.apk", size: fs.statSync(apk.path).size }, timestamp: Date.now(), })); } return; } // Raum-Diagnose: direkt an den anfragenden Client antworten (nicht relay'en). // Zeigt ALLE Raeume mit token8 (wie im Log), einem laengeren Fingerprint und // der Token-Laenge — so werden Prefix-Kollisionen (2 Raeume, gleicher 8-Zeichen- // Log, aber verschiedene volle Tokens) sofort sichtbar. KEINE vollen Tokens. if (msg.type === "rooms_query") { const crypto = require("crypto"); const out = []; for (const [tok, room] of rooms) { let live = 0; for (const c of room.clients) if (c.readyState === 1) live++; out.push({ token8: tok.slice(0, 8), fp: crypto.createHash("sha256").update(tok).digest("hex").slice(0, 12), len: tok.length, clients: live, you: tok === ws._token, }); } out.sort((a, b) => b.clients - a.clients); ws.send(JSON.stringify({ type: "rooms_info", payload: { rooms: out }, timestamp: Date.now() })); return; } // Update-Download: APK als Base64 ueber WebSocket senden if (msg.type === "update_download") { const apkInfo = getLatestAPK(); if (!apkInfo) { ws.send(JSON.stringify({ type: "update_data", payload: { error: "Keine APK verfuegbar" }, timestamp: Date.now() })); return; } try { const data = fs.readFileSync(apkInfo.path); const base64 = data.toString("base64"); const sizeMB = (data.length / 1024 / 1024).toFixed(1); log(`APK sende: v${apkInfo.version} (${sizeMB}MB) an Client`); ws.send(JSON.stringify({ type: "update_data", payload: { version: apkInfo.version, base64, size: data.length, fileName: `ARIA-v${apkInfo.version}.apk`, }, timestamp: Date.now(), })); } catch (err) { ws.send(JSON.stringify({ type: "update_data", payload: { error: err.message }, timestamp: Date.now() })); } return; } // Anfrage merken, damit die Antwort gleich gezielt zurueckgehen kann rememberRequester(ws, msg); // Gezielte Antwort? Dann NUR an den Fragenden — nicht an den ganzen Raum. let targets = targetedRecipients(msg); // Fragender inzwischen neu verbunden (RVS-/Proxy-Neustart waehrend die // Antwort noch lief): seine alte Verbindung gibt es nicht mehr, die neue // kennen wir nicht. Kleine Antworten (LLM-Antwort, Status) dann an den // Raum — der Fragende erkennt sie an der requestId. Grosse (Dateien) // bleiben verworfen, sonst gingen hunderte MB an alle. if (targets && targets.length === 0 && raw.length <= REJOIN_BROADCAST_MAX) { log(`${msg.type}: Fragender neu verbunden — ${(raw.length / 1024).toFixed(0)}KB an den Raum`); targets = null; } if (targets) { const data = raw.toString(); let sent = 0; for (const client of targets) { if (client !== ws) { client.send(data); sent++; } } // Grosse Payloads mitloggen: hier spart der RVS am meisten ein, und // wenn mal eine Antwort verschwindet will man genau das hier sehen. if (raw.length > 4 * 1024 * 1024 || sent === 0) { const mb = (raw.length / 1024 / 1024).toFixed(1); log(sent === 0 ? `${msg.type}: Fragender nicht mehr verbunden — ${mb}MB verworfen` : `${msg.type}: ${mb}MB gezielt an ${sent} Client(s) statt an ${room.clients.size - 1}`); } return; } // An alle anderen Clients im Raum weiterleiten for (const client of room.clients) { if (client !== ws && client.readyState === 1) { client.send(raw.toString()); } } }); ws.on("close", () => { room.clients.delete(ws); for (const [key, entry] of pendingReplies) { if (entry.clients.delete(ws) && entry.clients.size === 0) pendingReplies.delete(key); } log(`Client getrennt: ${token.slice(0, 8)}... (${room.clients.size} verbleibend)`); if (room.clients.size === 0) { rooms.delete(token); log(`Raum geschlossen: ${token.slice(0, 8)}...`); } }); ws.on("error", (err) => { log(`Fehler: ${err.message}`); room.clients.delete(ws); }); } // ── Heartbeat — hält Verbindungen am Leben, räumt tote auf ────────── const HEARTBEAT_INTERVAL = 15_000; const heartbeat = setInterval(() => { for (const client of wss.clients) { if (client.isAlive === false) { log(`Toter Client entfernt (kein Pong)`); client.terminate(); continue; } client.isAlive = false; client.ping(); } }, HEARTBEAT_INTERVAL); wss.on("connection", (ws) => { ws.isAlive = true; ws.on("pong", () => { ws.isAlive = true; }); // App-seitiger Heartbeat (JSON) zaehlt auch als lebendig const origOnMessage = ws._events?.message; ws.on("message", (raw) => { try { const msg = JSON.parse(raw); if (msg.type === "heartbeat") ws.isAlive = true; } catch {} }); }); // Aufräumen alle 30 Sekunden (statt 60) const cleanup = setInterval(cleanupRooms, 30_000); wss.on("close", () => { clearInterval(heartbeat); clearInterval(cleanup); }); // ── Auto-Update: APK-Erkennung + Push ────────────────────────────── let latestVersion = null; /** APK per HTTP ausliefern: Content-Length (Fortschritt), Range (Fortsetzen). */ function serveApk(req, res, apk, label) { if (!apk) { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Keine APK verfuegbar\n"); return; } const size = fs.statSync(apk.path).size; const headers = { "Content-Type": "application/vnd.android.package-archive", "Content-Disposition": `attachment; filename="${label}-v${apk.version}.apk"`, "Accept-Ranges": "bytes", "Cache-Control": "no-store", "X-APK-Version": apk.version, }; let start = 0, end = size - 1, status = 200; const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || "")); if (m && (m[1] || m[2])) { if (m[1]) { start = parseInt(m[1], 10); if (m[2]) end = Math.min(parseInt(m[2], 10), size - 1); } else { start = Math.max(0, size - parseInt(m[2], 10)); } if (start > end || start >= size) { res.writeHead(416, { "Content-Range": `bytes */${size}` }); res.end(); return; } status = 206; headers["Content-Range"] = `bytes ${start}-${end}/${size}`; } headers["Content-Length"] = end - start + 1; res.writeHead(status, headers); if (req.method === "HEAD") { res.end(); return; } log(`APK-Download (HTTP) ${label} v${apk.version}${status === 206 ? ` ab Byte ${start}` : ""}`); fs.createReadStream(apk.path, { start, end }).pipe(res); } function getLatestAPK(dir = UPDATES_DIR) { try { if (!fs.existsSync(dir)) return null; const files = fs.readdirSync(dir) .filter(f => f.endsWith(".apk")) .map(f => { // ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk const match = f.match(/(\d+(?:\.\d+){2,})/); return { file: f, path: path.join(dir, f), version: match ? match[1] : null }; }) .filter(f => f.version) .sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst return files[0] || null; } catch { return null; } } function compareVersions(a, b) { const pa = a.split(".").map(Number); const pb = b.split(".").map(Number); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const diff = (pa[i] || 0) - (pb[i] || 0); if (diff !== 0) return diff; } return 0; } function notifyClientsAboutUpdate(apkInfo) { const msg = JSON.stringify({ type: "update_available", payload: { version: apkInfo.version, downloadUrl: `/update/latest.apk`, size: fs.statSync(apkInfo.path).size, }, timestamp: Date.now(), }); // An alle Clients in allen Rooms senden for (const [, room] of rooms) { for (const client of room.clients) { if (client.readyState === 1) { client.send(msg); } } } log(`Update-Benachrichtigung gesendet: v${apkInfo.version} (${rooms.size} Raum/Raeume)`); } // Kein Polling — Update-Check passiert on-demand (update_check Message von App) // ── Sauberes Herunterfahren ───────────────────────────────────────── process.on("SIGTERM", () => { log("SIGTERM empfangen — fahre herunter"); wss.close(() => process.exit(0)); }); process.on("SIGINT", () => { log("SIGINT empfangen — fahre herunter"); wss.close(() => process.exit(0)); });