Compare commits

...
23 Commits
Author SHA1 Message Date
duffyduck e6e07f672a release(agent): bump to 0.0.0.1 2026-09-24 20:41:50 +02:00
duffyduckandClaude Opus 4.8 d32e7e59c3 feat(host-agent): Windows-Build aus Docker (Wine) + setup.exe (Dienst)
Dockerfile.win (tobix/pywine): baut die Windows-.exe via Wine+PyInstaller und
per NSIS ein setup.exe, das den Agent via nssm als Autostart-Windows-Dienst
einrichtet und die .env aus %ProgramData%\ARIA-Host-Agent liest (AppDirectory).
build-win.sh als Einstieg; release_agent.sh baut Windows jetzt mit (SKIP_WINDOWS=1
ueberspringt). _load_dotenv haertet: sucht .env auch neben sys.executable (onefile-
.exe-Ort), nicht nur __file__/CWD. README: Windows-Build + Dienst + Release.
macOS bleibt self-build (nicht aus Docker moeglich).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:39:08 +02:00
duffyduckandClaude Opus 4.8 4f8e65b76b feat(host-agent): release_agent.sh — Binaries als Gitea-Assets, nicht im Tree
release_agent.sh <version> (wie die App-release.sh): setzt Version
(host_agent.py AGENT_VERSION + Android versionName/Code), baut Linux-Binary +
Android-APK per Docker, taggt agent-v<version> (eigener Namespace, keine
Kollision mit App-Tags v<version>), legt Gitea-Release an und laedt Assets hoch.
mac/win optional falls in dist/ vorgebaut. Agent meldet AGENT_VERSION jetzt in
host_hello + info. README-Release-Sektion aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:32:11 +02:00
duffyduckandClaude Opus 4.8 a2605a2802 fix(host-agent): binutils im Build-Image (PyInstaller braucht objdump)
Das slim-bullseye-Image bringt kein objdump mit -> PyInstaller bricht mit
'On Linux, objdump is required' ab. binutils per apt nachinstalliert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:28:08 +02:00
duffyduckandClaude Opus 4.8 0588a8d9b3 docs(android-agent): README-Build-Abschnitt praezisiert; Version 0.2.0
Ehrlicher Bau-Weg (nur Docker noetig, dist/aria-android-agent.apk, Debug-Key,
Installieren, Erst-Build-Kosten/Stolperer). release.sh klar als M4 geplant
markiert statt als vorhandener Befehl. versionCode 2 / versionName 0.2.0 (M1+M2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:26:50 +02:00
duffyduckandClaude Opus 4.8 be202b1fa1 feat(android-agent): Meilenstein 2 — sehen (Screenshot + ui_dump)
Screenshot via MediaProjection (ScreenCapturer, gleicher {format,bytes,base64}-
Vertrag wie der Desktop-Agent -> host_screenshot, inkl. Vision). UI-Baum via
AriaAccessibilityService (nur lesend) -> neues Brain-Tool host_ui_dump. Freigabe
einmalig in der App: 'Bildschirm-Zugriff erlauben' + 'Bedienungshilfe oeffnen'.
caps = [info, screenshot, ui_dump]. targetSdk 33 -> keine mediaProjection-FGS-
Typpflicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:22:52 +02:00
duffyduckandClaude Opus 4.8 1ad85fb687 feat(android-agent): Meilenstein 1 — verbinden + in Diagnostic sichtbar
Nativer Kotlin-Agent (host-agent/android/): Gradle-Projekt, AndroidManifest,
RVS-WebSocket-Client (OkHttp) mit host_hello/host_ping/host_command->host_result,
Foreground-Service (Weg A, Auto-Reconnect, BootReceiver), Connect-UI (QR-Scan via
ZXing ODER manuell: host/port/token/name/TLS/Steuerung-Schalter). QR-Format =
{host,port,token,tls} wie die ARIA-App -> derselbe QR nutzbar.

M1-Aktion: info (Modell/Android/Akku). screenshot/ui_* liefern 'kommt in M2/M3'.
Gate: CONTROL_ENABLED-Schalter in der App. Build: Docker (Android-SDK+Gradle) ->
dist/aria-android-agent.apk (Debug, auto-signiert). Kein Google.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 20:08:54 +02:00
duffyduckandClaude Opus 4.8 558afee239 docs(android-agent): Push OHNE Google — self-hosted ntfy/UnifiedPush
Klargestellt: FCM (Google) ist NUR eine Option, keine Pflicht. Akkuschonender
Push geht self-hosted via UnifiedPush + ntfy auf dem ARIA-Server (laeuft auch auf
Custom-ROMs ohne Play Services). Fuer ein dediziertes Ziel-Handy ist der eigene
RVS-Socket (Weg A) oft die einfachste Dauerloesung. Empfehlung entsprechend
angepasst: A -> Push-Hybrid mit ntfy, FCM nur optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:50:14 +02:00
duffyduckandClaude Opus 4.8 b2fd8d8953 docs(android-agent): Push-Hybrid-Modell (Leerlauf=Push, Session=Socket) ergaenzt
Akku-optimal wie WhatsApp: idle nur FCM-Push, bei Befehl kurz Socket + Wakelock
fuer die Interaktion, danach wieder schlafen. Requirements (Firebase/Play Services/
Server-Push-Trigger) + Latenz dokumentiert. Empfehlung: erst Foreground-Service
(M1-3, sofort lauffaehig), dann Push-Hybrid als Akku-Ausbau.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:47:33 +02:00
duffyduckandClaude Opus 4.8 737915e267 docs(android-agent): Design fuer nativen Android-Agenten (Pfad B)
Nativer Kotlin-Agent unter host-agent/android/ zur Handy-Fernsteuerung inkl.
Bedienen fremder App-UIs (E-Mail-Setup) via AccessibilityService. Verbindung wie
die ARIA-App (QR-Scan/manuell), erscheint in der Diagnostic (host_hello).

Festgehalten: Android-Action-Set (screenshot/ui_dump/ui_tap/ui_text/ui_swipe/
ui_key/app_launch/info/notify statt Shell), Sicherheit (CONTROL_ENABLED + explizite
Accessibility/MediaProjection-Freigabe), Dauerbetrieb (A: Foreground-Service —
einfach, reutzt host_command 1:1; B: FCM-Push wie WhatsApp — akkuschonend, spaeter),
4 Meilensteine, Build via Docker/Gradle + release.sh <version> -> Gitea-Asset.

Status: Design. Naechster Schritt: Meilenstein 1 (Verbinden + sichtbar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:46:19 +02:00
duffyduckandClaude Opus 4.8 1140934e15 feat(host-agent): cross-platform — Linux, macOS UND Windows
Eine Codebasis fuer alle drei Desktop-OS. Der Agent waehlt je OS automatisch:
- exec: bash -lc (Linux/macOS) bzw. PowerShell (Windows)
- Root/Admin: sudo (Unix) bzw. 'als Administrator starten' (Windows, kein sudo)
- Screenshot: grim/scrot (Linux) · screencapture (macOS) · PowerShell/System.
  Drawing (Windows)
- Root-Check: _is_admin() (os.geteuid Unix / IsUserAnAdmin Windows) statt hartem
  os.geteuid (crashte auf Windows)
- info: user aus USER|USERNAME; getloadavg bleibt guarded

Build: build.sh (Linux/Docker), build-native.sh (Linux/macOS), build-native.bat
(Windows). PyInstaller cross-kompiliert nicht -> je OS bauen. README-Plattform-
Tabelle ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 19:40:17 +02:00
duffyduck 29bf3370b4 release: bump version to 0.2.5.0 2026-09-24 18:51:30 +02:00
duffyduckandClaude Opus 4.8 6b5d847615 fix(app+brain): leere Bubble bei Tool-Loop/Fehler beheben + Limit hoch
Symptom: ARIA laeuft ins Tool-Loop-Limit (host_agent-Exploration), postet die
Meldung, aber die App zeigt eine LEERE Bubble.

Ursache: stripSystemHints (App) entfernt fuehrende [..]-Bloecke (fuer Hinweise
wie '[Kontext:..] Hallo'). Die Tool-Loop-/Fehler-Meldung ist KOMPLETT ein
[..]-Block -> restlos gestrippt -> leer.

Fixes:
- App (ChatScreen): stripSystemHints zeigt das Original, wenn nach dem Strippen
  nichts uebrig bleibt -> Fehler-/Meta-Meldungen (auch '[Fehler: ...]') sind
  wieder sichtbar statt leer.
- Brain: MAX_TOOL_ITERATIONS 8 -> 20 (env-tunebar) — 8 war zu knapp fuer echte
  agentische Arbeit (Host-Exploration); ARIA brach mittendrin ab.
- Brain: Loop-Limit-Meldung ARIA-stimmig + handlungsleitend statt technischer
  Marker (liest sich natuerlich in der Bubble).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 18:47:58 +02:00
duffyduckandClaude Opus 4.8 145c64e67f feat(diagnostic): RVS-Raum-Monitor + Server/GPU-Icon fuer die Compute-Flotte
Raum-Monitor (haette uns die Nacht erspart): der RVS-Log kuerzt Tokens auf 8
Zeichen -> zwei verschiedene Tokens mit gleichem Prefix ergeben zwei Raeume,
die im Log IDENTISCH aussehen (Prefix-Kollision). Jetzt zeigt die Diagnostic
(Satelliten-Tab, Panel 'RVS-Raeume 🚪') alle Raeume mit token8 + laengerem
Fingerprint (sha256) + Token-Laenge + Client-Zahl, ★ = eigener Raum. >1 Raum =
Warnung 'Token-Mismatch'. Keine vollen Tokens (nur Fingerprint).

- rvs: rooms_query -> direkt rooms_info antworten (wie update_check); Typen in
  ALLOWED_TYPES; sha256-Fingerprint via crypto.
- diagnostic: rooms_query durchreichen, rooms_info an Browser; Panel + Tabelle.

Dazu: Compute-Flotte-Ueberschrift bekommt statt 🖥️ ein Inline-SVG (Server mit
GPU-Karte, currentColor -> theme-aware).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 18:29:37 +02:00
duffyduckandClaude Opus 4.8 6cb29a28ce feat(host-agent,satellite): RVS_TLS_FALLBACK wie in den Compute-Bridges
Host-Agent und Satellit hatten keinen TLS-Fallback (nur die vier Compute-Bridges).
Jetzt konsistent: bei TLS-Fehlschlag einmal auf ws:// zurueckfallen, danach wieder
mit RVS_TLS starten (kein Sticky-Fallback, wie bei den Bridges). uri_host/proto
werden pro Versuch aus use_tls berechnet, damit der RVS_SNI-Pfad nur bei wss gilt.

Hilft nur wo der RVS plaintext erreichbar ist; gegen Caddy-TLS bleibt wss. RVS_TLS_
FALLBACK in beiden .env.example dokumentiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:58:19 +02:00
duffyduckandClaude Opus 4.8 df5cd3be99 fix(rvs-clients): RVS_SNI via getaddrinfo-Override statt host=/port= (HTTP-200-Fix)
Symptom mit RVS_SNI: 'server rejected WebSocket connection: HTTP 200'. Ursache:
die IP stand in der URI -> HTTP-Host-Header = IP -> Caddy findet keinen RVS-Site-
Block (routet nach Host) und liefert eine Default-200-Seite statt WS-Upgrade. Der
vorige Fix korrigierte nur SNI/Cert (server_hostname), nicht den Host-Header.

Zudem: host=/port= als websockets.connect-kwargs kollidieren in der Legacy-API
mit dem aus der URI abgeleiteten Host (TypeError).

Loesung (host-agent, satellite, f5tts/whisper/voxtral/llm-adapter): die URI nutzt
den HOSTNAMEN (RVS_SNI) -> Host-Header + SNI + Cert stimmen; ein In-Process-
getaddrinfo-Override mappt RVS_SNI -> RVS_HOST (IP) fuer den TCP-Connect.
Versionsunabhaengig, nicht-blockierend, nur aktiv wenn RVS_SNI gesetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:54:38 +02:00
duffyduckandClaude Opus 4.8 4125ab8160 chore(seed): Pentest-Vorfalls-Absatz anonymisiert (Kundendomains raus)
Nennt keine realen Kunden-Produktions-/Staging-Domains mehr; die Sicherheits-
regel (nie gegen Produktion testen) bleibt vollstaendig wirksam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:44:16 +02:00
duffyduckandClaude Opus 4.8 edd0b4823f chore: .claude/settings.json aus Tracking nehmen (lokale Permission-Allowlist)
Enthaelt echte Server-IPs fuer den Dev-Zugriff -> gehoert nicht ins oeffentliche
Repo. In .gitignore aufgenommen; lokale Datei bleibt (mit echter IP) erhalten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:38:17 +02:00
duffyduckandClaude Opus 4.8 d4e3a73e89 feat(host-agent): systemd-Service-Installer + SNI-Doku ueberall
install-service.sh: installiert Binary (/usr/local/bin) + .env (/etc/aria-host-
agent/.env, 0600) + systemd-Unit und macht enable --now. .env-Pfad als Argument
ODER ohne Argument ein ncurses-Dateidialog (dialog --fselect, bietet Installation
von dialog an). Findet die Binary unter dist/ bzw. ./ oder als 2. Argument.

Doku aktualisiert (RVS_SNI fuer RZ-interne Clients + Installer):
- host-agent/README: Installer-Abschnitt + TLS/SNI-Abschnitt.
- README (zentral): Installer + SNI im Host-Agent-Abschnitt (Verweis auf
  Satellit/Compute-Nodes).
- satellite/README + xtts/README: RVS_SNI-Hinweis fuer Boxen/Satelliten im
  RVS-Netz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:27:07 +02:00
duffyduckandClaude Opus 4.8 9d93c3fe3d feat(xtts): RVS_SNI in allen Compute-Bridges — AI-Box im RZ-Netz auf interne IP
Damit eine AI-Box (f5tts/whisper/voxtral/llm-adapter) auch im selben Netz wie
der RVS direkt auf dessen interne IP verbinden kann (kein NAT-Hairpin ueber den
externen Hostnamen), ohne am SNI/Cert zu scheitern: RVS_HOST=<ip> + RVS_SNI=<name>.
server_hostname im ssl-Context, gated auf use_tls (respektiert den TLS-Fallback).
Leer = Verhalten wie bisher. Szenario: zweite Box im RZ + eine zuhause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:22:27 +02:00
duffyduckandClaude Opus 4.8 39f7947da9 feat(satellite): RVS_SNI — wie beim Host-Agent (interne IP + Cert-Hostname)
Gleiche Faehigkeit wie beim host-agent: ein Satellit im selben Netz wie der RVS
kann direkt auf die interne IP verbinden und trotzdem den Cert-Namen im TLS-SNI
praesentieren (Caddy findet sonst kein Zertifikat -> tlsv1 alert internal error).
Nuetzlich z.B. fuer einen RZ-internen Satelliten, nur um Credentials zu hinterlegen.
RVS_HOST=<ip> + RVS_SNI=<name>; leer = Verhalten wie bisher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:19:59 +02:00
duffyduckandClaude Opus 4.8 855cc0333e feat(host-agent): RVS_SNI — auf interne IP verbinden, aber Cert-Hostname im TLS
Fuer Agenten im selben Netz wie der RVS: direkt auf die interne IP verbinden
(kein NAT-Hairpin ueber den externen Hostnamen), aber im TLS-Handshake den
Namen praesentieren, fuer den das Caddy-Zertifikat gilt. Ohne das schickt der
Client die IP als SNI -> Caddy findet kein Cert -> 'tlsv1 alert internal error'.

RVS_HOST=<interne-ip> + RVS_SNI=<zert-name>. server_hostname im ssl-Context.
Leeres RVS_SNI = Verhalten wie bisher. Robuster als /etc/hosts (ueberlebt
Reboots, wichtig auf Live-ISOs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 17:16:45 +02:00
duffyduckandClaude Opus 4.8 2cac1cad0a feat(host-agent): Docker-loser Native-Build + Live-ISO-Hinweis
Docker-Build scheitert auf Live-ISOs (overlayfs-Root -> overlay2 kann kein
Overlay-auf-Overlay stapeln: 'failed to mount ... invalid argument'). Ergaenzt:
- build-native.sh: PyInstaller in einem venv, ohne Docker (Warnung: linkt gegen
  lokales glibc).
- README: Live-ISO-Ursache erklaert + zwei Auswege (Binary woanders bauen und
  kopieren [empfohlen, portabel] oder nativ bauen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 16:42:39 +02:00
52 changed files with 2261 additions and 71 deletions
-7
View File
@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(ssh root@10.0.0.1 \"ls -la /root/ARIA-AGENT/aria-shared/logs/\")"
]
}
}
+2
View File
@@ -15,6 +15,8 @@
# Werte pro Maschine selbst pflegen.
.claude/*.env
!.claude/*.env.example
# Lokale Claude-Permission-Allowlist mit echten Server-IPs — nicht ins (oeffentliche) Repo.
.claude/settings.json
# brain-import/ ist nur ein Drop-Folder: Stefan packt MDs rein wenn er
# was migrieren will, klickt im Diagnostic „Migration aus brain-import/",
+18 -1
View File
@@ -970,7 +970,24 @@ cp .env.example .env # RVS-Zugang + CONTROL_ENABLED=true eintragen
chmod +x aria-host-agent && ./aria-host-agent
```
Oder als Dienst: `host-agent/aria-host-agent.service` (systemd).
**Als systemd-Dienst** (Dauerbetrieb) — der Installer kopiert Binary + `.env`
an ihre Plätze und richtet den Dienst ein:
```bash
sudo ./install-service.sh /pfad/zur/.env # .env-Pfad direkt
sudo ./install-service.sh # oder: ncurses-Dateidialog (dialog)
```
→ Binary nach `/usr/local/bin`, `.env` nach `/etc/aria-host-agent/.env` (0600),
Unit installiert + `enable --now`. Danach `journalctl -u aria-host-agent -f`.
### TLS / SNI — Agent im selben Netz wie der RVS
Verbindet der Agent direkt auf die **interne RVS-IP** (statt über den externen
Hostnamen per NAT-Hairpin), scheitert TLS sonst am fehlenden Cert für die IP
(`tlsv1 alert internal error`). Dann in der `.env`: `RVS_HOST=<interne-ip>` +
`RVS_SNI=<zert-name>` (z.B. `example.com`). Gilt genauso für Satelliten
und Compute-Nodes im RZ-Netz (`RVS_SNI` in deren `.env`).
### sudo
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20409
versionName "0.2.4.9"
versionCode 20500
versionName "0.2.5.0"
// 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.4.9",
"version": "0.2.5.0",
"private": true,
"scripts": {
"android": "react-native run-android",
+4 -1
View File
@@ -185,7 +185,10 @@ function stripSystemHints(text: string): string {
if (!m) break;
out = out.slice(m[0].length);
}
return out;
// Bestand die Nachricht NUR aus Klammer-Bloecken (z.B. "[Tool-Loop-Limit ...]"
// oder "[Fehler: ...]"), waere sie jetzt leer → dann das ORIGINAL zeigen. Sonst
// haette der User eine leere Bubble. Fehler-/Meta-Meldungen sollen sichtbar sein.
return out.trim() ? out : text;
}
/** Sekunden → "M:SS" fuer die Sprachnachricht-Dauer. */
function formatDur(sec: number): string {
+44 -4
View File
@@ -1270,7 +1270,8 @@ META_TOOLS = [
"description": (
"Zeigt die ARIA-Host-Agenten (Rechner, auf denen ein Agent DIREKT "
"laeuft und sich per RVS meldet) die ONLINE sind + was sie koennen "
"(exec/read/write/info/screenshot). Ein Host-Agent gibt Dir vollen "
"(exec/read/write/info/screenshot/ui_dump; Android-Agenten koennen "
"screenshot+ui_dump). Ein Host-Agent gibt Dir vollen "
"Zugriff auf GENAU DIESEN Rechner — auch wenn er hinter NAT/Firewall "
"sitzt. Nutze das, wenn Stefan etwas 'auf meinem Laptop/PC/Server X' "
"machen will, das kein Geraet im Netz ist."
@@ -1364,6 +1365,25 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "host_ui_dump",
"description": (
"Liest die sichtbaren Bedienelemente eines Host-Agenten als strukturierte "
"Liste (Text, Beschriftung, Klasse, Bildschirm-Position x/y, Rahmen, ob "
"klickbar/editierbar). Vor allem fuer Android-Agenten: ergaenzt "
"host_screenshot — der Screenshot zeigt Dir das Bild, ui_dump liefert die "
"exakten Element-Texte und Koordinaten, um spaeter gezielt zu tippen. Setzt "
"auf dem Geraet eine aktive Bedienungshilfe voraus."
),
"parameters": {
"type": "object",
"properties": {"host": {"type": "string", "description": "Host-ID/Name."}},
"required": ["host"],
},
},
},
{
"type": "function",
"function": {
@@ -2044,7 +2064,10 @@ class Agent:
# ── Hauptpfad: ein User-Turn → Tool-Loop → finaler Reply ──
MAX_TOOL_ITERATIONS = 8 # Schutz vor Endlos-Loops
# Schutz vor Endlos-Loops, aber hoch genug fuer echte agentische Arbeit
# (Host-Agent-Exploration, Multi-Datei-Tasks brauchen viele Tool-Runden).
# 8 war zu knapp — ARIA brach mitten in laufender Arbeit ab. Env-tunebar.
MAX_TOOL_ITERATIONS = int(os.getenv("MAX_TOOL_ITERATIONS", "20"))
def chat(self, user_message: str, source: str = "",
project_id: Optional[str] = None,
@@ -2343,7 +2366,10 @@ class Agent:
break
else:
# Loop-Limit erreicht
final_reply = "[Tool-Loop-Limit erreicht — ARIA hat zu viele Tool-Calls gemacht ohne fertig zu werden]"
final_reply = (
f"Ich hab die Aufgabe nach {self.MAX_TOOL_ITERATIONS} Arbeitsschritten "
f"gestoppt, damit ich nicht endlos weiterlaufe — war aber noch mittendrin. "
f"Sag mir, ob ich weitermachen oder es anders angehen soll.")
logger.warning("Tool-Loop hit MAX_TOOL_ITERATIONS=%d", self.MAX_TOOL_ITERATIONS)
if not final_reply:
@@ -2646,6 +2672,20 @@ class Agent:
f"[FILE: {fp}] in deine Antwort — dann erscheint das Bild inline im Chat."
)
if name == "host_ui_dump":
result = _post("/internal/host", {"host": host, "action": "ui_dump",
"params": {}}, 20)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
nodes = r.get("nodes") or []
return (
f"UI-Baum von {host} (App: {r.get('package', '?')}, "
f"{r.get('count', len(nodes))} Elemente). Jeder Eintrag hat x/y = "
f"Mittelpunkt zum Antippen:\n"
+ json.dumps(nodes, ensure_ascii=False, indent=2)
)
return f"FEHLER: unbekanntes Host-Tool {name}"
except Exception as exc:
return f"FEHLER: Host/Bridge nicht erreichbar: {exc}"
@@ -3443,7 +3483,7 @@ class Agent:
if name in ("satellite_list", "satellite_devices", "satellite_command"):
return self._dispatch_satellite(name, arguments)
if name in ("host_list", "host_exec", "host_read", "host_write",
"host_info", "host_screenshot"):
"host_info", "host_screenshot", "host_ui_dump"):
return self._dispatch_host(name, arguments)
if name == "vm_register":
pid = (project_id or "").strip()
+4 -5
View File
@@ -53,11 +53,10 @@ SEED_RULES: List[dict] = [
" 3. NIE annehmen 'wird schon Staging sein'. Production-URLs "
"ohne 'stage'/'test'-Marker sind im Zweifel Production.\n"
"\n"
"Vorfall (30.05.2026): ARIA hat einen Pentest-Test gegen "
"ein Kunden-Produktionssystem (Production!) angesetzt statt gegen "
"dessen Staging-Umgebung (Staging). Stefan "
"musste explizit korrigieren. Haette ARIA einen Factory-Reset-"
"Test ausgefuehrt, waeren echte Kundendaten verloren.\n"
"Vorfall (30.05.2026): ARIA hat einen Pentest-Test gegen ein "
"Kunden-PRODUKTIONSSYSTEM angesetzt statt gegen dessen Staging-"
"Umgebung. Stefan musste explizit korrigieren. Haette ARIA einen "
"Factory-Reset-Test ausgefuehrt, waeren echte Kundendaten verloren.\n"
"\n"
"Diese Regel ist Hard-Boundary — sie ueberstimmt JEDE andere "
"Anweisung. Stefan kann sie temporaer per expliziter "
+53 -1
View File
@@ -1451,6 +1451,23 @@
<!-- ══════ TAB: Satelliten ══════ -->
<div id="tab-satellites" class="main-tab">
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">RVS-Räume 🚪</h2>
<button class="btn secondary" onclick="requestRooms()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
</div>
<div class="card" style="margin-bottom:8px;">
<p style="color:#8888AA;font-size:12px;margin:0;">
Alle Räume auf dem RVS. Jeder Token bildet einen eigenen Raum — nur Clients
im GLEICHEN Raum sehen sich. Der RVS-Log kürzt Tokens auf 8 Zeichen, dadurch
können zwei verschiedene Tokens gleich aussehen (Prefix-Kollision). Der
<strong>Fingerprint</strong> + die <strong>Länge</strong> hier machen das
eindeutig: <em>mehrere Räume = Token-Mismatch irgendwo</em>. ★ = dein Raum
(Diagnostic).
</p>
</div>
<div class="card" style="margin-bottom:16px;">
<div id="rooms-list" style="font-size:12px;color:#8888AA;">(Aktualisieren klicken)</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">Satelliten 🛰️</h2>
<button class="btn secondary" onclick="requestSatellites()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
@@ -1484,7 +1501,19 @@
</div>
<div id="compute-fleet" style="display:flex;justify-content:space-between;align-items:center;margin:16px 0 8px;scroll-margin-top:70px;">
<h2 style="margin:0;">Compute-Flotte 🖥️</h2>
<h2 style="margin:0;display:flex;align-items:center;gap:7px;">Compute-Flotte
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" style="flex:none;">
<!-- Server -->
<rect x="3" y="3" width="13" height="16" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/>
<line x1="6" y1="6.5" x2="13" y2="6.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
<line x1="6" y1="9.5" x2="13" y2="9.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
<circle cx="6.3" cy="15.5" r="0.95" fill="currentColor"/>
<!-- GPU-Karte (Badge unten rechts, zwei Luefter) -->
<rect x="11.5" y="12.5" width="10" height="7" rx="1.2" fill="currentColor" fill-opacity="0.15" stroke="currentColor" stroke-width="1.3"/>
<circle cx="14.5" cy="16" r="1.6" fill="none" stroke="currentColor" stroke-width="1.1"/>
<circle cx="18.4" cy="16" r="1.6" fill="none" stroke="currentColor" stroke-width="1.1"/>
</svg>
</h2>
<button class="btn secondary" onclick="requestWorkers()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
</div>
<div class="card" style="margin-bottom:8px;">
@@ -2155,6 +2184,7 @@
return;
}
if (msg.type === 'host_update') { hosts = msg.hosts || []; renderHosts(); return; }
if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); return; }
if (msg.type === 'sat_creds_list_result') {
const p = msg.payload || msg;
const sat = p.satellite || '';
@@ -4442,6 +4472,7 @@
} else if (tab === 'triggers') {
loadTriggers();
} else if (tab === 'satellites') {
requestRooms();
requestSatellites();
requestHosts();
requestWorkers();
@@ -4575,6 +4606,27 @@
function requestSatCreds(id) {
send({ action: 'sat_creds_list', satellite: id });
}
let rooms = []; // [{token8, fp, len, clients, you}]
function requestRooms() { send({ action: 'rooms_query' }); }
function renderRooms() {
const box = document.getElementById('rooms-list');
if (!box) return;
if (!rooms.length) { box.innerHTML = '<span style="color:#8888AA;">(keine Räume — RVS leer?)</span>'; return; }
const warn = rooms.length > 1
? '<div style="color:#FFB020;margin-bottom:8px;">⚠ ' + rooms.length + ' Räume — Clients in verschiedenen Räumen sehen sich NICHT. Meist ein abweichender/abgeschnittener RVS_TOKEN.</div>'
: '<div style="color:#3FFF3F;margin-bottom:8px;">✓ Genau 1 Raum — alle Clients zusammen.</div>';
box.innerHTML = warn + '<table style="width:100%;border-collapse:collapse;font-size:12px;">' +
'<tr style="color:#8888AA;text-align:left;"><th>Raum (token8)</th><th>Fingerprint</th><th>Länge</th><th>Clients</th></tr>' +
rooms.map(r => {
const you = r.you ? ' <span style="color:#0096FF;">★ du</span>' : '';
const c = rooms.length > 1 ? (r.you ? '#3FFF3F' : '#FFB020') : '#E0E0F0';
return '<tr style="border-top:1px solid #1E1E2E;">' +
'<td style="color:' + c + ';font-family:monospace;">' + escapeHtml(r.token8) + '…' + you + '</td>' +
'<td style="color:#8888AA;font-family:monospace;">' + escapeHtml(r.fp) + '</td>' +
'<td style="color:#8888AA;">' + r.len + '</td>' +
'<td style="color:#E0E0F0;">' + r.clients + '</td></tr>';
}).join('') + '</table>';
}
let hosts = []; // [{hostId, name, os, caps, control, online}]
function requestHosts() { send({ action: 'host_list' }); }
function renderHosts() {
+7
View File
@@ -1095,6 +1095,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 === "rooms_info") {
// Antwort des RVS auf rooms_query → an den Browser (Raum-Diagnose).
broadcast({ type: "rooms_info", payload: msg.payload || {} });
} else if (msg.type === "sat_devices") {
// Antwort eines Satelliten auf sat_discover → Geraeteliste an Browser.
const p = msg.payload || {};
@@ -2789,6 +2792,10 @@ wss.on("connection", (ws) => {
} else if (msg.action === "host_list") {
// Browser will die aktuelle Host-Agenten-Liste.
ws.send(JSON.stringify({ type: "host_update", hosts: hostList() }));
} 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.
sendToRVS_raw({ type: "rooms_query", payload: {}, timestamp: Date.now() });
} else if (msg.action === "worker_list") {
// Browser will die aktuelle Compute-Flotte.
ws.send(JSON.stringify({ type: "worker_update", workers: workerList() }));
+7
View File
@@ -6,7 +6,14 @@
RVS_HOST=rvs.example.de
RVS_PORT=443
RVS_TLS=true
RVS_TLS_FALLBACK=true # bei TLS-Fehlschlag einmal auf ws:// zurueckfallen
RVS_TOKEN=
# RVS_SNI: nur noetig, wenn RVS_HOST eine IP ist (Agent im selben Netz wie der
# RVS, direkt auf die interne IP). Dann hier den Zertifikats-/Hostnamen angeben,
# damit der TLS-Handshake (SNI) passt. Sonst leer lassen.
# RVS_HOST=10.0.0.2
# RVS_SNI=example.com
RVS_SNI=
# ─── Identitaet dieses Rechners ────────────────────────────────────
# HOST_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
+1
View File
@@ -1,5 +1,6 @@
.env
build/
dist/
.buildenv/
*.spec
__pycache__/
+6
View File
@@ -4,6 +4,12 @@
FROM python:3.11-slim-bullseye
WORKDIR /build
# PyInstaller braucht objdump aus binutils (im slim-Image nicht enthalten).
RUN apt-get update \
&& apt-get install -y --no-install-recommends binutils \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir pyinstaller
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+36
View File
@@ -0,0 +1,36 @@
# Baut die Windows-.exe des Host-Agents AUF LINUX — via Wine + Windows-Python +
# PyInstaller. tobix/pywine bringt Wine + Windows-Python 3.11 mit (PyInstaller
# kann NICHT cross-compilen, deshalb der Wine-Umweg).
#
# Zusaetzlich baut NSIS ein setup.exe, das die Agent-.exe installiert, eine .env
# in C:\ProgramData\ARIA-Host-Agent anlegt (falls keine da ist) und den Agent als
# automatisch startenden Windows-Dienst (via nssm) einrichtet.
FROM tobix/pywine:3.11
ARG VERSION=0.0.0
# NSIS (Installer-Compiler, laeuft nativ auf Linux) + Tools fuer nssm.
RUN apt-get update \
&& apt-get install -y --no-install-recommends nsis curl unzip ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /work
# nssm — Non-Sucking Service Manager (public domain): macht aus der Konsolen-.exe
# einen sauberen Windows-Dienst (die .exe selbst spricht das SCM nicht).
RUN curl -fsSL https://nssm.cc/release/nssm-2.24.zip -o /tmp/nssm.zip \
&& unzip -q /tmp/nssm.zip -d /tmp \
&& cp /tmp/nssm-2.24/win64/nssm.exe ./nssm.exe \
&& rm -rf /tmp/nssm*
COPY requirements.txt host_agent.py ./
COPY windows/installer.nsi ./
# Windows-Python-Deps + PyInstaller, dann die Onefile-.exe bauen.
RUN wine pip install --no-cache-dir -r requirements.txt pyinstaller
RUN wine pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py \
&& cp dist/aria-host-agent.exe ./aria-host-agent.exe \
&& makensis -DVERSION=${VERSION} installer.nsi
# Beide Artefakte rausreichen (dist/ wird vom build-win.sh als Volume gemountet).
CMD ["bash","-lc","cp dist/aria-host-agent.exe /out/ && cp aria-host-agent-setup.exe /out/ && echo 'OK -> /out/aria-host-agent.exe + /out/aria-host-agent-setup.exe'"]
+121 -5
View File
@@ -21,21 +21,137 @@ Geräte in einem LAN; der Host-Agent steuert *den Rechner, auf dem er läuft*.
ARIA nutzt diese über die Brain-Tools `host_list` / `host_exec` / `host_read` /
`host_write` / `host_info` / `host_screenshot`.
## Bauen (portable Binary)
## Plattformen
Eine Codebasis, läuft auf **Linux, macOS und Windows** (der Agent wählt Shell,
Screenshot-Methode und Root/Admin-Check je OS automatisch):
| | exec | Root/Admin | Screenshot |
|---|---|---|---|
| **Linux** | `bash -lc` | sudo (`SUDO_PASSWORD`/`SUDO_NOPASSWD`) / root | grim (Wayland) · scrot/maim (X11) |
| **macOS** | `bash -lc` | sudo (wie Linux) | `screencapture` (Bordmittel) |
| **Windows** | PowerShell | Agent **als Administrator** starten (kein sudo) | PowerShell/System.Drawing (Bordmittel) |
PyInstaller kann **nicht cross-kompilieren** — jede Binary wird auf ihrem OS gebaut.
## Bauen
**Linux (portabel, empfohlen)** — Docker-Container mit altem glibc:
```bash
./build.sh # braucht Docker; erzeugt dist/aria-host-agent (~15 MB)
./build.sh # -> dist/aria-host-agent (~15 MB, läuft auf vielen Distros)
```
Gebaut wird in einem bullseye-Container (altes glibc), damit die Binary auf
möglichst vielen Distributionen läuft.
**Linux/macOS ohne Docker** — PyInstaller direkt (linkt gegen lokales glibc/OS):
```bash
./build-native.sh # -> dist/aria-host-agent
```
**Windows — nativ** (auf einem Windows-Rechner, Python 3 im PATH nötig):
```bat
build-native.bat REM -> dist\aria-host-agent.exe
```
**Windows — aus Docker heraus (auf Linux!), inkl. Installer** — Wine baut die
`.exe`, NSIS packt ein `setup.exe`, das den Agent als Windows-Dienst einrichtet:
```bash
./build-win.sh [version]
# -> dist/aria-host-agent.exe (Konsolen-Binary)
# -> dist/aria-host-agent-setup.exe (Installer: Dienst + .env in ProgramData)
```
Der erste Lauf zieht das `tobix/pywine`-Image (~1–2 GB) und richtet die Wine-
Python-Umgebung ein — das dauert; Folge-Builds sind schnell. PyInstaller kann
nicht cross-compilen, deshalb der Wine-Umweg. **macOS geht so NICHT** (Apple
lässt sich nicht legal aus Docker bauen) — dort `./build-native.sh` auf einem Mac.
### Docker scheitert? (Live-ISO / overlayfs-Root)
Wenn `build.sh` mit `failed to mount … overlayfs … invalid argument` abbricht,
läufst du wahrscheinlich auf einem **Live-System** (Live-ISO). Dessen Root ist
selbst ein overlayfs, und Dockers `overlay2`-Treiber kann kein Overlay-auf-
Overlay stapeln. Zwei Auswege:
- **Empfohlen:** Binary auf einem normal installierten Linux bauen (`./build.sh`)
und nur die fertige `dist/aria-host-agent` aufs Live-System kopieren. Die
Binary ist portabel — Ziel braucht weder Docker noch Python.
- **Nativ bauen (ohne Docker):**
```bash
sudo apt install -y python3-pip python3-venv
./build-native.sh
```
Achtung: nativ gebaut linkt die Binary gegen das glibc **dieser** Maschine —
sie läuft dann nur auf Systemen mit gleichem oder neuerem glibc.
## Installieren
1. `dist/aria-host-agent` auf den Ziel-Rechner kopieren.
2. `.env.example` → `.env` daneben, RVS-Zugang + `CONTROL_ENABLED=true` eintragen.
3. Starten: `chmod +x aria-host-agent && ./aria-host-agent`
— oder als Dienst: siehe `aria-host-agent.service`.
### Als systemd-Dienst (empfohlen für Dauerbetrieb)
Der Installer kopiert Binary + `.env` an ihre Plätze und richtet den Dienst ein:
```bash
# .env-Pfad direkt übergeben:
sudo ./install-service.sh /pfad/zur/.env
# ODER ohne Argument -> ncurses-Dateidialog (dialog) zum Auswählen der .env:
sudo ./install-service.sh
```
Er legt ab:
- Binary → `/usr/local/bin/aria-host-agent`
- `.env` → `/etc/aria-host-agent/.env` (Rechte `0600`, enthält Token/Passwörter)
- Unit → `/etc/systemd/system/aria-host-agent.service`, dann `enable --now`.
Danach: `systemctl status aria-host-agent` · `journalctl -u aria-host-agent -f`.
Die Binary sucht er unter `dist/aria-host-agent` bzw. `./aria-host-agent` (oder
2. Argument). Braucht `dialog` für den Dateibrowser (bietet die Installation an).
### Windows-Dienst (setup.exe)
`aria-host-agent-setup.exe` (aus `build-win.sh` oder dem Gitea-Release) als
Administrator ausführen. Der Installer:
- kopiert die `.exe` nach `%ProgramFiles%\ARIA Host-Agent`,
- legt `%ProgramData%\ARIA-Host-Agent\.env` an (nur falls noch keine da ist),
- richtet über **nssm** den Dienst **ARIA Host-Agent** ein (Autostart) und startet ihn.
Danach die `.env` unter `%ProgramData%\ARIA-Host-Agent\` mit RVS-Zugang +
`CONTROL_ENABLED=true` füllen und den Dienst neu starten (`services.msc` →
*ARIA Host-Agent*, oder `nssm restart ARIAHostAgent`). Deinstallation über
*Apps & Features* → *ARIA Host-Agent* (die `.env` in ProgramData bleibt erhalten).
## Release (Binaries als Gitea-Assets)
`release_agent.sh <version>` baut alles Docker-Baubare und hängt es als
**Release-Asset** an den Tag `agent-v<version>` — nichts landet im Git-Tree:
```bash
./release_agent.sh 0.2.0 # Linux + Android + Windows (Wine)
SKIP_WINDOWS=1 ./release_agent.sh 0.2.0 # ohne Windows (schneller)
```
Assets: `aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<v>.apk`,
`aria-host-agent-windows.exe`, `aria-host-agent-windows-setup.exe`. **macOS** ist
nicht Docker-baubar — auf einem Mac `./build-native.sh` laufen lassen und das
Ergebnis vor dem Release nach `dist/aria-host-agent-macos` legen, dann nimmt das
Skript es automatisch mit. Gitea-Zugang via `.env`/Umgebung (`GITEA_URL`,
`GITEA_REPO`, `GITEA_USER`), Kennwort wird abgefragt.
## TLS / SNI — Agent im selben Netz wie der RVS
Steht der Rechner im **selben Netz wie der RVS** (z.B. Rechenzentrum) und soll
direkt auf dessen **interne IP** verbinden (kein NAT-Hairpin über den externen
Hostnamen), scheitert der TLS-Handshake sonst an `tlsv1 alert internal error`
(Caddy hat kein Zertifikat für die IP). Lösung — in der `.env`:
```
RVS_HOST=10.0.0.2 # interne RVS-IP
RVS_SNI=example.com # Name, für den das Caddy-Zert gilt
```
Der Agent verbindet dann auf die IP, präsentiert aber den Namen im TLS-SNI.
Zuhause / im Normalfall `RVS_SNI` leer lassen und den Hostnamen als `RVS_HOST`.
## sudo
+8
View File
@@ -0,0 +1,8 @@
build/
.gradle/
dist/
*.apk
local.properties
.idea/
*.iml
captures/
+32
View File
@@ -0,0 +1,32 @@
# Baut die Android-Agent-APK (Debug, auto-signiert -> direkt installierbar).
# APK-Builds gehen nur unter Linux — deshalb im Container.
FROM eclipse-temurin:17-jdk-jammy
ARG GRADLE_VERSION=8.5
ARG CMDLINE_TOOLS=11076708
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ENV ANDROID_HOME=/opt/android-sdk
RUN apt-get update && apt-get install -y --no-install-recommends \
unzip wget ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Gradle
RUN wget -q https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -O /tmp/g.zip \
&& unzip -q /tmp/g.zip -d /opt && rm /tmp/g.zip
# Android cmdline-tools + SDK
RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip -O /tmp/c.zip \
&& unzip -q /tmp/c.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools && rm /tmp/c.zip \
&& mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest
ENV PATH="/opt/gradle-${GRADLE_VERSION}/bin:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools:${PATH}"
RUN yes | sdkmanager --licenses >/dev/null 2>&1 || true
RUN sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" >/dev/null 2>&1
WORKDIR /project
COPY . /project
CMD ["bash","-lc","gradle --no-daemon assembleDebug && cp app/build/outputs/apk/debug/app-debug.apk /out/aria-android-agent.apk && echo 'OK -> /out/aria-android-agent.apk'"]
+194
View File
@@ -0,0 +1,194 @@
# ARIA Android-Agent
Ein **nativer Android-Agent** (eigene APK), der ARIA erlaubt, ein Smartphone
**fernzusteuern** — inkl. Bedienen fremder App-UIs (z.B. „ARIA, richte auf dem
Handy ein E-Mail-Konto ein"). Gegenstück zum Desktop-`host-agent` (Linux/macOS/
Windows), aber Android ist kein Unix-Shell-System — deshalb ein **anderes
Action-Set** (UI-Automation statt beliebiger Shell-Kommandos).
Verbindet sich wie die ARIA-App **ausgehend** zum RVS (gleicher Token/Raum),
Verbindungs-Setup per **QR-Scan oder manueller Eingabe**. Taucht in der
Diagnostic unter **Satelliten → Host-Agenten 💻** auf (`host_hello` mit
`os="Android …"` + Android-Caps).
## Warum nativ (Kotlin), nicht Termux/RN
- **UI-Automation** (fremde Apps bedienen) geht auf Android nur über einen
**AccessibilityService** — den kann nur eine native App bereitstellen.
- **Screenshots** einer laufenden Session: **MediaProjection** (native).
- **Dauerbetrieb**: Foreground-Service mit Notification (native).
- Termux gäbe nur Shell + `termux-api` (SMS/Anruf/Standort …), **kein** Bedienen
anderer App-UIs. Für „E-Mail-Konto durchklicken" reicht das nicht.
Tech: **Kotlin**, OkHttp-WebSocket (RVS-Client), CameraX/ML-Kit (QR),
AccessibilityService (Input), MediaProjection (Screenshot). Build via Docker
(Android-SDK + Gradle) → APK. Nur Linux baut APKs (Docker), Deploy manuell.
## Action-Set (host_command → host_result)
Android-spezifisch (statt exec/read/write des Desktop-Agents):
| Action | Was |
|---|---|
| `screenshot` | Bildschirmfoto (MediaProjection) — ARIA *sieht* den Schirm |
| `ui_dump` | Sichtbare UI als Baum (Texte, Buttons, Felder + Koordinaten) — ARIAs „Augen" für gezieltes Tippen |
| `ui_tap` | Tippen (x,y ODER auf ein Element aus ui_dump) |
| `ui_text` | Text in das fokussierte/angegebene Feld schreiben |
| `ui_swipe` | Wischen/Scrollen |
| `ui_key` | Systemtasten (BACK, HOME, ENTER …) |
| `app_launch` | App per Paketname starten (z.B. E-Mail-App) |
| `app_list` | installierte Apps auflisten |
| `info` | Gerät: Modell, Android-Version, Akku, Netz, IP |
| `notify` | Benachrichtigung anzeigen |
| *(später)* | `sms_send`, `call`, `location`, `clipboard` (je nach Bedarf + Berechtigung) |
ARIA-Flow „E-Mail einrichten": `app_launch` (Mail-App) → `screenshot`/`ui_dump`
(sehen, was da ist) → `ui_tap`/`ui_text` (durchklicken) → wieder `ui_dump` prüfen,
bis fertig. Genau das agentische Muster wie beim Endian-Fix, nur mit Handy-UI.
## Dauerbetrieb — Foreground-Service vs. Push (FCM)
Der Agent muss **immer erreichbar** sein, obwohl Android Hintergrundprozesse
aggressiv killt (Doze, App-Standby, OEM-Batterie-Manager wie Xiaomi/Huawei).
Zwei Wege, deine WhatsApp-Intuition trifft ins Schwarze:
**A) Foreground-Service (persistente WebSocket)** — der einfache Start:
- Dauerhafte RVS-Verbindung + Foreground-Notification („Agent aktiv").
- Braucht: `FOREGROUND_SERVICE`, **Akku-Optimierung ausnehmen**
(`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` — User whitelistet die App),
`RECEIVE_BOOT_COMPLETED` + BootReceiver (Neustart nach Reboot), Auto-Reconnect
(haben wir im Protokoll schon).
- **Reutzt unser bestehendes `host_hello`/`host_command`/`host_result` 1:1.**
- Nachteil: etwas Akku; manche OEMs killen trotzdem → „Autostart" manuell erlauben.
**B) Self-hosted Push (KEIN Google!)** — genau wie WhatsApp, aber auf eigenem Server:
- **UnifiedPush + self-hosted ntfy**: Auf dem ARIA-Server läuft **ntfy** (freier,
self-hostbarer Push-Server). Der Agent nutzt **UnifiedPush** (offener Standard,
de-Google-Welt/F-Droid) mit dem ntfy-Distributor auf dem Handy. Will ARIA etwas,
POSTet die Bridge/RVS an ntfy → weckt die App → sie holt den Befehl vom RVS,
arbeitet, antwortet. **Läuft auch auf Custom-ROMs OHNE Google Play Services.**
- Akkuschonend wie FCM, aber ohne jede Google-Abhängigkeit. Nur ein Dienst mehr
(ntfy) im Stack + der Push-Auslöser serverseitig.
**C) FCM (Google) — optional:** Wer ein Stock-Android mit Play Services hat und
Googles Push-Kanal will, kann FCM statt ntfy nehmen (bester Akku auf GMS-Geräten).
Braucht Firebase-Projekt + Play Services. **Nur eine Option, keine Pflicht.**
**Custom-ROM ohne Google:** → Weg **A** (eigener Socket) oder **B** (self-hosted
ntfy). Beide brauchen KEIN Google. Für Stefans dediziertes Ziel-Handy ist **A**
sogar oft die einfachste Dauerlösung (unser eigener „Push" über den RVS-Socket).
**Hybrid (ideal, End-Ausbau):** Im Leerlauf nur Push (max. Akku). Ein Push weckt
die App → sie öffnet die RVS-Verbindung, hält sich per Wakelock für die Interaktion
wach (mehrere Befehle flüssig, z.B. E-Mail-Setup durchklicken) → schläft nach ein
paar Sekunden Ruhe wieder ein. So WhatsApp-Akku UND schnelle Multi-Befehl-Sessions.
Der Push kommt dabei von **B (self-hosted ntfy)** oder C (FCM) — freie Wahl.
Erste Push-Latenz aus tiefem Doze ~1–3 s; danach bleibt der Socket die Session offen.
**Empfehlung:** Meilenstein 1–3 mit **A (Foreground-Service)** — läuft sofort und
nutzt alles Vorhandene, damit wir schnell einen funktionierenden Agenten haben, ganz
ohne externe Dienste. Dann **Push-Hybrid mit self-hosted ntfy (B)** als Akku-Ausbau —
KEIN Google. FCM (C) nur optional für Stock-Android. Action-Set/Protokoll bleiben
identisch, nur der Wecker ändert sich.
## Sicherheit
- Reagiert nur auf den eigenen RVS-Raum (Token); Setup per QR/manuell.
- **CONTROL_ENABLED**-Schalter in der App (Default AUS) — erst wenn Stefan es
bewusst aktiviert, führt der Agent Aktionen aus.
- AccessibilityService + MediaProjection müssen vom User **explizit** in den
Android-Einstellungen freigegeben werden (kein stiller Zugriff möglich).
- Alle Aktionen werden protokolliert (In-App-Log + optional an ARIA).
- Voller Gerätezugriff — nur auf eigenen/anvertrauten Geräten nutzen.
## Meilensteine
1. **✅ Verbinden + sichtbar** — Gradle-Projekt, AndroidManifest, RVS-WS-Client,
Foreground-Service, Connect-UI (QR-Scan + manuell), `host_hello`/`host_ping`.
→ Agent erscheint in der Diagnostic. `info` funktioniert.
2. **✅ Sehen** — MediaProjection-Screenshot (`ScreenCapturer`, gleicher
`{format,bytes,base64}`-Vertrag wie der Desktop-Agent → `host_screenshot`) +
`ui_dump` (`AriaAccessibilityService`, nur lesend → Brain-Tool `host_ui_dump`).
Freigabe einmalig in der App: „Bildschirm-Zugriff erlauben" + „Bedienungshilfe
öffnen". → ARIA sieht den Schirm und liest die UI-Elemente mit Koordinaten.
3. **Steuern** — `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` über den
AccessibilityService. → ARIA bedient Apps (E-Mail-Setup).
4. **Feinschliff** — `info`/`app_list`/`notify`, Build-Härtung, `release.sh`
(Version-Param → Gitea-Release-Asset, wie die App).
## Bauen
APK-Builds laufen **nur unter Linux** — deshalb im Docker-Container. Du brauchst
nichts Android-spezifisches installiert, **nur Docker**. Android-SDK, Gradle und
Build-Tools zieht der Container selbst (`Dockerfile.build`).
```bash
cd host-agent/android
./build.sh
```
`build.sh` baut das Image `aria-android-agent-build` und lässt darin
`gradle assembleDebug` laufen. Ergebnis:
```
host-agent/android/dist/aria-android-agent.apk
```
Das ist ein **Debug-APK**: auto-signiert mit dem Android-Debug-Key, also direkt
installierbar — ohne eigenen Keystore, ohne Play Store.
### Auf dem Handy installieren
1. `dist/aria-android-agent.apk` aufs Zielgerät kopieren (USB, Cloud, `adb install
dist/aria-android-agent.apk`, …).
2. Antippen → Android fragt nach **„Unbekannte Quellen / Aus dieser Quelle
installieren erlauben"** → erlauben.
3. App öffnen → verbinden (QR/manuell), „Steuerung erlauben" an, für M2 zusätzlich
„Bildschirm-Zugriff erlauben" + „Bedienungshilfe öffnen".
### Was der erste Build kostet
Der **erste** Lauf lädt viel (JDK-Image, Gradle, Android-SDK, Dependencies) und
dauert entsprechend — mehrere Minuten. Folge-Builds sind schnell (Docker-Layer +
Gradle-Cache im Image). Häufige Stolperer:
- **Docker fehlt / kein Zugriff** → `docker`-Rechte prüfen (`docker ps`).
- **Overlay-on-Overlay auf einem Live-ISO** („invalid argument" beim Image-Bau) —
gleiches Problem wie beim Desktop-Agent auf dem Mint-Live-System; von einer
installierten Linux-Kiste bauen.
- **Kotlin-/Manifest-Fehler** beim allerersten Bau eines neuen Meilensteins: den
Gradle-Fehler posten, das glätten wir schnell.
### Version setzen
Bis es `release.sh` gibt, wird die Version in `app/build.gradle` gepflegt
(`versionCode` / `versionName`). Aktuell `1` / `0.2.0` (M1+M2).
## Release
Das APK ist **kein** Teil des Git-Trees (blaeht sonst die History dauerhaft auf) —
es wird als **Release-Asset** an einen Tag gehaengt. Das macht `release_agent.sh`
(liegt eine Ebene hoeher, in `host-agent/`):
```bash
cd host-agent
./release_agent.sh 0.2.0
```
Das Skript (wie die `release.sh` der Haupt-App, Version als Parameter):
- setzt die Version (`host_agent.py` → `AGENT_VERSION`, `app/build.gradle` →
`versionName`/`versionCode`),
- baut **Linux-Binary + Android-APK** per Docker,
- committet den Version-Bump, taggt **`agent-v<version>`** (eigener Namespace,
kollidiert nicht mit den App-Tags `v<version>`) und pusht,
- legt ein Gitea-Release an und laedt die Assets hoch:
`aria-host-agent-linux-x64`, `aria-host-agent-android-agent-v<version>.apk`,
optional `-macos` / `-windows.exe` (falls in `host-agent/dist/` vorgebaut).
Gitea-Zugang (`GITEA_URL`, `GITEA_REPO`, `GITEA_USER`) kommt aus der Umgebung oder
einer `.env`; das Kennwort wird interaktiv abgefragt. **Binaries landen unter
„Releases", nie im Tree.**
> Status: **M1 + M2 fertig** (verbinden, `info`, `screenshot`, `ui_dump`),
> `release_agent.sh` vorhanden. Als Nächstes Meilenstein 3 (Steuern).
+37
View File
@@ -0,0 +1,37 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'de.hackersoft.ariaagent'
compileSdk 34
defaultConfig {
applicationId 'de.hackersoft.ariaagent'
minSdk 26
targetSdk 33 // 33 vermeidet die Foreground-Service-Typ-Pflicht von 34
versionCode 1
versionName '0.0.0.1'
}
buildTypes {
release {
minifyEnabled false
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.squareup.okhttp3:okhttp:4.12.0' // RVS-WebSocket
implementation 'com.journeyapps:zxing-android-embedded:4.3.0' // QR-Scan (FOSS, kein Google-Dienst)
}
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:usesCleartextTraffic="true"
android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.DayNight">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".AgentService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<service
android:name=".AriaAccessibilityService"
android:exported="false"
android:label="ARIA Host-Agent"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_config" />
</service>
</application>
</manifest>
@@ -0,0 +1,53 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.os.Build
/** Verbindungs-/Agent-Einstellungen (in SharedPreferences persistiert). */
data class AgentConfig(
var host: String = "",
var port: Int = 443,
var tls: Boolean = true,
var token: String = "",
var name: String = "",
var controlEnabled: Boolean = false,
) {
companion object {
private const val PREFS = "aria_agent"
fun load(ctx: Context): AgentConfig {
val p = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
return AgentConfig(
host = p.getString("host", "") ?: "",
port = p.getInt("port", 443),
tls = p.getBoolean("tls", true),
token = p.getString("token", "") ?: "",
name = p.getString("name", "") ?: "",
controlEnabled = p.getBoolean("control", false),
)
}
}
fun save(ctx: Context) {
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().apply {
putString("host", host)
putInt("port", port)
putBoolean("tls", tls)
putString("token", token)
putString("name", name)
putBoolean("control", controlEnabled)
apply()
}
}
fun isValid(): Boolean = host.isNotBlank() && token.isNotBlank()
fun displayName(): String = if (name.isNotBlank()) name else Build.MODEL
/** Stabile, technische Host-ID (a-z0-9_-), wie beim Desktop-Agent. */
fun hostId(): String {
val base = displayName().lowercase()
.replace(Regex("[^a-z0-9_-]+"), "-").trim('-')
return base.ifBlank { "android" }
}
}
@@ -0,0 +1,117 @@
package de.hackersoft.ariaagent
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
/**
* Foreground-Service (Weg A): haelt die RVS-Verbindung dauerhaft, damit ARIA
* den Agenten jederzeit erreicht. Persistente Notification + Auto-Reconnect.
*/
class AgentService : Service() {
private var rvs: RvsClient? = null
companion object {
private const val CH = "aria_agent"
private const val NOTIF_ID = 1
const val ACTION_STATUS = "de.hackersoft.ariaagent.STATUS"
const val ACTION_PROJECTION = "de.hackersoft.ariaagent.PROJECTION"
const val EXTRA_RESULT_CODE = "resultCode"
const val EXTRA_RESULT_DATA = "resultData"
@Volatile var status: String = "gestoppt"
@Volatile var connected: Boolean = false
fun start(ctx: Context) {
val i = Intent(ctx, AgentService::class.java)
if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
}
fun stop(ctx: Context) {
ctx.stopService(Intent(ctx, AgentService::class.java))
}
}
override fun onCreate() {
super.onCreate()
createChannel()
startForeground(NOTIF_ID, buildNotification("startet …"))
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_PROJECTION) {
val code = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
@Suppress("DEPRECATION")
val data = intent.getParcelableExtra<Intent>(EXTRA_RESULT_DATA)
if (code != 0 && data != null) ScreenCapturer.start(applicationContext, code, data)
if (rvs == null) startRvs() // Dienst war frisch -> Verbindung nachziehen
return START_STICKY
}
return startRvs()
}
/** (Re-)Startet die RVS-Verbindung anhand der gespeicherten Config. */
private fun startRvs(): Int {
val cfg = AgentConfig.load(this)
if (!cfg.isValid()) {
stopSelf()
return START_NOT_STICKY
}
rvs?.stop()
rvs = RvsClient(applicationContext, cfg) { conn, msg ->
connected = conn
status = msg
updateNotification(msg)
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
}
rvs?.start()
return START_STICKY
}
override fun onDestroy() {
rvs?.stop()
ScreenCapturer.stop()
connected = false
status = "gestoppt"
sendBroadcast(Intent(ACTION_STATUS).setPackage(packageName))
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createChannel() {
if (Build.VERSION.SDK_INT >= 26) {
val ch = NotificationChannel(CH, "ARIA Agent", NotificationManager.IMPORTANCE_LOW)
ch.setShowBadge(false)
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(ch)
}
}
private fun buildNotification(text: String): Notification {
val pi = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CH)
.setContentTitle("ARIA Host-Agent")
.setContentText(text)
.setSmallIcon(R.drawable.ic_launcher)
.setOngoing(true)
.setContentIntent(pi)
.build()
}
private fun updateNotification(text: String) {
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.notify(NOTIF_ID, buildNotification(text))
}
}
@@ -0,0 +1,91 @@
package de.hackersoft.ariaagent
import android.accessibilityservice.AccessibilityService
import android.graphics.Rect
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import org.json.JSONArray
import org.json.JSONObject
/**
* Bedienungshilfe-Dienst (Meilenstein 2 — nur LESEND).
*
* Liefert einen strukturierten Baum der sichtbaren Bildschirm-Elemente: Text,
* Beschriftung (contentDescription), Klasse, Bildschirm-Position (Mittelpunkt +
* Rahmen) und Flags (klickbar/editierbar/ankreuzbar). ARIA nutzt das ergaenzend
* zum Screenshot, um Ziele exakt zu benennen. Tippen/Text folgt in M3.
*
* Der Nutzer schaltet den Dienst einmalig unter Einstellungen > Bedienungshilfen
* frei. Er fuehrt hier nichts autonom aus — reagiert nur auf `dump()`.
*/
class AriaAccessibilityService : AccessibilityService() {
override fun onServiceConnected() {
instance = this
}
override fun onUnbind(intent: android.content.Intent?): Boolean {
if (instance === this) instance = null
return super.onUnbind(intent)
}
override fun onDestroy() {
if (instance === this) instance = null
super.onDestroy()
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) { /* passiv */ }
override fun onInterrupt() { /* passiv */ }
/** Strukturierter Baum des aktiven Fensters. Shape: {ok, result:{package,count,nodes[]}}. */
fun dump(): JSONObject {
val root = rootInActiveWindow
?: return JSONObject().put("ok", false)
.put("error", "Kein aktives Fenster lesbar (Bildschirm evtl. aus oder gesperrt).")
val nodes = JSONArray()
try {
walk(root, nodes, 0)
} finally {
@Suppress("DEPRECATION") try { root.recycle() } catch (_: Exception) {}
}
val result = JSONObject()
.put("package", root.packageName?.toString() ?: "")
.put("count", nodes.length())
.put("nodes", nodes)
return JSONObject().put("ok", true).put("result", result)
}
private fun walk(node: AccessibilityNodeInfo?, out: JSONArray, depth: Int) {
if (node == null || depth > 40 || out.length() >= 400) return
val text = node.text?.toString()?.trim()
val desc = node.contentDescription?.toString()?.trim()
val cls = node.className?.toString()?.substringAfterLast('.')
val interesting = !text.isNullOrBlank() || !desc.isNullOrBlank() ||
node.isClickable || node.isEditable || node.isCheckable
if (interesting) {
val r = Rect()
node.getBoundsInScreen(r)
val o = JSONObject()
if (!text.isNullOrBlank()) o.put("text", text)
if (!desc.isNullOrBlank()) o.put("desc", desc)
if (cls != null) o.put("cls", cls)
if (node.isClickable) o.put("clickable", true)
if (node.isEditable) o.put("editable", true)
if (node.isCheckable) o.put("checked", node.isChecked)
o.put("x", r.centerX())
o.put("y", r.centerY())
o.put("bounds", "${r.left},${r.top},${r.right},${r.bottom}")
out.put(o)
}
for (i in 0 until node.childCount) {
walk(node.getChild(i), out, depth + 1)
}
}
companion object {
@Volatile
var instance: AriaAccessibilityService? = null
val available: Boolean get() = instance != null
}
}
@@ -0,0 +1,16 @@
package de.hackersoft.ariaagent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Startet den Agent-Service nach dem Booten wieder (wenn konfiguriert). */
class BootReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
if (AgentConfig.load(ctx).isValid()) {
AgentService.start(ctx)
}
}
}
}
@@ -0,0 +1,179 @@
package de.hackersoft.ariaagent
import android.Manifest
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SwitchCompat
import androidx.core.content.ContextCompat
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import org.json.JSONObject
class MainActivity : AppCompatActivity() {
private lateinit var host: EditText
private lateinit var port: EditText
private lateinit var token: EditText
private lateinit var name: EditText
private lateinit var tls: SwitchCompat
private lateinit var control: SwitchCompat
private lateinit var statusView: TextView
private val scan = registerForActivityResult(ScanContract()) { res ->
res.contents?.let { applyQr(it) }
}
private val camPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) launchScan() else toast("Kamera-Berechtigung nötig für QR-Scan")
}
private val notifPerm = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
private val projection = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { res ->
if (res.resultCode == Activity.RESULT_OK && res.data != null) {
val i = Intent(this, AgentService::class.java)
.setAction(AgentService.ACTION_PROJECTION)
.putExtra(AgentService.EXTRA_RESULT_CODE, res.resultCode)
.putExtra(AgentService.EXTRA_RESULT_DATA, res.data)
ContextCompat.startForegroundService(this, i)
toast("Bildschirm-Zugriff aktiv — ARIA kann jetzt Screenshots machen")
} else {
toast("Bildschirm-Zugriff abgelehnt")
}
}
private val statusReceiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, i: Intent?) = refreshStatus()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
host = findViewById(R.id.host)
port = findViewById(R.id.port)
token = findViewById(R.id.token)
name = findViewById(R.id.name)
tls = findViewById(R.id.tls)
control = findViewById(R.id.control)
statusView = findViewById(R.id.status)
findViewById<Button>(R.id.btnScan).setOnClickListener {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED) launchScan()
else camPerm.launch(Manifest.permission.CAMERA)
}
findViewById<Button>(R.id.btnConnect).setOnClickListener { saveAndConnect() }
findViewById<Button>(R.id.btnStop).setOnClickListener {
AgentService.stop(this)
refreshStatus()
}
findViewById<Button>(R.id.btnScreen).setOnClickListener {
if (!AgentService.connected && !AgentConfig.load(this).isValid()) {
toast("Erst verbinden, dann Bildschirm-Zugriff erlauben")
return@setOnClickListener
}
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
projection.launch(mpm.createScreenCaptureIntent())
}
findViewById<Button>(R.id.btnAccessibility).setOnClickListener {
try {
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
toast("'ARIA Host-Agent' in der Liste einschalten")
} catch (_: Exception) {
toast("Bedienungshilfe-Einstellungen nicht gefunden")
}
}
loadIntoUi(AgentConfig.load(this))
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
notifPerm.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
override fun onResume() {
super.onResume()
val filter = IntentFilter(AgentService.ACTION_STATUS)
ContextCompat.registerReceiver(this, statusReceiver, filter,
ContextCompat.RECEIVER_NOT_EXPORTED)
refreshStatus()
}
override fun onPause() {
super.onPause()
try { unregisterReceiver(statusReceiver) } catch (_: Exception) {}
}
private fun launchScan() {
val o = ScanOptions()
.setBeepEnabled(false)
.setOrientationLocked(false)
.setPrompt("ARIA-Verbindungs-QR scannen")
scan.launch(o)
}
private fun applyQr(content: String) {
try {
val j = JSONObject(content)
host.setText(j.optString("host"))
port.setText((if (j.has("port")) j.optInt("port", 443) else 443).toString())
token.setText(j.optString("token"))
if (j.has("tls")) tls.isChecked = j.optBoolean("tls", true)
toast("QR übernommen — jetzt 'Speichern & Verbinden'")
} catch (_: Exception) {
toast("Das ist kein ARIA-Verbindungs-QR")
}
}
private fun loadIntoUi(c: AgentConfig) {
host.setText(c.host)
port.setText(c.port.toString())
token.setText(c.token)
name.setText(c.name)
tls.isChecked = c.tls
control.isChecked = c.controlEnabled
}
private fun saveAndConnect() {
val c = AgentConfig(
host = host.text.toString().trim(),
port = port.text.toString().trim().toIntOrNull() ?: 443,
tls = tls.isChecked,
token = token.text.toString().trim(),
name = name.text.toString().trim(),
controlEnabled = control.isChecked,
)
if (!c.isValid()) {
toast("Host und Token sind Pflicht")
return
}
c.save(this)
AgentService.start(this)
toast("Agent gestartet")
refreshStatus()
}
private fun refreshStatus() {
val dot = if (AgentService.connected) "🟢" else "🔴"
statusView.text = "Status: ${AgentService.status} $dot"
}
private fun toast(m: String) = Toast.makeText(this, m, Toast.LENGTH_SHORT).show()
}
@@ -0,0 +1,181 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.os.BatteryManager
import android.os.Build
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit
/**
* RVS-WebSocket-Client (Meilenstein 1). Spricht dasselbe Protokoll wie der
* Desktop-Agent: meldet sich per host_hello, haelt sich per host_ping frisch,
* beantwortet host_command -> host_result.
*
* M1-Aktionen: nur `info`. screenshot/ui_* folgen in M2/M3.
*/
class RvsClient(
private val appCtx: Context,
private val config: AgentConfig,
private val onStatus: (connected: Boolean, msg: String) -> Unit,
) {
private val client = OkHttpClient.Builder()
.pingInterval(20, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // Server-Push: nie lesen-timeouten
.build()
private var ws: WebSocket? = null
@Volatile private var running = false
private var pingThread: Thread? = null
private val caps = listOf("info", "screenshot", "ui_dump") // M3: ui_tap/ui_text/…
fun start() {
running = true
connect()
}
fun stop() {
running = false
pingThread?.interrupt()
try { ws?.close(1000, "bye") } catch (_: Exception) {}
ws = null
}
private fun url(): String {
val proto = if (config.tls) "wss" else "ws"
return "$proto://${config.host}:${config.port}?token=${config.token}"
}
private fun connect() {
if (!running) return
onStatus(false, "verbinde …")
val req = Request.Builder().url(url()).build()
ws = client.newWebSocket(req, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
onStatus(true, "verbunden")
sendHello(webSocket)
startPing(webSocket)
}
override fun onMessage(webSocket: WebSocket, text: String) {
handle(webSocket, text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
onStatus(false, "getrennt: ${t.message ?: "?"}")
reconnectLater()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
onStatus(false, "geschlossen")
reconnectLater()
}
})
}
private fun reconnectLater() {
pingThread?.interrupt()
if (!running) return
Thread {
try { Thread.sleep(3000) } catch (_: InterruptedException) { return@Thread }
connect()
}.start()
}
private fun send(webSocket: WebSocket, type: String, payload: JSONObject) {
val o = JSONObject()
o.put("type", type)
o.put("payload", payload)
o.put("timestamp", System.currentTimeMillis())
try { webSocket.send(o.toString()) } catch (_: Exception) {}
}
private fun sendHello(webSocket: WebSocket) {
val p = JSONObject()
p.put("hostId", config.hostId())
p.put("name", config.displayName())
p.put("os", "Android ${Build.VERSION.RELEASE} (${Build.MODEL})")
p.put("caps", JSONArray(caps))
p.put("control", config.controlEnabled)
send(webSocket, "host_hello", p)
}
private fun startPing(webSocket: WebSocket) {
pingThread?.interrupt()
pingThread = Thread {
while (running && !Thread.currentThread().isInterrupted) {
try { Thread.sleep(25000) } catch (_: InterruptedException) { break }
val p = JSONObject().put("hostId", config.hostId())
send(webSocket, "host_ping", p)
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht)
}
}.also { it.start() }
}
private fun handle(webSocket: WebSocket, text: String) {
val msg = try { JSONObject(text) } catch (_: Exception) { return }
if (msg.optString("type") != "host_command") return
val payload = msg.optJSONObject("payload") ?: JSONObject()
val target = payload.optString("host").ifBlank { payload.optString("hostId") }
if (target.isNotBlank()
&& !target.equals(config.hostId(), true)
&& !target.equals(config.displayName(), true)) return
val action = payload.optString("action")
val result: JSONObject = when {
!config.controlEnabled ->
err("Steuerung ist in der Agent-App deaktiviert (Schalter 'Steuerung erlauben').")
action == "info" -> doInfo()
action == "screenshot" -> doScreenshot()
action == "ui_dump" -> doUiDump()
action in listOf("ui_tap", "ui_text", "ui_swipe", "ui_key",
"app_launch", "app_list", "notify") ->
err("Aktion '$action' kommt in Meilenstein 3 (noch nicht implementiert).")
else -> err("Aktion '$action' unbekannt.")
}
result.put("requestId", payload.optString("requestId"))
result.put("hostId", config.hostId())
result.put("action", action)
send(webSocket, "host_result", result)
}
private fun err(m: String): JSONObject = JSONObject().put("ok", false).put("error", m)
/** Bildschirmfoto — selber Vertrag wie der Desktop-Agent: {format,bytes,base64}. */
private fun doScreenshot(): JSONObject {
if (!ScreenCapturer.active)
return err("Bildschirm-Zugriff nicht erlaubt. In der Agent-App auf dem Handy " +
"einmalig 'Bildschirm-Zugriff erlauben' antippen.")
val png = ScreenCapturer.capture()
?: return err("Screenshot fehlgeschlagen (kein Frame). Ist der Bildschirm an?")
val b64 = android.util.Base64.encodeToString(png, android.util.Base64.NO_WRAP)
val res = JSONObject().put("format", "png").put("bytes", png.size).put("base64", b64)
return JSONObject().put("ok", true).put("result", res)
}
/** Sichtbare Bedienelemente als Baum (Bedienungshilfe). */
private fun doUiDump(): JSONObject {
val svc = AriaAccessibilityService.instance
?: return err("Bedienungshilfe nicht aktiv. In der Agent-App 'Bedienungshilfe " +
"öffnen' antippen und 'ARIA Host-Agent' einschalten.")
return svc.dump()
}
private fun doInfo(): JSONObject {
val res = JSONObject()
res.put("host", config.displayName())
res.put("model", Build.MODEL)
res.put("manufacturer", Build.MANUFACTURER)
res.put("android", Build.VERSION.RELEASE)
res.put("sdk", Build.VERSION.SDK_INT)
try {
val bm = appCtx.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
res.put("battery_percent", bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY))
} catch (_: Exception) {}
return JSONObject().put("ok", true).put("result", res)
}
}
@@ -0,0 +1,132 @@
package de.hackersoft.ariaagent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.Image
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Handler
import android.os.HandlerThread
import android.util.DisplayMetrics
import android.view.WindowManager
import java.io.ByteArrayOutputStream
/**
* Bildschirm-Aufnahme via MediaProjection (Meilenstein 2 — "sehen").
*
* Der Nutzer erlaubt den Zugriff EINMALIG in der Agent-App (System-Dialog).
* Danach laeuft ein stiller VirtualDisplay -> ImageReader, aus dem `capture()`
* bei Bedarf das aktuelle Bild als PNG zieht. Kein Google-Dienst.
*
* Der Zugriff geht bei App-Kill / Neustart verloren und muss neu erlaubt werden
* (Android-Sicherheit — Projection-Token ist nicht persistierbar).
*/
object ScreenCapturer {
private var projection: MediaProjection? = null
private var reader: ImageReader? = null
private var vdisplay: VirtualDisplay? = null
private var handlerThread: HandlerThread? = null
private var handler: Handler? = null
private var w = 0
private var h = 0
private var dpi = 0
val active: Boolean
@Synchronized get() = projection != null
@Synchronized
fun start(ctx: Context, resultCode: Int, data: Intent) {
stop()
val mpm = ctx.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val mp = mpm.getMediaProjection(resultCode, data) ?: return
val metrics = DisplayMetrics()
val wm = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealMetrics(metrics)
w = metrics.widthPixels
h = metrics.heightPixels
dpi = metrics.densityDpi
handlerThread = HandlerThread("aria-capture").also { it.start() }
handler = Handler(handlerThread!!.looper)
mp.registerCallback(object : MediaProjection.Callback() {
override fun onStop() { stop() }
}, handler)
val ir = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 2)
reader = ir
vdisplay = mp.createVirtualDisplay(
"aria-screen", w, h, dpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
ir.surface, null, handler,
)
projection = mp
}
@Synchronized
fun stop() {
try { vdisplay?.release() } catch (_: Exception) {}
try { reader?.close() } catch (_: Exception) {}
try { projection?.stop() } catch (_: Exception) {}
try { handlerThread?.quitSafely() } catch (_: Exception) {}
vdisplay = null
reader = null
projection = null
handlerThread = null
handler = null
}
/** PNG-Bytes des aktuellen Bildschirms, oder null. Wartet kurz auf einen Frame. */
fun capture(): ByteArray? {
val r = reader ?: return null
var image: Image? = null
var tries = 0
while (tries < 20) {
image = r.acquireLatestImage()
if (image != null) break
try { Thread.sleep(80) } catch (_: InterruptedException) {}
tries++
}
if (image == null) return null
return try {
val plane = image.planes[0]
val buffer = plane.buffer
val pixelStride = plane.pixelStride
val rowStride = plane.rowStride
val rowPadding = rowStride - pixelStride * w
val padded = Bitmap.createBitmap(
w + (if (pixelStride > 0) rowPadding / pixelStride else 0),
h, Bitmap.Config.ARGB_8888,
)
padded.copyPixelsFromBuffer(buffer)
val cropped = if (rowPadding == 0) padded else Bitmap.createBitmap(padded, 0, 0, w, h)
val scaled = downscale(cropped, 1280)
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
if (scaled !== cropped) scaled.recycle()
if (cropped !== padded) cropped.recycle()
padded.recycle()
out.toByteArray()
} catch (_: Exception) {
null
} finally {
try { image?.close() } catch (_: Exception) {}
}
}
private fun downscale(src: Bitmap, maxSide: Int): Bitmap {
val longSide = maxOf(src.width, src.height)
if (longSide <= maxSide) return src
val scale = maxSide.toFloat() / longSide
return Bitmap.createScaledBitmap(
src, (src.width * scale).toInt(), (src.height * scale).toInt(), true,
)
}
}
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0D0D1A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#3FFF9F"
android:pathData="M54,26 m-26,0 a26,26 0 1,0 52,0 a26,26 0 1,0 -52,0 Z M54,26 m-13,0 a13,13 0 1,1 26,0 a13,13 0 1,1 -26,0 Z" />
<path
android:fillColor="#3FFF9F"
android:pathData="M52,58 h4 v24 h-4 z" />
</vector>
@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="22sp"
android:textStyle="bold"
android:paddingBottom="4dp" />
<TextView
android:id="@+id/status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Status: —"
android:paddingBottom="16dp" />
<Button
android:id="@+id/btnScan"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="QR-Code scannen" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="— oder manuell —"
android:paddingTop="12dp"
android:paddingBottom="4dp" />
<EditText
android:id="@+id/host"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="RVS-Host (z.B. rvs.example.com)"
android:inputType="textUri" />
<EditText
android:id="@+id/port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Port"
android:text="443"
android:inputType="number" />
<EditText
android:id="@+id/token"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="RVS-Token"
android:inputType="textNoSuggestions" />
<EditText
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Anzeigename (optional, z.B. 'Stefans Handy')"
android:inputType="text" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/tls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="TLS (wss)"
android:checked="true"
android:paddingTop="12dp" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/control"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Steuerung erlauben (Aktionen ausführen)"
android:paddingTop="8dp"
android:paddingBottom="16dp" />
<Button
android:id="@+id/btnConnect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Speichern &amp; Verbinden" />
<Button
android:id="@+id/btnStop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Agent stoppen"
android:paddingTop="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sehen (Meilenstein 2)"
android:textStyle="bold"
android:paddingTop="24dp"
android:paddingBottom="4dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Damit ARIA Screenshots machen und die Oberfläche lesen kann. Beides einmalig freigeben."
android:textSize="13sp"
android:paddingBottom="8dp" />
<Button
android:id="@+id/btnScreen"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Bildschirm-Zugriff erlauben" />
<Button
android:id="@+id/btnAccessibility"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Bedienungshilfe öffnen"
android:paddingTop="8dp" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ARIA Host-Agent</string>
<string name="accessibility_desc">Erlaubt ARIA, die sichtbaren Bildschirm-Elemente zu lesen (Text und Position), um Dich fernzusteuern. Nur aktiv, wenn Du \'Steuerung erlauben\' eingeschaltet hast.</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds"
android:canRetrieveWindowContent="true"
android:notificationTimeout="100"
android:description="@string/accessibility_desc" />
+5
View File
@@ -0,0 +1,5 @@
// Root-Build. Plugin-Versionen zentral, in den Modulen nur angewandt.
plugins {
id 'com.android.application' version '8.2.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
}
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Baut die Android-Agent-APK per Docker (Android-SDK + Gradle).
# ./build.sh
# Ergebnis: dist/aria-android-agent.apk -> aufs Handy kopieren + installieren
# ("Unbekannte Quellen erlauben").
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p dist
docker build -f Dockerfile.build -t aria-android-agent-build .
docker run --rm -v "$(pwd)/dist:/out" aria-android-agent-build
echo
echo "Fertig: dist/aria-android-agent.apk"
+5
View File
@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
org.gradle.caching=true
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "aria-android-agent"
include(":app")
+28
View File
@@ -0,0 +1,28 @@
@echo off
REM ARIA Host-Agent — Windows-Build mit PyInstaller (kein Docker).
REM Voraussetzung: Python 3 installiert und im PATH (python.org, "Add to PATH").
REM
REM build-native.bat
REM
REM Ergebnis: dist\aria-host-agent.exe (Onefile). Danach .env danebenlegen
REM (siehe .env.example) und starten. Fuer Admin-Rechte die .exe per Rechtsklick
REM "Als Administrator ausfuehren".
setlocal
cd /d "%~dp0"
python -m venv .buildenv || goto :err
call .buildenv\Scripts\activate.bat
python -m pip install --quiet --upgrade pip
python -m pip install --quiet pyinstaller -r requirements.txt || goto :err
pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py || goto :err
call deactivate
echo.
echo Fertig: dist\aria-host-agent.exe
echo .env danebenlegen (siehe .env.example), dann starten (ggf. als Administrator).
goto :eof
:err
echo.
echo FEHLER beim Bauen. Ist Python 3 installiert und im PATH? (python --version)
exit /b 1
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Baut die Host-Agent-Binary OHNE Docker — direkt mit PyInstaller.
# Nutze das, wenn Docker nicht geht (z.B. Live-ISO mit overlayfs-Root, wo
# Dockers overlay2-Treiber kein Overlay-auf-Overlay stapeln kann).
#
# sudo apt install -y python3-pip python3-venv # falls noch nicht da
# ./build-native.sh
#
# WICHTIG: Nativ gebaut linkt die Binary gegen das glibc DIESER Maschine. Sie
# laeuft dann nur auf Systemen mit glibc >= dem hier. Fuer breite Kompatibilitaet
# lieber ./build.sh (Docker/bullseye) auf einem normalen Rechner nutzen.
set -euo pipefail
cd "$(dirname "$0")"
python3 -m venv .buildenv
# shellcheck disable=SC1091
. .buildenv/bin/activate
pip install --quiet --upgrade pip
pip install --quiet pyinstaller -r requirements.txt
pyinstaller --onefile --name aria-host-agent --collect-all psutil host_agent.py
deactivate
echo
echo "Fertig: dist/aria-host-agent"
echo ".env danebenlegen (siehe .env.example), dann: chmod +x aria-host-agent && ./aria-host-agent"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Baut die Windows-.exe + setup.exe des Host-Agents AUF LINUX (Wine im Docker).
# ./build-win.sh [version]
# Ergebnis:
# dist/aria-host-agent.exe (Konsolen-Binary)
# dist/aria-host-agent-setup.exe (Installer: richtet Windows-Dienst ein)
#
# Hinweis: Der erste Lauf zieht das tobix/pywine-Image (~1-2 GB) + baut die
# Wine-Umgebung — das dauert. Folge-Builds sind schnell.
set -euo pipefail
cd "$(dirname "$0")"
VERSION="${1:-0.0.0}"
mkdir -p dist
docker build -f Dockerfile.win --build-arg VERSION="$VERSION" -t aria-host-agent-win .
docker run --rm -v "$(pwd)/dist:/out" aria-host-agent-win
echo
echo "Fertig: dist/aria-host-agent.exe + dist/aria-host-agent-setup.exe"
+113 -24
View File
@@ -31,11 +31,28 @@ import re
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path
import websockets
# ─── Plattform-Weichen (Linux / macOS / Windows) ────────────────────
IS_WINDOWS = os.name == "nt"
IS_MAC = sys.platform == "darwin"
def _is_admin() -> bool:
"""root (Unix) bzw. Administrator (Windows)."""
try:
return os.geteuid() == 0 # Unix (Linux/macOS)
except AttributeError:
try:
import ctypes
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # Windows
except Exception:
return False
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [host-agent] %(levelname)s %(message)s",
@@ -47,7 +64,13 @@ def _load_dotenv() -> None:
"""Laedt eine .env neben der Binary/dem Script (oder im CWD) in os.environ.
Bereits gesetzte Werte gewinnen. Kein python-dotenv noetig."""
here = os.path.dirname(os.path.abspath(__file__))
for path in (os.path.join(here, ".env"), os.path.join(os.getcwd(), ".env")):
# PyInstaller-onefile: __file__ liegt im Temp-Extract-Dir, NICHT beim .exe/
# Binary — deshalb zusaetzlich sys.executable-Ordner (echter Binary-Ort) und
# das CWD (z.B. der ProgramData-Ordner, den der Windows-Dienst als AppDir nutzt).
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
seen = set()
candidates = [os.path.join(d, ".env") for d in (exe_dir, here, os.getcwd())]
for path in [p for p in candidates if not (p in seen or seen.add(p))]:
if not os.path.isfile(path):
continue
try:
@@ -98,7 +121,27 @@ def _default_id() -> str:
RVS_HOST = os.environ.get("RVS_HOST", "")
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
RVS_TLS = _env_bool("RVS_TLS", True)
# Bei TLS-Fehlschlag einmal auf ws:// zurueckfallen (wie die Compute-Bridges).
# Hilft nur, wenn der RVS plaintext erreichbar ist; gegen Caddy-TLS bleibt wss.
RVS_TLS_FALLBACK = _env_bool("RVS_TLS_FALLBACK", True)
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
# TLS-Hostname (SNI + Zertifikatspruefung), falls RVS_HOST eine IP ist — z.B. der
# Agent laeuft im selben Netz wie der RVS und verbindet direkt auf die interne IP,
# das Caddy-Zertifikat gilt aber fuer den Namen. Dann: RVS_HOST=<interne-ip>,
# RVS_SNI=<zert-name>. Leer = SNI = RVS_HOST (Normalfall).
RVS_SNI = os.environ.get("RVS_SNI", "").strip()
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
if RVS_TLS and RVS_SNI:
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo
def _sni_getaddrinfo(host, *a, **k):
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
_socket.getaddrinfo = _sni_getaddrinfo
HOST_ID = (os.environ.get("HOST_ID") or _default_id()).strip()
HOST_NAME = (os.environ.get("HOST_NAME") or HOST_ID).strip()
@@ -118,6 +161,9 @@ OUT_MAX_CHARS_HARD = int(os.environ.get("OUT_MAX_CHARS_HARD", "200000") or "2000
# Datei-Transfer-Limit (Base64 durchs RVS).
FILE_MAX_BYTES = int(os.environ.get("FILE_MAX_BYTES", str(10 * 1024 * 1024)) or str(10 * 1024 * 1024))
# Version (wird von release_agent.sh beim Release gesetzt).
AGENT_VERSION = "0.0.0.1"
HEARTBEAT_SEC = 25
CAPS = ["exec", "read", "write", "info", "screenshot"]
@@ -160,8 +206,18 @@ def _window_text(text: str, params: dict) -> dict:
# ─── Aktionen ───────────────────────────────────────────────────────
def _wrap_sudo(cmd: str, use_sudo: bool):
"""Gibt (argv, stdin_data) oder (None, fehlertext) wenn sudo nicht moeglich."""
if not use_sudo or os.geteuid() == 0:
"""Baut die Argv (OS-abhaengige Shell) + optional Root/Admin-Rechte.
Gibt (argv, stdin_data) oder (None, fehlertext)."""
if IS_WINDOWS:
# PowerShell; kein sudo. Fuer Admin-Rechte muss der Agent SELBST als
# Administrator laufen (UAC) — dann hat 'sudo:true' bereits volle Rechte.
if use_sudo and not _is_admin():
return None, ("Windows kennt kein sudo. Starte den Agent als "
"Administrator ('Als Administrator ausfuehren'), dann "
"laufen Kommandos mit vollen Rechten.")
return ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd], None
# Unix: Linux + macOS (bash vorhanden; macOS-sudo verhaelt sich wie Linux)
if not use_sudo or _is_admin():
return ["bash", "-lc", cmd], None
if SUDO_PASSWORD:
return ["sudo", "-S", "-p", "", "bash", "-lc", cmd], SUDO_PASSWORD + "\n"
@@ -244,9 +300,11 @@ def _do_write(params: dict) -> dict:
def _do_info(params: dict) -> dict:
info = {
"host": HOST_NAME, "hostname": socket.gethostname(),
"agent_version": AGENT_VERSION,
"os": platform.platform(), "kernel": platform.release(),
"arch": platform.machine(), "python": platform.python_version(),
"user": os.environ.get("USER") or "", "is_root": os.geteuid() == 0,
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
"is_root": _is_admin(),
}
try:
import psutil
@@ -286,24 +344,35 @@ def _do_info(params: dict) -> dict:
def _do_screenshot(params: dict) -> dict:
"""Bildschirmfoto via System-Tool (Wayland: grim; X11: scrot/maim/import/
gnome-screenshot). Braucht eine aktive grafische Session (DISPLAY/WAYLAND)."""
"""Bildschirmfoto — OS-abhaengig. Windows: PowerShell/System.Drawing;
macOS: screencapture; Linux: grim (Wayland) / scrot/maim/import (X11).
Braucht eine aktive grafische Session."""
import tempfile
tmp = os.path.join(tempfile.gettempdir(), f"aria_shot_{int(time.time())}.png")
wayland = bool(os.environ.get("WAYLAND_DISPLAY"))
candidates = []
if wayland and shutil.which("grim"):
candidates.append(["grim", tmp])
for tool, argv in (("scrot", ["scrot", "-o", tmp]),
("maim", ["maim", tmp]),
("gnome-screenshot", ["gnome-screenshot", "-f", tmp]),
("import", ["import", "-window", "root", tmp])):
if shutil.which(tool):
candidates.append(argv)
if IS_WINDOWS:
ps = ("Add-Type -AssemblyName System.Windows.Forms,System.Drawing;"
"$b=[System.Windows.Forms.SystemInformation]::VirtualScreen;"
"$bmp=New-Object System.Drawing.Bitmap $b.Width,$b.Height;"
"$g=[System.Drawing.Graphics]::FromImage($bmp);"
"$g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$b.Size);"
f"$bmp.Save('{tmp}');$g.Dispose();$bmp.Dispose()")
candidates.append(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps])
elif IS_MAC:
candidates.append(["screencapture", "-x", tmp]) # -x = ohne Ton
else:
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("grim"):
candidates.append(["grim", tmp])
for tool, argv in (("scrot", ["scrot", "-o", tmp]),
("maim", ["maim", tmp]),
("gnome-screenshot", ["gnome-screenshot", "-f", tmp]),
("import", ["import", "-window", "root", tmp])):
if shutil.which(tool):
candidates.append(argv)
if not candidates:
return {"ok": False, "error":
"Kein Screenshot-Tool gefunden. Installiere grim (Wayland) oder "
"scrot/maim (X11)."}
"Kein Screenshot-Tool gefunden. Linux: grim (Wayland) oder "
"scrot/maim (X11) installieren. (Windows/macOS nutzen Bordmittel.)"}
last_err = ""
for argv in candidates:
try:
@@ -355,7 +424,7 @@ class HostAgent:
HOST_ID, HOST_NAME, ",".join(CAPS), CONTROL_ENABLED)
await self._send({"type": "host_hello", "payload": {
"hostId": HOST_ID, "name": HOST_NAME, "os": platform.platform(),
"caps": CAPS, "control": CONTROL_ENABLED,
"version": AGENT_VERSION, "caps": CAPS, "control": CONTROL_ENABLED,
}, "timestamp": int(time.time() * 1000)})
async def _heartbeat(self) -> None:
@@ -400,15 +469,26 @@ class HostAgent:
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
return
backoff = 1
# use_tls kann bei Fehlschlag einmal auf ws:// fallen (RVS_TLS_FALLBACK),
# danach wieder zurueck auf RVS_TLS (kein Sticky-Fallback).
use_tls = RVS_TLS
tls_fallback_tried = False
connect_kwargs = {"max_size": 16 * 1024 * 1024,
"ping_interval": 20, "ping_timeout": 20}
while True:
proto = "wss" if RVS_TLS else "ws"
url = f"{proto}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
proto = "wss" if use_tls else "ws"
# Bei TLS + RVS_SNI: URI nutzt den HOSTNAMEN (Host-Header/SNI/Cert),
# getaddrinfo mappt ihn auf die IP in RVS_HOST. Bei ws:// direkt die IP.
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{proto}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
fallback = False
try:
logger.info("Verbinde mit RVS %s://%s:%s …", proto, RVS_HOST, RVS_PORT)
async with websockets.connect(url, max_size=16 * 1024 * 1024,
ping_interval=20, ping_timeout=20) as ws:
logger.info("Verbinde mit RVS %s://%s:%s%s …", proto, uri_host, RVS_PORT,
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
self.ws = ws
backoff = 1
tls_fallback_tried = False
await self._hello(log=True)
hb = asyncio.create_task(self._heartbeat())
try:
@@ -418,15 +498,24 @@ class HostAgent:
hb.cancel()
except Exception as exc:
logger.warning("RVS-Verbindung verloren: %s", exc)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
use_tls = False
tls_fallback_tried = True
fallback = True
finally:
self.ws = None
if fallback:
continue # sofort erneut mit ws://
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
use_tls = RVS_TLS # kein Sticky-Fallback
tls_fallback_tried = False
def main() -> None:
logger.info("ARIA Host-Agent startet — id=%s name=%s control=%s root=%s",
HOST_ID, HOST_NAME, CONTROL_ENABLED, os.geteuid() == 0)
HOST_ID, HOST_NAME, CONTROL_ENABLED, _is_admin())
try:
asyncio.run(HostAgent().run())
except KeyboardInterrupt:
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# ARIA Host-Agent — Service-Installer (systemd).
#
# Installiert die Binary nach /usr/local/bin, die .env nach /etc/aria-host-agent
# und richtet den systemd-Dienst ein (enable + start).
#
# Nutzung:
# sudo ./install-service.sh /pfad/zur/.env # .env-Pfad direkt uebergeben
# sudo ./install-service.sh # ncurses-Dateidialog (dialog)
#
# Die Binary wird unter ./dist/aria-host-agent oder ./aria-host-agent erwartet
# (vorher ./build.sh oder ./build-native.sh ausfuehren), oder als 2. Argument.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN_DST="/usr/local/bin/aria-host-agent"
ETC_DIR="/etc/aria-host-agent"
UNIT_DST="/etc/systemd/system/aria-host-agent.service"
die() { echo "FEHLER: $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "Bitte mit sudo/root ausfuehren: sudo $0 $*"
# ── Binary finden (Arg 2 > dist/ > ./) ──────────────────────────────
BIN_SRC="${2:-}"
if [ -z "$BIN_SRC" ]; then
if [ -x "$SCRIPT_DIR/dist/aria-host-agent" ]; then BIN_SRC="$SCRIPT_DIR/dist/aria-host-agent"
elif [ -x "$SCRIPT_DIR/aria-host-agent" ]; then BIN_SRC="$SCRIPT_DIR/aria-host-agent"
fi
fi
[ -n "$BIN_SRC" ] && [ -f "$BIN_SRC" ] || die "Binary nicht gefunden. Erst bauen (./build.sh) oder als 2. Argument uebergeben."
# ── .env bestimmen: Arg 1, sonst ncurses-Dateidialog ────────────────
ENV_SRC="${1:-}"
if [ -z "$ENV_SRC" ]; then
if ! command -v dialog >/dev/null 2>&1; then
echo "Kein .env-Pfad uebergeben und 'dialog' ist nicht installiert."
read -r -p "dialog jetzt installieren (apt)? [j/N] " a
case "$a" in
j|J|y|Y) (apt-get update && apt-get install -y dialog) || die "dialog-Installation fehlgeschlagen — .env-Pfad bitte als Argument uebergeben." ;;
*) die "Ohne dialog bitte den .env-Pfad als Argument uebergeben: sudo $0 /pfad/zur/.env" ;;
esac
fi
# dialog --fselect: Dateibrowser; --stdout gibt die Auswahl auf stdout.
START_DIR="${SUDO_USER:+/home/$SUDO_USER/}"
[ -d "$START_DIR" ] || START_DIR="$SCRIPT_DIR/"
ENV_SRC="$(dialog --stdout --title 'ARIA Host-Agent — .env auswaehlen' \
--fselect "$START_DIR" 14 72)" || true
clear
[ -n "$ENV_SRC" ] || die "Abgebrochen — keine .env ausgewaehlt."
fi
[ -f "$ENV_SRC" ] || die ".env nicht gefunden: $ENV_SRC"
echo "Binary : $BIN_SRC"
echo ".env : $ENV_SRC"
# ── Installieren ────────────────────────────────────────────────────
install -m 0755 "$BIN_SRC" "$BIN_DST"
install -d -m 0755 "$ETC_DIR"
install -m 0600 "$ENV_SRC" "$ETC_DIR/.env" # 0600: enthaelt Token/Passwoerter
if [ -f "$SCRIPT_DIR/aria-host-agent.service" ]; then
install -m 0644 "$SCRIPT_DIR/aria-host-agent.service" "$UNIT_DST"
else
die "aria-host-agent.service nicht gefunden neben dem Installer."
fi
systemctl daemon-reload
systemctl enable --now aria-host-agent
echo
echo "✓ Installiert & gestartet."
echo " Status: systemctl status aria-host-agent"
echo " Log: journalctl -u aria-host-agent -f"
echo " .env: $ETC_DIR/.env (aendern -> systemctl restart aria-host-agent)"
+150
View File
@@ -0,0 +1,150 @@
#!/bin/bash
# ════════════════════════════════════════════════════════════════════
# ARIA Host-Agent — Release Script
# Baut die auf Linux+Docker moeglichen Artefakte und haengt sie als
# Gitea-Release-Assets an einen Tag. NICHTS wandert in den Git-Tree —
# Binaries leben nur unter "Releases" (blaeht clone/History nicht auf).
#
# Verwendung: ./release_agent.sh <version> (z.B. ./release_agent.sh 0.2.0)
#
# Artefakte:
# - Linux-x64-Binary (Docker, PyInstaller) -> immer
# - Android-APK (Docker, Gradle) -> immer
# - macOS / Windows (falls in dist/ vorgebaut) -> optional
# (mac/win koennen auf Linux nicht cross-gebaut werden -> build-native.*
# auf dem jeweiligen OS laufen lassen, Ergebnis nach host-agent/dist/ legen)
#
# Eigener Tag-Namespace agent-v<version> (kollidiert NICHT mit den
# App-Tags v<version>).
#
# Gitea-Zugang (GITEA_URL, GITEA_REPO, GITEA_USER) wird aus der Umgebung
# oder aus host-agent/.env bzw. der Repo-Wurzel-.env gelesen; das Kennwort
# wird interaktiv abgefragt.
# ════════════════════════════════════════════════════════════════════
set -e
cd "$(dirname "$0")" # host-agent/
SCRIPT_DIR="$(pwd)"
ROOT_DIR="$(cd .. && pwd)"
# ── Farben ───────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
# ── Parameter ────────────────────────────────────────────────────────
VERSION=${1:?"Usage: ./release_agent.sh <version> (z.B. 0.2.0)"}
TAG="agent-v$VERSION"
# ── Gitea-Konfiguration (env > host-agent/.env > Repo-Wurzel-.env) ────
[ -f "$SCRIPT_DIR/.env" ] && source "$SCRIPT_DIR/.env"
[ -f "$ROOT_DIR/.env" ] && source "$ROOT_DIR/.env"
GITEA_URL="${GITEA_URL:?"GITEA_URL nicht gesetzt (in .env oder als Umgebungsvariable)"}"
GITEA_REPO="${GITEA_REPO:?"GITEA_REPO nicht gesetzt (z.B. stefan/aria-agent)"}"
GITEA_USER="${GITEA_USER:-$(echo "$GITEA_REPO" | cut -d'/' -f1)}"
echo -e "${CYAN}╔═══════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ ARIA Host-Agent Release — ${TAG}$(printf '%*s' $((14 - ${#TAG})) '')║${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════╝${NC}\n"
# ── Kennwort ─────────────────────────────────────────────────────────
echo -e "${YELLOW}Gitea-Login: ${GITEA_USER}${NC}"
read -s -p "Gitea-Kennwort: " GITEA_PASS; echo ""
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -u "${GITEA_USER}:${GITEA_PASS}" "$GITEA_URL/api/v1/user")
if [ "$HTTP_CODE" != "200" ]; then
echo -e "${RED}Login fehlgeschlagen (HTTP $HTTP_CODE). Kennwort korrekt?${NC}"; exit 1
fi
echo -e " ${GREEN}✓${NC} Login erfolgreich\n"
# ── Versionsnummern setzen ───────────────────────────────────────────
echo -e "${GREEN}[1/5] Version auf $VERSION setzen...${NC}"
# Desktop-Agent
sed -i "s/^AGENT_VERSION = \"[^\"]*\"/AGENT_VERSION = \"$VERSION\"/" host_agent.py
echo -e " ${GREEN}✓${NC} host_agent.py → AGENT_VERSION $VERSION"
# Android: versionName + versionCode (aus Version berechnen; 3- oder 4-stellig)
IFS='.' read -ra VP <<< "$VERSION"
V1=${VP[0]:-0}; V2=${VP[1]:-0}; V3=${VP[2]:-0}; V4=${VP[3]:-0}
VERSION_CODE=$((V1 * 1000000 + V2 * 10000 + V3 * 100 + V4)); [ "$VERSION_CODE" -lt 1 ] && VERSION_CODE=1
sed -i "s/versionName '[^']*'/versionName '$VERSION'/" android/app/build.gradle
sed -i "s/versionCode [0-9]*/versionCode $VERSION_CODE/" android/app/build.gradle
echo -e " ${GREEN}✓${NC} android/app/build.gradle → versionName $VERSION, versionCode $VERSION_CODE\n"
# ── Bauen (Docker) ───────────────────────────────────────────────────
echo -e "${GREEN}[2/5] Linux-Binary + Android-APK bauen (Docker)...${NC}"
./build.sh >/dev/null
LINUX_BIN="$SCRIPT_DIR/dist/aria-host-agent"
[ -f "$LINUX_BIN" ] || { echo -e "${RED}Linux-Binary fehlt: $LINUX_BIN${NC}"; exit 1; }
echo -e " ${GREEN}✓${NC} Linux-Binary ($(du -h "$LINUX_BIN" | cut -f1))"
( cd android && ./build.sh >/dev/null )
APK="$SCRIPT_DIR/android/dist/aria-android-agent.apk"
[ -f "$APK" ] || { echo -e "${RED}APK fehlt: $APK${NC}"; exit 1; }
echo -e " ${GREEN}✓${NC} Android-APK ($(du -h "$APK" | cut -f1))"
# Windows (.exe + setup.exe) via Wine im Docker. Dauert (pywine-Image ~1-2 GB) —
# mit SKIP_WINDOWS=1 ./release_agent.sh <v> ueberspringbar.
if [ "${SKIP_WINDOWS:-0}" = "1" ]; then
echo -e " ${YELLOW}Windows-Build uebersprungen (SKIP_WINDOWS=1)${NC}"
else
echo -e " ${CYAN}…${NC} Windows-.exe + setup.exe bauen (Wine, kann dauern)"
if ./build-win.sh "$VERSION" >/dev/null 2>&1; then
echo -e " ${GREEN}✓${NC} Windows-.exe + setup.exe"
else
echo -e " ${YELLOW}Windows-Build fehlgeschlagen — Release laeuft ohne Windows weiter.${NC}"
echo -e " ${YELLOW}(Einzeln testen: ./build-win.sh $VERSION)${NC}"
fi
fi
echo ""
# Asset-Liste aufbauen: "lokaler_pfad::asset-name"
ASSETS=(
"$LINUX_BIN::aria-host-agent-linux-x64"
"$APK::aria-host-agent-android-$TAG.apk"
)
# Native Artefakte (nur wenn vorhanden): Windows aus build-win.sh, macOS
# vorgebaut (build-native.sh auf einem Mac -> dist/aria-host-agent-macos legen).
[ -f "$SCRIPT_DIR/dist/aria-host-agent-macos" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent-macos::aria-host-agent-macos")
[ -f "$SCRIPT_DIR/dist/aria-host-agent.exe" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent.exe::aria-host-agent-windows.exe")
[ -f "$SCRIPT_DIR/dist/aria-host-agent-setup.exe" ] && ASSETS+=("$SCRIPT_DIR/dist/aria-host-agent-setup.exe::aria-host-agent-windows-setup.exe")
# ── Git-Tag ──────────────────────────────────────────────────────────
echo -e "${GREEN}[3/5] Git-Tag $TAG...${NC}"
git add host_agent.py android/app/build.gradle
git commit -m "release(agent): bump to $VERSION" 2>/dev/null || echo -e " ${YELLOW}Keine Aenderungen zum Committen${NC}"
if git rev-parse "$TAG" &>/dev/null; then
echo -e " ${YELLOW}Tag $TAG existiert bereits — überspringe${NC}"
else
git tag "$TAG"; echo -e " ${GREEN}✓${NC} Tag $TAG erstellt"
fi
git push origin main "$TAG"
echo -e " ${GREEN}✓${NC} Tag gepusht\n"
# ── Gitea-Release ────────────────────────────────────────────────────
echo -e "${GREEN}[4/5] Gitea-Release anlegen...${NC}"
BODY="ARIA Host-Agent $TAG\n\nDesktop (Linux) + Android-APK. Auf das Zielgeraet kopieren, siehe README."
BODY_JSON=$(printf '%s' "$BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "$BODY")
RESP=$(curl -s -X POST "$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" \
-u "${GITEA_USER}:${GITEA_PASS}" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Host-Agent $TAG\",\"body\":$BODY_JSON,\"draft\":false,\"prerelease\":false}")
RELEASE_ID=$(echo "$RESP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "$RELEASE_ID" ]; then echo -e "${RED}Release fehlgeschlagen:${NC}\n$RESP"; exit 1; fi
echo -e " ${GREEN}✓${NC} Release #$RELEASE_ID erstellt\n"
# ── Assets hochladen ─────────────────────────────────────────────────
echo -e "${GREEN}[5/5] Assets hochladen (${#ASSETS[@]})...${NC}"
for entry in "${ASSETS[@]}"; do
path="${entry%%::*}"; name="${entry##*::}"
UP=$(curl -s -X POST \
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases/$RELEASE_ID/assets?name=$name" \
-u "${GITEA_USER}:${GITEA_PASS}" -F "attachment=@${path}")
if echo "$UP" | grep -q '"name"'; then
echo -e " ${GREEN}✓${NC} $name"
else
echo -e " ${RED}✗ $name fehlgeschlagen:${NC} $UP"
fi
done
echo ""
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
echo -e "${GREEN}║${NC} $GITEA_URL/$GITEA_REPO/releases/tag/$TAG"
echo -e "${GREEN}╚═══════════════════════════════════════════════════╝${NC}"
+83
View File
@@ -0,0 +1,83 @@
; ARIA Host-Agent — Windows-Installer (NSIS, auf Linux mit makensis gebaut).
;
; Installiert die Agent-.exe nach %ProgramFiles%\ARIA Host-Agent, legt eine .env
; in %ProgramData%\ARIA-Host-Agent an (nur falls noch keine da ist — User-Config
; bleibt erhalten) und richtet einen automatisch startenden Windows-Dienst via
; nssm ein. Der Dienst laeuft mit AppDirectory = ProgramData-Ordner, damit der
; Agent die .env von dort (aus dem CWD) liest.
!ifndef VERSION
!define VERSION "0.0.0"
!endif
!define SVC "ARIAHostAgent"
Name "ARIA Host-Agent ${VERSION}"
OutFile "aria-host-agent-setup.exe"
InstallDir "$PROGRAMFILES64\ARIA Host-Agent"
RequestExecutionLevel admin
Unicode true
ShowInstDetails show
ShowUninstDetails show
Var DataDir
Page directory
Page instfiles
UninstPage uninstConfirm
UninstPage instfiles
Section "Install"
SetOutPath "$INSTDIR"
File "aria-host-agent.exe"
File "nssm.exe"
; ProgramData-Ordner fuer die .env bestimmen
ReadEnvStr $0 "ProgramData"
StrCmp $0 "" 0 +2
StrCpy $0 "$PROFILE" ; Fallback, falls %ProgramData% fehlt
StrCpy $DataDir "$0\ARIA-Host-Agent"
CreateDirectory "$DataDir"
; .env nur schreiben, wenn noch keine existiert (User-Config nicht ueberschreiben)
IfFileExists "$DataDir\.env" env_done 0
FileOpen $1 "$DataDir\.env" w
FileWrite $1 "# ARIA Host-Agent — Konfiguration (dieser Windows-Dienst liest diese Datei).$\r$\n"
FileWrite $1 "# Nach dem Aendern den Dienst neu starten: services.msc -> ARIA Host-Agent.$\r$\n"
FileWrite $1 "RVS_HOST=rvs.example.de$\r$\n"
FileWrite $1 "RVS_PORT=443$\r$\n"
FileWrite $1 "RVS_TLS=true$\r$\n"
FileWrite $1 "RVS_TLS_FALLBACK=true$\r$\n"
FileWrite $1 "RVS_TOKEN=$\r$\n"
FileWrite $1 "RVS_SNI=$\r$\n"
FileWrite $1 "HOST_NAME=$\r$\n"
FileWrite $1 "CONTROL_ENABLED=true$\r$\n"
FileClose $1
env_done:
; Dienst (neu) einrichten — evtl. alten sauber entfernen, dann installieren
nsExec::ExecToLog '"$INSTDIR\nssm.exe" stop ${SVC}'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" remove ${SVC} confirm'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" install ${SVC} "$INSTDIR\aria-host-agent.exe"'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} AppDirectory "$DataDir"'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} DisplayName "ARIA Host-Agent"'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} Description "ARIA-Fernsteuerung dieses Rechners (RVS-Agent)."'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" set ${SVC} Start SERVICE_AUTO_START'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" start ${SVC}'
; Uninstaller + Eintrag unter "Apps & Features"
WriteUninstaller "$INSTDIR\uninstall.exe"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "DisplayName" "ARIA Host-Agent"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent" "UninstallString" '"$INSTDIR\uninstall.exe"'
SectionEnd
Section "Uninstall"
nsExec::ExecToLog '"$INSTDIR\nssm.exe" stop ${SVC}'
nsExec::ExecToLog '"$INSTDIR\nssm.exe" remove ${SVC} confirm'
Delete "$INSTDIR\aria-host-agent.exe"
Delete "$INSTDIR\nssm.exe"
Delete "$INSTDIR\uninstall.exe"
RMDir "$INSTDIR"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARIAHostAgent"
; Die .env in %ProgramData%\ARIA-Host-Agent bleibt bewusst erhalten (User-Config).
SectionEnd
+27
View File
@@ -88,6 +88,10 @@ const ALLOWED_TYPES = new Set([
// 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",
// 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.
@@ -349,6 +353,29 @@ function registerClient(ws, token) {
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();
+7
View File
@@ -6,7 +6,14 @@
RVS_HOST=rvs.example.de
RVS_PORT=443
RVS_TLS=true
RVS_TLS_FALLBACK=true # bei TLS-Fehlschlag einmal auf ws:// zurueckfallen
RVS_TOKEN=
# RVS_SNI: nur noetig, wenn RVS_HOST eine IP ist (Satellit im selben Netz wie der
# RVS, direkt auf die interne IP). Dann hier den Zertifikats-/Hostnamen angeben,
# damit der TLS-Handshake (SNI) passt. Sonst leer lassen.
# RVS_HOST=10.0.0.2
# RVS_SNI=example.com
RVS_SNI=
# ─── Identitaet / Adresse dieses Satelliten ────────────────────────
# SATELLITE_ID = technisch eindeutig (a-z0-9-_), Default = Hostname-Slug.
+5
View File
@@ -48,6 +48,11 @@ python satellite.py # liest .env automatisch
den Satelliten erreicht). `SATELLITE_LOCATION` ist der Name, über den ARIA das Netz
anspricht („Büro").
**Satellit im selben Netz wie der RVS** (z.B. Rechenzentrum, direkt auf die
interne IP statt NAT-Hairpin): `RVS_HOST=<interne-ip>` + **`RVS_SNI=<zert-name>`**
(der Name, für den das Caddy-Zertifikat gilt). Ohne das scheitert TLS an
`tlsv1 alert internal error`. Zuhause / normal: `RVS_SNI` leer lassen.
**Kontrolle:** im Log/Diagnostic muss `primary_ip` im Ziel-LAN liegen
(`192.168.0.x`) — steht da `192.168.65.x` oder `172.x`, sitzt der Satellit im
falschen (Docker-)Netz.
+43 -5
View File
@@ -106,7 +106,25 @@ def _default_id() -> str:
RVS_HOST = os.environ.get("RVS_HOST", "")
RVS_PORT = int(os.environ.get("RVS_PORT", "443") or "443")
RVS_TLS = _env_bool("RVS_TLS", True)
# Bei TLS-Fehlschlag einmal auf ws:// zurueckfallen (wie die Compute-Bridges).
RVS_TLS_FALLBACK = _env_bool("RVS_TLS_FALLBACK", True)
RVS_TOKEN = os.environ.get("RVS_TOKEN", "")
# TLS-Hostname (SNI + Zertifikatspruefung), falls RVS_HOST eine IP ist — z.B. ein
# Satellit im selben Netz wie der RVS, der direkt auf die interne IP verbindet
# (das Caddy-Zertifikat gilt aber fuer den Namen). Leer = SNI = RVS_HOST.
RVS_SNI = os.environ.get("RVS_SNI", "").strip()
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
if RVS_TLS and RVS_SNI:
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo
def _sni_getaddrinfo(host, *a, **k):
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
_socket.getaddrinfo = _sni_getaddrinfo
SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip()
SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).strip()
@@ -1316,15 +1334,26 @@ class Satellite:
_creds_load()
asyncio.create_task(self._periodic_scan())
backoff = 1
# use_tls kann bei Fehlschlag einmal auf ws:// fallen (RVS_TLS_FALLBACK),
# danach wieder zurueck auf RVS_TLS (kein Sticky-Fallback). Bei TLS+RVS_SNI
# nutzt die URI den HOSTNAMEN (Host-Header/SNI/Cert), getaddrinfo mappt ihn
# auf die IP in RVS_HOST.
use_tls = RVS_TLS
tls_fallback_tried = False
connect_kwargs = {"max_size": 8 * 1024 * 1024,
"ping_interval": 20, "ping_timeout": 20}
while True:
proto = "wss" if RVS_TLS else "ws"
url = f"{proto}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
proto = "wss" if use_tls else "ws"
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{proto}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
fallback = False
try:
logger.info("Verbinde mit RVS %s://%s:%s …", proto, RVS_HOST, RVS_PORT)
async with websockets.connect(url, max_size=8 * 1024 * 1024,
ping_interval=20, ping_timeout=20) as ws:
logger.info("Verbinde mit RVS %s://%s:%s%s …", proto, uri_host, RVS_PORT,
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
self.ws = ws
backoff = 1
tls_fallback_tried = False
await self._hello(log=True)
hb = asyncio.create_task(self._heartbeat())
try:
@@ -1334,10 +1363,19 @@ class Satellite:
hb.cancel()
except Exception as exc:
logger.warning("RVS-Verbindung verloren: %s", exc)
if use_tls and RVS_TLS_FALLBACK and not tls_fallback_tried:
logger.info("TLS fehlgeschlagen — Fallback auf ws://")
use_tls = False
tls_fallback_tried = True
fallback = True
finally:
self.ws = None
if fallback:
continue
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
use_tls = RVS_TLS
tls_fallback_tried = False
def main() -> None:
+7
View File
@@ -43,6 +43,13 @@ RVS_PORT=444
RVS_TLS=true
RVS_TLS_FALLBACK=true
RVS_TOKEN=dein_token_hier
# RVS_SNI: nur noetig, wenn die Box im SELBEN Netz wie der RVS steht und direkt
# auf dessen interne IP verbinden soll (kein NAT-Hairpin ueber den externen
# Hostnamen). Dann RVS_HOST=<interne-ip> und hier den Zertifikats-/Hostnamen, fuer
# den Caddy sein Cert hat. Zuhause / normal: leer lassen.
# RVS_HOST=10.0.0.2
# RVS_SNI=example.com
RVS_SNI=
# ─── Optional ─────────────────────────────────────
# HF_TOKEN= # nur falls ein HF-gated Modell (z.B. Voxtral) geladen wird
+4
View File
@@ -58,6 +58,10 @@ GPU-Test + Stack-Start.
24-GB-Karte: `docker compose stop whisper-bridge && docker compose --profile voxtral up -d --build`.
- **Erster Start lädt Modelle** (mehrere GB via HuggingFace nach `xtts/hf-cache`
+ `xtts/models`) — genug Platz (1 TB NVMe ✓) und etwas Geduld.
- **Box im selben Netz wie der RVS** (z.B. Rechenzentrum, direkt auf die interne
IP statt NAT-Hairpin): in `xtts/.env` `RVS_HOST=<interne-ip>` + **`RVS_SNI=<zert-name>`**
(Name, für den das Caddy-Zert gilt). Ohne das scheitert TLS an `tlsv1 alert
internal error`. Zuhause / normal: `RVS_SNI` leer lassen.
## Verifizieren
+20 -3
View File
@@ -51,6 +51,21 @@ RVS_PORT = int(os.getenv("RVS_PORT", "443"))
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
RVS_SNI = os.getenv("RVS_SNI", "").strip()
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
if RVS_TLS and RVS_SNI:
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo
def _sni_getaddrinfo(host, *a, **k):
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
_socket.getaddrinfo = _sni_getaddrinfo
# F5-TTS Konfiguration
# ─────────────────────────────────────────────────────────────────
@@ -877,12 +892,14 @@ async def run_loop(runner: F5Runner) -> None:
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
try:
logger.info("Verbinde zu RVS: %s", masked)
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
logger.info("RVS verbunden")
retry_s = 2
tls_fallback_tried = False
+21 -5
View File
@@ -43,6 +43,21 @@ RVS_PORT = os.getenv("RVS_PORT", "443").strip()
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
RVS_SNI = os.getenv("RVS_SNI", "").strip()
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
if RVS_TLS and RVS_SNI:
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo
def _sni_getaddrinfo(host, *a, **k):
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
_socket.getaddrinfo = _sni_getaddrinfo
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama:8081").rstrip("/")
LLM_MODEL = os.getenv("LLM_MODEL", "qwen3-8b")
@@ -417,13 +432,14 @@ async def _run() -> None:
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 16 * 1024 * 1024}
try:
logger.info("Verbinde zu RVS: %s (llama=%s)", masked, LLAMA_URL)
async with websockets.connect(
url, ping_interval=20, ping_timeout=10, max_size=16 * 1024 * 1024
) as ws:
logger.info("Verbinde zu RVS: %s%s (llama=%s)", masked,
f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "", LLAMA_URL)
async with websockets.connect(url, **connect_kwargs) as ws:
logger.info("RVS verbunden — llm-adapter online")
retry_s = 2
tls_fallback_tried = False
+20 -4
View File
@@ -53,6 +53,21 @@ RVS_PORT = int(os.getenv("RVS_PORT", "443"))
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
RVS_SNI = os.getenv("RVS_SNI", "").strip()
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
if RVS_TLS and RVS_SNI:
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo
def _sni_getaddrinfo(host, *a, **k):
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
_socket.getaddrinfo = _sni_getaddrinfo
VOXTRAL_MODEL = os.getenv("VOXTRAL_MODEL", "mistralai/Voxtral-Mini-3B-2507")
VOXTRAL_LANGUAGE = os.getenv("VOXTRAL_LANGUAGE", "de")
@@ -742,12 +757,13 @@ async def run_loop(sessions: SessionManager) -> None:
tls_fallback_tried = False
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
try:
logger.info("Verbinde zu RVS: %s", masked)
async with websockets.connect(url, ping_interval=20, ping_timeout=10,
max_size=50 * 1024 * 1024) as ws:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
logger.info("RVS verbunden")
retry_s = 2
tls_fallback_tried = False
+20 -3
View File
@@ -53,6 +53,21 @@ RVS_PORT = int(os.getenv("RVS_PORT", "443"))
RVS_TLS = os.getenv("RVS_TLS", "true").lower() == "true"
RVS_TLS_FALLBACK = os.getenv("RVS_TLS_FALLBACK", "true").lower() == "true"
RVS_TOKEN = os.getenv("RVS_TOKEN", "").strip()
# TLS-Hostname (SNI + Cert), falls RVS_HOST eine IP ist (Box im selben Netz wie
# der RVS, direkt auf die interne IP). Leer = SNI = RVS_HOST.
RVS_SNI = os.getenv("RVS_SNI", "").strip()
# In-Process-DNS-Override: wenn RVS_SNI gesetzt ist, verbindet die URI ueber den
# HOSTNAMEN (Host-Header + SNI + Cert stimmen), waehrend getaddrinfo den Namen auf
# die echte IP in RVS_HOST aufloest. Versionsunabhaengig — host=/port= kollidiert
# in der Legacy-websockets-API mit dem aus der URI abgeleiteten Host.
if RVS_TLS and RVS_SNI:
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo
def _sni_getaddrinfo(host, *a, **k):
return _orig_getaddrinfo(RVS_HOST if host == RVS_SNI else host, *a, **k)
_socket.getaddrinfo = _sni_getaddrinfo
WHISPER_MODEL = os.getenv("WHISPER_MODEL", "small")
WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cuda")
@@ -874,11 +889,13 @@ async def run_loop(runner: WhisperRunner, sessions: SessionManager) -> None:
while True:
scheme = "wss" if use_tls else "ws"
url = f"{scheme}://{RVS_HOST}:{RVS_PORT}?token={RVS_TOKEN}"
uri_host = RVS_SNI if (use_tls and RVS_SNI) else RVS_HOST
url = f"{scheme}://{uri_host}:{RVS_PORT}?token={RVS_TOKEN}"
masked = url.replace(RVS_TOKEN, "***") if RVS_TOKEN else url
connect_kwargs = {"ping_interval": 20, "ping_timeout": 10, "max_size": 50 * 1024 * 1024}
try:
logger.info("Verbinde zu RVS: %s", masked)
async with websockets.connect(url, ping_interval=20, ping_timeout=10, max_size=50 * 1024 * 1024) as ws:
logger.info("Verbinde zu RVS: %s%s", masked, f" (TCP {RVS_HOST})" if (use_tls and RVS_SNI) else "")
async with websockets.connect(url, **connect_kwargs) as ws:
logger.info("RVS verbunden")
retry_s = 2
tls_fallback_tried = False