diff --git a/.env.example b/.env.example index d4b6c00..c97974f 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,13 @@ AUTH_ENABLED=true # Schluessel fuer die Nutzerverwaltung (POST /api/admin/users). Nur ueber die Umgebung. 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 ------------------------------------- # Aktives Profil aus config/voice-assistant.toml waehlen: local-dev | hybrid | cloud # (leer lassen = nur Defaults/ENV). Eigener Pfad via VA_CONFIG_FILE. diff --git a/README.md b/README.md index 9296074..dd3a751 100644 --- a/README.md +++ b/README.md @@ -377,10 +377,25 @@ sed -i 's/^PORT=.*/PORT=8003/' .env # dauerhaft 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 - **Docker:** `docker compose up --build` (reicht `OPENROUTER_API_KEY` aus der Shell durch) - **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) diff --git a/app/api/admin.py b/app/api/admin.py index c69ee72..bcafae4 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -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.schemas import UserCreate, UserCreated 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)]) async def create_user(payload: UserCreate): """Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck.""" user, token = get_store().create_user(payload.display_name) 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() + ] diff --git a/app/api/me.py b/app/api/me.py index 95855e8..0e87f7a 100644 --- a/app/api/me.py +++ b/app/api/me.py @@ -1,6 +1,7 @@ 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.schemas import UserPrefs, MemoryCreate, MemoryOut from app.store import User @@ -10,7 +11,14 @@ router = APIRouter() @router.get("/me") 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") diff --git a/app/api/ws.py b/app/api/ws.py index 0f75111..48aeb13 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -31,6 +31,7 @@ from app.store import SessionOwnershipError from app.audio.vad import EnergyVAD from app.core.memory_extractor import maybe_schedule_extraction 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 router = APIRouter() @@ -45,13 +46,11 @@ _OVERRIDE_KEYS = ( ) -def _authenticate(token: str | None): - store = get_store() - if not settings.auth_enabled: - return store.ensure_anonymous_user() - if not token: - return None - return store.get_user_by_token(token) +def _authenticate(websocket: WebSocket, token: str | None): + # Forward-Auth (SSO) greift auch beim WS-Handshake: SSOwat injiziert den + # Identitaets-Header in den Upgrade-Request -> aus websocket.headers lesbar. + client_host = websocket.client.host if websocket.client else "" + return authenticate(websocket.headers, client_host, token) 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") 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: await websocket.close(code=1008) return @@ -244,7 +243,7 @@ async def ws_chat(websocket: WebSocket, session_id: str | None = None, token: st @router.websocket("/ws/voice") 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: await websocket.close(code=1008) return diff --git a/app/auth.py b/app/auth.py index 93407d3..bb55720 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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.store import User -def require_user(authorization: str | None = Header(default=None)) -> User: - """FastAPI-Dependency: liefert den authentifizierten Nutzer. +def _csv_set(value: str) -> set[str]: + 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() - 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() - if not authorization or not authorization.lower().startswith("bearer "): - raise HTTPException(status_code=401, detail="Bearer token required") + # 3. Bearer-Token. + 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: - raise HTTPException(status_code=401, detail="Invalid token") + raise HTTPException(status_code=401, detail="Authentication required") 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: 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") diff --git a/app/config.py b/app/config.py index 717a383..92cf500 100644 --- a/app/config.py +++ b/app/config.py @@ -141,6 +141,14 @@ class Settings(BaseSettings): db_path: str = str(BASE_DIR / "data" / "voice-assistant.db") admin_api_key: str = "" 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 # Automatische Erinnerungs-Extraktion: das LLM destilliert dauerhafte Fakten # aus dem Gespraech und legt sie als Nutzer-Erinnerungen ab (best-effort, diff --git a/app/main.py b/app/main.py index 0b12281..fb6fcbf 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,8 @@ import time +from pathlib import Path from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles from app.metrics import metrics 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(metrics_router, prefix="/api") 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") diff --git a/app/store.py b/app/store.py index a06b2da..ead900b 100644 --- a/app/store.py +++ b/app/store.py @@ -33,6 +33,8 @@ class User: display_name: str prefs: dict = field(default_factory=dict) created_at: str = "" + external_id: str | None = None # SSO-/Proxy-Identitaet (Forward-Auth) + is_admin: bool = False # transient, aus ADMIN_USERS abgeleitet @dataclass @@ -70,6 +72,17 @@ class Store(ABC): @abstractmethod 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 def get_session(self, session_id: str) -> Session | None: ... @@ -132,7 +145,8 @@ class SQLiteStore(Store): display_name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, 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 ( 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 ----------------------------------------------------------- def _row_to_user(self, row: sqlite3.Row) -> User: + keys = row.keys() return User( id=row["id"], display_name=row["display_name"], prefs=json.loads(row["prefs_json"] or "{}"), 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]: @@ -230,6 +255,46 @@ class SQLiteStore(Store): ) 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 --------------------------------------------------------- def get_session(self, session_id: str) -> Session | None: with self._connect() as conn: diff --git a/app/web/app.js b/app/web/app.js new file mode 100644 index 0000000..75ad3f1 --- /dev/null +++ b/app/web/app.js @@ -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 = "
  • lade …
  • "; + try { + const res = await fetch("/api/admin/users"); + if (!res.ok) { ul.innerHTML = "
  • kein Zugriff
  • "; 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 = "
  • keine Nutzer
  • "; + } catch (e) { + ul.innerHTML = "
  • Fehler
  • "; + } +} + +// ---------- 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(); diff --git a/app/web/index.html b/app/web/index.html new file mode 100644 index 0000000..c287c68 --- /dev/null +++ b/app/web/index.html @@ -0,0 +1,36 @@ + + + + + + Voice Assistant + + + +
    + + +
    +
    + +
    + + + +
    +
    +
    +
    + + + diff --git a/app/web/style.css b/app/web/style.css new file mode 100644 index 0000000..d7e191a --- /dev/null +++ b/app/web/style.css @@ -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; } +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..ea45337 --- /dev/null +++ b/deploy/README.md @@ -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://: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 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 ". +- Text-Chat funktioniert; **Mikrofon-Button** nimmt auf und spielt die Antwort ab. +- Admins (`ADMIN_USERS`) sehen den Admin-Bereich (Nutzerliste). diff --git a/deploy/assistent.linix.de.nginx.conf b/deploy/assistent.linix.de.nginx.conf new file mode 100644 index 0000000..eafa7bc --- /dev/null +++ b/deploy/assistent.linix.de.nginx.conf @@ -0,0 +1,42 @@ +# Reverse-Proxy fuer die Voice-Assistant-Web-UI auf einem YunoHost-Server. +# +# Ziel: https://assistent.linix.de -> http://: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 +} diff --git a/deploy/voice-assistant.env.example b/deploy/voice-assistant.env.example index 578603b..21341af 100644 --- a/deploy/voice-assistant.env.example +++ b/deploy/voice-assistant.env.example @@ -1,12 +1,22 @@ -HOST=0.0.0.0 -PORT=8080 +# Nur an die LAN-IP binden (NICHT 0.0.0.0), erreichbar allein fuer den Reverse-Proxy. +HOST=192.168.0.50 # <-- LAN-IP der GPU-Box +PORT=8003 OPENROUTER_API_KEY= # Profil und Konfiguration 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 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) DB_PATH=/var/lib/voice-assistant/voice-assistant.db diff --git a/tests/test_forward_auth.py b/tests/test_forward_auth.py new file mode 100644 index 0000000..15b6d2a --- /dev/null +++ b/tests/test_forward_auth.py @@ -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