refactor: agent/ → agent-bsd/, README.md für BSD-Agent erstellt, Referenzen aktualisiert
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user