feat(diagnostic): Liste, wer gerade mit dem RVS-Raum verbunden ist

Einstellungen, zwischen Runtime-Konfiguration und App-Onboarding: alle
Verbindungen im Raum des konfigurierten Tokens — Art, Name, Details,
Adresse, verbunden seit, zuletzt aktiv.

RVS merkt sich pro Verbindung, wer sie ist: aus Nachrichten, die ohnehin
fliessen (sat_hello, host_hello, worker_hello, update_check der App,
llm_cluster_*), oder aus dem neuen client_hello (Bridge, Diagnostic, App
mit Geraetename). Adresse aus X-Forwarded-For hinter einem Reverse-Proxy.
room_clients_query liefert NUR den eigenen Raum; room_clients von Clients
wird nicht weitergereicht. Typen in der Allow-List ergaenzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 12:34:14 +01:00
co-authored by Claude Opus 5.5
parent 2267f0a726
commit 7b4a1f4429
5 changed files with 188 additions and 1 deletions
+87
View File
@@ -145,6 +145,9 @@ const ALLOWED_TYPES = new Set([
// (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",
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
"node_stats_history_request", "node_stats_history",
"node_stats_reset", "node_stats_reset_done",
@@ -422,6 +425,11 @@ function escapeHtml(s) {
}
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");
@@ -446,6 +454,70 @@ wss.on("connection", (ws, req) => {
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;
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) {
@@ -463,6 +535,9 @@ function registerClient(ws, token) {
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)`);
@@ -479,6 +554,18 @@ function registerClient(ws, token) {
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") {