Compare commits
No commits in common. "07824047232791c5481351e3ceb11f11f72e44ed" and "e600fada5a8cf8c9aaeeb617b6ff266fd1c2315b" have entirely different histories.
0782404723
...
e600fada5a
2 changed files with 3 additions and 197 deletions
96
app.py
96
app.py
|
|
@ -1,13 +1,13 @@
|
||||||
"""Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab.
|
"""Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab.
|
||||||
Reads service config from butler.yaml, credentials from Vaultwarden cache with flat-file fallback."""
|
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
|
from datetime import datetime, timezone
|
||||||
import httpx, yaml
|
import httpx, yaml
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel
|
||||||
from fastapi import FastAPI, Request, HTTPException, Depends, Query
|
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
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
log = logging.getLogger("butler")
|
log = logging.getLogger("butler")
|
||||||
|
|
@ -273,7 +273,6 @@ async def root():
|
||||||
"inventory_add": "POST /inventory/host {name, ip, group?}",
|
"inventory_add": "POST /inventory/host {name, ip, group?}",
|
||||||
"ansible_run": "POST /ansible/run {hostname}",
|
"ansible_run": "POST /ansible/run {hostname}",
|
||||||
"tts_speak": "POST /tts/speak {text, target: speaker|telegram}",
|
"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_voices": "GET /tts/voices",
|
||||||
"tts_health": "GET /tts/health",
|
"tts_health": "GET /tts/health",
|
||||||
"status": "GET /status - health of all backends",
|
"status": "GET /status - health of all backends",
|
||||||
|
|
@ -1835,98 +1834,9 @@ class TTSRequest(BaseModel):
|
||||||
voice: str = "deep_thought.mp3"
|
voice: str = "deep_thought.mp3"
|
||||||
language: str = "de"
|
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"
|
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"
|
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")
|
@app.post("/tts/speak")
|
||||||
async def tts_speak(req: TTSRequest, _=Depends(_verify)):
|
async def tts_speak(req: TTSRequest, _=Depends(_verify)):
|
||||||
if req.target == "speaker":
|
if req.target == "speaker":
|
||||||
|
|
|
||||||
|
|
@ -45,110 +45,6 @@ def test_health_exposes_current_version():
|
||||||
assert response.json()["version"] == app.VERSION == "2.3.5"
|
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):
|
def test_sysctl_audit_reads_fixed_keys_from_inventory_host(monkeypatch):
|
||||||
payload = {
|
payload = {
|
||||||
"live": {"net.ipv4.tcp_congestion_control": "bbr"},
|
"live": {"net.ipv4.tcp_congestion_control": "bbr"},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue