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 = "