Compare commits

..
7 Commits
Author SHA1 Message Date
duffyduck baf9e93c2a release: bump version to 0.2.5.8 2026-10-25 12:55:10 +01:00
duffyduckandClaude Opus 5.5 db2055ff3b fix: alte App-Versionen in der Raum-Liste erkennen + HF-Token aus zwei weiteren Log-Zeilen
RVS: die ARIA-App wird auch ohne client_hello erkannt, sobald sie eine
Nachricht schickt, die nur sie sendet (app_log mit platform android/ios,
location_update, stt_lease_request, update_download, vnc_open).

Bridge: "Voice-Config geladen" und "Voice-Config gespeichert" schrieben
den huggingfaceToken noch im Klartext ins Log — jetzt ***.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:52:36 +01:00
duffyduckandClaude Opus 5.5 5a59ea3930 fix(xtts): Flotte zeigt die echte Karte statt "GPU void"
Das NVIDIA-Container-Toolkit ersetzt NVIDIA_VISIBLE_DEVICES im Prozess durch
"void" — whisper/f5tts/voxtral meldeten das als GPU. Die Compose gibt die
Karte jetzt zusaetzlich als ARIA_GPU mit (nur Anzeige).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:51:41 +01:00
duffyduckandClaude Opus 5.5 7b4a1f4429 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>
2026-10-25 12:34:14 +01:00
duffyduckandClaude Opus 5.5 2267f0a726 docs(rvs): WebSocket-Weiterleitung beim Reverse-Proxy deutlich markieren + Testbefehl
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:29:30 +01:00
duffyduckandClaude Opus 5.5 8cc85c1fe1 feat(rvs): interner Port optional verschluesselt (RVS_HTTP_TLS)
Mit RVS_HTTP_TLS=true spricht der RVS auf RVS_HTTP_PORT HTTPS/WSS statt
Klartext — damit ist auch die Strecke externer Proxy → RVS verschluesselt.
Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes,
das beim ersten Start in data/rvs-tls/ erzeugt wird (eigenes darf dort
liegen). Image bekommt dafuer openssl. Nur ohne den Caddy im Stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:23:24 +01:00
duffyduckandClaude Opus 5.5 88036bcad3 feat(rvs): Caddy optional per COMPOSE_PROFILES — oder eigener Reverse-Proxy
Neu in rvs/.env: COMPOSE_PROFILES=caddy (wie bisher, Caddy macht TLS) oder
leer (ohne Caddy). Der RVS bietet dafuer immer einen unverschluesselten Port
an (RVS_HTTP_BIND:RVS_HTTP_PORT, Default 127.0.0.1:3000), auf den ein
vorhandener nginx/Traefik zeigt — statt nginx → Caddy → RVS mit doppeltem TLS.
Caddys HTTPS-Port ist jetzt CADDY_HTTPS_PORT (Default 444 wie bisher).

