diff --git a/Makefile b/Makefile index 55e1df9..0466429 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,21 @@ -# Infer VERSION from the most recent tag; if none, use short commit hash. -VERSION ?= $(shell git describe --tags --abbrev=0 2>/dev/null || \ - git rev-parse --short HEAD) +# -------- version detection ------------------------------------------------- +# Use latest annotated/lightweight tag; if none, fall back to short SHA. +VERSION ?= $(shell git describe --tags --always --dirty=-dirty 2>/dev/null) -GOLDFLAGS += -s -w \ - -X 'git.eeqj.de/sneak/sysinfo/internal/sysinfo.Version=$(VERSION)' +# -------- linker flags ------------------------------------------------------- +# No inner quotes around -X value — go tool handles the spaces correctly. +LDFLAGS = -s -w -X "git.eeqj.de/sneak/sysinfo/internal/sysinfo.Version=$(VERSION)" -all: build +# -------- generic go build/install ------------------------------------------ +PKG_CMD = ./cmd/sysinfo build: - GOFLAGS=-ldflags="$(GOLDFLAGS)" go build ./cmd/sysinfo + go build -ldflags '$(LDFLAGS)' $(PKG_CMD) install: - GOFLAGS=-ldflags="$(GOLDFLAGS)" go install ./cmd/sysinfo + go install -ldflags '$(LDFLAGS)' $(PKG_CMD) -.PHONY: all build install +clean: + rm -f sysinfo + +.PHONY: build install clean diff --git a/internal/sysinfo/collector.go b/internal/sysinfo/collector.go new file mode 100644 index 0000000..7dfe6df --- /dev/null +++ b/internal/sysinfo/collector.go @@ -0,0 +1,21 @@ +package sysinfo + +import ( + "encoding/json" + "time" +) + +// Context is shared with every Collector. +type Context struct { + Now time.Time + Logf func(string, ...any) + Run func(string, ...string) ([]byte, error) + SafeRun func(string, ...string) string + SafeRead func(string) string +} + +// Collector produces one JSON fragment. +type Collector interface { + Key() string + Collect(*Context) (json.RawMessage, error) +} diff --git a/internal/sysinfo/collector_block.go b/internal/sysinfo/collector_block.go new file mode 100644 index 0000000..ef47115 --- /dev/null +++ b/internal/sysinfo/collector_block.go @@ -0,0 +1,67 @@ +package sysinfo + +import ( + "encoding/json" + "path/filepath" + "regexp" + "strings" +) + +type BlockCollector struct{} + +func (BlockCollector) Key() string { return "blockdevs" } + +func (BlockCollector) Collect(c *Context) (json.RawMessage, error) { + rePU := regexp.MustCompile(`PARTUUID="([^"]+)"`) + all := map[string]any{} + + links, _ := filepath.Glob("/dev/disk/by-id/*") + seen := map[string]bool{} + for _, link := range links { + base := filepath.Base(link) + target, _ := filepath.EvalSymlinks(link) + dev := filepath.Base(target) + if seen[dev] { + continue + } + seen[dev] = true + + bd := map[string]any{ + "timestamp": c.Now.Format(time.RFC3339), + "smartctl": c.SafeRun("smartctl", "-a", "/dev/"+dev), + "blkid": c.SafeRun("blkid", "-p", "/dev/"+dev), + } + sfd, _ := c.runCmd("sfdisk", "-J", "/dev/"+dev) + bd["sfdisk"] = json.RawMessage(sfd) + lsb, _ := c.runCmd("lsblk", "-J", "/dev/"+dev) + bd["lsblk"] = json.RawMessage(lsb) + + parts := map[string]any{} + var ls struct { + Blockdevices []struct { + Children []struct{ Name string `json:"name"` } `json:"children"` + } `json:"blockdevices"` + } + _ = json.Unmarshal(lsb, &ls) + for _, d := range ls.Blockdevices { + for _, ch := range d.Children { + partdev := "/dev/" + ch.Name + blk := c.SafeRun("blkid", "-p", partdev) + m := rePU.FindStringSubmatch(blk) + if len(m) != 2 { + continue + } + uuid := strings.ToLower(m[1]) + pinfo := map[string]any{ + "blkid": blk, + } + plsb, _ := c.runCmd("lsblk", "-J", partdev) + pinfo["lsblk"] = json.RawMessage(plsb) + parts[uuid] = pinfo + } + } + bd["partitions"] = parts + all[base] = bd + } + return json.Marshal(all) +} diff --git a/internal/sysinfo/collector_network.go b/internal/sysinfo/collector_network.go new file mode 100644 index 0000000..be8b61a --- /dev/null +++ b/internal/sysinfo/collector_network.go @@ -0,0 +1,49 @@ +package sysinfo + +import ( + "encoding/json" + "path/filepath" + "strings" +) + +type NetworkCollector struct{} + +func (NetworkCollector) Key() string { return "network" } + +func (NetworkCollector) Collect(c *Context) (json.RawMessage, error) { + def, _ := c.defaultIfaceSet() + sections := map[string]any{} + + nets, _ := filepath.Glob("/sys/class/net/*") + for _, n := range nets { + if strings.HasSuffix(n, "/lo") { + continue + } + iface := filepath.Base(n) + mac := strings.ToLower(strings.ReplaceAll( + c.SafeRead(filepath.Join(n, "address")), ":", "")) + if mac == "" { + continue + } + entry := map[string]any{ + "iface": iface, + "timestamp": c.Now.Format(time.RFC3339), + "link": c.SafeRun("ip", "-details", "link", "show", iface), + "ethtool": c.SafeRun("ethtool", iface), + } + ipJSON, _ := c.runCmd("ip", "-j", "address", "show", iface) + entry["ip_addr"] = json.RawMessage(ipJSON) + + stats, _ := c.readNetStats(iface) + entry["statistics"] = json.RawMessage(stats) + + if def[iface] { + if out := c.SafeRun("curl", "--interface", iface, "-s", + "https://ipinfo.io"); json.Valid([]byte(out)) { + entry["ipinfo"] = json.RawMessage(out) + } + } + sections[mac] = entry + } + return json.Marshal(sections) +} diff --git a/internal/sysinfo/collector_packages.go b/internal/sysinfo/collector_packages.go new file mode 100644 index 0000000..dc821f4 --- /dev/null +++ b/internal/sysinfo/collector_packages.go @@ -0,0 +1,12 @@ +package sysinfo + +import "encoding/json" + +type PackagesCollector struct{} + +func (PackagesCollector) Key() string { return "packages" } +func (PackagesCollector) Collect(c *Context) (json.RawMessage, error) { + out := c.SafeRun("dpkg-query", "-W", + "-f=${Package} ${Version}\\n") + return json.Marshal(map[string]string{"dpkg": out}) +} diff --git a/internal/sysinfo/collector_sensors.go b/internal/sysinfo/collector_sensors.go new file mode 100644 index 0000000..13d085b --- /dev/null +++ b/internal/sysinfo/collector_sensors.go @@ -0,0 +1,12 @@ +package sysinfo + +import "encoding/json" + +type SensorsCollector struct{} + +func (SensorsCollector) Key() string { return "sensors" } +func (SensorsCollector) Collect(c *Context) (json.RawMessage, error) { + return json.Marshal(map[string]string{ + "output": c.SafeRun("sensors"), + }) +} diff --git a/internal/sysinfo/collector_system.go b/internal/sysinfo/collector_system.go new file mode 100644 index 0000000..3c34582 --- /dev/null +++ b/internal/sysinfo/collector_system.go @@ -0,0 +1,27 @@ +package sysinfo + +import ( + "encoding/json" + "strings" +) + +type SystemCollector struct{} + +func (SystemCollector) Key() string { return "system" } + +func (SystemCollector) Collect(c *Context) (json.RawMessage, error) { + data := map[string]string{ + "timestamp": c.Now.Format(time.RFC3339), + "uname": c.SafeRun("uname", "-a"), + "lsb_release": c.SafeRun("lsb_release", "-a"), + "cpuinfo": c.SafeRead("/proc/cpuinfo"), + "meminfo": c.SafeRead("/proc/meminfo"), + "distro": "ubuntu", + } + serial := strings.TrimSpace( + c.SafeRun("dmidecode", "-s", "system-serial-number")) + if serial != "" { + data["id"] = serial + } + return json.Marshal(data) +} diff --git a/internal/sysinfo/collector_zfs.go b/internal/sysinfo/collector_zfs.go new file mode 100644 index 0000000..64af3a3 --- /dev/null +++ b/internal/sysinfo/collector_zfs.go @@ -0,0 +1,28 @@ +package sysinfo + +import ( + "encoding/json" + "strings" +) + +type ZFSCollector struct{} + +func (ZFSCollector) Key() string { return "zfs" } + +func (ZFSCollector) Collect(c *Context) (json.RawMessage, error) { + if c.SafeRun("which", "zpool") == "" { + return json.Marshal(nil) // no zfs installed + } + pools := strings.Fields( + c.SafeRun("zpool", "list", "-H", "-o", "name")) + section := map[string]any{} + for _, p := range pools { + section[p] = map[string]string{ + "timestamp": c.Now.Format(time.RFC3339), + "status": c.SafeRun("zpool", "status", "-v", p), + "get": c.SafeRun("zpool", "get", "-H", "all", p), + "zfs_list": c.SafeRun("zfs", "list", "-Hp", "-r", p), + } + } + return json.Marshal(section) +} diff --git a/internal/sysinfo/snapshot.go b/internal/sysinfo/snapshot.go index 292fb4d..1c4d339 100644 --- a/internal/sysinfo/snapshot.go +++ b/internal/sysinfo/snapshot.go @@ -1,12 +1,21 @@ package sysinfo -// Snapshot is the top-level JSON object. +import "encoding/json" + +// Snapshot stores timestamp + raw section blobs. type Snapshot struct { - SnapshotTime string `json:"snapshot_time"` - System *SystemData `json:"system"` - Sensors *SensorsData `json:"sensors,omitempty"` - Blockdevs map[string]*BlockDevice `json:"blockdevs,omitempty"` - Network map[string]*NetIface `json:"network,omitempty"` - ZFS map[string]*ZPool `json:"zfs,omitempty"` - Packages *PackagesData `json:"packages,omitempty"` + Timestamp string `json:"snapshot_time"` + Sections map[string]json.RawMessage `json:"-"` +} + +// MarshalJSON injects raw sections into root object. +func (s *Snapshot) MarshalJSON() ([]byte, error) { + obj := map[string]json.RawMessage{ + "snapshot_time": json.RawMessage( + []byte(`"` + s.Timestamp + `"`)), + } + for k, v := range s.Sections { + obj[k] = v + } + return json.Marshal(obj) }