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:
Fimeg 2026-09-08 21:59:33 -04:00
commit 67e26be2d9
838 changed files with 157302 additions and 0 deletions

View 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()
}

View 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
}

View 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")
}

View 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 ""
}

View 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)
}
}

View 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, ""
}

View 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
}

View 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)
}
}

View 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()")
}

View 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")
}

View 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.")
}
}

View 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 &copy, 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
}

View 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
}

View 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)
}

View 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)
}
}

View 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)
}

View 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
}

View 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])
}
}

View 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
}

View 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)
}

View 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

View 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")
}

View 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")
}

View 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
}

View 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)
}
}
}

View 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))
}
}
}

View 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
}

View 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() }

View 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
}

View 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")
}

View 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) }

View 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
}

View 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")
}

View 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
}

View 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
}

View 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)
}
}

View 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"
}