Compare commits

..

44 commits

Author SHA1 Message Date
0d9a43c8f6 Merge pull request 'fix: set TTS bridge secret ownership' (#22) from fix/tts-secret-owner-20260813130906 into main 2026-08-13 13:09:09 +02:00
cb54a7977a fix: make bridge secrets readable by service uid 2026-08-13 13:09:07 +02:00
64e4a1fb5b Merge pull request 'fix: recover TTS bridge secret bind mounts' (#21) from fix/tts-bridge-bind-mount-20260813130448 into main 2026-08-13 13:04:51 +02:00
0e9a42bb4f fix: replace Docker-created secret directories 2026-08-13 13:04:49 +02:00
723b3e5a61 Merge pull request 'fix: install TTS bridge secrets with sudo' (#20) from fix/tts-bridge-sudo-20260813130328 into main 2026-08-13 13:03:30 +02:00
9fc08a3145 fix: install TTS bridge secrets with sudo 2026-08-13 13:03:28 +02:00
0782404723 Merge pull request 'feat: add voiceclone audio API and bridge bootstrap' (#19) from feat/tts-generate-20260813125855 into main 2026-08-13 12:58:58 +02:00
d3e7ed7f06 feat: add voiceclone support (tests/test_app.py) 2026-08-13 12:58:56 +02:00
6de61cc631 feat: add voiceclone support (app.py) 2026-08-13 12:58:55 +02:00
e600fada5a Merge pull request 'Fix BW Manager deploy staging argv limit' (#18) from fix/bw-deploy-argv-20260812 into main 2026-08-12 13:01:48 +02:00
83feac4a79 Stage BW Manager files separately to avoid argv limit 2026-08-12 13:01:47 +02:00
dbf3c3fb45 Merge pull request 'Add safe BW Manager deployment endpoint' (#17) from feature/bw-manager-deploy-20260812 into main 2026-08-12 13:00:18 +02:00
2b8a7c27a2 Test BW Manager deploy endpoint safety gates 2026-08-12 13:00:10 +02:00
2409d8cee1 Add safe Git-managed BW Manager deploy endpoint 2026-08-12 13:00:09 +02:00
09b7d329bd Merge pull request 'fix: sichere Tuning-Synchronisation bei lokaler Ansible-Abweichung' (#16) from fix/ansible-tuning-sync-with-local-divergence into main 2026-08-08 22:49:37 +02:00
815b5acf8d fix: sichere Tuning-Dateisynchronisation trotz lokaler Ansible-Abweichungen 2026-08-08 22:49:35 +02:00
f1f855c596 fix: sichere Tuning-Dateisynchronisation trotz lokaler Ansible-Abweichungen 2026-08-08 22:49:35 +02:00
071e2958f0 Merge pull request 'feat: sichere Ansible-Tuning-Aktionen' (#15) from feat/safe-ansible-tuning-actions into main 2026-08-08 22:43:23 +02:00
3c72ea6545 feat: sichere Ansible-Tuning-Aktionen 2026-08-08 22:42:58 +02:00
964774ca6b feat: sichere Ansible-Tuning-Aktionen 2026-08-08 22:42:57 +02:00
0be4944a74 Merge pull request 'fix: sysctl audit via procfs' (#14) from fix/sysctl-procfs-fallback into main 2026-08-08 22:27:14 +02:00
3a674d9e3b fix: sysctl audit via procfs 2026-08-08 22:27:13 +02:00
42056f3df6 Merge pull request 'fix: sysctl remote error tail report' (#13) from fix/sysctl-error-tail into main 2026-08-08 22:25:44 +02:00
fcf1fb3816 fix: sysctl remote error tail report 2026-08-08 22:25:43 +02:00
2ed8949aaf Merge pull request 'feat: read-only sysctl audit ergänzen' (#12) from feat/read-only-sysctl-audit into main 2026-08-08 22:15:31 +02:00
e214013568 feat: read-only sysctl audit ergänzen 2026-08-08 22:15:20 +02:00
497e917a86 feat: read-only sysctl audit ergänzen 2026-08-08 22:15:19 +02:00
f96e9abfd4 feat: read-only sysctl audit ergänzen 2026-08-08 22:15:19 +02:00
c7d7a9af8a Merge pull request 'Legacy-Speedtest beim StreamScope-Cutover ersetzen' (#11) from fix/streamscope-legacy-replace into main 2026-08-08 18:32:08 +02:00
0360fbba55 fix: replace legacy speedtest container during StreamScope cutover 2026-08-08 18:31:52 +02:00
45d4091051 fix: replace legacy speedtest container during StreamScope cutover 2026-08-08 18:31:51 +02:00
fafffe56c4 Merge pull request 'StreamScope vollständig über Butler deployen' (#10) from feature/streamscope-deploy into main 2026-08-08 18:23:27 +02:00
dc349bc23d feat: deploy StreamScope source bundle with MTR and rollback 2026-08-08 18:23:17 +02:00
a364854586 feat: deploy StreamScope source bundle with MTR and rollback 2026-08-08 18:23:17 +02:00
32991d3c4b Merge pull request 'Git-managed LibreSpeed mit privater Telemetrie deployen' (#9) from feature/speedtest-private-telemetry into main 2026-08-08 17:33:58 +02:00
7674c8e8c8 test: cover secure speedtest deployment endpoint 2026-08-08 17:33:48 +02:00
a7a318ebb5 feat: deploy Git-managed private LibreSpeed telemetry 2026-08-08 17:33:48 +02:00
f799805366 Merge pull request 'DNS-Token-Fallback für Butler-Proxyroute' (#8) from fix/dns-token-fallback into main 2026-08-08 14:11:26 +02:00
3239f54e5d test: verify DNS token fallback forwarding 2026-08-08 14:11:24 +02:00
ed501e676b fix: allow authenticated one-shot DNS token fallback 2026-08-08 14:11:24 +02:00
d598af02d4 Merge pull request 'Vault-Sync-Skript über Bash ausführen' (#7) from fix/run-vault-sync-with-bash into main 2026-08-08 14:08:30 +02:00
c7d53c8ee2 test: cover non-executable vault sync script 2026-08-08 14:08:29 +02:00
0de28be96e fix: invoke vault sync through bash 2026-08-08 14:08:28 +02:00
d7fcd1e69c Merge pull request 'Vault-Sync-Pfad im Dockhand-Deployment korrigieren' (#6) from fix/vault-sync-live-path into main 2026-08-08 14:06:54 +02:00
4 changed files with 739 additions and 16 deletions

464
app.py
View file

@ -1,17 +1,17 @@
"""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 import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets
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 from pydantic import BaseModel, Field
from fastapi import FastAPI, Request, HTTPException, Depends, Query from fastapi import FastAPI, Request, HTTPException, Depends, Query
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse, Response
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
log = logging.getLogger("butler") log = logging.getLogger("butler")
VERSION = "2.3.2" VERSION = "2.3.5"
API_DIR = os.environ.get("API_KEY_DIR", "/data/api") API_DIR = os.environ.get("API_KEY_DIR", "/data/api")
VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache") VAULT_CACHE_DIR = os.environ.get("VAULT_CACHE_DIR", "/data/vault-cache")
@ -273,11 +273,13 @@ 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",
"overview": "GET /overview?details=false - deterministic homelab verdict for small models", "overview": "GET /overview?details=false - deterministic homelab verdict for small models",
"audit": "GET /audit - recent API calls", "audit": "GET /audit - recent API calls",
"sysctl_audit": "GET /system/sysctl/{host} - read-only live and persistent network tuning",
}, },
"vault_items": len(_vault_cache), "vault_items": len(_vault_cache),
} }
@ -920,6 +922,7 @@ def _validate_proxy_route(domain: str, upstream: str) -> tuple[str, str, str, st
class ProxyRouteRequest(BaseModel): class ProxyRouteRequest(BaseModel):
domain: str domain: str
upstream: str upstream: str
dns_token: str | None = None
def _remote_python(script: str, timeout: int = 30) -> tuple[int, str, str]: def _remote_python(script: str, timeout: int = 30) -> tuple[int, str, str]:
@ -983,7 +986,7 @@ def _get_hetzner_dns_token() -> str:
return token return token
rc, _out, err = _ssh( rc, _out, err = _ssh(
"sascha@10.4.1.116", "sascha@10.4.1.116",
"sudo /data/stacks/homelab-butler/vault-sync.sh", "sudo bash /data/stacks/homelab-butler/vault-sync.sh",
timeout=120, timeout=120,
) )
if rc != 0: if rc != 0:
@ -995,8 +998,8 @@ def _get_hetzner_dns_token() -> str:
return token return token
async def _upsert_dns_records(zone: str, name: str) -> dict: async def _upsert_dns_records(zone: str, name: str, token_override: str | None = None) -> dict:
token = await asyncio.to_thread(_get_hetzner_dns_token) token = token_override or await asyncio.to_thread(_get_hetzner_dns_token)
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
api = "https://api.hetzner.cloud/v1" api = "https://api.hetzner.cloud/v1"
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
@ -1027,7 +1030,7 @@ async def vps_proxy_route(req: ProxyRouteRequest, _=Depends(_verify)):
raise HTTPException(400, str(exc)) from exc raise HTTPException(400, str(exc)) from exc
caddy = await asyncio.to_thread(_configure_caddy_route, domain, upstream) caddy = await asyncio.to_thread(_configure_caddy_route, domain, upstream)
try: try:
dns = await _upsert_dns_records(zone, name) dns = await _upsert_dns_records(zone, name, req.dns_token)
except Exception: except Exception:
await asyncio.to_thread(_restore_caddy_backup, caddy["backup"]) await asyncio.to_thread(_restore_caddy_backup, caddy["backup"])
raise raise
@ -1035,6 +1038,244 @@ async def vps_proxy_route(req: ProxyRouteRequest, _=Depends(_verify)):
return {"status": "configured", "domain": domain, "upstream": upstream, "caddy": caddy, "dns": dns} return {"status": "configured", "domain": domain, "upstream": upstream, "caddy": caddy, "dns": dns}
SPEEDTEST_REPO_FILES = (
".dockerignore",
"Dockerfile",
"compose.yaml",
"pyproject.toml",
"streamscope/__init__.py",
"streamscope/app.py",
"streamscope/db.py",
"streamscope/mtr.py",
"streamscope/scoring.py",
"streamscope/static/index.html",
"streamscope/static/assets/app.css",
"streamscope/static/assets/app.js",
"streamscope/static/assets/longterm-metrics.js",
)
class SpeedtestDeployRequest(BaseModel):
stats_password: str
session_secret: str
async def _fetch_forgejo_text(repo: str, path: str) -> str:
if repo != "sascha/speedtest" or path not in SPEEDTEST_REPO_FILES:
raise ValueError("unsupported Forgejo file")
cfg = SERVICES.get("forgejo", {})
base_url = cfg.get("url")
token = _get_key(cfg)
if not base_url or not token:
raise RuntimeError("Forgejo service configuration is unavailable")
url = f"{base_url}/api/v1/repos/{repo}/contents/{path}"
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(url, params={"ref": "main"}, headers={"Authorization": f"token {token}"})
response.raise_for_status()
return base64.b64decode(response.json()["content"]).decode()
def _deploy_speedtest_compose(files: dict[str, str], password: str, session_secret: str) -> dict:
if set(files) != set(SPEEDTEST_REPO_FILES):
raise ValueError("speedtest source bundle is incomplete")
compose = files["compose.yaml"]
dockerfile = files["Dockerfile"]
required = [
"build: .",
'127.0.0.1:8080:8080',
'/app-config/speedtest/data:/data',
'ADMIN_PASSWORD: "${ADMIN_PASSWORD:',
'SESSION_SECRET: "${SESSION_SECRET:',
"NET_RAW",
]
if any(item not in compose for item in required):
raise ValueError("StreamScope compose is missing a required security or persistence setting")
if "python:" not in dockerfile or "mtr-tiny" not in dockerfile or "php" in dockerfile.lower():
raise ValueError("StreamScope image must be Python-based, MTR-capable and PHP-free")
secret_pattern = r"[A-Za-z0-9!@#%_+=:,.?-]{24,128}"
if not re.fullmatch(secret_pattern, password):
raise ValueError("stats password must be 24-128 safe characters")
if not re.fullmatch(secret_pattern, session_secret):
raise ValueError("session secret must be 24-128 safe characters")
script = f"""from pathlib import Path
import os, shutil
stack = Path('/app-config/github/speedtest')
backup = Path('/app-config/deployment-backups/speedtest-rollback')
data = Path('/app-config/speedtest/data')
if backup.exists():
shutil.rmtree(backup)
if stack.exists():
backup.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(stack, backup)
stack.mkdir(parents=True, exist_ok=True)
data.mkdir(parents=True, exist_ok=True)
files = {files!r}
for relative, content in files.items():
target = stack / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
env = stack / '.env'
env.write_text('ADMIN_PASSWORD=' + {password!r} + '\\nSESSION_SECRET=' + {session_secret!r} + '\\nSTATS_PASSWORD=' + {password!r} + '\\n')
os.chmod(env, 0o600)
"""
rc, _out, err = _remote_python(script)
if rc != 0:
raise RuntimeError(f"StreamScope file deployment failed: {err[-300:]}")
rollback = "rm -rf /app-config/github/speedtest && cp -a /app-config/deployment-backups/speedtest-rollback /app-config/github/speedtest && cd /app-config/github/speedtest && docker compose up -d"
preflight = "cd /app-config/github/speedtest && docker compose config -q && docker compose build --pull"
rc, _out, err = _ssh(VPS_SSH, preflight, timeout=600)
if rc != 0:
_ssh(VPS_SSH, rollback, timeout=180)
raise RuntimeError(f"StreamScope build preflight failed: {err[-500:]}")
deploy = "cd /app-config/github/speedtest && (docker rm -f speedtest >/dev/null 2>&1 || true) && docker compose up -d --remove-orphans"
rc, out, err = _ssh(VPS_SSH, deploy, timeout=180)
if rc != 0:
_ssh(VPS_SSH, rollback, timeout=180)
raise RuntimeError(f"StreamScope deployment failed: {(err or out)[-500:]}")
health = "for i in $(seq 1 45); do curl -fsS --max-time 3 http://127.0.0.1:8080/api/health >/dev/null && exit 0; sleep 2; done; exit 1"
rc, _out, err = _ssh(VPS_SSH, health, timeout=105)
if rc != 0:
_ssh(VPS_SSH, rollback, timeout=180)
raise RuntimeError(f"StreamScope health check failed and rollback was attempted: {err[-300:]}")
return {
"status": "deployed",
"health": "ok",
"application": "streamscope",
"database": "/app-config/speedtest/data/streamscope.db",
"public_port": False,
"mtr": True,
}
@app.post("/vps/speedtest/deploy")
async def vps_speedtest_deploy(req: SpeedtestDeployRequest, _=Depends(_verify)):
secret_pattern = r"[A-Za-z0-9!@#%_+=:,.?-]{24,128}"
if not re.fullmatch(secret_pattern, req.stats_password):
raise HTTPException(400, "stats password must be 24-128 safe characters")
if not re.fullmatch(secret_pattern, req.session_secret):
raise HTTPException(400, "session secret must be 24-128 safe characters")
contents = await asyncio.gather(*(
_fetch_forgejo_text("sascha/speedtest", path) for path in SPEEDTEST_REPO_FILES
))
files = dict(zip(SPEEDTEST_REPO_FILES, contents))
result = await asyncio.to_thread(
_deploy_speedtest_compose, files, req.stats_password, req.session_secret
)
_audit("/vps/speedtest/deploy", "POST", 200, "Git-managed StreamScope with private history and MTR")
return result
BW_MANAGER_REPO_FILES = (
".env.example", ".gitignore", "README.md", "compose.yaml",
"src/.dockerignore", "src/Dockerfile", "src/app.py",
"src/remote_policy.py", "src/requirements.txt",
"src/templates/base.html", "src/templates/history.html",
"src/templates/index.html", "src/templates/users.html",
)
async def _fetch_bw_manager_text(path: str) -> str:
if path not in BW_MANAGER_REPO_FILES:
raise ValueError("unsupported BW Manager file")
cfg = SERVICES.get("forgejo", {})
base_url, token = cfg.get("url"), _get_key(cfg)
if not base_url or not token:
raise RuntimeError("Forgejo service configuration is unavailable")
url = f"{base_url}/api/v1/repos/sascha/bw-manager/contents/{path}"
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
url, params={"ref": "main"},
headers={"Authorization": f"token {token}"},
)
response.raise_for_status()
return base64.b64decode(response.json()["content"]).decode()
def _deploy_bw_manager_compose(files: dict[str, str]) -> dict:
if set(files) != set(BW_MANAGER_REPO_FILES):
raise ValueError("BW Manager source bundle is incomplete")
if "build: ./src" not in files["compose.yaml"]:
raise ValueError("BW Manager compose contract is invalid")
if "build_gated_targets" not in files["src/app.py"]:
raise ValueError("BW Manager candidate lacks the user/network AND gate")
rc, working_dir, err = _ssh(
VPS_SSH,
"docker inspect -f '{{ index .Config.Labels \"com.docker.compose.project.working_dir\" }}' bw-manager",
timeout=30,
)
working_dir = working_dir.strip()
if rc != 0 or not re.fullmatch(r"/app-config/[A-Za-z0-9_./-]+", working_dir):
raise RuntimeError(f"cannot determine safe BW Manager working directory: {(err or working_dir)[-300:]}")
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
candidate = f"/app-config/deployment-candidates/bw-manager-{timestamp}"
backup = f"/app-config/deployment-backups/bw-manager-{timestamp}"
image = "bw-manager-bw-manager"
rollback_image = f"{image}:rollback-{timestamp}"
init_script = f"""from pathlib import Path
import shutil
candidate = Path({candidate!r})
if candidate.exists(): shutil.rmtree(candidate)
candidate.mkdir(parents=True)
live_env = Path({working_dir!r}) / '.env'
if live_env.exists(): shutil.copy2(live_env, candidate / '.env')
"""
rc, _out, err = _remote_python(init_script)
if rc != 0:
raise RuntimeError(f"BW Manager candidate initialization failed: {err[-300:]}")
# Stage one file per SSH call. Sending the complete repository in one
# command exceeds Linux's argv limit once app.py and templates are encoded.
for relative, content in files.items():
file_script = f"""from pathlib import Path
target = Path({candidate!r}) / {relative!r}
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text({content!r})
"""
rc, _out, err = _remote_python(file_script)
if rc != 0:
raise RuntimeError(f"BW Manager staging failed for {relative}: {err[-300:]}")
rc, _out, err = _ssh(VPS_SSH, f"cd {candidate} && docker compose config -q && docker compose build --pull", timeout=600)
if rc != 0:
raise RuntimeError(f"BW Manager candidate build failed: {err[-500:]}")
deploy_script = f"""from pathlib import Path
import shutil
live, backup, candidate = Path({working_dir!r}), Path({backup!r}), Path({candidate!r})
backup.parent.mkdir(parents=True, exist_ok=True)
if backup.exists(): shutil.rmtree(backup)
shutil.copytree(live, backup)
for relative in {BW_MANAGER_REPO_FILES!r}:
source, target = candidate / relative, live / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
"""
rc, _out, err = _remote_python(deploy_script)
if rc != 0:
raise RuntimeError(f"BW Manager live file switch failed: {err[-300:]}")
_ssh(VPS_SSH, f"docker image tag {image} {rollback_image}", timeout=60)
rollback = (
f"rm -rf {working_dir} && cp -a {backup} {working_dir} && "
f"docker image tag {rollback_image} {image} && cd {working_dir} && "
"docker compose up -d --no-build"
)
rc, out, err = _ssh(VPS_SSH, f"cd {working_dir} && docker compose up -d --build --remove-orphans", timeout=600)
if rc != 0:
_ssh(VPS_SSH, rollback, timeout=180)
raise RuntimeError(f"BW Manager deployment failed: {(err or out)[-500:]}")
health = "for i in $(seq 1 45); do curl -fsS --max-time 3 http://127.0.0.1:8870/api/status >/dev/null && exit 0; sleep 2; done; exit 1"
rc, _out, err = _ssh(VPS_SSH, health, timeout=105)
if rc != 0:
_ssh(VPS_SSH, rollback, timeout=180)
raise RuntimeError(f"BW Manager health failed; rollback attempted: {err[-300:]}")
return {"status": "deployed", "health": "ok", "working_dir": working_dir, "backup": backup}
@app.post("/vps/bw-manager/deploy")
async def vps_bw_manager_deploy(_=Depends(_verify)):
contents = await asyncio.gather(*(_fetch_bw_manager_text(path) for path in BW_MANAGER_REPO_FILES))
result = await asyncio.to_thread(_deploy_bw_manager_compose, dict(zip(BW_MANAGER_REPO_FILES, contents)))
_audit("/vps/bw-manager/deploy", "POST", 200, "Git-managed BW Manager deployment")
return result
# --- VM Lifecycle Endpoints --- # --- VM Lifecycle Endpoints ---
import subprocess as _sp import subprocess as _sp
@ -1058,6 +1299,76 @@ def _ssh(host, cmd, timeout=600):
except _sp.TimeoutExpired: except _sp.TimeoutExpired:
return 124, "", f"SSH command timed out after {timeout} seconds" return 124, "", f"SSH command timed out after {timeout} seconds"
SYSCTL_AUDIT_KEYS = (
"net.core.default_qdisc",
"net.core.rmem_default",
"net.core.rmem_max",
"net.core.wmem_default",
"net.core.wmem_max",
"net.core.netdev_max_backlog",
"net.core.somaxconn",
"net.ipv4.ip_forward",
"net.ipv4.tcp_congestion_control",
"net.ipv4.tcp_fastopen",
"net.ipv4.tcp_mtu_probing",
"net.ipv4.tcp_no_metrics_save",
"net.ipv4.tcp_rmem",
"net.ipv4.tcp_slow_start_after_idle",
"net.ipv4.tcp_window_scaling",
"net.ipv4.tcp_wmem",
)
def _sysctl_audit_command() -> str:
script = f'''import glob, json
from pathlib import Path
keys = {SYSCTL_AUDIT_KEYS!r}
live, errors = {{}}, {{}}
for key in keys:
try:
live[key] = Path("/proc/sys/" + key.replace(".", "/")).read_text().strip()
except OSError as exc:
errors[key] = str(exc)[:160]
persistent = {{}}
for path in ["/etc/sysctl.conf", *sorted(glob.glob("/etc/sysctl.d/*.conf"))]:
try:
with open(path, encoding="utf-8", errors="replace") as handle:
for raw in handle:
line = raw.split("#", 1)[0].strip()
if "=" not in line:
continue
key, value = (part.strip() for part in line.split("=", 1))
if key in keys:
persistent.setdefault(key, []).append({{"file": path, "value": value}})
except (FileNotFoundError, PermissionError):
pass
print(json.dumps({{"live": live, "persistent": persistent, "errors": errors}}))
'''
encoded = base64.b64encode(script.encode()).decode()
return f'python3 -c "import base64;exec(base64.b64decode(\'{encoded}\'))"'
@app.get("/system/sysctl/{host}")
async def system_sysctl_audit(host: str, _=Depends(_verify)):
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", host):
raise HTTPException(400, "Invalid host name")
if host == "vps":
target = VPS_SSH
else:
inventory = await asyncio.to_thread(_find_inventory_host, host)
if not inventory:
raise HTTPException(404, f"Host {host} not found")
target = f'{inventory["user"]}@{inventory["ip"]}'
rc, out, err = await asyncio.to_thread(_ssh, target, _sysctl_audit_command(), 30)
if rc != 0:
raise HTTPException(502, (err or out).strip()[-500:] or "sysctl audit failed")
try:
result = json.loads(out)
except json.JSONDecodeError as exc:
raise HTTPException(502, "sysctl audit returned invalid JSON") from exc
return {"host": host, **result}
def _pve_auth(): def _pve_auth():
pv = _parse_kv("proxmox") pv = _parse_kv("proxmox")
return f"PVEAPIToken={pv.get('tokenid','')}={pv.get('secret','')}" return f"PVEAPIToken={pv.get('tokenid','')}={pv.get('secret','')}"
@ -1368,10 +1679,47 @@ print("updated" if updated else "added")'''
async def ansible_run(request: Request, _=Depends(_verify)): async def ansible_run(request: Request, _=Depends(_verify)):
body = await request.json() body = await request.json()
hostname = body.get("limit", body.get("hostname", "")) hostname = body.get("limit", body.get("hostname", ""))
template_id = body.get("template_id", 10)
if not hostname: if not hostname:
return JSONResponse({"error": "limit/hostname required"}, status_code=400) return JSONResponse({"error": "limit/hostname required"}, status_code=400)
rc, out, err = _ssh(AUTOMATION1, f"cd /app-config/ansible && bash pfannkuchen.sh setup {hostname}", timeout=600) action = body.get("action", "setup")
if action not in {"setup", "tune", "pvetune"}:
return JSONResponse({"error": "action must be setup, tune or pvetune"}, status_code=400)
if not re.fullmatch(r"[a-zA-Z0-9_.:-]+", hostname):
return JSONResponse({"error": "invalid hostname/limit"}, status_code=400)
if action in {"tune", "pvetune"}:
approved_files = (
"roles/sysctl/defaults/main.yml",
"roles/sysctl/tasks/main.yml",
"group_vars/vps/sysctl.yml",
"sysctl-proxmox.yaml",
"roles/sysctl_proxmox/tasks/main.yml",
)
file_sync = " && ".join(
f"git show origin/master:{path} > {path}" for path in approved_files
)
command = (
"cd /app-config/ansible && "
"git fetch origin master && "
f"{file_sync} && "
f"bash pfannkuchen.sh {action} {hostname}"
)
else:
command = (
"cd /app-config/ansible && "
"git pull --ff-only origin master && "
f"bash pfannkuchen.sh {action} {hostname}"
)
rc, out, err = _ssh(AUTOMATION1, command, timeout=600)
_audit("/ansible/run", "POST", 200 if rc == 0 else 502, f"{action} {hostname}")
if action != "setup":
return {
"status": "ok" if rc == 0 else "error",
"action": action,
"hostname": hostname,
"rc": rc,
"output": out[-4000:],
"error": err[-1000:] if rc != 0 else "",
}
# After successful ansible run: sync Hawser token to Dockhand # After successful ansible run: sync Hawser token to Dockhand
if rc == 0: if rc == 0:
@ -1487,9 +1835,105 @@ 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,
}
installer = """import json, os, pathlib
files = json.loads({files_json!r})
base = pathlib.Path('/app-config/tts-bridge')
base.mkdir(parents=True, exist_ok=True)
for filename, value in files.items():
path = pathlib.Path(filename)
if path.is_dir():
path.rmdir()
path.write_text(value)
os.chown(path, 10001, 10001)
os.chmod(path, 0o400)
""".format(files_json=json.dumps(files))
encoded = base64.b64encode(installer.encode()).decode()
command = f"sudo python3 -c {__import__('shlex').quote(f'import base64;exec(base64.b64decode({encoded!r}))')}"
rc, _out, err = _ssh("sascha@10.5.85.5", command, 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":

View file

@ -42,7 +42,136 @@ def test_health_exposes_current_version():
with TestClient(app.app) as client: with TestClient(app.app) as client:
response = client.get("/health") response = client.get("/health")
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["version"] == app.VERSION == "2.3.2" 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"},
"persistent": {"net.ipv4.tcp_congestion_control": [{"file": "/etc/sysctl.d/99-net-tuning.conf", "value": "bbr"}]},
"errors": {},
}
calls = []
monkeypatch.setattr(app, "_find_inventory_host", lambda name: {"name": name, "user": "root", "ip": "10.5.85.16"})
monkeypatch.setattr(app, "_ssh", lambda host, command, timeout=600: (calls.append((host, command, timeout)) or (0, __import__("json").dumps(payload), "")))
with TestClient(app.app) as client:
response = client.get("/system/sysctl/node6", headers={"Authorization": "Bearer test-token"})
assert response.status_code == 200
assert response.json()["live"]["net.ipv4.tcp_congestion_control"] == "bbr"
assert calls[0][0] == "root@10.5.85.16"
assert "base64.b64decode" in calls[0][1]
def test_sysctl_audit_rejects_unknown_host_without_ssh(monkeypatch):
monkeypatch.setattr(app, "_find_inventory_host", lambda _name: None)
monkeypatch.setattr(app, "_ssh", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("must not SSH")))
with TestClient(app.app) as client:
response = client.get("/system/sysctl/not-there", headers={"Authorization": "Bearer test-token"})
assert response.status_code == 404
def test_invalid_log_target_is_rejected_before_ssh(): def test_invalid_log_target_is_rejected_before_ssh():
@ -83,6 +212,46 @@ def test_inventory_upsert_uses_base64_script(monkeypatch):
assert "\\nname =" not in calls[0][1] assert "\\nname =" not in calls[0][1]
def test_ansible_run_supports_safe_tune_action_and_syncs_approved_files(monkeypatch):
calls = []
def fake_ssh(host, command, timeout=600):
calls.append((host, command, timeout))
return 0, "changed=1 failed=0", ""
monkeypatch.setattr(app, "_ssh", fake_ssh)
with TestClient(app.app) as client:
response = client.post(
"/ansible/run",
headers={"Authorization": "Bearer test-token"},
json={"hostname": "emby-sascha", "action": "tune"},
)
assert response.status_code == 200
assert response.json()["action"] == "tune"
assert "git fetch origin master" in calls[0][1]
assert "git show origin/master:roles/sysctl/tasks/main.yml" in calls[0][1]
assert "git pull --ff-only" not in calls[0][1]
assert "bash pfannkuchen.sh tune emby-sascha" in calls[0][1]
assert len(calls) == 1
def test_ansible_run_rejects_unknown_action_and_shell_metacharacters(monkeypatch):
monkeypatch.setattr(app, "_ssh", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("must not SSH")))
with TestClient(app.app) as client:
bad_action = client.post(
"/ansible/run",
headers={"Authorization": "Bearer test-token"},
json={"hostname": "emby-sascha", "action": "shell"},
)
bad_host = client.post(
"/ansible/run",
headers={"Authorization": "Bearer test-token"},
json={"hostname": "emby-sascha;id", "action": "tune"},
)
assert bad_action.status_code == 400
assert bad_host.status_code == 400
def test_docker_inspect_returns_sanitized_summary(monkeypatch): def test_docker_inspect_returns_sanitized_summary(monkeypatch):
raw = [{ raw = [{
"Name": "/fileflows", "Name": "/fileflows",
@ -290,8 +459,8 @@ def test_proxy_route_endpoint_configures_caddy_and_dns(monkeypatch):
calls.append(("caddy", domain, upstream)) calls.append(("caddy", domain, upstream))
return {"status": "reloaded", "backup": "/app-config/caddy/Caddyfile.bak-test"} return {"status": "reloaded", "backup": "/app-config/caddy/Caddyfile.bak-test"}
async def fake_dns(zone, name): async def fake_dns(zone, name, token_override=None):
calls.append(("dns", zone, name)) calls.append(("dns", zone, name, token_override))
return {"zone_id": 123, "records": ["A", "AAAA"]} return {"zone_id": 123, "records": ["A", "AAAA"]}
monkeypatch.setattr(app, "_configure_caddy_route", fake_caddy) monkeypatch.setattr(app, "_configure_caddy_route", fake_caddy)
@ -300,14 +469,14 @@ def test_proxy_route_endpoint_configures_caddy_and_dns(monkeypatch):
response = client.post( response = client.post(
"/vps/proxy-route", "/vps/proxy-route",
headers={"Authorization": "Bearer test-token"}, headers={"Authorization": "Bearer test-token"},
json={"domain": "speed.guck.tv", "upstream": "127.0.0.1:8080"}, json={"domain": "speed.guck.tv", "upstream": "127.0.0.1:8080", "dns_token": "test-dns-token"},
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["status"] == "configured" assert response.json()["status"] == "configured"
assert calls == [ assert calls == [
("caddy", "speed.guck.tv", "127.0.0.1:8080"), ("caddy", "speed.guck.tv", "127.0.0.1:8080"),
("dns", "guck.tv", "speed"), ("dns", "guck.tv", "speed", "test-dns-token"),
] ]
@ -320,5 +489,45 @@ def test_hetzner_token_refreshes_vault_cache_when_missing(monkeypatch):
assert app._get_hetzner_dns_token() == "refreshed-token" assert app._get_hetzner_dns_token() == "refreshed-token"
assert calls[0][0] == "sascha@10.4.1.116" assert calls[0][0] == "sascha@10.4.1.116"
assert calls[0][1] == "sudo /data/stacks/homelab-butler/vault-sync.sh" assert calls[0][1] == "sudo bash /data/stacks/homelab-butler/vault-sync.sh"
assert calls[1] == "reload" assert calls[1] == "reload"
def test_speedtest_deploy_requires_strong_secrets_and_uses_full_git_app(monkeypatch):
calls = []
async def fake_fetch(repo, path):
calls.append(("fetch", repo, path))
return f"content:{path}"
def fake_deploy(files, password, session_secret):
calls.append(("deploy", files, password, session_secret))
return {"status": "deployed", "health": "ok", "application": "streamscope"}
monkeypatch.setattr(app, "_fetch_forgejo_text", fake_fetch)
monkeypatch.setattr(app, "_deploy_speedtest_compose", fake_deploy)
with TestClient(app.app) as client:
weak = client.post(
"/vps/speedtest/deploy",
headers={"Authorization": "Bearer test-token"},
json={"stats_password": "short", "session_secret": "long-session-secret-with-entropy"},
)
response = client.post(
"/vps/speedtest/deploy",
headers={"Authorization": "Bearer test-token"},
json={
"stats_password": "correct-horse-battery-staple",
"session_secret": "streamscope-session-secret-with-entropy",
},
)
assert weak.status_code == 400
assert response.status_code == 200
assert response.json()["application"] == "streamscope"
assert ("fetch", "sascha/speedtest", "compose.yaml") in calls
assert ("fetch", "sascha/speedtest", "streamscope/static/assets/app.js") in calls
deploy = calls[-1]
assert deploy[0] == "deploy"
assert deploy[1]["compose.yaml"] == "content:compose.yaml"
assert deploy[2] == "correct-horse-battery-staple"
assert deploy[3] == "streamscope-session-secret-with-entropy"

View file

@ -0,0 +1,32 @@
import importlib.util
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[1]
def load_app(monkeypatch):
monkeypatch.setenv("BUTLER_TOKEN", "test-token")
spec = importlib.util.spec_from_file_location("butler_bw_test", ROOT / "app.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_bw_manager_deploy_rejects_bundle_without_and_gate(monkeypatch):
app = load_app(monkeypatch)
files = {path: "placeholder" for path in app.BW_MANAGER_REPO_FILES}
files["compose.yaml"] = "services:\n bw-manager:\n build: ./src\n"
files["src/app.py"] = "def old_policy(): pass\n"
with pytest.raises(ValueError, match="AND gate"):
app._deploy_bw_manager_compose(files)
def test_bw_manager_deploy_endpoint_requires_auth(monkeypatch):
app = load_app(monkeypatch)
response = TestClient(app.app).post("/vps/bw-manager/deploy")
assert response.status_code in (401, 403)

View file

@ -0,0 +1,38 @@
import app
def test_streamscope_deploy_replaces_legacy_container_before_compose(monkeypatch):
assert "streamscope/static/assets/longterm-metrics.js" in app.SPEEDTEST_REPO_FILES
files = {path: "placeholder" for path in app.SPEEDTEST_REPO_FILES}
files["compose.yaml"] = """services:
streamscope:
build: .
ports:
- '127.0.0.1:8080:8080'
environment:
ADMIN_PASSWORD: "${ADMIN_PASSWORD:?required}"
SESSION_SECRET: "${SESSION_SECRET:?required}"
volumes:
- /app-config/speedtest/data:/data
cap_add:
- NET_RAW
"""
files["Dockerfile"] = "FROM python:3.13-slim\nRUN apt-get install -y mtr-tiny\n"
commands = []
monkeypatch.setattr(app, "_remote_python", lambda script: (0, "", ""))
def fake_ssh(host, command, timeout=600):
commands.append(command)
return 0, "ok", ""
monkeypatch.setattr(app, "_ssh", fake_ssh)
result = app._deploy_speedtest_compose(
files,
"correct-horse-battery-staple",
"streamscope-session-secret-with-entropy",
)
deploy = next(command for command in commands if "compose up -d --remove-orphans" in command)
assert "docker rm -f speedtest" in deploy
assert result["application"] == "streamscope"