diff --git a/backend/api/audit.go b/backend/api/audit.go new file mode 100644 index 0000000..24c6233 --- /dev/null +++ b/backend/api/audit.go @@ -0,0 +1,68 @@ +package api + +import ( + "log" + "net/http" + "strconv" +) + +// auditLog writes an audit log entry, extracting user from JWT context +func (h *Handler) auditLog(r *http.Request, action, targetType, targetID, targetName, details string) { + userName := "api-key" + if claims, ok := r.Context().Value(UserContextKey).(*JWTClaims); ok { + userName = claims.Username + } + sourceIP := r.RemoteAddr + + go func() { + if err := h.db.AddAuditLog(userName, action, targetType, targetID, targetName, details, sourceIP); err != nil { + log.Printf("Audit-Log Fehler: %v", err) + } + }() +} + +// auditLogDirect writes an audit log entry with explicit userName (for login handler) +func auditLogDirect(h *Handler, r *http.Request, userName, action, targetType, targetID, targetName, details string) { + sourceIP := r.RemoteAddr + go func() { + if err := h.db.AddAuditLog(userName, action, targetType, targetID, targetName, details, sourceIP); err != nil { + log.Printf("Audit-Log Fehler: %v", err) + } + }() +} + +// GET /api/v1/audit +func (h *Handler) getAuditLogs(w http.ResponseWriter, r *http.Request) { + // JWT only - check that user context exists + if _, ok := r.Context().Value(UserContextKey).(*JWTClaims); !ok { + writeError(w, http.StatusForbidden, "JWT-Authentifizierung erforderlich") + return + } + + limit := 100 + offset := 0 + action := r.URL.Query().Get("action") + agentID := r.URL.Query().Get("agent_id") + + if l := r.URL.Query().Get("limit"); l != "" { + if n, err := strconv.Atoi(l); err == nil && n > 0 { + limit = n + } + } + if o := r.URL.Query().Get("offset"); o != "" { + if n, err := strconv.Atoi(o); err == nil && n >= 0 { + offset = n + } + } + + entries, total, err := h.db.GetAuditLogs(limit, offset, action, agentID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Laden") + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "data": entries, + "total": total, + }) +} diff --git a/backend/api/auth.go b/backend/api/auth.go index 086f096..ebfceb6 100644 --- a/backend/api/auth.go +++ b/backend/api/auth.go @@ -114,6 +114,9 @@ func (a *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { a.db.UpdateUserLastLogin(user.ID) + // Audit log + go a.db.AddAuditLog(user.Username, "login", "user", "", user.Username, "", r.RemoteAddr) + writeJSON(w, http.StatusOK, models.LoginResponse{ Token: token, ExpiresAt: expiresAt.Format(time.RFC3339), diff --git a/backend/api/backup.go b/backend/api/backup.go index bb85b1b..77ca579 100644 --- a/backend/api/backup.go +++ b/backend/api/backup.go @@ -86,6 +86,8 @@ func (h *Handler) triggerBackup(hub *ws.Hub) http.HandlerFunc { msg = "Config unveraendert (Hash identisch)" } + h.auditLog(r, "backup.create", "agent", agentID, "", fmt.Sprintf("backup_id=%d", backupID)) + writeJSON(w, http.StatusOK, map[string]interface{}{ "backup_id": backupID, "hash": hash, @@ -167,6 +169,8 @@ func (h *Handler) getBackup() http.HandlerFunc { return } + h.auditLog(r, "backup.download", "agent", agentID, "", fmt.Sprintf("backup_id=%s", backupIDStr)) + // Raw XML Download if r.URL.Query().Get("format") == "xml" { w.Header().Set("Content-Type", "application/xml") diff --git a/backend/api/customers.go b/backend/api/customers.go index 927516f..403731d 100644 --- a/backend/api/customers.go +++ b/backend/api/customers.go @@ -31,6 +31,7 @@ func (h *Handler) createCustomer(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "Kunde anlegen fehlgeschlagen") return } + h.auditLog(r, "customer.create", "customer", req.Number, req.Name, "") writeJSON(w, http.StatusCreated, c) } @@ -91,6 +92,7 @@ func (h *Handler) deleteCustomer(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "Kunde nicht gefunden") return } + h.auditLog(r, "customer.delete", "customer", strconv.Itoa(id), "", "") writeJSON(w, http.StatusOK, map[string]string{"message": "Kunde geloescht"}) } diff --git a/backend/api/firmware.go b/backend/api/firmware.go index eb81796..3b477e1 100644 --- a/backend/api/firmware.go +++ b/backend/api/firmware.go @@ -54,6 +54,7 @@ func (h *Handler) uploadFirmware(w http.ResponseWriter, r *http.Request) { return } + h.auditLog(r, "firmware.upload", "firmware", version, platform, fmt.Sprintf("%d bytes", len(binary))) log.Printf("Agent-Firmware v%s (%s) hochgeladen (%d bytes, Hash: %s)", version, platform, len(binary), hashStr[:16]) writeJSON(w, http.StatusOK, map[string]interface{}{ @@ -133,6 +134,7 @@ func (h *Handler) requestUpdate(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "Fehler beim Setzen des Update-Flags") return } + h.auditLog(r, "firmware.update_request", "agent", agentID, "", "") log.Printf("Update angefordert fuer Agent %s", agentID) writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Update angefordert", "agent_id": agentID}) } @@ -151,6 +153,7 @@ func (h *Handler) requestUpdateAll(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "Fehler") return } + h.auditLog(r, "firmware.update_all", "", "", "", fmt.Sprintf("%d Agents", count)) log.Printf("Update angefordert fuer %d Agents", count) writeJSON(w, http.StatusOK, map[string]interface{}{"message": fmt.Sprintf("Update fuer %d Agents angefordert", count), "count": count}) } diff --git a/backend/api/handlers.go b/backend/api/handlers.go index 4ed023e..fcdda42 100644 --- a/backend/api/handlers.go +++ b/backend/api/handlers.go @@ -59,6 +59,9 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) { mux.HandleFunc("POST /api/v1/apikeys", h.createAPIKey) mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey) + // Audit-Log + mux.HandleFunc("GET /api/v1/audit", h.getAuditLogs) + // Firmware/Agent-Update Routes mux.HandleFunc("GET /api/v1/firmware", h.getFirmwareInfo) mux.HandleFunc("POST /api/v1/firmware/upload", h.uploadFirmware) @@ -210,6 +213,7 @@ func (h *Handler) preRegisterAgent(w http.ResponseWriter, r *http.Request) { } log.Printf("Agent pre-registriert: %s (%s)", req.Name, agentID) + h.auditLog(r, "agent.create", "agent", agentID, req.Name, "") writeJSON(w, http.StatusCreated, map[string]interface{}{ "id": agentID, "name": req.Name, @@ -354,6 +358,7 @@ func (h *Handler) deleteAgent(w http.ResponseWriter, r *http.Request) { return } log.Printf("Agent geloescht: %s", id) + h.auditLog(r, "agent.delete", "agent", id, id, "") writeJSON(w, http.StatusOK, map[string]string{"message": "Agent geloescht"}) } diff --git a/backend/api/tunnel_proxy.go b/backend/api/tunnel_proxy.go index 7c9be99..c67b7b5 100644 --- a/backend/api/tunnel_proxy.go +++ b/backend/api/tunnel_proxy.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "fmt" "log" "net/http" "time" @@ -99,6 +100,13 @@ func (h *Handler) executeCommand(hub *ws.Hub) http.HandlerFunc { return } + // Audit + cmdTrunc := req.Command + if len(cmdTrunc) > 100 { + cmdTrunc = cmdTrunc[:100] + } + h.auditLog(r, "exec", "agent", agentID, "", cmdTrunc) + // Response zurückgeben writeJSON(w, http.StatusOK, map[string]interface{}{ "message": "Command gesendet", @@ -135,6 +143,8 @@ func (h *Handler) openTunnel(hub *ws.Hub) http.HandlerFunc { return } + h.auditLog(r, "tunnel.open", "agent", agentID, "", fmt.Sprintf("%s:%d", req.TargetHost, req.TargetPort)) + // Response writeJSON(w, http.StatusCreated, map[string]interface{}{ "tunnel_id": tunnel.ID, @@ -155,6 +165,9 @@ func (h *Handler) closeTunnel(hub *ws.Hub) http.HandlerFunc { writeError(w, http.StatusNotFound, err.Error()) return } + + agentID := r.PathValue("id") + h.auditLog(r, "tunnel.close", "agent", agentID, "", tunnelID) writeJSON(w, http.StatusOK, map[string]string{ "message": "Tunnel geschlossen", @@ -201,7 +214,11 @@ func (h *Handler) httpProxy(hub *ws.Hub) http.HandlerFunc { // --- Update Commands --- func (h *Handler) agentUpdateCheck(hub *ws.Hub) http.HandlerFunc { - return h.sendAgentCommand(hub, "update_check", nil, 120) + return func(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + h.auditLog(r, "update.check", "agent", agentID, "", "") + h.sendCommandAndWait(hub, agentID, "update_check", nil, 120, w) + } } func (h *Handler) agentUpdate(hub *ws.Hub) http.HandlerFunc { @@ -217,6 +234,11 @@ func (h *Handler) agentUpdate(hub *ws.Hub) http.HandlerFunc { "reboot": req.Reboot, } + rebootStr := "ohne Reboot" + if req.Reboot { + rebootStr = "mit Reboot" + } + h.auditLog(r, "update.run", "agent", agentID, "", rebootStr) h.sendCommandAndWait(hub, agentID, "update", params, 900, w) } } @@ -243,6 +265,7 @@ func (h *Handler) agentMajorUpdate(hub *ws.Hub) http.HandlerFunc { params["phase"] = req.Phase } + h.auditLog(r, "update.major", "agent", agentID, "", fmt.Sprintf("Version: %s, Phase: %s", req.Version, req.Phase)) h.sendCommandAndWait(hub, agentID, "major_update", params, 1200, w) } } @@ -261,6 +284,7 @@ func (h *Handler) agentReboot(hub *ws.Hub) http.HandlerFunc { params["delay"] = req.Delay } + h.auditLog(r, "reboot", "agent", agentID, "", "") h.sendCommandAndWait(hub, agentID, "reboot", params, 30, w) } } diff --git a/backend/api/users.go b/backend/api/users.go index 6d1d5ce..e9eb14c 100644 --- a/backend/api/users.go +++ b/backend/api/users.go @@ -36,6 +36,7 @@ func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "User anlegen fehlgeschlagen") return } + h.auditLog(r, "user.create", "user", "", req.Username, "") writeJSON(w, http.StatusCreated, u) } @@ -61,6 +62,7 @@ func (h *Handler) changePassword(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "Passwort aendern fehlgeschlagen") return } + h.auditLog(r, "password.change", "user", strconv.Itoa(id), "", "") writeJSON(w, http.StatusOK, map[string]string{"message": "Passwort geaendert"}) } @@ -80,5 +82,6 @@ func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "User nicht gefunden") return } + h.auditLog(r, "user.delete", "user", strconv.Itoa(id), "", "") writeJSON(w, http.StatusOK, map[string]string{"message": "User geloescht"}) } diff --git a/backend/api/wireguard.go b/backend/api/wireguard.go index 8b0a1a4..cea1d8e 100644 --- a/backend/api/wireguard.go +++ b/backend/api/wireguard.go @@ -38,6 +38,7 @@ func (h *Handler) wgAddPeer(hub *ws.Hub) http.HandlerFunc { params["server_name"] = req.ServerName } + h.auditLog(r, "wireguard.add", "agent", agentID, "", req.Name) h.sendCommandAndWait(hub, agentID, "wg_add_peer", params, 30, w) } } @@ -52,6 +53,7 @@ func (h *Handler) wgDeletePeer(hub *ws.Hub) http.HandlerFunc { "peer_uuid": peerUUID, } + h.auditLog(r, "wireguard.delete", "agent", agentID, "", peerUUID) h.sendCommandAndWait(hub, agentID, "wg_delete_peer", params, 30, w) } } diff --git a/backend/db/postgres.go b/backend/db/postgres.go index 967a510..5f5ef3b 100644 --- a/backend/db/postgres.go +++ b/backend/db/postgres.go @@ -202,6 +202,20 @@ func (d *Database) migrate() error { uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`) + // Audit-Log Tabelle + d.db.Exec(`CREATE TABLE IF NOT EXISTS audit_log ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + user_name TEXT NOT NULL, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + target_name TEXT, + details TEXT, + source_ip TEXT + )`) + d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp DESC)`) + // Retention Policy (90 Tage) d.setupRetention() @@ -1108,6 +1122,81 @@ func (d *Database) UpdateAPIKeyLastUsed(key string) { } // MigrateConfigAPIKeys migriert API-Keys aus der Config in die DB (einmalig) +// --- Audit Log --- + +func (d *Database) AddAuditLog(userName, action, targetType, targetID, targetName, details, sourceIP string) error { + _, err := d.db.Exec( + `INSERT INTO audit_log (user_name, action, target_type, target_id, target_name, details, source_ip) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + userName, action, targetType, targetID, targetName, details, sourceIP, + ) + if err != nil { + log.Printf("Audit-Log schreiben fehlgeschlagen: %v", err) + } + return err +} + +func (d *Database) GetAuditLogs(limit int, offset int, action string, agentID string) ([]models.AuditLogEntry, int, error) { + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + + where := []string{} + args := []interface{}{} + argIdx := 1 + + if action != "" { + where = append(where, fmt.Sprintf("action = $%d", argIdx)) + args = append(args, action) + argIdx++ + } + if agentID != "" { + where = append(where, fmt.Sprintf("target_id = $%d", argIdx)) + args = append(args, agentID) + argIdx++ + } + + whereClause := "" + if len(where) > 0 { + whereClause = "WHERE " + strings.Join(where, " AND ") + } + + // Total count + var total int + countQuery := "SELECT COUNT(*) FROM audit_log " + whereClause + err := d.db.QueryRow(countQuery, args...).Scan(&total) + if err != nil { + return nil, 0, err + } + + // Data + query := fmt.Sprintf("SELECT id, timestamp, user_name, action, COALESCE(target_type,''), COALESCE(target_id,''), COALESCE(target_name,''), COALESCE(details,''), COALESCE(source_ip,'') FROM audit_log %s ORDER BY timestamp DESC LIMIT $%d OFFSET $%d", whereClause, argIdx, argIdx+1) + args = append(args, limit, offset) + + rows, err := d.db.Query(query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var entries []models.AuditLogEntry + for rows.Next() { + var e models.AuditLogEntry + var ts time.Time + if err := rows.Scan(&e.ID, &ts, &e.UserName, &e.Action, &e.TargetType, &e.TargetID, &e.TargetName, &e.Details, &e.SourceIP); err != nil { + return nil, 0, err + } + e.Timestamp = ts.Format(time.RFC3339) + entries = append(entries, e) + } + if entries == nil { + entries = []models.AuditLogEntry{} + } + return entries, total, rows.Err() +} + func (d *Database) MigrateConfigAPIKeys(configKeys []string) { for _, key := range configKeys { var exists bool diff --git a/backend/models/audit.go b/backend/models/audit.go new file mode 100644 index 0000000..f912931 --- /dev/null +++ b/backend/models/audit.go @@ -0,0 +1,13 @@ +package models + +type AuditLogEntry struct { + ID int `json:"id"` + Timestamp string `json:"timestamp"` + UserName string `json:"user_name"` + Action string `json:"action"` + TargetType string `json:"target_type,omitempty"` + TargetID string `json:"target_id,omitempty"` + TargetName string `json:"target_name,omitempty"` + Details string `json:"details,omitempty"` + SourceIP string `json:"source_ip,omitempty"` +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8c1c75b..5a863a6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,7 @@ import Customers from './pages/Customers' import SettingsPage from './pages/SettingsPage' import Tunnels from './pages/Tunnels' import Firmware from './pages/Firmware' +import AuditLog from './pages/AuditLog' function ProtectedRoute({ children }) { const { isAuthenticated, loading } = useAuthStore() @@ -57,6 +58,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index e8fa183..9339d08 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -236,6 +236,16 @@ class ApiClient { return this.delete(`/api/v1/apikeys/${id}`) } + // Audit Log + getAuditLogs(params = {}) { + const q = new URLSearchParams() + if (params.limit) q.set('limit', params.limit) + if (params.offset) q.set('offset', params.offset) + if (params.action) q.set('action', params.action) + if (params.agent_id) q.set('agent_id', params.agent_id) + return this.get(`/api/v1/audit?${q.toString()}`) + } + // Firmware getFirmwareInfo() { return this.get('/api/v1/firmware') diff --git a/frontend/src/layouts/AppLayout.jsx b/frontend/src/layouts/AppLayout.jsx index dda4980..4ef0d8e 100644 --- a/frontend/src/layouts/AppLayout.jsx +++ b/frontend/src/layouts/AppLayout.jsx @@ -13,6 +13,7 @@ import { Cable, Download, HardDrive, + ClipboardList, } from 'lucide-react' const navItems = [ @@ -23,6 +24,7 @@ const navItems = [ { path: '/firmware', label: 'Firmware', icon: Download }, { path: '/customers', label: 'Kunden', icon: Users }, { path: '/settings', label: 'Einstellungen', icon: Settings }, + { path: '/audit', label: 'Audit-Log', icon: ClipboardList }, ] export default function AppLayout() { diff --git a/frontend/src/pages/AuditLog.jsx b/frontend/src/pages/AuditLog.jsx new file mode 100644 index 0000000..263178e --- /dev/null +++ b/frontend/src/pages/AuditLog.jsx @@ -0,0 +1,185 @@ +import { useState, useEffect, useMemo } from 'react' +import { ClipboardList, Search, ChevronDown } from 'lucide-react' +import api from '../api/client' + +const ACTION_COLORS = { + login: 'bg-blue-500/20 text-blue-400', + 'tunnel.open': 'bg-green-500/20 text-green-400', + 'tunnel.close': 'bg-green-500/20 text-green-400', + 'backup.create': 'bg-yellow-500/20 text-yellow-400', + 'backup.download': 'bg-yellow-500/20 text-yellow-400', + 'update.check': 'bg-orange-500/20 text-orange-400', + 'update.run': 'bg-orange-500/20 text-orange-400', + 'update.major': 'bg-orange-500/20 text-orange-400', + reboot: 'bg-orange-500/20 text-orange-400', + 'agent.delete': 'bg-red-500/20 text-red-400', + 'agent.create': 'bg-emerald-500/20 text-emerald-400', + 'wireguard.add': 'bg-purple-500/20 text-purple-400', + 'wireguard.delete': 'bg-red-500/20 text-red-400', + 'firmware.upload': 'bg-cyan-500/20 text-cyan-400', + 'firmware.update_request': 'bg-orange-500/20 text-orange-400', + 'firmware.update_all': 'bg-orange-500/20 text-orange-400', + exec: 'bg-gray-500/20 text-gray-400', + 'customer.create': 'bg-emerald-500/20 text-emerald-400', + 'customer.delete': 'bg-red-500/20 text-red-400', + 'user.create': 'bg-emerald-500/20 text-emerald-400', + 'user.delete': 'bg-red-500/20 text-red-400', + 'password.change': 'bg-amber-500/20 text-amber-400', +} + +const ALL_ACTIONS = Object.keys(ACTION_COLORS) + +function timeAgo(dateStr) { + const now = new Date() + const date = new Date(dateStr) + const seconds = Math.floor((now - date) / 1000) + if (seconds < 60) return 'gerade eben' + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `vor ${minutes} Min` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `vor ${hours} Std` + const days = Math.floor(hours / 24) + return `vor ${days} Tag${days > 1 ? 'en' : ''}` +} + +export default function AuditLog() { + const [entries, setEntries] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [actionFilter, setActionFilter] = useState('') + const [offset, setOffset] = useState(0) + const LIMIT = 100 + + const load = async (reset = false) => { + try { + setLoading(true) + const newOffset = reset ? 0 : offset + const res = await api.getAuditLogs({ limit: LIMIT, offset: newOffset, action: actionFilter }) + if (reset) { + setEntries(res.data) + setOffset(LIMIT) + } else { + setEntries(prev => [...prev, ...res.data]) + setOffset(prev => prev + LIMIT) + } + setTotal(res.total) + } catch (e) { + console.error('Audit-Log laden fehlgeschlagen:', e) + } finally { + setLoading(false) + } + } + + useEffect(() => { + load(true) + }, [actionFilter]) + + const filtered = useMemo(() => { + if (!search) return entries + const q = search.toLowerCase() + return entries.filter(e => + e.user_name.toLowerCase().includes(q) || + e.action.toLowerCase().includes(q) || + (e.target_name || '').toLowerCase().includes(q) || + (e.target_id || '').toLowerCase().includes(q) || + (e.details || '').toLowerCase().includes(q) || + (e.source_ip || '').toLowerCase().includes(q) + ) + }, [entries, search]) + + return ( +
+
+ +

Audit-Log

+ {total} Einträge +
+ + {/* Filter */} +
+
+ + setSearch(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-orange-500" + /> +
+
+ + +
+
+ + {/* Table */} +
+ + + + + + + + + + + + + {filtered.map(e => ( + + + + + + + + + ))} + {filtered.length === 0 && !loading && ( + + + + )} + +
ZeitpunktBenutzerAktionZielDetailsIP
+ {timeAgo(e.timestamp)} + {e.user_name} + + {e.action} + + + {e.target_name || e.target_id || '-'} + + {e.details || '-'} + {e.source_ip || '-'}
+ Keine Einträge +
+
+ + {/* Load more */} + {entries.length < total && ( +
+ +
+ )} +
+ ) +}