Feature: PF States, HAProxy + Caddy collectors and dashboard

This commit is contained in:
cynfo3000
2026-03-05 09:29:14 +01:00
parent 31369381d0
commit f4a28463b7
6 changed files with 469 additions and 1 deletions
+89
View File
@@ -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
}
+132
View File
@@ -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
}
+71
View File
@@ -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
}
+6
View File
@@ -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,
},
}