feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)

- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
  Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
  Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
  TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
  via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
  GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-18 04:37:16 +02:00
commit 76df695111
16 changed files with 703 additions and 29 deletions

View file

@ -24,6 +24,13 @@ AUTH_ENABLED=true
# Schluessel fuer die Nutzerverwaltung (POST /api/admin/users). Nur ueber die Umgebung. # Schluessel fuer die Nutzerverwaltung (POST /api/admin/users). Nur ueber die Umgebung.
ADMIN_API_KEY= ADMIN_API_KEY=
# Forward-/Trusted-Header-Auth via Reverse-Proxy/SSO (z. B. YunoHost). Nur fuer
# Remote-Betrieb - siehe deploy/README.md. Lokal leer lassen.
# TRUSTED_AUTH_HEADER=X-Remote-User
# TRUSTED_PROXY_IPS=192.168.0.10
# ADMIN_USERS=atoor,dieterschlueter,dschlueter
# SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout
# --- Zentrale Konfiguration / Profile ------------------------------------- # --- Zentrale Konfiguration / Profile -------------------------------------
# Aktives Profil aus config/voice-assistant.toml waehlen: local-dev | hybrid | cloud # Aktives Profil aus config/voice-assistant.toml waehlen: local-dev | hybrid | cloud
# (leer lassen = nur Defaults/ENV). Eigener Pfad via VA_CONFIG_FILE. # (leer lassen = nur Defaults/ENV). Eigener Pfad via VA_CONFIG_FILE.

View file

@ -377,10 +377,25 @@ sed -i 's/^PORT=.*/PORT=8003/' .env # dauerhaft
PORT=8003 docker compose up # mit Docker PORT=8003 docker compose up # mit Docker
``` ```
## Web-UI & Remote-Zugang
Das Gateway liefert unter `/` eine minimale **Web-Oberfläche** aus (`app/web/`, vanilla,
kein Build): Text-Eingabe + **Mikrofon-Button** (Aufnahme im Browser → `/ws/voice`
Antwort wird vorgelesen) und links ein Menü mit Identität/Logout/Admin.
Für den **Remote-Betrieb** (Handy/Browser von unterwegs) hinter einem Reverse-Proxy mit
HTTPS + SSO (z. B. YunoHost): siehe **`deploy/README.md`**. Kernpunkte:
- **HTTPS ist Pflicht** — Browser geben das Mikrofon nur im „secure context" frei.
- **Forward-/Trusted-Header-Auth**: der Proxy/SSO authentifiziert, reicht die Identität
per Header durch (`TRUSTED_AUTH_HEADER`); das Gateway legt Nutzer automatisch an.
Akzeptiert wird der Header nur von der Proxy-Quell-IP (`TRUSTED_PROXY_IPS`).
- **WebSocket-Upgrade** im nginx nicht vergessen (sonst kein Mikrofon).
## Deployment ## Deployment
- **Docker:** `docker compose up --build` (reicht `OPENROUTER_API_KEY` aus der Shell durch) - **Docker:** `docker compose up --build` (reicht `OPENROUTER_API_KEY` aus der Shell durch)
- **systemd:** Vorlagen unter `deploy/` (`voice-assistant.service`, `voice-assistant.env.example`) - **systemd:** Vorlagen unter `deploy/` (`voice-assistant.service`, `voice-assistant.env.example`)
- **Remote über YunoHost/Reverse-Proxy:** `deploy/README.md` (HTTPS, SSO, nginx, Firewall)
## Projektstruktur (Kurzform) ## Projektstruktur (Kurzform)

View file

@ -1,14 +1,41 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Request
from app.auth import require_admin from app.auth import require_admin, require_admin_or_user
from app.dependencies import get_store from app.dependencies import get_store
from app.schemas import UserCreate, UserCreated from app.schemas import UserCreate, UserCreated
router = APIRouter() router = APIRouter()
@router.get("/admin/request-headers", dependencies=[Depends(require_admin_or_user)])
async def request_headers(request: Request):
"""Discovery: zeigt die eingehenden HTTP-Header + Quell-IP.
Hilft, hinter dem Reverse-Proxy/SSO den richtigen Identitaets-Header
(TRUSTED_AUTH_HEADER) festzustellen. Nur fuer Admins.
"""
return {
"client": request.client.host if request.client else None,
"headers": dict(request.headers),
}
@router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)]) @router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)])
async def create_user(payload: UserCreate): async def create_user(payload: UserCreate):
"""Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck.""" """Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck."""
user, token = get_store().create_user(payload.display_name) user, token = get_store().create_user(payload.display_name)
return UserCreated(user_id=user.id, display_name=user.display_name, token=token) return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
@router.get("/admin/users", dependencies=[Depends(require_admin_or_user)])
async def list_users():
"""Listet die Nutzer (ohne Secrets). Fuer Admins (SSO/ADMIN_USERS) oder ADMIN_API_KEY."""
return [
{
"user_id": u.id,
"display_name": u.display_name,
"external_id": u.external_id,
"created_at": u.created_at,
}
for u in get_store().list_users()
]

