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:
parent
855fc71a1e
commit
76df695111
16 changed files with 703 additions and 29 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
81
app/auth.py
81
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.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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
67
app/store.py
67
app/store.py
|
|
@ -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
210
app/web/app.js
Normal 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
36
app/web/index.html
Normal 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
52
app/web/style.css
Normal 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
53
deploy/README.md
Normal 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).
|
||||||
42
deploy/assistent.linix.de.nginx.conf
Normal file
42
deploy/assistent.linix.de.nginx.conf
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
77
tests/test_forward_auth.py
Normal file
77
tests/test_forward_auth.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue