Linux/Proxmox Agent + Plattform-Updater + Proxmox Frontend

- agent-linux/: Kompletter Linux/Proxmox RMM Agent
  - Collectors: CPU, Memory, Disk, Network, Services, Updates, Proxmox (VMs, Container, Storage, ZFS), Backups
  - Backup Collector: Jobs nach Node gefiltert (Cluster-aware)
  - WebSocket: Tunnel-Support, writeMux fuer stabile Verbindungen
  - Systemd Service Template

- updater/: Plattform-unabhaengiger Updater (FreeBSD + Linux)
  - runtime.GOOS erkennt Plattform automatisch
  - FreeBSD: /usr/local/rmm/, service rmm_agent
  - Linux: /usr/local/bin/, /etc/rmm/, systemctl rmm-agent
  - Firmware-Download mit ?platform= Parameter

- frontend/: Proxmox-Bereich
  - ProxmoxServers.jsx: Eigene Seite fuer PVE Server mit VM/CT Counts
  - ProxmoxPanel.jsx: Detail-Panel mit Uebersicht, VMs, Container, Storage, ZFS, Tunnel, Dienste, Updates, Backups
  - Agents.jsx: Filtert Linux-Agents raus (nur OPNsense)
  - Sidebar: Proxmox Navigationspunkt
  - Installationsanleitung mit rmm-agent-linux + rmm-updater-linux