README: beide Modi, nginx-Vorlage mit WebSocket-Headern und IPv6, Hinweis
dass Caddy fuer die Zertifikats-Erneuerung Port 80 von aussen braucht.
Bestehende Installationen mit Caddy muessen COMPOSE_PROFILES=caddy eintragen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:22:11 +01:00
15 changed files with 384 additions and 39 deletions
+84 -9
View File
@@ -825,17 +825,31 @@ Wer sich mit dem gleichen Token verbindet, landet im gleichen Room.
```bash
cd rvs
cp .env.example .env # PUBLIC_URL eintragen (Domain die auf den Server zeigt)
cp .env.example .env # Modus waehlen (COMPOSE_PROFILES) + Werte eintragen
docker compose up -d
```
**Zwei Modi** — in der `rvs/.env` per `COMPOSE_PROFILES`:
| Modus | `.env` | Wer macht TLS | Apps/Satelliten verbinden auf |
|---|---|---|---|
| **mit Caddy** | `COMPOSE_PROFILES=caddy` + `PUBLIC_URL` | Caddy im Stack (Let's Encrypt) | `PUBLIC_URL:CADDY_HTTPS_PORT` |
| **ohne Caddy** | `COMPOSE_PROFILES=` (leer) | dein Reverse-Proxy (nginx, Traefik …) | Name + Port deines Proxys (meist 443) |
**Stack:**
- `caddy` (TLS-Terminator + Let's Encrypt, lauscht auf 80+443)
- `rvs` (WebSocket Relay + OAuth-Callback HTTP, nur intern auf Port 3000)
- `rvs` (WebSocket Relay + OAuth-Callback HTTP). Unverschluesselter Port
`RVS_HTTP_BIND:RVS_HTTP_PORT` (Default `127.0.0.1:3000`) — fuer den Reverse-Proxy.
- `caddy` (nur Modus Caddy: TLS + Let's Encrypt auf 80 + `CADDY_HTTPS_PORT`)
Caddy holt automatisch ein Zertifikat fuer `PUBLIC_URL` via HTTP-01-Challenge.
ACME-State persistent in `./data/caddy/` (gitignored) — kein Rate-Limit-Drama
bei Container-Restart. WebSocket-Upgrades reicht Caddy transparent durch.
Dafuer muss **Port 80 von aussen auf diesen Rechner** gehen — sonst schlaegt die
Erneuerung still fehl und das Zertifikat laeuft nach 90 Tagen ab (Apps und
Satelliten verbinden dann nicht mehr). ACME-State persistent in `./data/caddy/`
(gitignored). WebSocket-Upgrades reicht Caddy transparent durch.
> **Bestehende Installation aktualisieren:** Caddy startet nur noch mit
> `COMPOSE_PROFILES=caddy` in der `rvs/.env`. Wer Caddy weiter nutzt, traegt das
> dort ein (sonst laeuft nach `git pull` + `up -d` kein TLS mehr).
**Features:**
- WebSocket Relay (alle Message-Types: chat, audio, file, config, xtts, update, etc.)
@@ -853,10 +867,71 @@ cp ARIA-v0.0.3.0.apk ~/ARIA-AGENT/rvs/updates/
**Multi-Instanz:** Mehrere ARIA-VMs koennen denselben RVS nutzen — jede mit eigenem Token.
**Ohne Caddy / eigener TLS-Terminator:** Wenn Du schon einen Reverse-Proxy
(nginx/Traefik) davor hast, kommentier den `caddy`-Service in der
`rvs/docker-compose.yml` aus und gib `rvs` wieder einen `ports`-Block
(z.B. `["3000:3000"]`). Dein Reverse-Proxy macht dann TLS und reicht weiter.
**Ohne Caddy — eigener Reverse-Proxy:** in der `rvs/.env`
```bash
COMPOSE_PROFILES=
RVS_HTTP_BIND=0.0.0.0 # Proxy auf anderem Rechner; gleicher Rechner: 127.0.0.1
RVS_HTTP_PORT=3000
```
Steht der Proxy auf einem anderen Rechner, den Port per Firewall **nur fuer den
Proxy** freigeben.
**Strecke Proxy → RVS verschluesseln:** `RVS_HTTP_TLS=true` — der RVS spricht
dann HTTPS auf `RVS_HTTP_PORT`, mit einem selbstsignierten Zertifikat (beim
ersten Start in `rvs/data/rvs-tls/` erzeugt; eigenes `rvs.crt`/`rvs.key` darf
dort liegen). Im Proxy als Ziel `https://<ip>:<port>`, das Zertifikat dabei
**nicht** pruefen (nginx: Default; Nginx Proxy Manager: Scheme `https`,
„Websockets Support" an). Nur ohne Caddy — der spricht den RVS per HTTP an.
Umstellen von Caddy:
`docker compose rm -sf caddy && docker compose up -d`.
> ⚠️ **WebSocket nicht vergessen!** Der RVS ist fast nur WebSocket. Ohne
> WebSocket-Weiterleitung laedt im Browser zwar eine Seite (404 o.ae.), aber
> App, Bridge, Satelliten und KI-Boxen bekommen **keine Verbindung** — ohne
> klare Fehlermeldung. Beim Neueinrichten des Proxys immer pruefen:
>
> - **Nginx Proxy Manager:** im Proxy Host den Schalter **„Websockets Support"**
> einschalten (bei `RVS_HTTP_TLS=true` zusaetzlich Scheme **`https`**).
> - **nginx von Hand:** `proxy_http_version 1.1;` plus die beiden Header
> `Upgrade` / `Connection "upgrade"` (siehe Beispiel unten) und lange
> Timeouts — sonst trennt nginx die Verbindung nach 60 s Stille.
> - **Test:** `curl -i -H "Connection: Upgrade" -H "Upgrade: websocket"
> -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGVzdHRlc3R0ZXN0dGVzdA=="
> "https://<dein-name>/?token=test"` muss mit **`101 Switching Protocols`**
> antworten. Kommt 200/400/404, fehlt die WebSocket-Weiterleitung.
nginx-Beispiel (WebSocket braucht HTTP/1.1 + Upgrade-Header und lange Timeouts):
```nginx
server {
listen 443 ssl;
listen [::]:443 ssl; # Handys nehmen bevorzugt IPv6 — mit AAAA-Eintrag Pflicht
server_name rvs.example.de;
ssl_certificate /etc/letsencrypt/live/rvs.example.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rvs.example.de/privkey.pem;
location / {
proxy_pass http://192.168.1.10:3000; # RVS_HTTP_BIND-IP : RVS_HTTP_PORT (https:// bei RVS_HTTP_TLS=true)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 3600s; # WebSocket bleibt lange offen
proxy_send_timeout 3600s;
proxy_buffering off;
client_max_body_size 0; # APK-Download, Uploads
}
}
```
Apps, Satelliten, KI-Boxen, Host-Agenten und die Bridge (`RVS_HOST`/`RVS_PORT`
in deren `.env`) dann auf den Namen und Port des Proxys umstellen.
---
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20507
versionName "0.2.5.7"
versionCode 20508
versionName "0.2.5.8"
// 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.5.7",
"version": "0.2.5.8",
"private": true,
"scripts": {
"android": "react-native run-android",
+14 -1
View File
@@ -7,7 +7,7 @@
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NativeModules } from 'react-native';
import { NativeModules, Platform } from 'react-native';
const TlsTrust: any = NativeModules.TlsTrust;
@@ -247,6 +247,7 @@ class RVSConnection {
this.log('info', `Verbunden${tlsInfo}`);
this.setState('connected');
this.updateSecurityOnOpen(useTLS);
this.sendClientHello();
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
this.startHeartbeat();
};
@@ -323,6 +324,18 @@ class RVSConnection {
}
}
/** Beim RVS vorstellen — Diagnostic zeigt so, welches Handy im Raum ist. */
private sendClientHello(): void {
try {
const c: any = (Platform as any).constants || {};
const device = [c.Manufacturer, c.Model].filter(Boolean).join(' ') || Platform.OS;
let version = '';
try { version = require('../../package.json').version || ''; } catch (_) {}
this.send('client_hello', { kind: 'App', name: device,
detail: `Android ${c.Release || Platform.Version}`, version });
} catch (_) {}
}
// --- Verbindungs-Sicherheit (Schloss-Symbol) ---
getSecurity(): SecurityState {
+9 -2
View File
@@ -720,7 +720,7 @@ class ARIABridge:
logger.info("Voice-Config geladen: tts=%s voice=%s f5tts=%s flux=%s",
self.tts_enabled, self.xtts_voice or "default",
self._f5tts_config or "defaults",
self._flux_config or "defaults")
_redact(self._flux_config) or "defaults")
except Exception as e:
logger.warning("Voice-Config laden fehlgeschlagen: %s", e)
# Whisper-Modell: Config hat Vorrang, dann env/Default (medium)
@@ -2272,6 +2272,13 @@ class ARIABridge:
self.ws_rvs = ws
retry_delay = 2
logger.info("[rvs] Verbunden — warte auf App-Nachrichten")
# Beim RVS vorstellen (Diagnostic → "Wer ist im Raum?")
try:
await ws.send(json.dumps({"type": "client_hello", "payload": {
"kind": "Bridge", "name": socket.gethostname(),
"detail": "ARIA-Bridge"}, "timestamp": int(time.time() * 1000)}))
except Exception:
pass
# TCP-Keepalive auf dem unterliegenden Socket aktivieren —
# damit NAT-Tabellen-Verfall oder "halb-tote" Verbindungen
@@ -2727,7 +2734,7 @@ class ARIABridge:
config_data.update(getattr(self, "_debug_log_config", {}))
with open("/shared/config/voice_config.json", "w") as f:
json.dump(config_data, f, indent=2)
logger.info("[rvs] Voice-Config gespeichert: %s", config_data)
logger.info("[rvs] Voice-Config gespeichert: %s", _redact(config_data))
except Exception as e:
logger.warning("[rvs] Config speichern fehlgeschlagen: %s", e)
return
+64
View File
@@ -1388,6 +1388,22 @@
</div>
</div>
<!-- Wer ist im Raum? -->
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h2 style="margin:0;">Verbunden mit diesem Raum</h2>
<button class="btn secondary" onclick="requestRoomClients()" style="padding:4px 12px;">Aktualisieren</button>
</div>
<div style="font-size:11px;color:#8888AA;margin:6px 0 8px;">
Alle Verbindungen, die gerade mit dem RVS-Token oben im selben Raum sind
(so wie der RVS sie sieht). Die Art erkennt der RVS an der Vorstellung der
Clients — „unbekannt" ist ein Programm, das sich (noch) nicht vorgestellt hat.
</div>
<div class="card" style="max-width:900px;">
<div id="room-clients" style="font-size:12px;color:#8888AA;">(Aktualisieren klicken)</div>
</div>
</div>
<!-- App-Onboarding via QR-Code -->
<div class="settings-section">
<h2>App-Onboarding (QR-Code)</h2>
@@ -2573,6 +2589,7 @@
return;
}
if (msg.type === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; }
if (msg.type === 'room_clients') { renderRoomClients(msg.payload || {}); return; }
if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); return; }
if (msg.type === 'sat_result') { onSatResult(msg.payload || {}); return; }
if (msg.type === 'llm_openai_config_result') { onOpenAiConfigResult(msg.payload || {}); return; }
@@ -5134,6 +5151,7 @@
if (tab === 'settings') {
send({ action: 'get_voice_config' });
loadRuntimeConfig();
requestRoomClients();
loadOnboardingQR();
loadOAuthServices();
loadJellyfinConfig();
@@ -5359,6 +5377,52 @@
function requestSatCreds(id) {
send({ action: 'sat_creds_list', satellite: id });
}
// ── Wer ist im Raum? ─────────────────────────
function requestRoomClients() {
const box = document.getElementById('room-clients');
if (box && !box.dataset.loaded) box.textContent = 'Frage den RVS …';
send({ action: 'room_clients_query' });
}
function _ago(ms) {
if (!ms) return '–';
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
if (s < 60) return s + ' s';
if (s < 3600) return Math.round(s / 60) + ' min';
if (s < 86400) return Math.round(s / 3600) + ' h';
return Math.round(s / 86400) + ' d';
}
const ROOM_KIND_ICON = { 'Bridge': '🌉', 'Diagnostic': '🩺', 'App': '📱', 'Satellit': '🛰️',
'Host-Agent': '🖥️', 'KI-Box': '🧠', 'LLM-Client': '🤖' };
function renderRoomClients(p) {
const box = document.getElementById('room-clients');
if (!box) return;
box.dataset.loaded = '1';
if (p.error) { box.innerHTML = '<span style="color:#FF6B6B;">' + escapeHtml(p.error) + '</span>'; return; }
const list = p.clients || [];
if (!list.length) { box.innerHTML = '<span style="color:#8888AA;">(niemand — oder der RVS kennt die Abfrage noch nicht: RVS aktualisieren)</span>'; return; }
const td = 'padding:5px 8px;border-top:1px solid #1E1E2E;vertical-align:top;';
box.innerHTML = '<div style="color:#E0E0F0;margin-bottom:6px;">' + list.length + ' Verbindung' + (list.length === 1 ? '' : 'en') + '</div>' +
'<div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;font-size:12px;">' +
'<tr style="color:#8888AA;text-align:left;"><th style="padding:4px 8px;">Art</th><th style="padding:4px 8px;">Name</th>' +
'<th style="padding:4px 8px;">Details</th><th style="padding:4px 8px;">Adresse</th>' +
'<th style="padding:4px 8px;">verbunden seit</th><th style="padding:4px 8px;">zuletzt aktiv</th></tr>' +
list.map(c => {
const kind = c.kind || '';
const icon = ROOM_KIND_ICON[kind] || '❔';
const kindTxt = kind ? escapeHtml(kind) : '<span style="color:#8888AA;">unbekannt</span>';
const you = c.you ? ' <span style="color:#0096FF;">★ diese Diagnostic</span>' : '';
const details = [c.detail, c.version ? 'v' + c.version : ''].filter(Boolean).join(' · ')
|| (!kind && c.userAgent ? c.userAgent : '');
return '<tr>' +
'<td style="' + td + 'white-space:nowrap;">' + icon + ' ' + kindTxt + '</td>' +
'<td style="' + td + 'color:#E0E0F0;">' + escapeHtml(c.name || '–') + you + '</td>' +
'<td style="' + td + 'color:#8888AA;">' + escapeHtml(details || '–') + '</td>' +
'<td style="' + td + 'color:#8888AA;font-family:monospace;">' + escapeHtml(c.ip || '–') + '</td>' +
'<td style="' + td + 'color:#8888AA;white-space:nowrap;">' + _ago(c.connectedAt) + '</td>' +
'<td style="' + td + 'color:#8888AA;white-space:nowrap;">' + _ago(c.lastSeen) + '</td></tr>';
}).join('') + '</table></div>';
}
let rooms = []; // [{token8, fp, len, clients, you}]
function requestRooms() { send({ action: 'rooms_query' }); }
function renderRooms() {
+16
View File
@@ -1216,6 +1216,12 @@ function connectRVS(forcePlain) {
state.rvs.lastError = null;
rvsWs = ws;
broadcastState();
// Beim RVS vorstellen (Liste "Wer ist im Raum?")
try {
ws.send(JSON.stringify({ type: "client_hello", payload: {
kind: "Diagnostic", name: require("os").hostname(), detail: "ARIA-Diagnostic" },
timestamp: Date.now() }));
} catch (_) {}
// Keepalive: alle 25s ein Ping senden damit die Verbindung nicht stirbt
const keepalive = setInterval(() => {
@@ -1294,6 +1300,9 @@ function connectRVS(forcePlain) {
} else if (msg.type === "host_ping") {
const p = msg.payload || {};
if (p.hostId && hosts.has(p.hostId)) hosts.get(p.hostId).last_seen = Date.now();
} else if (msg.type === "room_clients") {
// Antwort des RVS: wer ist in unserem Raum → an den Browser
broadcast({ type: "room_clients", payload: msg.payload || {} });
} else if (msg.type === "rooms_info") {
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
broadcast({ type: "rooms_info", payload: msg.payload || {} });
@@ -3181,6 +3190,13 @@ wss.on("connection", (ws) => {
log("info", "host", `Agent '${id}' aus der Liste entfernt`);
broadcastHosts();
}
} else if (msg.action === "room_clients_query") {
// Nur ueber die feste Verbindung — die Antwort kommt auf ihr zurueck
if (rvsWs && rvsWs.readyState === WebSocket.OPEN) {
rvsWs.send(JSON.stringify({ type: "room_clients_query", payload: {}, timestamp: Date.now() }));
} else {
ws.send(JSON.stringify({ type: "room_clients", payload: { error: "Diagnostic ist nicht mit dem RVS verbunden", clients: [] } }));
}
} else if (msg.action === "rooms_query") {
// Browser will die RVS-Raum-Diagnose — via persistente rvsWs anfragen,
// die Antwort (rooms_info) kommt auf derselben Verbindung zurueck.
+25 -4
View File
@@ -3,12 +3,33 @@
# Kopieren nach .env und Werte eintragen.
# ════════════════════════════════════════════════════════
# ── Wer macht TLS? ──────────────────────────────────────
# caddy = Caddy hier im Stack holt das Zertifikat selbst (Let's Encrypt)
# (leer) = OHNE Caddy: ein vorhandener Reverse-Proxy (nginx, Traefik ...)
# macht TLS und zeigt auf den RVS-Port unten
COMPOSE_PROFILES=caddy
# ── Modus caddy ─────────────────────────────────────────
# Oeffentlich erreichbarer DNS-Name dieses Servers. Caddy holt darauf ein
# Let's Encrypt-Zertifikat (HTTP-01 Challenge ueber Port 80) und routet
# WebSocket + HTTP weiter an den RVS-Container.
#
# WICHTIG:
# - Die Domain muss per DNS-A-Record/AAAA auf diese Maschine zeigen
# - Port 80 + 443 muessen vom Internet aus erreichbar sein
# - Kein anderer Reverse-Proxy davor (sonst Cert-Konflikt)
# - Port 80 muss von aussen HIERHER gehen (sonst laeuft das Zertifikat
# nach 90 Tagen still ab — Fritzbox: Freigabe auf 80 pruefen!)
PUBLIC_URL=rvs.example.de
# Port, auf dem Caddy HTTPS anbietet (Apps/Satelliten: dieser Port)
CADDY_HTTPS_PORT=444
# ── Modus ohne Caddy ────────────────────────────────────
# Unverschluesselter RVS-Port, auf den der Reverse-Proxy zeigt.
# 127.0.0.1 = nur von diesem Rechner. Steht der Proxy woanders:
# 0.0.0.0 bzw. die interne IP — und den Port per Firewall NUR fuer
# den Proxy freigeben (Klartext!).
RVS_HTTP_BIND=127.0.0.1
RVS_HTTP_PORT=3000
# Auch die Strecke Proxy → RVS verschluesseln: der RVS spricht dann HTTPS
# auf RVS_HTTP_PORT, mit selbstsigniertem Zertifikat (wird beim ersten Start
# in ./data/rvs-tls/ erzeugt; eigenes rvs.crt/rvs.key darf dort liegen).
# Im Proxy dann https://<ip>:<port> als Ziel, Zertifikat NICHT pruefen.
# Nur ohne Caddy — der Caddy im Stack spricht den RVS per HTTP an.
RVS_HTTP_TLS=false
+3
View File
@@ -2,6 +2,9 @@ FROM node:22-alpine
WORKDIR /app
# openssl: erzeugt bei RVS_HTTP_TLS=true das interne Zertifikat
RUN apk add --no-cache openssl
# Erst package.json kopieren — Docker-Cache für npm install nutzen
COPY package.json ./
RUN npm install --production
+22 -15
View File
@@ -1,30 +1,36 @@
# ════════════════════════════════════════════════════════
# ARIA RVS Stack — WebSocket Relay + OAuth Callback HTTP
# Caddy davor terminiert TLS via Let's Encrypt (HTTP-01
# Challenge ueber Port 80). OAuth-Provider wie Spotify
# verlangen HTTPS fuer non-localhost Redirect-URIs.
# Apps/Satelliten/Boxen brauchen TLS (wss://), OAuth-Provider
# wie Spotify HTTPS. Wer TLS macht, waehlt die .env:
#
# A) COMPOSE_PROFILES=caddy — Caddy hier im Stack holt das
# Zertifikat selbst (Let's Encrypt, HTTP-01 ueber Port 80).
# Voraussetzung: Port 80 + CADDY_HTTPS_PORT von aussen auf
# diese Maschine, PUBLIC_URL zeigt per DNS hierher.
#
# B) COMPOSE_PROFILES= — OHNE Caddy. Ein vorhandener
# Reverse-Proxy (nginx, Traefik, ...) macht TLS und zeigt auf
# den unverschluesselten RVS-Port (RVS_HTTP_BIND:RVS_HTTP_PORT).
# nginx-Beispiel: README → RVS → "Ohne Caddy".
# ════════════════════════════════════════════════════════
#
# Voraussetzungen:
# - Port 80 + 443 frei (kein anderer Reverse-Proxy davor)
# - Domain (PUBLIC_URL) zeigt per DNS auf diese Maschine
# - .env mit PUBLIC_URL gesetzt
#
# Start: docker compose up -d
# Wenn Du einen eigenen TLS-Terminator nutzt (z.B. nginx,
# externer Caddy): caddy-service auskommentieren und
# rvs-Container den ports-Block geben (3000 → public Port).
services:
rvs:
build: .
restart: always
# KEIN ports-Block — Caddy ist davor, RVS nur intern
# via aria-rvs-net erreichbar. Wenn Du Caddy nicht nutzt,
# diesen ports-Block reaktivieren: ports: ["${RVS_PORT:-443}:3000"]
# Unverschluesselter RVS-Port fuer einen externen Reverse-Proxy (Modus B).
# Default nur 127.0.0.1 — steht der Proxy auf einem ANDEREN Rechner,
# RVS_HTTP_BIND=0.0.0.0 (oder die interne IP) setzen und den Port per
# Firewall nur fuer den Proxy freigeben: hier laeuft alles im Klartext.
ports:
- "${RVS_HTTP_BIND:-127.0.0.1}:${RVS_HTTP_PORT:-3000}:3000"
volumes:
- ./updates:/updates # APK-Dateien fuer Auto-Update
- ./data/rvs-tls:/certs # internes Zertifikat (RVS_HTTP_TLS)
environment:
- RVS_HTTP_TLS=${RVS_HTTP_TLS:-false}
- MAX_SESSIONS=10
# 4 GB V8-Heap — sonst OOM beim Empfang von 1 GB-Files
# (base64 inflated ~1.34 GB plus WS-Frame-Margin).
@@ -39,10 +45,11 @@ services:
# damit Restart nicht jedes Mal ein neues Cert holt (Rate-Limit!).
caddy:
image: caddy:latest
profiles: ["caddy"] # nur mit COMPOSE_PROFILES=caddy
restart: always
ports:
- "80:80"
- "444:443"
- "${CADDY_HTTPS_PORT:-444}:443"
command: caddy reverse-proxy --from ${PUBLIC_URL} --to rvs:3000
volumes:
- ./data/caddy/data:/data # Zertifikate (PERSISTENT)
+126 -2
View File
@@ -2,6 +2,8 @@
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");
@@ -9,6 +11,12 @@ const path = require("path");
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";
// 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
@@ -137,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",
@@ -274,7 +285,25 @@ function cleanupRooms() {
// 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.
const httpServer = http.createServer(handleHttpRequest);
/** 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
@@ -285,7 +314,7 @@ httpServer.on("upgrade", (req, socket, head) => {
});
httpServer.listen(PORT, () => {
log(`RVS läuft auf Port ${PORT} (HTTP + WS) | Max Sessions: ${MAX_SESSIONS}`);
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)`);
@@ -396,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");
@@ -420,6 +454,81 @@ 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;
// 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) {
@@ -437,6 +546,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)`);
@@ -453,6 +565,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") {
+3
View File
@@ -43,6 +43,7 @@ services:
environment:
# GPU-Wahl: NVIDIA_VISIBLE_DEVICES (mehrere via "0,1"). Default GPU 0.
- NVIDIA_VISIBLE_DEVICES=${F5TTS_GPU:-0}
- ARIA_GPU=${F5TTS_GPU:-0} # nur Anzeige (Toolkit macht aus NVIDIA_VISIBLE_DEVICES "void")
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NODE_NAME=${NODE_NAME:-node} # erscheint in Diagnostic + Logs
# Bootstrap-only — alle anderen F5-TTS-Settings (Modell, cfg_strength,
@@ -70,6 +71,7 @@ services:
runtime: nvidia
environment:
- NVIDIA_VISIBLE_DEVICES=${WHISPER_GPU:-1}
- ARIA_GPU=${WHISPER_GPU:-1} # nur Anzeige (Toolkit macht aus NVIDIA_VISIBLE_DEVICES "void")
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NODE_NAME=${NODE_NAME:-node}
- RVS_HOST=${RVS_HOST}
@@ -105,6 +107,7 @@ services:
- ./voice-id:/voice-id # Speaker-Fingerprint (wie whisper)
environment:
- NVIDIA_VISIBLE_DEVICES=${VOXTRAL_GPU:-1} # STT-3B ~9 GB → 12-GB-Karte
- ARIA_GPU=${VOXTRAL_GPU:-1} # nur Anzeige (Toolkit macht aus NVIDIA_VISIBLE_DEVICES "void")
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- NODE_NAME=${NODE_NAME:-node}
- RVS_HOST=${RVS_HOST}
+5 -1
View File
@@ -78,7 +78,11 @@ F5TTS_DEVICE = os.getenv("F5TTS_DEVICE", "cuda") # nur Bootstrap
# ── Compute-Fleet: Worker-Identitaet & Registrierung ──────────────
# Meldet sich bei der aria-bridge (worker_hello) + periodischer worker_ping.
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
# Welche Karte(n) dieser Dienst nutzt — fuer die Anzeige. ARIA_GPU setzt die
# Compose; NVIDIA_VISIBLE_DEVICES ersetzt das Container-Toolkit im Prozess
# durch "void", taugt also nicht zur Anzeige.
GPU_IDS = (os.getenv("ARIA_GPU", "").strip()
or os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip().replace("void", ""))
WORKER_SERVICE = "f5tts"
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
+5 -1
View File
@@ -78,7 +78,11 @@ VOXTRAL_DEVICE = os.getenv("VOXTRAL_DEVICE", "cuda")
# Registry per periodischem worker_ping frisch. INSTANCE_ID adressiert diesen
# Worker bei Redundanz (targetInstance-Routing, Stage 3).
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
# Welche Karte(n) dieser Dienst nutzt — fuer die Anzeige. ARIA_GPU setzt die
# Compose; NVIDIA_VISIBLE_DEVICES ersetzt das Container-Toolkit im Prozess
# durch "void", taugt also nicht zur Anzeige.
GPU_IDS = (os.getenv("ARIA_GPU", "").strip()
or os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip().replace("void", ""))
WORKER_SERVICE = "voxtral"
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))
+5 -1
View File
@@ -77,7 +77,11 @@ WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "de")
# ── Compute-Fleet: Worker-Identitaet & Registrierung ──────────────
# Meldet sich bei der aria-bridge (worker_hello) + periodischer worker_ping.
NODE_NAME = os.getenv("NODE_NAME", "node").strip() or "node"
GPU_IDS = os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip()
# Welche Karte(n) dieser Dienst nutzt — fuer die Anzeige. ARIA_GPU setzt die
# Compose; NVIDIA_VISIBLE_DEVICES ersetzt das Container-Toolkit im Prozess
# durch "void", taugt also nicht zur Anzeige.
GPU_IDS = (os.getenv("ARIA_GPU", "").strip()
or os.getenv("NVIDIA_VISIBLE_DEVICES", "").strip().replace("void", ""))
WORKER_SERVICE = "whisper"
INSTANCE_ID = f"{WORKER_SERVICE}@{NODE_NAME}"
WORKER_PING_INTERVAL_S = int(os.getenv("WORKER_PING_INTERVAL_S", "10"))