Agent-Kontrolle und Offlien-Alarm auf 120 Minuten beschränkt
All checks were successful
release-tag / release-image (push) Successful in 2m41s

This commit is contained in:
2026-04-24 19:48:09 +02:00
parent 23ee68a018
commit eaef7322be
2 changed files with 207 additions and 7 deletions

View File

@@ -15,6 +15,7 @@ HTTP_IDLE_TIMEOUT=60s
DETECTION_INTERVAL=1m
OFFLINE_AFTER=10m
OFFLINE_ALERT_MAX=120m
FAILED_LOGON_WINDOW=5m
FAILED_LOGON_THRESHOLD=25
REBOOT_WINDOW=15m

213
main.go
View File

@@ -56,6 +56,13 @@ const uiTemplates = `
button { padding: 10px 14px; background: #2563eb; color: white; border: 0; border-radius: 8px; cursor: pointer; }
pre { white-space: pre-wrap; word-break: break-word; background: #111827; color: #e5e7eb; padding: 16px; border-radius: 10px; }
a { color: #2563eb; text-decoration: none; }
.badge { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 12px; font-weight: bold; }
.badge-on { background: #dcfce7; color: #166534; }
.badge-off { background: #fee2e2; color: #991b1b; }
.badge-muted { background: #e5e7eb; color: #374151; }
.inline-form { display: inline; }
button.danger { background: #dc2626; }
button.success { background: #16a34a; }
</style>
</head>
<body>
@@ -63,6 +70,7 @@ const uiTemplates = `
<div><strong>SIEM-lite</strong></div>
<nav>
<a href="/ui">Dashboard</a>
<a href="/ui/agents">Agents</a>
<a href="/ui/detections">Detections</a>
<a href="/ui/events">Events</a>
<a href="/metrics">Metrics</a>
@@ -189,6 +197,61 @@ const uiTemplates = `
{{template "footer" .}}
{{end}}
{{define "agents"}}
{{template "header" .}}
<h1>{{.Title}}</h1>
<p class="muted">Stand: {{fmtTime .Now}}</p>
<table>
<tr>
<th>Hostname</th>
<th>Status</th>
<th>Aktiviert</th>
<th>First Seen</th>
<th>Last Seen</th>
<th>Offline Minuten</th>
<th>Last IP</th>
<th>Aktion</th>
</tr>
{{range .Agents}}
<tr>
<td><strong>{{.Hostname}}</strong></td>
<td>
{{if .IsOnline}}
<span class="badge badge-on">online</span>
{{else}}
<span class="badge badge-off">offline</span>
{{end}}
</td>
<td>
{{if .IsEnabled}}
<span class="badge badge-on">aktiv</span>
{{else}}
<span class="badge badge-muted">inaktiv</span>
{{end}}
</td>
<td>{{fmtTime .FirstSeen}}</td>
<td>{{fmtTime .LastSeen}}</td>
<td>{{.OfflineMinutes}}</td>
<td>{{.LastIP}}</td>
<td>
<form class="inline-form" method="post" action="/ui/agents/toggle">
<input type="hidden" name="id" value="{{.ID}}">
{{if .IsEnabled}}
<input type="hidden" name="enabled" value="0">
<button class="danger" type="submit">Deaktivieren</button>
{{else}}
<input type="hidden" name="enabled" value="1">
<button class="success" type="submit">Aktivieren</button>
{{end}}
</form>
</td>
</tr>
{{end}}
</table>
{{template "footer" .}}
{{end}}
{{define "event_detail"}}
{{template "header" .}}
<h1>{{.Title}}</h1>
@@ -226,6 +289,7 @@ type Config struct {
DBConnMaxIdleTime time.Duration
DetectionInterval time.Duration
OfflineAfter time.Duration
OfflineAlertMax time.Duration
FailedLogonWindow time.Duration
FailedLogonThreshold int
RebootWindow time.Duration
@@ -379,6 +443,23 @@ type EventDetailPageData struct {
Event EventRow
}
type AgentRow struct {
ID uint64
Hostname string
FirstSeen time.Time
LastSeen time.Time
LastIP string
IsEnabled bool
IsOnline bool
OfflineMinutes int
}
type AgentListPageData struct {
Title string
Now time.Time
Agents []AgentRow
}
var (
httpRequestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "eventcollector_http_requests_total", Help: "Total HTTP requests."},
@@ -526,6 +607,8 @@ func main() {
mux.HandleFunc("/ui/detections", s.handleUIDetections)
mux.HandleFunc("/ui/events", s.handleUIEvents)
mux.HandleFunc("/ui/event", s.handleUIEventDetail)
mux.HandleFunc("/ui/agents", s.handleUIAgents)
mux.HandleFunc("/ui/agents/toggle", s.handleUIAgentToggle)
httpSrv := &http.Server{
Addr: cfg.ListenAddr,
@@ -559,6 +642,113 @@ func main() {
}
}
func (s *server) listAgents(ctx context.Context) ([]AgentRow, error) {
const q = `
SELECT id, hostname, first_seen, last_seen, last_ip, is_enabled
FROM agents
ORDER BY hostname ASC
`
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
now := time.Now().UTC()
out := make([]AgentRow, 0)
for rows.Next() {
var a AgentRow
if err := rows.Scan(
&a.ID,
&a.Hostname,
&a.FirstSeen,
&a.LastSeen,
&a.LastIP,
&a.IsEnabled,
); err != nil {
return nil, err
}
if !a.LastSeen.IsZero() {
a.OfflineMinutes = int(now.Sub(a.LastSeen.UTC()).Minutes())
a.IsOnline = a.IsEnabled && now.Sub(a.LastSeen.UTC()) <= s.cfg.OfflineAfter
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *server) handleUIAgents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
agents, err := s.listAgents(ctx)
if err != nil {
s.logger.Printf("ui agents: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
data := AgentListPageData{
Title: "Agents",
Now: time.Now(),
Agents: agents,
}
s.renderTemplate(w, "agents", data)
}
func (s *server) handleUIAgentToggle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if err := r.ParseForm(); err != nil {
writeError(w, http.StatusBadRequest, "invalid form")
return
}
id, err := strconv.ParseUint(strings.TrimSpace(r.FormValue("id")), 10, 64)
if err != nil || id == 0 {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
enabled := strings.TrimSpace(r.FormValue("enabled")) == "1"
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
res, err := s.db.ExecContext(ctx, `
UPDATE agents
SET is_enabled = ?
WHERE id = ?
`, enabled, id)
if err != nil {
s.logger.Printf("toggle agent: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/ui/agents", http.StatusSeeOther)
}
func (s *server) handleUIIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
@@ -930,6 +1120,7 @@ func loadConfig() Config {
DBConnMaxIdleTime: getenvDuration("DB_CONN_MAX_IDLE_TIME", 1*time.Minute),
DetectionInterval: getenvDuration("DETECTION_INTERVAL", 1*time.Minute),
OfflineAfter: getenvDuration("OFFLINE_AFTER", 10*time.Minute),
OfflineAlertMax: getenvDuration("OFFLINE_ALERT_MAX", 120*time.Minute),
FailedLogonWindow: getenvDuration("FAILED_LOGON_WINDOW", 5*time.Minute),
FailedLogonThreshold: getenvInt("FAILED_LOGON_THRESHOLD", 25),
RebootWindow: getenvDuration("REBOOT_WINDOW", 15*time.Minute),
@@ -1382,15 +1573,18 @@ WHERE is_enabled = 1
func (d *detector) runAgentOfflineRule(ctx context.Context) error {
windowEnd := time.Now().UTC()
windowStart := windowEnd.Add(-d.cfg.OfflineAfter)
offlineAfterTime := windowEnd.Add(-d.cfg.OfflineAfter)
maxOfflineTime := windowEnd.Add(-d.cfg.OfflineAlertMax)
const q = `
SELECT hostname, last_seen
FROM agents
WHERE is_enabled = 1
AND last_seen < ?
AND last_seen >= ?
`
rows, err := d.db.QueryContext(ctx, q, windowStart)
rows, err := d.db.QueryContext(ctx, q, offlineAfterTime, maxOfflineTime)
if err != nil {
return err
}
@@ -1402,7 +1596,9 @@ WHERE is_enabled = 1
if err := rows.Scan(&host, &lastSeen); err != nil {
return err
}
minutes := int(windowEnd.Sub(lastSeen).Minutes())
minutes := int(windowEnd.Sub(lastSeen.UTC()).Minutes())
score := math.Max(1, float64(minutes)/float64(int(d.cfg.OfflineAfter.Minutes())))
severity := severityFromScore(score, 1.5, 3.0)
@@ -1413,22 +1609,25 @@ WHERE is_enabled = 1
Severity: severity,
Hostname: host,
Score: score,
WindowStart: windowStart,
WindowStart: offlineAfterTime,
WindowEnd: windowEnd,
Summary: fmt.Sprintf("Agent %s ist seit %d Minuten offline", host, minutes),
Details: mustJSON(map[string]any{
"last_seen": lastSeen.UTC().Format(time.RFC3339Nano),
"offline_minutes": minutes,
"offline_after_min": int(d.cfg.OfflineAfter.Minutes()),
"last_seen": lastSeen.UTC().Format(time.RFC3339Nano),
"offline_minutes": minutes,
"offline_after_min": int(d.cfg.OfflineAfter.Minutes()),
"offline_alert_max_min": int(d.cfg.OfflineAlertMax.Minutes()),
}),
})
if err != nil {
return err
}
if created {
d.detectionHitsTotal.WithLabelValues("agent_offline", severity).Inc()
}
}
return rows.Err()
}