Potential fixes for issues #4 and #6. Agent version display: - Set CurrentVersion during registration instead of waiting for first check-in - Update UI to show "Initial Registration" instead of "Unknown" Host restart detection: - Added reboot_required, last_reboot_at, reboot_reason fields to agents table - Agent now detects pending reboots (Debian/Ubuntu via /var/run/reboot-required, RHEL/Fedora via needs-restarting) - New reboot command type with 1-minute grace period - UI shows restart alerts and adds restart button in quick actions - Restart indicator badge in agent list The reboot detection runs during system info collection and gets reported back to the server automatically. Using shutdown command for now until we make the restart mechanism user-adjustable later - need to think on that. Also need to come up with a Windows derivative outside of reading event log for detecting reboots.
538 lines
15 KiB
Go
538 lines
15 KiB
Go
package system
|
|
|
|
import (
|
|
"os/exec"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// SystemInfo contains detailed system information
|
|
type SystemInfo struct {
|
|
Hostname string `json:"hostname"`
|
|
OSType string `json:"os_type"`
|
|
OSVersion string `json:"os_version"`
|
|
OSArchitecture string `json:"os_architecture"`
|
|
AgentVersion string `json:"agent_version"`
|
|
IPAddress string `json:"ip_address"`
|
|
CPUInfo CPUInfo `json:"cpu_info"`
|
|
MemoryInfo MemoryInfo `json:"memory_info"`
|
|
DiskInfo []DiskInfo `json:"disk_info"`
|
|
RunningProcesses int `json:"running_processes"`
|
|
Uptime string `json:"uptime"`
|
|
RebootRequired bool `json:"reboot_required"`
|
|
RebootReason string `json:"reboot_reason"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
}
|
|
|
|
// CPUInfo contains CPU information
|
|
type CPUInfo struct {
|
|
ModelName string `json:"model_name"`
|
|
Cores int `json:"cores"`
|
|
Threads int `json:"threads"`
|
|
}
|
|
|
|
// MemoryInfo contains memory information
|
|
type MemoryInfo struct {
|
|
Total uint64 `json:"total"`
|
|
Available uint64 `json:"available"`
|
|
Used uint64 `json:"used"`
|
|
UsedPercent float64 `json:"used_percent"`
|
|
}
|
|
|
|
// DiskInfo contains disk information
|
|
type DiskInfo struct {
|
|
Mountpoint string `json:"mountpoint"`
|
|
Total uint64 `json:"total"`
|
|
Available uint64 `json:"available"`
|
|
Used uint64 `json:"used"`
|
|
UsedPercent float64 `json:"used_percent"`
|
|
Filesystem string `json:"filesystem"`
|
|
}
|
|
|
|
// GetSystemInfo collects detailed system information
|
|
func GetSystemInfo(agentVersion string) (*SystemInfo, error) {
|
|
info := &SystemInfo{
|
|
AgentVersion: agentVersion,
|
|
Metadata: make(map[string]string),
|
|
}
|
|
|
|
// Get basic system info
|
|
info.OSType = runtime.GOOS
|
|
info.OSArchitecture = runtime.GOARCH
|
|
|
|
// Get hostname
|
|
if hostname, err := exec.Command("hostname").Output(); err == nil {
|
|
info.Hostname = strings.TrimSpace(string(hostname))
|
|
}
|
|
|
|
// Get IP address
|
|
if ip, err := getIPAddress(); err == nil {
|
|
info.IPAddress = ip
|
|
}
|
|
|
|
// Get OS version info
|
|
if info.OSType == "linux" {
|
|
info.OSVersion = getLinuxDistroInfo()
|
|
} else if info.OSType == "windows" {
|
|
info.OSVersion = getWindowsInfo()
|
|
} else if info.OSType == "darwin" {
|
|
info.OSVersion = getMacOSInfo()
|
|
}
|
|
|
|
// Get CPU info
|
|
if cpu, err := getCPUInfo(); err == nil {
|
|
info.CPUInfo = *cpu
|
|
}
|
|
|
|
// Get memory info
|
|
if mem, err := getMemoryInfo(); err == nil {
|
|
info.MemoryInfo = *mem
|
|
}
|
|
|
|
// Get disk info
|
|
if disks, err := getDiskInfo(); err == nil {
|
|
info.DiskInfo = disks
|
|
}
|
|
|
|
// Get process count
|
|
if procs, err := getProcessCount(); err == nil {
|
|
info.RunningProcesses = procs
|
|
}
|
|
|
|
// Get uptime
|
|
if uptime, err := getUptime(); err == nil {
|
|
info.Uptime = uptime
|
|
}
|
|
|
|
// Add hardware information for Windows
|
|
if runtime.GOOS == "windows" {
|
|
if hardware := getWindowsHardwareInfo(); len(hardware) > 0 {
|
|
for key, value := range hardware {
|
|
info.Metadata[key] = value
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if system requires reboot
|
|
rebootRequired, rebootReason := checkRebootRequired()
|
|
info.RebootRequired = rebootRequired
|
|
info.RebootReason = rebootReason
|
|
|
|
// Add collection timestamp
|
|
info.Metadata["collected_at"] = time.Now().Format(time.RFC3339)
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// getLinuxDistroInfo parses /etc/os-release for distro information
|
|
func getLinuxDistroInfo() string {
|
|
if data, err := exec.Command("cat", "/etc/os-release").Output(); err == nil {
|
|
lines := strings.Split(string(data), "\n")
|
|
prettyName := ""
|
|
version := ""
|
|
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "PRETTY_NAME=") {
|
|
prettyName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"")
|
|
}
|
|
if strings.HasPrefix(line, "VERSION_ID=") {
|
|
version = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"")
|
|
}
|
|
}
|
|
|
|
if prettyName != "" {
|
|
return prettyName
|
|
}
|
|
|
|
// Fallback to parsing ID and VERSION_ID
|
|
id := ""
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "ID=") {
|
|
id = strings.Trim(strings.TrimPrefix(line, "ID="), "\"")
|
|
}
|
|
}
|
|
|
|
if id != "" {
|
|
if version != "" {
|
|
return strings.Title(id) + " " + version
|
|
}
|
|
return strings.Title(id)
|
|
}
|
|
}
|
|
|
|
// Try other methods
|
|
if data, err := exec.Command("lsb_release", "-d", "-s").Output(); err == nil {
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
|
|
return "Linux"
|
|
}
|
|
|
|
|
|
// getMacOSInfo gets macOS version information
|
|
func getMacOSInfo() string {
|
|
if cmd, err := exec.LookPath("sw_vers"); err == nil {
|
|
if data, err := exec.Command(cmd, "-productVersion").Output(); err == nil {
|
|
version := strings.TrimSpace(string(data))
|
|
return "macOS " + version
|
|
}
|
|
}
|
|
|
|
return "macOS"
|
|
}
|
|
|
|
// getCPUInfo gets CPU information
|
|
func getCPUInfo() (*CPUInfo, error) {
|
|
cpu := &CPUInfo{}
|
|
|
|
if runtime.GOOS == "linux" {
|
|
if data, err := exec.Command("cat", "/proc/cpuinfo").Output(); err == nil {
|
|
lines := strings.Split(string(data), "\n")
|
|
cores := 0
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "model name") {
|
|
cpu.ModelName = strings.TrimPrefix(line, "model name\t: ")
|
|
}
|
|
if strings.HasPrefix(line, "processor") {
|
|
cores++
|
|
}
|
|
}
|
|
cpu.Cores = cores
|
|
cpu.Threads = cores
|
|
}
|
|
} else if runtime.GOOS == "darwin" {
|
|
if cmd, err := exec.LookPath("sysctl"); err == nil {
|
|
if data, err := exec.Command(cmd, "-n", "hw.ncpu").Output(); err == nil {
|
|
if cores, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil {
|
|
cpu.Cores = cores
|
|
cpu.Threads = cores
|
|
}
|
|
}
|
|
}
|
|
} else if runtime.GOOS == "windows" {
|
|
return getWindowsCPUInfo()
|
|
}
|
|
|
|
return cpu, nil
|
|
}
|
|
|
|
// getMemoryInfo gets memory information
|
|
func getMemoryInfo() (*MemoryInfo, error) {
|
|
mem := &MemoryInfo{}
|
|
|
|
if runtime.GOOS == "linux" {
|
|
if data, err := exec.Command("cat", "/proc/meminfo").Output(); err == nil {
|
|
lines := strings.Split(string(data), "\n")
|
|
for _, line := range lines {
|
|
fields := strings.Fields(line)
|
|
if len(fields) >= 2 {
|
|
switch fields[0] {
|
|
case "MemTotal:":
|
|
if total, err := strconv.ParseUint(fields[1], 10, 64); err == nil {
|
|
mem.Total = total * 1024 // Convert from KB to bytes
|
|
}
|
|
case "MemAvailable:":
|
|
if available, err := strconv.ParseUint(fields[1], 10, 64); err == nil {
|
|
mem.Available = available * 1024
|
|
}
|
|
}
|
|
}
|
|
}
|
|
mem.Used = mem.Total - mem.Available
|
|
if mem.Total > 0 {
|
|
mem.UsedPercent = float64(mem.Used) / float64(mem.Total) * 100
|
|
}
|
|
}
|
|
} else if runtime.GOOS == "windows" {
|
|
return getWindowsMemoryInfo()
|
|
}
|
|
|
|
return mem, nil
|
|
}
|
|
|
|
// getDiskInfo gets disk information for mounted filesystems
|
|
func getDiskInfo() ([]DiskInfo, error) {
|
|
var disks []DiskInfo
|
|
|
|
if runtime.GOOS == "windows" {
|
|
return getWindowsDiskInfo()
|
|
} else {
|
|
if cmd, err := exec.LookPath("df"); err == nil {
|
|
if data, err := exec.Command(cmd, "-h", "--output=target,size,used,avail,pcent,source").Output(); err == nil {
|
|
lines := strings.Split(string(data), "\n")
|
|
for i, line := range lines {
|
|
if i == 0 || strings.TrimSpace(line) == "" {
|
|
continue // Skip header and empty lines
|
|
}
|
|
|
|
fields := strings.Fields(line)
|
|
if len(fields) >= 6 {
|
|
mountpoint := fields[0]
|
|
filesystem := fields[5]
|
|
|
|
// Filter out pseudo-filesystems and only show physical/important mounts
|
|
// Skip tmpfs, devtmpfs, overlay, squashfs, etc.
|
|
if strings.HasPrefix(filesystem, "tmpfs") ||
|
|
strings.HasPrefix(filesystem, "devtmpfs") ||
|
|
strings.HasPrefix(filesystem, "overlay") ||
|
|
strings.HasPrefix(filesystem, "squashfs") ||
|
|
strings.HasPrefix(filesystem, "udev") ||
|
|
strings.HasPrefix(filesystem, "proc") ||
|
|
strings.HasPrefix(filesystem, "sysfs") ||
|
|
strings.HasPrefix(filesystem, "cgroup") ||
|
|
strings.HasPrefix(filesystem, "devpts") ||
|
|
strings.HasPrefix(filesystem, "securityfs") ||
|
|
strings.HasPrefix(filesystem, "pstore") ||
|
|
strings.HasPrefix(filesystem, "bpf") ||
|
|
strings.HasPrefix(filesystem, "configfs") ||
|
|
strings.HasPrefix(filesystem, "fusectl") ||
|
|
strings.HasPrefix(filesystem, "hugetlbfs") ||
|
|
strings.HasPrefix(filesystem, "mqueue") ||
|
|
strings.HasPrefix(filesystem, "debugfs") ||
|
|
strings.HasPrefix(filesystem, "tracefs") {
|
|
continue // Skip virtual/pseudo filesystems
|
|
}
|
|
|
|
// Skip container/snap mounts unless they're important
|
|
if strings.Contains(mountpoint, "/snap/") ||
|
|
strings.Contains(mountpoint, "/var/lib/docker") ||
|
|
strings.Contains(mountpoint, "/run") {
|
|
continue
|
|
}
|
|
|
|
disk := DiskInfo{
|
|
Mountpoint: mountpoint,
|
|
Filesystem: filesystem,
|
|
}
|
|
|
|
// Parse sizes (df outputs in human readable format, we'll parse the numeric part)
|
|
if total, err := parseSize(fields[1]); err == nil {
|
|
disk.Total = total
|
|
}
|
|
if used, err := parseSize(fields[2]); err == nil {
|
|
disk.Used = used
|
|
}
|
|
if available, err := parseSize(fields[3]); err == nil {
|
|
disk.Available = available
|
|
}
|
|
if total, err := strconv.ParseFloat(strings.TrimSuffix(fields[4], "%"), 64); err == nil {
|
|
disk.UsedPercent = total
|
|
}
|
|
|
|
disks = append(disks, disk)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return disks, nil
|
|
}
|
|
|
|
// parseSize parses human readable size strings (like "1.5G" or "500M")
|
|
func parseSize(sizeStr string) (uint64, error) {
|
|
sizeStr = strings.TrimSpace(sizeStr)
|
|
if len(sizeStr) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
multiplier := uint64(1)
|
|
unit := sizeStr[len(sizeStr)-1:]
|
|
if unit == "G" || unit == "g" {
|
|
multiplier = 1024 * 1024 * 1024
|
|
sizeStr = sizeStr[:len(sizeStr)-1]
|
|
} else if unit == "M" || unit == "m" {
|
|
multiplier = 1024 * 1024
|
|
sizeStr = sizeStr[:len(sizeStr)-1]
|
|
} else if unit == "K" || unit == "k" {
|
|
multiplier = 1024
|
|
sizeStr = sizeStr[:len(sizeStr)-1]
|
|
}
|
|
|
|
size, err := strconv.ParseFloat(sizeStr, 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return uint64(size * float64(multiplier)), nil
|
|
}
|
|
|
|
// getProcessCount gets the number of running processes
|
|
func getProcessCount() (int, error) {
|
|
if runtime.GOOS == "linux" {
|
|
if data, err := exec.Command("ps", "-e").Output(); err == nil {
|
|
lines := strings.Split(string(data), "\n")
|
|
return len(lines) - 1, nil // Subtract 1 for header
|
|
}
|
|
} else if runtime.GOOS == "darwin" {
|
|
if data, err := exec.Command("ps", "-ax").Output(); err == nil {
|
|
lines := strings.Split(string(data), "\n")
|
|
return len(lines) - 1, nil // Subtract 1 for header
|
|
}
|
|
} else if runtime.GOOS == "windows" {
|
|
return getWindowsProcessCount()
|
|
}
|
|
|
|
return 0, nil
|
|
}
|
|
|
|
// getUptime gets system uptime
|
|
func getUptime() (string, error) {
|
|
if runtime.GOOS == "linux" {
|
|
if data, err := exec.Command("uptime", "-p").Output(); err == nil {
|
|
return strings.TrimSpace(string(data)), nil
|
|
}
|
|
} else if runtime.GOOS == "darwin" {
|
|
if data, err := exec.Command("uptime").Output(); err == nil {
|
|
return strings.TrimSpace(string(data)), nil
|
|
}
|
|
} else if runtime.GOOS == "windows" {
|
|
return getWindowsUptime()
|
|
}
|
|
|
|
return "Unknown", nil
|
|
}
|
|
|
|
// getIPAddress gets the primary IP address
|
|
func getIPAddress() (string, error) {
|
|
if runtime.GOOS == "linux" {
|
|
// Try to get the IP from hostname -I
|
|
if data, err := exec.Command("hostname", "-I").Output(); err == nil {
|
|
ips := strings.Fields(string(data))
|
|
if len(ips) > 0 {
|
|
return ips[0], nil
|
|
}
|
|
}
|
|
|
|
// Fallback to ip route
|
|
if data, err := exec.Command("ip", "route", "get", "8.8.8.8").Output(); err == nil {
|
|
lines := strings.Split(string(data), "\n")
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "src") {
|
|
fields := strings.Fields(line)
|
|
for i, field := range fields {
|
|
if field == "src" && i+1 < len(fields) {
|
|
return fields[i+1], nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if runtime.GOOS == "windows" {
|
|
return getWindowsIPAddress()
|
|
}
|
|
|
|
return "127.0.0.1", nil
|
|
}
|
|
|
|
// LightweightMetrics contains lightweight system metrics for regular check-ins
|
|
type LightweightMetrics struct {
|
|
CPUPercent float64
|
|
MemoryPercent float64
|
|
MemoryUsedGB float64
|
|
MemoryTotalGB float64
|
|
DiskUsedGB float64
|
|
DiskTotalGB float64
|
|
DiskPercent float64
|
|
Uptime string
|
|
}
|
|
|
|
// GetLightweightMetrics collects lightweight system metrics for regular check-ins
|
|
// This is much faster than GetSystemInfo() and suitable for frequent calls
|
|
func GetLightweightMetrics() (*LightweightMetrics, error) {
|
|
metrics := &LightweightMetrics{}
|
|
|
|
// Get memory info
|
|
if mem, err := getMemoryInfo(); err == nil {
|
|
metrics.MemoryPercent = mem.UsedPercent
|
|
metrics.MemoryUsedGB = float64(mem.Used) / (1024 * 1024 * 1024)
|
|
metrics.MemoryTotalGB = float64(mem.Total) / (1024 * 1024 * 1024)
|
|
}
|
|
|
|
// Get primary disk info (root filesystem)
|
|
if disks, err := getDiskInfo(); err == nil {
|
|
for _, disk := range disks {
|
|
// Look for root filesystem or first mountpoint
|
|
if disk.Mountpoint == "/" || disk.Mountpoint == "C:" || len(metrics.Uptime) == 0 {
|
|
metrics.DiskUsedGB = float64(disk.Used) / (1024 * 1024 * 1024)
|
|
metrics.DiskTotalGB = float64(disk.Total) / (1024 * 1024 * 1024)
|
|
metrics.DiskPercent = disk.UsedPercent
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get uptime
|
|
if uptime, err := getUptime(); err == nil {
|
|
metrics.Uptime = uptime
|
|
}
|
|
|
|
// Note: CPU percentage requires sampling over time, which is expensive
|
|
// For now, we omit it from lightweight metrics
|
|
// In the future, we could add a background goroutine to track CPU usage
|
|
|
|
return metrics, nil
|
|
}
|
|
// checkRebootRequired checks if the system requires a reboot
|
|
func checkRebootRequired() (bool, string) {
|
|
if runtime.GOOS == "linux" {
|
|
return checkLinuxRebootRequired()
|
|
} else if runtime.GOOS == "windows" {
|
|
return checkWindowsRebootRequired()
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
// checkLinuxRebootRequired checks if a Linux system requires a reboot
|
|
func checkLinuxRebootRequired() (bool, string) {
|
|
// Method 1: Check Debian/Ubuntu reboot-required file
|
|
if err := exec.Command("test", "-f", "/var/run/reboot-required").Run(); err == nil {
|
|
// File exists, reboot is required
|
|
// Try to read the packages that require reboot
|
|
if output, err := exec.Command("cat", "/var/run/reboot-required.pkgs").Output(); err == nil {
|
|
packages := strings.TrimSpace(string(output))
|
|
if packages != "" {
|
|
// Truncate if too long
|
|
if len(packages) > 200 {
|
|
packages = packages[:200] + "..."
|
|
}
|
|
return true, "Packages: " + packages
|
|
}
|
|
}
|
|
return true, "System updates require reboot"
|
|
}
|
|
|
|
// Method 2: Check RHEL/Fedora/Rocky using needs-restarting
|
|
cmd := exec.Command("needs-restarting", "-r")
|
|
if err := cmd.Run(); err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
// Exit code 1 means reboot is needed
|
|
if exitErr.ExitCode() == 1 {
|
|
return true, "Kernel or system libraries updated"
|
|
}
|
|
}
|
|
}
|
|
|
|
return false, ""
|
|
}
|
|
|
|
// checkWindowsRebootRequired checks if a Windows system requires a reboot
|
|
func checkWindowsRebootRequired() (bool, string) {
|
|
// Check Windows Update pending reboot registry keys
|
|
// HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired
|
|
cmd := exec.Command("reg", "query", "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\WindowsUpdate\\Auto Update\\RebootRequired")
|
|
if err := cmd.Run(); err == nil {
|
|
return true, "Windows updates require reboot"
|
|
}
|
|
|
|
// Check Component Based Servicing pending reboot
|
|
cmd = exec.Command("reg", "query", "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing\\RebootPending")
|
|
if err := cmd.Run(); err == nil {
|
|
return true, "Component updates require reboot"
|
|
}
|
|
|
|
return false, ""
|
|
}
|