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:
cynfo3000
2026-02-28 07:38:14 +01:00
commit 6120d0cffa
35 changed files with 3992 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
package collector
import (
"crypto/x509"
"encoding/pem"
"os/exec"
"path/filepath"
"strings"
"time"
)
type Certificate struct {
Name string `json:"name"`
Subject string `json:"subject"`
Issuer string `json:"issuer"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
DaysLeft int `json:"days_left"`
Serial string `json:"serial"`
SAN string `json:"san,omitempty"`
KeyUsage string `json:"key_usage,omitempty"`
IsCA bool `json:"is_ca"`
Status string `json:"status"` // "valid", "expiring", "expired"
Source string `json:"source"` // "opnsense", "file"
}
// CollectCertificates liest Zertifikate aus OPNsense config.xml und gaengigen Pfaden
func CollectCertificates() []Certificate {
var certs []Certificate
// 1. OPNsense config.xml Zertifikate
certs = append(certs, collectOPNsenseCerts()...)
// 2. Zertifikate aus /var/etc/cert/ (aktiv genutzte)
certs = append(certs, collectCertDir("/var/etc/cert/")...)
return certs
}
func collectOPNsenseCerts() []Certificate {
// configctl: Zertifikate als Liste ausgeben
// Alternativ: direkt config.xml parsen
out, err := exec.Command("grep", "-A2", "<crt>", "/conf/config.xml").Output()
if err != nil || len(out) == 0 {
return collectFromConfigXML()
}
return collectFromConfigXML()
}
func collectFromConfigXML() []Certificate {
// PHP-basiertes Auslesen der Zertifikate (OPNsense hat php verfuegbar)
// Skript via stdin uebergeben, da -r bei langen Strings Probleme macht
script := `<?php
require_once("config.inc");
$config = parse_config();
if (isset($config['cert'])) {
foreach ($config['cert'] as $cert) {
$x509 = openssl_x509_parse(base64_decode($cert['crt']));
if ($x509 === false) continue;
$name = isset($cert['descr']) ? $cert['descr'] : 'unnamed';
$subject = isset($x509['subject']['CN']) ? $x509['subject']['CN'] : '';
$issuer = isset($x509['issuer']['CN']) ? $x509['issuer']['CN'] : '';
$notBefore = date('Y-m-d H:i:s', $x509['validFrom_time_t']);
$notAfter = date('Y-m-d H:i:s', $x509['validTo_time_t']);
$serial = $x509['serialNumberHex'];
$san = '';
if (isset($x509['extensions']['subjectAltName'])) {
$san = $x509['extensions']['subjectAltName'];
}
$isCA = isset($x509['extensions']['basicConstraints']) &&
strpos($x509['extensions']['basicConstraints'], 'CA:TRUE') !== false;
echo "$name|$subject|$issuer|$notBefore|$notAfter|$serial|$san|" . ($isCA ? "1" : "0") . "\n";
}
}
if (isset($config['ca'])) {
foreach ($config['ca'] as $ca) {
$x509 = openssl_x509_parse(base64_decode($ca['crt']));
if ($x509 === false) continue;
$name = isset($ca['descr']) ? $ca['descr'] : 'unnamed-ca';
$subject = isset($x509['subject']['CN']) ? $x509['subject']['CN'] : '';
$issuer = isset($x509['issuer']['CN']) ? $x509['issuer']['CN'] : '';
$notBefore = date('Y-m-d H:i:s', $x509['validFrom_time_t']);
$notAfter = date('Y-m-d H:i:s', $x509['validTo_time_t']);
$serial = $x509['serialNumberHex'];
echo "$name|$subject|$issuer|$notBefore|$notAfter|$serial||1\n";
}
}
`
cmd := exec.Command("/usr/local/bin/php")
cmd.Stdin = strings.NewReader(script)
out, err := cmd.Output()
if err != nil {
// Fallback: PEM-Dateien scannen
return collectCertDir("/var/etc/cert/")
}
var certs []Certificate
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
fields := strings.SplitN(line, "|", 8)
if len(fields) < 8 {
continue
}
cert := Certificate{
Name: fields[0],
Subject: fields[1],
Issuer: fields[2],
NotBefore: fields[3],
NotAfter: fields[4],
Serial: fields[5],
SAN: fields[6],
IsCA: fields[7] == "1",
Source: "opnsense",
}
// Status berechnen
if notAfter, err := time.Parse("2006-01-02 15:04:05", cert.NotAfter); err == nil {
daysLeft := int(time.Until(notAfter).Hours() / 24)
cert.DaysLeft = daysLeft
if daysLeft < 0 {
cert.Status = "expired"
} else if daysLeft < 30 {
cert.Status = "expiring"
} else {
cert.Status = "valid"
}
}
certs = append(certs, cert)
}
return certs
}
func collectCertDir(dir string) []Certificate {
matches, err := filepath.Glob(filepath.Join(dir, "*.crt"))
if err != nil {
return nil
}
// Auch .pem Dateien
pemMatches, _ := filepath.Glob(filepath.Join(dir, "*.pem"))
matches = append(matches, pemMatches...)
var certs []Certificate
for _, path := range matches {
out, err := exec.Command("cat", path).Output()
if err != nil {
continue
}
block, _ := pem.Decode(out)
if block == nil || block.Type != "CERTIFICATE" {
continue
}
x509Cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
continue
}
name := filepath.Base(path)
name = strings.TrimSuffix(name, filepath.Ext(name))
daysLeft := int(time.Until(x509Cert.NotAfter).Hours() / 24)
status := "valid"
if daysLeft < 0 {
status = "expired"
} else if daysLeft < 30 {
status = "expiring"
}
var sans []string
for _, dns := range x509Cert.DNSNames {
sans = append(sans, "DNS:"+dns)
}
for _, ip := range x509Cert.IPAddresses {
sans = append(sans, "IP:"+ip.String())
}
cert := Certificate{
Name: name,
Subject: x509Cert.Subject.CommonName,
Issuer: x509Cert.Issuer.CommonName,
NotBefore: x509Cert.NotBefore.Format("2006-01-02 15:04:05"),
NotAfter: x509Cert.NotAfter.Format("2006-01-02 15:04:05"),
DaysLeft: daysLeft,
Serial: x509Cert.SerialNumber.Text(16),
SAN: strings.Join(sans, ", "),
IsCA: x509Cert.IsCA,
Status: status,
Source: "file",
}
certs = append(certs, cert)
}
return certs
}
+109
View File
@@ -0,0 +1,109 @@
package collector
import (
"os/exec"
"strconv"
"strings"
"time"
)
type CPUInfo struct {
Model string `json:"model"`
Cores int `json:"cores"`
Threads int `json:"threads"`
FreqMHz int `json:"freq_mhz"`
UsagePercent float64 `json:"usage_percent"`
}
func CollectCPU() CPUInfo {
info := CPUInfo{}
// CPU Model
info.Model = sysctlGet("hw.model")
// Anzahl CPUs (Threads)
if v, err := strconv.Atoi(sysctlGet("hw.ncpu")); err == nil {
info.Threads = v
}
// Kerne - versuche kern.smp.cores, Fallback auf ncpu
if v, err := strconv.Atoi(sysctlGet("kern.smp.cores")); err == nil && v > 0 {
info.Cores = v
} else {
info.Cores = info.Threads
}
// Frequenz in MHz
if v, err := strconv.Atoi(sysctlGet("dev.cpu.0.freq")); err == nil {
info.FreqMHz = v
}
// CPU-Auslastung via kern.cp_time (zwei Messungen im Abstand von 1 Sekunde)
info.UsagePercent = measureCPUUsage()
return info
}
func sysctlGet(key string) string {
out, err := exec.Command("sysctl", "-n", key).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
// measureCPUUsage misst die CPU-Auslastung ueber kern.cp_time
// Format: user nice sys intr idle
func measureCPUUsage() float64 {
parse := func() (idle, total int64, ok bool) {
out := sysctlGet("kern.cp_time")
if out == "" {
return 0, 0, false
}
fields := strings.Fields(out)
if len(fields) < 5 {
return 0, 0, false
}
var vals [5]int64
for i := 0; i < 5; i++ {
v, err := strconv.ParseInt(fields[i], 10, 64)
if err != nil {
return 0, 0, false
}
vals[i] = v
}
// user + nice + sys + intr + idle
total = vals[0] + vals[1] + vals[2] + vals[3] + vals[4]
idle = vals[4]
return idle, total, true
}
idle1, total1, ok1 := parse()
if !ok1 {
return 0
}
time.Sleep(1 * time.Second)
idle2, total2, ok2 := parse()
if !ok2 {
return 0
}
totalDelta := float64(total2 - total1)
if totalDelta == 0 {
return 0
}
idleDelta := float64(idle2 - idle1)
usage := (1.0 - idleDelta/totalDelta) * 100.0
if usage < 0 {
usage = 0
}
if usage > 100 {
usage = 100
}
return usage
}
+207
View File
@@ -0,0 +1,207 @@
package collector
import (
"encoding/json"
"os/exec"
"strconv"
"strings"
"time"
)
type DHCPLease struct {
IP string `json:"ip"`
MAC string `json:"mac"`
Hostname string `json:"hostname"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Status string `json:"status"` // "active", "free", "backup"
Pool string `json:"pool,omitempty"`
}
type DHCPInfo struct {
Server string `json:"server"` // "kea", "isc-dhcpd", "dnsmasq", "none"
Leases []DHCPLease `json:"leases"`
}
func CollectDHCP() DHCPInfo {
// 1. Versuch: KEA via configctl (OPNsense 24.7+)
if info := tryKEA(); info != nil {
return *info
}
// 2. Versuch: ISC dhcpd Lease-Datei
if info := tryISCDhcpd(); info != nil {
return *info
}
// 3. Versuch: dnsmasq Leases
if info := tryDnsmasqLeases(); info != nil {
return *info
}
return DHCPInfo{Server: "none"}
}
// tryKEA versucht KEA-Leases ueber configctl abzufragen
func tryKEA() *DHCPInfo {
out, err := exec.Command("/usr/local/sbin/configctl", "kea", "list", "leases4").Output()
if err != nil {
return nil
}
// JSON-Antwort: {"records": [{"address": "...", "hwaddr": "...", "hostname": "...", "state": "0", "expire": "1772261642", ...}]}
var result struct {
Records []struct {
Address string `json:"address"`
HWAddr string `json:"hwaddr"`
HWAddress string `json:"hw_address"` // Fallback fuer aeltere KEA-Versionen
Hostname string `json:"hostname"`
State string `json:"state"`
SubnetID string `json:"subnet_id"`
Expire string `json:"expire"`
ClientID string `json:"client_id"`
} `json:"records"`
}
if err := json.Unmarshal(out, &result); err != nil {
return nil
}
info := &DHCPInfo{Server: "kea"}
for _, r := range result.Records {
status := "active"
if r.State == "1" {
status = "declined"
} else if r.State == "2" {
status = "expired"
}
mac := r.HWAddr
if mac == "" {
mac = r.HWAddress
}
// Expire-Timestamp in lesbares Datum umwandeln
expireStr := r.Expire
if ts, err := strconv.ParseInt(r.Expire, 10, 64); err == nil && ts > 0 {
expireStr = time.Unix(ts, 0).Format("2006-01-02 15:04:05")
}
info.Leases = append(info.Leases, DHCPLease{
IP: r.Address,
MAC: mac,
Hostname: r.Hostname,
End: expireStr,
Status: status,
})
}
return info
}
// tryISCDhcpd parst ISC dhcpd Lease-Dateien
func tryISCDhcpd() *DHCPInfo {
// Gaengige Pfade auf OPNsense/FreeBSD
paths := []string{
"/var/dhcpd/var/db/dhcpd.leases",
"/var/db/dhcpd.leases",
}
for _, path := range paths {
out, err := exec.Command("cat", path).Output()
if err != nil || len(out) == 0 {
continue
}
leases := parseISCLeases(string(out))
if len(leases) > 0 {
return &DHCPInfo{Server: "isc-dhcpd", Leases: leases}
}
}
return nil
}
// parseISCLeases parst das ISC dhcpd.leases Format
func parseISCLeases(content string) []DHCPLease {
var leases []DHCPLease
var current *DHCPLease
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "lease ") && strings.HasSuffix(line, "{") {
ip := strings.TrimSuffix(strings.TrimPrefix(line, "lease "), " {")
current = &DHCPLease{IP: strings.TrimSpace(ip), Status: "active"}
continue
}
if current == nil {
continue
}
if line == "}" {
leases = append(leases, *current)
current = nil
continue
}
line = strings.TrimSuffix(line, ";")
if strings.HasPrefix(line, "starts ") {
parts := strings.SplitN(line, " ", 3)
if len(parts) >= 3 {
current.Start = parts[2]
}
} else if strings.HasPrefix(line, "ends ") {
parts := strings.SplitN(line, " ", 3)
if len(parts) >= 3 {
current.End = parts[2]
}
} else if strings.HasPrefix(line, "hardware ethernet ") {
current.MAC = strings.TrimPrefix(line, "hardware ethernet ")
} else if strings.HasPrefix(line, "client-hostname ") {
current.Hostname = strings.Trim(strings.TrimPrefix(line, "client-hostname "), "\"")
} else if strings.HasPrefix(line, "binding state ") {
current.Status = strings.TrimPrefix(line, "binding state ")
}
}
return leases
}
// tryDnsmasqLeases sucht dnsmasq-Lease-Dateien
func tryDnsmasqLeases() *DHCPInfo {
paths := []string{
"/var/db/dnsmasq.leases",
"/tmp/dnsmasq.leases",
"/var/run/dnsmasq.leases",
}
for _, path := range paths {
out, err := exec.Command("cat", path).Output()
if err != nil || len(out) == 0 {
continue
}
leases := parseDnsmasqLeases(string(out))
if len(leases) > 0 {
return &DHCPInfo{Server: "dnsmasq", Leases: leases}
}
}
return nil
}
// parseDnsmasqLeases parst dnsmasq lease Format: timestamp mac ip hostname clientid
func parseDnsmasqLeases(content string) []DHCPLease {
var leases []DHCPLease
for _, line := range strings.Split(strings.TrimSpace(content), "\n") {
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
leases = append(leases, DHCPLease{
MAC: fields[1],
IP: fields[2],
Hostname: fields[3],
Status: "active",
})
}
return leases
}
+62
View File
@@ -0,0 +1,62 @@
package collector
import (
"os/exec"
"strconv"
"strings"
)
type DiskInfo struct {
Filesystem string `json:"filesystem"`
TotalBytes int64 `json:"total_bytes"`
UsedBytes int64 `json:"used_bytes"`
FreeBytes int64 `json:"free_bytes"`
MountPoint string `json:"mount_point"`
}
func CollectDisks() []DiskInfo {
// df -k (1K-Bloecke) fuer einfacheres Parsen
out, err := exec.Command("df", "-k", "-t", "ufs,zfs").Output()
if err != nil {
// Fallback ohne Typ-Filter
out, err = exec.Command("df", "-k").Output()
if err != nil {
return nil
}
}
var disks []DiskInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for i, line := range lines {
if i == 0 {
continue // Header ueberspringen
}
fields := strings.Fields(line)
if len(fields) < 6 {
continue
}
// Pseudo-Filesysteme ueberspringen
fs := fields[0]
mount := fields[len(fields)-1]
if strings.HasPrefix(fs, "devfs") || strings.HasPrefix(fs, "tmpfs") || fs == "fdescfs" || fs == "procfs" {
continue
}
total, _ := strconv.ParseInt(fields[1], 10, 64)
used, _ := strconv.ParseInt(fields[2], 10, 64)
avail, _ := strconv.ParseInt(fields[3], 10, 64)
disks = append(disks, DiskInfo{
Filesystem: fs,
TotalBytes: total * 1024,
UsedBytes: used * 1024,
FreeBytes: avail * 1024,
MountPoint: mount,
})
}
return disks
}
+68
View File
@@ -0,0 +1,68 @@
package collector
import (
"encoding/json"
"os/exec"
)
type Gateway struct {
Name string `json:"name"`
Address string `json:"address"`
Status string `json:"status"`
Loss string `json:"loss,omitempty"`
Delay string `json:"delay,omitempty"`
Stddev string `json:"stddev,omitempty"`
Monitor string `json:"monitor,omitempty"`
}
// CollectGateways liest OPNsense Gateways via configctl
func CollectGateways() []Gateway {
out, err := exec.Command("/usr/local/sbin/configctl", "interface", "gateways", "status").Output()
if err != nil {
return nil
}
var raw []struct {
Name string `json:"name"`
Address string `json:"address"`
Status string `json:"status"`
Loss string `json:"loss"`
Delay string `json:"delay"`
Stddev string `json:"stddev"`
Monitor string `json:"monitor"`
StatusTranslated string `json:"status_translated"`
}
if err := json.Unmarshal(out, &raw); err != nil {
return nil
}
var gateways []Gateway
for _, r := range raw {
gw := Gateway{
Name: r.Name,
Address: r.Address,
Status: r.StatusTranslated,
Loss: r.Loss,
Delay: r.Delay,
Stddev: r.Stddev,
Monitor: r.Monitor,
}
// Normalisiere "~" zu leer
if gw.Loss == "~" {
gw.Loss = ""
}
if gw.Delay == "~" {
gw.Delay = ""
}
if gw.Stddev == "~" {
gw.Stddev = ""
}
if gw.Monitor == "~" {
gw.Monitor = ""
}
gateways = append(gateways, gw)
}
return gateways
}
+55
View File
@@ -0,0 +1,55 @@
package collector
import (
"os/exec"
"strings"
)
type HardwareInfo struct {
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
Serial string `json:"serial"`
BIOSVersion string `json:"bios_version"`
}
func CollectHardware() HardwareInfo {
info := HardwareInfo{}
// Hersteller via kenv
info.Manufacturer = kenvGet("smbios.system.maker")
info.Model = kenvGet("smbios.system.product")
info.Serial = kenvGet("smbios.system.serial")
info.BIOSVersion = kenvGet("smbios.bios.version")
// Fallback auf dmidecode wenn kenv leer
if info.Manufacturer == "" {
info.Manufacturer = dmidecodeGet("system-manufacturer")
}
if info.Model == "" {
info.Model = dmidecodeGet("system-product-name")
}
if info.Serial == "" {
info.Serial = dmidecodeGet("system-serial-number")
}
if info.BIOSVersion == "" {
info.BIOSVersion = dmidecodeGet("bios-version")
}
return info
}
func kenvGet(key string) string {
out, err := exec.Command("kenv", key).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func dmidecodeGet(keyword string) string {
out, err := exec.Command("dmidecode", "-s", keyword).Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
+42
View File
@@ -0,0 +1,42 @@
package collector
import (
"strconv"
)
type MemoryInfo struct {
TotalBytes int64 `json:"total_bytes"`
UsedBytes int64 `json:"used_bytes"`
FreeBytes int64 `json:"free_bytes"`
}
func CollectMemory() MemoryInfo {
info := MemoryInfo{}
// Gesamter physischer Speicher
if v, err := strconv.ParseInt(sysctlGet("hw.physmem"), 10, 64); err == nil {
info.TotalBytes = v
}
// Freier Speicher berechnen via vm.stats.vm
pageSize := int64(4096)
if v, err := strconv.ParseInt(sysctlGet("hw.pagesize"), 10, 64); err == nil && v > 0 {
pageSize = v
}
// Freie + inactive + cache Pages
freePages, _ := strconv.ParseInt(sysctlGet("vm.stats.vm.v_free_count"), 10, 64)
inactivePages, _ := strconv.ParseInt(sysctlGet("vm.stats.vm.v_inactive_count"), 10, 64)
info.FreeBytes = (freePages + inactivePages) * pageSize
info.UsedBytes = info.TotalBytes - info.FreeBytes
if info.UsedBytes < 0 {
info.UsedBytes = 0
}
if info.FreeBytes < 0 {
info.FreeBytes = 0
}
return info
}
+178
View File
@@ -0,0 +1,178 @@
package collector
import (
"os/exec"
"strconv"
"strings"
)
type NetworkInterface struct {
Name string `json:"name"`
Role string `json:"role"`
IP string `json:"ip"`
MAC string `json:"mac"`
Status string `json:"status"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
}
func CollectNetwork() []NetworkInterface {
ifaces := parseIfconfig()
addTrafficStats(ifaces)
return ifaces
}
// parseIfconfig parst ifconfig -a Output
func parseIfconfig() []NetworkInterface {
out, err := exec.Command("ifconfig", "-a").Output()
if err != nil {
return nil
}
var ifaces []NetworkInterface
var current *NetworkInterface
for _, line := range strings.Split(string(out), "\n") {
// Neues Interface: "igb0: flags=..."
if len(line) > 0 && line[0] != '\t' && line[0] != ' ' && strings.Contains(line, ": flags=") {
if current != nil {
ifaces = append(ifaces, *current)
}
name := strings.Split(line, ":")[0]
// Loopback und virtuelle Interfaces ueberspringen
if name == "lo0" || strings.HasPrefix(name, "pflog") || strings.HasPrefix(name, "pfsync") || strings.HasPrefix(name, "enc") {
current = nil
continue
}
status := "down"
if strings.Contains(line, "UP") {
status = "up"
}
current = &NetworkInterface{
Name: name,
Status: status,
Role: guessRole(name),
}
continue
}
if current == nil {
continue
}
trimmed := strings.TrimSpace(line)
// IPv4 Adresse
if strings.HasPrefix(trimmed, "inet ") && !strings.HasPrefix(trimmed, "inet6") {
fields := strings.Fields(trimmed)
if len(fields) >= 2 {
current.IP = fields[1]
}
}
// MAC Adresse
if strings.HasPrefix(trimmed, "ether ") {
fields := strings.Fields(trimmed)
if len(fields) >= 2 {
current.MAC = fields[1]
}
}
}
if current != nil {
ifaces = append(ifaces, *current)
}
return ifaces
}
// addTrafficStats fuegt RX/TX Bytes via netstat -ib hinzu
func addTrafficStats(ifaces []NetworkInterface) {
out, err := exec.Command("/usr/bin/netstat", "-ib").Output()
if err != nil {
return
}
// Nur <Link#> Zeilen haben die totalen Byte-Zaehler
// Format variiert, aber die letzten 4 Felder sind immer: Opkts Oerrs Obytes Coll
// und davor: Ipkts Ierrs Idrop Ibytes
lines := strings.Split(string(out), "\n")
for _, line := range lines[1:] {
if !strings.Contains(line, "<Link#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 11 {
continue
}
name := fields[0]
// Von hinten zaehlen: Coll=last, Obytes=last-1, Oerrs=last-2, Opkts=last-3
// Ibytes=last-4 von Opkts aus: Ibytes ist 4 vor Opkts
n := len(fields)
obytes, err1 := strconv.ParseInt(fields[n-2], 10, 64)
// Ibytes: 7 von hinten (Coll, Obytes, Oerrs, Opkts, Ibytes, Idrop, Ierrs)
ibytes, err2 := strconv.ParseInt(fields[n-5], 10, 64)
if err1 != nil || err2 != nil {
continue
}
for i := range ifaces {
if ifaces[i].Name == name {
ifaces[i].RxBytes = ibytes
ifaces[i].TxBytes = obytes
}
}
}
}
// guessRole versucht die Rolle anhand des Interface-Namens zu erraten
func guessRole(name string) string {
// OPNsense benutzt typischerweise config.xml fuer Zuordnungen
// Hier: Heuristik basierend auf gaengigen Konventionen
lower := strings.ToLower(name)
// Versuche OPNsense-Config zu lesen
role := getOPNsenseIfRole(name)
if role != "" {
return role
}
// Fallback-Heuristik
if strings.Contains(lower, "wan") {
return "WAN"
}
if strings.Contains(lower, "lan") {
return "LAN"
}
if strings.HasPrefix(lower, "igb0") || strings.HasPrefix(lower, "em0") || strings.HasPrefix(lower, "ix0") {
return "WAN"
}
if strings.HasPrefix(lower, "igb1") || strings.HasPrefix(lower, "em1") || strings.HasPrefix(lower, "ix1") {
return "LAN"
}
return "OPT"
}
// getOPNsenseIfRole liest die Interface-Rolle aus der OPNsense config.xml
func getOPNsenseIfRole(ifName string) string {
// config.xml Struktur: <interfaces><wan><if>vtnet1</if>...
// Wir suchen nach <if>IFNAME</if> und schauen welcher Block (wan/lan/opt) es enthaelt
out, err := exec.Command("grep", "-B5", "<if>"+ifName+"</if>", "/conf/config.xml").Output()
if err != nil {
return ""
}
content := string(out)
// Suche nach dem oeffnenden Tag des Parent-Elements
for _, role := range []string{"wan", "lan", "opt1", "opt2", "opt3", "opt4", "opt5", "opt6"} {
if strings.Contains(content, "<"+role+">") {
return strings.ToUpper(role)
}
}
return ""
}
+61
View File
@@ -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
}
+64
View File
@@ -0,0 +1,64 @@
package collector
import (
"os/exec"
"strings"
)
type Plugin struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
}
// CollectPlugins liest installierte OPNsense Plugins via pkg info
func CollectPlugins() []Plugin {
out, err := exec.Command("/usr/sbin/pkg", "info").Output()
if err != nil {
return nil
}
var plugins []Plugin
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
// Nur os-* Pakete sind OPNsense Plugins
if !strings.HasPrefix(line, "os-") {
continue
}
// Format: "os-acme-client-4.13 ACME Client"
// Erste Spalte: Name-Version, Rest: Beschreibung
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
nameVer := fields[0]
desc := strings.Join(fields[1:], " ")
// Name und Version trennen: letztes "-" vor einer Ziffer ist der Trenner
name, version := splitNameVersion(nameVer)
plugins = append(plugins, Plugin{
Name: name,
Version: version,
Description: desc,
})
}
return plugins
}
// splitNameVersion trennt "os-acme-client-4.13" in ("os-acme-client", "4.13")
func splitNameVersion(s string) (string, string) {
// Von hinten suchen: letztes "-" gefolgt von einer Ziffer
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '-' && i+1 < len(s) && s[i+1] >= '0' && s[i+1] <= '9' {
return s[:i], s[i+1:]
}
}
return s, ""
}
+61
View File
@@ -0,0 +1,61 @@
package collector
import (
"os/exec"
"strings"
)
type Route struct {
Destination string `json:"destination"`
Gateway string `json:"gateway"`
Flags string `json:"flags"`
Interface string `json:"interface"`
}
// CollectRoutes liest die Routing-Tabelle via netstat -rn
func CollectRoutes() []Route {
out, err := exec.Command("/usr/bin/netstat", "-rn", "-f", "inet").Output()
if err != nil {
return nil
}
var routes []Route
inTable := false
for _, line := range strings.Split(string(out), "\n") {
trimmed := strings.TrimSpace(line)
// Header erkennen
if strings.HasPrefix(trimmed, "Destination") {
inTable = true
continue
}
if !inTable || trimmed == "" {
continue
}
fields := strings.Fields(trimmed)
if len(fields) < 4 {
continue
}
route := Route{
Destination: fields[0],
Gateway: fields[1],
Flags: fields[2],
}
// Interface ist typischerweise das letzte oder vorletzte Feld
// FreeBSD netstat -rn: Destination Gateway Flags Refs Use Netif Expire
// Mindestens 6 Felder fuer Netif
if len(fields) >= 6 {
route.Interface = fields[5]
} else if len(fields) >= 4 {
route.Interface = fields[len(fields)-1]
}
routes = append(routes, route)
}
return routes
}
+97
View File
@@ -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
}
+88
View File
@@ -0,0 +1,88 @@
package collector
import (
"os/exec"
"strings"
)
type PendingUpdate struct {
Package string `json:"package"`
CurrentVer string `json:"current_version"`
NewVer string `json:"new_version"`
}
type UpdateInfo struct {
UpdateAvailable bool `json:"update_available"`
PendingCount int `json:"pending_count"`
Updates []PendingUpdate `json:"updates"`
OPNsenseUpdate string `json:"opnsense_update,omitempty"` // z.B. "26.1.2 -> 26.1.2_5"
}
// CollectUpdates prueft ob Updates verfuegbar sind
func CollectUpdates() UpdateInfo {
info := UpdateInfo{}
// 1. OPNsense Core Update pruefen
// opnsense-update -c: exit 0 = kein Update, exit 1 = Update verfuegbar (mit Versions-Info auf stdout)
if out, err := exec.Command("/usr/local/sbin/opnsense-update", "-c").CombinedOutput(); err != nil {
// Exit 1 = Update verfuegbar
outStr := strings.TrimSpace(string(out))
if outStr != "" {
info.OPNsenseUpdate = outStr
info.UpdateAvailable = true
}
}
// 2. Paket-Updates pruefen via pkg upgrade -n (dry-run)
out, err := exec.Command("/usr/sbin/pkg", "upgrade", "-n").CombinedOutput()
if err != nil || len(out) == 0 {
// Bei pkg upgrade -n ist exit 1 = Updates verfuegbar
// Trotzdem stdout parsen
}
content := string(out)
// Zeilen mit ":" im UPGRADED-Block parsen
// Format: "\tpackage: old_version -> new_version"
inUpgradeBlock := false
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "packages to be UPGRADED") ||
strings.Contains(trimmed, "will be affected") {
inUpgradeBlock = true
continue
}
if inUpgradeBlock && strings.Contains(trimmed, "->") {
// Format: "package: old -> new"
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) == 2 {
pkg := strings.TrimSpace(parts[0])
versions := strings.TrimSpace(parts[1])
arrowParts := strings.SplitN(versions, "->", 2)
if len(arrowParts) == 2 {
update := PendingUpdate{
Package: pkg,
CurrentVer: strings.TrimSpace(arrowParts[0]),
NewVer: strings.TrimSpace(arrowParts[1]),
}
info.Updates = append(info.Updates, update)
info.UpdateAvailable = true
}
}
}
// Block endet bei Leerzeile oder "Number of packages"
if inUpgradeBlock && (trimmed == "" || strings.HasPrefix(trimmed, "Number of")) {
if strings.HasPrefix(trimmed, "Number of") {
continue
}
inUpgradeBlock = false
}
}
info.PendingCount = len(info.Updates)
return info
}
+133
View File
@@ -0,0 +1,133 @@
package collector
import (
"os/exec"
"strconv"
"strings"
"time"
)
type WireGuardTunnel struct {
Interface string `json:"interface"`
PublicKey string `json:"public_key"`
ListenPort int `json:"listen_port"`
Peers []WireGuardPeer `json:"peers"`
}
type WireGuardPeer struct {
PublicKey string `json:"public_key"`
Endpoint string `json:"endpoint"`
AllowedIPs []string `json:"allowed_ips"`
LatestHandshake int64 `json:"latest_handshake_epoch"`
HandshakeAge string `json:"handshake_age"`
TransferRx int64 `json:"transfer_rx_bytes"`
TransferTx int64 `json:"transfer_tx_bytes"`
Keepalive int `json:"persistent_keepalive"`
Status string `json:"status"` // "active", "inactive", "unknown"
}
func CollectWireGuard() []WireGuardTunnel {
// wg show all dump: maschinenlesbares Format
// Voller Pfad, da daemon(8) evtl. reduzierten PATH hat
wgPath := "/usr/bin/wg"
if _, err := exec.LookPath("wg"); err == nil {
wgPath = "wg"
}
out, err := exec.Command(wgPath, "show", "all", "dump").Output()
if err != nil {
return nil
}
tunnels := make(map[string]*WireGuardTunnel)
var order []string
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < 5 {
continue
}
iface := fields[0]
// Interface-Zeile: iface privkey pubkey listenport fwmark
if _, exists := tunnels[iface]; !exists {
port := 0
if len(fields) >= 4 {
port, _ = strconv.Atoi(fields[3])
}
pubkey := ""
if len(fields) >= 3 {
pubkey = fields[2]
}
tunnels[iface] = &WireGuardTunnel{
Interface: iface,
PublicKey: pubkey,
ListenPort: port,
}
order = append(order, iface)
continue
}
// Peer-Zeile: iface pubkey presharedkey endpoint allowedips latest-handshake transfer-rx transfer-tx keepalive
if len(fields) < 9 {
continue
}
peer := WireGuardPeer{
PublicKey: fields[1],
Endpoint: fields[3],
}
// Allowed IPs
if fields[4] != "(none)" {
peer.AllowedIPs = strings.Split(fields[4], ",")
}
// Latest handshake (epoch)
if ts, err := strconv.ParseInt(fields[5], 10, 64); err == nil && ts > 0 {
peer.LatestHandshake = ts
age := time.Since(time.Unix(ts, 0))
if age < 3*time.Minute {
peer.Status = "active"
} else if age < 10*time.Minute {
peer.Status = "inactive"
} else {
peer.Status = "inactive"
}
// Menschenlesbare Altersangabe
if age.Hours() >= 24 {
peer.HandshakeAge = strconv.Itoa(int(age.Hours()/24)) + "d " + strconv.Itoa(int(age.Hours())%24) + "h ago"
} else if age.Hours() >= 1 {
peer.HandshakeAge = strconv.Itoa(int(age.Hours())) + "h " + strconv.Itoa(int(age.Minutes())%60) + "m ago"
} else if age.Minutes() >= 1 {
peer.HandshakeAge = strconv.Itoa(int(age.Minutes())) + "m " + strconv.Itoa(int(age.Seconds())%60) + "s ago"
} else {
peer.HandshakeAge = strconv.Itoa(int(age.Seconds())) + "s ago"
}
} else {
peer.Status = "unknown"
peer.HandshakeAge = "never"
}
// Transfer RX/TX
peer.TransferRx, _ = strconv.ParseInt(fields[6], 10, 64)
peer.TransferTx, _ = strconv.ParseInt(fields[7], 10, 64)
// Keepalive
if fields[8] != "off" {
peer.Keepalive, _ = strconv.Atoi(fields[8])
}
tunnels[iface].Peers = append(tunnels[iface].Peers, peer)
}
// Geordnet zurueckgeben
var result []WireGuardTunnel
for _, name := range order {
result = append(result, *tunnels[name])
}
return result
}