From 06189a1468bfa5aced8fa058ce532da790d08e1a Mon Sep 17 00:00:00 2001 From: cynfo3000 Date: Wed, 4 Mar 2026 23:16:18 +0100 Subject: [PATCH] Feature: Sicherheit Tab - SSH Login Audit + Session History --- agent/collector/security.go | 254 +++++++++++++++++++++++++ agent/main.go | 2 + backend/models/system.go | 35 ++++ frontend/src/components/AgentPanel.jsx | 110 +++++++++++ 4 files changed, 401 insertions(+) create mode 100644 agent/collector/security.go diff --git a/agent/collector/security.go b/agent/collector/security.go new file mode 100644 index 0000000..f53fdf6 --- /dev/null +++ b/agent/collector/security.go @@ -0,0 +1,254 @@ +package collector + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "regexp" + "sort" + "strings" + "time" +) + +type LoginEvent struct { + Timestamp string `json:"timestamp"` + User string `json:"user"` + Source string `json:"source"` + Port string `json:"port"` + Method string `json:"method"` + Status string `json:"status"` + Key string `json:"key,omitempty"` +} + +type Session struct { + User string `json:"user"` + TTY string `json:"tty"` + From string `json:"from"` + Login string `json:"login"` + Logout string `json:"logout"` + Duration string `json:"duration"` +} + +type SecuritySummary struct { + TotalAccepted int `json:"total_accepted"` + TotalFailed int `json:"total_failed"` + UniqueIPs int `json:"unique_ips"` + UniqueUsers int `json:"unique_users"` + LastLogin string `json:"last_login"` +} + +type SecurityData struct { + SSHLogins []LoginEvent `json:"ssh_logins"` + WebGUILogins []LoginEvent `json:"webgui_logins"` + Sessions []Session `json:"sessions"` + Summary SecuritySummary `json:"summary"` +} + +var ( + reAccepted = regexp.MustCompile(`Accepted\s+(publickey|password)\s+for\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)`) + reFailed = regexp.MustCompile(`Failed\s+password\s+for\s+(?:invalid user\s+)?(\S+)\s+from\s+(\S+)\s+port\s+(\d+)`) + reInvalid = regexp.MustCompile(`Invalid user\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)`) + // Syslog with ISO 8601 after priority: 2024-01-15T10:30:00+01:00 ... + reTimestamp = regexp.MustCompile(`^<\d+>(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[^\s]*)`) + // BSD syslog: Jan 15 10:30:00 + reBSDTimestamp = regexp.MustCompile(`^<\d+>\s*([A-Z][a-z]{2}\s+\d+\s+\d{2}:\d{2}:\d{2})`) +) + +func CollectSecurity() *SecurityData { + data := &SecurityData{ + SSHLogins: []LoginEvent{}, + WebGUILogins: []LoginEvent{}, + Sessions: []Session{}, + } + + // Collect audit log files + logFiles := []string{"/var/log/audit/latest.log"} + now := time.Now() + for i := 0; i < 7; i++ { + d := now.AddDate(0, 0, -i) + logFiles = append(logFiles, fmt.Sprintf("/var/log/audit/audit_%s.log", d.Format("20060102"))) + } + + seen := make(map[string]bool) + for _, path := range logFiles { + events := parseAuditLog(path) + for _, e := range events { + key := e.Timestamp + e.User + e.Source + e.Port + e.Status + if !seen[key] { + seen[key] = true + data.SSHLogins = append(data.SSHLogins, e) + } + } + } + + // Sort by timestamp descending, limit to 100 + sort.Slice(data.SSHLogins, func(i, j int) bool { + return data.SSHLogins[i].Timestamp > data.SSHLogins[j].Timestamp + }) + if len(data.SSHLogins) > 100 { + data.SSHLogins = data.SSHLogins[:100] + } + + // Collect sessions + data.Sessions = collectSessions() + + // Build summary + ips := make(map[string]bool) + users := make(map[string]bool) + for _, e := range data.SSHLogins { + if e.Status == "accepted" { + data.Summary.TotalAccepted++ + } else { + data.Summary.TotalFailed++ + } + if e.Source != "" { + ips[e.Source] = true + } + if e.User != "" { + users[e.User] = true + } + } + data.Summary.UniqueIPs = len(ips) + data.Summary.UniqueUsers = len(users) + if len(data.SSHLogins) > 0 { + data.Summary.LastLogin = data.SSHLogins[0].Timestamp + } + + return data +} + +func parseAuditLog(path string) []LoginEvent { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var events []LoginEvent + scanner := bufio.NewScanner(f) + // Increase buffer for long lines + scanner.Buffer(make([]byte, 0, 64*1024), 256*1024) + + for scanner.Scan() { + line := scanner.Text() + + // Extract timestamp + ts := extractTimestamp(line) + + if m := reAccepted.FindStringSubmatch(line); m != nil { + events = append(events, LoginEvent{ + Timestamp: ts, + User: m[2], + Source: m[3], + Port: m[4], + Method: m[1], + Status: "accepted", + }) + } else if m := reFailed.FindStringSubmatch(line); m != nil { + events = append(events, LoginEvent{ + Timestamp: ts, + User: m[1], + Source: m[2], + Port: m[3], + Method: "password", + Status: "failed", + }) + } else if m := reInvalid.FindStringSubmatch(line); m != nil { + events = append(events, LoginEvent{ + Timestamp: ts, + User: m[1], + Source: m[2], + Port: m[3], + Method: "invalid_user", + Status: "failed", + }) + } + } + + return events +} + +func extractTimestamp(line string) string { + if m := reTimestamp.FindStringSubmatch(line); m != nil { + return m[1] + } + if m := reBSDTimestamp.FindStringSubmatch(line); m != nil { + // Parse BSD timestamp and add current year + t, err := time.Parse("Jan 2 15:04:05", m[1]) + if err == nil { + t = t.AddDate(time.Now().Year(), 0, 0) + return t.Format(time.RFC3339) + } + return m[1] + } + return "" +} + +func collectSessions() []Session { + out, err := exec.Command("/usr/bin/last", "-100").Output() + if err != nil { + return nil + } + + var sessions []Session + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "wtmp") || strings.HasPrefix(line, "utx.log") { + continue + } + + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + + s := Session{ + User: fields[0], + TTY: fields[1], + } + + // Determine if there's a "from" field (IP or hostname) + idx := 2 + if len(fields) > 4 && !isMonth(fields[2]) { + s.From = fields[2] + idx = 3 + } + + // Rest is login time, logout, duration + rest := strings.Join(fields[idx:], " ") + + // Try to extract login/logout/duration from the rest + if dashIdx := strings.Index(rest, " - "); dashIdx >= 0 { + s.Login = strings.TrimSpace(rest[:dashIdx]) + after := rest[dashIdx+3:] + if parenIdx := strings.Index(after, "("); parenIdx >= 0 { + s.Logout = strings.TrimSpace(after[:parenIdx]) + dur := after[parenIdx:] + dur = strings.Trim(dur, "()") + s.Duration = dur + } else { + s.Logout = strings.TrimSpace(after) + } + } else { + s.Login = rest + if strings.Contains(rest, "still logged in") { + s.Logout = "still logged in" + } + } + + sessions = append(sessions, s) + } + + return sessions +} + +func isMonth(s string) bool { + months := []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"} + for _, m := range months { + if s == m { + return true + } + } + return false +} diff --git a/agent/main.go b/agent/main.go index 04ae952..4d20c5e 100644 --- a/agent/main.go +++ b/agent/main.go @@ -127,6 +127,7 @@ func sendHeartbeat(c *client.Client, agentID string) error { updates := collector.CollectUpdates() cronJobs := collector.CollectCron() license := collector.CollectLicense() + security := collector.CollectSecurity() req := map[string]interface{}{ "agent_id": agentID, @@ -167,6 +168,7 @@ func sendHeartbeat(c *client.Client, agentID string) error { "updates": updates, "cron_jobs": cronJobs, "license": license, + "security": security, }, } diff --git a/backend/models/system.go b/backend/models/system.go index a033950..ac2b97a 100644 --- a/backend/models/system.go +++ b/backend/models/system.go @@ -24,6 +24,7 @@ type SystemData struct { License *LicenseInfo `json:"license,omitempty"` Proxmox map[string]interface{} `json:"proxmox,omitempty"` Backups map[string]interface{} `json:"backups,omitempty"` + Security *SecurityInfo `json:"security,omitempty"` } type LicenseInfo struct { @@ -181,6 +182,40 @@ type CronJob struct { Schedule string `json:"schedule"` } +type SecurityInfo struct { + SSHLogins []SecurityLoginEvent `json:"ssh_logins"` + WebGUILogins []SecurityLoginEvent `json:"webgui_logins"` + Sessions []SecuritySession `json:"sessions"` + Summary SecuritySummary `json:"summary"` +} + +type SecurityLoginEvent struct { + Timestamp string `json:"timestamp"` + User string `json:"user"` + Source string `json:"source"` + Port string `json:"port"` + Method string `json:"method"` + Status string `json:"status"` + Key string `json:"key,omitempty"` +} + +type SecuritySession struct { + User string `json:"user"` + TTY string `json:"tty"` + From string `json:"from"` + Login string `json:"login"` + Logout string `json:"logout"` + Duration string `json:"duration"` +} + +type SecuritySummary struct { + TotalAccepted int `json:"total_accepted"` + TotalFailed int `json:"total_failed"` + UniqueIPs int `json:"unique_ips"` + UniqueUsers int `json:"unique_users"` + LastLogin string `json:"last_login"` +} + // HeartbeatRequest type HeartbeatRequest struct { AgentID string `json:"agent_id"` diff --git a/frontend/src/components/AgentPanel.jsx b/frontend/src/components/AgentPanel.jsx index afc1ab4..a9c8599 100644 --- a/frontend/src/components/AgentPanel.jsx +++ b/frontend/src/components/AgentPanel.jsx @@ -19,6 +19,7 @@ const allTabs = [ { id: 'dhcp', label: 'DHCP', platforms: ['freebsd'] }, { id: 'certs', label: 'Zertifikate', platforms: ['freebsd'] }, { id: 'updates', label: 'Updates', platforms: ['freebsd'] }, + { id: 'security', label: 'Sicherheit', platforms: ['freebsd'] }, { id: 'backups', label: 'Backups' }, { id: 'agent', label: 'Agent' }, ] @@ -126,6 +127,8 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) { ) : tab === 'updates' ? ( + ) : tab === 'security' ? ( + ) : tab === 'backups' ? ( platform === 'linux' ? : ) : tab === 'agent' ? ( @@ -1384,6 +1387,113 @@ function PVEBackupsTab({ sys }) { ) } +function SecurityTab({ sys }) { + const sec = sys?.security + if (!sec) return
Keine Sicherheitsdaten verfuegbar
+ + const summary = sec.summary || {} + const logins = sec.ssh_logins || [] + const sessions = sec.sessions || [] + + const cards = [ + { label: 'Akzeptiert', value: summary.total_accepted || 0, color: 'text-green-400' }, + { label: 'Abgelehnt', value: summary.total_failed || 0, color: 'text-red-400' }, + { label: 'Eindeutige IPs', value: summary.unique_ips || 0, color: 'text-blue-400' }, + { label: 'Letzter Login', value: summary.last_login ? new Date(summary.last_login).toLocaleString('de-DE') : '-', color: 'text-gray-300', small: true }, + ] + + return ( +
+ {/* Summary Cards */} +
+ {cards.map((c, i) => ( +
+
{c.label}
+
{c.value}
+
+ ))} +
+ + {/* SSH Logins */} +
+

SSH-Logins

+ {logins.length === 0 ? ( +
Keine SSH-Login-Events
+ ) : ( +
+ + + + + + + + + + + + + {logins.map((e, i) => ( + + + + + + + + + ))} + +
ZeitstempelUserQuell-IPPortMethodeStatus
{e.timestamp ? new Date(e.timestamp).toLocaleString('de-DE') : '-'}{e.user}{e.source}{e.port} + {e.method} + + + + {e.status} + +
+
+ )} +
+ + {/* Sessions */} +
+

Sessions

+ {sessions.length === 0 ? ( +
Keine Sessions
+ ) : ( +
+ + + + + + + + + + + + + {sessions.map((s, i) => ( + + + + + + + + + ))} + +
UserTTYVonLoginLogoutDauer
{s.user}{s.tty}{s.from || '-'}{s.login}{s.logout || '-'}{s.duration || '-'}
+
+ )} +
+
+ ) +} + function BackupsTab({ agentId }) { const [backups, setBackups] = useState([]) const [loading, setLoading] = useState(true)