Compare commits

..
2 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 e81735d5ec Auditlog-Export: Ziel/Details korrekt getrennt (v1.1.7)
Beim Export archivierter Auditlog-Tage (log_action("auditlog.export_delete", ...))
stand faelschlich die komplette Dateiliste im Feld "Ziel" (dadurch wurde die
Spalte in der Auditlog-Tabelle wieder unschoen breit), statt wie bei allen
anderen Aktionen ueblich einen kurzen Ressourcennamen im Ziel und die
ausfuehrliche Beschreibung in Details zu haben. Jetzt: Ziel = "Auditlog",
Details = Anzahl + exakte Dateinamen. Gleiche Korrektur fuer den
automatischen Archivierungslauf selbst (auditlog.archive). "Ziel"-Spalte
zusaetzlich mit white-space:nowrap gegen Umbrueche bei laengeren Werten
abgesichert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:19:07 +02:00
alientimandClaude Sonnet 5 2eaefc9e1f Auditlog-Archivierung, Verlauf-RAW mit Zeilennummern, UI-Politur (v1.1.6)
- Auditlog waechst nur bis zu einem Schwellenwert (20.000 Zeilen): danach
  werden die aeltesten VOLLSTAENDIGEN Kalendertage (der laufende Tag wird
  nie mitten am Tag angefasst) tageweise in je eine Datei
  audit-YYYY-MM-DD.log unter /var/log/tesm/audit-archive archiviert und
  aus der DB geloescht, bis wieder 15.000 Zeilen unterschritten sind.
  Laeuft als taeglich pruefender Hintergrund-Thread (gate: TESM_WEB_PROCESS,
  wie beim bestehenden Fileshare-Sweep), zusaetzlich einmalig sofort beim
  Dienststart.
- Archivierte Auditlog-Tage sind unter Verlauf einsehbar (RAW-Ansicht) und
  koennen dort als ein ZIP exportiert werden, was sie danach vom Server
  loescht -- bewusst nur manuell ausgeloest statt automatisch, mit eigenem
  Recht logs_history.edit (neue "E"-Spalte bei Verlauf in der
  Gruppen-Rechtematrix, ergibt sich automatisch aus GROUP_ROW_TYPES).
  Aufbewahrung der Archivdateien selbst ist unbegrenzt; bei knappem
  Speicherplatz (< 1 GiB frei im Log-Verzeichnis) erscheint ein Warnhinweis
  auf der Verlauf-Seite.
- Verlauf -> "Komplettes Log (RAW)": Inhalt jetzt mit Zeilennummern (reine
  CSS-Counter-Loesung, kein Extra-Markup pro Zeile) -- Live-Log-RAW und
  nginx-RAW-Konfiguration bleiben bewusst unveraendert (nur an dieser
  Stelle angefragt).
- Auditlog-Tabelle: Avatar-Spalte entfernt, Spaltenbreiten (Zeitpunkt/
  Benutzer/Aktion/Ziel) schrumpfen jetzt auf den Inhalt statt zu strecken,
  "Ziel" nimmt nur noch so viel Platz wie noetig; Aktion-Pills brechen
  generell nicht mehr um (white-space: nowrap).
- "Änderungen" endgueltig zu "Auditlog" umbenannt (Recht-Label, Kommentare);
  die "Änderungen"-Filterkachel selbst (Bearbeiten-Kategorie) behaelt
  bewusst ihren Namen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:06:33 +02:00
