Feature: Audit-Log - tracks all user actions with timestamps

This commit is contained in:
cynfo3000
2026-03-04 23:45:01 +01:00
parent a60a91bff8
commit 70b10a4291
15 changed files with 416 additions and 1 deletions
+68
View 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,
})
}
+3
View File
@@ -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),
+4
View File
@@ -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")
+2
View File
@@ -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"})
}
+3
View File
@@ -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})
}
+5
View File
@@ -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"})
}
+25 -1
View File
@@ -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)
}
}
+3
View File
@@ -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"})
}
+2
View File
@@ -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)
}
}
+89
View File
@@ -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
+13
View 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"`
}