- backend: Platform-Feld fuer Agents, Firmware multi-platform
This commit is contained in:
cynfo3000
2026-03-01 22:03:18 +01:00
parent c114df215a
commit 7f9fbd257f
39 changed files with 5775 additions and 112 deletions
+89
View File
@@ -0,0 +1,89 @@
package collector
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"strings"
"time"
)
type BackupTask struct {
UPID string `json:"upid"`
Status string `json:"status"`
StartTime int64 `json:"starttime"`
EndTime int64 `json:"endtime"`
Node string `json:"node"`
}
type BackupData struct {
Jobs []map[string]interface{} `json:"jobs"`
Tasks []BackupTask `json:"tasks"`
}
func CollectBackups() *BackupData {
if _, err := os.Stat("/usr/bin/pvesh"); err != nil {
return nil
}
result := &BackupData{
Jobs: []map[string]interface{}{},
Tasks: []BackupTask{},
}
// Hostname fuer Node-Filterung
hostname, _ := os.Hostname()
nodeName := strings.TrimSpace(hostname)
// Backup Jobs aus Cluster-Config — nur Jobs die auf diesem Node laufen
cmd := exec.Command("/usr/bin/pvesh", "get", "/cluster/backup", "--output-format", "json")
if out, err := cmd.Output(); err == nil {
var allJobs []map[string]interface{}
if err := json.Unmarshal(out, &allJobs); err == nil {
for _, j := range allJobs {
// next-run -> next_run umbenennen fuer Frontend-Kompatibilitaet
if nr, ok := j["next-run"]; ok {
j["next_run"] = nr
delete(j, "next-run")
}
// Filtern: Job gehoert zu diesem Node wenn:
// - node == unser hostname
// - node leer/nicht gesetzt (= alle Nodes)
jobNode, _ := j["node"].(string)
if jobNode == "" || strings.EqualFold(jobNode, nodeName) {
result.Jobs = append(result.Jobs, j)
}
}
log.Printf("Backups: %d/%d Jobs fuer Node %s", len(result.Jobs), len(allJobs), nodeName)
}
} else {
log.Printf("Backups: Jobs laden fehlgeschlagen: %v", err)
}
if nodeName == "" {
return result
}
since := fmt.Sprintf("%d", time.Now().AddDate(0, 0, -30).Unix())
cmd = exec.Command("/usr/bin/pvesh", "get", "/nodes/"+nodeName+"/tasks",
"--output-format", "json",
"--typefilter", "vzdump",
"--limit", "500",
"--since", since,
)
if out, err := cmd.Output(); err == nil {
var tasks []BackupTask
if err := json.Unmarshal(out, &tasks); err == nil {
result.Tasks = tasks
log.Printf("Backups: %d Tasks (letzte 30 Tage)", len(tasks))
} else {
log.Printf("Backups: Tasks JSON parse fehler: %v", err)
}
} else {
log.Printf("Backups: Tasks laden fehlgeschlagen: %v", err)
}
return result
}
+168
View File
@@ -0,0 +1,168 @@
package collector
import (
"os"
"strconv"
"strings"
"time"
)
type CPUInfo struct {
Model string
Cores int
Threads int
FreqMHz int
UsagePercent float64
}
func CollectCPU() CPUInfo {
cpu := CPUInfo{}
// CPU-Informationen aus /proc/cpuinfo lesen
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
lines := strings.Split(string(data), "\n")
cpuCount := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "model name":
if cpu.Model == "" { // Nur vom ersten CPU nehmen
cpu.Model = value
}
case "cpu MHz":
if cpu.FreqMHz == 0 { // Nur vom ersten CPU nehmen
if freq, err := strconv.ParseFloat(value, 64); err == nil {
cpu.FreqMHz = int(freq)
}
}
case "processor":
cpuCount++
case "cpu cores":
if cores, err := strconv.Atoi(value); err == nil && cpu.Cores == 0 {
cpu.Cores = cores
}
case "siblings":
if threads, err := strconv.Atoi(value); err == nil && cpu.Threads == 0 {
cpu.Threads = threads
}
}
}
// Falls cpu cores und siblings nicht vorhanden, nutze processor count
if cpu.Cores == 0 {
cpu.Cores = cpuCount
}
if cpu.Threads == 0 {
cpu.Threads = cpuCount
}
}
// CPU-Usage berechnen
cpu.UsagePercent = calculateCPUUsage()
return cpu
}
// Berechnet die aktuelle CPU-Auslastung über zwei /proc/stat Messungen
func calculateCPUUsage() float64 {
stat1 := readCPUStat()
if stat1 == nil {
return 0.0
}
time.Sleep(100 * time.Millisecond) // Kurz warten für zweite Messung
stat2 := readCPUStat()
if stat2 == nil {
return 0.0
}
// Delta berechnen
totalDelta := (stat2.total - stat1.total)
idleDelta := (stat2.idle - stat1.idle)
if totalDelta <= 0 {
return 0.0
}
usage := 100.0 * float64(totalDelta-idleDelta) / float64(totalDelta)
if usage < 0 {
usage = 0
}
if usage > 100 {
usage = 100
}
return usage
}
type cpuStat struct {
total int64
idle int64
}
// Liest CPU-Statistiken aus /proc/stat
func readCPUStat() *cpuStat {
data, err := os.ReadFile("/proc/stat")
if err != nil {
return nil
}
lines := strings.Split(string(data), "\n")
if len(lines) == 0 {
return nil
}
// Erste Zeile sollte "cpu ..." sein
firstLine := strings.TrimSpace(lines[0])
if !strings.HasPrefix(firstLine, "cpu ") {
return nil
}
fields := strings.Fields(firstLine)
if len(fields) < 5 {
return nil
}
// cpu user nice system idle iowait irq softirq ...
var values []int64
for i := 1; i < len(fields) && i <= 7; i++ { // max 7 Werte lesen
if val, err := strconv.ParseInt(fields[i], 10, 64); err == nil {
values = append(values, val)
}
}
if len(values) < 4 {
return nil
}
// Total = Summe aller Werte
total := int64(0)
for _, val := range values {
total += val
}
// Idle = idle + iowait (falls iowait verfügbar)
idle := values[3] // idle
if len(values) > 4 {
idle += values[4] // + iowait
}
return &cpuStat{
total: total,
idle: idle,
}
}
+133
View File
@@ -0,0 +1,133 @@
package collector
import (
"os/exec"
"strconv"
"strings"
"syscall"
)
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 {
var disks []DiskInfo
// df -T zur Anzeige aller mounted filesystems
output, err := exec.Command("/usr/bin/df", "-T", "-P").CombinedOutput()
if err != nil {
return disks
}
lines := strings.Split(string(output), "\n")
if len(lines) < 2 {
return disks
}
// Erste Zeile ist Header, überspringen
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 7 {
continue
}
filesystem := fields[0]
fstype := fields[1]
totalKB := fields[2]
usedKB := fields[3]
availKB := fields[4]
mountpoint := fields[6]
// Überspringe spezielle Filesysteme
if shouldSkipFilesystem(filesystem, fstype, mountpoint) {
continue
}
// Konvertiere kB zu Bytes
total, _ := strconv.ParseInt(totalKB, 10, 64)
used, _ := strconv.ParseInt(usedKB, 10, 64)
avail, _ := strconv.ParseInt(availKB, 10, 64)
// Für bessere Genauigkeit: Versuche statvfs syscall
if stat := getStatvfs(mountpoint); stat != nil {
total = int64(stat.Blocks) * int64(stat.Bsize)
avail = int64(stat.Bavail) * int64(stat.Bsize)
used = total - avail
} else {
// Fallback: df-Werte in Bytes konvertieren
total *= 1024
used *= 1024
avail *= 1024
}
disks = append(disks, DiskInfo{
Filesystem: filesystem,
TotalBytes: total,
UsedBytes: used,
FreeBytes: avail,
MountPoint: mountpoint,
})
}
return disks
}
// Bestimmt ob ein Filesystem übersprungen werden soll
func shouldSkipFilesystem(filesystem, fstype, mountpoint string) bool {
// Überspringe virtuelle Filesysteme
skipTypes := []string{
"proc", "sysfs", "devfs", "devtmpfs", "tmpfs", "debugfs",
"securityfs", "cgroup", "cgroup2", "pstore", "bpf",
"tracefs", "configfs", "fusectl", "binfmt_misc",
"autofs", "mqueue", "hugetlbfs", "sunrpc",
}
for _, skipType := range skipTypes {
if fstype == skipType {
return true
}
}
// Überspringe spezielle Mount-Points
skipMounts := []string{
"/dev", "/proc", "/sys", "/run", "/tmp",
"/dev/shm", "/run/lock", "/run/user",
}
for _, skipMount := range skipMounts {
if strings.HasPrefix(mountpoint, skipMount) {
return true
}
}
// Überspringe Loop-Devices außer root
if strings.HasPrefix(filesystem, "/dev/loop") && mountpoint != "/" {
return true
}
// Überspringe Bind-Mounts (heuristic)
if strings.Contains(mountpoint, "/snap/") {
return true
}
return false
}
// Holt präzise Filesystem-Statistiken via statvfs syscall
func getStatvfs(path string) *syscall.Statfs_t {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return nil
}
return &stat
}
+70
View File
@@ -0,0 +1,70 @@
package collector
import (
"os"
"strconv"
"strings"
)
type MemoryInfo struct {
TotalBytes int64
UsedBytes int64
FreeBytes int64
}
func CollectMemory() MemoryInfo {
mem := MemoryInfo{}
data, err := os.ReadFile("/proc/meminfo")
if err != nil {
return mem
}
lines := strings.Split(string(data), "\n")
values := make(map[string]int64)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
key := strings.TrimSuffix(parts[0], ":")
valueStr := parts[1]
// Wert in kB, konvertiere zu Bytes
if value, err := strconv.ParseInt(valueStr, 10, 64); err == nil {
values[key] = value * 1024 // kB zu Bytes
}
}
// Basis-Werte
memTotal := values["MemTotal"]
memFree := values["MemFree"]
memAvailable := values["MemAvailable"]
buffers := values["Buffers"]
cached := values["Cached"]
mem.TotalBytes = memTotal
// Verfügbarer Speicher: Nutze MemAvailable falls vorhanden, sonst berechne
if memAvailable > 0 {
mem.FreeBytes = memAvailable
} else {
// Fallback: Free + Buffers + Cached (grobe Schätzung)
mem.FreeBytes = memFree + buffers + cached
}
// Benutzter Speicher = Total - Free
if mem.FreeBytes > mem.TotalBytes {
mem.FreeBytes = mem.TotalBytes
}
mem.UsedBytes = mem.TotalBytes - mem.FreeBytes
return mem
}
+185
View File
@@ -0,0 +1,185 @@
package collector
import (
"os"
"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 {
var interfaces []NetworkInterface
// Interface-Statistiken aus /proc/net/dev lesen
stats := readNetDevStats()
// Interface-Informationen aus /sys/class/net/* sammeln
entries, err := os.ReadDir("/sys/class/net")
if err != nil {
return interfaces
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
ifName := entry.Name()
// Überspringe loopback
if ifName == "lo" {
continue
}
iface := NetworkInterface{
Name: ifName,
Role: determineInterfaceRole(ifName),
}
// Status aus operstate lesen
if data, err := os.ReadFile("/sys/class/net/" + ifName + "/operstate"); err == nil {
state := strings.TrimSpace(string(data))
if state == "up" {
iface.Status = "up"
} else {
iface.Status = "down"
}
}
// MAC-Adresse lesen
if data, err := os.ReadFile("/sys/class/net/" + ifName + "/address"); err == nil {
iface.MAC = strings.TrimSpace(string(data))
}
// Statistiken hinzufügen
if stat, exists := stats[ifName]; exists {
iface.RxBytes = stat.rxBytes
iface.TxBytes = stat.txBytes
}
// IP-Adresse über ip addr bestimmen
iface.IP = getInterfaceIP(ifName)
interfaces = append(interfaces, iface)
}
return interfaces
}
// Bestimmt die Rolle eines Interfaces basierend auf dem Namen
func determineInterfaceRole(ifName string) string {
switch {
case strings.HasPrefix(ifName, "eth"):
return "lan"
case strings.HasPrefix(ifName, "ens"):
return "lan"
case strings.HasPrefix(ifName, "enp"):
return "lan"
case strings.HasPrefix(ifName, "wlan"):
return "wan"
case strings.HasPrefix(ifName, "wlp"):
return "wan"
case strings.HasPrefix(ifName, "ppp"):
return "wan"
case strings.HasPrefix(ifName, "tun"):
return "vpn"
case strings.HasPrefix(ifName, "tap"):
return "vpn"
case strings.HasPrefix(ifName, "br"):
return "bridge"
case strings.HasPrefix(ifName, "docker"):
return "virtual"
case strings.HasPrefix(ifName, "veth"):
return "virtual"
case strings.HasPrefix(ifName, "virbr"):
return "virtual"
default:
return "other"
}
}
// Liest die erste IPv4-Adresse eines Interfaces
func getInterfaceIP(ifName string) string {
output, err := exec.Command("/usr/sbin/ip", "-4", "addr", "show", ifName).CombinedOutput()
if err != nil {
return ""
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "inet ") {
fields := strings.Fields(line)
if len(fields) >= 2 {
// inet 192.168.1.100/24 ... -> 192.168.1.100
ipCidr := fields[1]
if parts := strings.SplitN(ipCidr, "/", 2); len(parts) > 0 {
return parts[0]
}
}
}
}
return ""
}
// Network-Statistiken
type netDevStat struct {
rxBytes int64
txBytes int64
}
// Liest Interface-Statistiken aus /proc/net/dev
func readNetDevStats() map[string]netDevStat {
stats := make(map[string]netDevStat)
data, err := os.ReadFile("/proc/net/dev")
if err != nil {
return stats
}
lines := strings.Split(string(data), "\n")
if len(lines) < 3 {
return stats
}
// Ersten 2 Zeilen sind Header
for _, line := range lines[2:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Interface: rx_bytes rx_packets ... tx_bytes tx_packets ...
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
ifName := strings.TrimSpace(parts[0])
values := strings.Fields(strings.TrimSpace(parts[1]))
if len(values) >= 9 {
// rx_bytes ist das erste Feld, tx_bytes das 9te Feld (0-basiert: Index 8)
rxBytes, _ := strconv.ParseInt(values[0], 10, 64)
txBytes, _ := strconv.ParseInt(values[8], 10, 64)
stats[ifName] = netDevStat{
rxBytes: rxBytes,
txBytes: txBytes,
}
}
}
return stats
}
+506
View File
@@ -0,0 +1,506 @@
package collector
import (
"encoding/json"
"log"
"os"
"os/exec"
"strconv"
"strings"
)
type ProxmoxInfo struct {
Available bool `json:"available"`
Version string `json:"version"`
Node string `json:"node"`
VMs []ProxmoxVM `json:"vms,omitempty"`
Containers []ProxmoxContainer `json:"containers,omitempty"`
Storage []ProxmoxStorage `json:"storage,omitempty"`
Cluster *ProxmoxCluster `json:"cluster,omitempty"`
Subscription *ProxmoxSubscription `json:"subscription,omitempty"`
ZFSPools []ZFSPool `json:"zfs_pools,omitempty"`
}
type ProxmoxVM struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
Type string `json:"type"`
CPUUsage *float64 `json:"cpu_usage,omitempty"`
MemoryUsed *int64 `json:"memory_used,omitempty"`
MemoryMax *int64 `json:"memory_max,omitempty"`
DiskUsed *int64 `json:"disk_used,omitempty"`
DiskMax *int64 `json:"disk_max,omitempty"`
Uptime *int64 `json:"uptime,omitempty"`
Node string `json:"node"`
}
type ProxmoxContainer struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
Type string `json:"type"`
CPUUsage *float64 `json:"cpu_usage,omitempty"`
MemoryUsed *int64 `json:"memory_used,omitempty"`
MemoryMax *int64 `json:"memory_max,omitempty"`
DiskUsed *int64 `json:"disk_used,omitempty"`
DiskMax *int64 `json:"disk_max,omitempty"`
Uptime *int64 `json:"uptime,omitempty"`
Node string `json:"node"`
}
type ProxmoxStorage struct {
Name string `json:"storage"`
Type string `json:"type"`
Total int64 `json:"total"`
Used int64 `json:"used"`
Available int64 `json:"available"`
Enabled bool `json:"enabled"`
Active bool `json:"active"`
Content string `json:"content"`
}
type ProxmoxCluster struct {
Name string `json:"name"`
Nodes []ProxmoxClusterNode `json:"nodes"`
Quorum bool `json:"quorum"`
}
type ProxmoxClusterNode struct {
Name string `json:"name"`
Status string `json:"status"`
Local bool `json:"local"`
}
type ProxmoxSubscription struct {
Status string `json:"status"`
ProductName string `json:"productname,omitempty"`
Key string `json:"key,omitempty"`
NextDueDate string `json:"next_due_date,omitempty"`
}
type ZFSPool struct {
Name string `json:"name"`
Size int64 `json:"size"`
Allocated int64 `json:"allocated"`
Free int64 `json:"free"`
Health string `json:"health"`
Fragmentation string `json:"fragmentation,omitempty"`
ScrubStatus string `json:"scrub_status,omitempty"`
}
func CollectProxmox() *ProxmoxInfo {
proxmox := &ProxmoxInfo{
Available: false,
}
// Prüfen ob pvesh verfügbar ist
// Voller Pfad fuer pvesh, da systemd reduzierten PATH hat
if _, err := os.Stat("/usr/bin/pvesh"); err != nil {
log.Printf("Proxmox: pvesh nicht gefunden: %v", err)
return nil // Proxmox nicht verfügbar
}
log.Printf("Proxmox: pvesh gefunden, sammle Daten...")
proxmox.Available = true
// Version abrufen
if data := pveshGet("/version"); data != nil {
log.Printf("Proxmox: Version data: %v", data)
if versionData, ok := data.(map[string]interface{}); ok {
if version, ok := versionData["version"].(string); ok {
proxmox.Version = version
}
}
}
// Node-Name bestimmen
proxmox.Node = getLocalNodeName()
// VMs sammeln
proxmox.VMs = collectProxmoxVMs()
// Container sammeln
proxmox.Containers = collectProxmoxContainers()
// Storage sammeln
proxmox.Storage = collectProxmoxStorage()
// Cluster-Info sammeln
proxmox.Cluster = collectProxmoxCluster()
// Subscription-Info sammeln
proxmox.Subscription = collectProxmoxSubscription()
// ZFS-Pools sammeln
proxmox.ZFSPools = collectZFSPools()
return proxmox
}
// pvesh get wrapper
func pveshGet(path string) interface{} {
cmd := exec.Command("/usr/bin/pvesh", "get", path, "--output-format", "json")
output, err := cmd.Output()
if err != nil {
log.Printf("Proxmox: pvesh get %s fehler: %v", path, err)
return nil
}
log.Printf("Proxmox: pvesh get %s: %d bytes", path, len(output))
var result interface{}
if err := json.Unmarshal(output, &result); err != nil {
log.Printf("Proxmox: JSON parse fehler fuer %s: %v", path, err)
return nil
}
return result
}
func getLocalNodeName() string {
// Hostname als Node-Name verwenden
if hostname, err := os.Hostname(); err == nil {
return hostname
}
return "localhost"
}
func collectProxmoxVMs() []ProxmoxVM {
var vms []ProxmoxVM
nodeName := getLocalNodeName()
data := pveshGet("/nodes/" + nodeName + "/qemu")
if data == nil {
return vms
}
if vmList, ok := data.([]interface{}); ok {
for _, item := range vmList {
if vmData, ok := item.(map[string]interface{}); ok {
vm := ProxmoxVM{
Type: "qemu",
Node: nodeName,
}
if vmid, ok := vmData["vmid"].(float64); ok {
vm.VMID = int(vmid)
}
if name, ok := vmData["name"].(string); ok {
vm.Name = name
}
if status, ok := vmData["status"].(string); ok {
vm.Status = status
}
if cpu, ok := vmData["cpu"].(float64); ok {
vm.CPUUsage = &cpu
}
if mem, ok := vmData["mem"].(float64); ok {
memUsed := int64(mem)
vm.MemoryUsed = &memUsed
}
if maxmem, ok := vmData["maxmem"].(float64); ok {
maxMem := int64(maxmem)
vm.MemoryMax = &maxMem
}
if disk, ok := vmData["disk"].(float64); ok {
diskUsed := int64(disk)
vm.DiskUsed = &diskUsed
}
if maxdisk, ok := vmData["maxdisk"].(float64); ok {
maxDisk := int64(maxdisk)
vm.DiskMax = &maxDisk
}
if uptime, ok := vmData["uptime"].(float64); ok {
uptimeVal := int64(uptime)
vm.Uptime = &uptimeVal
}
vms = append(vms, vm)
}
}
}
return vms
}
func collectProxmoxContainers() []ProxmoxContainer {
var containers []ProxmoxContainer
nodeName := getLocalNodeName()
data := pveshGet("/nodes/" + nodeName + "/lxc")
if data == nil {
return containers
}
if containerList, ok := data.([]interface{}); ok {
for _, item := range containerList {
if containerData, ok := item.(map[string]interface{}); ok {
container := ProxmoxContainer{
Type: "lxc",
Node: nodeName,
}
if vmid, ok := containerData["vmid"].(float64); ok {
container.VMID = int(vmid)
}
if name, ok := containerData["name"].(string); ok {
container.Name = name
}
if status, ok := containerData["status"].(string); ok {
container.Status = status
}
if cpu, ok := containerData["cpu"].(float64); ok {
container.CPUUsage = &cpu
}
if mem, ok := containerData["mem"].(float64); ok {
memUsed := int64(mem)
container.MemoryUsed = &memUsed
}
if maxmem, ok := containerData["maxmem"].(float64); ok {
maxMem := int64(maxmem)
container.MemoryMax = &maxMem
}
if disk, ok := containerData["disk"].(float64); ok {
diskUsed := int64(disk)
container.DiskUsed = &diskUsed
}
if maxdisk, ok := containerData["maxdisk"].(float64); ok {
maxDisk := int64(maxdisk)
container.DiskMax = &maxDisk
}
if uptime, ok := containerData["uptime"].(float64); ok {
uptimeVal := int64(uptime)
container.Uptime = &uptimeVal
}
containers = append(containers, container)
}
}
}
return containers
}
func collectProxmoxStorage() []ProxmoxStorage {
var storages []ProxmoxStorage
nodeName := getLocalNodeName()
data := pveshGet("/nodes/" + nodeName + "/storage")
if data == nil {
return storages
}
if storageList, ok := data.([]interface{}); ok {
for _, item := range storageList {
if storageData, ok := item.(map[string]interface{}); ok {
storage := ProxmoxStorage{}
if name, ok := storageData["storage"].(string); ok {
storage.Name = name
}
if storageType, ok := storageData["type"].(string); ok {
storage.Type = storageType
}
if total, ok := storageData["total"].(float64); ok {
storage.Total = int64(total)
}
if used, ok := storageData["used"].(float64); ok {
storage.Used = int64(used)
}
if avail, ok := storageData["avail"].(float64); ok {
storage.Available = int64(avail)
}
if enabled, ok := storageData["enabled"].(float64); ok {
storage.Enabled = enabled > 0
}
if active, ok := storageData["active"].(float64); ok {
storage.Active = active > 0
}
if content, ok := storageData["content"].(string); ok {
storage.Content = content
}
storages = append(storages, storage)
}
}
}
return storages
}
func collectProxmoxCluster() *ProxmoxCluster {
data := pveshGet("/cluster/status")
if data == nil {
return nil
}
cluster := &ProxmoxCluster{}
if clusterData, ok := data.([]interface{}); ok {
for _, item := range clusterData {
if itemData, ok := item.(map[string]interface{}); ok {
if itemType, ok := itemData["type"].(string); ok {
if itemType == "cluster" {
if name, ok := itemData["name"].(string); ok {
cluster.Name = name
}
if quorum, ok := itemData["quorate"].(float64); ok {
cluster.Quorum = quorum > 0
}
} else if itemType == "node" {
node := ProxmoxClusterNode{}
if name, ok := itemData["name"].(string); ok {
node.Name = name
}
if online, ok := itemData["online"].(float64); ok {
if online > 0 {
node.Status = "online"
} else {
node.Status = "offline"
}
}
if local, ok := itemData["local"].(float64); ok {
node.Local = local > 0
}
cluster.Nodes = append(cluster.Nodes, node)
}
}
}
}
}
if cluster.Name == "" && len(cluster.Nodes) == 0 {
return nil // Kein Cluster
}
return cluster
}
func collectProxmoxSubscription() *ProxmoxSubscription {
nodeName := getLocalNodeName()
data := pveshGet("/nodes/" + nodeName + "/subscription")
if data == nil {
return nil
}
subscription := &ProxmoxSubscription{}
if subData, ok := data.(map[string]interface{}); ok {
if status, ok := subData["status"].(string); ok {
subscription.Status = status
}
if productName, ok := subData["productname"].(string); ok {
subscription.ProductName = productName
}
if key, ok := subData["key"].(string); ok {
subscription.Key = key
}
if nextDueDate, ok := subData["nextduedate"].(string); ok {
subscription.NextDueDate = nextDueDate
}
}
return subscription
}
func collectZFSPools() []ZFSPool {
var pools []ZFSPool
// zpool list -H -o name,size,alloc,free,health
cmd := exec.Command("/usr/sbin/zpool", "list", "-H", "-o", "name,size,alloc,free,health")
output, err := cmd.Output()
if err != nil {
return pools // ZFS nicht verfügbar oder keine Pools
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
pool := ZFSPool{
Name: fields[0],
Health: fields[4],
}
// Größen parsen (können Suffixe wie K, M, G, T haben)
pool.Size = parseZFSSize(fields[1])
pool.Allocated = parseZFSSize(fields[2])
pool.Free = parseZFSSize(fields[3])
// Zusätzliche Informationen aus zpool status
pool.ScrubStatus = getZFSPoolScrubStatus(pool.Name)
pools = append(pools, pool)
}
return pools
}
// Parst ZFS-Größen mit Einheiten (z.B. "1.2T" -> Bytes)
func parseZFSSize(sizeStr string) int64 {
if sizeStr == "-" || sizeStr == "" {
return 0
}
sizeStr = strings.TrimSpace(sizeStr)
if len(sizeStr) == 0 {
return 0
}
// Letzes Zeichen prüfen für Einheit
lastChar := sizeStr[len(sizeStr)-1]
var multiplier int64 = 1
var numberStr string
switch lastChar {
case 'K':
multiplier = 1024
numberStr = sizeStr[:len(sizeStr)-1]
case 'M':
multiplier = 1024 * 1024
numberStr = sizeStr[:len(sizeStr)-1]
case 'G':
multiplier = 1024 * 1024 * 1024
numberStr = sizeStr[:len(sizeStr)-1]
case 'T':
multiplier = 1024 * 1024 * 1024 * 1024
numberStr = sizeStr[:len(sizeStr)-1]
default:
numberStr = sizeStr
}
if value, err := strconv.ParseFloat(numberStr, 64); err == nil {
return int64(value * float64(multiplier))
}
return 0
}
// Holt Scrub-Status für einen ZFS-Pool
func getZFSPoolScrubStatus(poolName string) string {
cmd := exec.Command("/usr/sbin/zpool", "status", poolName)
output, err := cmd.Output()
if err != nil {
return ""
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.Contains(line, "scrub") {
return line
}
}
return ""
}
+94
View File
@@ -0,0 +1,94 @@
package collector
import (
"os/exec"
"strings"
)
type ServiceInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
}
func CollectServices() []ServiceInfo {
var services []ServiceInfo
// systemctl list-units --type=service --all --no-pager --no-legend
output, err := exec.Command("/usr/bin/systemctl", "list-units", "--type=service", "--all", "--no-pager", "--no-legend").CombinedOutput()
if err != nil {
return services
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Format: UNIT LOAD ACTIVE SUB DESCRIPTION
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
serviceName := fields[0]
load := fields[1]
active := fields[2]
sub := fields[3]
// Beschreibung ist der Rest ab Feld 4
description := ""
if len(fields) > 4 {
description = strings.Join(fields[4:], " ")
}
// Status bestimmen
status := determineServiceStatus(load, active, sub)
// .service suffix entfernen für bessere Lesbarkeit
displayName := strings.TrimSuffix(serviceName, ".service")
services = append(services, ServiceInfo{
Name: displayName,
Description: description,
Status: status,
})
}
return services
}
// Bestimmt den Service-Status basierend auf systemctl Ausgabe
func determineServiceStatus(load, active, sub string) string {
// Priorität auf active state
switch active {
case "active":
// Wenn active, schaue auf sub-state
switch sub {
case "running":
return "running"
case "exited":
return "stopped" // one-shot service, beendet
default:
return "active"
}
case "inactive":
return "stopped"
case "failed":
return "error"
case "activating":
return "starting"
case "deactivating":
return "stopping"
default:
// Falls load state problematisch ist
if load == "not-found" {
return "not-found"
} else if load == "error" {
return "error"
}
return "unknown"
}
}
+132
View File
@@ -0,0 +1,132 @@
package collector
import (
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// Basis-Systemdaten sammeln
func CollectHostname() string {
if data, err := os.ReadFile("/etc/hostname"); err == nil {
return strings.TrimSpace(string(data))
}
// Fallback
if hostname, err := os.Hostname(); err == nil {
return hostname
}
return "unknown"
}
func CollectOSVersion() string {
// Versuche verschiedene Wege die OS-Version zu bestimmen
// 1. /etc/os-release (Standard für moderne Linux-Distributionen)
if data, err := os.ReadFile("/etc/os-release"); err == nil {
lines := strings.Split(string(data), "\n")
var name, version string
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "NAME=") {
name = strings.Trim(strings.SplitN(line, "=", 2)[1], "\"")
} else if strings.HasPrefix(line, "VERSION=") {
version = strings.Trim(strings.SplitN(line, "=", 2)[1], "\"")
}
}
if name != "" {
if version != "" {
return name + " " + version
}
return name
}
}
// 2. /etc/debian_version (Debian/Ubuntu)
if data, err := os.ReadFile("/etc/debian_version"); err == nil {
version := strings.TrimSpace(string(data))
return "Debian " + version
}
// 3. /etc/redhat-release (RHEL/CentOS)
if data, err := os.ReadFile("/etc/redhat-release"); err == nil {
return strings.TrimSpace(string(data))
}
// 4. uname fallback
if output, err := exec.Command("/usr/bin/uname", "-sr").CombinedOutput(); err == nil {
return strings.TrimSpace(string(output))
}
return "Linux (unknown)"
}
func CollectUptime() int64 {
if data, err := os.ReadFile("/proc/uptime"); err == nil {
parts := strings.Fields(string(data))
if len(parts) > 0 {
if uptime, err := strconv.ParseFloat(parts[0], 64); err == nil {
return int64(uptime)
}
}
}
// Fallback: Boot-Zeit berechnen
if stat, err := os.Stat("/proc/1"); err == nil {
bootTime := stat.ModTime()
return int64(time.Since(bootTime).Seconds())
}
return 0
}
// Hardware-Informationen sammeln
type HardwareInfo struct {
Manufacturer string
Model string
Serial string
BIOSVersion string
}
func CollectHardware() HardwareInfo {
hw := HardwareInfo{}
// DMI-Informationen aus /sys/class/dmi/id/ lesen
if data, err := os.ReadFile("/sys/class/dmi/id/sys_vendor"); err == nil {
hw.Manufacturer = strings.TrimSpace(string(data))
}
if data, err := os.ReadFile("/sys/class/dmi/id/product_name"); err == nil {
hw.Model = strings.TrimSpace(string(data))
}
if data, err := os.ReadFile("/sys/class/dmi/id/product_serial"); err == nil {
hw.Serial = strings.TrimSpace(string(data))
}
if data, err := os.ReadFile("/sys/class/dmi/id/bios_version"); err == nil {
hw.BIOSVersion = strings.TrimSpace(string(data))
}
// Fallback für Manufacturer
if hw.Manufacturer == "" {
if data, err := os.ReadFile("/sys/class/dmi/id/board_vendor"); err == nil {
hw.Manufacturer = strings.TrimSpace(string(data))
}
}
// Fallback für Model
if hw.Model == "" {
if data, err := os.ReadFile("/sys/class/dmi/id/board_name"); err == nil {
hw.Model = strings.TrimSpace(string(data))
}
}
return hw
}
+162
View File
@@ -0,0 +1,162 @@
package collector
import (
"os/exec"
"strings"
)
type UpdateInfo struct {
UpdateAvailable bool `json:"update_available"`
PendingCount int `json:"pending_count"`
Updates []PendingUpdateInfo `json:"updates"`
}
type PendingUpdateInfo struct {
Package string `json:"package"`
CurrentVer string `json:"current_version"`
NewVer string `json:"new_version"`
}
func CollectUpdates() *UpdateInfo {
updates := &UpdateInfo{
UpdateAvailable: false,
PendingCount: 0,
Updates: []PendingUpdateInfo{},
}
// apt list --upgradable 2>/dev/null
cmd := exec.Command("/usr/bin/apt", "list", "--upgradable")
output, err := cmd.CombinedOutput()
if err != nil {
// Fallback: apt-check falls verfügbar
return collectUpdatesAptCheck()
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "WARNING:") || strings.HasPrefix(line, "Listing...") {
continue
}
// Format: package/suite version [upgradable from: old-version]
// Beispiel: curl/focal-updates 7.68.0-1ubuntu2.7 amd64 [upgradable from: 7.68.0-1ubuntu2.6]
if !strings.Contains(line, "[upgradable from:") {
continue
}
// Package-Name extrahieren (bis zum ersten /)
parts := strings.SplitN(line, "/", 2)
if len(parts) < 2 {
continue
}
packageName := parts[0]
// Neue Version extrahieren (zwischen / und [upgradable from:)
remaining := parts[1]
beforeUpgradable := strings.Split(remaining, " [upgradable from:")
if len(beforeUpgradable) < 2 {
continue
}
// Neue Version ist im ersten Teil, aber wir müssen die Architektur entfernen
versionParts := strings.Fields(beforeUpgradable[0])
if len(versionParts) < 1 {
continue
}
newVersion := versionParts[0]
// Alte Version extrahieren (zwischen "from:" und "]")
oldVersionPart := beforeUpgradable[1]
oldVersion := strings.TrimSuffix(strings.TrimSpace(oldVersionPart), "]")
updates.Updates = append(updates.Updates, PendingUpdateInfo{
Package: packageName,
CurrentVer: oldVersion,
NewVer: newVersion,
})
}
updates.PendingCount = len(updates.Updates)
updates.UpdateAvailable = updates.PendingCount > 0
return updates
}
// Fallback-Methode mit apt-check (Ubuntu/Debian)
func collectUpdatesAptCheck() *UpdateInfo {
updates := &UpdateInfo{
UpdateAvailable: false,
PendingCount: 0,
Updates: []PendingUpdateInfo{},
}
// /usr/lib/update-notifier/apt-check
cmd := exec.Command("/usr/lib/update-notifier/apt-check")
output, err := cmd.CombinedOutput()
if err != nil {
// Noch ein Fallback: apt-get -s upgrade
return collectUpdatesAptGetSimulate()
}
// apt-check gibt "updates;security-updates" auf stderr aus
result := strings.TrimSpace(string(output))
parts := strings.SplitN(result, ";", 2)
if len(parts) >= 1 && parts[0] != "0" {
// Packages verfügbar, aber keine Details ohne apt list
updates.UpdateAvailable = true
updates.PendingCount = 1 // Platzhalter
updates.Updates = []PendingUpdateInfo{
{
Package: "system-updates",
CurrentVer: "various",
NewVer: "available",
},
}
}
return updates
}
// Letzter Fallback mit apt-get simulate
func collectUpdatesAptGetSimulate() *UpdateInfo {
updates := &UpdateInfo{
UpdateAvailable: false,
PendingCount: 0,
Updates: []PendingUpdateInfo{},
}
// apt-get -s upgrade (simulate)
cmd := exec.Command("/usr/bin/apt-get", "-s", "upgrade")
output, err := cmd.CombinedOutput()
if err != nil {
return updates
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
// Suche nach "Inst package-name" Zeilen
if strings.HasPrefix(line, "Inst ") {
fields := strings.Fields(line)
if len(fields) >= 3 {
packageName := fields[1]
// Sehr simple Erkennung, bessere Parsing schwierig ohne apt list
updates.Updates = append(updates.Updates, PendingUpdateInfo{
Package: packageName,
CurrentVer: "unknown",
NewVer: "available",
})
}
}
}
updates.PendingCount = len(updates.Updates)
updates.UpdateAvailable = updates.PendingCount > 0
return updates
}