refactor: agent/ → agent-bsd/, README.md für BSD-Agent erstellt, Referenzen aktualisiert

This commit is contained in:
cynfo3000
2026-03-06 09:48:05 +01:00
parent 39c168e946
commit f3b052e2c7
32 changed files with 111 additions and 3 deletions
+138
View File
@@ -0,0 +1,138 @@
package collector
import (
"encoding/json"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"
)
type CaddySite struct {
Domain string `json:"domain"`
Upstream string `json:"upstream"`
TLS bool `json:"tls"`
}
type CaddyUpstream struct {
Address string `json:"address"`
NumRequests int `json:"num_requests"`
Fails int `json:"fails"`
Healthy bool `json:"healthy"`
}
type CaddyData struct {
Version string `json:"version"`
Sites []CaddySite `json:"sites"`
Upstreams []CaddyUpstream `json:"upstreams,omitempty"`
}
const caddyFile = "/usr/local/etc/caddy/Caddyfile"
const caddyAdminAPI = "http://127.0.0.1:2019"
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 fuer Sites
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
}
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 {
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
}
}
}
// Admin-API: Upstream-Statistiken
data.Upstreams = collectCaddyUpstreams()
return data
}
func collectCaddyUpstreams() []CaddyUpstream {
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(caddyAdminAPI + "/api/reverse_proxy/upstreams")
if err != nil {
return nil
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return nil
}
// Admin-API liefert: [{"address":"host:port","num_requests":N,"fails":N}]
var raw []struct {
Address string `json:"address"`
NumRequests int `json:"num_requests"`
Fails int `json:"fails"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil
}
upstreams := make([]CaddyUpstream, 0, len(raw))
for _, u := range raw {
upstreams = append(upstreams, CaddyUpstream{
Address: u.Address,
NumRequests: u.NumRequests,
Fails: u.Fails,
Healthy: u.Fails == 0,
})
}
return upstreams
}
+203
View File
@@ -0,0 +1,203 @@
package collector
import (
"crypto/x509"
"encoding/pem"
"os/exec"
"path/filepath"
"strings"
"time"
)
type Certificate struct {
Name string `json:"name"`
Subject string `json:"subject"`
Issuer string `json:"issuer"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
DaysLeft int `json:"days_left"`
Serial string `json:"serial"`
SAN string `json:"san,omitempty"`
KeyUsage string `json:"key_usage,omitempty"`
IsCA bool `json:"is_ca"`
Status string `json:"status"` // "valid", "expiring", "expired"
Source string `json:"source"` // "opnsense", "file"
}
// CollectCertificates liest Zertifikate aus OPNsense config.xml und gaengigen Pfaden
func CollectCertificates() []Certificate {
var certs []Certificate
// 1. OPNsense config.xml Zertifikate
certs = append(certs, collectOPNsenseCerts()...)
// 2. Zertifikate aus /var/etc/cert/ (aktiv genutzte)
certs = append(certs, collectCertDir("/var/etc/cert/")...)
return certs
}
func collectOPNsenseCerts() []Certificate {
// configctl: Zertifikate als Liste ausgeben
// Alternativ: direkt config.xml parsen
out, err := exec.Command("grep", "-A2", "<crt>", "/conf/config.xml").Output()
if err != nil || len(out) == 0 {
return collectFromConfigXML()
}
return collectFromConfigXML()
}
func collectFromConfigXML() []Certificate {
// PHP-basiertes Auslesen der Zertifikate (OPNsense hat php verfuegbar)
// Skript via stdin uebergeben, da -r bei langen Strings Probleme macht
script := `<?php
require_once("config.inc");
$config = parse_config();
if (isset($config['cert'])) {
foreach ($config['cert'] as $cert) {
$x509 = openssl_x509_parse(base64_decode($cert['crt']));
if ($x509 === false) continue;
$name = isset($cert['descr']) ? $cert['descr'] : 'unnamed';
$subject = isset($x509['subject']['CN']) ? $x509['subject']['CN'] : '';
$issuer = isset($x509['issuer']['CN']) ? $x509['issuer']['CN'] : '';
$notBefore = date('Y-m-d H:i:s', $x509['validFrom_time_t']);
$notAfter = date('Y-m-d H:i:s', $x509['validTo_time_t']);
$serial = $x509['serialNumberHex'];
$san = '';
if (isset($x509['extensions']['subjectAltName'])) {
$san = $x509['extensions']['subjectAltName'];
}
$isCA = isset($x509['extensions']['basicConstraints']) &&
strpos($x509['extensions']['basicConstraints'], 'CA:TRUE') !== false;
echo "$name|$subject|$issuer|$notBefore|$notAfter|$serial|$san|" . ($isCA ? "1" : "0") . "\n";
}
}
if (isset($config['ca'])) {
foreach ($config['ca'] as $ca) {
$x509 = openssl_x509_parse(base64_decode($ca['crt']));
if ($x509 === false) continue;
$name = isset($ca['descr']) ? $ca['descr'] : 'unnamed-ca';
$subject = isset($x509['subject']['CN']) ? $x509['subject']['CN'] : '';
$issuer = isset($x509['issuer']['CN']) ? $x509['issuer']['CN'] : '';
$notBefore = date('Y-m-d H:i:s', $x509['validFrom_time_t']);
$notAfter = date('Y-m-d H:i:s', $x509['validTo_time_t']);
$serial = $x509['serialNumberHex'];
echo "$name|$subject|$issuer|$notBefore|$notAfter|$serial||1\n";
}
}
`
cmd := exec.Command("/usr/local/bin/php")
cmd.Stdin = strings.NewReader(script)
out, err := cmd.Output()
if err != nil {
// Fallback: PEM-Dateien scannen
return collectCertDir("/var/etc/cert/")
}
var certs []Certificate
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
fields := strings.SplitN(line, "|", 8)
if len(fields) < 8 {
continue
}
cert := Certificate{
Name: fields[0],
Subject: fields[1],
Issuer: fields[2],
NotBefore: fields[3],
NotAfter: fields[4],
Serial: fields[5],
SAN: fields[6],
IsCA: fields[7] == "1",
Source: "opnsense",
}
// Status berechnen
if notAfter, err := time.Parse("2006-01-02 15:04:05", cert.NotAfter); err == nil {
daysLeft := int(time.Until(notAfter).Hours() / 24)
cert.DaysLeft = daysLeft
if daysLeft < 0 {
cert.Status = "expired"
} else if daysLeft < 30 {
cert.Status = "expiring"
} else {
cert.Status = "valid"
}
}
certs = append(certs, cert)
}
return certs
}
func collectCertDir(dir string) []Certificate {
matches, err := filepath.Glob(filepath.Join(dir, "*.crt"))
if err != nil {
return nil
}
// Auch .pem Dateien
pemMatches, _ := filepath.Glob(filepath.Join(dir, "*.pem"))
matches = append(matches, pemMatches...)
var certs []Certificate
for _, path := range matches {
out, err := exec.Command("cat", path).Output()
if err != nil {
continue
}
block, _ := pem.Decode(out)
if block == nil || block.Type != "CERTIFICATE" {
continue
}
x509Cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
continue
}
name := filepath.Base(path)
name = strings.TrimSuffix(name, filepath.Ext(name))
daysLeft := int(time.Until(x509Cert.NotAfter).Hours() / 24)
status := "valid"
if daysLeft < 0 {
status = "expired"
} else if daysLeft < 30 {
status = "expiring"
}
var sans []string
for _, dns := range x509Cert.DNSNames {
sans = append(sans, "DNS:"+dns)
}
for _, ip := range x509Cert.IPAddresses {
sans = append(sans, "IP:"+ip.String())
}
cert := Certificate{
Name: name,
Subject: x509Cert.Subject.CommonName,
Issuer: x509Cert.Issuer.CommonName,
NotBefore: x509Cert.NotBefore.Format("2006-01-02 15:04:05"),
NotAfter: x509Cert.NotAfter.Format("2006-01-02 15:04:05"),
DaysLeft: daysLeft,
Serial: x509Cert.SerialNumber.Text(16),
SAN: strings.Join(sans, ", "),
IsCA: x509Cert.IsCA,
Status: status,
Source: "file",
}
certs = append(certs, cert)
}
return certs
}
+109
View File
@@ -0,0 +1,109 @@
package collector
import (
"os/exec"
"strconv"
"strings"
"time"
)
type CPUInfo struct {
Model string `json:"model"`
Cores int `json:"cores"`
Threads int `json:"threads"`
FreqMHz int `json:"freq_mhz"`
UsagePercent float64 `json:"usage_percent"`
}
func CollectCPU() CPUInfo {
info := CPUInfo{}
// CPU Model
info.Model = sysctlGet("hw.model")
// Anzahl CPUs (Threads)
if v, err := strconv.Atoi(sysctlGet("hw.ncpu")); err == nil {
info.Threads = v
}
// Kerne - versuche kern.smp.cores, Fallback auf ncpu
if v, err := strconv.Atoi(sysctlGet("kern.smp.cores")); err == nil && v > 0 {
info.Cores = v
} else {
info.Cores = info.Threads
}
// Frequenz in MHz
if v, err := strconv.Atoi(sysctlGet("dev.cpu.0.freq")); err == nil {
info.FreqMHz = v
}
// CPU-Auslastung via kern.cp_time (zwei Messungen im Abstand von 1 Sekunde)
info.UsagePercent = measureCPUUsage()
return info
}
func sysctlGet(key string) string {
out, err := exec.Command("sysctl", "-n", key).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// measureCPUUsage misst die CPU-Auslastung ueber kern.cp_time
// Format: user nice sys intr idle
func measureCPUUsage() float64 {
parse := func() (idle, total int64, ok bool) {
out := sysctlGet("kern.cp_time")
if out == "" {
return 0, 0, false
}
fields := strings.Fields(out)
if len(fields) < 5 {
return 0, 0, false
}
var vals [5]int64
for i := 0; i < 5; i++ {
v, err := strconv.ParseInt(fields[i], 10, 64)
if err != nil {
return 0, 0, false
}
vals[i] = v
}
// user + nice + sys + intr + idle
total = vals[0] + vals[1] + vals[2] + vals[3] + vals[4]
idle = vals[4]
return idle, total, true
}
idle1, total1, ok1 := parse()
if !ok1 {
return 0
}
time.Sleep(1 * time.Second)
idle2, total2, ok2 := parse()
if !ok2 {
return 0
}
totalDelta := float64(total2 - total1)
if totalDelta == 0 {
return 0
}
idleDelta := float64(idle2 - idle1)
usage := (1.0 - idleDelta/totalDelta) * 100.0
if usage < 0 {
usage = 0
}
if usage > 100 {
usage = 100
}
return usage
}
+145
View File
@@ -0,0 +1,145 @@
package collector
import (
"encoding/xml"
"log"
"os"
"os/exec"
"strings"
)
type CronJob struct {
Source string `json:"source"` // "system" oder "opnsense"
Enabled bool `json:"enabled"`
Minutes string `json:"minutes"`
Hours string `json:"hours"`
Days string `json:"days"`
Months string `json:"months"`
Weekdays string `json:"weekdays"`
Command string `json:"command"`
Parameters string `json:"parameters,omitempty"`
Who string `json:"who,omitempty"`
Description string `json:"description,omitempty"`
UUID string `json:"uuid,omitempty"`
Origin string `json:"origin,omitempty"`
Schedule string `json:"schedule"` // Menschenlesbar: "*/4 * * * *"
}
func CollectCron() []CronJob {
var jobs []CronJob
// 1. System-Crontab (crontab -l)
sysJobs := collectSystemCrontab()
jobs = append(jobs, sysJobs...)
// 2. OPNsense config.xml Cron-Jobs
opnJobs := collectOPNsenseCron()
jobs = append(jobs, opnJobs...)
log.Printf("Cron: %d Jobs gesammelt (%d System, %d OPNsense)",
len(jobs), len(sysJobs), len(opnJobs))
return jobs
}
func collectSystemCrontab() []CronJob {
out, err := exec.Command("/bin/sh", "-c", "/usr/bin/crontab -l").Output()
output := string(out)
if err != nil {
return nil
}
var jobs []CronJob
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
// Kommentare, leere Zeilen, Variablen ueberspringen
if line == "" || strings.HasPrefix(line, "#") || strings.Contains(line, "=") {
continue
}
// Crontab-Format: min hour day month weekday command
fields := strings.Fields(line)
if len(fields) < 6 {
continue
}
command := strings.Join(fields[5:], " ")
// Klammern und Redirects bereinigen
command = strings.TrimPrefix(command, "(")
command = strings.TrimSuffix(command, ") > /dev/null")
jobs = append(jobs, CronJob{
Source: "system",
Enabled: true,
Minutes: fields[0],
Hours: fields[1],
Days: fields[2],
Months: fields[3],
Weekdays: fields[4],
Command: command,
Schedule: strings.Join(fields[:5], " "),
})
}
return jobs
}
func collectOPNsenseCron() []CronJob {
data, err := os.ReadFile("/conf/config.xml")
if err != nil {
return nil
}
type cronJob struct {
UUID string `xml:"uuid,attr"`
Origin string `xml:"origin"`
Enabled string `xml:"enabled"`
Minutes string `xml:"minutes"`
Hours string `xml:"hours"`
Days string `xml:"days"`
Months string `xml:"months"`
Weekdays string `xml:"weekdays"`
Who string `xml:"who"`
Command string `xml:"command"`
Parameters string `xml:"parameters"`
Description string `xml:"description"`
}
type opnsenseCron struct {
Jobs []cronJob `xml:"OPNsense>cron>jobs>job"`
}
var cfg opnsenseCron
if err := xml.Unmarshal(data, &cfg); err != nil {
log.Printf("Cron XML parsen fehlgeschlagen: %v", err)
return nil
}
var jobs []CronJob
for _, j := range cfg.Jobs {
schedule := strings.Join([]string{j.Minutes, j.Hours, j.Days, j.Months, j.Weekdays}, " ")
cmd := j.Command
if j.Parameters != "" {
cmd += " " + j.Parameters
}
jobs = append(jobs, CronJob{
Source: "opnsense",
Enabled: j.Enabled == "1",
Minutes: j.Minutes,
Hours: j.Hours,
Days: j.Days,
Months: j.Months,
Weekdays: j.Weekdays,
Command: cmd,
Who: j.Who,
Description: strings.TrimSpace(j.Description),
UUID: j.UUID,
Origin: j.Origin,
Schedule: schedule,
})
}
return jobs
}
+207
View File
@@ -0,0 +1,207 @@
package collector
import (
"encoding/json"
"os/exec"
"strconv"
"strings"
"time"
)
type DHCPLease struct {
IP string `json:"ip"`
MAC string `json:"mac"`
Hostname string `json:"hostname"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Status string `json:"status"` // "active", "free", "backup"
Pool string `json:"pool,omitempty"`
}
type DHCPInfo struct {
Server string `json:"server"` // "kea", "isc-dhcpd", "dnsmasq", "none"
Leases []DHCPLease `json:"leases"`
}
func CollectDHCP() DHCPInfo {
// 1. Versuch: KEA via configctl (OPNsense 24.7+)
if info := tryKEA(); info != nil {
return *info
}
// 2. Versuch: ISC dhcpd Lease-Datei
if info := tryISCDhcpd(); info != nil {
return *info
}
// 3. Versuch: dnsmasq Leases
if info := tryDnsmasqLeases(); info != nil {
return *info
}
return DHCPInfo{Server: "none"}
}
// tryKEA versucht KEA-Leases ueber configctl abzufragen
func tryKEA() *DHCPInfo {
out, err := exec.Command("/usr/local/sbin/configctl", "kea", "list", "leases4").Output()
if err != nil {
return nil
}
// JSON-Antwort: {"records": [{"address": "...", "hwaddr": "...", "hostname": "...", "state": "0", "expire": "1772261642", ...}]}
var result struct {
Records []struct {
Address string `json:"address"`
HWAddr string `json:"hwaddr"`
HWAddress string `json:"hw_address"` // Fallback fuer aeltere KEA-Versionen
Hostname string `json:"hostname"`
State string `json:"state"`
SubnetID string `json:"subnet_id"`
Expire string `json:"expire"`
ClientID string `json:"client_id"`
} `json:"records"`
}
if err := json.Unmarshal(out, &result); err != nil {
return nil
}
info := &DHCPInfo{Server: "kea"}
for _, r := range result.Records {
status := "active"
if r.State == "1" {
status = "declined"
} else if r.State == "2" {
status = "expired"
}
mac := r.HWAddr
if mac == "" {
mac = r.HWAddress
}
// Expire-Timestamp in lesbares Datum umwandeln
expireStr := r.Expire
if ts, err := strconv.ParseInt(r.Expire, 10, 64); err == nil && ts > 0 {
expireStr = time.Unix(ts, 0).Format("2006-01-02 15:04:05")
}
info.Leases = append(info.Leases, DHCPLease{
IP: r.Address,
MAC: mac,
Hostname: r.Hostname,
End: expireStr,
Status: status,
})
}
return info
}
// tryISCDhcpd parst ISC dhcpd Lease-Dateien
func tryISCDhcpd() *DHCPInfo {
// Gaengige Pfade auf OPNsense/FreeBSD
paths := []string{
"/var/dhcpd/var/db/dhcpd.leases",
"/var/db/dhcpd.leases",
}
for _, path := range paths {
out, err := exec.Command("cat", path).Output()
if err != nil || len(out) == 0 {
continue
}
leases := parseISCLeases(string(out))
if len(leases) > 0 {
return &DHCPInfo{Server: "isc-dhcpd", Leases: leases}
}
}
return nil
}
// parseISCLeases parst das ISC dhcpd.leases Format
func parseISCLeases(content string) []DHCPLease {
var leases []DHCPLease
var current *DHCPLease
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "lease ") && strings.HasSuffix(line, "{") {
ip := strings.TrimSuffix(strings.TrimPrefix(line, "lease "), " {")
current = &DHCPLease{IP: strings.TrimSpace(ip), Status: "active"}
continue
}
if current == nil {
continue
}
if line == "}" {
leases = append(leases, *current)
current = nil
continue
}
line = strings.TrimSuffix(line, ";")
if strings.HasPrefix(line, "starts ") {
parts := strings.SplitN(line, " ", 3)
if len(parts) >= 3 {
current.Start = parts[2]
}
} else if strings.HasPrefix(line, "ends ") {
parts := strings.SplitN(line, " ", 3)
if len(parts) >= 3 {
current.End = parts[2]
}
} else if strings.HasPrefix(line, "hardware ethernet ") {
current.MAC = strings.TrimPrefix(line, "hardware ethernet ")
} else if strings.HasPrefix(line, "client-hostname ") {
current.Hostname = strings.Trim(strings.TrimPrefix(line, "client-hostname "), "\"")
} else if strings.HasPrefix(line, "binding state ") {
current.Status = strings.TrimPrefix(line, "binding state ")
}
}
return leases
}
// tryDnsmasqLeases sucht dnsmasq-Lease-Dateien
func tryDnsmasqLeases() *DHCPInfo {
paths := []string{
"/var/db/dnsmasq.leases",
"/tmp/dnsmasq.leases",
"/var/run/dnsmasq.leases",
}
for _, path := range paths {
out, err := exec.Command("cat", path).Output()
if err != nil || len(out) == 0 {
continue
}
leases := parseDnsmasqLeases(string(out))
if len(leases) > 0 {
return &DHCPInfo{Server: "dnsmasq", Leases: leases}
}
}
return nil
}
// parseDnsmasqLeases parst dnsmasq lease Format: timestamp mac ip hostname clientid
func parseDnsmasqLeases(content string) []DHCPLease {
var leases []DHCPLease
for _, line := range strings.Split(strings.TrimSpace(content), "\n") {
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
leases = append(leases, DHCPLease{
MAC: fields[1],
IP: fields[2],
Hostname: fields[3],
Status: "active",
})
}
return leases
}
+62
View File
@@ -0,0 +1,62 @@
package collector
import (
"os/exec"
"strconv"
"strings"
)
type DiskInfo struct {
Filesystem string `json:"filesystem"`
TotalBytes int64 `json:"total_bytes"`
UsedBytes int64 `json:"used_bytes"`
FreeBytes int64 `json:"free_bytes"`
MountPoint string `json:"mount_point"`
}
func CollectDisks() []DiskInfo {
// df -k (1K-Bloecke) fuer einfacheres Parsen
out, err := exec.Command("df", "-k", "-t", "ufs,zfs").Output()
if err != nil {
// Fallback ohne Typ-Filter
out, err = exec.Command("df", "-k").Output()
if err != nil {
return nil
}
}
var disks []DiskInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for i, line := range lines {
if i == 0 {
continue // Header ueberspringen
}
fields := strings.Fields(line)
if len(fields) < 6 {
continue
}
// Pseudo-Filesysteme ueberspringen
fs := fields[0]
mount := fields[len(fields)-1]
if strings.HasPrefix(fs, "devfs") || strings.HasPrefix(fs, "tmpfs") || fs == "fdescfs" || fs == "procfs" {
continue
}
total, _ := strconv.ParseInt(fields[1], 10, 64)
used, _ := strconv.ParseInt(fields[2], 10, 64)
avail, _ := strconv.ParseInt(fields[3], 10, 64)
disks = append(disks, DiskInfo{
Filesystem: fs,
TotalBytes: total * 1024,
UsedBytes: used * 1024,
FreeBytes: avail * 1024,
MountPoint: mount,
})
}
return disks
}
+68
View File
@@ -0,0 +1,68 @@
package collector
import (
"encoding/json"
"os/exec"
)
type Gateway struct {
Name string `json:"name"`
Address string `json:"address"`
Status string `json:"status"`
Loss string `json:"loss,omitempty"`
Delay string `json:"delay,omitempty"`
Stddev string `json:"stddev,omitempty"`
Monitor string `json:"monitor,omitempty"`
}
// CollectGateways liest OPNsense Gateways via configctl
func CollectGateways() []Gateway {
out, err := exec.Command("/usr/local/sbin/configctl", "interface", "gateways", "status").Output()
if err != nil {
return nil
}
var raw []struct {
Name string `json:"name"`
Address string `json:"address"`
Status string `json:"status"`
Loss string `json:"loss"`
Delay string `json:"delay"`
Stddev string `json:"stddev"`
Monitor string `json:"monitor"`
StatusTranslated string `json:"status_translated"`
}
if err := json.Unmarshal(out, &raw); err != nil {
return nil
}
var gateways []Gateway
for _, r := range raw {
gw := Gateway{
Name: r.Name,
Address: r.Address,
Status: r.StatusTranslated,
Loss: r.Loss,
Delay: r.Delay,
Stddev: r.Stddev,
Monitor: r.Monitor,
}
// Normalisiere "~" zu leer
if gw.Loss == "~" {
gw.Loss = ""
}
if gw.Delay == "~" {
gw.Delay = ""
}
if gw.Stddev == "~" {
gw.Stddev = ""
}
if gw.Monitor == "~" {
gw.Monitor = ""
}
gateways = append(gateways, gw)
}
return gateways
}
+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
}
+55
View File
@@ -0,0 +1,55 @@
package collector
import (
"os/exec"
"strings"
)
type HardwareInfo struct {
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
Serial string `json:"serial"`
BIOSVersion string `json:"bios_version"`
}
func CollectHardware() HardwareInfo {
info := HardwareInfo{}
// Hersteller via kenv
info.Manufacturer = kenvGet("smbios.system.maker")
info.Model = kenvGet("smbios.system.product")
info.Serial = kenvGet("smbios.system.serial")
info.BIOSVersion = kenvGet("smbios.bios.version")
// Fallback auf dmidecode wenn kenv leer
if info.Manufacturer == "" {
info.Manufacturer = dmidecodeGet("system-manufacturer")
}
if info.Model == "" {
info.Model = dmidecodeGet("system-product-name")
}
if info.Serial == "" {
info.Serial = dmidecodeGet("system-serial-number")
}
if info.BIOSVersion == "" {
info.BIOSVersion = dmidecodeGet("bios-version")
}
return info
}
func kenvGet(key string) string {
out, err := exec.Command("kenv", key).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func dmidecodeGet(keyword string) string {
out, err := exec.Command("dmidecode", "-s", keyword).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
+42
View File
@@ -0,0 +1,42 @@
package collector
import (
"strconv"
)
type MemoryInfo struct {
TotalBytes int64 `json:"total_bytes"`
UsedBytes int64 `json:"used_bytes"`
FreeBytes int64 `json:"free_bytes"`
}
func CollectMemory() MemoryInfo {
info := MemoryInfo{}
// Gesamter physischer Speicher
if v, err := strconv.ParseInt(sysctlGet("hw.physmem"), 10, 64); err == nil {
info.TotalBytes = v
}
// Freier Speicher berechnen via vm.stats.vm
pageSize := int64(4096)
if v, err := strconv.ParseInt(sysctlGet("hw.pagesize"), 10, 64); err == nil && v > 0 {
pageSize = v
}
// Freie + inactive + cache Pages
freePages, _ := strconv.ParseInt(sysctlGet("vm.stats.vm.v_free_count"), 10, 64)
inactivePages, _ := strconv.ParseInt(sysctlGet("vm.stats.vm.v_inactive_count"), 10, 64)
info.FreeBytes = (freePages + inactivePages) * pageSize
info.UsedBytes = info.TotalBytes - info.FreeBytes
if info.UsedBytes < 0 {
info.UsedBytes = 0
}
if info.FreeBytes < 0 {
info.FreeBytes = 0
}
return info
}
+178
View File
@@ -0,0 +1,178 @@
package collector
import (
"os/exec"
"strconv"
"strings"
)
type NetworkInterface struct {
Name string `json:"name"`
Role string `json:"role"`
IP string `json:"ip"`
MAC string `json:"mac"`
Status string `json:"status"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
}
func CollectNetwork() []NetworkInterface {
ifaces := parseIfconfig()
addTrafficStats(ifaces)
return ifaces
}
// parseIfconfig parst ifconfig -a Output
func parseIfconfig() []NetworkInterface {
out, err := exec.Command("ifconfig", "-a").Output()
if err != nil {
return nil
}
var ifaces []NetworkInterface
var current *NetworkInterface
for _, line := range strings.Split(string(out), "\n") {
// Neues Interface: "igb0: flags=..."
if len(line) > 0 && line[0] != '\t' && line[0] != ' ' && strings.Contains(line, ": flags=") {
if current != nil {
ifaces = append(ifaces, *current)
}
name := strings.Split(line, ":")[0]
// Loopback und virtuelle Interfaces ueberspringen
if name == "lo0" || strings.HasPrefix(name, "pflog") || strings.HasPrefix(name, "pfsync") || strings.HasPrefix(name, "enc") {
current = nil
continue
}
status := "down"
if strings.Contains(line, "UP") {
status = "up"
}
current = &NetworkInterface{
Name: name,
Status: status,
Role: guessRole(name),
}
continue
}
if current == nil {
continue
}
trimmed := strings.TrimSpace(line)
// IPv4 Adresse
if strings.HasPrefix(trimmed, "inet ") && !strings.HasPrefix(trimmed, "inet6") {
fields := strings.Fields(trimmed)
if len(fields) >= 2 {
current.IP = fields[1]
}
}
// MAC Adresse
if strings.HasPrefix(trimmed, "ether ") {
fields := strings.Fields(trimmed)
if len(fields) >= 2 {
current.MAC = fields[1]
}
}
}
if current != nil {
ifaces = append(ifaces, *current)
}
return ifaces
}
// addTrafficStats fuegt RX/TX Bytes via netstat -ib hinzu
func addTrafficStats(ifaces []NetworkInterface) {
out, err := exec.Command("/usr/bin/netstat", "-ib").Output()
if err != nil {
return
}
// Nur <Link#> Zeilen haben die totalen Byte-Zaehler
// Format variiert, aber die letzten 4 Felder sind immer: Opkts Oerrs Obytes Coll
// und davor: Ipkts Ierrs Idrop Ibytes
lines := strings.Split(string(out), "\n")
for _, line := range lines[1:] {
if !strings.Contains(line, "<Link#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 11 {
continue
}
name := fields[0]
// Von hinten zaehlen: Coll=last, Obytes=last-1, Oerrs=last-2, Opkts=last-3
// Ibytes=last-4 von Opkts aus: Ibytes ist 4 vor Opkts
n := len(fields)
obytes, err1 := strconv.ParseInt(fields[n-2], 10, 64)
// Ibytes: 7 von hinten (Coll, Obytes, Oerrs, Opkts, Ibytes, Idrop, Ierrs)
ibytes, err2 := strconv.ParseInt(fields[n-5], 10, 64)
if err1 != nil || err2 != nil {
continue
}
for i := range ifaces {
if ifaces[i].Name == name {
ifaces[i].RxBytes = ibytes
ifaces[i].TxBytes = obytes
}
}
}
}
// guessRole versucht die Rolle anhand des Interface-Namens zu erraten
func guessRole(name string) string {
// OPNsense benutzt typischerweise config.xml fuer Zuordnungen
// Hier: Heuristik basierend auf gaengigen Konventionen
lower := strings.ToLower(name)
// Versuche OPNsense-Config zu lesen
role := getOPNsenseIfRole(name)
if role != "" {
return role
}
// Fallback-Heuristik
if strings.Contains(lower, "wan") {
return "WAN"
}
if strings.Contains(lower, "lan") {
return "LAN"
}
if strings.HasPrefix(lower, "igb0") || strings.HasPrefix(lower, "em0") || strings.HasPrefix(lower, "ix0") {
return "WAN"
}
if strings.HasPrefix(lower, "igb1") || strings.HasPrefix(lower, "em1") || strings.HasPrefix(lower, "ix1") {
return "LAN"
}
return "OPT"
}
// getOPNsenseIfRole liest die Interface-Rolle aus der OPNsense config.xml
func getOPNsenseIfRole(ifName string) string {
// config.xml Struktur: <interfaces><wan><if>vtnet1</if>...
// Wir suchen nach <if>IFNAME</if> und schauen welcher Block (wan/lan/opt) es enthaelt
out, err := exec.Command("grep", "-B5", "<if>"+ifName+"</if>", "/conf/config.xml").Output()
if err != nil {
return ""
}
content := string(out)
// Suche nach dem oeffnenden Tag des Parent-Elements
for _, role := range []string{"wan", "lan", "opt1", "opt2", "opt3", "opt4", "opt5", "opt6"} {
if strings.Contains(content, "<"+role+">") {
return strings.ToUpper(role)
}
}
return ""
}
+129
View File
@@ -0,0 +1,129 @@
package collector
import (
"encoding/json"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
func CollectOPNsenseVersion() string {
out, err := exec.Command("/usr/local/sbin/opnsense-version").Output()
if err != nil {
return ""
}
// Format: "OPNsense 24.1.1_1" - alles nach erstem Leerzeichen
s := strings.TrimSpace(string(out))
return s
}
func CollectFreeBSDVersion() string {
out, err := exec.Command("/bin/freebsd-version").Output()
if err != nil {
return sysctlGet("kern.osrelease")
}
return strings.TrimSpace(string(out))
}
func CollectHostname() string {
return sysctlGet("kern.hostname")
}
func CollectUptime() int64 {
// kern.boottime: { sec = 1706000000, usec = 0 } ... oder "sec = 1706000000"
out := sysctlGet("kern.boottime")
if out == "" {
return 0
}
// Parse "{ sec = TIMESTAMP, usec = ... }"
idx := strings.Index(out, "sec = ")
if idx == -1 {
return 0
}
numStr := out[idx+6:]
if commaIdx := strings.IndexAny(numStr, ", }"); commaIdx != -1 {
numStr = numStr[:commaIdx]
}
numStr = strings.TrimSpace(numStr)
bootTime, err := strconv.ParseInt(numStr, 10, 64)
if err != nil {
return 0
}
uptime := time.Now().Unix() - bootTime
if uptime < 0 {
return 0
}
return uptime
}
// LicenseInfo fuer OPNsense Business Edition
type LicenseInfo struct {
IsBusinessEdition bool `json:"is_business_edition"`
ProductName string `json:"product_name,omitempty"`
Subscription string `json:"subscription,omitempty"`
ValidTo string `json:"valid_to,omitempty"`
DaysRemaining int `json:"days_remaining,omitempty"`
}
func CollectLicense() *LicenseInfo {
// core version file lesen
coreData, err := os.ReadFile("/usr/local/opnsense/version/core")
if err != nil {
return nil
}
var core map[string]interface{}
if err := json.Unmarshal(coreData, &core); err != nil {
return nil
}
productID, _ := core["product_id"].(string)
coreName, _ := core["CORE_NAME"].(string)
isBusiness := productID == "opnsense-business" || coreName == "opnsense-business"
if !isBusiness {
return &LicenseInfo{IsBusinessEdition: false, ProductName: "OPNsense Community"}
}
info := &LicenseInfo{
IsBusinessEdition: true,
ProductName: "OPNsense Business Edition",
}
// Lizenz-Ablauf lesen
licData, err := os.ReadFile("/usr/local/opnsense/version/core.license")
if err == nil {
var lic map[string]string
if json.Unmarshal(licData, &lic) == nil {
if validTo, ok := lic["valid_to"]; ok {
info.ValidTo = validTo
if t, err := time.Parse("2006-01-02", validTo); err == nil {
days := int(time.Until(t).Hours() / 24)
info.DaysRemaining = days
}
}
}
}
// Subscription-Code aus config.xml lesen
out, err := exec.Command("/usr/local/bin/php", "-r", `
$c = simplexml_load_file("/conf/config.xml");
if (isset($c->system->firmware->subscription)) {
echo (string)$c->system->firmware->subscription;
}
`).Output()
if err == nil {
sub := strings.TrimSpace(string(out))
if sub != "" {
info.Subscription = sub
}
}
return info
}
+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
}
+64
View File
@@ -0,0 +1,64 @@
package collector
import (
"os/exec"
"strings"
)
type Plugin struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
}
// CollectPlugins liest installierte OPNsense Plugins via pkg info
func CollectPlugins() []Plugin {
out, err := exec.Command("/usr/sbin/pkg", "info").Output()
if err != nil {
return nil
}
var plugins []Plugin
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
// Nur os-* Pakete sind OPNsense Plugins
if !strings.HasPrefix(line, "os-") {
continue
}
// Format: "os-acme-client-4.13 ACME Client"
// Erste Spalte: Name-Version, Rest: Beschreibung
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
nameVer := fields[0]
desc := strings.Join(fields[1:], " ")
// Name und Version trennen: letztes "-" vor einer Ziffer ist der Trenner
name, version := splitNameVersion(nameVer)
plugins = append(plugins, Plugin{
Name: name,
Version: version,
Description: desc,
})
}
return plugins
}
// splitNameVersion trennt "os-acme-client-4.13" in ("os-acme-client", "4.13")
func splitNameVersion(s string) (string, string) {
// Von hinten suchen: letztes "-" gefolgt von einer Ziffer
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '-' && i+1 < len(s) && s[i+1] >= '0' && s[i+1] <= '9' {
return s[:i], s[i+1:]
}
}
return s, ""
}
+61
View File
@@ -0,0 +1,61 @@
package collector
import (
"os/exec"
"strings"
)
type Route struct {
Destination string `json:"destination"`
Gateway string `json:"gateway"`
Flags string `json:"flags"`
Interface string `json:"interface"`
}
// CollectRoutes liest die Routing-Tabelle via netstat -rn
func CollectRoutes() []Route {
out, err := exec.Command("/usr/bin/netstat", "-rn", "-f", "inet").Output()
if err != nil {
return nil
}
var routes []Route
inTable := false
for _, line := range strings.Split(string(out), "\n") {
trimmed := strings.TrimSpace(line)
// Header erkennen
if strings.HasPrefix(trimmed, "Destination") {
inTable = true
continue
}
if !inTable || trimmed == "" {
continue
}
fields := strings.Fields(trimmed)
if len(fields) < 4 {
continue
}
route := Route{
Destination: fields[0],
Gateway: fields[1],
Flags: fields[2],
}
// Interface ist typischerweise das letzte oder vorletzte Feld
// FreeBSD netstat -rn: Destination Gateway Flags Refs Use Netif Expire
// Mindestens 6 Felder fuer Netif
if len(fields) >= 6 {
route.Interface = fields[5]
} else if len(fields) >= 4 {
route.Interface = fields[len(fields)-1]
}
routes = append(routes, route)
}
return routes
}
+260
View File
@@ -0,0 +1,260 @@
package collector
import (
"bufio"
"fmt"
"log"
"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+)`)
// RFC 5424 Syslog: <NN>1 2024-01-15T10:30:00+01:00 ... (with version digit)
reTimestamp = regexp.MustCompile(`^<\d+>(?:1\s+)?(\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, err := parseAuditLog(path)
if err != nil {
log.Printf("Security: %s: %v", path, err)
continue
}
log.Printf("Security: %s: %d events", path, len(events))
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, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
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, nil
}
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
}
+117
View File
@@ -0,0 +1,117 @@
package collector
import (
"os/exec"
"path/filepath"
"strings"
)
type ServiceInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
}
func CollectServices() []ServiceInfo {
// Versuche zuerst pluginctl -s (OPNsense-spezifisch)
services := tryPluginctl()
if len(services) > 0 {
return services
}
// Fallback: service -e listet aktivierte Services
return tryServiceList()
}
// FreeBSD boot/init scripts that run once and are not real services
var ignoredServices = map[string]bool{
"bgfsck": true, "cleanvar": true, "cleartmp": true, "devmatch": true,
"dmesg": true, "gptboot": true, "hostid": true, "hostid_save": true,
"kldxref": true, "mixer": true, "motd": true, "netif": true,
"newsyslog": true, "os-release": true, "rctl": true, "resolv": true,
"savecore": true, "utx": true, "var_run": true, "virecover": true,
"adjkerntz": true, "automount": true, "automountd": true, "autounmountd": true,
"cron": true, "devd": true, "dhclient": true, "fsck": true,
"growfs": true, "iovctl": true, "ip6addrctl": true, "ipsec": true,
"kld": true, "ldconfig": true, "linux": true, "local": true,
"localpkg": true, "lockd": true, "mdconfig": true, "mdconfig2": true,
"mountcritlocal": true, "mountcritremote": true, "mountlate": true,
"msgs": true, "nsswitch": true, "ntpdate": true, "pf": true,
"pflog": true, "powerd": true, "random": true, "root": true,
"routing": true, "securelevel": true, "serial": true, "statd": true,
"swap": true, "swaplate": true, "syscons": true, "sysctl": true,
"tmp": true, "ugidfw": true, "zfs": true, "zvol": true,
}
func tryPluginctl() []ServiceInfo {
out, err := exec.Command("/usr/local/sbin/pluginctl", "-s").Output()
if err != nil {
return nil
}
var services []ServiceInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
name := strings.TrimSpace(line)
if name == "" || ignoredServices[name] {
continue
}
// pluginctl -s gibt nur Service-Namen, Status einzeln pruefen
status := "stopped"
statusOut, err := exec.Command("service", name, "status").CombinedOutput()
if err == nil && strings.Contains(string(statusOut), "is running") {
status = "running"
}
// Beschreibung aus rc.d Script extrahieren (# PROVIDE: name)
desc := ""
descOut, _ := exec.Command("grep", "-h", "PROVIDE:", "/etc/rc.d/"+name, "/usr/local/etc/rc.d/"+name).Output()
if len(descOut) > 0 {
parts := strings.SplitN(string(descOut), "PROVIDE:", 2)
if len(parts) == 2 {
desc = strings.TrimSpace(parts[1])
}
}
services = append(services, ServiceInfo{
Name: name,
Description: desc,
Status: status,
})
}
return services
}
func tryServiceList() []ServiceInfo {
out, err := exec.Command("service", "-e").Output()
if err != nil {
return nil
}
var services []ServiceInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
// service -e gibt volle Pfade: /etc/rc.d/sshd, /usr/local/etc/rc.d/openvpn
name := filepath.Base(strings.TrimSpace(line))
// Status pruefen
status := "running" // service -e listet nur laufende Services
statusOut, err := exec.Command("service", name, "status").Output()
if err != nil {
status = "stopped"
} else if strings.Contains(string(statusOut), "not running") {
status = "stopped"
}
services = append(services, ServiceInfo{
Name: name,
Status: status,
})
}
return services
}
+88
View File
@@ -0,0 +1,88 @@
package collector
import (
"os/exec"
"strings"
)
type PendingUpdate struct {
Package string `json:"package"`
CurrentVer string `json:"current_version"`
NewVer string `json:"new_version"`
}
type UpdateInfo struct {
UpdateAvailable bool `json:"update_available"`
PendingCount int `json:"pending_count"`
Updates []PendingUpdate `json:"updates"`
OPNsenseUpdate string `json:"opnsense_update,omitempty"` // z.B. "26.1.2 -> 26.1.2_5"
}
// CollectUpdates prueft ob Updates verfuegbar sind
func CollectUpdates() UpdateInfo {
info := UpdateInfo{}
// 1. OPNsense Core Update pruefen
// opnsense-update -c: exit 0 = kein Update, exit 1 = Update verfuegbar (mit Versions-Info auf stdout)
if out, err := exec.Command("/usr/local/sbin/opnsense-update", "-c").CombinedOutput(); err != nil {
// Exit 1 = Update verfuegbar
outStr := strings.TrimSpace(string(out))
if outStr != "" {
info.OPNsenseUpdate = outStr
info.UpdateAvailable = true
}
}
// 2. Paket-Updates pruefen via pkg upgrade -n (dry-run)
out, err := exec.Command("/usr/sbin/pkg", "upgrade", "-n").CombinedOutput()
if err != nil || len(out) == 0 {
// Bei pkg upgrade -n ist exit 1 = Updates verfuegbar
// Trotzdem stdout parsen
}
content := string(out)
// Zeilen mit ":" im UPGRADED-Block parsen
// Format: "\tpackage: old_version -> new_version"
inUpgradeBlock := false
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "packages to be UPGRADED") ||
strings.Contains(trimmed, "will be affected") {
inUpgradeBlock = true
continue
}
if inUpgradeBlock && strings.Contains(trimmed, "->") {
// Format: "package: old -> new"
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) == 2 {
pkg := strings.TrimSpace(parts[0])
versions := strings.TrimSpace(parts[1])
arrowParts := strings.SplitN(versions, "->", 2)
if len(arrowParts) == 2 {
update := PendingUpdate{
Package: pkg,
CurrentVer: strings.TrimSpace(arrowParts[0]),
NewVer: strings.TrimSpace(arrowParts[1]),
}
info.Updates = append(info.Updates, update)
info.UpdateAvailable = true
}
}
}
// Block endet bei Leerzeile oder "Number of packages"
if inUpgradeBlock && (trimmed == "" || strings.HasPrefix(trimmed, "Number of")) {
if strings.HasPrefix(trimmed, "Number of") {
continue
}
inUpgradeBlock = false
}
}
info.PendingCount = len(info.Updates)
return info
}
+133
View File
@@ -0,0 +1,133 @@
package collector
import (
"os/exec"
"strconv"
"strings"
"time"
)
type WireGuardTunnel struct {
Interface string `json:"interface"`
PublicKey string `json:"public_key"`
ListenPort int `json:"listen_port"`
Peers []WireGuardPeer `json:"peers"`
}
type WireGuardPeer struct {
PublicKey string `json:"public_key"`
Endpoint string `json:"endpoint"`
AllowedIPs []string `json:"allowed_ips"`
LatestHandshake int64 `json:"latest_handshake_epoch"`
HandshakeAge string `json:"handshake_age"`
TransferRx int64 `json:"transfer_rx_bytes"`
TransferTx int64 `json:"transfer_tx_bytes"`
Keepalive int `json:"persistent_keepalive"`
Status string `json:"status"` // "active", "inactive", "unknown"
}
func CollectWireGuard() []WireGuardTunnel {
// wg show all dump: maschinenlesbares Format
// Voller Pfad, da daemon(8) evtl. reduzierten PATH hat
wgPath := "/usr/bin/wg"
if _, err := exec.LookPath("wg"); err == nil {
wgPath = "wg"
}
out, err := exec.Command(wgPath, "show", "all", "dump").Output()
if err != nil {
return nil
}
tunnels := make(map[string]*WireGuardTunnel)
var order []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < 5 {
continue
}
iface := fields[0]
// Interface-Zeile: iface privkey pubkey listenport fwmark
if _, exists := tunnels[iface]; !exists {
port := 0
if len(fields) >= 4 {
port, _ = strconv.Atoi(fields[3])
}
pubkey := ""
if len(fields) >= 3 {
pubkey = fields[2]
}
tunnels[iface] = &WireGuardTunnel{
Interface: iface,
PublicKey: pubkey,
ListenPort: port,
}
order = append(order, iface)
continue
}
// Peer-Zeile: iface pubkey presharedkey endpoint allowedips latest-handshake transfer-rx transfer-tx keepalive
if len(fields) < 9 {
continue
}
peer := WireGuardPeer{
PublicKey: fields[1],
Endpoint: fields[3],
}
// Allowed IPs
if fields[4] != "(none)" {
peer.AllowedIPs = strings.Split(fields[4], ",")
}
// Latest handshake (epoch)
if ts, err := strconv.ParseInt(fields[5], 10, 64); err == nil && ts > 0 {
peer.LatestHandshake = ts
age := time.Since(time.Unix(ts, 0))
if age < 3*time.Minute {
peer.Status = "active"
} else if age < 10*time.Minute {
peer.Status = "inactive"
} else {
peer.Status = "inactive"
}
// Menschenlesbare Altersangabe
if age.Hours() >= 24 {
peer.HandshakeAge = strconv.Itoa(int(age.Hours()/24)) + "d " + strconv.Itoa(int(age.Hours())%24) + "h ago"
} else if age.Hours() >= 1 {
peer.HandshakeAge = strconv.Itoa(int(age.Hours())) + "h " + strconv.Itoa(int(age.Minutes())%60) + "m ago"
} else if age.Minutes() >= 1 {
peer.HandshakeAge = strconv.Itoa(int(age.Minutes())) + "m " + strconv.Itoa(int(age.Seconds())%60) + "s ago"
} else {
peer.HandshakeAge = strconv.Itoa(int(age.Seconds())) + "s ago"
}
} else {
peer.Status = "unknown"
peer.HandshakeAge = "never"
}
// Transfer RX/TX
peer.TransferRx, _ = strconv.ParseInt(fields[6], 10, 64)
peer.TransferTx, _ = strconv.ParseInt(fields[7], 10, 64)
// Keepalive
if fields[8] != "off" {
peer.Keepalive, _ = strconv.Atoi(fields[8])
}
tunnels[iface].Peers = append(tunnels[iface].Peers, peer)
}
// Geordnet zurueckgeben
var result []WireGuardTunnel
for _, name := range order {
result = append(result, *tunnels[name])
}
return result
}