server-up/server-up/core/audit.py
Ramon 2ba15418ab
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m13s
v0.7.20-beta - instellingen wijzigen, koppelen achteraf, defect uit v0.7.00
Defect (v0.7.00): add_shared_network() hing elke service aan het gedeelde
netwerk, ook services met network_mode. Docker compose weigert die combinatie
("declares mutually exclusive network_mode and networks"), waardoor Homebridge,
Scrypted, Beszel en de ARR-stack met Gluetun niet te installeren waren met de
standaardkeuze "Verbinden met andere apps". Die services worden nu overgeslagen;
blijft er niets over, dan ook geen netwerkblok. Test over alle 96 apps.

Instellingen wijzigen na installatie:
- De gemaakte keuzes worden bij installatie opgeslagen in .serverup.json
  (values + format); zonder die waarden viel het formulier niet te heropenen.
- GET /api/stacks/<naam>/config geeft de opgeslagen waarden plus het actuele
  veldschema; POST /reconfigure maakt een backup, rendert opnieuw, past netwerk
  en eigen IP opnieuw toe, valideert en herstart. Mislukt het valideren, dan
  wordt de backup teruggezet.
- Knop Instellingen op de stackkaart opent dezelfde modal in wijzigen-stand,
  met een waarschuwing dat handmatige compose-wijzigingen verloren gaan.
- Stacks van voor deze versie: knop uit met uitleg.

Bestaande stacks koppelen:
- POST /api/stacks/<naam>/connect zet een bestaande stack op het gedeelde
  netwerk, valideert en herstart; wordt de compose ongeldig, dan wordt de
  wijziging teruggedraaid.

Audit-log:
- audit.query()/count() accepteren filters op bron, actie, status, periode en
  vrije tekst; facets() levert de keuzelijsten. /api/audit ondersteunt die als
  queryparameters plus doorbladeren. Filterbalk in de UI.

Documentatie:
- README.md (bestond niet), docs/apps-maken.md met het volledige
  templateformaat, docs/README.md als index.
- Changelog-secties voor v0.5.44/45/46 aangevuld; release.yml haalt de notes
  daaruit, dus een tag daarop gaf een lege release. Test die afdwingt dat het
  huidige VERSION een sectie heeft.

941 tests groen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-27 15:43:49 +02:00

129 lines
4.1 KiB
Python

"""Audit log backed by SQLite."""
import sqlite3, time, json, os, threading
from pathlib import Path
_db = Path(os.environ.get("SU_AUDIT", "/data/audit.db"))
# Eén gedeelde verbinding achter een lock. Eerder kreeg elke thread een eigen
# thread-local verbinding die nooit gesloten werd; omdat iedere job een nieuwe
# thread start, lekte dat een file descriptor per job.
_conn_obj: sqlite3.Connection | None = None
_lock = threading.Lock()
def init():
_db.parent.mkdir(parents=True, exist_ok=True)
with _lock:
c = _connect()
with c:
c.execute("""CREATE TABLE IF NOT EXISTS log(
id INTEGER PRIMARY KEY, ts REAL,
src TEXT, action TEXT, status TEXT,
ref TEXT, detail TEXT, ip TEXT)""")
def _connect() -> sqlite3.Connection:
"""Maak (eenmalig) de gedeelde verbinding. Aanroepen onder _lock."""
global _conn_obj
if _conn_obj is None:
_db.parent.mkdir(parents=True, exist_ok=True)
_conn_obj = sqlite3.connect(str(_db), timeout=5, check_same_thread=False)
_conn_obj.row_factory = sqlite3.Row
# WAL laat lezers en schrijvers naast elkaar werken.
_conn_obj.execute("PRAGMA journal_mode=WAL")
return _conn_obj
def close():
"""Sluit de gedeelde verbinding (gebruikt door tests en bij afsluiten)."""
global _conn_obj
with _lock:
if _conn_obj is not None:
try:
_conn_obj.close()
finally:
_conn_obj = None
def log(src: str, action: str, status="ok", ref="", detail=None, ip=""):
d = json.dumps(detail) if isinstance(detail, (dict, list)) else str(detail or "")
try:
with _lock:
c = _connect()
with c:
c.execute("INSERT INTO log(ts,src,action,status,ref,detail,ip) "
"VALUES(?,?,?,?,?,?,?)",
(time.time(), src, action, status, ref, d, ip))
except Exception:
pass
def _filter(src="", action="", status="", zoek="", sinds=None):
"""Bouw de WHERE-clausule op uit de opgegeven filters."""
delen, params = [], []
if src:
delen.append("src = ?"); params.append(src)
if action:
delen.append("action = ?"); params.append(action)
if status:
delen.append("status = ?"); params.append(status)
if sinds:
delen.append("ts >= ?"); params.append(float(sinds))
if zoek:
delen.append("(ref LIKE ? OR detail LIKE ? OR ip LIKE ?)")
params += [f"%{zoek}%"] * 3
return (" WHERE " + " AND ".join(delen)) if delen else "", params
def query(limit=100, offset=0, **filters) -> list[dict]:
"""Regels uit het log, nieuwste eerst, optioneel gefilterd.
Zonder filters groeide het log snel onbruikbaar: sinds er logins,
rolwijzigingen, backups en updates in landen zijn de laatste tweehonderd
regels al na een dag beheerwerk niet meer representatief.
"""
where, params = _filter(**filters)
try:
with _lock:
rows = _connect().execute(
f"SELECT * FROM log{where} ORDER BY ts DESC LIMIT ? OFFSET ?",
(*params, limit, offset)).fetchall()
return [dict(r) for r in rows]
except Exception:
return []
def count(**filters) -> int:
where, params = _filter(**filters)
try:
with _lock:
return _connect().execute(
f"SELECT count(*) FROM log{where}", params).fetchone()[0]
except Exception:
return 0
def facets() -> dict:
"""Welke bronnen, acties en statussen komen voor — voor de filterkeuzes."""
uit = {"src": [], "action": [], "status": []}
try:
with _lock:
c = _connect()
for kolom in uit:
rows = c.execute(
f"SELECT DISTINCT {kolom} FROM log "
f"WHERE {kolom} != '' ORDER BY {kolom}").fetchall()
uit[kolom] = [r[0] for r in rows]
except Exception:
pass
return uit
def clear():
try:
with _lock:
c = _connect()
with c:
c.execute("DELETE FROM log")
except Exception:
pass