View file

@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from app.auth import require_user from app.auth import require_user, is_admin_user
from app.config import settings
from app.dependencies import get_store from app.dependencies import get_store
from app.schemas import UserPrefs, MemoryCreate, MemoryOut from app.schemas import UserPrefs, MemoryCreate, MemoryOut
from app.store import User from app.store import User
@ -10,7 +11,14 @@ router = APIRouter()
@router.get("/me") @router.get("/me")
async def get_me(user: User = Depends(require_user)): async def get_me(user: User = Depends(require_user)):
return {"user_id": user.id, "display_name": user.display_name, "prefs": user.prefs} return {
"user_id": user.id,
"display_name": user.display_name,
"external_id": user.external_id,
"is_admin": user.is_admin or is_admin_user(user),
"prefs": user.prefs,
"sso_logout_url": settings.sso_logout_url,
}
@router.put("/me/prefs") @router.put("/me/prefs")

View file

@ -31,6 +31,7 @@ from app.store import SessionOwnershipError
from app.audio.vad import EnergyVAD from app.audio.vad import EnergyVAD
from app.core.memory_extractor import maybe_schedule_extraction from app.core.memory_extractor import maybe_schedule_extraction
from app.quota import enforce_quota, record_usage, QuotaExceededError from app.quota import enforce_quota, record_usage, QuotaExceededError
from app.auth import authenticate
from app.safety.emergency import handle_emergency, schedule_llm_emergency_check from app.safety.emergency import handle_emergency, schedule_llm_emergency_check
router = APIRouter() router = APIRouter()
@ -45,13 +46,11 @@ _OVERRIDE_KEYS = (
) )
def _authenticate(token: str | None): def _authenticate(websocket: WebSocket, token: str | None):
store = get_store() # Forward-Auth (SSO) greift auch beim WS-Handshake: SSOwat injiziert den
if not settings.auth_enabled: # Identitaets-Header in den Upgrade-Request -> aus websocket.headers lesbar.
return store.ensure_anonymous_user() client_host = websocket.client.host if websocket.client else ""
if not token: return authenticate(websocket.headers, client_host, token)
return None
return store.get_user_by_token(token)
async def _resolve(user, session_id, options): async def _resolve(user, session_id, options):
@ -213,7 +212,7 @@ async def _voice_turn(websocket, store, user, session_id, audio, fmt, options):
@router.websocket("/ws/chat") @router.websocket("/ws/chat")
async def ws_chat(websocket: WebSocket, session_id: str | None = None, token: str | None = None): async def ws_chat(websocket: WebSocket, session_id: str | None = None, token: str | None = None):
user = _authenticate(token) user = _authenticate(websocket, token)
if user is None: if user is None:
await websocket.close(code=1008) await websocket.close(code=1008)
return return
@ -244,7 +243,7 @@ async def ws_chat(websocket: WebSocket, session_id: str | None = None, token: st
@router.websocket("/ws/voice") @router.websocket("/ws/voice")
async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: str | None = None): async def ws_voice(websocket: WebSocket, session_id: str | None = None, token: str | None = None):
user = _authenticate(token) user = _authenticate(websocket, token)
if user is None: if user is None:
await websocket.close(code=1008) await websocket.close(code=1008)
return return

View file

