From b63bab05822011f458cef619165127ce3da7a0a6 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 May 2025 03:07:12 -0700 Subject: [PATCH] initial --- Makefile | 16 +++ cmd/sysinfo/main.go | 20 ++++ go.mod | 23 ++++ go.sum | 42 +++++++ internal/sysinfo/app.go | 102 ++++++++++++++++ internal/sysinfo/collect_block.go | 85 ++++++++++++++ internal/sysinfo/collect_network.go | 57 +++++++++ internal/sysinfo/collect_packages.go | 14 +++ internal/sysinfo/collect_sensors.go | 13 ++ internal/sysinfo/collect_system.go | 38 ++++++ internal/sysinfo/collect_zfs.go | 32 +++++ internal/sysinfo/deps.go | 29 +++++ internal/sysinfo/helpers.go | 170 +++++++++++++++++++++++++++ internal/sysinfo/output.go | 135 +++++++++++++++++++++ internal/sysinfo/schema.go | 118 +++++++++++++++++++ internal/sysinfo/snapshot.go | 12 ++ internal/sysinfo/version.go | 5 + 17 files changed, 911 insertions(+) create mode 100644 Makefile create mode 100644 cmd/sysinfo/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/sysinfo/app.go create mode 100644 internal/sysinfo/collect_block.go create mode 100644 internal/sysinfo/collect_network.go create mode 100644 internal/sysinfo/collect_packages.go create mode 100644 internal/sysinfo/collect_sensors.go create mode 100644 internal/sysinfo/collect_system.go create mode 100644 internal/sysinfo/collect_zfs.go create mode 100644 internal/sysinfo/deps.go create mode 100644 internal/sysinfo/helpers.go create mode 100644 internal/sysinfo/output.go create mode 100644 internal/sysinfo/schema.go create mode 100644 internal/sysinfo/snapshot.go create mode 100644 internal/sysinfo/version.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..55e1df9 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +# 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) + +GOLDFLAGS += -s -w \ + -X 'git.eeqj.de/sneak/sysinfo/internal/sysinfo.Version=$(VERSION)' + +all: build + +build: + GOFLAGS=-ldflags="$(GOLDFLAGS)" go build ./cmd/sysinfo + +install: + GOFLAGS=-ldflags="$(GOLDFLAGS)" go install ./cmd/sysinfo + +.PHONY: all build install diff --git a/cmd/sysinfo/main.go b/cmd/sysinfo/main.go new file mode 100644 index 0000000..4e3b436 --- /dev/null +++ b/cmd/sysinfo/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "os" + + "git.eeqj.de/sneak/sysinfo/internal/sysinfo" +) + +func main() { + app, err := sysinfo.NewApp() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := app.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..37c5969 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module git.eeqj.de/sneak/sysinfo + +go 1.23.4 + +require ( + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/viper v1.20.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e7b8877 --- /dev/null +++ b/go.sum @@ -0,0 +1,42 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/sysinfo/app.go b/internal/sysinfo/app.go new file mode 100644 index 0000000..0d9f8bd --- /dev/null +++ b/internal/sysinfo/app.go @@ -0,0 +1,102 @@ +package sysinfo + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// App holds CLI state. +type App struct { + force bool + jsonOut bool + verbose bool + aptUpdated bool + cmd *cobra.Command +} + +// NewApp configures root command. +func NewApp() (*App, error) { + a := &App{} + root := &cobra.Command{ + Use: "sysinfo", + Short: "capture block-device / system snapshot", + Version: Version, // <-- --version flag + SilenceUsage: true, + SilenceErrors: true, + RunE: a.run, + } + + root.Flags().BoolVarP(&a.force, "force", "f", false, + "overwrite /etc/sysinfo if it exists") + root.Flags().BoolVar(&a.jsonOut, "json", false, + "emit JSON snapshot to stdout") + root.Flags().BoolVarP(&a.verbose, "verbose", "v", false, + "verbose progress") + _ = viper.BindPFlags(root.Flags()) + + root.AddCommand(&cobra.Command{ + Use: "schema", + Short: "print the JSON schema", + Run: func(*cobra.Command, []string) { + fmt.Fprintln(os.Stdout, JSONSchema) + }, + }) + + a.cmd = root + return a, nil +} + +func (a *App) Execute() error { return a.cmd.Execute() } + +func (a *App) logf(f string, v ...any) { + if a.verbose { + _, _ = fmt.Fprintf(os.Stderr, f+"\n", v...) + } +} + +// run executes the snapshot workflow. +func (a *App) run(_ *cobra.Command, _ []string) error { + if err := a.ensureUbuntu(); err != nil { + return err + } + if err := a.ensureDeps(); err != nil { + return err + } + + ctx := &Context{ + Now: time.Now().UTC(), + Logf: a.logf, + Run: a.runCmd, + SafeRun: a.safeRun, + SafeRead: a.safeRead, + } + + snap := &Snapshot{ + Timestamp: ctx.Now.Format(time.RFC3339), + Sections: map[string]json.RawMessage{}, + } + + collectors := []Collector{ + SystemCollector{}, BlockCollector{}, + NetworkCollector{}, SensorsCollector{}, + PackagesCollector{}, ZFSCollector{}, + } + + for _, c := range collectors { + raw, err := c.Collect(ctx) + if err != nil { + ctx.Logf("%s: %v", c.Key(), err) + continue + } + snap.Sections[c.Key()] = raw + } + + if a.jsonOut { + return a.emitJSON(os.Stdout, snap) + } + return a.writeHierarchy(snap) +} diff --git a/internal/sysinfo/collect_block.go b/internal/sysinfo/collect_block.go new file mode 100644 index 0000000..f4c5ce6 --- /dev/null +++ b/internal/sysinfo/collect_block.go @@ -0,0 +1,85 @@ +package sysinfo + +import ( + "encoding/json" + "path/filepath" + "regexp" + "strings" +) + +// BlockDevice + Partition live with this collector. + +type BlockDevice struct { + Timestamp string `json:"timestamp"` + Smartctl string `json:"smartctl"` + LuksDump string `json:"luksDump,omitempty"` + Sfdisk json.RawMessage `json:"sfdisk"` + Blkid string `json:"blkid"` + Lsblk json.RawMessage `json:"lsblk"` + Partitions map[string]*Partition `json:"partitions"` +} + +type Partition struct { + Blkid string `json:"blkid"` + Lsblk json.RawMessage `json:"lsblk"` +} + +func (a *App) collectBlock(s *Snapshot, now string) error { + links, _ := filepath.Glob("/dev/disk/by-id/*") + rePU := regexp.MustCompile(`PARTUUID="([^"]+)"`) + 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 := &BlockDevice{ + Timestamp: now, + Partitions: map[string]*Partition{}, + } + devPath := filepath.Join("/dev", dev) + + bd.Smartctl = a.safeRun("smartctl", "-a", devPath) + if out := a.safeRun("cryptsetup", "luksDump", devPath); out != "" { + bd.LuksDump = out + } + _ = json.Unmarshal( + a.runJSON(&bd.Sfdisk, "sfdisk", "-J", devPath), &bd.Sfdisk) + bd.Blkid = a.safeRun("blkid", "-p", devPath) + _ = json.Unmarshal( + a.runJSON(&bd.Lsblk, "lsblk", "-J", devPath), &bd.Lsblk) + + // enumerate partitions + var ls struct { + Blockdevices []struct { + Children []struct{ Name string `json:"name"` } `json:"children"` + } `json:"blockdevices"` + } + _ = json.Unmarshal(bd.Lsblk, &ls) + for _, d := range ls.Blockdevices { + for _, c := range d.Children { + part := filepath.Join("/dev", c.Name) + blk := a.safeRun("blkid", "-p", part) + m := rePU.FindStringSubmatch(blk) + if len(m) != 2 { + continue + } + uuid := strings.ToLower(m[1]) + p := &Partition{Blkid: blk} + _ = json.Unmarshal( + a.runJSON(&p.Lsblk, "lsblk", "-J", part), &p.Lsblk) + bd.Partitions[uuid] = p + } + } + if s.Blockdevs == nil { + s.Blockdevs = map[string]*BlockDevice{} + } + s.Blockdevs[base] = bd + } + return nil +} diff --git a/internal/sysinfo/collect_network.go b/internal/sysinfo/collect_network.go new file mode 100644 index 0000000..914ac0e --- /dev/null +++ b/internal/sysinfo/collect_network.go @@ -0,0 +1,57 @@ +package sysinfo + +import ( + "encoding/json" + "path/filepath" + "strings" +) + +// NetIface lives here. + +type NetIface struct { + Iface string `json:"iface"` + IPAddr json.RawMessage `json:"ip_addr"` + Link string `json:"link"` + Ethtool string `json:"ethtool,omitempty"` + Stats json.RawMessage `json:"statistics"` + IPInfo json.RawMessage `json:"ipinfo,omitempty"` + Timestamp string `json:"timestamp"` +} + +func (a *App) collectNetwork(s *Snapshot, now string) error { + defMap, _ := a.defaultIfaceSet() + 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( + a.safeRead(filepath.Join(n, "address")), ":", "")) + if mac == "" { + continue + } + + nif := &NetIface{ + Iface: iface, + Timestamp: now, + } + _ = json.Unmarshal( + a.runJSON(&nif.IPAddr, "ip", "-j", "address", "show", iface), + &nif.IPAddr) + nif.Link = a.safeRun("ip", "-details", "link", "show", iface) + nif.Ethtool = a.safeRun("ethtool", iface) + nif.Stats, _ = a.readNetStats(iface) + if defMap[iface] { + if o := a.safeRun("curl", "--interface", iface, "-s", + "https://ipinfo.io"); json.Valid([]byte(o)) { + nif.IPInfo = json.RawMessage(o) + } + } + if s.Network == nil { + s.Network = map[string]*NetIface{} + } + s.Network[mac] = nif + } + return nil +} diff --git a/internal/sysinfo/collect_packages.go b/internal/sysinfo/collect_packages.go new file mode 100644 index 0000000..e8f0f03 --- /dev/null +++ b/internal/sysinfo/collect_packages.go @@ -0,0 +1,14 @@ +package sysinfo + +// PackagesData lives with this collector. + +type PackagesData struct { + Dpkg string `json:"dpkg"` +} + +func (a *App) collectPackages(s *Snapshot, _ string) error { + out := a.safeRun("dpkg-query", "-W", + "-f=${Package} ${Version}\\n") + s.Packages = &PackagesData{Dpkg: out} + return nil +} diff --git a/internal/sysinfo/collect_sensors.go b/internal/sysinfo/collect_sensors.go new file mode 100644 index 0000000..7287f5d --- /dev/null +++ b/internal/sysinfo/collect_sensors.go @@ -0,0 +1,13 @@ +package sysinfo + +// SensorsData lives here. + +type SensorsData struct { + Output string `json:"output"` +} + +func (a *App) collectSensors(s *Snapshot, _ string) error { + out := a.safeRun("sensors") + s.Sensors = &SensorsData{Output: out} + return nil +} diff --git a/internal/sysinfo/collect_system.go b/internal/sysinfo/collect_system.go new file mode 100644 index 0000000..afc5616 --- /dev/null +++ b/internal/sysinfo/collect_system.go @@ -0,0 +1,38 @@ +package sysinfo + +import "strings" + +// SystemData lives with this collector. + +type SystemData struct { + Timestamp string `json:"timestamp"` + ID string `json:"id"` + Distro string `json:"distro"` + LSBRel string `json:"lsb_release"` + Uname string `json:"uname"` + CPUInfo string `json:"cpuinfo"` + MemInfo string `json:"meminfo"` + Dmidecode string `json:"dmidecode,omitempty"` +} + +func (a *App) collectSystem(s *Snapshot, now string) error { + sys := &SystemData{ + Timestamp: now, + LSBRel: a.safeRun("lsb_release", "-a"), + Uname: a.safeRun("uname", "-a"), + CPUInfo: a.safeRead("/proc/cpuinfo"), + MemInfo: a.safeRead("/proc/meminfo"), + Dmidecode: a.safeRun("dmidecode"), + Distro: "ubuntu", + } + + if id := strings.TrimSpace( + a.safeRun("dmidecode", "-s", "system-serial-number")); id != "" { + sys.ID = id + } else { + sys.ID = a.primaryMAC() + } + + s.System = sys + return nil +} diff --git a/internal/sysinfo/collect_zfs.go b/internal/sysinfo/collect_zfs.go new file mode 100644 index 0000000..a2250fa --- /dev/null +++ b/internal/sysinfo/collect_zfs.go @@ -0,0 +1,32 @@ +package sysinfo + +import "strings" + +// ZPool represents details for one ZFS pool. +type ZPool struct { + Timestamp string `json:"timestamp"` + Status string `json:"status"` + Get string `json:"get"` + ZfsList string `json:"zfs_list"` +} + +func (a *App) collectZFS(s *Snapshot, now string) error { + if a.safeRun("which", "zpool") == "" { + return nil // ZFS not installed + } + pools := strings.Fields(a.safeRun("zpool", "list", "-H", "-o", "name")) + if len(pools) == 0 { + return nil + } + if s.ZFS == nil { + s.ZFS = map[string]*ZPool{} + } + for _, p := range pools { + z := &ZPool{Timestamp: now} + z.Status = a.safeRun("zpool", "status", "-v", p) + z.Get = a.safeRun("zpool", "get", "-H", "all", p) + z.ZfsList = a.safeRun("zfs", "list", "-Hp", "-r", p) + s.ZFS[p] = z + } + return nil +} diff --git a/internal/sysinfo/deps.go b/internal/sysinfo/deps.go new file mode 100644 index 0000000..7959f28 --- /dev/null +++ b/internal/sysinfo/deps.go @@ -0,0 +1,29 @@ +package sysinfo + +import "os/exec" + +func (a *App) ensureDeps() error { + req := map[string]string{ + "smartctl": "smartmontools", + "cryptsetup":"cryptsetup", + "sfdisk": "util-linux", + "lsblk": "util-linux", + "blkid": "util-linux", + "ip": "iproute2", + "ethtool": "ethtool", + "dmidecode": "dmidecode", + "sensors": "lm-sensors", + "curl": "curl", + "jc": "jc", + "dpkg": "dpkg", + } + for bin, pkg := range req { + if _, err := exec.LookPath(bin); err != nil { + a.logf("apt install %s", pkg) + if err := a.aptInstall(pkg); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/sysinfo/helpers.go b/internal/sysinfo/helpers.go new file mode 100644 index 0000000..e26a6f2 --- /dev/null +++ b/internal/sysinfo/helpers.go @@ -0,0 +1,170 @@ +package sysinfo + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +const ( + rootDir = "/etc/sysinfo" + snapshotJSON = "snapshot.json" + debianFrontEnv = "DEBIAN_FRONTEND=noninteractive" +) + +/* ------------------------------------------------------------------ */ +/* platform check */ +/* ------------------------------------------------------------------ */ + +func (a *App) ensureUbuntu() error { + out, err := a.runCmd("lsb_release", "-is") + if err != nil { + return fmt.Errorf("unsupported OS: lsb_release not found") + } + if strings.ToLower(strings.TrimSpace(string(out))) != "ubuntu" { + return fmt.Errorf("unsupported OS: %s", + strings.TrimSpace(string(out))) + } + return nil +} + +/* ------------------------------------------------------------------ */ +/* command helpers */ +/* ------------------------------------------------------------------ */ + +func (a *App) runCmd(name string, args ...string) ([]byte, error) { + cmd := exec.Command(name, args...) + var buf bytes.Buffer + cmd.Stdout, cmd.Stderr = &buf, &buf + err := cmd.Run() + return buf.Bytes(), err +} + +func (a *App) safeRun(name string, args ...string) string { + out, _ := a.runCmd(name, args...) + return string(out) +} + +func (a *App) runJSON(dst *json.RawMessage, + name string, args ...string) []byte { + out, _ := a.runCmd(name, args...) + *dst = json.RawMessage(out) + return out +} + +func (a *App) safeRead(path string) string { + b, _ := ioutil.ReadFile(path) + return string(b) +} + +/* ------------------------------------------------------------------ */ +/* JSON emitter */ +/* ------------------------------------------------------------------ */ + +func (a *App) emitJSON(f *os.File, s *Snapshot) error { + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + return enc.Encode(s) +} + +/* ------------------------------------------------------------------ */ +/* network helpers */ +/* ------------------------------------------------------------------ */ + +func (a *App) readNetStats(iface string) (json.RawMessage, error) { + dir := filepath.Join("/sys/class/net", iface, "statistics") + ent, err := ioutil.ReadDir(dir) + if err != nil { + return nil, err + } + m := map[string]int64{} + for _, f := range ent { + b, _ := ioutil.ReadFile(filepath.Join(dir, f.Name())) + n, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64) + m[f.Name()] = n + } + j, _ := json.Marshal(m) + return j, nil +} + +func (a *App) defaultIfaceSet() (map[string]bool, error) { + out, err := a.runCmd("ip", "-j", "route", "show", "default") + if err != nil { + return nil, err + } + var routes []struct{ Dev string `json:"dev"` } + _ = json.Unmarshal(out, &routes) + m := map[string]bool{} + for _, r := range routes { + m[r.Dev] = true + } + return m, nil +} + +func (a *App) primaryMAC() string { + ifs, _ := ioutil.ReadDir("/sys/class/net") + for _, f := range ifs { + if f.Name() == "lo" { + continue + } + mac := a.safeRead(filepath.Join( + "/sys/class/net", f.Name(), "address")) + mac = strings.ToLower(strings.ReplaceAll( + strings.TrimSpace(mac), ":", "")) + if mac != "" { + return mac + } + } + return "" +} + +/* ------------------------------------------------------------------ */ +/* apt helpers */ +/* ------------------------------------------------------------------ */ + +func (a *App) aptInstall(pkg string) error { + if !a.aptUpdated { + if err := a.runApt("apt-get", "update"); err != nil { + return err + } + a.aptUpdated = true + } + return a.runApt("apt-get", "-y", "install", pkg) +} + +func (a *App) runApt(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Env = append(os.Environ(), debianFrontEnv) + if a.verbose { + cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr + } + return cmd.Run() +} + +/* ------------------------------------------------------------------ */ +/* file helpers */ +/* ------------------------------------------------------------------ */ + +func (a *App) mustWriteText(dir, name, content string) { + if err := ioutil.WriteFile( + filepath.Join(dir, name), + []byte(content), 0644); err != nil { + panic(err) + } +} + +func (a *App) mustWriteJSON(dir, name string, raw json.RawMessage) { + if len(raw) == 0 { + return + } + if err := ioutil.WriteFile( + filepath.Join(dir, name), raw, 0644); err != nil { + panic(err) + } +} diff --git a/internal/sysinfo/output.go b/internal/sysinfo/output.go new file mode 100644 index 0000000..4175316 --- /dev/null +++ b/internal/sysinfo/output.go @@ -0,0 +1,135 @@ +package sysinfo + +import ( + "fmt" + "os" + "path/filepath" +) + +// writeHierarchy mirrors the Snapshot into /etc/sysinfo. +func (a *App) writeHierarchy(s *Snapshot) error { + if _, err := os.Stat(rootDir); err == nil && !a.force { + return fmt.Errorf("%s exists; use --force", rootDir) + } + _ = os.RemoveAll(rootDir) + if err := os.MkdirAll(rootDir, 0755); err != nil { + return err + } + a.mustWriteText(rootDir, "snapshot_time.txt", s.SnapshotTime) + + if err := a.writeSystem( + filepath.Join(rootDir, "system"), s.System); err != nil { + return err + } + + if s.Sensors != nil { + sDir := filepath.Join(rootDir, "sensors") + if err := os.MkdirAll(sDir, 0755); err != nil { + return err + } + a.mustWriteText(sDir, "sensors.txt", s.Sensors.Output) + } + + for id, bd := range s.Blockdevs { + if err := a.writeBlockDevice( + filepath.Join(rootDir, "blockdevs", id), bd); err != nil { + return err + } + } + + for mac, n := range s.Network { + if err := a.writeNetIface( + filepath.Join(rootDir, "network", mac), n); err != nil { + return err + } + } + + for pool, z := range s.ZFS { + zDir := filepath.Join(rootDir, "zfs", "pools", pool) + if err := os.MkdirAll(zDir, 0755); err != nil { + return err + } + a.mustWriteText(zDir, "timestamp.txt", z.Timestamp) + a.mustWriteText(zDir, "status.txt", z.Status) + a.mustWriteText(zDir, "get.txt", z.Get) + a.mustWriteText(zDir, "zfs_list.txt", z.ZfsList) + } + + if s.Packages != nil { + pDir := filepath.Join(rootDir, "packages") + if err := os.MkdirAll(pDir, 0755); err != nil { + return err + } + a.mustWriteText(pDir, "dpkg_list.txt", s.Packages.Dpkg) + } + + f, err := os.Create(filepath.Join(rootDir, snapshotJSON)) + if err != nil { + return err + } + defer f.Close() + return a.emitJSON(f, s) +} + +/* ------------------------------------------------------------------ */ +/* helpers for leaf sections */ +/* ------------------------------------------------------------------ */ + +func (a *App) writeSystem(dir string, sys *SystemData) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + a.mustWriteText(dir, "timestamp.txt", sys.Timestamp) + a.mustWriteText(dir, "id.txt", sys.ID) + a.mustWriteText(dir, "distro.txt", sys.Distro) + a.mustWriteText(dir, "lsb_release.txt", sys.LSBRel) + a.mustWriteText(dir, "uname.txt", sys.Uname) + a.mustWriteText(dir, "cpuinfo.txt", sys.CPUInfo) + a.mustWriteText(dir, "meminfo.txt", sys.MemInfo) + if sys.Dmidecode != "" { + a.mustWriteText(dir, "dmidecode.txt", sys.Dmidecode) + } + return nil +} + +func (a *App) writeBlockDevice(dir string, bd *BlockDevice) error { + if err := os.MkdirAll( + filepath.Join(dir, "partitions"), 0755); err != nil { + return err + } + a.mustWriteText(dir, "timestamp.txt", bd.Timestamp) + a.mustWriteText(dir, "smartctl.txt", bd.Smartctl) + if bd.LuksDump != "" { + a.mustWriteText(dir, "luksDump.txt", bd.LuksDump) + } + a.mustWriteJSON(dir, "sfdisk.json", bd.Sfdisk) + a.mustWriteText(dir, "blkid.txt", bd.Blkid) + a.mustWriteJSON(dir, "lsblk.json", bd.Lsblk) + for uuid, p := range bd.Partitions { + pDir := filepath.Join(dir, "partitions", uuid) + if err := os.MkdirAll(pDir, 0755); err != nil { + return err + } + a.mustWriteText(pDir, "blkid.txt", p.Blkid) + a.mustWriteJSON(pDir, "lsblk.json", p.Lsblk) + } + return nil +} + +func (a *App) writeNetIface(dir string, n *NetIface) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + a.mustWriteText(dir, "timestamp.txt", n.Timestamp) + a.mustWriteText(dir, "iface.txt", n.Iface) + a.mustWriteJSON(dir, "ip_addr.json", n.IPAddr) + a.mustWriteText(dir, "link.txt", n.Link) + if n.Ethtool != "" { + a.mustWriteText(dir, "ethtool.txt", n.Ethtool) + } + a.mustWriteJSON(dir, "statistics.json", n.Stats) + if len(n.IPInfo) > 0 { + a.mustWriteJSON(dir, "ipinfo.json", n.IPInfo) + } + return nil +} diff --git a/internal/sysinfo/schema.go b/internal/sysinfo/schema.go new file mode 100644 index 0000000..c6b333e --- /dev/null +++ b/internal/sysinfo/schema.go @@ -0,0 +1,118 @@ +package sysinfo + +// JSONSchema – draft-2020-12. Updated to include “packages” and a +// “distro” field inside the system block so multiple Linux distros are +// representable even though only Ubuntu is collected today. +const JSONSchema = ` +{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "title":"Sysinfo snapshot", + "type":"object", + "required":["snapshot_time","system"], + "properties":{ + "snapshot_time":{"type":"string","format":"date-time"}, + "system":{"$ref":"#/$defs/system"}, + "sensors":{"$ref":"#/$defs/sensors"}, + "blockdevs":{ + "type":"object", + "patternProperties":{"^[A-Za-z0-9._-]+$":{"$ref":"#/$defs/block"}}, + "additionalProperties":false + }, + "network":{ + "type":"object", + "patternProperties":{"^[0-9a-f]{12}$":{"$ref":"#/$defs/iface"}}, + "additionalProperties":false + }, + "zfs":{ + "type":"object", + "patternProperties":{"^[A-Za-z0-9._-]+$":{"$ref":"#/$defs/zpool"}}, + "additionalProperties":false + }, + "packages":{"$ref":"#/$defs/packages"} + }, + "$defs":{ + "system":{ + "type":"object", + "required":["timestamp","id","distro","uname"], + "properties":{ + "timestamp":{"type":"string","format":"date-time"}, + "id":{"type":"string"}, + "distro":{"type":"string"}, + "lsb_release":{"type":"string"}, + "uname":{"type":"string"}, + "cpuinfo":{"type":"string"}, + "meminfo":{"type":"string"}, + "dmidecode":{"type":"string"} + }, + "additionalProperties":false + }, + "sensors":{ + "type":"object", + "properties":{"output":{"type":"string"}}, + "required":["output"], + "additionalProperties":false + }, + "block":{ + "type":"object", + "required":["timestamp","smartctl","sfdisk","blkid","lsblk", + "partitions"], + "properties":{ + "timestamp":{"type":"string","format":"date-time"}, + "smartctl":{"type":"string"}, + "luksDump":{"type":"string"}, + "sfdisk":{"type":"object"}, + "blkid":{"type":"string"}, + "lsblk":{"type":"object"}, + "partitions":{ + "type":"object", + "patternProperties":{ + "^[A-Fa-f0-9-]+$":{"$ref":"#/$defs/partition"} + }, + "additionalProperties":false + } + }, + "additionalProperties":false + }, + "partition":{ + "type":"object", + "required":["blkid","lsblk"], + "properties":{ + "blkid":{"type":"string"}, + "lsblk":{"type":"object"} + }, + "additionalProperties":false + }, + "iface":{ + "type":"object", + "required":["iface","ip_addr","link","statistics","timestamp"], + "properties":{ + "iface":{"type":"string"}, + "ip_addr":{"type":"object"}, + "link":{"type":"string"}, + "ethtool":{"type":"string"}, + "statistics":{"type":"object"}, + "ipinfo":{"type":"object"}, + "timestamp":{"type":"string","format":"date-time"} + }, + "additionalProperties":false + }, + "zpool":{ + "type":"object", + "required":["timestamp","status","get","zfs_list"], + "properties":{ + "timestamp":{"type":"string","format":"date-time"}, + "status":{"type":"string"}, + "get":{"type":"string"}, + "zfs_list":{"type":"string"} + }, + "additionalProperties":false + }, + "packages":{ + "type":"object", + "properties":{"dpkg":{"type":"string"}}, + "required":["dpkg"], + "additionalProperties":false + } + } +} +` diff --git a/internal/sysinfo/snapshot.go b/internal/sysinfo/snapshot.go new file mode 100644 index 0000000..292fb4d --- /dev/null +++ b/internal/sysinfo/snapshot.go @@ -0,0 +1,12 @@ +package sysinfo + +// Snapshot is the top-level JSON object. +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"` +} diff --git a/internal/sysinfo/version.go b/internal/sysinfo/version.go new file mode 100644 index 0000000..a63a0d9 --- /dev/null +++ b/internal/sysinfo/version.go @@ -0,0 +1,5 @@ +package sysinfo + +// Version is overridden at build time via: +// go build -ldflags "-X git.eeqj.de/sneak/sysinfo/internal/sysinfo.Version=1.2.3" +var Version = "dev"