diff --git a/agent/collector/caddy.go b/agent/collector/caddy.go new file mode 100644 index 0000000..34ba892 --- /dev/null +++ b/agent/collector/caddy.go @@ -0,0 +1,89 @@ +package collector + +import ( + "os" + "os/exec" + "strings" +) + +type CaddySite struct { + Domain string `json:"domain"` + Upstream string `json:"upstream"` + TLS bool `json:"tls"` +} + +type CaddyData struct { + Version string `json:"version"` + Sites []CaddySite `json:"sites"` +} + +const caddyFile = "/usr/local/etc/caddy/Caddyfile" + +func CollectCaddy() *CaddyData { + if _, err := os.Stat(caddyFile); err != nil { + return nil + } + + data := &CaddyData{} + + // Version + if out, err := exec.Command("/usr/local/bin/caddy", "version").Output(); err == nil { + fields := strings.Fields(strings.TrimSpace(string(out))) + if len(fields) > 0 { + data.Version = fields[0] + } + } + + // Parse Caddyfile + content, err := os.ReadFile(caddyFile) + if err != nil { + return data + } + + lines := strings.Split(string(content), "\n") + var currentDomain string + var currentTLS bool + depth := 0 + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + // Check for domain block start: "domain.com {" or "http://domain.com {" + if depth == 0 && strings.Contains(trimmed, "{") { + parts := strings.Fields(trimmed) + if len(parts) >= 2 && parts[len(parts)-1] == "{" { + currentDomain = parts[0] + currentTLS = !strings.HasPrefix(currentDomain, "http://") + depth = 1 + continue + } + } + + if depth > 0 { + // Track nested braces + depth += strings.Count(trimmed, "{") - strings.Count(trimmed, "}") + + if strings.HasPrefix(trimmed, "reverse_proxy") { + parts := strings.Fields(trimmed) + if len(parts) >= 2 { + upstream := parts[1] + data.Sites = append(data.Sites, CaddySite{ + Domain: currentDomain, + Upstream: upstream, + TLS: currentTLS, + }) + } + } + + if depth <= 0 { + currentDomain = "" + depth = 0 + } + } + } + + return data +} diff --git a/agent/collector/haproxy.go b/agent/collector/haproxy.go new file mode 100644 index 0000000..cc16499 --- /dev/null +++ b/agent/collector/haproxy.go @@ -0,0 +1,132 @@ +package collector + +import ( + "os" + "os/exec" + "strconv" + "strings" +) + +type HAProxyInfo struct { + Uptime string `json:"uptime"` + CurrentConns int `json:"current_conns"` + TotalConns int `json:"total_conns"` + TotalReqs int `json:"total_requests"` + CurrentSSL int `json:"current_ssl"` + TotalSSL int `json:"total_ssl"` + MaxConns int `json:"max_conns"` +} + +type HAProxyEntry struct { + ProxyName string `json:"proxy_name"` + ServerName string `json:"server_name"` + Type string `json:"type"` + Status string `json:"status"` + CurrentSessions int `json:"current_sessions"` + TotalSessions int `json:"total_sessions"` + BytesIn int64 `json:"bytes_in"` + BytesOut int64 `json:"bytes_out"` + RequestsTotal int `json:"requests_total"` + ErrorsReq int `json:"errors_req"` + ErrorsConn int `json:"errors_conn"` + ErrorsResp int `json:"errors_resp"` +} + +type HAProxyData struct { + Info HAProxyInfo `json:"info"` + Entries []HAProxyEntry `json:"entries"` +} + +const haproxySocket = "/var/run/haproxy.socket" + +func CollectHAProxy() *HAProxyData { + if _, err := os.Stat(haproxySocket); err != nil { + return nil + } + + data := &HAProxyData{} + + // show info + if out, err := exec.Command("/bin/sh", "-c", `echo "show info" | /usr/bin/nc -U `+haproxySocket).Output(); err == nil { + info := parseHAProxyInfo(string(out)) + data.Info = info + } + + // show stat + if out, err := exec.Command("/bin/sh", "-c", `echo "show stat" | /usr/bin/nc -U `+haproxySocket).Output(); err == nil { + data.Entries = parseHAProxyStats(string(out)) + } + + return data +} + +func parseHAProxyInfo(output string) HAProxyInfo { + info := HAProxyInfo{} + for _, line := range strings.Split(output, "\n") { + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + switch key { + case "Uptime": + info.Uptime = val + case "CurrConns": + info.CurrentConns, _ = strconv.Atoi(val) + case "CumConns": + info.TotalConns, _ = strconv.Atoi(val) + case "CumReq": + info.TotalReqs, _ = strconv.Atoi(val) + case "CurrSslConns": + info.CurrentSSL, _ = strconv.Atoi(val) + case "CumSslConns": + info.TotalSSL, _ = strconv.Atoi(val) + case "Maxconn": + info.MaxConns, _ = strconv.Atoi(val) + } + } + return info +} + +func parseHAProxyStats(output string) []HAProxyEntry { + var entries []HAProxyEntry + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" { + continue + } + fields := strings.Split(line, ",") + if len(fields) < 18 { + continue + } + + svname := fields[1] + entryType := "server" + if svname == "FRONTEND" { + entryType = "frontend" + } else if svname == "BACKEND" { + entryType = "backend" + } + + e := HAProxyEntry{ + ProxyName: fields[0], + ServerName: svname, + Type: entryType, + Status: fields[17], + } + e.CurrentSessions, _ = strconv.Atoi(fields[4]) + e.TotalSessions, _ = strconv.Atoi(fields[7]) + e.BytesIn, _ = strconv.ParseInt(fields[8], 10, 64) + e.BytesOut, _ = strconv.ParseInt(fields[9], 10, 64) + e.ErrorsReq, _ = strconv.Atoi(fields[12]) + e.ErrorsConn, _ = strconv.Atoi(fields[13]) + e.ErrorsResp, _ = strconv.Atoi(fields[14]) + if len(fields) > 48 { + e.RequestsTotal, _ = strconv.Atoi(fields[48]) + } + + entries = append(entries, e) + } + return entries +} diff --git a/agent/collector/pfstates.go b/agent/collector/pfstates.go new file mode 100644 index 0000000..8e469e7 --- /dev/null +++ b/agent/collector/pfstates.go @@ -0,0 +1,71 @@ +package collector + +import ( + "os/exec" + "regexp" + "strconv" + "strings" +) + +type PFStates struct { + CurrentEntries int `json:"current_entries"` + HardLimit int `json:"hard_limit"` + SearchRate float64 `json:"search_rate"` + InsertRate float64 `json:"insert_rate"` + RemovalRate float64 `json:"removal_rate"` + MatchRate float64 `json:"match_rate"` +} + +func CollectPFStates() *PFStates { + result := &PFStates{} + + // pfctl -si for state table info + if out, err := exec.Command("/sbin/pfctl", "-si").Output(); err == nil { + lines := strings.Split(string(out), "\n") + rateRe := regexp.MustCompile(`([\d.]+)/s`) + for _, line := range lines { + lower := strings.TrimSpace(line) + if strings.HasPrefix(lower, "current entries") { + result.CurrentEntries = parseLastInt(lower) + } else if strings.HasPrefix(lower, "searches") { + if m := rateRe.FindStringSubmatch(line); len(m) > 1 { + result.SearchRate, _ = strconv.ParseFloat(m[1], 64) + } + } else if strings.HasPrefix(lower, "inserts") { + if m := rateRe.FindStringSubmatch(line); len(m) > 1 { + result.InsertRate, _ = strconv.ParseFloat(m[1], 64) + } + } else if strings.HasPrefix(lower, "removals") { + if m := rateRe.FindStringSubmatch(line); len(m) > 1 { + result.RemovalRate, _ = strconv.ParseFloat(m[1], 64) + } + } else if strings.HasPrefix(lower, "match") { + if m := rateRe.FindStringSubmatch(line); len(m) > 1 { + result.MatchRate, _ = strconv.ParseFloat(m[1], 64) + } + } + } + } + + // pfctl -sm for hard limit + if out, err := exec.Command("/sbin/pfctl", "-sm").Output(); err == nil { + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "states") && strings.Contains(line, "hard limit") { + result.HardLimit = parseLastInt(line) + break + } + } + } + + return result +} + +func parseLastInt(line string) int { + fields := strings.Fields(line) + for i := len(fields) - 1; i >= 0; i-- { + if v, err := strconv.Atoi(fields[i]); err == nil { + return v + } + } + return 0 +} diff --git a/agent/main.go b/agent/main.go index 4d20c5e..df14aaa 100644 --- a/agent/main.go +++ b/agent/main.go @@ -128,6 +128,9 @@ func sendHeartbeat(c *client.Client, agentID string) error { cronJobs := collector.CollectCron() license := collector.CollectLicense() security := collector.CollectSecurity() + pfStates := collector.CollectPFStates() + haproxy := collector.CollectHAProxy() + caddy := collector.CollectCaddy() req := map[string]interface{}{ "agent_id": agentID, @@ -169,6 +172,9 @@ func sendHeartbeat(c *client.Client, agentID string) error { "cron_jobs": cronJobs, "license": license, "security": security, + "pf_states": pfStates, + "haproxy": haproxy, + "caddy": caddy, }, } diff --git a/backend/models/system.go b/backend/models/system.go index ac2b97a..e47f989 100644 --- a/backend/models/system.go +++ b/backend/models/system.go @@ -25,6 +25,9 @@ type SystemData struct { Proxmox map[string]interface{} `json:"proxmox,omitempty"` Backups map[string]interface{} `json:"backups,omitempty"` Security *SecurityInfo `json:"security,omitempty"` + PFStates *PFStatesInfo `json:"pf_states,omitempty"` + HAProxy *HAProxyData `json:"haproxy,omitempty"` + Caddy *CaddyData `json:"caddy,omitempty"` } type LicenseInfo struct { @@ -216,6 +219,56 @@ type SecuritySummary struct { LastLogin string `json:"last_login"` } +type PFStatesInfo struct { + CurrentEntries int `json:"current_entries"` + HardLimit int `json:"hard_limit"` + SearchRate float64 `json:"search_rate"` + InsertRate float64 `json:"insert_rate"` + RemovalRate float64 `json:"removal_rate"` + MatchRate float64 `json:"match_rate"` +} + +type HAProxyInfo struct { + Uptime string `json:"uptime"` + CurrentConns int `json:"current_conns"` + TotalConns int `json:"total_conns"` + TotalReqs int `json:"total_requests"` + CurrentSSL int `json:"current_ssl"` + TotalSSL int `json:"total_ssl"` + MaxConns int `json:"max_conns"` +} + +type HAProxyEntry struct { + ProxyName string `json:"proxy_name"` + ServerName string `json:"server_name"` + Type string `json:"type"` + Status string `json:"status"` + CurrentSessions int `json:"current_sessions"` + TotalSessions int `json:"total_sessions"` + BytesIn int64 `json:"bytes_in"` + BytesOut int64 `json:"bytes_out"` + RequestsTotal int `json:"requests_total"` + ErrorsReq int `json:"errors_req"` + ErrorsConn int `json:"errors_conn"` + ErrorsResp int `json:"errors_resp"` +} + +type HAProxyData struct { + Info HAProxyInfo `json:"info"` + Entries []HAProxyEntry `json:"entries"` +} + +type CaddySite struct { + Domain string `json:"domain"` + Upstream string `json:"upstream"` + TLS bool `json:"tls"` +} + +type CaddyData struct { + Version string `json:"version"` + Sites []CaddySite `json:"sites"` +} + // HeartbeatRequest type HeartbeatRequest struct { AgentID string `json:"agent_id"` diff --git a/frontend/src/components/AgentPanel.jsx b/frontend/src/components/AgentPanel.jsx index 51f241b..cbb608e 100644 --- a/frontend/src/components/AgentPanel.jsx +++ b/frontend/src/components/AgentPanel.jsx @@ -73,7 +73,17 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) { // Filter sub-items by platform and determine visible main categories const getVisibleSubs = (catId) => { const items = subItems[catId] || [] - return items.filter(s => !s.platforms || s.platforms.includes(platform)) + let filtered = items.filter(s => !s.platforms || s.platforms.includes(platform)) + // Dynamic sub-tabs based on data availability + if (catId === 'netzwerk') { + if (sys?.haproxy) { + filtered = [...filtered, { id: 'haproxy', label: 'HAProxy', icon: Server }] + } + if (sys?.caddy) { + filtered = [...filtered, { id: 'caddy', label: 'Caddy', icon: Globe }] + } + } + return filtered } const visibleMainTabs = mainCategories.filter(cat => { @@ -111,6 +121,8 @@ export default function AgentPanel({ agentId, customers, onClose, onReload }) { if (tab === 'tasks') return if (tab === 'backups') return platform === 'linux' ? : if (tab === 'agent') return + if (tab === 'haproxy') return + if (tab === 'caddy') return return null } @@ -363,6 +375,14 @@ function OverviewTab({ sys }) { sub={rootDisk ? `${formatBytes(rootDisk.used_bytes)} / ${formatBytes(rootDisk.total_bytes)}` : ''} bar={diskPct} barColor={barColor(diskPct || 0)} /> + {sys.pf_states && ( + 0 ? `${sys.pf_states.current_entries.toLocaleString('de-DE')} / ${sys.pf_states.hard_limit.toLocaleString('de-DE')}` : `${sys.pf_states.current_entries.toLocaleString('de-DE')}`} + sub={`Search: ${sys.pf_states.search_rate?.toFixed(1)}/s ยท Insert: ${sys.pf_states.insert_rate?.toFixed(1)}/s`} + bar={sys.pf_states.hard_limit > 0 ? (sys.pf_states.current_entries / sys.pf_states.hard_limit * 100) : null} + barColor={barColor(sys.pf_states.hard_limit > 0 ? (sys.pf_states.current_entries / sys.pf_states.hard_limit * 100) : 0)} + /> + )}

System Status

@@ -2289,6 +2309,103 @@ function AgentTab({ agent, status, platform }) { ) } +function HAProxyTab({ sys }) { + const ha = sys?.haproxy + if (!ha) return
Keine HAProxy-Daten
+ const info = ha.info || {} + const entries = ha.entries || [] + + return ( +
+

HAProxy Info

+
+ + + + +
+ +

Proxies

+
+ + + + + + + + + + + + + + + + {entries.map((e, i) => ( + + + + + + + + + + + + ))} + +
ProxyServerTypStatusSessionsRequestsTraffic InTraffic OutFehler
{e.proxy_name}{e.server_name}{e.type} + {e.status} + {e.current_sessions} / {e.total_sessions?.toLocaleString('de-DE')}{e.requests_total?.toLocaleString('de-DE') || 'โ€”'}{formatBytes(e.bytes_in)}{formatBytes(e.bytes_out)}{(e.errors_req || 0) + (e.errors_conn || 0) + (e.errors_resp || 0)}
+
+
+ ) +} + +function CaddyTab({ sys }) { + const caddy = sys?.caddy + if (!caddy) return
Keine Caddy-Daten
+ + return ( +
+

Caddy Reverse Proxy

+ {caddy.version && ( +
Version: {caddy.version}
+ )} +
+ + + + + + + + + + {(caddy.sites || []).map((s, i) => ( + + + + + + ))} + +
DomainUpstreamTLS
{s.domain}{s.upstream} + {s.tls ? 'Aktiv' : 'Aus'} +
+
+
+ ) +} + function gwColor(status) { const s = (status || '').toLowerCase() if (s === 'online' || s === 'none') return 'text-green-400'