@ -1,27 +1,69 @@
from fastapi import Header, HTTPException from fastapi import Header, HTTPException, Request
from app.config import settings from app.config import settings, Settings
from app.dependencies import get_store from app.dependencies import get_store
from app.store import User from app.store import User
def require_user(authorization: str | None = Header(default=None)) -> User: def _csv_set(value: str) -> set[str]:
"""FastAPI-Dependency: liefert den authentifizierten Nutzer. return {item.strip() for item in (value or "").split(",") if item.strip()}
Bei AUTH_ENABLED=false gilt ein anonymer Standardnutzer (dev/Test). Sonst ist
ein gueltiges Bearer-Token noetig (sonst 401). def is_admin_user(user: User | None, cfg: Settings = settings) -> bool:
"""True, wenn der Nutzer (per SSO-Identitaet) in ADMIN_USERS steht."""
if user is None or not user.external_id:
return False
return user.external_id in _csv_set(cfg.admin_users)
def authenticate(headers, client_host: str, token: str | None,
cfg: Settings = settings) -> User | None:
"""Gemeinsame Auth-Logik fuer HTTP und WebSocket.
Praezedenz:
1. Forward-Auth: trusted_auth_header gesetzt UND Request von einer Proxy-Quell-IP
-> Identitaet aus dem Header, interner Nutzer wird ggf. angelegt.
2. AUTH_ENABLED=false -> anonymer Standardnutzer (dev/Test).
3. Bearer-Token.
Liefert den Nutzer oder None (nicht authentifiziert).
""" """
store = get_store() store = get_store()
if not settings.auth_enabled:
# 1. Forward-/Trusted-Header-Auth (nur von der Proxy-Quell-IP akzeptiert).
if cfg.trusted_auth_header:
ips = _csv_set(cfg.trusted_proxy_ips)
if ips and client_host in ips:
external = (headers.get(cfg.trusted_auth_header) or "").strip()
if not external:
return None # SSO sollte den Header immer setzen -> 401
user = store.get_or_create_user_by_external_id(external, display_name=external)
user.is_admin = is_admin_user(user, cfg)
return user
# Nicht von der Proxy-IP -> Header ignorieren, normale Auth unten.
# 2. Auth abgeschaltet (dev/Test).
if not cfg.auth_enabled:
return store.ensure_anonymous_user() return store.ensure_anonymous_user()
if not authorization or not authorization.lower().startswith("bearer "): # 3. Bearer-Token.
raise HTTPException(status_code=401, detail="Bearer token required") if not token:
return None
return store.get_user_by_token(token)
token = authorization.split(" ", 1)[1].strip()
user = store.get_user_by_token(token) def _bearer_token(authorization: str | None) -> str | None:
if authorization and authorization.lower().startswith("bearer "):
return authorization.split(" ", 1)[1].strip()
return None
def require_user(request: Request) -> User:
"""FastAPI-Dependency: liefert den authentifizierten Nutzer (sonst 401)."""
client_host = request.client.host if request.client else ""
token = _bearer_token(request.headers.get("authorization"))
user = authenticate(request.headers, client_host, token)
if user is None: if user is None:
raise HTTPException(status_code=401, detail="Invalid token") raise HTTPException(status_code=401, detail="Authentication required")
return user return user
@ -34,3 +76,18 @@ def require_admin(x_admin_key: str | None = Header(default=None)) -> None:
) )
if not x_admin_key or x_admin_key.strip() != expected: if not x_admin_key or x_admin_key.strip() != expected:
raise HTTPException(status_code=401, detail="Invalid admin key") raise HTTPException(status_code=401, detail="Invalid admin key")
def require_admin_or_user(
request: Request, x_admin_key: str | None = Header(default=None)
) -> User | None:
"""Erlaubt Zugriff fuer Admin-Nutzer (SSO/ADMIN_USERS) ODER gueltigen ADMIN_API_KEY."""
expected = settings.admin_api_key.strip()
if expected and x_admin_key and x_admin_key.strip() == expected:
return None
client_host = request.client.host if request.client else ""
token = _bearer_token(request.headers.get("authorization"))
user = authenticate(request.headers, client_host, token)
if user is not None and is_admin_user(user):
return user
raise HTTPException(status_code=403, detail="Admin privileges required")

View file

@ -141,6 +141,14 @@ class Settings(BaseSettings):
db_path: str = str(BASE_DIR / "data" / "voice-assistant.db") db_path: str = str(BASE_DIR / "data" / "voice-assistant.db")
admin_api_key: str = "" admin_api_key: str = ""
auth_enabled: bool = True auth_enabled: bool = True
# --- Forward-/Trusted-Header-Auth (Reverse-Proxy / YunoHost-SSO) ---------
# Ist trusted_auth_header gesetzt UND die Quell-IP in trusted_proxy_ips, wird die
# Identitaet aus diesem Header gelesen (SSO-User) und ein interner Nutzer
# automatisch angelegt. Sonst gilt die normale Token-/Anonymous-Auth.
trusted_auth_header: str = ""
trusted_proxy_ips: str = "" # kommasepariert; IP(s) des Reverse-Proxys
admin_users: str = "" # kommaseparierte SSO-Usernamen mit Admin-Rechten
sso_logout_url: str = "" # Logout-Link fuers Frontend (SSO-Portal)
history_max_messages: int = 10 history_max_messages: int = 10
# Automatische Erinnerungs-Extraktion: das LLM destilliert dauerhafte Fakten # Automatische Erinnerungs-Extraktion: das LLM destilliert dauerhafte Fakten
# aus dem Gespraech und legt sie als Nutzer-Erinnerungen ab (best-effort, # aus dem Gespraech und legt sie als Nutzer-Erinnerungen ab (best-effort,

View file

