diff --git a/app.py b/app.py index 36afdb7..9f30e11 100644 --- a/app.py +++ b/app.py @@ -1,13 +1,13 @@ """Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab. Reads service config from butler.yaml, credentials from Vaultwarden cache with flat-file fallback.""" -import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets +import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress from datetime import datetime, timezone import httpx, yaml from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel from fastapi import FastAPI, Request, HTTPException, Depends, Query -from fastapi.responses import JSONResponse, RedirectResponse, Response +from fastapi.responses import JSONResponse, RedirectResponse from contextlib import asynccontextmanager log = logging.getLogger("butler") @@ -273,7 +273,6 @@ async def root(): "inventory_add": "POST /inventory/host {name, ip, group?}", "ansible_run": "POST /ansible/run {hostname}", "tts_speak": "POST /tts/speak {text, target: speaker|telegram}", - "tts_generate": "POST /tts/generate {text, voice?, language?} - return cloned WAV audio", "tts_voices": "GET /tts/voices", "tts_health": "GET /tts/health", "status": "GET /status - health of all backends", @@ -1835,98 +1834,9 @@ class TTSRequest(BaseModel): voice: str = "deep_thought.mp3" language: str = "de" - -class TTSGenerateRequest(BaseModel): - text: str = Field(min_length=1, max_length=2000) - voice: str = Field(default="deep_thought.mp3", pattern=r"^[A-Za-z0-9_.-]+$") - language: str = Field(default="de", pattern=r"^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{2,8})?$") - - -class TTSBridgeDeployRequest(BaseModel): - rotate_client_token: bool = False - - SPEAKER_URL = TTS_CFG.get("speaker_url", "http://10.10.1.166:10800") if TTS_CFG else "http://10.10.1.166:10800" CHATTERBOX_URL = TTS_CFG.get("chatterbox_url", "http://10.2.1.104:8004/tts") if TTS_CFG else "http://10.2.1.104:8004/tts" - -def _chatterbox_payload(text: str, voice: str, language: str) -> dict: - return { - "text": text, - "voice_mode": "clone", - "reference_audio_filename": voice, - "output_format": "wav", - "language": language, - "exaggeration": 0.3, - "cfg_weight": 0.7, - "temperature": 0.6, - } - - -@app.post("/tts/generate", response_class=Response) -async def tts_generate(req: TTSGenerateRequest, _=Depends(_verify)): - """Generate cloned speech and return the WAV bytes to the authenticated caller.""" - async with httpx.AsyncClient(verify=False, timeout=180) as client: - try: - result = await client.post(CHATTERBOX_URL, json=_chatterbox_payload(req.text, req.voice, req.language)) - except httpx.RequestError: - _audit("/tts/generate", "POST", 502, "chatterbox request failed") - raise HTTPException(status_code=502, detail="Chatterbox is unavailable") - - if result.status_code != 200: - _audit("/tts/generate", "POST", 502, f"chatterbox_http={result.status_code}") - raise HTTPException(status_code=502, detail="Chatterbox generation failed") - if not result.content.startswith(b"RIFF"): - _audit("/tts/generate", "POST", 502, "invalid audio response") - raise HTTPException(status_code=502, detail="Chatterbox returned invalid audio") - - _audit("/tts/generate", "POST", 200, f"voice={req.voice} chars={len(req.text)}") - return Response( - content=result.content, - media_type="audio/wav", - headers={ - "Content-Disposition": 'inline; filename="voiceclone.wav"', - "Cache-Control": "no-store", - "X-Content-Type-Options": "nosniff", - }, - ) - - -@app.post("/tts/bridge/deploy") -async def tts_bridge_deploy(req: TTSBridgeDeployRequest, _=Depends(_verify)): - """Install host-local bridge secrets on automation1 without exposing them.""" - client_token = _vault_cache.get("tts_bridge_client_token", "").strip() - if req.rotate_client_token or not client_token: - client_token = secrets.token_urlsafe(32) - if not BUTLER_TOKEN: - raise HTTPException(status_code=500, detail="Butler token is not configured") - - files = { - "/app-config/tts-bridge/butler-token": BUTLER_TOKEN, - "/app-config/tts-bridge/client-token": client_token, - } - encoded = base64.b64encode(json.dumps(files).encode()).decode() - script = ( - "import base64,json,os,pathlib;" - f"files=json.loads(base64.b64decode('{encoded}'));" - "pathlib.Path('/app-config/tts-bridge').mkdir(parents=True,exist_ok=True);" - "[(pathlib.Path(p).write_text(v),os.chmod(p,0o600)) for p,v in files.items()]" - ) - rc, _out, err = _ssh("sascha@10.5.85.5", f"python3 -c {__import__('shlex').quote(script)}", timeout=30) - if rc != 0: - _audit("/tts/bridge/deploy", "POST", 500, "secret installation failed") - raise HTTPException(status_code=500, detail="Could not install bridge secrets") - - _audit("/tts/bridge/deploy", "POST", 200, f"rotated={req.rotate_client_token or not _vault_cache.get('tts_bridge_client_token')}") - return { - "status": "ready", - "host": "automation1", - "listen": "0.0.0.0:8099", - "client_token": client_token, - "rotated": req.rotate_client_token or not _vault_cache.get("tts_bridge_client_token"), - } - - @app.post("/tts/speak") async def tts_speak(req: TTSRequest, _=Depends(_verify)): if req.target == "speaker": diff --git a/tests/test_app.py b/tests/test_app.py index 3a3be69..e9960d4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -45,110 +45,6 @@ def test_health_exposes_current_version(): assert response.json()["version"] == app.VERSION == "2.3.5" -def test_tts_generate_returns_cloned_wav(monkeypatch): - captured = {} - - class FakeResponse: - status_code = 200 - content = b"RIFF" + b"test-wave" - - class FakeClient: - def __init__(self, **_kwargs): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, *_args): - return False - - async def post(self, url, json): - captured["url"] = url - captured["json"] = json - return FakeResponse() - - monkeypatch.setattr(app.httpx, "AsyncClient", FakeClient) - with TestClient(app.app) as client: - response = client.post( - "/tts/generate", - headers={"Authorization": "Bearer test-token"}, - json={"text": "Hallo Sascha", "voice": "deep_thought.mp3", "language": "de"}, - ) - assert response.status_code == 200 - assert response.headers["content-type"].startswith("audio/wav") - assert response.content.startswith(b"RIFF") - assert captured["json"]["voice_mode"] == "clone" - assert captured["json"]["reference_audio_filename"] == "deep_thought.mp3" - - -def test_tts_generate_validates_text_and_voice_before_backend(monkeypatch): - monkeypatch.setattr( - app.httpx, - "AsyncClient", - lambda **_kwargs: (_ for _ in ()).throw(AssertionError("backend must not be called")), - ) - with TestClient(app.app) as client: - empty = client.post( - "/tts/generate", - headers={"Authorization": "Bearer test-token"}, - json={"text": ""}, - ) - traversal = client.post( - "/tts/generate", - headers={"Authorization": "Bearer test-token"}, - json={"text": "Hallo", "voice": "../secret.wav"}, - ) - assert empty.status_code == 422 - assert traversal.status_code == 422 - - -def test_tts_generate_rejects_non_wav_backend_response(monkeypatch): - class FakeResponse: - status_code = 200 - content = b"not audio" - - class FakeClient: - def __init__(self, **_kwargs): - pass - - async def __aenter__(self): - return self - - async def __aexit__(self, *_args): - return False - - async def post(self, _url, json): - return FakeResponse() - - monkeypatch.setattr(app.httpx, "AsyncClient", FakeClient) - with TestClient(app.app) as client: - response = client.post( - "/tts/generate", - headers={"Authorization": "Bearer test-token"}, - json={"text": "Hallo"}, - ) - assert response.status_code == 502 - assert "invalid audio" in response.text - - -def test_tts_bridge_deploy_installs_secrets_without_logging_them(monkeypatch): - calls = [] - monkeypatch.setattr(app, "BUTLER_TOKEN", "butler-secret") - monkeypatch.setattr(app, "_vault_cache", {}) - monkeypatch.setattr(app, "_ssh", lambda host, command, timeout=30: (calls.append((host, command, timeout)) or (0, "", ""))) - with TestClient(app.app) as client: - response = client.post( - "/tts/bridge/deploy", - headers={"Authorization": "Bearer butler-secret"}, - json={"rotate_client_token": True}, - ) - assert response.status_code == 200 - assert response.json()["listen"] == "0.0.0.0:8099" - assert len(response.json()["client_token"]) >= 32 - assert calls[0][0] == "sascha@10.5.85.5" - assert "butler-secret" not in calls[0][1] - - def test_sysctl_audit_reads_fixed_keys_from_inventory_host(monkeypatch): payload = { "live": {"net.ipv4.tcp_congestion_control": "bbr"},