Feature: Audit-Log - tracks all user actions with timestamps
This commit is contained in:
parent
a60a91bff8
commit
70b10a4291
68
backend/api/audit.go
Normal file
68
backend/api/audit.go
Normal file
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -114,6 +114,9 @@ func (a *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
a.db.UpdateUserLastLogin(user.ID)
|
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{
|
writeJSON(w, http.StatusOK, models.LoginResponse{
|
||||||
Token: token,
|
Token: token,
|
||||||
ExpiresAt: expiresAt.Format(time.RFC3339),
|
ExpiresAt: expiresAt.Format(time.RFC3339),
|
||||||
|
|||||||
@ -86,6 +86,8 @@ func (h *Handler) triggerBackup(hub *ws.Hub) http.HandlerFunc {
|
|||||||
msg = "Config unveraendert (Hash identisch)"
|
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{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"backup_id": backupID,
|
"backup_id": backupID,
|
||||||
"hash": hash,
|
"hash": hash,
|
||||||
@ -167,6 +169,8 @@ func (h *Handler) getBackup() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.auditLog(r, "backup.download", "agent", agentID, "", fmt.Sprintf("backup_id=%s", backupIDStr))
|
||||||
|
|
||||||
// Raw XML Download
|
// Raw XML Download
|
||||||
if r.URL.Query().Get("format") == "xml" {
|
if r.URL.Query().Get("format") == "xml" {
|
||||||
w.Header().Set("Content-Type", "application/xml")
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
|||||||
@ -31,6 +31,7 @@ func (h *Handler) createCustomer(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, "Kunde anlegen fehlgeschlagen")
|
writeError(w, http.StatusInternalServerError, "Kunde anlegen fehlgeschlagen")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.auditLog(r, "customer.create", "customer", req.Number, req.Name, "")
|
||||||
writeJSON(w, http.StatusCreated, c)
|
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")
|
writeError(w, http.StatusNotFound, "Kunde nicht gefunden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.auditLog(r, "customer.delete", "customer", strconv.Itoa(id), "", "")
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Kunde geloescht"})
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Kunde geloescht"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,7 @@ func (h *Handler) uploadFirmware(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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])
|
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{}{
|
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")
|
writeError(w, http.StatusInternalServerError, "Fehler beim Setzen des Update-Flags")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.auditLog(r, "firmware.update_request", "agent", agentID, "", "")
|
||||||
log.Printf("Update angefordert fuer Agent %s", agentID)
|
log.Printf("Update angefordert fuer Agent %s", agentID)
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Update angefordert", "agent_id": 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")
|
writeError(w, http.StatusInternalServerError, "Fehler")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.auditLog(r, "firmware.update_all", "", "", "", fmt.Sprintf("%d Agents", count))
|
||||||
log.Printf("Update angefordert fuer %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})
|
writeJSON(w, http.StatusOK, map[string]interface{}{"message": fmt.Sprintf("Update fuer %d Agents angefordert", count), "count": count})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,6 +59,9 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) {
|
|||||||
mux.HandleFunc("POST /api/v1/apikeys", h.createAPIKey)
|
mux.HandleFunc("POST /api/v1/apikeys", h.createAPIKey)
|
||||||
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey)
|
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey)
|
||||||
|
|
||||||
|
// Audit-Log
|
||||||
|
mux.HandleFunc("GET /api/v1/audit", h.getAuditLogs)
|
||||||
|
|
||||||
// Firmware/Agent-Update Routes
|
// Firmware/Agent-Update Routes
|
||||||
mux.HandleFunc("GET /api/v1/firmware", h.getFirmwareInfo)
|
mux.HandleFunc("GET /api/v1/firmware", h.getFirmwareInfo)
|
||||||
mux.HandleFunc("POST /api/v1/firmware/upload", h.uploadFirmware)
|
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)
|
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{}{
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
"id": agentID,
|
"id": agentID,
|
||||||
"name": req.Name,
|
"name": req.Name,
|
||||||
@ -354,6 +358,7 @@ func (h *Handler) deleteAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Agent geloescht: %s", id)
|
log.Printf("Agent geloescht: %s", id)
|
||||||
|
h.auditLog(r, "agent.delete", "agent", id, id, "")
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Agent geloescht"})
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Agent geloescht"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@ -99,6 +100,13 @@ func (h *Handler) executeCommand(hub *ws.Hub) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
cmdTrunc := req.Command
|
||||||
|
if len(cmdTrunc) > 100 {
|
||||||
|
cmdTrunc = cmdTrunc[:100]
|
||||||
|
}
|
||||||
|
h.auditLog(r, "exec", "agent", agentID, "", cmdTrunc)
|
||||||
|
|
||||||
// Response zurückgeben
|
// Response zurückgeben
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"message": "Command gesendet",
|
"message": "Command gesendet",
|
||||||
@ -135,6 +143,8 @@ func (h *Handler) openTunnel(hub *ws.Hub) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.auditLog(r, "tunnel.open", "agent", agentID, "", fmt.Sprintf("%s:%d", req.TargetHost, req.TargetPort))
|
||||||
|
|
||||||
// Response
|
// Response
|
||||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
"tunnel_id": tunnel.ID,
|
"tunnel_id": tunnel.ID,
|
||||||
@ -155,6 +165,9 @@ func (h *Handler) closeTunnel(hub *ws.Hub) http.HandlerFunc {
|
|||||||
writeError(w, http.StatusNotFound, err.Error())
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agentID := r.PathValue("id")
|
||||||
|
h.auditLog(r, "tunnel.close", "agent", agentID, "", tunnelID)
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
"message": "Tunnel geschlossen",
|
"message": "Tunnel geschlossen",
|
||||||
@ -201,7 +214,11 @@ func (h *Handler) httpProxy(hub *ws.Hub) http.HandlerFunc {
|
|||||||
// --- Update Commands ---
|
// --- Update Commands ---
|
||||||
|
|
||||||
func (h *Handler) agentUpdateCheck(hub *ws.Hub) http.HandlerFunc {
|
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 {
|
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,
|
"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)
|
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
|
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)
|
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
|
params["delay"] = req.Delay
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.auditLog(r, "reboot", "agent", agentID, "", "")
|
||||||
h.sendCommandAndWait(hub, agentID, "reboot", params, 30, w)
|
h.sendCommandAndWait(hub, agentID, "reboot", params, 30, w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, "User anlegen fehlgeschlagen")
|
writeError(w, http.StatusInternalServerError, "User anlegen fehlgeschlagen")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.auditLog(r, "user.create", "user", "", req.Username, "")
|
||||||
writeJSON(w, http.StatusCreated, u)
|
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")
|
writeError(w, http.StatusInternalServerError, "Passwort aendern fehlgeschlagen")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.auditLog(r, "password.change", "user", strconv.Itoa(id), "", "")
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Passwort geaendert"})
|
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")
|
writeError(w, http.StatusNotFound, "User nicht gefunden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.auditLog(r, "user.delete", "user", strconv.Itoa(id), "", "")
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"message": "User geloescht"})
|
writeJSON(w, http.StatusOK, map[string]string{"message": "User geloescht"})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ func (h *Handler) wgAddPeer(hub *ws.Hub) http.HandlerFunc {
|
|||||||
params["server_name"] = req.ServerName
|
params["server_name"] = req.ServerName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.auditLog(r, "wireguard.add", "agent", agentID, "", req.Name)
|
||||||
h.sendCommandAndWait(hub, agentID, "wg_add_peer", params, 30, w)
|
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,
|
"peer_uuid": peerUUID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.auditLog(r, "wireguard.delete", "agent", agentID, "", peerUUID)
|
||||||
h.sendCommandAndWait(hub, agentID, "wg_delete_peer", params, 30, w)
|
h.sendCommandAndWait(hub, agentID, "wg_delete_peer", params, 30, w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -202,6 +202,20 @@ func (d *Database) migrate() error {
|
|||||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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)
|
// Retention Policy (90 Tage)
|
||||||
d.setupRetention()
|
d.setupRetention()
|
||||||
|
|
||||||
@ -1108,6 +1122,81 @@ func (d *Database) UpdateAPIKeyLastUsed(key string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MigrateConfigAPIKeys migriert API-Keys aus der Config in die DB (einmalig)
|
// 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) {
|
func (d *Database) MigrateConfigAPIKeys(configKeys []string) {
|
||||||
for _, key := range configKeys {
|
for _, key := range configKeys {
|
||||||
var exists bool
|
var exists bool
|
||||||
|
|||||||
13
backend/models/audit.go
Normal file
13
backend/models/audit.go
Normal file
@ -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"`
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import Customers from './pages/Customers'
|
|||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import Tunnels from './pages/Tunnels'
|
import Tunnels from './pages/Tunnels'
|
||||||
import Firmware from './pages/Firmware'
|
import Firmware from './pages/Firmware'
|
||||||
|
import AuditLog from './pages/AuditLog'
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
const { isAuthenticated, loading } = useAuthStore()
|
const { isAuthenticated, loading } = useAuthStore()
|
||||||
@ -57,6 +58,7 @@ export default function App() {
|
|||||||
<Route path="customers" element={<Customers />} />
|
<Route path="customers" element={<Customers />} />
|
||||||
<Route path="firmware" element={<Firmware />} />
|
<Route path="firmware" element={<Firmware />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
<Route path="audit" element={<AuditLog />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@ -236,6 +236,16 @@ class ApiClient {
|
|||||||
return this.delete(`/api/v1/apikeys/${id}`)
|
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
|
// Firmware
|
||||||
getFirmwareInfo() {
|
getFirmwareInfo() {
|
||||||
return this.get('/api/v1/firmware')
|
return this.get('/api/v1/firmware')
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
Cable,
|
Cable,
|
||||||
Download,
|
Download,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
ClipboardList,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@ -23,6 +24,7 @@ const navItems = [
|
|||||||
{ path: '/firmware', label: 'Firmware', icon: Download },
|
{ path: '/firmware', label: 'Firmware', icon: Download },
|
||||||
{ path: '/customers', label: 'Kunden', icon: Users },
|
{ path: '/customers', label: 'Kunden', icon: Users },
|
||||||
{ path: '/settings', label: 'Einstellungen', icon: Settings },
|
{ path: '/settings', label: 'Einstellungen', icon: Settings },
|
||||||
|
{ path: '/audit', label: 'Audit-Log', icon: ClipboardList },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
|
|||||||
185
frontend/src/pages/AuditLog.jsx
Normal file
185
frontend/src/pages/AuditLog.jsx
Normal file
@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<ClipboardList className="w-5 h-5 text-orange-500" />
|
||||||
|
<h1 className="text-lg font-bold text-white">Audit-Log</h1>
|
||||||
|
<span className="text-xs text-gray-500">{total} Einträge</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 mb-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-2 w-4 h-4 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Suchen..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={e => setActionFilter(e.target.value)}
|
||||||
|
className="appearance-none pl-3 pr-8 py-1.5 bg-gray-800 border border-gray-700 rounded text-xs text-gray-200 focus:outline-none focus:border-orange-500"
|
||||||
|
>
|
||||||
|
<option value="">Alle Aktionen</option>
|
||||||
|
{ALL_ACTIONS.map(a => (
|
||||||
|
<option key={a} value={a}>{a}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-2 top-2 w-3 h-3 text-gray-500 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-x-auto rounded border border-gray-800">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-900 text-gray-400 border-b border-gray-800">
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Zeitpunkt</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Benutzer</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Aktion</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Ziel</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Details</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-800/50">
|
||||||
|
{filtered.map(e => (
|
||||||
|
<tr key={e.id} className="hover:bg-gray-800/30">
|
||||||
|
<td className="px-3 py-1.5 text-gray-400 whitespace-nowrap" title={new Date(e.timestamp).toLocaleString('de-DE')}>
|
||||||
|
{timeAgo(e.timestamp)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-300">{e.user_name}</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium ${ACTION_COLORS[e.action] || 'bg-gray-500/20 text-gray-400'}`}>
|
||||||
|
{e.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-400 max-w-[200px] truncate" title={`${e.target_type || ''} ${e.target_name || e.target_id || ''}`}>
|
||||||
|
{e.target_name || e.target_id || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-500 max-w-[250px] truncate" title={e.details}>
|
||||||
|
{e.details || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-500 whitespace-nowrap">{e.source_ip || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && !loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-3 py-6 text-center text-gray-500">
|
||||||
|
Keine Einträge
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Load more */}
|
||||||
|
{entries.length < total && (
|
||||||
|
<div className="mt-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => load(false)}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-1.5 bg-gray-800 hover:bg-gray-700 text-gray-300 text-xs rounded border border-gray-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Laden...' : `Mehr laden (${entries.length}/${total})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user