webui (3)

This commit is contained in:
Ludwig Lehnert
2026-07-31 20:23:52 +00:00
parent 055f318e52
commit 17f5ef8560
13 changed files with 222 additions and 56 deletions
+11 -24
View File
@@ -12,6 +12,11 @@ import sys
import time
from typing import Dict, Iterable, Optional
try:
from .audit_policy import action_for, skip_user
except ImportError:
from audit_policy import action_for, skip_user
SAMBA_LOG_GLOB = os.getenv("AUDIT_SOURCE_GLOB", "/var/log/samba/log.*")
ARCHIVE_DIR = os.getenv("AUDIT_ARCHIVE_DIR", "/state/audit")
@@ -69,28 +74,6 @@ def parse_samba_timestamp(raw_line: str, fallback: dt.datetime) -> str:
return fallback.isoformat(timespec="milliseconds")
def action_for(operation: str) -> str:
operation = operation.lower()
if operation in {
"read", "pread", "pread_recv", "pread_send", "recvfile", "sendfile",
"offload_read_recv", "offload_read_send",
}:
return "read"
if operation in {
"write", "pwrite", "pwrite_recv", "pwrite_send", "ftruncate",
"fallocate", "create_file", "mkdirat", "mknodat", "renameat",
"unlinkat", "symlinkat", "linkat", "offload_write_recv",
"offload_write_send", "fsetxattr", "removexattr", "fremovexattr",
"mkdir", "rmdir", "rename", "unlink",
}:
return "write"
if operation in {"opendir", "fdopendir", "readdir", "freaddir_attr", "closedir"}:
return "list"
if operation in {"connect", "disconnect"}:
return "session"
return "metadata"
def parse_audit_line(raw_line: str, source: str) -> Optional[Dict[str, object]]:
match = AUDIT_MARKER_RE.search(raw_line)
if match:
@@ -104,16 +87,20 @@ def parse_audit_line(raw_line: str, source: str) -> Optional[Dict[str, object]]:
return None
observed_at = utc_now()
operation = fields[5].strip()
action = action_for(operation)
user = fields[1].strip()
if action is None or skip_user(user):
return None
result = fields[6].strip()
return {
"timestamp": parse_samba_timestamp(raw_line, observed_at),
"ingestedAt": observed_at.isoformat(timespec="milliseconds"),
"user": fields[1].strip(),
"user": user,
"clientIp": fields[2].strip(),
"client": fields[3].strip(),
"share": fields[4].strip(),
"operation": operation,
"action": action_for(operation),
"action": action,
"result": result,
"success": result.upper() == "OK",
"path": "|".join(fields[7:]).strip(),
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Shared policy for the small set of user-facing audit events."""
import os
from typing import Optional, Tuple
AUDIT_ACTIONS = frozenset({"read", "write", "move", "delete"})
OPERATION_ACTIONS = {
"read": "read",
"pread": "read",
"pread_recv": "read",
"pread_send": "read",
"sendfile": "read",
"offload_read_recv": "read",
"offload_read_send": "read",
"write": "write",
"pwrite": "write",
"pwrite_recv": "write",
"pwrite_send": "write",
"recvfile": "write",
"offload_write_recv": "write",
"offload_write_send": "write",
"renameat": "move",
"rename": "move",
"unlinkat": "delete",
"unlink": "delete",
"rmdir": "delete",
}
def action_for(operation: str) -> Optional[str]:
return OPERATION_ACTIONS.get(operation.strip().casefold())
def skipped_user_suffixes() -> Tuple[str, ...]:
raw = os.getenv("AUDIT_SKIP_USER_SUFFIXES", "_svc,_ServiceAcc")
return tuple(
suffix.strip().casefold()
for suffix in raw.split(",")
if suffix.strip()
)
def account_name(user: str) -> str:
account = user.strip().rsplit("\\", 1)[-1]
return account.split("@", 1)[0]
def skip_user(user: str) -> bool:
account = account_name(user).casefold()
return any(account.endswith(suffix) for suffix in skipped_user_suffixes())
+6 -7
View File
@@ -24,7 +24,7 @@ const utcTime = value => {
const pad = number => String(number).padStart(2, "0");
return `${pad(date.getUTCDate())}.${pad(date.getUTCMonth() + 1)}.${date.getUTCFullYear()} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`;
};
const actionLabel = value => ({read: "Lesen", write: "Schreiben", list: "Auflisten", metadata: "Metadaten", session: "Sitzung"}[value] || value || "—");
const actionLabel = value => ({read: "Lesen", write: "Schreiben", move: "Umbenennen/Verschieben", delete: "Löschen"}[value] || value || "—");
const nodeTypeLabel = value => ({group: "Gruppe", user: "Benutzer", computer: "Computer", unknown: "Unbekannt"}[value] || value);
const backupStateLabel = value => ({starting: "Startet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", waiting: "Wartet", disabled: "Deaktiviert"}[value] || value || "Unbekannt");
function backupMessage(data) {
@@ -127,7 +127,7 @@ function eventRows(events) {
return events.map(event => `<tr>
<td class="timestamp">${esc(utcTime(event.timestamp))}</td>
<td>${esc(event.user)}</td><td>${esc(event.clientIp)}</td><td>${esc(event.share)}</td>
<td>${badge(actionLabel(event.action || event.operation))}<br><span class="muted">${esc(event.operation)}</span></td>
<td>${badge(actionLabel(event.action || event.operation))}</td>
<td class="path">${esc(event.path || "—")}</td>
<td>${event.success ? badge("Erfolgreich") : badge(event.result || "Fehlgeschlagen", "error")}</td>
</tr>`).join("");
@@ -230,20 +230,19 @@ async function renderStorage(type) {
async function renderActivity() {
const today = new Date();
const yesterday = new Date(Date.now() - 86400000);
content.innerHTML = pageHead("Aktivitätsprotokoll", "Aufgezeichnete Samba-Vorgänge nach Datum, Identität, Freigabe, Aktion, Ergebnis oder Pfad durchsuchen.") + `
content.innerHTML = pageHead("Aktivitätsprotokoll", "Lese-, Schreib-, Umbenennungs-/Verschiebe- und Löschvorgänge nach Datum, Identität, Freigabe, Ergebnis oder Pfad durchsuchen.") + `
<section class="panel">
<form id="activity-filter" class="filters">
<label>Von (UTC)<input name="from" type="date" value="${isoDay(yesterday)}" required></label>
<label>Bis (UTC)<input name="to" type="date" value="${isoDay(today)}" required></label>
<label>Benutzer<input name="user" list="users-list" placeholder="Alle Benutzer"></label>
<label>Freigabe<input name="share" list="shares-list" placeholder="Alle Freigaben"></label>
<label>Aktion<select name="action"><option value="">Alle Aktionen</option><option value="read">Lesen</option><option value="write">Schreiben</option><option value="list">Auflisten</option><option value="metadata">Metadaten</option><option value="session">Sitzung</option></select></label>
<label>Aktion<select name="action"><option value="">Alle Aktionen</option><option value="read">Lesen</option><option value="write">Schreiben</option><option value="move">Umbenennen/Verschieben</option><option value="delete">Löschen</option></select></label>
<label>Ergebnis<select name="result"><option value="">Alle Ergebnisse</option><option value="OK">Erfolgreich</option><option value="fail">Fehlgeschlagen</option></select></label>
<label>Operation<input name="operation" list="operations-list" placeholder="z. B. pread"></label>
<label class="wide">Pfad enthält<input name="path" placeholder="Ordner oder Dateiname"></label>
<button type="submit">Filter anwenden</button>
</form>
<datalist id="users-list"></datalist><datalist id="shares-list"></datalist><datalist id="operations-list"></datalist>
<datalist id="users-list"></datalist><datalist id="shares-list"></datalist>
<p id="activity-summary" class="muted"></p>
<div class="table-wrap"><table><thead><tr><th>Zeit (UTC)</th><th>Benutzer</th><th>Client</th><th>Freigabe</th><th>Aktion</th><th>Pfad</th><th>Ergebnis</th></tr></thead><tbody id="activity-rows"></tbody></table></div>
<p><button id="load-more" hidden>Weitere laden</button></p>
@@ -262,7 +261,7 @@ async function renderActivity() {
const more = document.querySelector("#load-more");
cursor = result.nextCursor || 0;
more.hidden = !result.nextCursor;
for (const [id, values] of [["users-list", result.facets.users], ["shares-list", result.facets.shares], ["operations-list", result.facets.operations]]) {
for (const [id, values] of [["users-list", result.facets.users], ["shares-list", result.facets.shares]]) {
document.querySelector(`#${id}`).innerHTML = values.filter(Boolean).map(value => `<option value="${esc(value)}">`).join("");
}
};
+22 -4
View File
@@ -30,6 +30,11 @@ try:
except ImportError: # Container execution uses /app as the import root.
import reconcile_shares as directory
try:
from app.audit_policy import AUDIT_ACTIONS, action_for, skip_user
except ImportError: # Container execution uses /app as the import root.
from audit_policy import AUDIT_ACTIONS, action_for, skip_user
STATE_ROOT = os.getenv("WEB_STATE_DIR", "/state/web")
AUDIT_ROOT = os.getenv("AUDIT_ARCHIVE_DIR", "/state/audit")
@@ -87,7 +92,10 @@ def base64url(value: bytes) -> str:
def base64url_decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
if not hmac.compare_digest(base64url(decoded), value):
raise ValueError("non-canonical base64url")
return decoded
class TokenManager:
@@ -589,10 +597,20 @@ def query_audit(params: Dict[str, List[str]]) -> Dict[str, object]:
if not os.path.isfile(path):
continue
for event in iter_audit_file_reverse(path):
facets["users"].add(str(event.get("user", "")))
user = str(event.get("user", ""))
operation = str(event.get("operation", ""))
action = action_for(operation)
if action is None and not operation:
stored_action = str(event.get("action", "")).casefold()
action = stored_action if stored_action in AUDIT_ACTIONS else None
if action is None or skip_user(user):
continue
if event.get("action") != action:
event = {**event, "action": action}
facets["users"].add(user)
facets["shares"].add(str(event.get("share", "")))
facets["operations"].add(str(event.get("operation", "")))
facets["actions"].add(str(event.get("action", "")))
facets["operations"].add(operation)
facets["actions"].add(action)
failed_filter = filters["result"] == "fail"
if failed_filter and bool(event.get("success", False)):
continue