6 changed files with 473 additions and 40 deletions
+1 -1
View File
@@ -1 +1 @@
1.1.5 1.1.7
+263 -15
View File
@@ -94,6 +94,18 @@ TESM_CHANGES_LOG_PATH = os.path.join(TESM_LOG_DIR, "changes.log")
TESM_APP_LOG_PATH = os.path.join(TESM_LOG_DIR, "app.log") TESM_APP_LOG_PATH = os.path.join(TESM_LOG_DIR, "app.log")
TESM_KEA_LOG_PATH = os.environ.get("TESM_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.log") TESM_KEA_LOG_PATH = os.environ.get("TESM_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.log")
LOGROTATE_CONFIG_PATH = os.environ.get("TESM_LOGROTATE_CONFIG", "/etc/logrotate.d/tesm") LOGROTATE_CONFIG_PATH = os.environ.get("TESM_LOGROTATE_CONFIG", "/etc/logrotate.d/tesm")
# Auditlog-Archivierung: eigenständig und bewusst UNABHÄNGIG von der
# logrotate-Rotation von changes.log (siehe _archive_old_audit_log_rows).
# Schwellenwerte mit dem Nutzer abgestimmt (20.000 Zeilen Trigger, Ziel
# 15.000) -- Aufbewahrung der Archivdateien selbst ist bewusst unbegrenzt,
# stattdessen nur eine Warnung bei knappem Speicherplatz plus manueller
# Export-und-Löschen-Funktion unter Verlauf.
AUDIT_ARCHIVE_DIR = os.path.join(TESM_LOG_DIR, "audit-archive")
AUDIT_LOG_ARCHIVE_THRESHOLD = 20000
AUDIT_LOG_ARCHIVE_TARGET = 15000
AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS = 24 * 60 * 60
LOG_DISK_SPACE_WARNING_BYTES = 1 * 1024 * 1024 * 1024 # 1 GiB frei
try: try:
os.makedirs(TESM_LOG_DIR, exist_ok=True) os.makedirs(TESM_LOG_DIR, exist_ok=True)
# War früher 0o777, als noch nicht jeder schreibende Prozess (tesm.service, # War früher 0o777, als noch nicht jeder schreibende Prozess (tesm.service,
@@ -281,8 +293,8 @@ PERMISSIONS = {
"view_key": "logs_group.view", "view_key": "logs_group.view",
"children": { "children": {
"logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}}, "logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}},
"logs_history": {"label": "Verlauf", "rows": {"view": "logs_history.view"}}, "logs_history": {"label": "Verlauf", "rows": {"view": "logs_history.view", "edit": "logs_history.edit"}},
"logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}}, "logs_activity": {"label": "Auditlog", "rows": {"view": "logs_activity.view"}},
"logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}}, "logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}},
}, },
}, },
@@ -367,9 +379,10 @@ PERMISSION_LABELS = {
"maintenance.edit": "Wartungsaktionen ausführen (SSH-Update, SSH-Neustart)", "maintenance.edit": "Wartungsaktionen ausführen (SSH-Update, SSH-Neustart)",
"papierkorb.view": "Papierkorb ansehen (alle gelöschten Geräte/Switche/Zugangsdaten/Benutzer/Gruppen, unabhängig von deren eigenen Leserechten)", "papierkorb.view": "Papierkorb ansehen (alle gelöschten Geräte/Switche/Zugangsdaten/Benutzer/Gruppen, unabhängig von deren eigenen Leserechten)",
"papierkorb.edit": "Papierkorb bearbeiten (beliebige Einträge wiederherstellen oder endgültig löschen, unabhängig vom sonstigen Bearbeiten-Recht der jeweiligen Ressource)", "papierkorb.edit": "Papierkorb bearbeiten (beliebige Einträge wiederherstellen oder endgültig löschen, unabhängig vom sonstigen Bearbeiten-Recht der jeweiligen Ressource)",
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen", "logs_live.view": "Live-Log lesen", "logs_activity.view": "Auditlog lesen",
"logs_kea.view": "Kea-DHCP-Log lesen", "logs_kea.view": "Kea-DHCP-Log lesen",
"logs_history.view": "Log-Verlauf lesen (ältere, durch logrotate rotierte Kopien des Live-Logs)", "logs_history.view": "Log-Verlauf lesen (ältere, durch logrotate rotierte Kopien des Live-Logs, sowie archivierte Auditlog-Tage)",
"logs_history.edit": "Archivierte Auditlog-Tage exportieren (ZIP-Download) und danach vom Server löschen",
"users.view": "Benutzer lesen", "users.create": "Benutzer anlegen", "users.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
"users.edit": "Benutzer ändern (inkl. Löschen)", "users.edit": "Benutzer ändern (inkl. Löschen)",
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen", "groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
@@ -434,7 +447,7 @@ NAV_ITEMS = [
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [ {"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"}, {"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
{"key": "logs_history", "label": "Verlauf", "icon": "clock", "endpoint": "logs_history"}, {"key": "logs_history", "label": "Verlauf", "icon": "clock", "endpoint": "logs_history"},
{"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"}, {"key": "logs_activity", "label": "Auditlog", "icon": "history", "endpoint": "activity_log"},
{"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"}, {"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"},
]}, ]},
] ]
@@ -667,6 +680,14 @@ class User(UserMixin):
automatisch auch durch ältere Logstände blättern zu können.""" automatisch auch durch ältere Logstände blättern zu können."""
return self.has_permission("logs_history.view") return self.has_permission("logs_history.view")
@property
def can_manage_log_history(self):
"""Zusätzlich zum reinen Lesen (can_view_log_history): archivierte
Auditlog-Tage als ZIP exportieren und danach vom Server löschen --
bewusst eigenes Edit-Recht, weil das eine destruktive Aktion ist,
die über reines Einsehen des Verlaufs hinausgeht."""
return self.has_permission("logs_history.edit")
@property @property
def can_view_activity_log(self): def can_view_activity_log(self):
return self.has_permission("logs_activity.view") return self.has_permission("logs_activity.view")
@@ -1186,7 +1207,7 @@ def _append_changes_log(ts, who, action, target, details):
"""Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per """Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per
logrotate rotierte Datei (TESM_CHANGES_LOG_PATH) die audit_log-Tabelle logrotate rotierte Datei (TESM_CHANGES_LOG_PATH) die audit_log-Tabelle
bleibt die Quelle für die durchsuchbare/sortierbare Seite unter bleibt die Quelle für die durchsuchbare/sortierbare Seite unter
Logs Änderungen, wächst aber unbegrenzt weiter; die Datei bekommt Logs Auditlog, wächst aber unbegrenzt weiter; die Datei bekommt
dieselbe wöchentliche Rotation/Aufbewahrung wie die anderen drei Logs. dieselbe wöchentliche Rotation/Aufbewahrung wie die anderen drei Logs.
Ein Schreibfehler hier darf den eigentlichen, DB-basierten Audit-Trail Ein Schreibfehler hier darf den eigentlichen, DB-basierten Audit-Trail
nicht gefährden daher bewusst best-effort mit breitem except.""" nicht gefährden daher bewusst best-effort mit breitem except."""
@@ -1233,6 +1254,150 @@ def log_action_system(action, target=None, details=None):
_append_changes_log(ts, "system", action, target, details) _append_changes_log(ts, "system", action, target, details)
def _format_audit_line(ts, who, action, target, details):
"""Gleiches Zeilenformat wie _append_changes_log(), damit ein
archivierter Auditlog-Tag optisch identisch zu changes.log aussieht."""
line = f"{ts} [{who}] {action}"
if target:
line += f"{target}"
if details:
line += f": {details}"
return line
def _audit_archive_file_path(day_str):
"""day_str: 'YYYY-MM-DD', ausschließlich aus intern erzeugten
DB-Zeitstempeln abgeleitet, nie aus Nutzereingabe -- anders als bei
_resolve_log_history_file() ist hier kein Path-Traversal-Schutz nötig."""
return os.path.join(AUDIT_ARCHIVE_DIR, f"audit-{day_str}.log")
def _audit_archive_files():
"""Für die Verlauf-Seite: alle bereits archivierten Auditlog-Tage,
neueste zuerst, mit Größe für die Dateiliste."""
if not os.path.isdir(AUDIT_ARCHIVE_DIR):
return []
out = []
for name in os.listdir(AUDIT_ARCHIVE_DIR):
m = re.fullmatch(r"audit-(\d{4}-\d{2}-\d{2})\.log", name)
if not m:
continue
full_path = os.path.join(AUDIT_ARCHIVE_DIR, name)
try:
size = os.path.getsize(full_path)
except OSError:
continue
out.append({"filename": name, "day": m.group(1), "size": size})
out.sort(key=lambda f: f["day"], reverse=True)
return out
def _resolve_audit_archive_file(filename):
"""Path-Traversal-Schutz analog _resolve_log_history_file(): nur exakt
ein Dateiname im erwarteten audit-YYYY-MM-DD.log-Muster, innerhalb von
AUDIT_ARCHIVE_DIR."""
if not filename or not re.fullmatch(r"audit-\d{4}-\d{2}-\d{2}\.log", filename):
return None
full_path = os.path.join(AUDIT_ARCHIVE_DIR, filename)
if os.path.dirname(os.path.abspath(full_path)) != os.path.abspath(AUDIT_ARCHIVE_DIR):
return None
if not os.path.isfile(full_path):
return None
return full_path
def _log_disk_space_warning():
"""None wenn genug Platz frei ist, sonst ein Warnhinweis-Text fürs
Verlauf-Template. Bewusst nur eine Warnung (kein automatisches
Löschen) -- Aufräumen bleibt eine bewusste, manuelle Entscheidung
über den Export-und-Löschen-Button."""
try:
free = shutil.disk_usage(TESM_LOG_DIR).free
except OSError:
return None
if free >= LOG_DISK_SPACE_WARNING_BYTES:
return None
free_mb = free // (1024 * 1024)
return (
f"Wenig Speicherplatz im Log-Verzeichnis (nur noch {free_mb} MB frei). "
f"Archivierte Auditlog-Tage können unten exportiert (ZIP-Download) und "
f"dabei vom Server gelöscht werden, um Platz zu schaffen."
)
def _archive_old_audit_log_rows():
"""Hält die audit_log-Tabelle auf eine vernünftige historische
Zeilenanzahl begrenzt: sobald AUDIT_LOG_ARCHIVE_THRESHOLD überschritten
ist, werden die ältesten VOLLSTÄNDIGEN Kalendertage (alles vor dem
heutigen Tag -- der laufende Tag wird nie mitten am Tag angefasst) in je
eine eigene Datei audit-YYYY-MM-DD.log unter AUDIT_ARCHIVE_DIR
geschrieben und anschließend aus der DB gelöscht, so lange bis
AUDIT_LOG_ARCHIVE_TARGET wieder unterschritten ist oder kein
archivierbarer (nicht-heutiger) Tag mehr übrig ist.
Bewusst unabhängig von der wöchentlichen logrotate-Rotation von
changes.log (siehe _write_logrotate_config): die Dateien hier sind
tagesgenau benannt, damit sie unter Verlauf direkt nach Kalendertag
auswählbar sind -- eine logrotate-Kopie (changes.log.1.gz usw.) lässt
sich nicht so eindeutig einem Tag zuordnen.
Schreiben je Tagesdatei erfolgt im Overwrite-Modus ('w', nicht 'a'):
die DB ist bis zum COMMIT des DELETE die alleinige Quelle der Wahrheit
für einen noch nicht archivierten Tag -- ein erneuter Lauf (z.B. nach
einem Absturz zwischen Schreiben und Löschen) erzeugt exakt denselben
Dateiinhalt erneut, statt Zeilen zu duplizieren."""
conn = get_db_connection()
archived_days = 0
archived_rows = 0
try:
total = conn.execute("SELECT COUNT(*) AS n FROM audit_log").fetchone()["n"]
if total <= AUDIT_LOG_ARCHIVE_THRESHOLD:
return
today_start = datetime.now().strftime("%Y-%m-%d 00:00:00")
os.makedirs(AUDIT_ARCHIVE_DIR, exist_ok=True)
while total > AUDIT_LOG_ARCHIVE_TARGET:
oldest = conn.execute(
"SELECT MIN(ts) AS ts FROM audit_log WHERE ts < ?", (today_start,)
).fetchone()["ts"]
if not oldest:
break # nur noch der heutige Tag übrig -- nicht anfassen
day_str = oldest[:10]
day_start, day_end = f"{day_str} 00:00:00", f"{day_str} 23:59:59"
rows = conn.execute(
"SELECT ts, username, action, target, details FROM audit_log "
"WHERE ts >= ? AND ts <= ? ORDER BY ts, id",
(day_start, day_end),
).fetchall()
if not rows:
break
lines = [
_format_audit_line(r["ts"], r["username"], r["action"], r["target"], r["details"])
for r in rows
]
with open(_audit_archive_file_path(day_str), "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
conn.execute("DELETE FROM audit_log WHERE ts >= ? AND ts <= ?", (day_start, day_end))
conn.commit()
archived_days += 1
archived_rows += len(rows)
total -= len(rows)
finally:
conn.close()
if archived_rows:
log_action_system(
"auditlog.archive",
"Auditlog",
f"{archived_rows} Einträge über {archived_days} Tag(e) archiviert "
f"(Schwellenwert {AUDIT_LOG_ARCHIVE_THRESHOLD}, Ziel {AUDIT_LOG_ARCHIVE_TARGET}).",
)
def touch_record(conn, table, key_col, key_val): def touch_record(conn, table, key_col, key_val):
"""Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches).""" """Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches)."""
who = current_user.username if current_user.is_authenticated else "system" who = current_user.username if current_user.is_authenticated else "system"
@@ -1285,7 +1450,7 @@ LOG_ROTATION_DEFAULT_KEEP = 4
def _write_logrotate_config(): def _write_logrotate_config():
"""Schreibt/aktualisiert die logrotate-Konfiguration für alle vier """Schreibt/aktualisiert die logrotate-Konfiguration für alle vier
App-Logs (Live, Änderungen, App unter TESM_LOG_DIR; Kea-DHCP separat App-Logs (Live, Auditlog, App unter TESM_LOG_DIR; Kea-DHCP separat
unter TESM_KEA_LOG_PATH Keas AppArmor-Profil erlaubt nur genau diesen unter TESM_KEA_LOG_PATH Keas AppArmor-Profil erlaubt nur genau diesen
einen Pfad, siehe Kommentar bei TESM_KEA_LOG_PATH) anhand der aktuellen einen Pfad, siehe Kommentar bei TESM_KEA_LOG_PATH) anhand der aktuellen
Einstellungen (Systemeinstellungen Logs). Läuft sowohl beim App-Start Einstellungen (Systemeinstellungen Logs). Läuft sowohl beim App-Start
@@ -2106,6 +2271,25 @@ if _IS_WEB_PROCESS:
threading.Thread(target=_fileshare_sweep_loop, daemon=True).start() threading.Thread(target=_fileshare_sweep_loop, daemon=True).start()
def _audit_archive_loop():
"""Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob
die audit_log-Tabelle den Schwellenwert überschritten hat, und
archiviert dann bei Bedarf über _archive_old_audit_log_rows(). Läuft
direkt beim Start einmal sofort (statt erst nach 24h zu prüfen), damit
ein frisch aktualisierter/neu gestarteter Dienst eine bereits
übervolle Tabelle nicht einen ganzen Tag lang unangetastet lässt."""
while True:
try:
_archive_old_audit_log_rows()
except Exception:
app.logger.error("Auditlog-Archivierung fehlgeschlagen:\n%s", traceback.format_exc())
time.sleep(AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS)
if _IS_WEB_PROCESS:
threading.Thread(target=_audit_archive_loop, daemon=True).start()
def _current_fileshare_mounts(): def _current_fileshare_mounts():
"""Für die Fileshare-Seite und die Nav-Sichtbarkeit: gemountete """Für die Fileshare-Seite und die Nav-Sichtbarkeit: gemountete
Freigaben der AKTUELLEN Session, oder eine leere Liste (lokale Nutzer, Freigaben der AKTUELLEN Session, oder eine leere Liste (lokale Nutzer,
@@ -7366,6 +7550,8 @@ def logs_history():
return render_template( return render_template(
"logs_history.html", files=files, selected_name=selected_name, selected_file=selected_file, "logs_history.html", files=files, selected_name=selected_name, selected_file=selected_file,
log_content=log_content, total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES, log_content=log_content, total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES,
audit_files=_audit_archive_files(), disk_warning=_log_disk_space_warning(),
audit_threshold=AUDIT_LOG_ARCHIVE_THRESHOLD, audit_target=AUDIT_LOG_ARCHIVE_TARGET,
) )
@@ -7387,6 +7573,72 @@ def logs_history_raw():
return response return response
@app.route("/logs/history/audit-raw")
@login_required
def logs_history_audit_raw():
"""Wie logs_history_raw(), aber für einen einzelnen bereits
archivierten Auditlog-Tag (audit-YYYY-MM-DD.log) statt für eine
Live-Log-Rotation."""
if not current_user.can_view_log_history:
return "Keine Berechtigung.", 403
path = _resolve_audit_archive_file(request.args.get("file", ""))
if not path:
return "Unbekannte oder ungültige Archivdatei.", 404
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
content = f"Fehler beim Lesen der Datei: {e}"
response = app.response_class(content, mimetype="text/plain")
response.headers["X-Log-Name"] = os.path.basename(path)
return response
@app.route("/logs/history/audit-export", methods=["POST"])
@login_required
def logs_history_audit_export():
"""Exportiert ALLE aktuell archivierten Auditlog-Tage als ein ZIP und
löscht sie danach vom Server -- bewusst eine einzige, manuell
ausgelöste Aktion (Recht logs_history.edit, siehe
User.can_manage_log_history) statt einer automatischen Löschung nach
Ablaufzeit: die Archivdateien selbst bleiben unbegrenzt liegen, bis ein
Admin hier gezielt aufräumt (z.B. wenn _log_disk_space_warning()
anschlägt)."""
if not current_user.can_manage_log_history:
return "Keine Berechtigung.", 403
files = _audit_archive_files()
if not files:
flash("Keine archivierten Auditlog-Tage zum Exportieren vorhanden.", "info")
return redirect(url_for("logs_history"))
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for f in files:
zf.write(os.path.join(AUDIT_ARCHIVE_DIR, f["filename"]), arcname=f["filename"])
buf.seek(0)
deleted = []
for f in files:
try:
os.remove(os.path.join(AUDIT_ARCHIVE_DIR, f["filename"]))
deleted.append(f["filename"])
except OSError:
pass
log_action(
"auditlog.export_delete",
"Auditlog",
f"{len(deleted)} Datei(en) exportiert und gelöscht: {', '.join(deleted)}",
)
today_str = datetime.now().strftime("%Y-%m-%d")
return send_file(
buf, mimetype="application/zip", as_attachment=True,
download_name=f"auditlog-archiv-{today_str}.zip",
)
@app.route("/logs") @app.route("/logs")
@login_required @login_required
def logs(): def logs():
@@ -7435,7 +7687,7 @@ def get_kea_log():
@login_required @login_required
def kea_log(): def kea_log():
"""Eigene Log-Seite für den Kea-DHCP-Dienst, konsistent mit Live-Log/ """Eigene Log-Seite für den Kea-DHCP-Dienst, konsistent mit Live-Log/
Änderungen die Konfiguration (siehe _render_kea_config) leitet Keas Auditlog die Konfiguration (siehe _render_kea_config) leitet Keas
eigene Logging-Ausgabe explizit in TESM_KEA_LOG_PATH statt in den eigene Logging-Ausgabe explizit in TESM_KEA_LOG_PATH statt in den
distributionsabhängigen Default (meist Syslog), damit sie sich hier distributionsabhängigen Default (meist Syslog), damit sie sich hier
genauso wie die anderen Logs einsehen und per logrotate rotieren genauso wie die anderen Logs einsehen und per logrotate rotieren
@@ -7457,22 +7709,18 @@ def kea_log():
@app.route("/logs/aenderungen") @app.route("/logs/aenderungen")
@login_required @login_required
def activity_log(): def activity_log():
"""Änderungslog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen, """Auditlog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen,
Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts die stehen im Live-Log.""" Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts die stehen im Live-Log."""
if not current_user.can_view_activity_log: if not current_user.can_view_activity_log:
flash("Keine Berechtigung, das Änderungslog einzusehen.", "danger") flash("Keine Berechtigung, das Auditlog einzusehen.", "danger")
return redirect(url_for("index")) return redirect(url_for("index"))
conn = get_db_connection() conn = get_db_connection()
entries = conn.execute( entries = conn.execute(
"SELECT id, ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT 500" "SELECT id, ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT 500"
).fetchall() ).fetchall()
avatars = {
row["username"]: row["avatar_filename"]
for row in conn.execute("SELECT username, avatar_filename FROM users").fetchall()
}
conn.close() conn.close()
return render_template("activity_log.html", entries=entries, avatars=avatars) return render_template("activity_log.html", entries=entries)
+38
View File
@@ -446,6 +446,18 @@ button { font-family: inherit; }
.card-pad { padding: 20px 22px; } .card-pad { padding: 20px 22px; }
.notice-banner {
padding: 12px 16px;
border-radius: var(--radius);
font-size: 12.5px;
line-height: 1.5;
}
.notice-banner--warning {
background: var(--warning-dim);
color: var(--warning);
border: 1px solid var(--warning);
}
.section-head { .section-head {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -498,6 +510,7 @@ button { font-family: inherit; }
border-radius: 999px; border-radius: 999px;
font-size: 12px; font-size: 12px;
font-weight: 650; font-weight: 650;
white-space: nowrap;
} }
.pill svg { width: 12px; height: 12px; } .pill svg { width: 12px; height: 12px; }
.pill::before { .pill::before {
@@ -1151,6 +1164,31 @@ select {
} }
[data-theme="light"] .raw-log-content { background: #0e1116; } [data-theme="light"] .raw-log-content { background: #0e1116; }
/* Zeilennummern im RAW-Log-Popup (aktuell nur "Verlauf") -- Nummer kommt
rein aus CSS-Countern (kein Extra-Markup pro Zeile mit fest eingebrannter
Zahl), damit Filtern/Kopieren des reinen Logtexts unverändert bleibt. */
.raw-log-content.with-line-numbers {
counter-reset: raw-line;
white-space: normal;
}
.raw-log-content.with-line-numbers .raw-log-line {
display: flex;
white-space: pre-wrap;
word-break: break-word;
}
.raw-log-content.with-line-numbers .raw-log-line::before {
counter-increment: raw-line;
content: counter(raw-line);
flex: 0 0 auto;
min-width: 3.5em;
margin-right: 14px;
padding-right: 10px;
border-right: 1px solid var(--border);
text-align: right;
color: var(--text-faint);
user-select: none;
}
/* ========================================================================== /* ==========================================================================
Permission checklists (Gruppen) Permission checklists (Gruppen)
========================================================================== */ ========================================================================== */
+23 -2
View File
@@ -245,12 +245,30 @@
}); });
} }
window.openRawLogModal = function (url, title) { function renderRawLogLines(container, text) {
// Eine Zeile = ein <div>, Nummer kommt per CSS-Counter (::before) --
// so bleibt der eigentliche Zeileninhalt reiner textContent (kein XSS-
// Risiko) und die Nummerierung muss nirgends von Hand mitgezählt werden.
const lines = text.split("\n");
if (lines.length && lines[lines.length - 1] === "") lines.pop(); // trailing \n erzeugt sonst eine Phantomzeile
container.textContent = "";
const frag = document.createDocumentFragment();
lines.forEach((line) => {
const row = document.createElement("div");
row.className = "raw-log-line";
row.textContent = line;
frag.appendChild(row);
});
container.appendChild(frag);
}
window.openRawLogModal = function (url, title, withLineNumbers) {
if (!url) return; if (!url) return;
ensureRawLogModal(); ensureRawLogModal();
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)"; document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
const content = document.getElementById("raw-log-modal-content"); const content = document.getElementById("raw-log-modal-content");
const meta = document.getElementById("raw-log-modal-meta"); const meta = document.getElementById("raw-log-modal-meta");
content.classList.toggle("with-line-numbers", !!withLineNumbers);
content.textContent = "Lade …"; content.textContent = "Lade …";
meta.textContent = ""; meta.textContent = "";
openModal("raw-log-modal"); openModal("raw-log-modal");
@@ -260,7 +278,10 @@
if (logName) meta.textContent = logName; if (logName) meta.textContent = logName;
return r.text(); return r.text();
}) })
.then((text) => { content.textContent = text; }) .then((text) => {
if (withLineNumbers) renderRawLogLines(content, text);
else content.textContent = text;
})
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; }); .catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
}; };
+97 -21
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "logs" %} {% set active_page = "logs" %}
{% block page_title %}Änderungen{% endblock %} {% block page_title %}Auditlog{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %}
{% block content %} {% block content %}
@@ -34,6 +34,8 @@
"fileshare.delete": "Datei/Ordner gelöscht", "fileshare.delete": "Datei/Ordner gelöscht",
"fileshare.rename": "Datei/Ordner umbenannt", "fileshare.rename": "Datei/Ordner umbenannt",
"fileshare.download": "Als ZIP heruntergeladen", "fileshare.download": "Als ZIP heruntergeladen",
"auditlog.archive": "Auditlog archiviert",
"auditlog.export_delete": "Auditlog-Archiv exportiert",
} %} } %}
{% set action_icons = { {% set action_icons = {
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>', "delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
@@ -42,45 +44,119 @@
"activate": '<path d="M20 6L9 17l-5-5"/>', "activate": '<path d="M20 6L9 17l-5-5"/>',
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>', "deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
} %} } %}
{% set action_kind_class = { {% set create_kinds = ("create", "upload", "mkdir") %}
"create": "action-pill--create",
"upload": "action-pill--create", {% macro category_of(kind) %}{% if kind in create_kinds %}create{% elif kind == "delete" %}delete{% else %}edit{% endif %}{% endmacro %}
"mkdir": "action-pill--create",
"delete": "action-pill--delete", {% set ns = namespace(create=0, delete=0, edit=0) %}
} %} {% for e in entries %}
{% set cat = category_of(e['action'].split('.')[-1])|trim %}
{% if cat == "create" %}{% set ns.create = ns.create + 1 %}
{% elif cat == "delete" %}{% set ns.delete = ns.delete + 1 %}
{% else %}{% set ns.edit = ns.edit + 1 %}{% endif %}
{% endfor %}
<div class="stat-row" style="margin-bottom:18px;">
<div class="stat-card" data-category-filter="">
<div class="stat-label">Alle</div>
<div class="stat-value">{{ entries|length }}</div>
</div>
<div class="stat-card" data-category-filter="create">
<div class="stat-label">Hinzufügen</div>
<div class="stat-value" style="color:var(--success);">{{ ns.create }}</div>
</div>
<div class="stat-card" data-category-filter="edit">
<div class="stat-label">Änderungen</div>
<div class="stat-value" style="color:var(--accent-strong);">{{ ns.edit }}</div>
</div>
<div class="stat-card" data-category-filter="delete">
<div class="stat-label">Löschungen</div>
<div class="stat-value" style="color:var(--danger);">{{ ns.delete }}</div>
</div>
</div>
<div class="table-wrap"> <div class="table-wrap">
<div class="table-toolbar">
<div class="search-input">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" id="auditSearch" placeholder="Benutzer, Aktion, Ziel oder Details durchsuchen...">
</div>
</div>
<div style="overflow-x:auto;"> <div style="overflow-x:auto;">
<table class="data-table"> <table class="data-table" id="auditTable" data-sortable>
<thead><tr><th style="width:1%;"></th><th>Zeitpunkt</th><th>Benutzer</th><th>Aktion</th><th>Ziel</th><th>Details</th></tr></thead> <thead><tr>
<th data-sort-key="ts" style="width:1%; white-space:nowrap;">Zeitpunkt</th>
<th data-sort-key="user" style="width:1%; white-space:nowrap;">Benutzer</th>
<th data-sort-key="action" style="width:1%; white-space:nowrap;">Aktion</th>
<th data-sort-key="target" style="width:1%; white-space:nowrap;">Ziel</th>
<th>Details</th>
</tr></thead>
<tbody> <tbody>
{% for e in entries %} {% for e in entries %}
{% set kind = e['action'].split('.')[-1] %} {% set kind = e['action'].split('.')[-1] %}
<tr> {% set cat = category_of(kind)|trim %}
<td> <tr data-category="{{ cat }}" data-sort-ts="{{ e['ts'] }}" data-sort-user="{{ e['username']|lower }}"
{% if avatars.get(e['username']) %} data-sort-action="{{ action_labels.get(e['action'], e['action'])|lower }}" data-sort-target="{{ (e['target'] or '')|lower }}">
<img class="avatar-sm" src="{{ url_for('static', filename='uploads/avatars/' + avatars[e['username']]) }}" alt="">
{% else %}
<span class="avatar-sm avatar-placeholder">{{ e['username'][:1]|upper }}</span>
{% endif %}
</td>
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td> <td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
<td class="cell-name">{{ e['username'] }}</td> <td class="cell-name">{{ e['username'] }}</td>
<td> <td>
<span class="pill action-pill {{ action_kind_class.get(kind, 'action-pill--edit') }}"> <span class="pill action-pill action-pill--{{ cat }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
{{ action_labels.get(e['action'], e['action']) }} {{ action_labels.get(e['action'], e['action']) }}
</span> </span>
</td> </td>
<td>{{ e['target'] or '—' }}</td> <td style="white-space:nowrap;">{{ e['target'] or '—' }}</td>
<td class="text-dim">{{ e['details'] or '—' }}</td> <td class="text-dim">{{ e['details'] or '—' }}</td>
</tr> </tr>
{% else %} {% else %}
<tr class="empty-row"><td colspan="6">Noch keine Änderungen protokolliert.</td></tr> <tr class="empty-row"><td colspan="5">Noch keine Änderungen protokolliert.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> <p id="auditNoResults" class="text-faint hidden" style="padding:16px; text-align:center; font-size:12.5px;">Keine Einträge für diese Auswahl.</p>
</div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const searchInput = document.getElementById("auditSearch");
const noResults = document.getElementById("auditNoResults");
const rows = Array.from(document.querySelectorAll("#auditTable tbody tr"));
let activeCategory = null;
function applyFilters() {
const q = (searchInput ? searchInput.value : "").trim().toLowerCase();
let anyVisible = false;
rows.forEach(function (row) {
if (row.classList.contains("empty-row")) return;
const categoryMatches = !activeCategory || row.dataset.category === activeCategory;
const textMatches = !q || row.innerText.toLowerCase().includes(q);
const match = categoryMatches && textMatches;
row.style.display = match ? "" : "none";
if (match) anyVisible = true;
});
if (noResults) noResults.classList.toggle("hidden", anyVisible);
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
const isTotal = card.dataset.categoryFilter === "";
const active = activeCategory === null ? isTotal : card.dataset.categoryFilter === activeCategory;
card.classList.toggle("active", active);
});
}
if (searchInput) searchInput.addEventListener("input", applyFilters);
document.querySelectorAll(".stat-card[data-category-filter]").forEach(function (card) {
card.addEventListener("click", function () {
const key = this.dataset.categoryFilter;
activeCategory = (!key || activeCategory === key) ? null : key;
applyFilters();
});
});
applyFilters();
});
</script>
{% endblock %}
+51 -1
View File
@@ -9,6 +9,7 @@
<div> <div>
<h2 style="font-size:16px;">Log-Verlauf</h2> <h2 style="font-size:16px;">Log-Verlauf</h2>
<div class="hint">Aktuelles Live-Log sowie ältere, von logrotate rotierte Kopien davon — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar, auswählbar nach Zeitraum. Aus Performance-Gründen unformatiert (RAW) dargestellt, ohne farbliche Aufbereitung.</div> <div class="hint">Aktuelles Live-Log sowie ältere, von logrotate rotierte Kopien davon — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar, auswählbar nach Zeitraum. Aus Performance-Gründen unformatiert (RAW) dargestellt, ohne farbliche Aufbereitung.</div>
<div class="hint">Zusätzlich weiter unten: bereits archivierte Auditlog-Tage (das Auditlog wird ab {{ "{:,}".format(audit_threshold).replace(",", ".") }} Einträgen automatisch tageweise archiviert, bis {{ "{:,}".format(audit_target).replace(",", ".") }} Einträge unterschritten sind).</div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a href="{{ url_for('logs') }}" class="btn btn-secondary"> <a href="{{ url_for('logs') }}" class="btn btn-secondary">
@@ -16,7 +17,7 @@
Zurück zur Live-Ansicht Zurück zur Live-Ansicht
</a> </a>
<button type="button" class="btn btn-secondary" <button type="button" class="btn btn-secondary"
onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_file.range_label if selected_file else '' }}')" onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_file.range_label if selected_file else '' }}', true)"
{% if not selected_name %}disabled{% endif %}> {% if not selected_name %}disabled{% endif %}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
Komplettes Log (RAW) Komplettes Log (RAW)
@@ -50,6 +51,55 @@
<pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre> <pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre>
</div> </div>
{% endif %} {% endif %}
<div class="section-head" style="margin-top:28px;">
<div>
<h2 style="font-size:16px;">Archivierte Auditlog-Tage</h2>
<div class="hint">Ältere Auditlog-Einträge, tageweise als eigene Datei ausgelagert, sobald die laufende Tabelle den Schwellenwert überschreitet — die Dateien selbst bleiben unbegrenzt erhalten, bis sie hier bewusst exportiert werden.</div>
</div>
{% if current_user.can_manage_log_history and audit_files %}
<form method="post" action="{{ url_for('logs_history_audit_export') }}"
data-confirm="Alle {{ audit_files|length }} archivierten Auditlog-Datei(en) als ZIP herunterladen und danach vom Server löschen?"
data-confirm-title="Export &amp; Löschen">
<button type="submit" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
Alle exportieren &amp; löschen (ZIP)
</button>
</form>
{% endif %}
</div>
{% if disk_warning %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">{{ disk_warning }}</div>
{% endif %}
{% if not audit_files %}
<p class="text-faint" style="font-size:12.5px;">Noch keine archivierten Auditlog-Tage vorhanden.</p>
{% else %}
<div class="table-wrap">
<table class="data-table">
<thead><tr>
<th style="width:1%; white-space:nowrap;">Tag</th>
<th style="width:1%; white-space:nowrap;">Größe</th>
<th></th>
</tr></thead>
<tbody>
{% for f in audit_files %}
<tr>
<td class="mono">{{ f.day }}</td>
<td class="text-dim mono" style="font-size:12.5px;">{{ (f.size / 1024)|round(1) }} KB</td>
<td>
<button type="button" class="btn btn-secondary btn-sm"
onclick="openRawLogModal('{{ url_for('logs_history_audit_raw', file=f.filename) }}', 'Auditlog-Archiv — {{ f.day }}', true)">
Ansehen (RAW)
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}