projection: begin the exact-path public epoch
The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally. Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
26
agent/internal/system/connections.go
Normal file
26
agent/internal/system/connections.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package system
|
||||
|
||||
import "time"
|
||||
|
||||
type ConnectionSnapshot struct {
|
||||
Connections []Connection `json:"connections"`
|
||||
Count int `json:"count"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
type Connection struct {
|
||||
PID int `json:"pid,omitempty"`
|
||||
Process string `json:"process,omitempty"`
|
||||
Protocol string `json:"protocol"`
|
||||
Family string `json:"family"`
|
||||
LocalAddr string `json:"local_addr"`
|
||||
LocalPort int `json:"local_port"`
|
||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||
RemotePort int `json:"remote_port,omitempty"`
|
||||
State string `json:"state"`
|
||||
Inode uint64 `json:"inode"`
|
||||
}
|
||||
|
||||
func GetConnectionsSnapshot() (*ConnectionSnapshot, error) {
|
||||
return getConnectionsSnapshot()
|
||||
}
|
||||
104
agent/internal/system/connections_linux.go
Normal file
104
agent/internal/system/connections_linux.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
//go:build linux
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func getConnectionsSnapshot() (*ConnectionSnapshot, error) {
|
||||
owners := socketOwners()
|
||||
var connections []Connection
|
||||
for _, source := range []struct {
|
||||
path, protocol, family string
|
||||
}{
|
||||
{"/proc/net/tcp", "TCP", "IPv4"},
|
||||
{"/proc/net/tcp6", "TCP", "IPv6"},
|
||||
{"/proc/net/udp", "UDP", "IPv4"},
|
||||
{"/proc/net/udp6", "UDP", "IPv6"},
|
||||
} {
|
||||
data, err := os.ReadFile(source.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for index, line := range strings.Split(string(data), "\n") {
|
||||
if index == 0 || strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
localAddr, localPort := parseHexAddr(fields[1])
|
||||
remoteAddr, remotePort := parseHexAddr(fields[2])
|
||||
inode := atouint64(fields[9])
|
||||
owner := owners[inode]
|
||||
state := fields[3]
|
||||
if source.protocol == "TCP" {
|
||||
state = tcpState(state)
|
||||
}
|
||||
connections = append(connections, Connection{
|
||||
PID: owner.pid, Process: owner.name, Protocol: source.protocol, Family: source.family,
|
||||
LocalAddr: localAddr, LocalPort: localPort, RemoteAddr: remoteAddr,
|
||||
RemotePort: remotePort, State: state, Inode: inode,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(connections) == 0 {
|
||||
if _, err := os.Stat("/proc/net/tcp"); err != nil {
|
||||
return nil, fmt.Errorf("read network connection table: %w", err)
|
||||
}
|
||||
}
|
||||
sort.Slice(connections, func(i, j int) bool {
|
||||
if connections[i].State != connections[j].State {
|
||||
return connections[i].State == "LISTEN"
|
||||
}
|
||||
if connections[i].Process != connections[j].Process {
|
||||
return connections[i].Process < connections[j].Process
|
||||
}
|
||||
return connections[i].LocalPort < connections[j].LocalPort
|
||||
})
|
||||
return &ConnectionSnapshot{Connections: connections, Count: len(connections), CollectedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
|
||||
type socketOwner struct {
|
||||
pid int
|
||||
name string
|
||||
}
|
||||
|
||||
func socketOwners() map[uint64]socketOwner {
|
||||
owners := make(map[uint64]socketOwner)
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return owners
|
||||
}
|
||||
for _, entry := range entries {
|
||||
pid, err := strconv.Atoi(entry.Name())
|
||||
if err != nil || !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
nameBytes, _ := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
|
||||
name := strings.TrimSpace(string(nameBytes))
|
||||
fds, err := os.ReadDir(fmt.Sprintf("/proc/%d/fd", pid))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, fd := range fds {
|
||||
target, err := os.Readlink(fmt.Sprintf("/proc/%d/fd/%s", pid, fd.Name()))
|
||||
if err != nil || !strings.HasPrefix(target, "socket:[") {
|
||||
continue
|
||||
}
|
||||
inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]")
|
||||
value, err := strconv.ParseUint(inode, 10, 64)
|
||||
if err == nil {
|
||||
owners[value] = socketOwner{pid: pid, name: name}
|
||||
}
|
||||
}
|
||||
}
|
||||
return owners
|
||||
}
|
||||
9
agent/internal/system/connections_other.go
Normal file
9
agent/internal/system/connections_other.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//go:build !linux
|
||||
|
||||
package system
|
||||
|
||||
import "fmt"
|
||||
|
||||
func getConnectionsSnapshot() (*ConnectionSnapshot, error) {
|
||||
return nil, fmt.Errorf("connection inventory not supported on this platform")
|
||||
}
|
||||
317
agent/internal/system/device.go
Normal file
317
agent/internal/system/device.go
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
package system
|
||||
|
||||
// device.go — DEVICE-001: device form-factor detection.
|
||||
//
|
||||
// Classifies the host as container / vm / server / desktop / laptop / phone /
|
||||
// tablet. The agent runs as a systemd service, so session environment
|
||||
// (DISPLAY, WAYLAND_DISPLAY) is useless here — every signal is read from
|
||||
// /sys and /proc. The server stores this as device_type; the operator can
|
||||
// override it (device_type_manual), so misclassification is recoverable.
|
||||
//
|
||||
// Layered by signal reliability (DEVICE-001):
|
||||
//
|
||||
// 1. container — PID 1's environment carries container=, or systemd
|
||||
// stamped /run/systemd/container (LXC, LXD, nspawn, docker, podman)
|
||||
// 2. vm — DMI sys_vendor names a hypervisor; Hyper-V hides behind
|
||||
// "Microsoft Corporation" (shared with Surface hardware) and is
|
||||
// matched on product_name instead
|
||||
// 3. SMBIOS chassis_type — the ACPI enum hostnamectl and osquery key
|
||||
// off: laptop / desktop / server / tablet / phone by chassis class
|
||||
// 4. DMI present but chassis unmapped — battery means laptop, not
|
||||
// phone: phones don't ship SMBIOS at all
|
||||
// 5. no DMI (ARM/embedded) — battery × display matrix, phone/tablet
|
||||
// split on the framebuffer's short edge
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// phoneMaxMinDimension is the phone/tablet split on the framebuffer's smaller
|
||||
// dimension in pixels. Phones run 720–1440 on the short edge; tablets start
|
||||
// around 1600. The task's raw width<1024 test misfires on any modern phone
|
||||
// (Pixel 3 is 1080 wide in portrait), so we compare the minimum dimension.
|
||||
const phoneMaxMinDimension = 1440
|
||||
|
||||
// detectPaths carries every filesystem signal the classifier reads, so tests
|
||||
// can point each one at a mock tree.
|
||||
type detectPaths struct {
|
||||
pid1Environ string // /proc/1/environ
|
||||
systemdContainer string // /run/systemd/container
|
||||
sysVendor string // /sys/class/dmi/id/sys_vendor
|
||||
productName string // /sys/class/dmi/id/product_name
|
||||
chassisType string // /sys/class/dmi/id/chassis_type
|
||||
powerSupplyDir string // /sys/class/power_supply
|
||||
drmDir string // /sys/class/drm
|
||||
fbSizePath string // /sys/class/graphics/fb0/virtual_size
|
||||
}
|
||||
|
||||
// DetectDeviceType classifies this host's form factor. Only Linux exposes the
|
||||
// signals we read; other platforms return "" and the server applies its
|
||||
// conservative 'server' default.
|
||||
func DetectDeviceType() string {
|
||||
if runtime.GOOS != "linux" {
|
||||
return ""
|
||||
}
|
||||
return detectDeviceTypeFrom(detectPaths{
|
||||
pid1Environ: "/proc/1/environ",
|
||||
systemdContainer: "/run/systemd/container",
|
||||
sysVendor: "/sys/class/dmi/id/sys_vendor",
|
||||
productName: "/sys/class/dmi/id/product_name",
|
||||
chassisType: "/sys/class/dmi/id/chassis_type",
|
||||
powerSupplyDir: "/sys/class/power_supply",
|
||||
drmDir: "/sys/class/drm",
|
||||
fbSizePath: "/sys/class/graphics/fb0/virtual_size",
|
||||
})
|
||||
}
|
||||
|
||||
func detectDeviceTypeFrom(p detectPaths) string {
|
||||
// A container on a VM (or a laptop host) is still a container — the
|
||||
// agent manages the guest, not the hardware underneath it.
|
||||
if isContainer(p.pid1Environ, p.systemdContainer) {
|
||||
return "container"
|
||||
}
|
||||
if isVM(p.sysVendor, p.productName) {
|
||||
return "vm"
|
||||
}
|
||||
if t := chassisDeviceType(p.chassisType); t != "" {
|
||||
return t
|
||||
}
|
||||
|
||||
battery := hasSystemBattery(p.powerSupplyDir)
|
||||
display := hasDisplay(p.drmDir, p.fbSizePath)
|
||||
|
||||
if hasDMI(p.chassisType, p.sysVendor) {
|
||||
// x86-class firmware with an unknown/other chassis code. A battery
|
||||
// here means laptop (possibly lid-closed, hence display optional);
|
||||
// phones never expose SMBIOS.
|
||||
switch {
|
||||
case battery:
|
||||
return "laptop"
|
||||
case display:
|
||||
return "desktop"
|
||||
}
|
||||
return "server"
|
||||
}
|
||||
|
||||
// No DMI: ARM/embedded (Pixel 3, Raspberry Pi, SBCs).
|
||||
switch {
|
||||
case !battery && !display:
|
||||
return "server"
|
||||
case !battery && display:
|
||||
return "desktop"
|
||||
case battery && !display:
|
||||
return "phone"
|
||||
}
|
||||
|
||||
// battery + display: split phone/tablet on screen size
|
||||
if w, h := framebufferSize(p.fbSizePath); w > 0 && h > 0 {
|
||||
if min(w, h) < phoneMaxMinDimension {
|
||||
return "phone"
|
||||
}
|
||||
return "tablet"
|
||||
}
|
||||
return "phone"
|
||||
}
|
||||
|
||||
// isContainer detects LXC/LXD/nspawn/docker/podman. PID 1's environment
|
||||
// carries container= (readable thanks to the unit's CAP_SYS_PTRACE); when
|
||||
// that read is denied, systemd's own detection at /run/systemd/container is
|
||||
// the fallback.
|
||||
func isContainer(pid1Environ, systemdContainer string) bool {
|
||||
if data, err := os.ReadFile(pid1Environ); err == nil {
|
||||
for _, kv := range strings.Split(string(data), "\x00") {
|
||||
if v, ok := strings.CutPrefix(kv, "container="); ok && v != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile(systemdContainer); err == nil {
|
||||
return strings.TrimSpace(string(data)) != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// vmVendorPrefixes are DMI sys_vendor values only hypervisors emit.
|
||||
// Microsoft is deliberately absent — Surface hardware shares that vendor
|
||||
// string — and Google is absent because Chromebooks report GOOGLE; both are
|
||||
// matched on product_name below.
|
||||
var vmVendorPrefixes = []string{
|
||||
"QEMU", "VMware", "innotek GmbH", "Xen", "Bochs", "Parallels",
|
||||
"Amazon EC2", "DigitalOcean", "OpenStack Foundation", "oVirt",
|
||||
"Nutanix", "Red Hat", "Alibaba Cloud",
|
||||
}
|
||||
|
||||
var vmProductNames = []string{
|
||||
"Virtual Machine", // Hyper-V
|
||||
"Google Compute Engine", // GCE
|
||||
"KVM",
|
||||
"VirtualBox",
|
||||
}
|
||||
|
||||
func isVM(sysVendorPath, productNamePath string) bool {
|
||||
if vendor := readTrimmed(sysVendorPath); vendor != "" {
|
||||
for _, prefix := range vmVendorPrefixes {
|
||||
if strings.HasPrefix(vendor, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if product := readTrimmed(productNamePath); product != "" {
|
||||
for _, name := range vmProductNames {
|
||||
if product == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// chassisDeviceType maps the SMBIOS chassis enum (SMBIOS spec 7.4.1) to a
|
||||
// device type. Unknown/other codes return "" and fall through to the
|
||||
// battery/display layers.
|
||||
func chassisDeviceType(path string) string {
|
||||
code, err := strconv.Atoi(readTrimmed(path))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
switch code {
|
||||
case 8, 9, 10, 14, 31, 32: // portable, laptop, notebook, sub-notebook, convertible, detachable
|
||||
return "laptop"
|
||||
case 3, 4, 5, 6, 7, 13, 15, 16, 24, 34, 35, 36: // desktop through stick PC
|
||||
return "desktop"
|
||||
case 17, 23, 25, 28, 29: // main server, rack mount, multi-system, blade, blade enclosure
|
||||
return "server"
|
||||
case 11: // hand held
|
||||
return "phone"
|
||||
case 30:
|
||||
return "tablet"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hasDMI reports whether SMBIOS/DMI firmware data exists at all. Its absence
|
||||
// is itself a signal: ARM phones and SBCs have device-tree instead.
|
||||
func hasDMI(chassisPath, sysVendorPath string) bool {
|
||||
if _, err := os.Stat(chassisPath); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat(sysVendorPath); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// hasSystemBattery scans /sys/class/power_supply for a system battery.
|
||||
// Peripheral batteries (bluetooth mice, keyboards) advertise scope=Device
|
||||
// and must not classify a desktop as mobile; UPS units report type=UPS.
|
||||
func hasSystemBattery(dir string) bool {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
typ, err := os.ReadFile(filepath.Join(dir, e.Name(), "type"))
|
||||
if err != nil || strings.TrimSpace(string(typ)) != "Battery" {
|
||||
continue
|
||||
}
|
||||
if scope, err := os.ReadFile(filepath.Join(dir, e.Name(), "scope")); err == nil {
|
||||
if strings.TrimSpace(string(scope)) == "Device" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasDisplay reports whether a display is attached: any DRM connector in
|
||||
// state "connected", falling back to a present framebuffer.
|
||||
func hasDisplay(drmDir, fbSizePath string) bool {
|
||||
if entries, err := os.ReadDir(drmDir); err == nil {
|
||||
for _, e := range entries {
|
||||
status, err := os.ReadFile(filepath.Join(drmDir, e.Name(), "status"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(string(status)) == "connected" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(fbSizePath); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// framebufferSize parses /sys/class/graphics/fb0/virtual_size ("1080,2160").
|
||||
func framebufferSize(path string) (int, int) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimSpace(string(data)), ",", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0
|
||||
}
|
||||
w, errW := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
h, errH := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if errW != nil || errH != nil {
|
||||
return 0, 0
|
||||
}
|
||||
return w, h
|
||||
}
|
||||
|
||||
// ReadDeviceModel returns the hardware model: device-tree on ARM, DMI on x86.
|
||||
func ReadDeviceModel() string {
|
||||
return readDeviceModelFrom("/proc/device-tree/model", "/sys/class/dmi/id/product_name")
|
||||
}
|
||||
|
||||
func readDeviceModelFrom(dtPath, dmiPath string) string {
|
||||
if model, err := os.ReadFile(dtPath); err == nil {
|
||||
// device-tree strings are NUL-terminated
|
||||
if s := strings.TrimSpace(strings.Trim(string(model), "\x00")); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if model, err := os.ReadFile(dmiPath); err == nil {
|
||||
if s := strings.TrimSpace(string(model)); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DetectOSDistro returns the distro ID from /etc/os-release ("arch",
|
||||
// "fedora", "debian"). Empty on non-Linux or when unreadable.
|
||||
func DetectOSDistro() string {
|
||||
if runtime.GOOS != "linux" {
|
||||
return ""
|
||||
}
|
||||
return osDistroFrom("/etc/os-release")
|
||||
}
|
||||
|
||||
func osDistroFrom(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "ID=") {
|
||||
return strings.Trim(strings.TrimPrefix(line, "ID="), "\"")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
333
agent/internal/system/device_test.go
Normal file
333
agent/internal/system/device_test.go
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
package system
|
||||
|
||||
// device_test.go — DEVICE-001: form-factor detection against mocked /sys trees.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeSysTree builds a mock tree for every signal the classifier reads. DMI
|
||||
// and container paths point at nonexistent files until a test writes them,
|
||||
// which is exactly the ARM/bare-metal shape.
|
||||
type fakeSysTree struct {
|
||||
pid1Environ string
|
||||
systemdContainer string
|
||||
sysVendor string
|
||||
productName string
|
||||
chassisType string
|
||||
powerDir string
|
||||
drmDir string
|
||||
fbSize string
|
||||
}
|
||||
|
||||
func newFakeSysTree(t *testing.T) *fakeSysTree {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
tree := &fakeSysTree{
|
||||
pid1Environ: filepath.Join(root, "pid1_environ"),
|
||||
systemdContainer: filepath.Join(root, "systemd_container"),
|
||||
sysVendor: filepath.Join(root, "sys_vendor"),
|
||||
productName: filepath.Join(root, "product_name"),
|
||||
chassisType: filepath.Join(root, "chassis_type"),
|
||||
powerDir: filepath.Join(root, "power_supply"),
|
||||
drmDir: filepath.Join(root, "drm"),
|
||||
fbSize: filepath.Join(root, "fb0_virtual_size"),
|
||||
}
|
||||
if err := os.MkdirAll(tree.powerDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(tree.drmDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
func (f *fakeSysTree) addPowerSupply(t *testing.T, name, typ, scope string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(f.powerDir, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "type"), []byte(typ+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scope != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "scope"), []byte(scope+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSysTree) addConnector(t *testing.T, name, status string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(f.drmDir, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "status"), []byte(status+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSysTree) setFramebuffer(t *testing.T, size string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(f.fbSize, []byte(size+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSysTree) writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSysTree) setDMI(t *testing.T, vendor, product, chassis string) {
|
||||
t.Helper()
|
||||
if vendor != "" {
|
||||
f.writeFile(t, f.sysVendor, vendor+"\n")
|
||||
}
|
||||
if product != "" {
|
||||
f.writeFile(t, f.productName, product+"\n")
|
||||
}
|
||||
if chassis != "" {
|
||||
f.writeFile(t, f.chassisType, chassis+"\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSysTree) detect() string {
|
||||
return detectDeviceTypeFrom(detectPaths{
|
||||
pid1Environ: f.pid1Environ,
|
||||
systemdContainer: f.systemdContainer,
|
||||
sysVendor: f.sysVendor,
|
||||
productName: f.productName,
|
||||
chassisType: f.chassisType,
|
||||
powerSupplyDir: f.powerDir,
|
||||
drmDir: f.drmDir,
|
||||
fbSizePath: f.fbSize,
|
||||
})
|
||||
}
|
||||
|
||||
func TestDetectServer(t *testing.T) {
|
||||
tree := newFakeSysTree(t)
|
||||
tree.addPowerSupply(t, "AC", "Mains", "")
|
||||
tree.addConnector(t, "card0-VGA-1", "disconnected")
|
||||
|
||||
if got := tree.detect(); got != "server" {
|
||||
t.Errorf("[ERROR] [agent] [system] no battery, no display: got %q, want server", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectDesktop(t *testing.T) {
|
||||
tree := newFakeSysTree(t)
|
||||
tree.addConnector(t, "card0-HDMI-A-1", "connected")
|
||||
|
||||
if got := tree.detect(); got != "desktop" {
|
||||
t.Errorf("[ERROR] [agent] [system] display, no battery: got %q, want desktop", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectPhonePixel3(t *testing.T) {
|
||||
// Pixel 3 blueline: system battery, panel connected, 1080x2160 portrait
|
||||
// fb, no DMI (device-tree platform)
|
||||
tree := newFakeSysTree(t)
|
||||
tree.addPowerSupply(t, "battery", "Battery", "System")
|
||||
tree.addConnector(t, "card0-DSI-1", "connected")
|
||||
tree.setFramebuffer(t, "1080,2160")
|
||||
|
||||
if got := tree.detect(); got != "phone" {
|
||||
t.Errorf("[ERROR] [agent] [system] Pixel 3 signals: got %q, want phone", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTablet(t *testing.T) {
|
||||
tree := newFakeSysTree(t)
|
||||
tree.addPowerSupply(t, "battery", "Battery", "")
|
||||
tree.addConnector(t, "card0-DSI-1", "connected")
|
||||
tree.setFramebuffer(t, "2560,1600")
|
||||
|
||||
if got := tree.detect(); got != "tablet" {
|
||||
t.Errorf("[ERROR] [agent] [system] large battery-backed display: got %q, want tablet", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectBatteryNoDisplayIsPhone(t *testing.T) {
|
||||
tree := newFakeSysTree(t)
|
||||
tree.addPowerSupply(t, "bms", "Battery", "")
|
||||
|
||||
if got := tree.detect(); got != "phone" {
|
||||
t.Errorf("[ERROR] [agent] [system] battery, no display: got %q, want phone", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeripheralBatteryDoesNotMakeDesktopMobile(t *testing.T) {
|
||||
// Bluetooth mouse battery has scope=Device; desktop must stay desktop
|
||||
tree := newFakeSysTree(t)
|
||||
tree.addPowerSupply(t, "hid-aa:bb-battery", "Battery", "Device")
|
||||
tree.addConnector(t, "card0-DP-1", "connected")
|
||||
|
||||
if got := tree.detect(); got != "desktop" {
|
||||
t.Errorf("[ERROR] [agent] [system] peripheral battery misclassified: got %q, want desktop", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUPSDoesNotMakeServerMobile(t *testing.T) {
|
||||
tree := newFakeSysTree(t)
|
||||
tree.addPowerSupply(t, "ups", "UPS", "")
|
||||
|
||||
if got := tree.detect(); got != "server" {
|
||||
t.Errorf("[ERROR] [agent] [system] UPS misclassified: got %q, want server", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectLaptopChassis(t *testing.T) {
|
||||
// The live regression: Fedora laptop, battery + connected 1080p panel.
|
||||
// Pre-chassis logic classified this as phone (min dimension 1080 < 1440).
|
||||
tree := newFakeSysTree(t)
|
||||
tree.setDMI(t, "Dell Inc.", "Precision 5820", "10")
|
||||
tree.addPowerSupply(t, "BAT0", "Battery", "")
|
||||
tree.addConnector(t, "card0-eDP-1", "connected")
|
||||
tree.setFramebuffer(t, "1920,1080")
|
||||
|
||||
if got := tree.detect(); got != "laptop" {
|
||||
t.Errorf("[ERROR] [agent] [system] chassis 10 notebook: got %q, want laptop", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectDesktopChassis(t *testing.T) {
|
||||
tree := newFakeSysTree(t)
|
||||
tree.setDMI(t, "ASUS", "PRIME B550", "3")
|
||||
|
||||
if got := tree.detect(); got != "desktop" {
|
||||
t.Errorf("[ERROR] [agent] [system] chassis 3 desktop: got %q, want desktop", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectServerChassisRackMount(t *testing.T) {
|
||||
tree := newFakeSysTree(t)
|
||||
tree.setDMI(t, "Supermicro", "X11DPH-T", "23")
|
||||
tree.addConnector(t, "card0-VGA-1", "connected") // BMC display must not flip it to desktop
|
||||
|
||||
if got := tree.detect(); got != "server" {
|
||||
t.Errorf("[ERROR] [agent] [system] chassis 23 rack mount: got %q, want server", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectTabletChassis(t *testing.T) {
|
||||
tree := newFakeSysTree(t)
|
||||
tree.setDMI(t, "LENOVO", "MIIX 520", "30")
|
||||
|
||||
if got := tree.detect(); got != "tablet" {
|
||||
t.Errorf("[ERROR] [agent] [system] chassis 30 tablet: got %q, want tablet", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownChassisWithBatteryIsLaptop(t *testing.T) {
|
||||
// DMI present but chassis "Other" (1): a battery on SMBIOS hardware
|
||||
// means laptop — phones don't have SMBIOS at all.
|
||||
tree := newFakeSysTree(t)
|
||||
tree.setDMI(t, "Framework", "Laptop 13", "1")
|
||||
tree.addPowerSupply(t, "BAT1", "Battery", "")
|
||||
tree.addConnector(t, "card0-eDP-1", "connected")
|
||||
tree.setFramebuffer(t, "2256,1504")
|
||||
|
||||
if got := tree.detect(); got != "laptop" {
|
||||
t.Errorf("[ERROR] [agent] [system] unknown chassis + battery + DMI: got %q, want laptop", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectVMQemu(t *testing.T) {
|
||||
tree := newFakeSysTree(t)
|
||||
tree.setDMI(t, "QEMU", "Standard PC (Q35 + ICH9, 2009)", "1")
|
||||
tree.addConnector(t, "card0-Virtual-1", "connected")
|
||||
|
||||
if got := tree.detect(); got != "vm" {
|
||||
t.Errorf("[ERROR] [agent] [system] QEMU vendor: got %q, want vm", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectVMHyperV(t *testing.T) {
|
||||
tree := newFakeSysTree(t)
|
||||
tree.setDMI(t, "Microsoft Corporation", "Virtual Machine", "3")
|
||||
|
||||
if got := tree.detect(); got != "vm" {
|
||||
t.Errorf("[ERROR] [agent] [system] Hyper-V product name: got %q, want vm", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSurfaceHardwareIsNotVM(t *testing.T) {
|
||||
// Surface shares sys_vendor with Hyper-V guests; product_name is the
|
||||
// disambiguator and the chassis code must win here.
|
||||
tree := newFakeSysTree(t)
|
||||
tree.setDMI(t, "Microsoft Corporation", "Surface Pro 9", "9")
|
||||
tree.addPowerSupply(t, "BAT0", "Battery", "")
|
||||
|
||||
if got := tree.detect(); got != "laptop" {
|
||||
t.Errorf("[ERROR] [agent] [system] Surface misclassified: got %q, want laptop", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectContainerFromPid1Environ(t *testing.T) {
|
||||
tree := newFakeSysTree(t)
|
||||
tree.writeFile(t, tree.pid1Environ, "PATH=/usr/bin\x00container=lxc\x00HOME=/root")
|
||||
// LXC exposes the host's DMI; container must win over vm/chassis
|
||||
tree.setDMI(t, "QEMU", "Standard PC (Q35 + ICH9, 2009)", "1")
|
||||
|
||||
if got := tree.detect(); got != "container" {
|
||||
t.Errorf("[ERROR] [agent] [system] container=lxc in pid1 environ: got %q, want container", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectContainerFromSystemdFile(t *testing.T) {
|
||||
tree := newFakeSysTree(t)
|
||||
tree.writeFile(t, tree.systemdContainer, "lxc\n")
|
||||
|
||||
if got := tree.detect(); got != "container" {
|
||||
t.Errorf("[ERROR] [agent] [system] /run/systemd/container: got %q, want container", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyContainerVarIsNotContainer(t *testing.T) {
|
||||
tree := newFakeSysTree(t)
|
||||
tree.writeFile(t, tree.pid1Environ, "PATH=/usr/bin\x00container=\x00HOME=/root")
|
||||
tree.setDMI(t, "ASUS", "PRIME B550", "3")
|
||||
|
||||
if got := tree.detect(); got != "desktop" {
|
||||
t.Errorf("[ERROR] [agent] [system] empty container= treated as container: got %q, want desktop", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDeviceModelPrefersDeviceTree(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dt := filepath.Join(dir, "model")
|
||||
dmi := filepath.Join(dir, "product_name")
|
||||
if err := os.WriteFile(dt, []byte("Google Pixel 3\x00"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dmi, []byte("Precision 5820\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := readDeviceModelFrom(dt, dmi); got != "Google Pixel 3" {
|
||||
t.Errorf("[ERROR] [agent] [system] got %q, want device-tree model", got)
|
||||
}
|
||||
if got := readDeviceModelFrom(filepath.Join(dir, "missing"), dmi); got != "Precision 5820" {
|
||||
t.Errorf("[ERROR] [agent] [system] got %q, want DMI fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDistroParsesID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
osRelease := filepath.Join(dir, "os-release")
|
||||
content := "NAME=\"Arch Linux ARM\"\nPRETTY_NAME=\"Arch Linux ARM\"\nID=archarm\nID_LIKE=arch\n"
|
||||
if err := os.WriteFile(osRelease, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := osDistroFrom(osRelease); got != "archarm" {
|
||||
t.Errorf("[ERROR] [agent] [system] got %q, want archarm", got)
|
||||
}
|
||||
}
|
||||
658
agent/internal/system/info.go
Normal file
658
agent/internal/system/info.go
Normal file
|
|
@ -0,0 +1,658 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"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"`
|
||||
DeviceType string `json:"device_type"` // "server", "desktop", "laptop", "phone", "tablet", "vm", "container" (DEVICE-001)
|
||||
DeviceModel string `json:"device_model"` // "Google Pixel 3", "Dell PowerEdge R740"
|
||||
OSDistro string `json:"os_distro"` // /etc/os-release ID: "arch", "fedora", "debian"
|
||||
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 for modular storage management
|
||||
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"`
|
||||
IsRoot bool `json:"is_root"` // Primary system disk
|
||||
IsLargest bool `json:"is_largest"` // Largest storage disk
|
||||
DiskType string `json:"disk_type"` // SSD, HDD, NVMe, etc.
|
||||
Device string `json:"device"` // Block device name
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Device classification (DEVICE-001)
|
||||
info.DeviceType = DetectDeviceType()
|
||||
info.DeviceModel = ReadDeviceModel()
|
||||
info.OSDistro = DetectOSDistro()
|
||||
|
||||
// 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")
|
||||
|
||||
// First pass: collect all valid disks
|
||||
var rawDisks []DiskInfo
|
||||
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,
|
||||
Device: 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
|
||||
}
|
||||
|
||||
rawDisks = append(rawDisks, disk)
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: enhance with disk type detection and set flags
|
||||
var largestSize uint64 = 0
|
||||
var largestIndex int = -1
|
||||
|
||||
for i := range rawDisks {
|
||||
// Detect root filesystem
|
||||
if rawDisks[i].Mountpoint == "/" || rawDisks[i].Mountpoint == "C:" {
|
||||
rawDisks[i].IsRoot = true
|
||||
}
|
||||
|
||||
// Track largest disk
|
||||
if rawDisks[i].Total > largestSize {
|
||||
largestSize = rawDisks[i].Total
|
||||
largestIndex = i
|
||||
}
|
||||
|
||||
// Detect disk type
|
||||
rawDisks[i].DiskType = detectDiskType(rawDisks[i].Device)
|
||||
}
|
||||
|
||||
// Set largest disk flag
|
||||
if largestIndex >= 0 {
|
||||
rawDisks[largestIndex].IsLargest = true
|
||||
}
|
||||
|
||||
disks = rawDisks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return disks, nil
|
||||
}
|
||||
|
||||
// detectDiskType determines the type of storage device (SSD, HDD, NVMe, etc.)
|
||||
func detectDiskType(device string) string {
|
||||
if device == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// Extract base device name (remove partition numbers like /dev/sda1 -> /dev/sda)
|
||||
baseDevice := device
|
||||
if strings.Contains(device, "/dev/") {
|
||||
parts := strings.Fields(device)
|
||||
if len(parts) > 0 {
|
||||
baseDevice = parts[0]
|
||||
// Remove partition numbers for common patterns
|
||||
re := strings.NewReplacer("/dev/sda", "/dev/sda", "/dev/sdb", "/dev/sdb", "/dev/nvme0n1", "/dev/nvme0n1")
|
||||
baseDevice = re.Replace(baseDevice)
|
||||
|
||||
// Strip partition number to get base device
|
||||
if matches := regexp.MustCompile(`^(/dev/sd[a-z]|/dev/nvme\d+n\d|/dev/hd[a-z])\d*$`).FindStringSubmatch(baseDevice); len(matches) > 1 {
|
||||
baseDevice = matches[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for NVMe
|
||||
if strings.Contains(baseDevice, "nvme") {
|
||||
return "NVMe"
|
||||
}
|
||||
|
||||
// Check for SSD indicators using lsblk
|
||||
if cmd, err := exec.LookPath("lsblk"); err == nil {
|
||||
if data, err := exec.Command(cmd, "-d", "-o", "rota,NAME", baseDevice).Output(); err == nil {
|
||||
output := string(data)
|
||||
if strings.Contains(output, "0") && strings.Contains(output, baseDevice[strings.LastIndex(baseDevice, "/")+1:]) {
|
||||
return "SSD" // rota=0 indicates non-rotating (SSD)
|
||||
} else if strings.Contains(output, "1") && strings.Contains(output, baseDevice[strings.LastIndex(baseDevice, "/")+1:]) {
|
||||
return "HDD" // rota=1 indicates rotating (HDD)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback detection based on device name patterns
|
||||
if strings.Contains(baseDevice, "sd") || strings.Contains(baseDevice, "hd") {
|
||||
return "HDD" // Traditional naming for SATA/IDE drives
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// parseSize parses human readable size strings (like "1.5G", "500M", "3.7T")
|
||||
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 == "T" || unit == "t" {
|
||||
multiplier = 1024 * 1024 * 1024 * 1024 // Terabyte
|
||||
sizeStr = sizeStr[:len(sizeStr)-1]
|
||||
} else if unit == "G" || unit == "g" {
|
||||
multiplier = 1024 * 1024 * 1024 // Gigabyte
|
||||
sizeStr = sizeStr[:len(sizeStr)-1]
|
||||
} else if unit == "M" || unit == "m" {
|
||||
multiplier = 1024 * 1024 // Megabyte
|
||||
sizeStr = sizeStr[:len(sizeStr)-1]
|
||||
} else if unit == "K" || unit == "k" {
|
||||
multiplier = 1024 // Kilobyte
|
||||
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
|
||||
// Root filesystem disk info (primary disk)
|
||||
DiskUsedGB float64
|
||||
DiskTotalGB float64
|
||||
DiskPercent float64
|
||||
// Largest disk info (for systems with separate data partitions)
|
||||
LargestDiskUsedGB float64
|
||||
LargestDiskTotalGB float64
|
||||
LargestDiskPercent float64
|
||||
LargestDiskMount string
|
||||
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 disk info (both root and largest)
|
||||
if disks, err := getDiskInfo(); err == nil {
|
||||
var rootDisk *DiskInfo
|
||||
var largestDisk *DiskInfo
|
||||
|
||||
for i, disk := range disks {
|
||||
// Find root filesystem
|
||||
if disk.Mountpoint == "/" || disk.Mountpoint == "C:" {
|
||||
rootDisk = &disks[i]
|
||||
}
|
||||
|
||||
// Track largest disk
|
||||
if largestDisk == nil || disk.Total > largestDisk.Total {
|
||||
largestDisk = &disks[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Set root disk metrics (primary disk)
|
||||
if rootDisk != nil {
|
||||
metrics.DiskUsedGB = float64(rootDisk.Used) / (1024 * 1024 * 1024)
|
||||
metrics.DiskTotalGB = float64(rootDisk.Total) / (1024 * 1024 * 1024)
|
||||
metrics.DiskPercent = rootDisk.UsedPercent
|
||||
}
|
||||
|
||||
// Set largest disk metrics (for data partitions like /home)
|
||||
if largestDisk != nil && (rootDisk == nil || largestDisk.Total > rootDisk.Total) {
|
||||
metrics.LargestDiskUsedGB = float64(largestDisk.Used) / (1024 * 1024 * 1024)
|
||||
metrics.LargestDiskTotalGB = float64(largestDisk.Total) / (1024 * 1024 * 1024)
|
||||
metrics.LargestDiskPercent = largestDisk.UsedPercent
|
||||
metrics.LargestDiskMount = largestDisk.Mountpoint
|
||||
}
|
||||
}
|
||||
|
||||
// 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, ""
|
||||
}
|
||||
192
agent/internal/system/machine_id.go
Normal file
192
agent/internal/system/machine_id.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/denisbrodbeck/machineid"
|
||||
)
|
||||
|
||||
// GetMachineID generates a unique machine identifier that persists across reboots
|
||||
func GetMachineID() (string, error) {
|
||||
// Try machineid library first (cross-platform)
|
||||
id, err := machineid.ID()
|
||||
if err == nil && id != "" {
|
||||
// Hash the machine ID for consistency and privacy
|
||||
return hashMachineID(id), nil
|
||||
}
|
||||
|
||||
// Fallback to OS-specific methods
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
return getLinuxMachineID()
|
||||
case "windows":
|
||||
return getWindowsMachineID()
|
||||
case "darwin":
|
||||
return getDarwinMachineID()
|
||||
default:
|
||||
return generateGenericMachineID()
|
||||
}
|
||||
}
|
||||
|
||||
// hashMachineID creates a consistent hash from machine ID
|
||||
func hashMachineID(id string) string {
|
||||
hash := sha256.Sum256([]byte(id))
|
||||
return hex.EncodeToString(hash[:]) // Return full hash for uniqueness
|
||||
}
|
||||
|
||||
// getLinuxMachineID tries multiple sources for Linux machine ID
|
||||
func getLinuxMachineID() (string, error) {
|
||||
// Try /etc/machine-id first (systemd)
|
||||
if id, err := os.ReadFile("/etc/machine-id"); err == nil {
|
||||
idStr := strings.TrimSpace(string(id))
|
||||
if idStr != "" {
|
||||
return hashMachineID(idStr), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try /var/lib/dbus/machine-id
|
||||
if id, err := os.ReadFile("/var/lib/dbus/machine-id"); err == nil {
|
||||
idStr := strings.TrimSpace(string(id))
|
||||
if idStr != "" {
|
||||
return hashMachineID(idStr), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try DMI product UUID
|
||||
if id, err := os.ReadFile("/sys/class/dmi/id/product_uuid"); err == nil {
|
||||
idStr := strings.TrimSpace(string(id))
|
||||
if idStr != "" {
|
||||
return hashMachineID(idStr), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ARM: device-tree model, combined with /etc/machine-id when present.
|
||||
// ARM hardware has no DMI; device-tree model binds to the hardware and
|
||||
// /etc/machine-id distinguishes OS installs (dual-boot gets distinct IDs).
|
||||
if id := getDeviceTreeMachineID(); id != "" {
|
||||
return hashMachineID(id), nil
|
||||
}
|
||||
|
||||
// ARM: SoC serial from /proc/cpuinfo (not all SoCs expose one)
|
||||
if serial := getARMSerial(); serial != "" {
|
||||
return hashMachineID(serial), nil
|
||||
}
|
||||
|
||||
// Try /etc/hostname as last resort
|
||||
if hostname, err := os.ReadFile("/etc/hostname"); err == nil {
|
||||
hostnameStr := strings.TrimSpace(string(hostname))
|
||||
if hostnameStr != "" {
|
||||
return hashMachineID(hostnameStr + "-linux-fallback"), nil
|
||||
}
|
||||
}
|
||||
|
||||
return generateGenericMachineID()
|
||||
}
|
||||
|
||||
// getDeviceTreeMachineID builds an ID source from /proc/device-tree/model
|
||||
// plus /etc/machine-id. Returns "" when device-tree is absent (x86, containers).
|
||||
func getDeviceTreeMachineID() string {
|
||||
return deviceTreeMachineIDFrom("/proc/device-tree/model", "/etc/machine-id")
|
||||
}
|
||||
|
||||
func deviceTreeMachineIDFrom(modelPath, machineIDPath string) string {
|
||||
model, err := os.ReadFile(modelPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// device-tree strings are NUL-terminated
|
||||
modelStr := strings.TrimSpace(strings.Trim(string(model), "\x00"))
|
||||
if modelStr == "" {
|
||||
return ""
|
||||
}
|
||||
if id, err := os.ReadFile(machineIDPath); err == nil {
|
||||
if idStr := strings.TrimSpace(string(id)); idStr != "" {
|
||||
return modelStr + ":" + idStr
|
||||
}
|
||||
}
|
||||
// Hardware-bound but not install-unique; still better than hostname
|
||||
return modelStr + ":arm-device"
|
||||
}
|
||||
|
||||
// getARMSerial reads a SoC serial number from /proc/cpuinfo when exposed.
|
||||
func getARMSerial() string {
|
||||
return armSerialFrom("/proc/cpuinfo")
|
||||
}
|
||||
|
||||
func armSerialFrom(cpuinfoPath string) string {
|
||||
data, err := os.ReadFile(cpuinfoPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if !strings.HasPrefix(line, "Serial") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
serial := strings.TrimSpace(parts[1])
|
||||
// All-zero serials are placeholders, not identifiers
|
||||
if serial != "" && strings.Trim(serial, "0") != "" {
|
||||
return "arm-serial:" + serial
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getWindowsMachineID gets Windows machine ID.
|
||||
// Note: The primary machineid.ID() in GetMachineID() already tried the
|
||||
// registry key HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid.
|
||||
// If it failed, retrying returns the same error — go straight to generic.
|
||||
// On Windows reinstall, MachineGuid changes. Use the rebind endpoint
|
||||
// (POST /admin/agents/:id/rebind-machine-id) to recover without re-registration.
|
||||
func getWindowsMachineID() (string, error) {
|
||||
return generateGenericMachineID()
|
||||
}
|
||||
|
||||
// getDarwinMachineID gets macOS machine ID
|
||||
func getDarwinMachineID() (string, error) {
|
||||
// Try machineid library platform-specific keys first
|
||||
if id, err := machineid.ID(); err == nil && id != "" {
|
||||
return hashMachineID(id), nil
|
||||
}
|
||||
|
||||
// Fallback to generating generic ID
|
||||
return generateGenericMachineID()
|
||||
}
|
||||
|
||||
// generateGenericMachineID creates a fallback machine ID from available system info
|
||||
func generateGenericMachineID() (string, error) {
|
||||
// Combine hostname with other available info
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "unknown"
|
||||
}
|
||||
|
||||
// Create a reasonably unique ID from available system info
|
||||
idSource := fmt.Sprintf("%s-%s-%s", hostname, runtime.GOOS, runtime.GOARCH)
|
||||
return hashMachineID(idSource), nil
|
||||
}
|
||||
|
||||
// GetEmbeddedPublicKey returns the embedded public key fingerprint
|
||||
// This should be set at build time using ldflags
|
||||
var EmbeddedPublicKey = "not-set-at-build-time"
|
||||
|
||||
// GetPublicKeyFingerprint returns the fingerprint of the embedded public key
|
||||
func GetPublicKeyFingerprint() string {
|
||||
if EmbeddedPublicKey == "not-set-at-build-time" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Return first 8 bytes as fingerprint
|
||||
if len(EmbeddedPublicKey) >= 16 {
|
||||
return EmbeddedPublicKey[:16]
|
||||
}
|
||||
return EmbeddedPublicKey
|
||||
}
|
||||
83
agent/internal/system/machine_id_arm_test.go
Normal file
83
agent/internal/system/machine_id_arm_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package system
|
||||
|
||||
// machine_id_arm_test.go — DEVICE-002: ARM machine ID fallback.
|
||||
//
|
||||
// ARM devices (phones, SBCs) have no /sys/class/dmi/id/product_uuid. The
|
||||
// fallback chain gains two hardware-bound sources before the weak hostname
|
||||
// fallback: device-tree model + /etc/machine-id, then /proc/cpuinfo Serial.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTempFile(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestDeviceTreeMachineIDCombinesModelAndMachineID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// device-tree model strings are NUL-terminated
|
||||
model := writeTempFile(t, dir, "model", "Google Pixel 3\x00")
|
||||
machineID := writeTempFile(t, dir, "machine-id", "abcdef0123456789abcdef0123456789\n")
|
||||
|
||||
got := deviceTreeMachineIDFrom(model, machineID)
|
||||
want := "Google Pixel 3:abcdef0123456789abcdef0123456789"
|
||||
if got != want {
|
||||
t.Errorf("[ERROR] [agent] [system] got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceTreeMachineIDWithoutMachineIDIsHardwareBound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
model := writeTempFile(t, dir, "model", "Raspberry Pi 4 Model B\x00")
|
||||
|
||||
got := deviceTreeMachineIDFrom(model, filepath.Join(dir, "missing"))
|
||||
want := "Raspberry Pi 4 Model B:arm-device"
|
||||
if got != want {
|
||||
t.Errorf("[ERROR] [agent] [system] got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceTreeMachineIDAbsentOnX86(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if got := deviceTreeMachineIDFrom(filepath.Join(dir, "missing"), filepath.Join(dir, "machine-id")); got != "" {
|
||||
t.Errorf("[ERROR] [agent] [system] expected empty on missing device-tree, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARMSerialParsed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cpuinfo := writeTempFile(t, dir, "cpuinfo",
|
||||
"processor\t: 0\nHardware\t: Qualcomm Technologies, Inc SDM845\nSerial\t\t: 0000000012345abc\n")
|
||||
|
||||
got := armSerialFrom(cpuinfo)
|
||||
want := "arm-serial:0000000012345abc"
|
||||
if got != want {
|
||||
t.Errorf("[ERROR] [agent] [system] got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARMSerialRejectsAllZero(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cpuinfo := writeTempFile(t, dir, "cpuinfo", "Serial\t\t: 0000000000000000\n")
|
||||
|
||||
if got := armSerialFrom(cpuinfo); got != "" {
|
||||
t.Errorf("[ERROR] [agent] [system] all-zero serial must be rejected, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARMSerialAbsent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cpuinfo := writeTempFile(t, dir, "cpuinfo", "processor\t: 0\nmodel name\t: Intel Xeon\n")
|
||||
|
||||
if got := armSerialFrom(cpuinfo); got != "" {
|
||||
t.Errorf("[ERROR] [agent] [system] expected empty without Serial line, got %q", got)
|
||||
}
|
||||
}
|
||||
111
agent/internal/system/machine_id_fallback_test.go
Normal file
111
agent/internal/system/machine_id_fallback_test.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package system
|
||||
|
||||
// machine_id_fallback_test.go — Tests for machine ID registration fallback.
|
||||
//
|
||||
// F-D1-1 FIXED: Registration no longer uses unhashed "unknown-" fallback.
|
||||
// GetMachineID() is trusted as canonical; registration aborts if it fails.
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistrationFallbackIsNotHashed(t *testing.T) {
|
||||
// POST-FIX: "unknown-" fallback is gone from main.go.
|
||||
mainPath := filepath.Join("..", "..", "cmd", "agent", "main.go")
|
||||
content, err := os.ReadFile(mainPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read main.go: %v", err)
|
||||
}
|
||||
|
||||
src := string(content)
|
||||
|
||||
if strings.Contains(src, `"unknown-"`) {
|
||||
t.Error("[ERROR] [agent] [system] F-D1-1 NOT FIXED: 'unknown-' fallback still present")
|
||||
}
|
||||
|
||||
t.Log("[INFO] [agent] [system] F-D1-1 FIXED: unhashed fallback removed from registration")
|
||||
}
|
||||
|
||||
func TestRegistrationFallbackUsesCanonicalFunction(t *testing.T) {
|
||||
// POST-FIX: Registration uses system.GetMachineID() with no inline fallback.
|
||||
// The registration flow lives in internal/registration/service.go, which
|
||||
// calls the canonical function and aborts if it fails (no "unknown-" path).
|
||||
regPath := filepath.Join("..", "registration", "service.go")
|
||||
content, err := os.ReadFile(regPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read registration/service.go: %v", err)
|
||||
}
|
||||
|
||||
src := string(content)
|
||||
|
||||
if strings.Contains(src, `"unknown-"`) {
|
||||
t.Errorf("[ERROR] [agent] [system] registration still uses 'unknown-' fallback.\n" +
|
||||
"F-D1-1: must use canonical GetMachineID() or abort.")
|
||||
}
|
||||
|
||||
if !strings.Contains(src, "system.GetMachineID()") {
|
||||
t.Error("[ERROR] [agent] [system] registration doesn't call system.GetMachineID()")
|
||||
}
|
||||
|
||||
t.Log("[INFO] [agent] [system] F-D1-1 FIXED: registration uses canonical function only")
|
||||
}
|
||||
|
||||
func TestMachineIDIsAlways64HexChars(t *testing.T) {
|
||||
id, err := GetMachineID()
|
||||
if err != nil {
|
||||
t.Fatalf("GetMachineID failed: %v", err)
|
||||
}
|
||||
|
||||
if len(id) != 64 {
|
||||
t.Errorf("[ERROR] [agent] [system] machine ID is %d chars, expected 64", len(id))
|
||||
}
|
||||
|
||||
if _, err := hex.DecodeString(id); err != nil {
|
||||
t.Errorf("[ERROR] [agent] [system] machine ID is not valid hex: %v", err)
|
||||
}
|
||||
|
||||
// Idempotency check
|
||||
id2, _ := GetMachineID()
|
||||
if id != id2 {
|
||||
t.Error("[ERROR] [agent] [system] GetMachineID not idempotent")
|
||||
}
|
||||
|
||||
t.Logf("[INFO] [agent] [system] canonical machine ID: %s... (64 hex chars)", id[:16])
|
||||
}
|
||||
|
||||
func TestRegistrationAndRuntimeUseSameCodePath(t *testing.T) {
|
||||
// POST-FIX: registration and runtime both resolve the machine ID through the
|
||||
// canonical system.GetMachineID(), with no divergent "unknown-" fallback.
|
||||
// Registration: internal/registration/service.go. Runtime: internal/client/client.go.
|
||||
regPath := filepath.Join("..", "registration", "service.go")
|
||||
regContent, err := os.ReadFile(regPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read registration/service.go: %v", err)
|
||||
}
|
||||
|
||||
clientPath := filepath.Join("..", "client", "client.go")
|
||||
clientContent, err := os.ReadFile(clientPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read client.go: %v", err)
|
||||
}
|
||||
|
||||
regSrc := string(regContent)
|
||||
clientSrc := string(clientContent)
|
||||
|
||||
if !strings.Contains(regSrc, "system.GetMachineID()") {
|
||||
t.Error("[ERROR] [agent] [system] registration doesn't call system.GetMachineID()")
|
||||
}
|
||||
if !strings.Contains(clientSrc, "system.GetMachineID()") {
|
||||
t.Error("[ERROR] [agent] [system] client.go doesn't call system.GetMachineID()")
|
||||
}
|
||||
|
||||
if strings.Contains(regSrc, `"unknown-"`) {
|
||||
t.Errorf("[ERROR] [agent] [system] registration has divergent 'unknown-' fallback")
|
||||
}
|
||||
|
||||
t.Log("[INFO] [agent] [system] F-D1-1 FIXED: both paths use canonical GetMachineID()")
|
||||
}
|
||||
96
agent/internal/system/machine_id_format_test.go
Normal file
96
agent/internal/system/machine_id_format_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package system
|
||||
|
||||
// machine_id_format_test.go — Pre-fix tests for machine ID format consistency.
|
||||
//
|
||||
// F-D1-1: All machine ID paths in GetMachineID() produce consistent format.
|
||||
// The divergence is only in main.go's inline fallback.
|
||||
//
|
||||
// Run: cd agent && go test ./internal/system/... -v -run TestHash
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2.1 — All canonical fallbacks produce same format (documentary)
|
||||
//
|
||||
// Category: PASS-NOW (canonical function is internally consistent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAllMachineIDFallbacksProduceSameFormat(t *testing.T) {
|
||||
// F-D1-1: The canonical GetMachineID() is internally consistent.
|
||||
// All paths call hashMachineID(). The divergence is in main.go:428.
|
||||
|
||||
// Test the generic fallback directly
|
||||
genericID, err := generateGenericMachineID()
|
||||
if err != nil {
|
||||
t.Fatalf("generateGenericMachineID failed: %v", err)
|
||||
}
|
||||
|
||||
if len(genericID) != 64 {
|
||||
t.Errorf("[ERROR] [agent] [system] generic fallback is %d chars, expected 64", len(genericID))
|
||||
}
|
||||
if _, err := hex.DecodeString(genericID); err != nil {
|
||||
t.Errorf("[ERROR] [agent] [system] generic fallback is not valid hex: %v", err)
|
||||
}
|
||||
|
||||
t.Log("[INFO] [agent] [system] F-D1-1: all canonical fallbacks produce 64 hex chars")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2.2 — hashMachineID always produces 64 hex chars
|
||||
//
|
||||
// Category: PASS-NOW (hash function is correct)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHashMachineIDAlwaysProduces64HexChars(t *testing.T) {
|
||||
// F-D1-1: hashMachineID() is correctly implemented.
|
||||
// The bug is that main.go bypasses it.
|
||||
inputs := []string{
|
||||
"",
|
||||
"a",
|
||||
strings.Repeat("x", 256),
|
||||
"special chars: !@#$%^&*()",
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
}
|
||||
|
||||
for _, input := range inputs {
|
||||
result := hashMachineID(input)
|
||||
if len(result) != 64 {
|
||||
t.Errorf("[ERROR] [agent] [system] hashMachineID(%q) = %d chars, expected 64", input, len(result))
|
||||
}
|
||||
if _, err := hex.DecodeString(result); err != nil {
|
||||
t.Errorf("[ERROR] [agent] [system] hashMachineID(%q) not valid hex: %v", input, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determinism check
|
||||
if hashMachineID("test") != hashMachineID("test") {
|
||||
t.Error("[ERROR] [agent] [system] hashMachineID is not deterministic")
|
||||
}
|
||||
|
||||
t.Log("[INFO] [agent] [system] hashMachineID: all inputs produce 64 hex, deterministic")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2.3 — "unknown-" fallback format differs from hash format
|
||||
//
|
||||
// Category: PASS-NOW (documents the incompatibility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUnknownFallbackFormatDifferentFromHash(t *testing.T) {
|
||||
// POST-FIX (F-D1-1): The "unknown-" fallback no longer exists in
|
||||
// registration code. This test confirms canonical paths always hash.
|
||||
canonical := hashMachineID("testhost-linux-fallback")
|
||||
generic, _ := generateGenericMachineID()
|
||||
|
||||
// Both canonical paths produce 64 hex chars
|
||||
if len(canonical) != 64 || len(generic) != 64 {
|
||||
t.Errorf("[ERROR] [agent] [system] canonical paths not 64 chars: canonical=%d generic=%d",
|
||||
len(canonical), len(generic))
|
||||
}
|
||||
|
||||
t.Log("[INFO] [agent] [system] F-D1-1 FIXED: all machine ID paths produce consistent 64 hex format")
|
||||
}
|
||||
88
agent/internal/system/machine_id_winpath_test.go
Normal file
88
agent/internal/system/machine_id_winpath_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package system
|
||||
|
||||
// machine_id_windows_test.go — Pre-fix tests for Windows machine ID redundancy.
|
||||
// [SHARED] — no build tag, source inspection only.
|
||||
//
|
||||
// F-D1-4 LOW: getWindowsMachineID() redundantly retries machineid.ID().
|
||||
//
|
||||
// Run: cd agent && go test ./internal/system/... -v -run TestWindows
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 5.1 — Documents redundant retry (F-D1-4)
|
||||
//
|
||||
// Category: PASS-NOW (documents the redundancy)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWindowsFallbackHasRedundantRetry(t *testing.T) {
|
||||
// POST-FIX (F-D1-4): Redundant retry removed.
|
||||
content, err := os.ReadFile("machine_id.go")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read machine_id.go: %v", err)
|
||||
}
|
||||
|
||||
src := string(content)
|
||||
|
||||
winIdx := strings.Index(src, "func getWindowsMachineID()")
|
||||
if winIdx == -1 {
|
||||
t.Fatal("[ERROR] [agent] [system] getWindowsMachineID not found")
|
||||
}
|
||||
|
||||
fnBody := src[winIdx:]
|
||||
nextFn := strings.Index(fnBody[1:], "\nfunc ")
|
||||
if nextFn > 0 {
|
||||
fnBody = fnBody[:nextFn+1]
|
||||
}
|
||||
|
||||
count := strings.Count(fnBody, "machineid.ID()")
|
||||
if count > 0 {
|
||||
t.Errorf("[ERROR] [agent] [system] F-D1-4 NOT FIXED: machineid.ID() still in Windows fallback (%d calls)", count)
|
||||
}
|
||||
|
||||
t.Log("[INFO] [agent] [system] F-D1-4 FIXED: redundant retry removed from Windows fallback")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 5.2 — Windows fallback should use alternative sources (assert fix)
|
||||
//
|
||||
// Category: FAIL-NOW / PASS-AFTER-FIX
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWindowsFallbackUsesAlternativeSources(t *testing.T) {
|
||||
// F-D1-4: After fix, either remove the redundant retry or
|
||||
// check an alternative Windows source.
|
||||
content, err := os.ReadFile("machine_id.go")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read machine_id.go: %v", err)
|
||||
}
|
||||
|
||||
src := string(content)
|
||||
|
||||
winIdx := strings.Index(src, "func getWindowsMachineID()")
|
||||
if winIdx == -1 {
|
||||
t.Fatal("[ERROR] [agent] [system] getWindowsMachineID not found")
|
||||
}
|
||||
|
||||
fnBody := src[winIdx:]
|
||||
nextFn := strings.Index(fnBody[1:], "\nfunc ")
|
||||
if nextFn > 0 {
|
||||
fnBody = fnBody[:nextFn+1]
|
||||
}
|
||||
|
||||
// Should NOT have machineid.ID() (redundant retry removed)
|
||||
// OR should have an alternative source
|
||||
hasRedundantRetry := strings.Count(fnBody, "machineid.ID()") > 0
|
||||
hasAlternative := strings.Contains(fnBody, "Registry") ||
|
||||
strings.Contains(fnBody, "WMI") ||
|
||||
strings.Contains(fnBody, "InstallDate")
|
||||
|
||||
if hasRedundantRetry && !hasAlternative {
|
||||
t.Errorf("[ERROR] [agent] [system] Windows fallback has redundant machineid.ID() retry.\n" +
|
||||
"F-D1-4: remove the retry or add an alternative source.")
|
||||
}
|
||||
}
|
||||
297
agent/internal/system/monitor.go
Normal file
297
agent/internal/system/monitor.go
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrMonitorUnavailable = errors.New("system resource monitor unavailable")
|
||||
|
||||
// ResourceMonitor owns the bounded, live machine history consumed by Desktop.
|
||||
// Collection stays in the Agent: UIs receive observations and express intent;
|
||||
// they never learn a second way to inspect or mutate the machine.
|
||||
type ResourceMonitor struct {
|
||||
mu sync.RWMutex
|
||||
interval time.Duration
|
||||
capacity int
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
started bool
|
||||
previous *rawMonitorSample
|
||||
latest *ResourceSnapshot
|
||||
lastErr error
|
||||
history []ResourcePoint
|
||||
}
|
||||
|
||||
type ResourceSnapshot struct {
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
IntervalMS int64 `json:"interval_ms"`
|
||||
CPU CPUMetrics `json:"cpu"`
|
||||
Memory MemoryMetrics `json:"memory"`
|
||||
Network NetworkMetrics `json:"network"`
|
||||
Storage StorageMetrics `json:"storage"`
|
||||
Thermals []ThermalReading `json:"thermals,omitempty"`
|
||||
History []ResourcePoint `json:"history"`
|
||||
}
|
||||
|
||||
type CPUMetrics struct {
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
UserPercent float64 `json:"user_percent"`
|
||||
SystemPercent float64 `json:"system_percent"`
|
||||
IOWaitPercent float64 `json:"iowait_percent"`
|
||||
Load1 float64 `json:"load_1"`
|
||||
Load5 float64 `json:"load_5"`
|
||||
Load15 float64 `json:"load_15"`
|
||||
PerCore []CoreUsage `json:"per_core"`
|
||||
}
|
||||
|
||||
type CoreUsage struct {
|
||||
Core int `json:"core"`
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
type MemoryMetrics struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
AvailableBytes uint64 `json:"available_bytes"`
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
SwapTotalBytes uint64 `json:"swap_total_bytes"`
|
||||
SwapUsedBytes uint64 `json:"swap_used_bytes"`
|
||||
SwapPercent float64 `json:"swap_percent"`
|
||||
}
|
||||
|
||||
type NetworkMetrics struct {
|
||||
ReceiveBytesPerSecond float64 `json:"receive_bytes_per_second"`
|
||||
TransmitBytesPerSecond float64 `json:"transmit_bytes_per_second"`
|
||||
ReceiveBytes uint64 `json:"receive_bytes"`
|
||||
TransmitBytes uint64 `json:"transmit_bytes"`
|
||||
Interfaces []NetworkInterface `json:"interfaces"`
|
||||
}
|
||||
|
||||
type NetworkInterface struct {
|
||||
Name string `json:"name"`
|
||||
ReceiveBytesPerSecond float64 `json:"receive_bytes_per_second"`
|
||||
TransmitBytesPerSecond float64 `json:"transmit_bytes_per_second"`
|
||||
ReceiveBytes uint64 `json:"receive_bytes"`
|
||||
TransmitBytes uint64 `json:"transmit_bytes"`
|
||||
}
|
||||
|
||||
type StorageMetrics struct {
|
||||
ReadBytesPerSecond float64 `json:"read_bytes_per_second"`
|
||||
WriteBytesPerSecond float64 `json:"write_bytes_per_second"`
|
||||
ReadBytes uint64 `json:"read_bytes"`
|
||||
WriteBytes uint64 `json:"write_bytes"`
|
||||
}
|
||||
|
||||
type ThermalReading struct {
|
||||
Name string `json:"name"`
|
||||
Celsius float64 `json:"celsius"`
|
||||
}
|
||||
|
||||
type ResourcePoint struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryPercent float64 `json:"memory_percent"`
|
||||
NetworkReceivePerSec float64 `json:"network_receive_per_second"`
|
||||
NetworkTransmitPerSec float64 `json:"network_transmit_per_second"`
|
||||
DiskReadPerSec float64 `json:"disk_read_per_second"`
|
||||
DiskWritePerSec float64 `json:"disk_write_per_second"`
|
||||
}
|
||||
|
||||
type cpuCounters struct {
|
||||
User, Nice, System, Idle, IOWait, IRQ, SoftIRQ, Steal uint64
|
||||
}
|
||||
|
||||
func (c cpuCounters) total() uint64 {
|
||||
return c.User + c.Nice + c.System + c.Idle + c.IOWait + c.IRQ + c.SoftIRQ + c.Steal
|
||||
}
|
||||
|
||||
func (c cpuCounters) idle() uint64 { return c.Idle + c.IOWait }
|
||||
|
||||
type byteCounters struct{ Receive, Transmit uint64 }
|
||||
|
||||
type rawMonitorSample struct {
|
||||
at time.Time
|
||||
cpu cpuCounters
|
||||
cores []cpuCounters
|
||||
load [3]float64
|
||||
memory MemoryMetrics
|
||||
network map[string]byteCounters
|
||||
diskRead uint64
|
||||
diskWrite uint64
|
||||
thermals []ThermalReading
|
||||
}
|
||||
|
||||
func NewResourceMonitor(interval time.Duration, capacity int) *ResourceMonitor {
|
||||
if interval <= 0 {
|
||||
interval = time.Second
|
||||
}
|
||||
if capacity <= 0 {
|
||||
capacity = 300
|
||||
}
|
||||
return &ResourceMonitor{
|
||||
interval: interval,
|
||||
capacity: capacity,
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ResourceMonitor) Start() {
|
||||
m.mu.Lock()
|
||||
if m.started {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.started = true
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer close(m.done)
|
||||
m.collect()
|
||||
ticker := time.NewTicker(m.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
m.collect()
|
||||
case <-m.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *ResourceMonitor) Stop() {
|
||||
m.mu.Lock()
|
||||
if !m.started {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.started = false
|
||||
close(m.stop)
|
||||
m.mu.Unlock()
|
||||
<-m.done
|
||||
}
|
||||
|
||||
func (m *ResourceMonitor) Snapshot() (*ResourceSnapshot, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.latest == nil {
|
||||
if m.lastErr != nil {
|
||||
return nil, m.lastErr
|
||||
}
|
||||
return nil, ErrMonitorUnavailable
|
||||
}
|
||||
copy := *m.latest
|
||||
copy.CPU.PerCore = append([]CoreUsage(nil), m.latest.CPU.PerCore...)
|
||||
copy.Network.Interfaces = append([]NetworkInterface(nil), m.latest.Network.Interfaces...)
|
||||
copy.Thermals = append([]ThermalReading(nil), m.latest.Thermals...)
|
||||
copy.History = append([]ResourcePoint(nil), m.history...)
|
||||
return ©, nil
|
||||
}
|
||||
|
||||
func (m *ResourceMonitor) collect() {
|
||||
raw, err := readRawMonitorSample()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err != nil {
|
||||
m.lastErr = err
|
||||
return
|
||||
}
|
||||
|
||||
snapshot := snapshotFromRaw(raw, m.previous, m.interval)
|
||||
m.previous = raw
|
||||
m.lastErr = nil
|
||||
point := ResourcePoint{
|
||||
Timestamp: snapshot.CollectedAt,
|
||||
CPUPercent: snapshot.CPU.UsagePercent,
|
||||
MemoryPercent: snapshot.Memory.UsedPercent,
|
||||
NetworkReceivePerSec: snapshot.Network.ReceiveBytesPerSecond,
|
||||
NetworkTransmitPerSec: snapshot.Network.TransmitBytesPerSecond,
|
||||
DiskReadPerSec: snapshot.Storage.ReadBytesPerSecond,
|
||||
DiskWritePerSec: snapshot.Storage.WriteBytesPerSecond,
|
||||
}
|
||||
m.history = append(m.history, point)
|
||||
if len(m.history) > m.capacity {
|
||||
copy(m.history, m.history[len(m.history)-m.capacity:])
|
||||
m.history = m.history[:m.capacity]
|
||||
}
|
||||
snapshot.History = append([]ResourcePoint(nil), m.history...)
|
||||
m.latest = snapshot
|
||||
}
|
||||
|
||||
func snapshotFromRaw(now, previous *rawMonitorSample, fallback time.Duration) *ResourceSnapshot {
|
||||
elapsed := fallback.Seconds()
|
||||
if previous != nil {
|
||||
elapsed = now.at.Sub(previous.at).Seconds()
|
||||
}
|
||||
if elapsed <= 0 {
|
||||
elapsed = 1
|
||||
}
|
||||
|
||||
cpu := CPUMetrics{Load1: now.load[0], Load5: now.load[1], Load15: now.load[2]}
|
||||
if previous != nil {
|
||||
cpu.UsagePercent, cpu.UserPercent, cpu.SystemPercent, cpu.IOWaitPercent = cpuDelta(now.cpu, previous.cpu)
|
||||
for index, counters := range now.cores {
|
||||
usage := 0.0
|
||||
if index < len(previous.cores) {
|
||||
usage, _, _, _ = cpuDelta(counters, previous.cores[index])
|
||||
}
|
||||
cpu.PerCore = append(cpu.PerCore, CoreUsage{Core: index, UsagePercent: usage})
|
||||
}
|
||||
}
|
||||
|
||||
network := NetworkMetrics{}
|
||||
for name, counters := range now.network {
|
||||
iface := NetworkInterface{Name: name, ReceiveBytes: counters.Receive, TransmitBytes: counters.Transmit}
|
||||
if previous != nil {
|
||||
before := previous.network[name]
|
||||
iface.ReceiveBytesPerSecond = perSecond(counters.Receive, before.Receive, elapsed)
|
||||
iface.TransmitBytesPerSecond = perSecond(counters.Transmit, before.Transmit, elapsed)
|
||||
}
|
||||
network.Interfaces = append(network.Interfaces, iface)
|
||||
if name != "lo" {
|
||||
network.ReceiveBytes += counters.Receive
|
||||
network.TransmitBytes += counters.Transmit
|
||||
network.ReceiveBytesPerSecond += iface.ReceiveBytesPerSecond
|
||||
network.TransmitBytesPerSecond += iface.TransmitBytesPerSecond
|
||||
}
|
||||
}
|
||||
|
||||
storage := StorageMetrics{ReadBytes: now.diskRead, WriteBytes: now.diskWrite}
|
||||
if previous != nil {
|
||||
storage.ReadBytesPerSecond = perSecond(now.diskRead, previous.diskRead, elapsed)
|
||||
storage.WriteBytesPerSecond = perSecond(now.diskWrite, previous.diskWrite, elapsed)
|
||||
}
|
||||
|
||||
return &ResourceSnapshot{
|
||||
CollectedAt: now.at,
|
||||
IntervalMS: int64(elapsed * 1000),
|
||||
CPU: cpu,
|
||||
Memory: now.memory,
|
||||
Network: network,
|
||||
Storage: storage,
|
||||
Thermals: now.thermals,
|
||||
}
|
||||
}
|
||||
|
||||
func cpuDelta(now, before cpuCounters) (usage, user, system, iowait float64) {
|
||||
total := now.total() - before.total()
|
||||
if total == 0 {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
percent := func(delta uint64) float64 { return float64(delta) * 100 / float64(total) }
|
||||
return percent(total - (now.idle() - before.idle())),
|
||||
percent((now.User + now.Nice) - (before.User + before.Nice)),
|
||||
percent((now.System + now.IRQ + now.SoftIRQ) - (before.System + before.IRQ + before.SoftIRQ)),
|
||||
percent(now.IOWait - before.IOWait)
|
||||
}
|
||||
|
||||
func perSecond(now, before uint64, elapsed float64) float64 {
|
||||
if now < before || elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(now-before) / elapsed
|
||||
}
|
||||
205
agent/internal/system/monitor_linux.go
Normal file
205
agent/internal/system/monitor_linux.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
//go:build linux
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func readRawMonitorSample() (*rawMonitorSample, error) {
|
||||
cpu, cores, err := readCPUCounters("/proc/stat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memory, err := readMemoryMetrics("/proc/meminfo")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
load, _ := readLoadAverage("/proc/loadavg")
|
||||
network, _ := readNetworkCounters("/proc/net/dev")
|
||||
diskRead, diskWrite := readDiskCounters()
|
||||
return &rawMonitorSample{
|
||||
at: time.Now().UTC(),
|
||||
cpu: cpu,
|
||||
cores: cores,
|
||||
load: load,
|
||||
memory: memory,
|
||||
network: network,
|
||||
diskRead: diskRead,
|
||||
diskWrite: diskWrite,
|
||||
thermals: readThermals(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readCPUCounters(path string) (cpuCounters, []cpuCounters, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return cpuCounters{}, nil, fmt.Errorf("read cpu counters: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
var total cpuCounters
|
||||
var cores []cpuCounters
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 5 || !strings.HasPrefix(fields[0], "cpu") {
|
||||
if len(cores) > 0 {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
counters := parseCPUFields(fields[1:])
|
||||
if fields[0] == "cpu" {
|
||||
total = counters
|
||||
} else {
|
||||
cores = append(cores, counters)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return cpuCounters{}, nil, err
|
||||
}
|
||||
if total.total() == 0 {
|
||||
return cpuCounters{}, nil, fmt.Errorf("cpu counters are empty")
|
||||
}
|
||||
return total, cores, nil
|
||||
}
|
||||
|
||||
func parseCPUFields(fields []string) cpuCounters {
|
||||
values := make([]uint64, 8)
|
||||
for i := 0; i < len(values) && i < len(fields); i++ {
|
||||
values[i], _ = strconv.ParseUint(fields[i], 10, 64)
|
||||
}
|
||||
return cpuCounters{User: values[0], Nice: values[1], System: values[2], Idle: values[3], IOWait: values[4], IRQ: values[5], SoftIRQ: values[6], Steal: values[7]}
|
||||
}
|
||||
|
||||
func readMemoryMetrics(path string) (MemoryMetrics, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return MemoryMetrics{}, fmt.Errorf("read memory counters: %w", err)
|
||||
}
|
||||
values := map[string]uint64{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
key := strings.TrimSuffix(fields[0], ":")
|
||||
value, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
values[key] = value * 1024
|
||||
}
|
||||
}
|
||||
total := values["MemTotal"]
|
||||
available := values["MemAvailable"]
|
||||
if total == 0 {
|
||||
return MemoryMetrics{}, fmt.Errorf("MemTotal is unavailable")
|
||||
}
|
||||
used := total - available
|
||||
swapTotal := values["SwapTotal"]
|
||||
swapUsed := swapTotal - values["SwapFree"]
|
||||
metrics := MemoryMetrics{TotalBytes: total, UsedBytes: used, AvailableBytes: available, UsedPercent: float64(used) * 100 / float64(total), SwapTotalBytes: swapTotal, SwapUsedBytes: swapUsed}
|
||||
if swapTotal > 0 {
|
||||
metrics.SwapPercent = float64(swapUsed) * 100 / float64(swapTotal)
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func readLoadAverage(path string) ([3]float64, error) {
|
||||
var load [3]float64
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return load, err
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
for i := 0; i < 3 && i < len(fields); i++ {
|
||||
load[i], _ = strconv.ParseFloat(fields[i], 64)
|
||||
}
|
||||
return load, nil
|
||||
}
|
||||
|
||||
func readNetworkCounters(path string) (map[string]byteCounters, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]byteCounters)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
name, values, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(values)
|
||||
if len(fields) < 9 {
|
||||
continue
|
||||
}
|
||||
receive, _ := strconv.ParseUint(fields[0], 10, 64)
|
||||
transmit, _ := strconv.ParseUint(fields[8], 10, 64)
|
||||
result[strings.TrimSpace(name)] = byteCounters{Receive: receive, Transmit: transmit}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readDiskCounters() (uint64, uint64) {
|
||||
devices, err := os.ReadDir("/sys/block")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
whole := make(map[string]struct{}, len(devices))
|
||||
for _, device := range devices {
|
||||
name := device.Name()
|
||||
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "dm-") {
|
||||
continue
|
||||
}
|
||||
whole[name] = struct{}{}
|
||||
}
|
||||
data, err := os.ReadFile("/proc/diskstats")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
var read, written uint64
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
if _, ok := whole[fields[2]]; !ok {
|
||||
continue
|
||||
}
|
||||
sectorsRead, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||
sectorsWritten, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||
read += sectorsRead * 512
|
||||
written += sectorsWritten * 512
|
||||
}
|
||||
return read, written
|
||||
}
|
||||
|
||||
func readThermals() []ThermalReading {
|
||||
paths, _ := filepath.Glob("/sys/class/thermal/thermal_zone*/temp")
|
||||
var readings []ThermalReading
|
||||
for _, path := range paths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
name := filepath.Base(filepath.Dir(path))
|
||||
if data, err := os.ReadFile(filepath.Join(filepath.Dir(path), "type")); err == nil {
|
||||
name = strings.TrimSpace(string(data))
|
||||
}
|
||||
if value > 1000 {
|
||||
value /= 1000
|
||||
}
|
||||
if value > -100 && value < 250 {
|
||||
readings = append(readings, ThermalReading{Name: name, Celsius: value})
|
||||
}
|
||||
}
|
||||
sort.Slice(readings, func(i, j int) bool { return readings[i].Name < readings[j].Name })
|
||||
return readings
|
||||
}
|
||||
9
agent/internal/system/monitor_other.go
Normal file
9
agent/internal/system/monitor_other.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//go:build !linux
|
||||
|
||||
package system
|
||||
|
||||
import "fmt"
|
||||
|
||||
func readRawMonitorSample() (*rawMonitorSample, error) {
|
||||
return nil, fmt.Errorf("%w on this platform", ErrMonitorUnavailable)
|
||||
}
|
||||
68
agent/internal/system/monitor_test.go
Normal file
68
agent/internal/system/monitor_test.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSnapshotFromRawComputesRatesAndHistoryPointInputs(t *testing.T) {
|
||||
before := &rawMonitorSample{
|
||||
at: time.Unix(100, 0),
|
||||
cpu: cpuCounters{User: 100, System: 50, Idle: 850},
|
||||
cores: []cpuCounters{{User: 100, Idle: 900}},
|
||||
memory: MemoryMetrics{TotalBytes: 1000, UsedBytes: 400, AvailableBytes: 600, UsedPercent: 40},
|
||||
network: map[string]byteCounters{"eth0": {Receive: 1000, Transmit: 2000}},
|
||||
diskRead: 5000,
|
||||
diskWrite: 8000,
|
||||
}
|
||||
now := &rawMonitorSample{
|
||||
at: time.Unix(102, 0),
|
||||
cpu: cpuCounters{User: 180, System: 70, Idle: 950},
|
||||
cores: []cpuCounters{{User: 160, Idle: 1040}},
|
||||
load: [3]float64{1.2, 0.8, 0.5},
|
||||
memory: MemoryMetrics{TotalBytes: 1000, UsedBytes: 450, AvailableBytes: 550, UsedPercent: 45},
|
||||
network: map[string]byteCounters{"eth0": {Receive: 3000, Transmit: 5000}},
|
||||
diskRead: 9000,
|
||||
diskWrite: 14000,
|
||||
}
|
||||
|
||||
snapshot := snapshotFromRaw(now, before, time.Second)
|
||||
assertNear(t, snapshot.CPU.UsagePercent, 50)
|
||||
assertNear(t, snapshot.CPU.UserPercent, 40)
|
||||
assertNear(t, snapshot.CPU.SystemPercent, 10)
|
||||
assertNear(t, snapshot.Network.ReceiveBytesPerSecond, 1000)
|
||||
assertNear(t, snapshot.Network.TransmitBytesPerSecond, 1500)
|
||||
assertNear(t, snapshot.Storage.ReadBytesPerSecond, 2000)
|
||||
assertNear(t, snapshot.Storage.WriteBytesPerSecond, 3000)
|
||||
if len(snapshot.CPU.PerCore) != 1 {
|
||||
t.Fatalf("per-core samples = %d, want 1", len(snapshot.CPU.PerCore))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceMonitorHistoryIsBounded(t *testing.T) {
|
||||
monitor := NewResourceMonitor(time.Second, 2)
|
||||
monitor.history = []ResourcePoint{{CPUPercent: 1}, {CPUPercent: 2}}
|
||||
monitor.capacity = 2
|
||||
monitor.latest = &ResourceSnapshot{}
|
||||
monitor.previous = nil
|
||||
|
||||
monitor.mu.Lock()
|
||||
monitor.history = append(monitor.history, ResourcePoint{CPUPercent: 3})
|
||||
if len(monitor.history) > monitor.capacity {
|
||||
copy(monitor.history, monitor.history[len(monitor.history)-monitor.capacity:])
|
||||
monitor.history = monitor.history[:monitor.capacity]
|
||||
}
|
||||
monitor.mu.Unlock()
|
||||
|
||||
if len(monitor.history) != 2 || monitor.history[0].CPUPercent != 2 || monitor.history[1].CPUPercent != 3 {
|
||||
t.Fatalf("bounded history = %#v", monitor.history)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNear(t *testing.T, got, want float64) {
|
||||
t.Helper()
|
||||
if math.Abs(got-want) > 0.001 {
|
||||
t.Fatalf("got %.3f, want %.3f", got, want)
|
||||
}
|
||||
}
|
||||
21
agent/internal/system/process.go
Normal file
21
agent/internal/system/process.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package system
|
||||
|
||||
// TopProcess represents a single process snapshot for the dashboard's
|
||||
// "Top Processes" table. Fields map directly to the UI columns:
|
||||
// Name, PID, CPU%, Mem%.
|
||||
type TopProcess struct {
|
||||
Name string `json:"name"`
|
||||
PID int `json:"pid"`
|
||||
CPU float64 `json:"cpu"` // percent of total CPU time
|
||||
Mem float64 `json:"mem"` // percent of total physical memory
|
||||
}
|
||||
|
||||
// GetTopProcesses returns the top N processes sorted by CPU usage descending.
|
||||
// Platform-specific implementations live in process_linux.go, process_windows.go,
|
||||
// process_darwin.go.
|
||||
//
|
||||
// This is a snapshot primitive — no sampling interval, no background goroutine.
|
||||
// The caller (loop.go) decides when to invoke it and at what cadence.
|
||||
func GetTopProcesses(limit int) ([]TopProcess, error) {
|
||||
return getTopProcesses(limit)
|
||||
}
|
||||
53
agent/internal/system/process_capabilities.go
Normal file
53
agent/internal/system/process_capabilities.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// capabilityNames is indexed by capability bit. CAP_CHECKPOINT_RESTORE (40) is
|
||||
// CAP_LAST_CAP on Linux 5.9 and later; a bit past the end of this table is
|
||||
// reported as CAP_<n> rather than dropped, so a newer kernel stays legible.
|
||||
var capabilityNames = []string{
|
||||
"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH", "CAP_FOWNER",
|
||||
"CAP_FSETID", "CAP_KILL", "CAP_SETGID", "CAP_SETUID",
|
||||
"CAP_SETPCAP", "CAP_LINUX_IMMUTABLE", "CAP_NET_BIND_SERVICE", "CAP_NET_BROADCAST",
|
||||
"CAP_NET_ADMIN", "CAP_NET_RAW", "CAP_IPC_LOCK", "CAP_IPC_OWNER",
|
||||
"CAP_SYS_MODULE", "CAP_SYS_RAWIO", "CAP_SYS_CHROOT", "CAP_SYS_PTRACE",
|
||||
"CAP_SYS_PACCT", "CAP_SYS_ADMIN", "CAP_SYS_BOOT", "CAP_SYS_NICE",
|
||||
"CAP_SYS_RESOURCE", "CAP_SYS_TIME", "CAP_SYS_TTY_CONFIG", "CAP_MKNOD",
|
||||
"CAP_LEASE", "CAP_AUDIT_WRITE", "CAP_AUDIT_CONTROL", "CAP_SETFCAP",
|
||||
"CAP_MAC_OVERRIDE", "CAP_MAC_ADMIN", "CAP_SYSLOG", "CAP_WAKE_ALARM",
|
||||
"CAP_BLOCK_SUSPEND", "CAP_AUDIT_READ", "CAP_PERFMON", "CAP_BPF",
|
||||
"CAP_CHECKPOINT_RESTORE",
|
||||
}
|
||||
|
||||
// decodeCapabilities expands a CapEff hex mask from /proc/[pid]/status into
|
||||
// capability names. The mask is 64 bits of hex with no 0x prefix.
|
||||
//
|
||||
// The list scan carries the mask; only drill-down expands it. Fully privileged
|
||||
// processes hold every bit, and 41 strings on every row of a 300-process
|
||||
// inventory is payload nobody reads.
|
||||
func decodeCapabilities(mask string) []string {
|
||||
mask = strings.TrimSpace(mask)
|
||||
if mask == "" {
|
||||
return nil
|
||||
}
|
||||
bits, err := strconv.ParseUint(mask, 16, 64)
|
||||
if err != nil || bits == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var held []string
|
||||
for bit := 0; bit < 64; bit++ {
|
||||
if bits&(1<<uint(bit)) == 0 {
|
||||
continue
|
||||
}
|
||||
if bit < len(capabilityNames) {
|
||||
held = append(held, capabilityNames[bit])
|
||||
continue
|
||||
}
|
||||
held = append(held, "CAP_"+strconv.Itoa(bit))
|
||||
}
|
||||
return held
|
||||
}
|
||||
53
agent/internal/system/process_capabilities_test.go
Normal file
53
agent/internal/system/process_capabilities_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeCapabilities(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mask string
|
||||
want []string
|
||||
}{
|
||||
{name: "unprivileged", mask: "0000000000000000", want: nil},
|
||||
{name: "empty", mask: "", want: nil},
|
||||
{name: "whitespace is trimmed", mask: " 0000000000000001 ", want: []string{"CAP_CHOWN"}},
|
||||
{name: "single low bit", mask: "0000000000000001", want: []string{"CAP_CHOWN"}},
|
||||
{name: "net bind service", mask: "0000000000000400", want: []string{"CAP_NET_BIND_SERVICE"}},
|
||||
{name: "sys admin", mask: "0000000000200000", want: []string{"CAP_SYS_ADMIN"}},
|
||||
{
|
||||
name: "a container's default docker set",
|
||||
mask: "00000000a80425fb",
|
||||
want: []string{
|
||||
"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID",
|
||||
"CAP_KILL", "CAP_SETGID", "CAP_SETUID", "CAP_SETPCAP",
|
||||
"CAP_NET_BIND_SERVICE", "CAP_NET_RAW", "CAP_SYS_CHROOT",
|
||||
"CAP_MKNOD", "CAP_AUDIT_WRITE", "CAP_SETFCAP",
|
||||
},
|
||||
},
|
||||
{name: "beyond the known table stays legible", mask: "0000020000000000", want: []string{"CAP_41"}},
|
||||
{name: "not hex", mask: "not-a-mask", want: nil},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := decodeCapabilities(tc.mask)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("decodeCapabilities(%q)\n got: %v\nwant: %v", tc.mask, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCapabilitiesFullRootSet(t *testing.T) {
|
||||
// Bits 0..40 — every capability up to CAP_LAST_CAP on Linux 5.9+.
|
||||
got := decodeCapabilities("000001ffffffffff")
|
||||
if len(got) != len(capabilityNames) {
|
||||
t.Fatalf("full mask decoded %d capabilities, want %d", len(got), len(capabilityNames))
|
||||
}
|
||||
if got[0] != "CAP_CHOWN" || got[len(got)-1] != "CAP_CHECKPOINT_RESTORE" {
|
||||
t.Fatalf("full mask bounds wrong: first=%s last=%s", got[0], got[len(got)-1])
|
||||
}
|
||||
}
|
||||
52
agent/internal/system/process_darwin.go
Normal file
52
agent/internal/system/process_darwin.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//go:build darwin
|
||||
// +build darwin
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getTopProcesses on macOS uses `ps aux` — no /proc, no sysctl for per-process.
|
||||
// Can be optimized later with sysctl KERN_PROC if needed.
|
||||
func getTopProcesses(limit int) ([]TopProcess, error) {
|
||||
out, err := exec.Command("ps", "aux", "--sort=-%cpu").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(out), "\n")
|
||||
var procs []TopProcess
|
||||
for i, line := range lines {
|
||||
if i == 0 || strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 11 {
|
||||
continue
|
||||
}
|
||||
pid, _ := strconv.Atoi(fields[1])
|
||||
cpu, _ := strconv.ParseFloat(fields[2], 64)
|
||||
mem, _ := strconv.ParseFloat(fields[3], 64)
|
||||
name := fields[10]
|
||||
|
||||
procs = append(procs, TopProcess{
|
||||
Name: name,
|
||||
PID: pid,
|
||||
CPU: cpu,
|
||||
Mem: mem,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(procs, func(i, j int) bool {
|
||||
return procs[i].CPU > procs[j].CPU
|
||||
})
|
||||
|
||||
if limit > 0 && len(procs) > limit {
|
||||
procs = procs[:limit]
|
||||
}
|
||||
return procs, nil
|
||||
}
|
||||
154
agent/internal/system/process_detail.go
Normal file
154
agent/internal/system/process_detail.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package system
|
||||
|
||||
import "time"
|
||||
|
||||
// FullProcess represents a complete process snapshot with osquery-level detail.
|
||||
// Fields mirror the osquery `processes` table schema plus related data for
|
||||
// drill-down (open files, sockets, env, memory map, namespaces, pipes).
|
||||
type FullProcess struct {
|
||||
// Core identification
|
||||
PID int `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path,omitempty"` // /proc/[pid]/exe symlink target
|
||||
Cmdline string `json:"cmdline"` // /proc/[pid]/cmdline (NUL→space)
|
||||
Cwd string `json:"cwd,omitempty"` // /proc/[pid]/cwd symlink target
|
||||
|
||||
// State and identity
|
||||
State string `json:"state"` // R/S/D/Z/T from /proc/[pid]/stat
|
||||
UID uint32 `json:"uid"`
|
||||
GID uint32 `json:"gid"`
|
||||
EUID uint32 `json:"euid"`
|
||||
EGID uint32 `json:"egid"`
|
||||
User string `json:"user"` // resolved from /etc/passwd
|
||||
Group string `json:"group"` // resolved from /etc/group
|
||||
|
||||
// Terminal
|
||||
TTY int `json:"tty"`
|
||||
TTYName string `json:"tty_name,omitempty"`
|
||||
|
||||
// Resource usage
|
||||
CPUSecondsUser float64 `json:"cpu_seconds_user"` // utime / clock_ticks
|
||||
CPUSecondsSystem float64 `json:"cpu_seconds_system"` // stime / clock_ticks
|
||||
CPUPercent float64 `json:"cpu_percent"` // vs total CPU ticks
|
||||
RSSBytes uint64 `json:"rss_bytes"` // VmRSS from /proc/[pid]/status
|
||||
VMSBytes uint64 `json:"vms_bytes"` // VmSize from /proc/[pid]/status
|
||||
MemPercent float64 `json:"mem_percent"`
|
||||
Threads int `json:"threads"` // Threads field from /proc/[pid]/status
|
||||
|
||||
// Process metadata
|
||||
Nice int `json:"nice"`
|
||||
StartTimeSeconds uint64 `json:"start_time_seconds"` // starttime from /proc/[pid]/stat
|
||||
ParentPID int `json:"parent_pid"`
|
||||
ProcessGroupID int `json:"process_group_id"`
|
||||
|
||||
// On-disk check
|
||||
OnDisk int `json:"on_disk"` // 1=yes, 0=no, -1=unknown (matches osquery)
|
||||
|
||||
// Elevation
|
||||
ElevationStatus string `json:"elevation_status,omitempty"` // "elevated" if uid!=euid
|
||||
|
||||
// Ownership — cgroup, systemd unit, container. Inlined into the JSON.
|
||||
ProcessOwner
|
||||
|
||||
// Effective capability mask from /proc/[pid]/status. Decoded into names on
|
||||
// drill-down only — see Capabilities below.
|
||||
CapabilitiesEffective string `json:"capabilities_effective,omitempty"`
|
||||
|
||||
// Disk I/O (from /proc/[pid]/io, may be empty for other users' processes)
|
||||
DiskBytesRead uint64 `json:"disk_bytes_read,omitempty"`
|
||||
DiskBytesWritten uint64 `json:"disk_bytes_written,omitempty"`
|
||||
|
||||
// Related data — populated only on drill-down (GetProcessDetail), not on list scan
|
||||
OpenFiles []ProcessOpenFile `json:"open_files,omitempty"`
|
||||
OpenSockets []ProcessOpenSocket `json:"open_sockets,omitempty"`
|
||||
OpenPipes []ProcessOpenPipe `json:"open_pipes,omitempty"`
|
||||
EnvironmentVars []string `json:"environment_vars,omitempty"` // keys only
|
||||
MemoryMap []ProcessMemoryMap `json:"memory_map,omitempty"`
|
||||
Namespaces []ProcessNamespace `json:"namespaces,omitempty"`
|
||||
ListeningPorts []ProcessListeningPort `json:"listening_ports,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"` // effective set, decoded
|
||||
}
|
||||
|
||||
// Related data types — stored as JSONB per relation in the database.
|
||||
|
||||
type ProcessOpenFile struct {
|
||||
FD int `json:"fd"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type,omitempty"` // file, socket, pipe, etc.
|
||||
}
|
||||
|
||||
type ProcessOpenSocket struct {
|
||||
FD int `json:"fd"`
|
||||
Family string `json:"family"` // IPv4, IPv6, UNIX
|
||||
Protocol string `json:"protocol"` // TCP, UDP
|
||||
LocalAddr string `json:"local_addr"`
|
||||
LocalPort int `json:"local_port"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
RemotePort int `json:"remote_port"`
|
||||
State string `json:"state"`
|
||||
Inode uint64 `json:"inode"`
|
||||
Path string `json:"path,omitempty"` // for UNIX sockets
|
||||
}
|
||||
|
||||
type ProcessOpenPipe struct {
|
||||
FD int `json:"fd"`
|
||||
Inode uint64 `json:"inode"`
|
||||
Mode string `json:"mode,omitempty"` // r/w
|
||||
Type string `json:"type,omitempty"` // named vs anonymous
|
||||
}
|
||||
|
||||
type ProcessMemoryMap struct {
|
||||
Start uint64 `json:"start"`
|
||||
End uint64 `json:"end"`
|
||||
Permissions string `json:"permissions"` // r/w/x/p
|
||||
Offset uint64 `json:"offset"`
|
||||
Device string `json:"device"`
|
||||
Inode uint64 `json:"inode"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type ProcessNamespace struct {
|
||||
Type string `json:"type"` // cgroup, ipc, mnt, net, pid, user, uts
|
||||
Inode string `json:"inode"` // string because kernel shows as "[4026531835]"
|
||||
}
|
||||
|
||||
type ProcessListeningPort struct {
|
||||
Protocol string `json:"protocol"` // TCP, UDP
|
||||
LocalAddr string `json:"local_addr"`
|
||||
LocalPort int `json:"local_port"`
|
||||
FD int `json:"fd,omitempty"`
|
||||
Socket uint64 `json:"socket,omitempty"`
|
||||
}
|
||||
|
||||
// FullProcessSnapshot is the complete result of an on-demand process scan.
|
||||
type FullProcessSnapshot struct {
|
||||
Processes []FullProcess `json:"processes"`
|
||||
ProcessCount int `json:"process_count"`
|
||||
ScannedAt time.Time `json:"scanned_at"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
// GetFullProcessSnapshot reads /proc to build a complete process inventory.
|
||||
// Platform-specific implementations live in process_detail_linux.go and
|
||||
// process_detail_other.go (stub returning "not supported").
|
||||
func GetFullProcessSnapshot() (*FullProcessSnapshot, error) {
|
||||
return getFullProcessSnapshot()
|
||||
}
|
||||
|
||||
// ProcessCaps holds per-process data collection limits.
|
||||
// Zero values mean "no cap" (but the underlying /proc data is still bounded by the kernel).
|
||||
type ProcessCaps struct {
|
||||
MaxOpenFiles int `json:"max_open_files"`
|
||||
MaxSockets int `json:"max_sockets"`
|
||||
MaxPipes int `json:"max_pipes"`
|
||||
MaxMemoryMap int `json:"max_memory_map"`
|
||||
MaxNamespaces int `json:"max_namespaces"`
|
||||
MaxEnvKeys int `json:"max_env_keys"`
|
||||
MaxListeningPorts int `json:"max_listening_ports"`
|
||||
}
|
||||
|
||||
// GetProcessDetail reads the related data (open files, sockets, env, etc.) for a
|
||||
// specific process. Called on drill-down, not on the initial list scan.
|
||||
func GetProcessDetail(pid int, caps ProcessCaps) (*FullProcess, error) {
|
||||
return getProcessDetail(pid, caps)
|
||||
}
|
||||
713
agent/internal/system/process_detail_linux.go
Normal file
713
agent/internal/system/process_detail_linux.go
Normal file
|
|
@ -0,0 +1,713 @@
|
|||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// getFullProcessSnapshot reads /proc to build a complete process inventory.
|
||||
// No subprocess spawns — pure /proc reads following the pattern of process_linux.go.
|
||||
func getFullProcessSnapshot() (*FullProcessSnapshot, error) {
|
||||
start := time.Now()
|
||||
|
||||
totalCPU, err := readTotalCPUTicks()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read total cpu ticks: %w", err)
|
||||
}
|
||||
if totalCPU == 0 {
|
||||
return nil, fmt.Errorf("total CPU ticks is zero")
|
||||
}
|
||||
|
||||
memTotal, err := readMemTotal()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read mem total: %w", err)
|
||||
}
|
||||
if memTotal == 0 {
|
||||
return nil, fmt.Errorf("mem total is zero")
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read /proc: %w", err)
|
||||
}
|
||||
|
||||
// Pre-load /etc/passwd and /etc/group for uid→name resolution.
|
||||
passwd := readPasswd()
|
||||
group := readGroup()
|
||||
|
||||
var procs []FullProcess
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
proc, err := readFullProc(pid, totalCPU, memTotal, passwd, group)
|
||||
if err != nil {
|
||||
continue // vanished or permission denied
|
||||
}
|
||||
procs = append(procs, *proc)
|
||||
}
|
||||
|
||||
return &FullProcessSnapshot{
|
||||
Processes: procs,
|
||||
ProcessCount: len(procs),
|
||||
ScannedAt: start.UTC(),
|
||||
DurationMs: time.Since(start).Milliseconds(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// readFullProc reads all /proc/[pid]/* files for a single process.
|
||||
func readFullProc(pid int, totalCPU, memTotal uint64, passwd, group map[uint32]string) (*FullProcess, error) {
|
||||
// /proc/[pid]/stat — core fields
|
||||
statData, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proc := &FullProcess{PID: pid, OnDisk: -1}
|
||||
|
||||
// Parse comm: between first '(' and last ')'
|
||||
firstParen := bytes.IndexByte(statData, '(')
|
||||
lastParen := bytes.LastIndexByte(statData, ')')
|
||||
if firstParen < 0 || lastParen <= firstParen {
|
||||
return nil, fmt.Errorf("malformed stat")
|
||||
}
|
||||
proc.Name = string(statData[firstParen+1 : lastParen])
|
||||
|
||||
// Fields after ')'
|
||||
afterName := bytes.TrimSpace(statData[lastParen+1:])
|
||||
fields := strings.Fields(string(afterName))
|
||||
// Need at least 20 fields (state through nice)
|
||||
if len(fields) < 20 {
|
||||
return nil, fmt.Errorf("not enough fields in stat")
|
||||
}
|
||||
|
||||
// /proc/[pid]/stat fields after "pid (comm)" — 0-indexed in fields[]:
|
||||
// [0]=state [1]=ppid [2]=pgrp [3]=session [4]=tty_nr [5]=tpgid [6]=flags
|
||||
// [7..10]=minflt..cmajflt [11]=utime [12]=stime [13]=cutime [14]=cstime
|
||||
// [15]=priority [16]=nice [17]=num_threads [18]=itrealvalue [19]=starttime
|
||||
proc.State = fields[0]
|
||||
proc.ParentPID = atoi(fields[1])
|
||||
proc.ProcessGroupID = atoi(fields[2])
|
||||
proc.TTY = atoi(fields[4])
|
||||
utime := atouint64(fields[11])
|
||||
stime := atouint64(fields[12])
|
||||
proc.Nice = atoi(fields[16])
|
||||
proc.StartTimeSeconds = atouint64(fields[19])
|
||||
|
||||
// CPU seconds
|
||||
if totalCPU > 0 {
|
||||
proc.CPUSecondsUser = float64(utime) / float64(totalCPU) * 100
|
||||
proc.CPUSecondsSystem = float64(stime) / float64(totalCPU) * 100
|
||||
proc.CPUPercent = (float64(utime+stime) / float64(totalCPU)) * 100.0
|
||||
}
|
||||
|
||||
// /proc/[pid]/status — memory, threads, uid/gid
|
||||
statusData, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
|
||||
if err == nil {
|
||||
for _, line := range bytes.Split(statusData, []byte("\n")) {
|
||||
switch {
|
||||
case bytes.HasPrefix(line, []byte("Uid:")):
|
||||
// Uid:\t<real>\t<effective>\t<saved>\t<fs>
|
||||
parts := strings.Fields(string(line))
|
||||
if len(parts) >= 3 {
|
||||
proc.UID = uint32(atoi(parts[1]))
|
||||
proc.EUID = uint32(atoi(parts[2]))
|
||||
}
|
||||
case bytes.HasPrefix(line, []byte("Gid:")):
|
||||
parts := strings.Fields(string(line))
|
||||
if len(parts) >= 3 {
|
||||
proc.GID = uint32(atoi(parts[1]))
|
||||
proc.EGID = uint32(atoi(parts[2]))
|
||||
}
|
||||
case bytes.HasPrefix(line, []byte("VmRSS:")):
|
||||
parts := strings.Fields(string(line))
|
||||
if len(parts) >= 2 {
|
||||
proc.RSSBytes = atouint64(parts[1]) * 1024 // KB to bytes
|
||||
}
|
||||
case bytes.HasPrefix(line, []byte("VmSize:")):
|
||||
parts := strings.Fields(string(line))
|
||||
if len(parts) >= 2 {
|
||||
proc.VMSBytes = atouint64(parts[1]) * 1024
|
||||
}
|
||||
case bytes.HasPrefix(line, []byte("Threads:")):
|
||||
parts := strings.Fields(string(line))
|
||||
if len(parts) >= 2 {
|
||||
proc.Threads = atoi(parts[1])
|
||||
}
|
||||
case bytes.HasPrefix(line, []byte("CapEff:")):
|
||||
parts := strings.Fields(string(line))
|
||||
if len(parts) >= 2 {
|
||||
proc.CapabilitiesEffective = parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Memory percent
|
||||
if memTotal > 0 {
|
||||
proc.MemPercent = (float64(proc.RSSBytes) / float64(memTotal*1024)) * 100.0
|
||||
}
|
||||
|
||||
// Resolved user/group names
|
||||
proc.User = passwd[proc.UID]
|
||||
proc.Group = group[proc.GID]
|
||||
|
||||
// Elevation
|
||||
if proc.UID != proc.EUID || proc.GID != proc.EGID {
|
||||
proc.ElevationStatus = "elevated"
|
||||
}
|
||||
|
||||
// /proc/[pid]/exe — binary path
|
||||
exePath := fmt.Sprintf("/proc/%d/exe", pid)
|
||||
if link, err := os.Readlink(exePath); err == nil {
|
||||
proc.Path = link
|
||||
// On-disk check: does the binary exist at the resolved path?
|
||||
if _, err := os.Stat(link); err == nil {
|
||||
proc.OnDisk = 1
|
||||
} else {
|
||||
proc.OnDisk = 0
|
||||
}
|
||||
}
|
||||
|
||||
// /proc/[pid]/cgroup — the owning unit or container
|
||||
if cgroupData, err := os.ReadFile(fmt.Sprintf("/proc/%d/cgroup", pid)); err == nil {
|
||||
proc.ProcessOwner = parseCgroupOwner(string(cgroupData))
|
||||
}
|
||||
|
||||
// /proc/[pid]/cmdline — full command line
|
||||
cmdlineData, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
||||
if err == nil {
|
||||
// NUL-delimited, join with spaces, trim trailing NUL
|
||||
proc.Cmdline = strings.ReplaceAll(strings.TrimRight(string(cmdlineData), "\x00"), "\x00", " ")
|
||||
}
|
||||
|
||||
// /proc/[pid]/cwd — working directory
|
||||
cwdPath := fmt.Sprintf("/proc/%d/cwd", pid)
|
||||
if link, err := os.Readlink(cwdPath); err == nil {
|
||||
proc.Cwd = link
|
||||
}
|
||||
|
||||
// TTY name resolution
|
||||
if proc.TTY > 0 {
|
||||
proc.TTYName = resolveTTY(proc.TTY)
|
||||
}
|
||||
|
||||
// /proc/[pid]/io — disk I/O (may fail for other users' processes)
|
||||
ioData, err := os.ReadFile(fmt.Sprintf("/proc/%d/io", pid))
|
||||
if err == nil {
|
||||
for _, line := range bytes.Split(ioData, []byte("\n")) {
|
||||
switch {
|
||||
case bytes.HasPrefix(line, []byte("read_bytes:")):
|
||||
proc.DiskBytesRead = atouint64(strings.TrimSpace(strings.TrimPrefix(string(line), "read_bytes:")))
|
||||
case bytes.HasPrefix(line, []byte("write_bytes:")):
|
||||
proc.DiskBytesWritten = atouint64(strings.TrimSpace(strings.TrimPrefix(string(line), "write_bytes:")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return proc, nil
|
||||
}
|
||||
|
||||
// readPasswd parses /etc/passwd into a uid→username map.
|
||||
func readPasswd() map[uint32]string {
|
||||
m := make(map[uint32]string)
|
||||
data, err := os.ReadFile("/etc/passwd")
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
for _, line := range bytes.Split(data, []byte("\n")) {
|
||||
parts := strings.SplitN(string(line), ":", 7)
|
||||
if len(parts) >= 3 {
|
||||
if uid, err := strconv.ParseUint(parts[2], 10, 32); err == nil {
|
||||
m[uint32(uid)] = parts[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// readGroup parses /etc/group into a gid→groupname map.
|
||||
func readGroup() map[uint32]string {
|
||||
m := make(map[uint32]string)
|
||||
data, err := os.ReadFile("/etc/group")
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
for _, line := range bytes.Split(data, []byte("\n")) {
|
||||
parts := strings.SplitN(string(line), ":", 4)
|
||||
if len(parts) >= 3 {
|
||||
if gid, err := strconv.ParseUint(parts[2], 10, 32); err == nil {
|
||||
m[uint32(gid)] = parts[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// resolveTTY maps a TTY device number (from /proc/[pid]/stat field 7) to /dev/pts/N or /dev/ttyN.
|
||||
func resolveTTY(tty int) string {
|
||||
major := (tty >> 8) & 0xfff
|
||||
minor := (tty & 0xff) | ((tty >> 12) & 0xfff00)
|
||||
|
||||
// /dev/pts/N — minor is the pts number
|
||||
if major == 136 || major == 188 {
|
||||
return fmt.Sprintf("pts/%d", minor)
|
||||
}
|
||||
// /dev/ttyN — major 4
|
||||
if major == 4 {
|
||||
if minor == 0 {
|
||||
return "tty0"
|
||||
}
|
||||
return fmt.Sprintf("tty%d", minor)
|
||||
}
|
||||
// /dev/tty (controlling terminal)
|
||||
if major == 5 && minor == 0 {
|
||||
return "tty"
|
||||
}
|
||||
return fmt.Sprintf("%d:%d", major, minor)
|
||||
}
|
||||
|
||||
// getProcessDetail reads the related data (open files, sockets, env, etc.) for a
|
||||
// single process. Called on drill-down, not on the initial list scan.
|
||||
func getProcessDetail(pid int, caps ProcessCaps) (*FullProcess, error) {
|
||||
totalCPU, err := readTotalCPUTicks()
|
||||
if err != nil || totalCPU == 0 {
|
||||
return nil, fmt.Errorf("read total cpu ticks: %w", err)
|
||||
}
|
||||
memTotal, err := readMemTotal()
|
||||
if err != nil || memTotal == 0 {
|
||||
return nil, fmt.Errorf("read mem total: %w", err)
|
||||
}
|
||||
passwd := readPasswd()
|
||||
group := readGroup()
|
||||
|
||||
proc, err := readFullProc(pid, totalCPU, memTotal, passwd, group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if owner, err := FindSoftwareOwner(proc.Path); err == nil {
|
||||
proc.PackageManager = owner.PackageType
|
||||
proc.PackageName = owner.PackageName
|
||||
}
|
||||
|
||||
pidStr := strconv.Itoa(pid)
|
||||
|
||||
// Single /proc/[pid]/fd/ walk for open files, pipe inodes, and socket inodes
|
||||
fdFiles, pipeInodes, socketInodes := walkProcFD(pidStr, caps.MaxOpenFiles, caps.MaxPipes)
|
||||
proc.OpenFiles = fdFiles
|
||||
proc.OpenPipes = pipeInodes
|
||||
|
||||
// Sockets from /proc/[pid]/net/tcp, /proc/[pid]/net/tcp6, /proc/[pid]/net/unix
|
||||
proc.OpenSockets = readProcSockets(pidStr, caps.MaxSockets)
|
||||
|
||||
// Environment variable names from /proc/[pid]/environ (keys only, no values)
|
||||
proc.EnvironmentVars = readEnvKeys(pidStr, caps.MaxEnvKeys)
|
||||
|
||||
// Memory map from /proc/[pid]/maps
|
||||
proc.MemoryMap = readMemoryMap(pidStr, caps.MaxMemoryMap)
|
||||
|
||||
// Namespaces from /proc/[pid]/ns/
|
||||
proc.Namespaces = readNamespaces(pidStr, caps.MaxNamespaces)
|
||||
|
||||
// Effective capabilities, expanded from the mask the list scan already read
|
||||
proc.Capabilities = decodeCapabilities(proc.CapabilitiesEffective)
|
||||
|
||||
// Listening ports: correlate socket inodes from fd walk with /proc/net/tcp
|
||||
proc.ListeningPorts = readListeningPorts(pid, socketInodes, caps.MaxListeningPorts)
|
||||
|
||||
return proc, nil
|
||||
}
|
||||
|
||||
// walkProcFD reads /proc/[pid]/fd/ once and partitions results into open files,
|
||||
// pipe inodes, and socket inodes — avoiding three separate traversals.
|
||||
func walkProcFD(pidStr string, maxFiles, maxPipes int) ([]ProcessOpenFile, []ProcessOpenPipe, map[uint64]bool) {
|
||||
dir := fmt.Sprintf("/proc/%s/fd", pidStr)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
var files []ProcessOpenFile
|
||||
var pipes []ProcessOpenPipe
|
||||
socketInodes := make(map[uint64]bool)
|
||||
|
||||
for _, entry := range entries {
|
||||
fd, err := strconv.Atoi(entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
target, err := os.Readlink(fmt.Sprintf("%s/%d", dir, fd))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Always collect socket inodes (needed for listening port correlation)
|
||||
// regardless of the file cap.
|
||||
if strings.HasPrefix(target, "socket:[") {
|
||||
inodeStr := strings.TrimPrefix(target, "socket:[")
|
||||
inodeStr = strings.TrimSuffix(inodeStr, "]")
|
||||
if inode := atouint64(inodeStr); inode > 0 {
|
||||
socketInodes[inode] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Cap files and pipes independently
|
||||
switch {
|
||||
case strings.HasPrefix(target, "socket:["):
|
||||
if maxFiles <= 0 || len(files) < maxFiles {
|
||||
files = append(files, ProcessOpenFile{FD: fd, Path: target, Type: "socket"})
|
||||
}
|
||||
case strings.HasPrefix(target, "pipe:["):
|
||||
if maxPipes <= 0 || len(pipes) < maxPipes {
|
||||
inodeStr := strings.TrimPrefix(target, "pipe:[")
|
||||
inodeStr = strings.TrimSuffix(inodeStr, "]")
|
||||
pipes = append(pipes, ProcessOpenPipe{FD: fd, Inode: atouint64(inodeStr)})
|
||||
}
|
||||
if maxFiles <= 0 || len(files) < maxFiles {
|
||||
files = append(files, ProcessOpenFile{FD: fd, Path: target, Type: "pipe"})
|
||||
}
|
||||
case strings.HasPrefix(target, "anon_inode:"):
|
||||
if maxFiles <= 0 || len(files) < maxFiles {
|
||||
files = append(files, ProcessOpenFile{FD: fd, Path: target, Type: "anon_inode"})
|
||||
}
|
||||
default:
|
||||
if maxFiles <= 0 || len(files) < maxFiles {
|
||||
files = append(files, ProcessOpenFile{FD: fd, Path: target, Type: "file"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return files, pipes, socketInodes
|
||||
}
|
||||
|
||||
// readProcSockets reads /proc/[pid]/net/tcp, tcp6, and unix for socket info.
|
||||
func readProcSockets(pidStr string, maxSockets int) []ProcessOpenSocket {
|
||||
var sockets []ProcessOpenSocket
|
||||
|
||||
// TCP sockets
|
||||
for _, proto := range []struct {
|
||||
file string
|
||||
family string
|
||||
prot string
|
||||
}{
|
||||
{fmt.Sprintf("/proc/%s/net/tcp", pidStr), "IPv4", "TCP"},
|
||||
{fmt.Sprintf("/proc/%s/net/tcp6", pidStr), "IPv6", "TCP"},
|
||||
} {
|
||||
if maxSockets > 0 && len(sockets) >= maxSockets {
|
||||
break
|
||||
}
|
||||
data, err := os.ReadFile(proto.file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for i, line := range lines {
|
||||
if maxSockets > 0 && len(sockets) >= maxSockets {
|
||||
break
|
||||
}
|
||||
if i == 0 || strings.TrimSpace(line) == "" {
|
||||
continue // skip header
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
localAddr, localPort := parseHexAddr(fields[1])
|
||||
remoteAddr, remotePort := parseHexAddr(fields[2])
|
||||
state := tcpState(fields[3])
|
||||
inode := atouint64(fields[9])
|
||||
|
||||
sockets = append(sockets, ProcessOpenSocket{
|
||||
Family: proto.family,
|
||||
Protocol: proto.prot,
|
||||
LocalAddr: localAddr,
|
||||
LocalPort: localPort,
|
||||
RemoteAddr: remoteAddr,
|
||||
RemotePort: remotePort,
|
||||
State: state,
|
||||
Inode: inode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// UNIX sockets
|
||||
if maxSockets <= 0 || len(sockets) < maxSockets {
|
||||
unixFile := fmt.Sprintf("/proc/%s/net/unix", pidStr)
|
||||
data, err := os.ReadFile(unixFile)
|
||||
if err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for i, line := range lines {
|
||||
if maxSockets > 0 && len(sockets) >= maxSockets {
|
||||
break
|
||||
}
|
||||
if i == 0 || strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 7 {
|
||||
continue
|
||||
}
|
||||
inode := atouint64(fields[6])
|
||||
path := ""
|
||||
if len(fields) > 7 {
|
||||
path = fields[7]
|
||||
}
|
||||
sockets = append(sockets, ProcessOpenSocket{
|
||||
Family: "UNIX",
|
||||
Protocol: "UNIX",
|
||||
Inode: inode,
|
||||
Path: path,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sockets
|
||||
}
|
||||
|
||||
// readEnvKeys reads /proc/[pid]/environ and returns only the key names.
|
||||
// Values are never transmitted (security: env vars may contain secrets).
|
||||
func readEnvKeys(pidStr string, maxKeys int) []string {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%s/environ", pidStr))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var keys []string
|
||||
for _, part := range bytes.Split(data, []byte{0}) {
|
||||
if maxKeys > 0 && len(keys) >= maxKeys {
|
||||
break
|
||||
}
|
||||
s := string(part)
|
||||
if idx := strings.IndexByte(s, '='); idx > 0 {
|
||||
keys = append(keys, s[:idx])
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// readMemoryMap reads /proc/[pid]/maps for memory-mapped regions.
|
||||
func readMemoryMap(pidStr string, maxEntries int) []ProcessMemoryMap {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%s/maps", pidStr))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var regions []ProcessMemoryMap
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if maxEntries > 0 && len(regions) >= maxEntries {
|
||||
break
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Address range: start-end
|
||||
addrs := strings.SplitN(fields[0], "-", 2)
|
||||
if len(addrs) != 2 {
|
||||
continue
|
||||
}
|
||||
start, _ := strconv.ParseUint(addrs[0], 16, 64)
|
||||
end, _ := strconv.ParseUint(addrs[1], 16, 64)
|
||||
|
||||
offset, _ := strconv.ParseUint(fields[2], 16, 64)
|
||||
inode := atouint64(fields[4])
|
||||
|
||||
path := ""
|
||||
if len(fields) > 5 {
|
||||
path = strings.Join(fields[5:], " ")
|
||||
}
|
||||
|
||||
regions = append(regions, ProcessMemoryMap{
|
||||
Start: start,
|
||||
End: end,
|
||||
Permissions: fields[1],
|
||||
Offset: offset,
|
||||
Device: fields[3],
|
||||
Inode: inode,
|
||||
Path: path,
|
||||
})
|
||||
}
|
||||
return regions
|
||||
}
|
||||
|
||||
// readNamespaces reads /proc/[pid]/ns/ symlinks for namespace info.
|
||||
func readNamespaces(pidStr string, maxNS int) []ProcessNamespace {
|
||||
dir := fmt.Sprintf("/proc/%s/ns", pidStr)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ns []ProcessNamespace
|
||||
for _, entry := range entries {
|
||||
if maxNS > 0 && len(ns) >= maxNS {
|
||||
break
|
||||
}
|
||||
link := fmt.Sprintf("%s/%s", dir, entry.Name())
|
||||
target, err := os.Readlink(link)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Target format: "type:[inode]" e.g. "cgroup:[4026531835]"
|
||||
inode := ""
|
||||
if idx := strings.Index(target, "["); idx >= 0 {
|
||||
inode = strings.TrimSuffix(target[idx+1:], "]")
|
||||
}
|
||||
ns = append(ns, ProcessNamespace{
|
||||
Type: entry.Name(),
|
||||
Inode: inode,
|
||||
})
|
||||
}
|
||||
return ns
|
||||
}
|
||||
|
||||
// readListeningPorts finds TCP listening ports that belong to a specific process
|
||||
// by correlating socket inodes (from walkProcFD) with /proc/net/tcp entries.
|
||||
func readListeningPorts(pid int, socketInodes map[uint64]bool, maxPorts int) []ProcessListeningPort {
|
||||
if len(socketInodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read system-wide /proc/net/tcp and /proc/net/tcp6, keep only LISTEN
|
||||
// entries whose inode matches one of this process's sockets.
|
||||
var ports []ProcessListeningPort
|
||||
for _, file := range []struct {
|
||||
path string
|
||||
}{
|
||||
{"/proc/net/tcp"},
|
||||
{"/proc/net/tcp6"},
|
||||
} {
|
||||
if maxPorts > 0 && len(ports) >= maxPorts {
|
||||
break
|
||||
}
|
||||
data, err := os.ReadFile(file.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for i, line := range lines {
|
||||
if maxPorts > 0 && len(ports) >= maxPorts {
|
||||
break
|
||||
}
|
||||
if i == 0 || strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
if fields[3] != "0A" { // LISTEN only
|
||||
continue
|
||||
}
|
||||
inode := atouint64(fields[9])
|
||||
if !socketInodes[inode] {
|
||||
continue // not this process
|
||||
}
|
||||
localAddr, localPort := parseHexAddr(fields[1])
|
||||
ports = append(ports, ProcessListeningPort{
|
||||
Protocol: "TCP",
|
||||
LocalAddr: localAddr,
|
||||
LocalPort: localPort,
|
||||
Socket: inode,
|
||||
})
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
// parseHexAddr parses "HEXADDR:HEXPORT" from /proc/net/tcp format.
|
||||
// e.g. "0100007F:0050" → "127.0.0.1", 80
|
||||
func parseHexAddr(hex string) (string, int) {
|
||||
parts := strings.SplitN(hex, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", 0
|
||||
}
|
||||
|
||||
port, _ := strconv.ParseUint(parts[1], 16, 16)
|
||||
|
||||
// Parse IP (little-endian hex for IPv4)
|
||||
addrHex := parts[0]
|
||||
if len(addrHex) == 8 {
|
||||
// IPv4: 4 bytes in little-endian
|
||||
b0, _ := strconv.ParseUint(addrHex[6:8], 16, 8)
|
||||
b1, _ := strconv.ParseUint(addrHex[4:6], 16, 8)
|
||||
b2, _ := strconv.ParseUint(addrHex[2:4], 16, 8)
|
||||
b3, _ := strconv.ParseUint(addrHex[0:2], 16, 8)
|
||||
return fmt.Sprintf("%d.%d.%d.%d", b0, b1, b2, b3), int(port)
|
||||
}
|
||||
if len(addrHex) == 32 {
|
||||
// IPv6: kernel stores as 4 little-endian 32-bit words (%08X%08X%08X%08X).
|
||||
// Reverse bytes within each 4-byte group to get standard IPv6 byte order.
|
||||
var ipv6 [16]byte
|
||||
for g := 0; g < 4; g++ {
|
||||
off := g * 8
|
||||
for j := 0; j < 4; j++ {
|
||||
b, _ := strconv.ParseUint(addrHex[off+j*2:off+j*2+2], 16, 8)
|
||||
ipv6[g*4+(3-j)] = byte(b)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x",
|
||||
ipv6[0], ipv6[1], ipv6[2], ipv6[3], ipv6[4], ipv6[5], ipv6[6], ipv6[7],
|
||||
ipv6[8], ipv6[9], ipv6[10], ipv6[11], ipv6[12], ipv6[13], ipv6[14], ipv6[15]), int(port)
|
||||
}
|
||||
return addrHex, int(port)
|
||||
}
|
||||
|
||||
// tcpState maps the hex state field from /proc/net/tcp to a human-readable name.
|
||||
func tcpState(hex string) string {
|
||||
switch strings.ToUpper(hex) {
|
||||
case "01":
|
||||
return "ESTABLISHED"
|
||||
case "02":
|
||||
return "SYN_SENT"
|
||||
case "03":
|
||||
return "SYN_RECV"
|
||||
case "04":
|
||||
return "FIN_WAIT1"
|
||||
case "05":
|
||||
return "FIN_WAIT2"
|
||||
case "06":
|
||||
return "TIME_WAIT"
|
||||
case "07":
|
||||
return "CLOSE"
|
||||
case "08":
|
||||
return "CLOSE_WAIT"
|
||||
case "09":
|
||||
return "LAST_ACK"
|
||||
case "0A":
|
||||
return "LISTEN"
|
||||
case "0B":
|
||||
return "CLOSING"
|
||||
default:
|
||||
return hex
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers — avoid importing strconv for every tiny conversion.
|
||||
|
||||
func atoi(s string) int {
|
||||
v, _ := strconv.Atoi(s)
|
||||
return v
|
||||
}
|
||||
|
||||
func atouint64(s string) uint64 {
|
||||
v, _ := strconv.ParseUint(s, 10, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
// Ensure log is used (ETHOS: no silent imports).
|
||||
var _ = log.Printf
|
||||
14
agent/internal/system/process_detail_other.go
Normal file
14
agent/internal/system/process_detail_other.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//go:build !linux
|
||||
// +build !linux
|
||||
|
||||
package system
|
||||
|
||||
import "fmt"
|
||||
|
||||
func getFullProcessSnapshot() (*FullProcessSnapshot, error) {
|
||||
return nil, fmt.Errorf("process detail scanning not supported on this platform")
|
||||
}
|
||||
|
||||
func getProcessDetail(pid int, caps ProcessCaps) (*FullProcess, error) {
|
||||
return nil, fmt.Errorf("process detail scanning not supported on this platform")
|
||||
}
|
||||
187
agent/internal/system/process_linux.go
Normal file
187
agent/internal/system/process_linux.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getTopProcesses reads /proc directly — no subprocess spawn, no cgo.
|
||||
// It samples utime+stime from /proc/[pid]/stat and VmRSS from /proc/[pid]/status,
|
||||
// then computes CPU percent against the total CPU ticks from /proc/stat.
|
||||
func getTopProcesses(limit int) ([]TopProcess, error) {
|
||||
totalCPU, err := readTotalCPUTicks()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read total cpu ticks: %w", err)
|
||||
}
|
||||
if totalCPU == 0 {
|
||||
return nil, fmt.Errorf("total CPU ticks is zero")
|
||||
}
|
||||
|
||||
memTotal, err := readMemTotal()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read mem total: %w", err)
|
||||
}
|
||||
if memTotal == 0 {
|
||||
return nil, fmt.Errorf("mem total is zero")
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read /proc: %w", err)
|
||||
}
|
||||
|
||||
var procs []TopProcess
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
proc, err := readProcStat(pid)
|
||||
if err != nil {
|
||||
continue // process vanished or permission denied
|
||||
}
|
||||
|
||||
// CPU percent = (utime + stime) / total_cpu_ticks * 100
|
||||
cpuTicks := float64(proc.utime + proc.stime)
|
||||
proc.cpu = (cpuTicks / float64(totalCPU)) * 100.0
|
||||
|
||||
// Memory percent = VmRSS / MemTotal * 100
|
||||
if memTotal > 0 {
|
||||
proc.mem = (float64(proc.rss) / float64(memTotal)) * 100.0
|
||||
}
|
||||
|
||||
procs = append(procs, TopProcess{
|
||||
Name: proc.name,
|
||||
PID: pid,
|
||||
CPU: proc.cpu,
|
||||
Mem: proc.mem,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(procs, func(i, j int) bool {
|
||||
return procs[i].CPU > procs[j].CPU
|
||||
})
|
||||
|
||||
if limit > 0 && len(procs) > limit {
|
||||
procs = procs[:limit]
|
||||
}
|
||||
return procs, nil
|
||||
}
|
||||
|
||||
// procSnapshot holds raw values parsed from /proc/[pid]/stat and /proc/[pid]/status.
|
||||
type procSnapshot struct {
|
||||
name string
|
||||
utime uint64
|
||||
stime uint64
|
||||
rss uint64 // in pages; converted to KB below
|
||||
cpu float64
|
||||
mem float64
|
||||
}
|
||||
|
||||
// readProcStat parses /proc/[pid]/stat for utime (field 14) and stime (field 15),
|
||||
// then reads /proc/[pid]/status for VmRSS. The comm field (field 2) is between
|
||||
// the first '(' and the last ')'.
|
||||
func readProcStat(pid int) (*procSnapshot, error) {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snap := &procSnapshot{}
|
||||
|
||||
// Parse comm: everything between first '(' and last ')'
|
||||
firstParen := bytes.IndexByte(data, '(')
|
||||
lastParen := bytes.LastIndexByte(data, ')')
|
||||
if firstParen < 0 || lastParen <= firstParen {
|
||||
return nil, fmt.Errorf("malformed stat")
|
||||
}
|
||||
snap.name = string(data[firstParen+1 : lastParen])
|
||||
|
||||
// Fields after ')' — space-separated, field 3 onward (0-indexed after the name)
|
||||
afterName := bytes.TrimSpace(data[lastParen+1:])
|
||||
fields := strings.Fields(string(afterName))
|
||||
// fields[0] = state (field 3), fields[11] = utime (field 14), fields[12] = stime (field 15)
|
||||
if len(fields) < 13 {
|
||||
return nil, fmt.Errorf("not enough fields in stat")
|
||||
}
|
||||
|
||||
utime, err := strconv.ParseUint(fields[11], 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stime, err := strconv.ParseUint(fields[12], 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap.utime = utime
|
||||
snap.stime = stime
|
||||
|
||||
// Read VmRSS from /proc/[pid]/status
|
||||
statusData, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
|
||||
if err == nil {
|
||||
for _, line := range bytes.Split(statusData, []byte("\n")) {
|
||||
if bytes.HasPrefix(line, []byte("VmRSS:")) {
|
||||
parts := strings.Fields(string(line))
|
||||
if len(parts) >= 2 {
|
||||
if rss, err := strconv.ParseUint(parts[1], 10, 64); err == nil {
|
||||
snap.rss = rss // in KB
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// readTotalCPUTicks reads the first "cpu " line from /proc/stat and sums all fields.
|
||||
func readTotalCPUTicks() (uint64, error) {
|
||||
data, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, line := range bytes.Split(data, []byte("\n")) {
|
||||
if bytes.HasPrefix(line, []byte("cpu ")) {
|
||||
fields := strings.Fields(string(line))
|
||||
var total uint64
|
||||
for _, f := range fields[1:] { // skip "cpu" label
|
||||
v, err := strconv.ParseUint(f, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
total += v
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no cpu line in /proc/stat")
|
||||
}
|
||||
|
||||
// readMemTotal reads MemTotal from /proc/meminfo (in KB).
|
||||
func readMemTotal() (uint64, error) {
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, line := range bytes.Split(data, []byte("\n")) {
|
||||
if bytes.HasPrefix(line, []byte("MemTotal:")) {
|
||||
parts := strings.Fields(string(line))
|
||||
if len(parts) >= 2 {
|
||||
return strconv.ParseUint(parts[1], 10, 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("MemTotal not found")
|
||||
}
|
||||
132
agent/internal/system/process_owner.go
Normal file
132
agent/internal/system/process_owner.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package system
|
||||
|
||||
import "strings"
|
||||
|
||||
// ProcessOwner names what a process belongs to, read from its cgroup path.
|
||||
// It is the join between the process list and the service, container, and
|
||||
// package inventories — a PID answers "who owns me" without the Desktop
|
||||
// having to guess from a name.
|
||||
type ProcessOwner struct {
|
||||
Cgroup string `json:"cgroup,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
ContainerID string `json:"container_id,omitempty"`
|
||||
ContainerRuntime string `json:"container_runtime,omitempty"`
|
||||
PackageManager string `json:"package_manager,omitempty"`
|
||||
PackageName string `json:"package_name,omitempty"`
|
||||
}
|
||||
|
||||
// parseCgroupOwner reads the contents of /proc/[pid]/cgroup.
|
||||
//
|
||||
// v2 is one `0::<path>` line. v1 is one line per controller, and the paths can
|
||||
// disagree — the systemd hierarchy is the one that carries unit and container
|
||||
// scopes, so it wins when present.
|
||||
func parseCgroupOwner(content string) ProcessOwner {
|
||||
path := ""
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
fields := strings.SplitN(strings.TrimSpace(line), ":", 3)
|
||||
if len(fields) != 3 || fields[2] == "" {
|
||||
continue
|
||||
}
|
||||
hierarchy, controllers, candidate := fields[0], fields[1], fields[2]
|
||||
if hierarchy == "0" && controllers == "" {
|
||||
path = candidate // v2: unified, authoritative
|
||||
break
|
||||
}
|
||||
if controllers == "name=systemd" {
|
||||
path = candidate
|
||||
continue
|
||||
}
|
||||
if path == "" && candidate != "/" {
|
||||
path = candidate
|
||||
}
|
||||
}
|
||||
if path == "" || path == "/" {
|
||||
return ProcessOwner{}
|
||||
}
|
||||
|
||||
owner := ProcessOwner{Cgroup: path}
|
||||
segments := strings.Split(strings.Trim(path, "/"), "/")
|
||||
|
||||
containerIndex := -1
|
||||
for i := len(segments) - 1; i >= 0; i-- {
|
||||
runtime, id := containerSegment(segments, i)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
owner.ContainerRuntime = runtime
|
||||
owner.ContainerID = id
|
||||
containerIndex = i
|
||||
break
|
||||
}
|
||||
|
||||
for i := len(segments) - 1; i >= 0; i-- {
|
||||
if i == containerIndex {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(segments[i], ".service") || strings.HasSuffix(segments[i], ".scope") {
|
||||
owner.Unit = segments[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
return owner
|
||||
}
|
||||
|
||||
// containerSegment identifies a container from one cgroup path segment,
|
||||
// returning its runtime and full ID. Empty ID means the segment names no
|
||||
// container.
|
||||
func containerSegment(segments []string, i int) (string, string) {
|
||||
segment := segments[i]
|
||||
|
||||
// systemd cgroup drivers: docker-<id>.scope, libpod-<id>.scope,
|
||||
// crio-<id>.scope, cri-containerd-<id>.scope.
|
||||
if scope := strings.TrimSuffix(segment, ".scope"); scope != segment {
|
||||
for prefix, runtime := range map[string]string{
|
||||
"docker-": "docker",
|
||||
"libpod-": "podman",
|
||||
"crio-": "crio",
|
||||
"cri-containerd-": "containerd",
|
||||
"containerd-": "containerd",
|
||||
} {
|
||||
if id := strings.TrimPrefix(scope, prefix); id != scope && isContainerID(id) {
|
||||
return runtime, id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LXC keeps a name, not a hash: /lxc/<name>, /lxc.payload.<name>.
|
||||
if payload := strings.TrimPrefix(segment, "lxc.payload."); payload != segment && payload != "" {
|
||||
return "lxc", payload
|
||||
}
|
||||
if i > 0 && segments[i-1] == "lxc" && segment != "" {
|
||||
return "lxc", segment
|
||||
}
|
||||
|
||||
// cgroupfs drivers keep the bare ID: /docker/<id>, /kubepods/.../<id>.
|
||||
if isContainerID(segment) {
|
||||
if i > 0 {
|
||||
switch {
|
||||
case segments[i-1] == "docker":
|
||||
return "docker", segment
|
||||
case strings.HasPrefix(segments[i-1], "pod"), strings.HasPrefix(segments[i-1], "kubepods"):
|
||||
return "containerd", segment
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// isContainerID reports whether a segment is a container hash. Runtimes emit
|
||||
// the full 64-hex ID; some emit the 12-hex short form, and the cgroup is not
|
||||
// the place to decide which one a UI should show.
|
||||
func isContainerID(segment string) bool {
|
||||
if len(segment) != 64 && len(segment) != 12 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(segment); i++ {
|
||||
c := segment[i]
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
157
agent/internal/system/process_owner_test.go
Normal file
157
agent/internal/system/process_owner_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package system
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseCgroupOwner(t *testing.T) {
|
||||
const dockerID = "a3f1c2d4e5b60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want ProcessOwner
|
||||
}{
|
||||
{
|
||||
name: "v2 system service",
|
||||
body: "0::/system.slice/nginx.service\n",
|
||||
want: ProcessOwner{Cgroup: "/system.slice/nginx.service", Unit: "nginx.service"},
|
||||
},
|
||||
{
|
||||
name: "v2 user scope keeps the innermost unit",
|
||||
body: "0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-firefox-4090.scope\n",
|
||||
want: ProcessOwner{
|
||||
Cgroup: "/user.slice/user-1000.slice/user@1000.service/app.slice/app-firefox-4090.scope",
|
||||
Unit: "app-firefox-4090.scope",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "docker systemd driver",
|
||||
body: "0::/system.slice/docker-" + dockerID + ".scope\n",
|
||||
want: ProcessOwner{
|
||||
Cgroup: "/system.slice/docker-" + dockerID + ".scope",
|
||||
ContainerID: dockerID,
|
||||
ContainerRuntime: "docker",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "docker cgroupfs driver",
|
||||
body: "0::/docker/" + dockerID + "\n",
|
||||
want: ProcessOwner{
|
||||
Cgroup: "/docker/" + dockerID,
|
||||
ContainerID: dockerID,
|
||||
ContainerRuntime: "docker",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "podman",
|
||||
body: "0::/machine.slice/libpod-" + dockerID + ".scope\n",
|
||||
want: ProcessOwner{
|
||||
Cgroup: "/machine.slice/libpod-" + dockerID + ".scope",
|
||||
ContainerID: dockerID,
|
||||
ContainerRuntime: "podman",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cri-containerd under kubepods",
|
||||
body: "0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podabc.slice/cri-containerd-" + dockerID + ".scope\n",
|
||||
want: ProcessOwner{
|
||||
Cgroup: "/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podabc.slice/cri-containerd-" + dockerID + ".scope",
|
||||
ContainerID: dockerID,
|
||||
ContainerRuntime: "containerd",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "kubepods bare id",
|
||||
body: "0::/kubepods/burstable/pod9c4f/" + dockerID + "\n",
|
||||
want: ProcessOwner{
|
||||
Cgroup: "/kubepods/burstable/pod9c4f/" + dockerID,
|
||||
ContainerID: dockerID,
|
||||
ContainerRuntime: "containerd",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lxc payload",
|
||||
body: "0::/lxc.payload.mail/system.slice/postfix.service\n",
|
||||
want: ProcessOwner{
|
||||
Cgroup: "/lxc.payload.mail/system.slice/postfix.service",
|
||||
Unit: "postfix.service",
|
||||
ContainerID: "mail",
|
||||
ContainerRuntime: "lxc",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lxc plain",
|
||||
body: "0::/lxc/mail\n",
|
||||
want: ProcessOwner{Cgroup: "/lxc/mail", ContainerID: "mail", ContainerRuntime: "lxc"},
|
||||
},
|
||||
{
|
||||
name: "v1 prefers the systemd hierarchy",
|
||||
body: "12:pids:/\n" +
|
||||
"6:memory:/system.slice\n" +
|
||||
"1:name=systemd:/system.slice/sshd.service\n",
|
||||
want: ProcessOwner{Cgroup: "/system.slice/sshd.service", Unit: "sshd.service"},
|
||||
},
|
||||
{
|
||||
name: "v1 without systemd falls back to a named controller",
|
||||
body: "6:memory:/system.slice/cron.service\n5:cpu:/\n",
|
||||
want: ProcessOwner{Cgroup: "/system.slice/cron.service", Unit: "cron.service"},
|
||||
},
|
||||
{
|
||||
name: "kernel thread at the root has no owner",
|
||||
body: "0::/\n",
|
||||
want: ProcessOwner{},
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
body: "",
|
||||
want: ProcessOwner{},
|
||||
},
|
||||
{
|
||||
name: "malformed lines are skipped",
|
||||
body: "garbage\n0::/system.slice/chronyd.service\n",
|
||||
want: ProcessOwner{Cgroup: "/system.slice/chronyd.service", Unit: "chronyd.service"},
|
||||
},
|
||||
{
|
||||
name: "a scope that is not a container id stays a unit",
|
||||
body: "0::/system.slice/docker-notahash.scope\n",
|
||||
want: ProcessOwner{
|
||||
Cgroup: "/system.slice/docker-notahash.scope",
|
||||
Unit: "docker-notahash.scope",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := parseCgroupOwner(tc.body)
|
||||
if got != tc.want {
|
||||
t.Fatalf("parseCgroupOwner(%q)\n got: %+v\nwant: %+v", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsContainerID(t *testing.T) {
|
||||
valid := []string{
|
||||
"a3f1c2d4e5b60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
|
||||
"a3f1c2d4e5b6",
|
||||
}
|
||||
for _, id := range valid {
|
||||
if !isContainerID(id) {
|
||||
t.Errorf("isContainerID(%q) = false, want true", id)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
"nginx.service",
|
||||
"A3F1C2D4E5B6", // runtimes emit lowercase; uppercase is something else
|
||||
"a3f1c2d4e5b", // 11
|
||||
"a3f1c2d4e5b60", // 13
|
||||
"g3f1c2d4e5b6",
|
||||
}
|
||||
for _, id := range invalid {
|
||||
if isContainerID(id) {
|
||||
t.Errorf("isContainerID(%q) = true, want false", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
39
agent/internal/system/process_test.go
Normal file
39
agent/internal/system/process_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetTopProcesses(t *testing.T) {
|
||||
procs, err := GetTopProcesses(5)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopProcesses(5) error: %v", err)
|
||||
}
|
||||
if len(procs) == 0 {
|
||||
t.Fatal("expected at least one process")
|
||||
}
|
||||
|
||||
t.Logf("Top %d processes (on %s):", len(procs), runtime.GOOS)
|
||||
for i, p := range procs {
|
||||
t.Logf(" %d. %s (pid=%d) cpu=%.1f%% mem=%.1f%%", i+1, p.Name, p.PID, p.CPU, p.Mem)
|
||||
if p.Name == "" {
|
||||
t.Errorf("process %d has empty name", p.PID)
|
||||
}
|
||||
if p.PID == 0 {
|
||||
t.Error("process has zero PID")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTopProcessesLimit(t *testing.T) {
|
||||
for _, limit := range []int{1, 3, 10} {
|
||||
procs, err := GetTopProcesses(limit)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopProcesses(%d) error: %v", limit, err)
|
||||
}
|
||||
if len(procs) > limit {
|
||||
t.Errorf("GetTopProcesses(%d) returned %d processes", limit, len(procs))
|
||||
}
|
||||
}
|
||||
}
|
||||
57
agent/internal/system/process_windows.go
Normal file
57
agent/internal/system/process_windows.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getTopProcesses on Windows uses `tasklist /FO CSV /NH` for a no-dependency
|
||||
// snapshot. WMI would be more precise but requires PowerShell overhead.
|
||||
func getTopProcesses(limit int) ([]TopProcess, error) {
|
||||
cmd := exec.Command("tasklist", "/FO", "CSV", "/NH")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader := csv.NewReader(strings.NewReader(string(out)))
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var procs []TopProcess
|
||||
for _, row := range records {
|
||||
if len(row) < 5 {
|
||||
continue
|
||||
}
|
||||
name := strings.Trim(row[0], "\"")
|
||||
pid, _ := strconv.Atoi(strings.Trim(row[1], "\""))
|
||||
// tasklist doesn't give CPU%; mem is in KB
|
||||
memStr := strings.ReplaceAll(strings.Trim(row[4], "\""), ".", "")
|
||||
memKB, _ := strconv.ParseUint(memStr, 10, 64)
|
||||
memPercent := float64(memKB) / (1024 * 1024) * 100 // rough; real impl needs total mem
|
||||
|
||||
procs = append(procs, TopProcess{
|
||||
Name: name,
|
||||
PID: pid,
|
||||
CPU: 0, // tasklist doesn't report CPU%
|
||||
Mem: memPercent,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(procs, func(i, j int) bool {
|
||||
return procs[i].Mem > procs[j].Mem // sort by mem since CPU unavailable
|
||||
})
|
||||
|
||||
if limit > 0 && len(procs) > limit {
|
||||
procs = procs[:limit]
|
||||
}
|
||||
return procs, nil
|
||||
}
|
||||
22
agent/internal/system/services.go
Normal file
22
agent/internal/system/services.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package system
|
||||
|
||||
import "time"
|
||||
|
||||
type ServiceSnapshot struct {
|
||||
Manager string `json:"manager"`
|
||||
Services []Service `json:"services"`
|
||||
Count int `json:"count"`
|
||||
Running int `json:"running"`
|
||||
Failed int `json:"failed"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
LoadState string `json:"load_state,omitempty"`
|
||||
ActiveState string `json:"active_state"`
|
||||
SubState string `json:"sub_state,omitempty"`
|
||||
}
|
||||
|
||||
func GetServicesSnapshot() (*ServiceSnapshot, error) { return getServicesSnapshot() }
|
||||
51
agent/internal/system/services_linux.go
Normal file
51
agent/internal/system/services_linux.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
//go:build linux
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type systemdUnit struct {
|
||||
Unit string `json:"unit"`
|
||||
Load string `json:"load"`
|
||||
Active string `json:"active"`
|
||||
Sub string `json:"sub"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func getServicesSnapshot() (*ServiceSnapshot, error) {
|
||||
command := exec.Command("systemctl", "list-units", "--type=service", "--all", "--output=json", "--no-pager")
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list systemd services: %w", err)
|
||||
}
|
||||
var units []systemdUnit
|
||||
if err := json.Unmarshal(output, &units); err != nil {
|
||||
return nil, fmt.Errorf("decode systemd service list: %w", err)
|
||||
}
|
||||
snapshot := &ServiceSnapshot{Manager: "systemd", CollectedAt: time.Now().UTC()}
|
||||
for _, unit := range units {
|
||||
name := strings.TrimSuffix(unit.Unit, ".service")
|
||||
snapshot.Services = append(snapshot.Services, Service{Name: name, Description: unit.Description, LoadState: unit.Load, ActiveState: unit.Active, SubState: unit.Sub})
|
||||
if unit.Active == "active" {
|
||||
snapshot.Running++
|
||||
}
|
||||
if unit.Active == "failed" {
|
||||
snapshot.Failed++
|
||||
}
|
||||
}
|
||||
sort.Slice(snapshot.Services, func(i, j int) bool {
|
||||
if snapshot.Services[i].ActiveState != snapshot.Services[j].ActiveState {
|
||||
return snapshot.Services[i].ActiveState == "failed"
|
||||
}
|
||||
return snapshot.Services[i].Name < snapshot.Services[j].Name
|
||||
})
|
||||
snapshot.Count = len(snapshot.Services)
|
||||
return snapshot, nil
|
||||
}
|
||||
9
agent/internal/system/services_other.go
Normal file
9
agent/internal/system/services_other.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//go:build !linux
|
||||
|
||||
package system
|
||||
|
||||
import "fmt"
|
||||
|
||||
func getServicesSnapshot() (*ServiceSnapshot, error) {
|
||||
return nil, fmt.Errorf("service inventory not supported on this platform")
|
||||
}
|
||||
80
agent/internal/system/software.go
Normal file
80
agent/internal/system/software.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package system
|
||||
|
||||
import "time"
|
||||
|
||||
const MaxPackageFiles = 5000
|
||||
|
||||
// SoftwareSnapshot is the Agent-owned view of installed software. Managers
|
||||
// are reported independently so one broken inventory source does not turn
|
||||
// another manager's known state into absence.
|
||||
type SoftwareSnapshot struct {
|
||||
Supported bool `json:"supported"`
|
||||
Managers []SoftwareManager `json:"managers"`
|
||||
Packages []InstalledPackage `json:"packages"`
|
||||
Count int `json:"count"`
|
||||
ExplicitCount int `json:"explicit_count"`
|
||||
DependencyCount int `json:"dependency_count"`
|
||||
ForeignCount int `json:"foreign_count"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
// SoftwareManager records the state of one inventory source. Available and
|
||||
// Error stay separate: installed-but-failing is not the same as absent.
|
||||
type SoftwareManager struct {
|
||||
Name string `json:"name"`
|
||||
Available bool `json:"available"`
|
||||
Count int `json:"count"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// InstalledPackage is the common local-machine package identity. Identity is
|
||||
// the exact manager-owned query key; Name is the human/cross-domain package
|
||||
// name used to join pending updates where that mapping is unambiguous.
|
||||
type InstalledPackage struct {
|
||||
PackageType string `json:"package_type"`
|
||||
Identity string `json:"identity"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Architecture string `json:"architecture,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Repository string `json:"repository,omitempty"`
|
||||
InstallReason string `json:"install_reason"` // explicit, dependency, unknown
|
||||
Origin string `json:"origin"` // repository, foreign, unknown
|
||||
InstalledSizeBytes uint64 `json:"installed_size_bytes,omitempty"`
|
||||
InstalledAt string `json:"installed_at,omitempty"`
|
||||
}
|
||||
|
||||
// PackageDetail adds provenance and relationship data only when an operator
|
||||
// drills into one package. Files are bounded; FileCount preserves the real
|
||||
// total when the returned list is truncated.
|
||||
type PackageDetail struct {
|
||||
InstalledPackage
|
||||
URL string `json:"url,omitempty"`
|
||||
Licenses []string `json:"licenses,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
DependsOn []string `json:"depends_on,omitempty"`
|
||||
OptionalDependencies []string `json:"optional_dependencies,omitempty"`
|
||||
RequiredBy []string `json:"required_by,omitempty"`
|
||||
Provides []string `json:"provides,omitempty"`
|
||||
ConflictsWith []string `json:"conflicts_with,omitempty"`
|
||||
Replaces []string `json:"replaces,omitempty"`
|
||||
Packager string `json:"packager,omitempty"`
|
||||
BuildDate string `json:"build_date,omitempty"`
|
||||
Files []string `json:"files,omitempty"`
|
||||
FileCount int `json:"file_count"`
|
||||
FilesTruncated bool `json:"files_truncated"`
|
||||
}
|
||||
|
||||
// SoftwareOwner is the package identity that owns an executable path.
|
||||
type SoftwareOwner struct {
|
||||
PackageType string `json:"package_type"`
|
||||
PackageName string `json:"package_name"`
|
||||
}
|
||||
|
||||
func GetSoftwareSnapshot() (*SoftwareSnapshot, error) { return getSoftwareSnapshot() }
|
||||
|
||||
func GetPackageDetail(packageType, identity string) (*PackageDetail, error) {
|
||||
return getPackageDetail(packageType, identity)
|
||||
}
|
||||
|
||||
func FindSoftwareOwner(path string) (*SoftwareOwner, error) { return findSoftwareOwner(path) }
|
||||
512
agent/internal/system/software_linux.go
Normal file
512
agent/internal/system/software_linux.go
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
//go:build linux
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var errSoftwareManagerUnavailable = errors.New("software manager unavailable")
|
||||
|
||||
type softwareCollector struct {
|
||||
name string
|
||||
binary string
|
||||
inventory func() ([]InstalledPackage, error)
|
||||
detail func(string) (*PackageDetail, error)
|
||||
owner func(string) (*SoftwareOwner, error)
|
||||
}
|
||||
|
||||
func linuxSoftwareCollectors() []softwareCollector {
|
||||
return []softwareCollector{
|
||||
{name: "pacman", binary: "pacman", inventory: pacmanInventory, detail: pacmanDetail, owner: pacmanOwner},
|
||||
{name: "apt", binary: "dpkg-query", inventory: dpkgInventory, detail: dpkgDetail, owner: dpkgOwner},
|
||||
{name: "dnf", binary: "rpm", inventory: rpmInventory, detail: rpmDetail, owner: rpmOwner},
|
||||
}
|
||||
}
|
||||
|
||||
func getSoftwareSnapshot() (*SoftwareSnapshot, error) {
|
||||
snapshot := &SoftwareSnapshot{Supported: true, CollectedAt: time.Now().UTC()}
|
||||
for _, collector := range linuxSoftwareCollectors() {
|
||||
state := SoftwareManager{Name: collector.name}
|
||||
if _, err := exec.LookPath(collector.binary); err != nil {
|
||||
snapshot.Managers = append(snapshot.Managers, state)
|
||||
continue
|
||||
}
|
||||
state.Available = true
|
||||
packages, err := collector.inventory()
|
||||
if err != nil {
|
||||
state.Error = conciseCommandError(err)
|
||||
snapshot.Managers = append(snapshot.Managers, state)
|
||||
continue
|
||||
}
|
||||
state.Count = len(packages)
|
||||
snapshot.Managers = append(snapshot.Managers, state)
|
||||
snapshot.Packages = append(snapshot.Packages, packages...)
|
||||
}
|
||||
|
||||
sort.Slice(snapshot.Packages, func(i, j int) bool {
|
||||
if snapshot.Packages[i].Name != snapshot.Packages[j].Name {
|
||||
return snapshot.Packages[i].Name < snapshot.Packages[j].Name
|
||||
}
|
||||
return snapshot.Packages[i].PackageType < snapshot.Packages[j].PackageType
|
||||
})
|
||||
snapshot.Count = len(snapshot.Packages)
|
||||
for _, pkg := range snapshot.Packages {
|
||||
switch pkg.InstallReason {
|
||||
case "explicit":
|
||||
snapshot.ExplicitCount++
|
||||
case "dependency":
|
||||
snapshot.DependencyCount++
|
||||
}
|
||||
if pkg.Origin == "foreign" {
|
||||
snapshot.ForeignCount++
|
||||
}
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func getPackageDetail(packageType, identity string) (*PackageDetail, error) {
|
||||
if !validPackageIdentity(identity) {
|
||||
return nil, fmt.Errorf("invalid package identity")
|
||||
}
|
||||
for _, collector := range linuxSoftwareCollectors() {
|
||||
if collector.name != packageType {
|
||||
continue
|
||||
}
|
||||
if _, err := exec.LookPath(collector.binary); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", packageType, errSoftwareManagerUnavailable)
|
||||
}
|
||||
return collector.detail(identity)
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported package manager %q", packageType)
|
||||
}
|
||||
|
||||
func findSoftwareOwner(path string) (*SoftwareOwner, error) {
|
||||
if path == "" || !strings.HasPrefix(path, "/") || strings.ContainsRune(path, '\x00') {
|
||||
return nil, fmt.Errorf("invalid executable path")
|
||||
}
|
||||
var lastErr error
|
||||
for _, collector := range linuxSoftwareCollectors() {
|
||||
if _, err := exec.LookPath(collector.binary); err != nil {
|
||||
continue
|
||||
}
|
||||
owner, err := collector.owner(path)
|
||||
if err == nil && owner != nil {
|
||||
return owner, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errSoftwareManagerUnavailable
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func packageCommand(binary string, args ...string) ([]byte, error) {
|
||||
path, err := exec.LookPath(binary)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", binary, errSoftwareManagerUnavailable)
|
||||
}
|
||||
command := exec.Command(path, args...)
|
||||
command.Env = []string{
|
||||
"LC_ALL=C",
|
||||
"LANG=C",
|
||||
"PATH=/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
}
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s query failed: %w", binary, err)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func conciseCommandError(err error) string {
|
||||
if errors.Is(err, errSoftwareManagerUnavailable) {
|
||||
return "unavailable"
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "query exited " + strconv.Itoa(exitErr.ExitCode())
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func validPackageIdentity(identity string) bool {
|
||||
if identity == "" || len(identity) > 255 || (!unicode.IsLetter(rune(identity[0])) && !unicode.IsDigit(rune(identity[0]))) {
|
||||
return false
|
||||
}
|
||||
for _, char := range identity {
|
||||
if unicode.IsLetter(char) || unicode.IsDigit(char) || strings.ContainsRune("@+_.:-", char) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func lines(output []byte) []string {
|
||||
var result []string
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func stringSet(values []string) map[string]bool {
|
||||
set := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
fields := strings.Fields(value)
|
||||
if len(fields) > 0 {
|
||||
set[fields[0]] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func splitPackageList(value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || value == "None" || value == "(none)" {
|
||||
return nil
|
||||
}
|
||||
fields := strings.Fields(value)
|
||||
result := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(strings.TrimSuffix(field, ","))
|
||||
if field != "" {
|
||||
result = append(result, field)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseColonRecord(output []byte) map[string]string {
|
||||
record := make(map[string]string)
|
||||
lastKey := ""
|
||||
for _, raw := range strings.Split(string(output), "\n") {
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if (raw[0] == ' ' || raw[0] == '\t') && lastKey != "" {
|
||||
continuation := strings.TrimSpace(raw)
|
||||
if continuation != "" {
|
||||
record[lastKey] = strings.TrimSpace(record[lastKey] + " " + continuation)
|
||||
}
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(raw, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
lastKey = ""
|
||||
continue
|
||||
}
|
||||
lastKey = strings.TrimSpace(parts[0])
|
||||
record[lastKey] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func parseHumanSize(value string) uint64 {
|
||||
fields := strings.Fields(strings.ReplaceAll(value, ",", ""))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
amount, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil || amount < 0 {
|
||||
return 0
|
||||
}
|
||||
multiplier := float64(1)
|
||||
if len(fields) > 1 {
|
||||
switch strings.ToLower(fields[1]) {
|
||||
case "kib", "kb":
|
||||
multiplier = 1024
|
||||
case "mib", "mb":
|
||||
multiplier = 1024 * 1024
|
||||
case "gib", "gb":
|
||||
multiplier = 1024 * 1024 * 1024
|
||||
case "tib", "tb":
|
||||
multiplier = 1024 * 1024 * 1024 * 1024
|
||||
}
|
||||
}
|
||||
return uint64(amount * multiplier)
|
||||
}
|
||||
|
||||
func boundedFiles(values []string) ([]string, int, bool) {
|
||||
count := len(values)
|
||||
if count <= MaxPackageFiles {
|
||||
return values, count, false
|
||||
}
|
||||
return values[:MaxPackageFiles], count, true
|
||||
}
|
||||
|
||||
func pacmanInventory() ([]InstalledPackage, error) {
|
||||
installed, err := packageCommand("pacman", "-Q")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
explicitOutput, explicitErr := packageCommand("pacman", "-Qqe")
|
||||
dependencyOutput, dependencyErr := packageCommand("pacman", "-Qqd")
|
||||
foreignOutput, foreignErr := packageCommand("pacman", "-Qm")
|
||||
explicit := map[string]bool{}
|
||||
dependencies := map[string]bool{}
|
||||
foreign := map[string]bool{}
|
||||
if explicitErr == nil {
|
||||
explicit = stringSet(lines(explicitOutput))
|
||||
}
|
||||
if dependencyErr == nil {
|
||||
dependencies = stringSet(lines(dependencyOutput))
|
||||
}
|
||||
if foreignErr == nil {
|
||||
foreign = stringSet(lines(foreignOutput))
|
||||
}
|
||||
|
||||
var packages []InstalledPackage
|
||||
for _, line := range lines(installed) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) != 2 {
|
||||
continue
|
||||
}
|
||||
reason := "unknown"
|
||||
switch {
|
||||
case explicit[fields[0]]:
|
||||
reason = "explicit"
|
||||
case dependencies[fields[0]]:
|
||||
reason = "dependency"
|
||||
}
|
||||
origin := "repository"
|
||||
if foreign[fields[0]] {
|
||||
origin = "foreign"
|
||||
}
|
||||
packages = append(packages, InstalledPackage{
|
||||
PackageType: "pacman", Identity: fields[0], Name: fields[0], Version: fields[1],
|
||||
InstallReason: reason, Origin: origin,
|
||||
})
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func pacmanDetail(identity string) (*PackageDetail, error) {
|
||||
output, err := packageCommand("pacman", "-Qi", "--color=never", identity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record := parseColonRecord(output)
|
||||
origin := "repository"
|
||||
if foreign, err := packageCommand("pacman", "-Qm", identity); err == nil && len(lines(foreign)) > 0 {
|
||||
origin = "foreign"
|
||||
}
|
||||
reason := "unknown"
|
||||
if strings.EqualFold(record["Install Reason"], "Explicitly installed") {
|
||||
reason = "explicit"
|
||||
} else if strings.Contains(strings.ToLower(record["Install Reason"]), "dependency") {
|
||||
reason = "dependency"
|
||||
}
|
||||
detail := &PackageDetail{InstalledPackage: InstalledPackage{
|
||||
PackageType: "pacman", Identity: identity, Name: record["Name"], Version: record["Version"],
|
||||
Architecture: record["Architecture"], Description: record["Description"], InstallReason: reason,
|
||||
Origin: origin, InstalledSizeBytes: parseHumanSize(record["Installed Size"]), InstalledAt: record["Install Date"],
|
||||
}, URL: record["URL"], Licenses: splitPackageList(record["Licenses"]), Groups: splitPackageList(record["Groups"]),
|
||||
DependsOn: splitPackageList(record["Depends On"]), OptionalDependencies: splitPackageList(record["Optional Deps"]),
|
||||
RequiredBy: splitPackageList(record["Required By"]), Provides: splitPackageList(record["Provides"]),
|
||||
ConflictsWith: splitPackageList(record["Conflicts With"]), Replaces: splitPackageList(record["Replaces"]),
|
||||
Packager: record["Packager"], BuildDate: record["Build Date"],
|
||||
}
|
||||
if detail.Name == "" {
|
||||
detail.Name = identity
|
||||
}
|
||||
if output, err := packageCommand("pacman", "-Ql", identity); err == nil {
|
||||
prefix := identity + " "
|
||||
var files []string
|
||||
for _, line := range lines(output) {
|
||||
files = append(files, strings.TrimPrefix(line, prefix))
|
||||
}
|
||||
detail.Files, detail.FileCount, detail.FilesTruncated = boundedFiles(files)
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func pacmanOwner(path string) (*SoftwareOwner, error) {
|
||||
output, err := packageCommand("pacman", "-Qoq", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := lines(output)
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("no pacman package owns path")
|
||||
}
|
||||
return &SoftwareOwner{PackageType: "pacman", PackageName: names[0]}, nil
|
||||
}
|
||||
|
||||
func dpkgInventory() ([]InstalledPackage, error) {
|
||||
output, err := packageCommand("dpkg-query", "-W", "-f=${binary:Package}\t${Version}\t${Architecture}\t${db:Status-Abbrev}\t${binary:Synopsis}\n")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manual := map[string]bool{}
|
||||
if manualOutput, err := packageCommand("apt-mark", "showmanual"); err == nil {
|
||||
manual = stringSet(lines(manualOutput))
|
||||
}
|
||||
var packages []InstalledPackage
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) < 5 || !strings.HasPrefix(fields[3], "ii") {
|
||||
continue
|
||||
}
|
||||
identity := fields[0]
|
||||
name := strings.TrimSuffix(identity, ":"+fields[2])
|
||||
reason := "unknown"
|
||||
if manual[name] || manual[identity] {
|
||||
reason = "explicit"
|
||||
} else if len(manual) > 0 {
|
||||
reason = "dependency"
|
||||
}
|
||||
packages = append(packages, InstalledPackage{
|
||||
PackageType: "apt", Identity: identity, Name: name, Version: fields[1], Architecture: fields[2],
|
||||
Description: fields[4], InstallReason: reason, Origin: "unknown",
|
||||
})
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func dpkgDetail(identity string) (*PackageDetail, error) {
|
||||
format := "${binary:Package}\t${Version}\t${Architecture}\t${binary:Synopsis}\t${Homepage}\t${Installed-Size}\t${Maintainer}\t${Depends}\t${Pre-Depends}\t${Recommends}\t${Suggests}\t${Provides}\t${Conflicts}\t${Replaces}\n"
|
||||
output, err := packageCommand("dpkg-query", "-W", "-f="+format, identity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields := strings.Split(strings.TrimRight(string(output), "\n"), "\t")
|
||||
if len(fields) < 14 {
|
||||
return nil, fmt.Errorf("dpkg returned incomplete package detail")
|
||||
}
|
||||
name := strings.TrimSuffix(fields[0], ":"+fields[2])
|
||||
reason := "unknown"
|
||||
if manual, err := packageCommand("apt-mark", "showmanual"); err == nil {
|
||||
set := stringSet(lines(manual))
|
||||
if set[name] || set[identity] {
|
||||
reason = "explicit"
|
||||
} else {
|
||||
reason = "dependency"
|
||||
}
|
||||
}
|
||||
installedSize, _ := strconv.ParseUint(strings.TrimSpace(fields[5]), 10, 64)
|
||||
detail := &PackageDetail{InstalledPackage: InstalledPackage{
|
||||
PackageType: "apt", Identity: fields[0], Name: name, Version: fields[1], Architecture: fields[2],
|
||||
Description: fields[3], InstallReason: reason, Origin: "unknown", InstalledSizeBytes: installedSize * 1024,
|
||||
}, URL: fields[4], Packager: fields[6]}
|
||||
detail.DependsOn = splitCommaDependencies(strings.Join([]string{fields[7], fields[8]}, ","))
|
||||
detail.OptionalDependencies = splitCommaDependencies(strings.Join([]string{fields[9], fields[10]}, ","))
|
||||
detail.Provides = splitCommaDependencies(fields[11])
|
||||
detail.ConflictsWith = splitCommaDependencies(fields[12])
|
||||
detail.Replaces = splitCommaDependencies(fields[13])
|
||||
if output, err := packageCommand("dpkg-query", "-L", identity); err == nil {
|
||||
detail.Files, detail.FileCount, detail.FilesTruncated = boundedFiles(lines(output))
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func splitCommaDependencies(value string) []string {
|
||||
var result []string
|
||||
for _, dependency := range strings.Split(value, ",") {
|
||||
dependency = strings.TrimSpace(dependency)
|
||||
if dependency != "" && dependency != "<none>" {
|
||||
result = append(result, dependency)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func dpkgOwner(path string) (*SoftwareOwner, error) {
|
||||
output, err := packageCommand("dpkg-query", "-S", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line := strings.SplitN(string(output), "\n", 2)[0]
|
||||
parts := strings.SplitN(line, ": ", 2)
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
return nil, fmt.Errorf("dpkg returned no package owner")
|
||||
}
|
||||
name := parts[0]
|
||||
if index := strings.LastIndex(name, ":"); index > 0 {
|
||||
name = name[:index]
|
||||
}
|
||||
return &SoftwareOwner{PackageType: "apt", PackageName: name}, nil
|
||||
}
|
||||
|
||||
func rpmInventory() ([]InstalledPackage, error) {
|
||||
format := "%{NAME}\t%{NEVRA}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SUMMARY}\t%{SIZE}\t%{INSTALLTIME}\n"
|
||||
output, err := packageCommand("rpm", "-qa", "--qf", format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var packages []InstalledPackage
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) < 7 {
|
||||
continue
|
||||
}
|
||||
size, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||
installed := ""
|
||||
if epoch, err := strconv.ParseInt(fields[6], 10, 64); err == nil && epoch > 0 {
|
||||
installed = time.Unix(epoch, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
packages = append(packages, InstalledPackage{
|
||||
PackageType: "dnf", Identity: fields[1], Name: fields[0], Version: fields[2], Architecture: fields[3],
|
||||
Description: fields[4], InstallReason: "unknown", Origin: "unknown", InstalledSizeBytes: size, InstalledAt: installed,
|
||||
})
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func rpmDetail(identity string) (*PackageDetail, error) {
|
||||
output, err := packageCommand("rpm", "-qi", identity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record := parseColonRecord(output)
|
||||
version := record["Version"]
|
||||
if release := record["Release"]; release != "" {
|
||||
version += "-" + release
|
||||
}
|
||||
detail := &PackageDetail{InstalledPackage: InstalledPackage{
|
||||
PackageType: "dnf", Identity: identity, Name: record["Name"], Version: version,
|
||||
Architecture: record["Architecture"], Description: record["Summary"], InstallReason: "unknown",
|
||||
Origin: "unknown", InstalledSizeBytes: parseHumanSize(record["Size"]), InstalledAt: record["Install Date"],
|
||||
}, URL: record["URL"], Licenses: splitPackageList(record["License"]), Packager: record["Packager"], BuildDate: record["Build Date"]}
|
||||
if output, err := packageCommand("rpm", "-qR", identity); err == nil {
|
||||
for _, requirement := range lines(output) {
|
||||
if !strings.HasPrefix(requirement, "rpmlib(") {
|
||||
detail.DependsOn = append(detail.DependsOn, requirement)
|
||||
}
|
||||
}
|
||||
}
|
||||
if output, err := packageCommand("rpm", "-q", "--provides", identity); err == nil {
|
||||
detail.Provides = lines(output)
|
||||
}
|
||||
if output, err := packageCommand("rpm", "-q", "--whatrequires", detail.Name); err == nil {
|
||||
detail.RequiredBy = lines(output)
|
||||
}
|
||||
if output, err := packageCommand("rpm", "-ql", identity); err == nil {
|
||||
detail.Files, detail.FileCount, detail.FilesTruncated = boundedFiles(lines(output))
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func rpmOwner(path string) (*SoftwareOwner, error) {
|
||||
output, err := packageCommand("rpm", "-qf", "--qf", "%{NAME}\n", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := lines(output)
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("rpm returned no package owner")
|
||||
}
|
||||
return &SoftwareOwner{PackageType: "dnf", PackageName: names[0]}, nil
|
||||
}
|
||||
20
agent/internal/system/software_other.go
Normal file
20
agent/internal/system/software_other.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//go:build !linux
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func getSoftwareSnapshot() (*SoftwareSnapshot, error) {
|
||||
return &SoftwareSnapshot{Supported: false, CollectedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
|
||||
func getPackageDetail(packageType, identity string) (*PackageDetail, error) {
|
||||
return nil, fmt.Errorf("software detail not supported on this platform")
|
||||
}
|
||||
|
||||
func findSoftwareOwner(path string) (*SoftwareOwner, error) {
|
||||
return nil, fmt.Errorf("software ownership not supported on this platform")
|
||||
}
|
||||
413
agent/internal/system/windows.go
Normal file
413
agent/internal/system/windows.go
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package system
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getWindowsInfo gets detailed Windows version information using WMI
|
||||
func getWindowsInfo() string {
|
||||
// Try using wmic for detailed Windows version info
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
// Get Caption (e.g., "Microsoft Windows 10 Pro")
|
||||
caption := ""
|
||||
if data, err := exec.Command(cmd, "os", "get", "Caption", "/value").Output(); err == nil {
|
||||
output := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(output, "Caption=") {
|
||||
caption = strings.TrimPrefix(output, "Caption=")
|
||||
caption = strings.TrimSpace(caption)
|
||||
}
|
||||
}
|
||||
|
||||
// Get Version and Build Number
|
||||
version := ""
|
||||
if data, err := exec.Command(cmd, "os", "get", "Version", "/value").Output(); err == nil {
|
||||
output := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(output, "Version=") {
|
||||
version = strings.TrimPrefix(output, "Version=")
|
||||
version = strings.TrimSpace(version)
|
||||
}
|
||||
}
|
||||
|
||||
// Combine caption and version for clean output
|
||||
if caption != "" && version != "" {
|
||||
return fmt.Sprintf("%s (Build %s)", caption, version)
|
||||
} else if caption != "" {
|
||||
return caption
|
||||
} else if version != "" {
|
||||
return fmt.Sprintf("Windows %s", version)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to basic version detection
|
||||
return "Windows"
|
||||
}
|
||||
|
||||
// getWindowsCPUInfo gets detailed CPU information using WMI
|
||||
func getWindowsCPUInfo() (*CPUInfo, error) {
|
||||
cpu := &CPUInfo{}
|
||||
|
||||
// Try using wmic for CPU information
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
// Get CPU name
|
||||
if data, err := exec.Command(cmd, "cpu", "get", "Name").Output(); err == nil {
|
||||
output := string(data)
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.Contains(line, "Name") {
|
||||
cpu.ModelName = line
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("[ERROR] [agent] [windows_cpu_info] wmic_name_failed error=%v", err)
|
||||
}
|
||||
|
||||
// Get number of cores
|
||||
if data, err := exec.Command(cmd, "cpu", "get", "NumberOfCores").Output(); err == nil {
|
||||
output := string(data)
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.Contains(line, "NumberOfCores") {
|
||||
if cores, err := strconv.Atoi(line); err == nil {
|
||||
cpu.Cores = cores
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("[ERROR] [agent] [windows_cpu_info] wmic_cores_failed error=%v", err)
|
||||
}
|
||||
|
||||
// Get number of logical processors (threads)
|
||||
if data, err := exec.Command(cmd, "cpu", "get", "NumberOfLogicalProcessors").Output(); err == nil {
|
||||
output := string(data)
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.Contains(line, "NumberOfLogicalProcessors") {
|
||||
if threads, err := strconv.Atoi(line); err == nil {
|
||||
cpu.Threads = threads
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("[ERROR] [agent] [windows_cpu_info] wmic_threads_failed error=%v", err)
|
||||
}
|
||||
|
||||
// If we couldn't get threads, assume it's equal to cores
|
||||
if cpu.Threads == 0 {
|
||||
cpu.Threads = cpu.Cores
|
||||
}
|
||||
} else {
|
||||
log.Printf("[ERROR] [agent] [windows_cpu_info] wmic_not_found error=%v", err)
|
||||
}
|
||||
|
||||
// Fallback to PowerShell if wmic failed
|
||||
if cpu.ModelName == "" {
|
||||
log.Printf("[INFO] [agent] [windows_cpu_info] falling_back_to_powershell")
|
||||
if psCmd, err := exec.LookPath("powershell"); err == nil {
|
||||
if data, err := exec.Command(psCmd, "-NoProfile", "-NonInteractive", "-Command", "Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1 Name,NumberOfCores,NumberOfLogicalProcessors | ConvertTo-Json").Output(); err == nil {
|
||||
parsed, parseErr := parseWindowsCPUInfoJSON(data)
|
||||
if parseErr != nil {
|
||||
log.Printf("[ERROR] [agent] [windows_cpu_info] powershell_parse_failed error=%v", parseErr)
|
||||
} else {
|
||||
cpu = parsed
|
||||
}
|
||||
} else {
|
||||
log.Printf("[ERROR] [agent] [windows_cpu_info] powershell_fallback_failed error=%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [agent] [windows_cpu_info] model=%q cores=%d threads=%d", cpu.ModelName, cpu.Cores, cpu.Threads)
|
||||
|
||||
return cpu, nil
|
||||
}
|
||||
|
||||
// getWindowsMemoryInfo gets memory information using WMI
|
||||
func getWindowsMemoryInfo() (*MemoryInfo, error) {
|
||||
mem := &MemoryInfo{}
|
||||
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
// Get total memory in bytes
|
||||
if data, err := exec.Command(cmd, "computersystem", "get", "TotalPhysicalMemory").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "TotalPhysicalMemory") {
|
||||
if total, err := strconv.ParseUint(strings.TrimSpace(line), 10, 64); err == nil {
|
||||
mem.Total = total
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get available memory using PowerShell (more accurate than wmic for available memory)
|
||||
if cmd, err := exec.LookPath("powershell"); err == nil {
|
||||
if data, err := exec.Command(cmd, "-Command",
|
||||
"(Get-Counter '\\Memory\\Available MBytes').CounterSamples.CookedValue").Output(); err == nil {
|
||||
if available, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64); err == nil {
|
||||
mem.Available = uint64(available * 1024 * 1024) // Convert MB to bytes
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: estimate available memory (this is not very accurate)
|
||||
mem.Available = mem.Total / 4 // Rough estimate: 25% available
|
||||
}
|
||||
|
||||
mem.Used = mem.Total - mem.Available
|
||||
if mem.Total > 0 {
|
||||
mem.UsedPercent = float64(mem.Used) / float64(mem.Total) * 100
|
||||
}
|
||||
}
|
||||
|
||||
return mem, nil
|
||||
}
|
||||
|
||||
// getWindowsDiskInfo gets disk information using WMI
|
||||
func getWindowsDiskInfo() ([]DiskInfo, error) {
|
||||
var disks []DiskInfo
|
||||
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
// Get logical disk information - use /value format for reliable parsing
|
||||
if data, err := exec.Command(cmd, "logicaldisk", "get", "DeviceID,Size,FreeSpace,FileSystem", "/format:csv").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
// Skip header and empty lines
|
||||
if i == 0 || line == "" || !strings.Contains(line, ",") {
|
||||
continue
|
||||
}
|
||||
|
||||
// CSV format: Node,DeviceID,FileSystem,FreeSpace,Size
|
||||
fields := strings.Split(line, ",")
|
||||
if len(fields) >= 5 {
|
||||
deviceID := strings.TrimSpace(fields[1])
|
||||
filesystem := strings.TrimSpace(fields[2])
|
||||
freeSpaceStr := strings.TrimSpace(fields[3])
|
||||
sizeStr := strings.TrimSpace(fields[4])
|
||||
|
||||
// Skip if no size info (e.g., CD-ROM drives)
|
||||
if sizeStr == "" || freeSpaceStr == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
disk := DiskInfo{
|
||||
Mountpoint: deviceID,
|
||||
Filesystem: filesystem,
|
||||
}
|
||||
|
||||
// Parse sizes (wmic outputs in bytes)
|
||||
if total, err := strconv.ParseUint(sizeStr, 10, 64); err == nil {
|
||||
disk.Total = total
|
||||
}
|
||||
if available, err := strconv.ParseUint(freeSpaceStr, 10, 64); err == nil {
|
||||
disk.Available = available
|
||||
}
|
||||
|
||||
// Calculate used space
|
||||
if disk.Total > 0 && disk.Available <= disk.Total {
|
||||
disk.Used = disk.Total - disk.Available
|
||||
disk.UsedPercent = float64(disk.Used) / float64(disk.Total) * 100
|
||||
}
|
||||
|
||||
// Only add disks with valid size info
|
||||
if disk.Total > 0 {
|
||||
disks = append(disks, disk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return disks, nil
|
||||
}
|
||||
|
||||
// getWindowsProcessCount gets the number of running processes using WMI
|
||||
func getWindowsProcessCount() (int, error) {
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
if data, err := exec.Command(cmd, "process", "get", "ProcessId").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
// Count non-empty lines that don't contain the header
|
||||
count := 0
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "ProcessId") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// getWindowsUptime gets system uptime using WMI or PowerShell
|
||||
func getWindowsUptime() (string, error) {
|
||||
// Try PowerShell first for more accurate uptime
|
||||
if cmd, err := exec.LookPath("powershell"); err == nil {
|
||||
// Get uptime in seconds for precise calculation
|
||||
if data, err := exec.Command(cmd, "-Command",
|
||||
"(New-TimeSpan -Start (Get-CimInstance Win32_OperatingSystem).LastBootUpTime -End (Get-Date)).TotalSeconds").Output(); err == nil {
|
||||
secondsStr := strings.TrimSpace(string(data))
|
||||
if seconds, err := strconv.ParseFloat(secondsStr, 64); err == nil {
|
||||
return formatUptimeFromSeconds(seconds), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to wmic with manual parsing
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
if data, err := exec.Command(cmd, "os", "get", "LastBootUpTime", "/value").Output(); err == nil {
|
||||
output := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(output, "LastBootUpTime=") {
|
||||
wmiTime := strings.TrimPrefix(output, "LastBootUpTime=")
|
||||
wmiTime = strings.TrimSpace(wmiTime)
|
||||
// Parse WMI datetime format: 20251025123045.123456-300
|
||||
if len(wmiTime) >= 14 {
|
||||
// Extract date/time components: YYYYMMDDHHmmss
|
||||
year := wmiTime[0:4]
|
||||
month := wmiTime[4:6]
|
||||
day := wmiTime[6:8]
|
||||
hour := wmiTime[8:10]
|
||||
minute := wmiTime[10:12]
|
||||
second := wmiTime[12:14]
|
||||
|
||||
bootTimeStr := fmt.Sprintf("%s-%s-%s %s:%s:%s", year, month, day, hour, minute, second)
|
||||
return fmt.Sprintf("Since %s", bootTimeStr), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "Unknown", nil
|
||||
}
|
||||
|
||||
// formatUptimeFromSeconds formats uptime from seconds into human readable format
|
||||
func formatUptimeFromSeconds(seconds float64) string {
|
||||
days := int(seconds / 86400)
|
||||
hours := int((seconds - float64(days*86400)) / 3600)
|
||||
minutes := int((seconds - float64(days*86400) - float64(hours*3600)) / 60)
|
||||
|
||||
if days > 0 {
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%d days, %d hours", days, hours)
|
||||
}
|
||||
return fmt.Sprintf("%d days", days)
|
||||
} else if hours > 0 {
|
||||
if minutes > 0 {
|
||||
return fmt.Sprintf("%d hours, %d minutes", hours, minutes)
|
||||
}
|
||||
return fmt.Sprintf("%d hours", hours)
|
||||
} else {
|
||||
return fmt.Sprintf("%d minutes", minutes)
|
||||
}
|
||||
}
|
||||
|
||||
// formatUptimeFromDays formats uptime from days into human readable format
|
||||
func formatUptimeFromDays(days float64) string {
|
||||
if days < 1 {
|
||||
hours := int(days * 24)
|
||||
return fmt.Sprintf("%d hours", hours)
|
||||
} else if days < 7 {
|
||||
hours := int((days - float64(int(days))) * 24)
|
||||
return fmt.Sprintf("%d days, %d hours", int(days), hours)
|
||||
} else {
|
||||
weeks := int(days / 7)
|
||||
remainingDays := int(days) % 7
|
||||
return fmt.Sprintf("%d weeks, %d days", weeks, remainingDays)
|
||||
}
|
||||
}
|
||||
|
||||
// getWindowsIPAddress gets the primary IP address using Windows commands
|
||||
func getWindowsIPAddress() (string, error) {
|
||||
// Try using ipconfig
|
||||
if cmd, err := exec.LookPath("ipconfig"); err == nil {
|
||||
if data, err := exec.Command(cmd, "/all").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "IPv4 Address") || strings.HasPrefix(line, "IP Address") {
|
||||
// Extract the IP address from the line
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) >= 2 {
|
||||
ip := strings.TrimSpace(parts[1])
|
||||
// Prefer non-169.254.x.x (APIPA) addresses
|
||||
if !strings.HasPrefix(ip, "169.254.") {
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to localhost
|
||||
return "127.0.0.1", nil
|
||||
}
|
||||
|
||||
// Override the generic functions with Windows-specific implementations
|
||||
func init() {
|
||||
// This function will be called when the package is imported on Windows
|
||||
}
|
||||
|
||||
// getWindowsHardwareInfo gets additional hardware information
|
||||
func getWindowsHardwareInfo() map[string]string {
|
||||
hardware := make(map[string]string)
|
||||
|
||||
if cmd, err := exec.LookPath("wmic"); err == nil {
|
||||
// Get motherboard information
|
||||
if data, err := exec.Command(cmd, "baseboard", "get", "Manufacturer,Product,SerialNumber").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "Manufacturer") &&
|
||||
!strings.Contains(line, "Product") && !strings.Contains(line, "SerialNumber") {
|
||||
// Simplified parsing — splits on whitespace for key-value pairs
|
||||
if strings.Contains(line, " ") {
|
||||
hardware["motherboard"] = strings.TrimSpace(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get BIOS information
|
||||
if data, err := exec.Command(cmd, "bios", "get", "Version,SerialNumber").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "Version") &&
|
||||
!strings.Contains(line, "SerialNumber") {
|
||||
hardware["bios"] = strings.TrimSpace(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get GPU information
|
||||
if data, err := exec.Command(cmd, "path", "win32_VideoController", "get", "Name").Output(); err == nil {
|
||||
lines := strings.Split(string(data), "\n")
|
||||
gpus := []string{}
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" && !strings.Contains(line, "Name") {
|
||||
gpu := strings.TrimSpace(line)
|
||||
if gpu != "" {
|
||||
gpus = append(gpus, gpu)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(gpus) > 0 {
|
||||
hardware["graphics"] = strings.Join(gpus, ", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hardware
|
||||
}
|
||||
63
agent/internal/system/windows_cpu_parse.go
Normal file
63
agent/internal/system/windows_cpu_parse.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type windowsCPUInfoJSON struct {
|
||||
Name string `json:"Name"`
|
||||
NumberOfCores json.RawMessage `json:"NumberOfCores"`
|
||||
NumberOfLogicalProcessors json.RawMessage `json:"NumberOfLogicalProcessors"`
|
||||
}
|
||||
|
||||
func parseWindowsCPUInfoJSON(data []byte) (*CPUInfo, error) {
|
||||
var item windowsCPUInfoJSON
|
||||
if err := json.Unmarshal(data, &item); err != nil {
|
||||
var items []windowsCPUInfoJSON
|
||||
if arrayErr := json.Unmarshal(data, &items); arrayErr != nil || len(items) == 0 {
|
||||
return nil, fmt.Errorf("invalid Windows CPU JSON: %w", err)
|
||||
}
|
||||
item = items[0]
|
||||
}
|
||||
|
||||
cpu := &CPUInfo{
|
||||
ModelName: strings.TrimSpace(item.Name),
|
||||
Cores: parseWindowsJSONInt(item.NumberOfCores),
|
||||
Threads: parseWindowsJSONInt(item.NumberOfLogicalProcessors),
|
||||
}
|
||||
if cpu.Threads == 0 {
|
||||
cpu.Threads = cpu.Cores
|
||||
}
|
||||
if cpu.ModelName == "" && cpu.Cores == 0 && cpu.Threads == 0 {
|
||||
return nil, fmt.Errorf("Windows CPU JSON did not include usable fields")
|
||||
}
|
||||
return cpu, nil
|
||||
}
|
||||
|
||||
func parseWindowsJSONInt(raw json.RawMessage) int {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return 0
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := json.Unmarshal(raw, &n); err == nil {
|
||||
return n
|
||||
}
|
||||
|
||||
var f float64
|
||||
if err := json.Unmarshal(raw, &f); err == nil {
|
||||
return int(f)
|
||||
}
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
60
agent/internal/system/windows_cpu_parse_test.go
Normal file
60
agent/internal/system/windows_cpu_parse_test.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package system
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseWindowsCPUInfoJSON(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"Name": "Intel(R) Core(TM) i7-6700 CPU @ 3.40GHz",
|
||||
"NumberOfCores": 4,
|
||||
"NumberOfLogicalProcessors": 8
|
||||
}`)
|
||||
|
||||
cpu, err := parseWindowsCPUInfoJSON(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWindowsCPUInfoJSON() error = %v", err)
|
||||
}
|
||||
|
||||
if cpu.ModelName != "Intel(R) Core(TM) i7-6700 CPU @ 3.40GHz" {
|
||||
t.Fatalf("ModelName = %q", cpu.ModelName)
|
||||
}
|
||||
if cpu.Cores != 4 {
|
||||
t.Fatalf("Cores = %d, want 4", cpu.Cores)
|
||||
}
|
||||
if cpu.Threads != 8 {
|
||||
t.Fatalf("Threads = %d, want 8", cpu.Threads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWindowsCPUInfoJSONAcceptsStringNumbers(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"Name": "AMD Ryzen",
|
||||
"NumberOfCores": "6",
|
||||
"NumberOfLogicalProcessors": "12"
|
||||
}`)
|
||||
|
||||
cpu, err := parseWindowsCPUInfoJSON(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWindowsCPUInfoJSON() error = %v", err)
|
||||
}
|
||||
|
||||
if cpu.Cores != 6 || cpu.Threads != 12 {
|
||||
t.Fatalf("CPU counts = cores:%d threads:%d, want cores:6 threads:12", cpu.Cores, cpu.Threads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWindowsCPUInfoJSONDefaultsThreadsToCores(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"Name": "CPU",
|
||||
"NumberOfCores": 4,
|
||||
"NumberOfLogicalProcessors": null
|
||||
}`)
|
||||
|
||||
cpu, err := parseWindowsCPUInfoJSON(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWindowsCPUInfoJSON() error = %v", err)
|
||||
}
|
||||
|
||||
if cpu.Threads != 4 {
|
||||
t.Fatalf("Threads = %d, want 4", cpu.Threads)
|
||||
}
|
||||
}
|
||||
39
agent/internal/system/windows_stub.go
Normal file
39
agent/internal/system/windows_stub.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package system
|
||||
|
||||
// Stub functions for non-Windows platforms
|
||||
// These return empty/default values on non-Windows systems
|
||||
|
||||
func getWindowsCPUInfo() (*CPUInfo, error) {
|
||||
return &CPUInfo{}, nil
|
||||
}
|
||||
|
||||
func getWindowsMemoryInfo() (*MemoryInfo, error) {
|
||||
return &MemoryInfo{}, nil
|
||||
}
|
||||
|
||||
func getWindowsDiskInfo() ([]DiskInfo, error) {
|
||||
return []DiskInfo{}, nil
|
||||
}
|
||||
|
||||
func getWindowsProcessCount() (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func getWindowsUptime() (string, error) {
|
||||
return "Unknown", nil
|
||||
}
|
||||
|
||||
func getWindowsIPAddress() (string, error) {
|
||||
return "127.0.0.1", nil
|
||||
}
|
||||
|
||||
func getWindowsHardwareInfo() map[string]string {
|
||||
return make(map[string]string)
|
||||
}
|
||||
|
||||
func getWindowsInfo() string {
|
||||
return "Windows"
|
||||
}
|
||||
Loading…
Reference in a new issue