Merge pull request 'Akteursbezogenes KI- und Butler-Aktivitätsprotokoll' (#44) from feature/butler-ai-activity-log into main

This commit is contained in:
sascha 2026-08-16 21:40:41 +02:00
commit 6605940a81
3 changed files with 72 additions and 10 deletions

36
app.py
View file

@ -9,6 +9,7 @@ from pydantic import BaseModel, Field
from fastapi import FastAPI, Request, HTTPException, Depends, Query
from fastapi.responses import JSONResponse, RedirectResponse, Response, HTMLResponse
from contextlib import asynccontextmanager
from contextvars import ContextVar
log = logging.getLogger("butler")
VERSION = "2.3.5"
@ -46,6 +47,7 @@ _load_config()
# --- Audit log ---
_audit_log: list[dict] = []
_audit_actor: ContextVar[str] = ContextVar("audit_actor", default="System/API")
MAX_AUDIT = 500
AUDIT_DB_PATH = os.environ.get("AUDIT_DB_PATH", "/data/state/audit.sqlite3")
@ -69,8 +71,12 @@ def _init_audit_db() -> bool:
method TEXT NOT NULL,
status INTEGER NOT NULL,
detail TEXT NOT NULL,
dry_run INTEGER NOT NULL
dry_run INTEGER NOT NULL,
actor TEXT NOT NULL DEFAULT 'Legacy/API'
)""")
columns = {row[1] for row in db.execute("PRAGMA table_info(audit)")}
if "actor" not in columns:
db.execute("ALTER TABLE audit ADD COLUMN actor TEXT NOT NULL DEFAULT 'Legacy/API'")
return True
except (OSError, sqlite3.Error):
return False
@ -83,6 +89,7 @@ def _audit(endpoint: str, method: str, status: int, detail: str = "", dry_run: b
"status": status,
"detail": _redact_audit_detail(detail),
"dry_run": dry_run,
"actor": _audit_actor.get(),
}
_audit_log.append(entry)
if len(_audit_log) > MAX_AUDIT:
@ -91,8 +98,8 @@ def _audit(endpoint: str, method: str, status: int, detail: str = "", dry_run: b
if _init_audit_db():
with sqlite3.connect(AUDIT_DB_PATH) as db:
db.execute(
"INSERT INTO audit (ts, endpoint, method, status, detail, dry_run) VALUES (?, ?, ?, ?, ?, ?)",
(entry["ts"], entry["endpoint"], entry["method"], entry["status"], entry["detail"], int(entry["dry_run"])),
"INSERT INTO audit (ts, endpoint, method, status, detail, dry_run, actor) VALUES (?, ?, ?, ?, ?, ?, ?)",
(entry["ts"], entry["endpoint"], entry["method"], entry["status"], entry["detail"], int(entry["dry_run"]), entry["actor"]),
)
db.execute("DELETE FROM audit WHERE id NOT IN (SELECT id FROM audit ORDER BY id DESC LIMIT ?)", (MAX_AUDIT,))
except (OSError, sqlite3.Error):
@ -280,6 +287,27 @@ def _verify(request: Request):
if not csrf or not secrets.compare_digest(csrf, session["csrf"]):
raise HTTPException(403, "Invalid CSRF token")
def _clean_audit_actor(value: str) -> str:
cleaned = re.sub(r"[^\w .@/\-]", "", str(value or ""))[:40].strip()
return cleaned or "KI/API"
@app.middleware("http")
async def audit_actor_context(request: Request, call_next):
if request.headers.get("authorization", "").startswith("Bearer "):
actor = _clean_audit_actor(request.headers.get("x-butler-actor", "KI/API"))
elif _ui_session(request):
actor = "Weboberfläche"
else:
actor = "System/Öffentlich"
token = _audit_actor.set(actor)
try:
return await call_next(request)
finally:
_audit_actor.reset(token)
def _emby_network_identity(endpoint: str) -> dict:
"""Normalize an Emby endpoint without treating IPv6 privacy addresses as new households."""
raw = str(endpoint or "").strip()
@ -830,7 +858,7 @@ async def audit(_=Depends(_verify), limit: int = Query(50, le=MAX_AUDIT)):
with sqlite3.connect(AUDIT_DB_PATH) as db:
db.row_factory = sqlite3.Row
rows = db.execute(
"SELECT ts, endpoint, method, status, detail, dry_run FROM audit ORDER BY id DESC LIMIT ?",
"SELECT ts, endpoint, method, status, detail, dry_run, actor FROM audit ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) | {"dry_run": bool(row["dry_run"])} for row in rows]

View file

@ -266,6 +266,39 @@ def test_audit_persists_and_redacts_secrets(tmp_path, monkeypatch):
assert entry["detail"].count("[REDACTED]") == 3
def test_audit_records_ai_and_web_ui_actor(tmp_path, monkeypatch):
db = tmp_path / "audit.sqlite3"
monkeypatch.setattr(app, "AUDIT_DB_PATH", str(db))
async def empty_status():
return {}
monkeypatch.setattr(app, "_collect_service_status", empty_status)
app._ui_sessions.clear()
with TestClient(app.app) as client:
ai = client.get(
"/status",
headers={"Authorization": "Bearer test-token", "X-Butler-Actor": "Trulla"},
)
assert ai.status_code == 200
client.headers.pop("Authorization", None)
client.get("/ui/session")
web = client.get("/status")
assert web.status_code == 200
entries = client.get("/audit").json()
assert [item["actor"] for item in entries[:2]] == ["Weboberfläche", "Trulla"]
def test_ui_audit_has_actor_column_and_filter():
with TestClient(app.app) as client:
response = client.get("/ui")
assert '<th>Akteur</th>' in response.text
assert 'id="audit-actor"' in response.text
def test_wireguard_status_returns_redacted_live_state(monkeypatch):
payload = {
"interface": "wg0",

13
ui.html
View file

@ -76,8 +76,8 @@
</section>
<section id="view-audit" class="view">
<div class="hero"><div><div class="eyebrow">Persistente Historie</div><h2>Audit</h2><p>Redigierte Butler-Aktionen aus SQLite.</p></div></div>
<article class="panel"><div class="panel-head"><div class="panel-title">Letzte Aktionen</div><button id="audit-refresh" class="btn">Aktualisieren</button></div><div class="table-wrap"><table><thead><tr><th>Zeit</th><th>Methode</th><th>Endpoint</th><th>Status</th><th>Dry-run</th><th>Detail</th></tr></thead><tbody id="audit-body"><tr><td colspan="6">Wird geladen …</td></tr></tbody></table></div></article>
<div class="hero"><div><div class="eyebrow">KI- und API-Protokoll</div><h2>Butler-Aktivitäten</h2><p>Wer hat wann welche Butler-Funktion aufgerufen? Sensible Werte bleiben redigiert.</p></div></div>
<article class="panel"><div class="panel-head"><div class="panel-title">Letzte Aktivitäten</div><div class="top-actions"><select id="audit-actor" class="input" aria-label="Akteur filtern" style="min-width:170px"><option value="all">Alle Akteure</option><option value="ai">Nur KI / API</option><option value="Weboberfläche">Nur Weboberfläche</option></select><button id="audit-refresh" class="btn">Aktualisieren</button></div></div><div class="table-wrap"><table><thead><tr><th>Zeit</th><th>Akteur</th><th>Methode</th><th>Endpoint</th><th>Status</th><th>Dry-run</th><th>Detail</th></tr></thead><tbody id="audit-body"><tr><td colspan="7">Wird geladen …</td></tr></tbody></table></div></article>
</section>
<section id="view-functions" class="view">
@ -93,7 +93,7 @@
<script>
(() => {
'use strict';
const state={capabilities:null,operations:[],filter:'all'};
const state={capabilities:null,operations:[],audit:[],filter:'all'};
const $=s=>document.querySelector(s);
const $$=s=>[...document.querySelectorAll(s)];
const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
@ -101,9 +101,9 @@
const toast=msg=>{const el=$('#toast');el.textContent=msg;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),2600)};
async function api(path,options={}){const method=(options.method||'GET').toUpperCase();const headers={...(options.headers||{})};if(!['GET','HEAD','OPTIONS'].includes(method))headers['X-CSRF-Token']=decodeURIComponent(cookie('butler_csrf'));if(options.body&&!headers['Content-Type'])headers['Content-Type']='application/json';const res=await fetch(path,{credentials:'same-origin',...options,headers});if(res.status===401){await fetch('/ui/session',{credentials:'same-origin'});location.reload();throw new Error('Read-only Sitzung wurde erneuert')}const text=await res.text();let data;try{data=JSON.parse(text)}catch{data=text}if(!res.ok)throw new Error(data?.detail||data?.error||`HTTP ${res.status}`);return data}
$$('.nav button[data-view]').forEach(btn=>btn.addEventListener('click',()=>switchView(btn.dataset.view)));
function switchView(name){$$('.view').forEach(v=>v.classList.toggle('active',v.id===`view-${name}`));$$('.nav button[data-view]').forEach(b=>b.classList.toggle('active',b.dataset.view===name));$('#view-title').textContent={overview:'Übersicht',doctor:'Doctor',sharing:'Emby Account Sharing',safety:'Wartung & Drift',audit:'Audit',functions:'Alle Funktionen'}[name];$('#sidebar').classList.remove('open')}
function switchView(name){$$('.view').forEach(v=>v.classList.toggle('active',v.id===`view-${name}`));$$('.nav button[data-view]').forEach(b=>b.classList.toggle('active',b.dataset.view===name));$('#view-title').textContent={overview:'Übersicht',doctor:'Doctor',sharing:'Emby Account Sharing',safety:'Wartung & Drift',audit:'Butler-Aktivitäten',functions:'Alle Funktionen'}[name];$('#sidebar').classList.remove('open')}
$('#menu').addEventListener('click',()=>$('#sidebar').classList.toggle('open'));
$('#refresh').addEventListener('click',refreshAll);$('#drift-refresh').addEventListener('click',loadDrift);$('#audit-refresh').addEventListener('click',loadAudit);
$('#refresh').addEventListener('click',refreshAll);$('#drift-refresh').addEventListener('click',loadDrift);$('#audit-refresh').addEventListener('click',loadAudit);$('#audit-actor').addEventListener('change',renderAudit);
async function refreshAll(){toast('Aktualisierung gestartet');await Promise.allSettled([loadOverview(),loadDrift(),loadAudit(),loadCapabilities()]);toast('Live-Daten aktualisiert')}
async function loadOverview(){try{const x=await api('/overview');const good=x.overall_state==='healthy';const box=$('#metric-state');box.className=`metric ${good?'good':x.overall_state==='warning'?'warn':'bad'}`;box.querySelector('.metric-value').textContent=x.overall_state;$('#metric-services').textContent=x.components.services.healthy;$('#metric-hosts').textContent=x.components.hosts.healthy;$('#metric-backups').textContent=x.components.backups.healthy;$('#finding-count').textContent=x.findings.length;$('#updated').textContent=new Date(x.generated).toLocaleString('de-DE');$('#findings').innerHTML=x.findings.length?x.findings.map(f=>`<div class="finding ${esc(f.severity)}"><span class="finding-dot"></span><div><strong>${esc(f.target)} · ${esc(f.code)}</strong><p>${esc(f.message)}</p></div><span class="badge">${esc(f.severity)}</span></div>`).join(''):'<div class="empty">Keine Findings. Alles sieht gut aus.</div>'}catch(e){$('#findings').innerHTML=`<div class="empty">${esc(e.message)}</div>`}}
async function loadCapabilities(){try{const x=await api('/capabilities');state.capabilities=x;state.operations=x.operations;$('#capability-summary').innerHTML=[['Operationen',x.counts.total],['Read-only',x.counts.read_only],['Mutationen',x.counts.mutations],['Destruktiv',x.counts.destructive]].map(([k,v])=>`<div class="layer"><h4>${k}</h4><div class="metric-value">${v}</div></div>`).join('');renderOperations()}catch(e){$('#operations-grid').innerHTML=`<div class="empty">${esc(e.message)}</div>`}}
@ -116,7 +116,8 @@
$('#sharing-form').addEventListener('submit',async e=>{e.preventDefault();const q=new URLSearchParams({days:$('#sharing-days').value,server:$('#sharing-server').value});const username=$('#sharing-username').value.trim();if(username)q.set('username',username);$('#sharing-events').innerHTML='<div class="empty"><span class="spinner"></span> Sitzungsverlauf wird analysiert …</div>';$('#sharing-users').innerHTML='<div class="empty">Analyse läuft …</div>';try{renderSharing(await api(`/emby/account-sharing?${q}`))}catch(err){$('#sharing-events').innerHTML=`<div class="empty">${esc(err.message)}</div>`;$('#sharing-users').innerHTML='<div class="empty">Keine Auswertung verfügbar.</div>'}});
$('#preflight-form').addEventListener('submit',async e=>{e.preventDefault();const action=$('#preflight-action').value,target=$('#preflight-target').value.trim();const out=$('#preflight-result');out.innerHTML='<span class="spinner"></span>';try{const q=new URLSearchParams({action});if(target)q.set('target',target);const x=await api(`/maintenance/preflight?${q}`);out.innerHTML=`<div class="safe-banner ${x.safe?'':'blocked'}"><span class="safe-icon">${x.safe?'✓':'×'}</span><div><strong>${x.safe?'Wartung ist freigegeben':'Wartung ist blockiert'}</strong><div class="panel-sub">${x.blockers.length} Blocker · ${x.warnings.length} Warnungen</div></div></div>${x.blockers.concat(x.warnings).length?`<pre class="json" style="margin-top:12px">${esc(JSON.stringify({blockers:x.blockers,warnings:x.warnings},null,2))}</pre>`:''}`}catch(err){out.innerHTML=`<div class="empty">${esc(err.message)}</div>`}});
async function loadDrift(){const out=$('#drift-result');try{const x=await api('/drift');out.innerHTML=`<div class="safe-banner ${x.state==='healthy'?'':'blocked'}"><span class="safe-icon">${x.state==='healthy'?'✓':'!'}</span><div><strong>Drift: ${esc(x.state)}</strong><div class="panel-sub">${x.findings.length} Abdeckungslücken</div></div></div><div class="layer-grid" style="margin-top:10px">${Object.entries(x.coverage).map(([k,v])=>`<div class="layer"><h4>${esc(k)}</h4><div class="metric-value">${v}</div></div>`).join('')}</div>`}catch(e){out.innerHTML=`<div class="empty">${esc(e.message)}</div>`}}
async function loadAudit(){try{const x=await api('/audit?limit=50');$('#audit-body').innerHTML=x.length?x.map(a=>`<tr><td>${esc(new Date(a.ts).toLocaleString('de-DE'))}</td><td><span class="badge ${esc(a.method.toLowerCase())}">${esc(a.method)}</span></td><td class="mono">${esc(a.endpoint)}</td><td class="status-code ${a.status<400?'ok':'err'}">${esc(a.status)}</td><td>${a.dry_run?'ja':'nein'}</td><td>${esc(a.detail)}</td></tr>`).join(''):'<tr><td colspan="6">Noch keine Audit-Einträge.</td></tr>'}catch(e){$('#audit-body').innerHTML=`<tr><td colspan="6">${esc(e.message)}</td></tr>`}}
function renderAudit(){const mode=$('#audit-actor').value;const rows=state.audit.filter(a=>mode==='all'||(mode==='ai'?a.actor!=='Weboberfläche'&&a.actor!=='System/Öffentlich':a.actor===mode));$('#audit-body').innerHTML=rows.length?rows.map(a=>`<tr><td>${esc(new Date(a.ts).toLocaleString('de-DE'))}</td><td><span class="badge">${esc(a.actor||'Legacy/API')}</span></td><td><span class="badge ${esc(a.method.toLowerCase())}">${esc(a.method)}</span></td><td class="mono">${esc(a.endpoint)}</td><td class="status-code ${a.status<400?'ok':'err'}">${esc(a.status)}</td><td>${a.dry_run?'ja':'nein'}</td><td>${esc(a.detail)}</td></tr>`).join(''):'<tr><td colspan="7">Keine Aktivitäten für diesen Filter.</td></tr>'}
async function loadAudit(){try{state.audit=await api('/audit?limit=100');renderAudit()}catch(e){$('#audit-body').innerHTML=`<tr><td colspan="7">${esc(e.message)}</td></tr>`}}
(async()=>{try{await fetch('/ui/session',{credentials:'same-origin'});await refreshAll()}catch{toast('Butler ist momentan nicht erreichbar')}})();
})();
</script>