@ -1,6 +1,8 @@
import time import time
from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from app.metrics import metrics from app.metrics import metrics
from app.api.health import router as health_router from app.api.health import router as health_router
@ -44,3 +46,9 @@ app.include_router(admin_router, prefix="/api")
app.include_router(me_router, prefix="/api") app.include_router(me_router, prefix="/api")
app.include_router(metrics_router, prefix="/api") app.include_router(metrics_router, prefix="/api")
app.include_router(ws_router) app.include_router(ws_router)
# Statische Web-UI (same-origin -> kein CORS). Muss NACH allen API-/WS-Routen
# gemountet werden, damit "/" nur die uebrigen Pfade abfaengt.
_WEB_DIR = Path(__file__).resolve().parent / "web"
if _WEB_DIR.is_dir():
app.mount("/", StaticFiles(directory=str(_WEB_DIR), html=True), name="web")

View file

@ -33,6 +33,8 @@ class User:
display_name: str display_name: str
prefs: dict = field(default_factory=dict) prefs: dict = field(default_factory=dict)
created_at: str = "" created_at: str = ""
external_id: str | None = None # SSO-/Proxy-Identitaet (Forward-Auth)
is_admin: bool = False # transient, aus ADMIN_USERS abgeleitet
@dataclass @dataclass
@ -70,6 +72,17 @@ class Store(ABC):
@abstractmethod @abstractmethod
def ensure_anonymous_user(self) -> User: ... def ensure_anonymous_user(self) -> User: ...
@abstractmethod
def list_users(self) -> list[User]: ...
@abstractmethod
def get_user_by_external_id(self, external_id: str) -> User | None: ...
@abstractmethod
def get_or_create_user_by_external_id(
self, external_id: str, display_name: str | None = None
) -> User: ...
@abstractmethod @abstractmethod
def get_session(self, session_id: str) -> Session | None: ... def get_session(self, session_id: str) -> Session | None: ...
@ -132,7 +145,8 @@ class SQLiteStore(Store):
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL UNIQUE,
prefs_json TEXT NOT NULL DEFAULT '{}', prefs_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL created_at TEXT NOT NULL,
external_id TEXT
); );
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -174,14 +188,25 @@ class SQLiteStore(Store):
); );
""" """
) )
# Migration fuer bestehende DBs: external_id ergaenzen (falls noch nicht da).
cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)")}
if "external_id" not in cols:
conn.execute("ALTER TABLE users ADD COLUMN external_id TEXT")
# NULLs gelten in SQLite als verschieden -> Alt-Nutzer ohne external_id ok.
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external"
" ON users(external_id)"
)
# ----- Nutzer ----------------------------------------------------------- # ----- Nutzer -----------------------------------------------------------
def _row_to_user(self, row: sqlite3.Row) -> User: def _row_to_user(self, row: sqlite3.Row) -> User:
keys = row.keys()
return User( return User(
id=row["id"], id=row["id"],
display_name=row["display_name"], display_name=row["display_name"],
prefs=json.loads(row["prefs_json"] or "{}"), prefs=json.loads(row["prefs_json"] or "{}"),
created_at=row["created_at"], created_at=row["created_at"],
external_id=row["external_id"] if "external_id" in keys else None,
) )
def create_user(self, display_name: str) -> tuple[User, str]: def create_user(self, display_name: str) -> tuple[User, str]:
@ -230,6 +255,46 @@ class SQLiteStore(Store):
) )
return self.get_user(ANONYMOUS_USER_ID) return self.get_user(ANONYMOUS_USER_ID)
def list_users(self) -> list[User]:
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM users WHERE id != ? ORDER BY created_at",
(ANONYMOUS_USER_ID,),
).fetchall()
return [self._row_to_user(row) for row in rows]
def get_user_by_external_id(self, external_id: str) -> User | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM users WHERE external_id = ?", (external_id,)
).fetchone()
return self._row_to_user(row) if row else None
def get_or_create_user_by_external_id(
self, external_id: str, display_name: str | None = None
) -> User:
"""Findet den Nutzer zur SSO-/Proxy-Identitaet oder legt ihn an (Forward-Auth)."""
existing = self.get_user_by_external_id(external_id)
if existing:
return existing
user = User(
id=uuid.uuid4().hex,
display_name=display_name or external_id,
prefs={},
created_at=_now(),
external_id=external_id,
)
with self._connect() as conn:
conn.execute(
"INSERT INTO users (id, display_name, token_hash, prefs_json, created_at,"
" external_id) VALUES (?, ?, ?, ?, ?, ?)",
# token_hash ist NOT NULL UNIQUE -> synthetischer, kollisionsfreier Platzhalter
# (SSO-Nutzer authentifizieren sich nicht ueber ein Token).
(user.id, user.display_name, f"ext:{external_id}", "{}",
user.created_at, external_id),
)
return user
# ----- Sessions --------------------------------------------------------- # ----- Sessions ---------------------------------------------------------
def get_session(self, session_id: str) -> Session | None: def get_session(self, session_id: str) -> Session | None:
with self._connect() as conn: with self._connect() as conn:

210
app/web/app.js Normal file
View file

