Akteursbezogenes KI- und Butler-Aktivitätsprotokoll #44

Merged
sascha merged 3 commits from feature/butler-ai-activity-log into main 2026-08-16 21:40:41 +02:00
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 import FastAPI, Request, HTTPException, Depends, Query
from fastapi.responses import JSONResponse, RedirectResponse, Response, HTMLResponse from fastapi.responses import JSONResponse, RedirectResponse, Response, HTMLResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from contextvars import ContextVar
log = logging.getLogger("butler") log = logging.getLogger("butler")
VERSION = "2.3.5" VERSION = "2.3.5"
@ -46,6 +47,7 @@ _load_config()
# --- Audit log --- # --- Audit log ---
_audit_log: list[dict] = [] _audit_log: list[dict] = []
_audit_actor: ContextVar[str] = ContextVar("audit_actor", default="System/API")
MAX_AUDIT = 500 MAX_AUDIT = 500
AUDIT_DB_PATH = os.environ.get("AUDIT_DB_PATH", "/data/state/audit.sqlite3") 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, method TEXT NOT NULL,
status INTEGER NOT NULL, status INTEGER NOT NULL,
detail TEXT 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 return True
except (OSError, sqlite3.Error): except (OSError, sqlite3.Error):
return False return False
@ -83,6 +89,7 @@ def _audit(endpoint: str, method: str, status: int, detail: str = "", dry_run: b
"status": status, "status": status,
"detail": _redact_audit_detail(detail), "detail": _redact_audit_detail(detail),
"dry_run": dry_run, "dry_run": dry_run,
"actor": _audit_actor.get(),
} }
_audit_log.append(entry) _audit_log.append(entry)
if len(_audit_log) > MAX_AUDIT: 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(): if _init_audit_db():
with sqlite3.connect(AUDIT_DB_PATH) as db: with sqlite3.connect(AUDIT_DB_PATH) as db:
db.execute( db.execute(
"INSERT INTO audit (ts, endpoint, method, status, detail, dry_run) VALUES (?, ?, ?, ?, ?, ?)", "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["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,)) db.execute("DELETE FROM audit WHERE id NOT IN (SELECT id FROM audit ORDER BY id DESC LIMIT ?)", (MAX_AUDIT,))
except (OSError, sqlite3.Error): except (OSError, sqlite3.Error):
@ -280,6 +287,27 @@ def _verify(request: Request):
if not csrf or not secrets.compare_digest(csrf, session["csrf"]): if not csrf or not secrets.compare_digest(csrf, session["csrf"]):
raise HTTPException(403, "Invalid CSRF token") 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: def _emby_network_identity(endpoint: str) -> dict:
"""Normalize an Emby endpoint without treating IPv6 privacy addresses as new households.""" """Normalize an Emby endpoint without treating IPv6 privacy addresses as new households."""
raw = str(endpoint or "").strip() 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: with sqlite3.connect(AUDIT_DB_PATH) as db:
db.row_factory = sqlite3.Row db.row_factory = sqlite3.Row
rows = db.execute( 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,), (limit,),
).fetchall() ).fetchall()
return [dict(row) | {"dry_run": bool(row["dry_run"])} for row in rows] 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 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): def test_wireguard_status_returns_redacted_live_state(monkeypatch):
payload = { payload = {
"interface": "wg0", "interface": "wg0",

13
ui.html
View file

@ -76,8 +76,8 @@
</section> </section>
<section id="view-audit" class="view"> <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> <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 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> <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>
<section id="view-functions" class="view"> <section id="view-functions" class="view">
@ -93,7 +93,7 @@
<script> <script>
(() => { (() => {
'use strict'; '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.querySelector(s);
const $$=s=>[...document.querySelectorAll(s)]; const $$=s=>[...document.querySelectorAll(s)];
const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c])); 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)}; 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} 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))); $$('.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')); $('#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 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 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>`}} 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>'}}); $('#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>`}}); $('#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 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')}})(); (async()=>{try{await fetch('/ui/session',{credentials:'same-origin'});await refreshAll()}catch{toast('Butler ist momentan nicht erreichbar')}})();
})(); })();
</script> </script>