Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9988e7244 | ||
|
|
a00199c121 | ||
|
|
baf9e93c2a | ||
|
|
db2055ff3b | ||
|
|
5a59ea3930 | ||
|
|
7b4a1f4429 | ||
|
|
2267f0a726 | ||
|
|
8cc85c1fe1 | ||
|
|
88036bcad3 |
@@ -825,23 +825,39 @@ 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.)
|
||||
- OAuth-Callback HTTP: `GET /oauth/callback/{service}?code=...` → broadcastet als
|
||||
`oauth_callback`-WS-Message + zeigt dem Browser eine "OAuth erfolgreich"-Seite
|
||||
- Auto-Update: APK-Verteilung an Apps ueber WebSocket
|
||||
- Auto-Update: App fragt per WebSocket nach der Version, laedt die APK per HTTP
|
||||
(`/update/latest.apk`, nur mit gueltigem Raum-Token im Header `X-RVS-Token`,
|
||||
mit Fortschrittsanzeige + Fortsetzen per Range). Alte Apps: Fallback ueber WebSocket
|
||||
- Heartbeat + tote Verbindungen aufraeumen
|
||||
|
||||
**Auto-Update APK bereitstellen:**
|
||||
@@ -853,10 +869,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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import WorkspaceScreen from './src/workspace/WorkspaceScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import ViewModeToggle from './src/components/ViewModeToggle';
|
||||
import SecurityLock from './src/components/SecurityLock';
|
||||
import UpdateProgress from './src/components/UpdateProgress';
|
||||
import rvs from './src/services/rvs';
|
||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||
@@ -193,6 +194,7 @@ const App: React.FC = () => {
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
<UpdateProgress />
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20507
|
||||
versionName "0.2.5.7"
|
||||
versionCode 20509
|
||||
versionName "0.2.5.9"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.2.5.7",
|
||||
"version": "0.2.5.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Fortschrittsanzeige beim App-Update: Balken mit Prozent und MB, Knopf
|
||||
* zum Abbrechen. Erscheint nur, waehrend ein Update laedt/installiert.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import updateService, { UpdateProgress as Progress } from '../services/updater';
|
||||
|
||||
const UpdateProgress: React.FC = () => {
|
||||
const [p, setP] = useState<Progress | null>(null);
|
||||
|
||||
useEffect(() => updateService.onProgress(setP), []);
|
||||
|
||||
if (!p || !p.active) return null;
|
||||
const known = p.fraction >= 0;
|
||||
const pct = known ? Math.round(p.fraction * 100) : 0;
|
||||
|
||||
return (
|
||||
<Modal visible transparent animationType="fade" onRequestClose={() => {}}>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.box}>
|
||||
<Text style={styles.title}>Update auf Version {p.version}</Text>
|
||||
<Text style={styles.status}>{p.status}</Text>
|
||||
<View style={styles.track}>
|
||||
<View style={[styles.bar, { width: known ? `${pct}%` : '100%' }, !known && styles.barUnknown]} />
|
||||
</View>
|
||||
<Text style={styles.numbers}>
|
||||
{known ? `${pct} % · ` : ''}
|
||||
{p.receivedMB > 0 ? `${p.receivedMB.toFixed(1)} von ${p.totalMB.toFixed(1)} MB` : `${p.totalMB.toFixed(1)} MB`}
|
||||
</Text>
|
||||
{p.status !== 'Installiere …' && (
|
||||
<TouchableOpacity style={styles.btn} onPress={() => updateService.cancelDownload()}>
|
||||
<Text style={styles.btnText}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24 },
|
||||
box: { backgroundColor: '#1A1A2E', borderRadius: 14, padding: 20 },
|
||||
title: { color: '#FFFFFF', fontSize: 17, fontWeight: '700' },
|
||||
status: { color: '#C8C8D8', fontSize: 14, marginTop: 6 },
|
||||
track: { height: 10, borderRadius: 5, backgroundColor: '#2A2A3E', marginTop: 14, overflow: 'hidden' },
|
||||
bar: { height: 10, borderRadius: 5, backgroundColor: '#0096FF' },
|
||||
barUnknown: { opacity: 0.35 },
|
||||
numbers: { color: '#9090A8', fontSize: 13, marginTop: 8 },
|
||||
btn: { alignSelf: 'flex-end', marginTop: 14, paddingVertical: 8, paddingHorizontal: 16,
|
||||
borderRadius: 8, backgroundColor: '#2A2A3E' },
|
||||
btnText: { color: '#FFFFFF', fontWeight: '600' },
|
||||
});
|
||||
|
||||
export default UpdateProgress;
|
||||
@@ -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 {
|
||||
|
||||
+139
-34
@@ -17,6 +17,26 @@ const APP_VERSION = packageJson.version || '0.0.0.0';
|
||||
|
||||
type UpdateCallback = (info: UpdateInfo) => void;
|
||||
|
||||
/** Fortschritt fuer die Anzeige (UpdateProgress-Komponente) */
|
||||
export interface UpdateProgress {
|
||||
active: boolean;
|
||||
version: string;
|
||||
/** 0..1, -1 = unbekannt */
|
||||
fraction: number;
|
||||
receivedMB: number;
|
||||
totalMB: number;
|
||||
/** z.B. "Lade herunter …", "Neuer Versuch (2/3) …", "Installiere …" */
|
||||
status: string;
|
||||
}
|
||||
|
||||
type ProgressCallback = (p: UpdateProgress) => void;
|
||||
|
||||
const HTTP_ATTEMPTS = 3;
|
||||
// Abbruch nur, wenn der Download HAENGT (so lange kommt kein Byte) — nicht
|
||||
// nach einer festen Gesamtzeit wie frueher (60 s fuer 65 MB war zu knapp).
|
||||
const READ_STALL_MS = 30_000;
|
||||
const WS_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
@@ -27,6 +47,10 @@ class UpdateService {
|
||||
private listeners: UpdateCallback[] = [];
|
||||
private checking = false;
|
||||
private downloading = false;
|
||||
private progressListeners: ProgressCallback[] = [];
|
||||
private progress: UpdateProgress = { active: false, version: '', fraction: -1, receivedMB: 0, totalMB: 0, status: '' };
|
||||
private jobId: number | null = null;
|
||||
private cancelled = false;
|
||||
|
||||
constructor() {
|
||||
// Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App
|
||||
@@ -150,46 +174,125 @@ class UpdateService {
|
||||
);
|
||||
}
|
||||
|
||||
/** APK ueber WebSocket herunterladen und installieren */
|
||||
onProgress(callback: ProgressCallback): () => void {
|
||||
this.progressListeners.push(callback);
|
||||
callback(this.progress);
|
||||
return () => {
|
||||
this.progressListeners = this.progressListeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
private setProgress(p: Partial<UpdateProgress>): void {
|
||||
this.progress = { ...this.progress, ...p };
|
||||
this.progressListeners.forEach(cb => cb(this.progress));
|
||||
}
|
||||
|
||||
/** Laufenden Download abbrechen (Knopf in der Fortschrittsanzeige) */
|
||||
cancelDownload(): void {
|
||||
this.cancelled = true;
|
||||
if (this.jobId !== null) {
|
||||
try { RNFS.stopDownload(this.jobId); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* APK per HTTP vom RVS laden (/update/latest.apk, Token im Header).
|
||||
* Gibt false zurueck, wenn der RVS den Weg noch nicht kennt (alter RVS) —
|
||||
* dann nimmt downloadAndInstall den alten WebSocket-Weg.
|
||||
*/
|
||||
private async downloadViaHttp(info: UpdateInfo, destPath: string): Promise<boolean> {
|
||||
const cfg = rvs.getConfig();
|
||||
if (!cfg) return false;
|
||||
const url = `${cfg.useTLS ? 'https' : 'http'}://${cfg.host}:${cfg.port}/update/latest.apk`;
|
||||
let lastErr = '';
|
||||
for (let attempt = 1; attempt <= HTTP_ATTEMPTS; attempt++) {
|
||||
if (this.cancelled) throw new Error('Abgebrochen');
|
||||
this.setProgress({ status: attempt === 1 ? 'Lade herunter …' : `Neuer Versuch (${attempt}/${HTTP_ATTEMPTS}) …`,
|
||||
fraction: -1, receivedMB: 0 });
|
||||
let expected = 0;
|
||||
try {
|
||||
const job = RNFS.downloadFile({
|
||||
fromUrl: url,
|
||||
toFile: destPath,
|
||||
headers: { 'X-RVS-Token': cfg.token },
|
||||
connectionTimeout: 15_000,
|
||||
readTimeout: READ_STALL_MS,
|
||||
progressInterval: 300,
|
||||
begin: (r) => { expected = r.contentLength > 0 ? r.contentLength : 0; },
|
||||
progress: (r) => {
|
||||
const total = r.contentLength > 0 ? r.contentLength : info.size;
|
||||
this.setProgress({
|
||||
fraction: total > 0 ? Math.min(1, r.bytesWritten / total) : -1,
|
||||
receivedMB: r.bytesWritten / 1048576,
|
||||
totalMB: total / 1048576,
|
||||
});
|
||||
},
|
||||
});
|
||||
this.jobId = job.jobId;
|
||||
const res = await job.promise;
|
||||
this.jobId = null;
|
||||
if (res.statusCode === 403 || res.statusCode === 404) {
|
||||
// Alter RVS ohne HTTP-Download (404) bzw. Token unbekannt (403)
|
||||
console.warn(`[Update] HTTP-Download nicht verfuegbar (${res.statusCode}) — nehme WebSocket`);
|
||||
return false;
|
||||
}
|
||||
if (res.statusCode !== 200) throw new Error(`HTTP ${res.statusCode}`);
|
||||
const st = await RNFS.stat(destPath);
|
||||
if (expected && Number(st.size) !== expected) {
|
||||
throw new Error(`Datei unvollstaendig (${st.size} von ${expected} Bytes)`);
|
||||
}
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
this.jobId = null;
|
||||
if (this.cancelled) throw new Error('Abgebrochen');
|
||||
lastErr = err?.message || String(err);
|
||||
console.warn(`[Update] HTTP-Download Versuch ${attempt} fehlgeschlagen: ${lastErr}`);
|
||||
await new Promise<void>(r => setTimeout(r, 2000 * attempt));
|
||||
}
|
||||
}
|
||||
throw new Error(`Download fehlgeschlagen: ${lastErr}`);
|
||||
}
|
||||
|
||||
/** Alter Weg: APK als ein grosser Block ueber die WebSocket-Verbindung */
|
||||
private async downloadViaWebSocket(info: UpdateInfo, destPath: string): Promise<void> {
|
||||
this.setProgress({ status: 'Lade herunter (ohne Fortschritt) …', fraction: -1 });
|
||||
rvs.send('update_download' as any, {});
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => { unsub(); reject(new Error('Download-Timeout (5 min)')); }, WS_TIMEOUT_MS);
|
||||
const unsub = rvs.onMessage((msg: RVSMessage) => {
|
||||
if ((msg.type as string) === 'update_data') {
|
||||
clearTimeout(timeout);
|
||||
unsub();
|
||||
if (msg.payload.error) reject(new Error(msg.payload.error as string));
|
||||
else resolve(msg.payload.base64 as string);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (this.cancelled) throw new Error('Abgebrochen');
|
||||
await RNFS.writeFile(destPath, base64, 'base64');
|
||||
}
|
||||
|
||||
/** APK herunterladen (HTTP mit Fortschritt, sonst WebSocket) und installieren */
|
||||
async downloadAndInstall(info: UpdateInfo): Promise<void> {
|
||||
if (this.downloading) return;
|
||||
this.downloading = true;
|
||||
this.cancelled = false;
|
||||
this.setProgress({ active: true, version: info.version, fraction: -1, receivedMB: 0,
|
||||
totalMB: info.size / 1048576, status: 'Starte …' });
|
||||
|
||||
const fileName = `ARIA-v${info.version}.apk`;
|
||||
const destPath = `${RNFS.CachesDirectoryPath}/${fileName}`;
|
||||
try {
|
||||
console.log(`[Update] Fordere APK v${info.version} an...`);
|
||||
Alert.alert('Download gestartet', `Version ${info.version} wird ueber RVS heruntergeladen...`);
|
||||
|
||||
// APK ueber WebSocket anfordern
|
||||
rvs.send('update_download' as any, {});
|
||||
|
||||
// Auf update_data warten (einmalig)
|
||||
const apkData = await new Promise<{base64: string, fileName: string}>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Download-Timeout (60s)')), 60000);
|
||||
const unsub = rvs.onMessage((msg: RVSMessage) => {
|
||||
if ((msg.type as string) === 'update_data') {
|
||||
clearTimeout(timeout);
|
||||
unsub();
|
||||
if (msg.payload.error) {
|
||||
reject(new Error(msg.payload.error as string));
|
||||
} else {
|
||||
resolve({
|
||||
base64: msg.payload.base64 as string,
|
||||
fileName: msg.payload.fileName as string || `ARIA-${info.version}.apk`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Vor dem Schreiben alte APKs im Cache wegraeumen — falls mehrere
|
||||
// Updates in einer Session gezogen werden
|
||||
console.log(`[Update] Lade APK v${info.version} …`);
|
||||
// Vor dem Download alte APKs im Cache wegraeumen
|
||||
await this.cleanupOldApks();
|
||||
|
||||
// Base64 als APK-Datei speichern
|
||||
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
|
||||
await RNFS.writeFile(destPath, apkData.base64, 'base64');
|
||||
const viaHttp = await this.downloadViaHttp(info, destPath);
|
||||
if (!viaHttp) await this.downloadViaWebSocket(info, destPath);
|
||||
|
||||
const fileSize = await RNFS.stat(destPath);
|
||||
console.log(`[Update] APK gespeichert: ${destPath} (${(Number(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
|
||||
console.log(`[Update] APK gespeichert: ${destPath} (${(Number(fileSize.size) / 1024 / 1024).toFixed(1)}MB, ${viaHttp ? 'HTTP' : 'WebSocket'})`);
|
||||
this.setProgress({ fraction: 1, status: 'Installiere …' });
|
||||
|
||||
// APK installieren via natives ApkInstaller Module (FileProvider + Intent)
|
||||
if (Platform.OS === 'android') {
|
||||
@@ -199,15 +302,17 @@ class UpdateService {
|
||||
} catch (installErr: any) {
|
||||
Alert.alert(
|
||||
'APK heruntergeladen',
|
||||
`Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${apkData.fileName} antippen.\n\n(${installErr.message})`,
|
||||
`Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${fileName} antippen.\n\n(${installErr.message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Update] Fehler: ${err.message}`);
|
||||
Alert.alert('Update fehlgeschlagen', err.message);
|
||||
try { await RNFS.unlink(destPath); } catch (_) {}
|
||||
if (!this.cancelled) Alert.alert('Update fehlgeschlagen', err.message);
|
||||
} finally {
|
||||
this.downloading = false;
|
||||
this.setProgress({ active: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+172
-3
@@ -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)`);
|
||||
@@ -374,6 +403,51 @@ function handleHttpRequest(req, res) {
|
||||
return;
|
||||
}
|
||||
|
||||
// App-Update als normaler HTTP-Download (statt eines riesigen WS-Blocks):
|
||||
// Content-Length fuer die Fortschrittsanzeige, Range zum Fortsetzen.
|
||||
// Nur fuer Geraete mit gueltigem Raum-Token (Header X-RVS-Token).
|
||||
if ((req.method === "GET" || req.method === "HEAD") && pathname === "/update/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 apk = getLatestAPK();
|
||||
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="ARIA-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) v${apk.version}${status === 206 ? ` ab Byte ${start}` : ""}`);
|
||||
fs.createReadStream(apk.path, { start, end }).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Health-Endpoint
|
||||
if (req.method === "GET" && pathname === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
@@ -396,6 +470,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 +499,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 +591,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 +610,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") {
|
||||
@@ -619,7 +788,7 @@ function getLatestAPK() {
|
||||
.filter(f => f.endsWith(".apk"))
|
||||
.map(f => {
|
||||
// ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk
|
||||
const match = f.match(/(\d+\.\d+\.\d+[\.\d]*)/);
|
||||
const match = f.match(/(\d+(?:\.\d+){2,})/);
|
||||
return { file: f, path: path.join(UPDATES_DIR, f), version: match ? match[1] : null };
|
||||
})
|
||||
.filter(f => f.version)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user