Initial commit: RMM Agent + Backend
- Go Agent (FreeBSD/amd64) fuer OPNsense - Go Backend (Linux/amd64) mit REST API + SQLite - Collector: Hardware, CPU, Memory, Disks, Network, Services, WireGuard, DHCP (KEA/ISC/dnsmasq), Routes, Gateways, Certificates, Plugins, Updates - Persistente Agent-ID - TLS + API-Key Auth - WebSocket-Infrastruktur (WIP): bidirektionaler Command-Kanal, TCP-Tunnel fuer Remote-Zugriff auf Firewall-WebUI - Lastenheft und README
This commit is contained in:
@@ -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,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,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,61 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CollectOPNsenseVersion() string {
|
||||
out, err := exec.Command("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("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
|
||||
}
|
||||
@@ -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,97 @@
|
||||
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()
|
||||
}
|
||||
|
||||
func tryPluginctl() []ServiceInfo {
|
||||
out, err := exec.Command("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 == "" {
|
||||
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