@ -0,0 +1,210 @@
"use strict";
const SESSION_ID = "web-main";
const $ = (sel) => document.querySelector(sel);
const messagesEl = $("#messages");
const statusEl = $("#status");
const promptEl = $("#prompt");
const formEl = $("#prompt-form");
const micBtn = $("#mic");
let busy = false;
// ---------- Identitaet / Menue ----------
async function loadMe() {
try {
const res = await fetch("/api/me", { headers: { Accept: "application/json" } });
if (!res.ok) {
$("#identity").textContent = "Nicht angemeldet";
return;
}
const me = await res.json();
$("#identity").textContent = "Angemeldet als " + (me.display_name || me.external_id || "Gast");
if (me.sso_logout_url) {
const logout = $("#logout");
logout.href = me.sso_logout_url;
logout.classList.remove("hidden");
}
if (me.is_admin) {
$("#admin").classList.remove("hidden");
}
} catch (e) {
$("#identity").textContent = "Verbindung fehlgeschlagen";
}
}
async function loadUsers() {
const ul = $("#user-list");
ul.innerHTML = "<li class='muted'>lade …</li>";
try {
const res = await fetch("/api/admin/users");
if (!res.ok) { ul.innerHTML = "<li class='muted'>kein Zugriff</li>"; return; }
const users = await res.json();
ul.innerHTML = "";
for (const u of users) {
const li = document.createElement("li");
li.textContent = (u.external_id || u.display_name) + " (" + u.user_id.slice(0, 8) + ")";
ul.appendChild(li);
}
if (!users.length) ul.innerHTML = "<li class='muted'>keine Nutzer</li>";
} catch (e) {
ul.innerHTML = "<li class='muted'>Fehler</li>";
}
}
// ---------- Nachrichten-UI ----------
function addMessage(role, text) {
const div = document.createElement("div");
div.className = "msg " + role;
div.textContent = text;
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return div;
}
// ---------- Audio-Wiedergabe (PCM s16le) ----------
let audioCtx = null;
function playPcm(chunks, sampleRate) {
if (!chunks.length) return;
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
const merged = new Uint8Array(total);
let off = 0;
for (const c of chunks) { merged.set(new Uint8Array(c), off); off += c.byteLength; }
const view = new DataView(merged.buffer);
const n = Math.floor(merged.byteLength / 2);
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
const buf = audioCtx.createBuffer(1, n, sampleRate || 24000);
const ch = buf.getChannelData(0);
for (let i = 0; i < n; i++) ch[i] = view.getInt16(i * 2, true) / 32768;
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
src.start();
}
// ---------- WS-Turn (Text und Sprache teilen die Event-Logik) ----------
function wsUrl(path) {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}${path}?session_id=${encodeURIComponent(SESSION_ID)}`;
}
// onopen: (ws) => sendet die Eingabe. Liefert ein Promise, das beim done-Event endet.
function runTurn(path, onopen) {
return new Promise((resolve) => {
const ws = new WebSocket(wsUrl(path));
ws.binaryType = "arraybuffer";
const pcm = [];
let answerEl = null;
let sampleRate = 24000;
ws.onopen = () => onopen(ws);
ws.onerror = () => { statusEl.textContent = "Verbindungsfehler"; resolve(); };
ws.onclose = () => resolve();
ws.onmessage = (event) => {
if (typeof event.data !== "string") { pcm.push(event.data); return; }
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
switch (msg.type) {
case "transcript":
if (msg.text) addMessage("user", msg.text);
break;
case "token":
if (!answerEl) answerEl = addMessage("assistant", "");
answerEl.textContent += msg.text || "";
messagesEl.scrollTop = messagesEl.scrollHeight;
break;
case "semantic":
if (!answerEl) answerEl = addMessage("assistant", "");
if (msg.text) answerEl.textContent = msg.text;
break;
case "emergency":
addMessage("emergency", "⚠ Notfall erkannt (" + (msg.category || "?") + ")");
break;
case "done":
sampleRate = msg.sample_rate || 24000;
playPcm(pcm, sampleRate);
statusEl.textContent = "";
ws.close();
break;
case "error":
addMessage("system", "Fehler: " + (msg.detail || msg.status || "unbekannt"));
ws.close();
break;
}
};
});
}
// ---------- Text senden ----------
async function sendText(text) {
if (busy || !text.trim()) return;
busy = true;
addMessage("user", text);
statusEl.textContent = "denkt …";
await runTurn("/ws/chat", (ws) => {
ws.send(JSON.stringify({ text, stream: true, audio_stream: true }));
});
busy = false;
}
formEl.addEventListener("submit", (e) => {
e.preventDefault();
const text = promptEl.value;
promptEl.value = "";
sendText(text);
});
// ---------- Mikrofon (Push-to-Talk) ----------
let mediaRecorder = null;
let recChunks = [];
async function startRecording() {
if (busy) return;
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (e) {
addMessage("system", "Mikrofon nicht verfuegbar (HTTPS noetig?). " + e.message);
return;
}
recChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (e) => { if (e.data.size) recChunks.push(e.data); };
mediaRecorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || "audio/webm" });
const bytes = await blob.arrayBuffer();
sendVoice(bytes);
};
mediaRecorder.start();
micBtn.classList.add("recording");
statusEl.textContent = "Aufnahme … (zum Stoppen erneut tippen)";
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop();
micBtn.classList.remove("recording");
}
async function sendVoice(bytes) {
if (busy) return;
busy = true;
statusEl.textContent = "verarbeite Sprache …";
await runTurn("/ws/voice", (ws) => {
ws.send(JSON.stringify({ type: "start", format: "webm", stream: true, audio_stream: true }));
ws.send(bytes);
ws.send(JSON.stringify({ type: "end" }));
});
busy = false;
}
micBtn.addEventListener("click", () => {
if (mediaRecorder && mediaRecorder.state === "recording") stopRecording();
else startRecording();
});
$("#reload-users").addEventListener("click", loadUsers);
loadMe();

36
app/web/index.html Normal file
View file

@ -0,0 +1,36 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Voice Assistant</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div id="layout">
<aside id="menu">
<h2>Voice Assistant</h2>
<div id="identity" class="muted">lade …</div>
<div id="admin" class="hidden">
<h3>Admin</h3>
<button id="reload-users">Nutzer laden</button>
<ul id="user-list"></ul>
<a href="/api/metrics" target="_blank" rel="noopener">Metriken</a>
</div>
<a id="logout" class="hidden" href="#">Abmelden</a>
</aside>
<main id="chat">
<div id="messages"></div>
<form id="prompt-form">
<button type="button" id="mic" title="Mikrofon" aria-label="Mikrofon">🎤</button>
<input id="prompt" type="text" placeholder="Nachricht eingeben …" autocomplete="off" />
<button type="submit" id="send">Senden</button>
</form>
<div id="status" class="muted"></div>
</main>
</div>
<script src="/app.js"></script>
</body>
</html>

52
app/web/style.css Normal file
View file

@ -0,0 +1,52 @@
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: #1c1c1c;
background: #f6f7f9;
}
#layout { display: flex; min-height: 100vh; }
#menu {
width: 240px;
flex-shrink: 0;
background: #1f2933;
color: #e4e7eb;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
#menu h2 { font-size: 1.1rem; margin: 0; }
#menu h3 { font-size: 0.9rem; margin: 0 0 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; }
#menu a { color: #9fd3ff; }
#identity { font-size: 0.9rem; }
.muted { color: #7b8794; font-size: 0.85rem; }
.hidden { display: none; }
#user-list { list-style: none; padding: 0; margin: 0 0 0.5rem; font-size: 0.85rem; }
#user-list li { padding: 0.15rem 0; border-bottom: 1px solid #323f4b; }
#chat { flex: 1; display: flex; flex-direction: column; padding: 1rem; max-width: 820px; margin: 0 auto; width: 100%; }
#messages { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.6rem; padding-bottom: 1rem; }
.msg { padding: 0.6rem 0.8rem; border-radius: 0.8rem; max-width: 80%; white-space: pre-wrap; line-height: 1.4; }
.msg.user { align-self: flex-end; background: #2b6cb0; color: #fff; }
.msg.assistant { align-self: flex-start; background: #fff; border: 1px solid #e1e4e8; }
.msg.system { align-self: center; background: #fdf3d7; border: 1px solid #f0d98c; font-size: 0.85rem; }
.msg.emergency { align-self: center; background: #fde0e0; border: 1px solid #f5a3a3; font-weight: 600; }
#prompt-form { display: flex; gap: 0.5rem; align-items: center; }
#prompt { flex: 1; padding: 0.7rem; border: 1px solid #cbd2d9; border-radius: 0.6rem; font-size: 1rem; }
button { padding: 0.7rem 1rem; border: none; border-radius: 0.6rem; background: #2b6cb0; color: #fff; font-size: 1rem; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: default; }
#mic { background: #2f855a; }
#mic.recording { background: #c53030; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } }
#status { min-height: 1.2rem; margin-top: 0.4rem; }
@media (max-width: 640px) {
#layout { flex-direction: column; }
#menu { width: 100%; flex-direction: row; flex-wrap: wrap; align-items: center; }
#menu h2 { flex: 1; }
}

53
deploy/README.md Normal file
View file

@ -0,0 +1,53 @@
# Remote-Betrieb über YunoHost (assistent.linix.de)
Web-UI + Mikrofon vom Handy/Browser, abgesichert per HTTPS und YunoHost-SSO.
Topologie: `https://assistent.linix.de` → nginx@YunoHost (TLS, SSO) → über LAN →
`http://<GPU-Box>:8003` (Gateway). Der interne LAN-Hop ist unverschlüsselt
(vertrautes LAN, wie bei Ollama).
> **Warum HTTPS Pflicht ist:** Browser geben das Mikrofon (`getUserMedia`) nur in
> einem „secure context" frei also HTTPS oder `http://localhost`. Über einfaches
> `http://` vom Handy gibt es **kein** Mikrofon.
## 1. Gateway auf der GPU-Box
`deploy/voice-assistant.env.example``/etc/voice-assistant/voice-assistant.env` anpassen:
- `HOST` = **LAN-IP** der GPU-Box (nicht `0.0.0.0`), `PORT=8003`
- `AUTH_ENABLED=true`
- `TRUSTED_AUTH_HEADER` (nach Discovery, s. u.), `TRUSTED_PROXY_IPS` = LAN-IP von linix.de
- `ADMIN_USERS=atoor,dieterschlueter,dschlueter`, `SSO_LOGOUT_URL=…`
> Kein `--forwarded-allow-ips` setzen: Das Identitäts-Vertrauen prüft die
> **direkte Quell-IP** (= der Proxy). Würde uvicorn den Client aus
> `X-Forwarded-For` überschreiben, schlüge der Proxy-IP-Check fehl.
## 2. Drei Sicherheits-Pflichten
1. **Bind:** Gateway nur an die LAN-IP (Schritt 1).
2. **Firewall:** Port 8003 der GPU-Box **nur** von der linix.de-IP erlauben, z. B.:
```
sudo ufw allow from <linix.de_LAN_IP> to any port 8003 proto tcp
sudo ufw deny 8003
```
3. **Header überschreiben:** nginx setzt den Identitäts-Header selbst; Client-Eingaben
werden verworfen (siehe nginx-Conf). App-seitig zusätzlich der Proxy-IP-Check.
## 3. nginx auf YunoHost
`deploy/assistent.linix.de.nginx.conf` als Vorlage → auf dem YunoHost-Server unter
`/etc/nginx/conf.d/assistent.linix.de.d/assistant.conf` ablegen, `GPU_BOX_LAN_IP`
eintragen, die `map $http_upgrade …` einmalig im http{}-Kontext anlegen, dann
`nginx -t && systemctl reload nginx`. Subdomain `assistent.linix.de` in YunoHost
anlegen (Let's Encrypt) und per SSO schützen (nur erlaubte Tester/Gruppe).
## 4. Discovery: richtigen Identitäts-Header bestimmen
Als Admin **hinter dem SSO** aufrufen:
```
https://assistent.linix.de/api/admin/request-headers
```
Dort den Header finden, der den eingeloggten SSO-Usernamen trägt (z. B. `X-Remote-User`,
`Remote-User`, `Auth-User` …). Diesen Namen in `TRUSTED_AUTH_HEADER` **und** in der
nginx-`proxy_set_header …`-Zeile eintragen, Gateway + nginx neu laden.
## 5. Test
- `https://assistent.linix.de/` lädt die UI, links „Angemeldet als <SSO-User>".
- Text-Chat funktioniert; **Mikrofon-Button** nimmt auf und spielt die Antwort ab.
- Admins (`ADMIN_USERS`) sehen den Admin-Bereich (Nutzerliste).

View file

@ -0,0 +1,42 @@
# Reverse-Proxy fuer die Voice-Assistant-Web-UI auf einem YunoHost-Server.
#
# Ziel: https://assistent.linix.de -> http://<GPU_BOX_LAN_IP>:8003 (Gateway im LAN)
# YunoHost terminiert TLS (Let's Encrypt) und schuetzt die Subdomain per SSO.
#
# Ablage auf dem YunoHost-Server (Beispiel):
# /etc/nginx/conf.d/assistent.linix.de.d/assistant.conf
# danach: nginx -t && systemctl reload nginx
#
# WICHTIG (Unterschied zu Ollama): WebSockets (/ws/voice, /ws/chat) brauchen die
# Upgrade-Header und einen langen read-timeout - sonst bricht der Mikrofon-Button.
# --- einmalig im http{}-Kontext (z. B. /etc/nginx/conf.d/websocket-upgrade.conf) ---
# map $http_upgrade $connection_upgrade {
# default upgrade;
# '' close;
# }
location / {
proxy_pass http://GPU_BOX_LAN_IP:8003; # <-- LAN-IP der GPU-Box eintragen
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket-Upgrade (zwingend fuer das Mikrofon):
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# Identitaets-Header: SSOwat setzt die Nutzeridentitaet. Nach der Discovery
# (GET /api/admin/request-headers hinter dem SSO) den richtigen Namen hier
# FEST setzen und Client-Spoofing verwerfen. Beispiel, wenn SSO $remote_user
# bereitstellt (Name ggf. anpassen):
#
# proxy_set_header X-Remote-User $remote_user;
#
# In der Gateway-Konfiguration dann: TRUSTED_AUTH_HEADER=X-Remote-User
}

View file

@ -1,12 +1,22 @@
HOST=0.0.0.0 # Nur an die LAN-IP binden (NICHT 0.0.0.0), erreichbar allein fuer den Reverse-Proxy.
PORT=8080 HOST=192.168.0.50 # <-- LAN-IP der GPU-Box
PORT=8003
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
# Profil und Konfiguration # Profil und Konfiguration
VA_PROFILE=cloud VA_PROFILE=cloud
# Authentifizierung (Produktion: an). ADMIN_API_KEY fuer die Nutzerverwaltung. # Authentifizierung (Produktion: an). ADMIN_API_KEY fuer die Nutzerverwaltung (Fallback).
AUTH_ENABLED=true AUTH_ENABLED=true
ADMIN_API_KEY= ADMIN_API_KEY=
# --- Forward-/Trusted-Header-Auth via YunoHost-SSO ---------------------------
# Nach der Discovery (GET /api/admin/request-headers hinter dem SSO) den echten
# Header-Namen eintragen. Identitaet wird nur von der Proxy-Quell-IP akzeptiert.
TRUSTED_AUTH_HEADER=X-Remote-User
TRUSTED_PROXY_IPS=192.168.0.10 # <-- LAN-IP des YunoHost-Servers (linix.de)
ADMIN_USERS=atoor,dieterschlueter,dschlueter
SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout
# Persistente Datenbank (Pfad auf dem Host) # Persistente Datenbank (Pfad auf dem Host)
DB_PATH=/var/lib/voice-assistant/voice-assistant.db DB_PATH=/var/lib/voice-assistant/voice-assistant.db

View file

@ -0,0 +1,77 @@
import pytest
from fastapi.testclient import TestClient
import app.dependencies as deps
from app.config import settings
from app.main import app
client = TestClient(app)
HEADER = "X-Remote-User"
# Starlette-TestClient meldet sich als Host "testclient" -> als vertrauten Proxy setzen.
TRUSTED = "testclient"
@pytest.fixture
def forward_auth(monkeypatch):
monkeypatch.setattr(settings, "trusted_auth_header", HEADER)
monkeypatch.setattr(settings, "trusted_proxy_ips", TRUSTED)
monkeypatch.setattr(settings, "admin_users", "atoor,dieterschlueter,dschlueter")
monkeypatch.setattr(settings, "auth_enabled", True)
def test_forward_auth_provisions_user(forward_auth):
r = client.get("/api/me", headers={HEADER: "lieschen"})
assert r.status_code == 200
body = r.json()
assert body["external_id"] == "lieschen"
assert body["is_admin"] is False
# Nutzer wurde im Store angelegt.
assert deps.get_store().get_user_by_external_id("lieschen") is not None
def test_admin_users_flagged(forward_auth):
for name in ("atoor", "dieterschlueter", "dschlueter"):
r = client.get("/api/me", headers={HEADER: name})
assert r.status_code == 200
assert r.json()["is_admin"] is True, name
def test_untrusted_ip_ignores_header(monkeypatch):
# Proxy-IP passt NICHT zur TestClient-Quelle -> Header wird ignoriert.
monkeypatch.setattr(settings, "trusted_auth_header", HEADER)
monkeypatch.setattr(settings, "trusted_proxy_ips", "10.9.9.9")
monkeypatch.setattr(settings, "auth_enabled", False) # Fallback -> anonym
r = client.get("/api/me", headers={HEADER: "angreifer"})
assert r.status_code == 200
assert r.json()["external_id"] is None # nicht als 'angreifer' uebernommen
def test_missing_header_from_trusted_proxy_401(forward_auth):
r = client.get("/api/me") # vertrauter Proxy, aber kein Identitaets-Header
assert r.status_code == 401
def test_get_or_create_idempotent(forward_auth):
r1 = client.get("/api/me", headers={HEADER: "wiederkehr"})
r2 = client.get("/api/me", headers={HEADER: "wiederkehr"})
assert r1.json()["user_id"] == r2.json()["user_id"]
users = [u for u in deps.get_store().list_users() if u.external_id == "wiederkehr"]
assert len(users) == 1
def test_admin_users_endpoint_gated(forward_auth):
# Admin darf die Liste sehen.
client.get("/api/me", headers={HEADER: "lieschen"}) # einen Nutzer anlegen
r_admin = client.get("/api/admin/users", headers={HEADER: "dschlueter"})
assert r_admin.status_code == 200
assert any(u["external_id"] == "lieschen" for u in r_admin.json())
# Nicht-Admin wird abgewiesen.
r_user = client.get("/api/admin/users", headers={HEADER: "lieschen"})
assert r_user.status_code == 403
def test_static_index_served():
r = client.get("/")
assert r.status_code == 200
assert "Voice Assistant" in r.text