mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Detect NAS host vendors from platform files
This commit is contained in:
@@ -156,6 +156,7 @@ func New(cfg Config) (*Agent, error) {
|
||||
osName = strings.TrimSpace(info.PlatformFamily)
|
||||
}
|
||||
osVersion := strings.TrimSpace(info.PlatformVersion)
|
||||
osName, osVersion = resolveHostOSIdentity(collector, osName, osVersion)
|
||||
kernelVersion := strings.TrimSpace(info.KernelVersion)
|
||||
arch := strings.TrimSpace(info.KernelArch)
|
||||
if arch == "" {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package hostagent
|
||||
|
||||
import "strings"
|
||||
|
||||
func resolveHostOSIdentity(collector SystemCollector, osName, osVersion string) (string, string) {
|
||||
currentName := strings.TrimSpace(osName)
|
||||
currentVersion := strings.TrimSpace(osVersion)
|
||||
|
||||
if collector == nil || collector.GOOS() != "linux" {
|
||||
return currentName, currentVersion
|
||||
}
|
||||
|
||||
if name, version, ok := detectSynologyOSIdentity(collector); ok {
|
||||
if version == "" {
|
||||
version = currentVersion
|
||||
}
|
||||
return name, strings.TrimSpace(version)
|
||||
}
|
||||
|
||||
if name, version, ok := detectQNAPOSIdentity(collector); ok {
|
||||
if version == "" {
|
||||
version = currentVersion
|
||||
}
|
||||
return name, strings.TrimSpace(version)
|
||||
}
|
||||
|
||||
return currentName, currentVersion
|
||||
}
|
||||
|
||||
func detectSynologyOSIdentity(collector SystemCollector) (string, string, bool) {
|
||||
hasSynologyDir := false
|
||||
if _, err := collector.Stat("/usr/syno"); err == nil {
|
||||
hasSynologyDir = true
|
||||
}
|
||||
|
||||
for _, path := range []string{"/etc.defaults/VERSION", "/etc/VERSION"} {
|
||||
data, err := collector.ReadFile(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
values := parseAssignmentConfig(string(data))
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
if !hasSynologyDir && !looksLikeSynologyVersionFile(values) {
|
||||
continue
|
||||
}
|
||||
|
||||
version := composeSynologyVersion(values)
|
||||
return "Synology DSM", version, true
|
||||
}
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func composeSynologyVersion(values map[string]string) string {
|
||||
version := strings.TrimSpace(values["productversion"])
|
||||
if version == "" {
|
||||
major := strings.TrimSpace(values["majorversion"])
|
||||
minor := strings.TrimSpace(values["minorversion"])
|
||||
switch {
|
||||
case major != "" && minor != "":
|
||||
version = major + "." + minor
|
||||
case major != "":
|
||||
version = major
|
||||
case minor != "":
|
||||
version = minor
|
||||
}
|
||||
}
|
||||
|
||||
build := strings.TrimSpace(values["buildnumber"])
|
||||
if build != "" && !strings.Contains(version, build) {
|
||||
if version != "" {
|
||||
version += "-" + build
|
||||
} else {
|
||||
version = build
|
||||
}
|
||||
}
|
||||
|
||||
smallfix := strings.TrimSpace(values["smallfixnumber"])
|
||||
if smallfix != "" && smallfix != "0" {
|
||||
if version != "" {
|
||||
version += " Update " + smallfix
|
||||
} else {
|
||||
version = smallfix
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(version)
|
||||
}
|
||||
|
||||
func looksLikeSynologyVersionFile(values map[string]string) bool {
|
||||
if values["majorversion"] != "" || values["minorversion"] != "" || values["buildnumber"] != "" || values["productversion"] != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
hints := strings.ToLower(strings.Join([]string{
|
||||
values["product"],
|
||||
values["unique"],
|
||||
values["buildphase"],
|
||||
}, " "))
|
||||
return strings.Contains(hints, "synology") || strings.Contains(hints, "dsm") || strings.Contains(hints, "diskstation")
|
||||
}
|
||||
|
||||
func detectQNAPOSIdentity(collector SystemCollector) (string, string, bool) {
|
||||
for _, path := range []string{"/etc/config/uLinux.conf", "/etc/default_config/uLinux.conf"} {
|
||||
data, err := collector.ReadFile(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
values := parseAssignmentConfig(string(data))
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := "QNAP QTS"
|
||||
hintFields := strings.ToLower(strings.Join([]string{
|
||||
values["display_name"],
|
||||
values["platform"],
|
||||
values["system_name"],
|
||||
values["version"],
|
||||
}, " "))
|
||||
if strings.Contains(hintFields, "quts") {
|
||||
name = "QNAP QuTS"
|
||||
}
|
||||
return name, strings.TrimSpace(values["version"]), true
|
||||
}
|
||||
|
||||
if _, err := collector.Stat("/etc/config/qpkg.conf"); err == nil {
|
||||
return "QNAP QTS", "", true
|
||||
}
|
||||
if _, err := collector.Stat("/sbin/getcfg"); err == nil {
|
||||
return "QNAP QTS", "", true
|
||||
}
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func parseAssignmentConfig(content string) map[string]string {
|
||||
parsed := make(map[string]string)
|
||||
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "[") {
|
||||
continue
|
||||
}
|
||||
|
||||
idx := strings.IndexRune(line, '=')
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.ToLower(strings.TrimSpace(line[:idx]))
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
value = strings.Trim(value, `"'`)
|
||||
if key == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parsed[key] = value
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package hostagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
gohost "github.com/shirou/gopsutil/v4/host"
|
||||
)
|
||||
|
||||
func TestResolveHostOSIdentity(t *testing.T) {
|
||||
t.Run("detects synology dsm from version file when gopsutil is generic", func(t *testing.T) {
|
||||
mc := &mockCollector{
|
||||
goos: "linux",
|
||||
readFileFn: func(name string) ([]byte, error) {
|
||||
switch name {
|
||||
case "/etc.defaults/VERSION":
|
||||
return []byte(`majorversion="7"
|
||||
minorversion="2"
|
||||
productversion="7.2.2"
|
||||
buildnumber="72806"
|
||||
smallfixnumber="3"
|
||||
`), nil
|
||||
default:
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
name, version := resolveHostOSIdentity(mc, "linux", "")
|
||||
|
||||
if name != "Synology DSM" {
|
||||
t.Fatalf("name = %q, want %q", name, "Synology DSM")
|
||||
}
|
||||
if version != "7.2.2-72806 Update 3" {
|
||||
t.Fatalf("version = %q, want %q", version, "7.2.2-72806 Update 3")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("detects qnap from config file when gopsutil is generic", func(t *testing.T) {
|
||||
mc := &mockCollector{
|
||||
goos: "linux",
|
||||
readFileFn: func(name string) ([]byte, error) {
|
||||
switch name {
|
||||
case "/etc/config/uLinux.conf":
|
||||
return []byte(`Version = 5.2.0
|
||||
Platform = QTS
|
||||
`), nil
|
||||
default:
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
name, version := resolveHostOSIdentity(mc, "linux", "")
|
||||
|
||||
if name != "QNAP QTS" {
|
||||
t.Fatalf("name = %q, want %q", name, "QNAP QTS")
|
||||
}
|
||||
if version != "5.2.0" {
|
||||
t.Fatalf("version = %q, want %q", version, "5.2.0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keeps existing identity when no vendor hint is present", func(t *testing.T) {
|
||||
mc := &mockCollector{goos: "linux"}
|
||||
|
||||
name, version := resolveHostOSIdentity(mc, "ubuntu", "24.04")
|
||||
|
||||
if name != "ubuntu" {
|
||||
t.Fatalf("name = %q, want %q", name, "ubuntu")
|
||||
}
|
||||
if version != "24.04" {
|
||||
t.Fatalf("version = %q, want %q", version, "24.04")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not misclassify generic version files as synology", func(t *testing.T) {
|
||||
mc := &mockCollector{
|
||||
goos: "linux",
|
||||
readFileFn: func(name string) ([]byte, error) {
|
||||
switch name {
|
||||
case "/etc/VERSION":
|
||||
return []byte("VERSION=1\n"), nil
|
||||
default:
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
name, version := resolveHostOSIdentity(mc, "linux", "")
|
||||
|
||||
if name != "linux" {
|
||||
t.Fatalf("name = %q, want %q", name, "linux")
|
||||
}
|
||||
if version != "" {
|
||||
t.Fatalf("version = %q, want empty string", version)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildReportDetectsSynologyDSMFromVersionFile(t *testing.T) {
|
||||
fixedReadFile := func(name string) ([]byte, error) {
|
||||
switch name {
|
||||
case "/etc/machine-id":
|
||||
return []byte("0123456789abcdef0123456789abcdef\n"), nil
|
||||
case "/etc.defaults/VERSION":
|
||||
return []byte(`majorversion="7"
|
||||
minorversion="2"
|
||||
productversion="7.2.2"
|
||||
buildnumber="72806"
|
||||
smallfixnumber="3"
|
||||
`), nil
|
||||
default:
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
}
|
||||
|
||||
mc := &mockCollector{
|
||||
goos: "linux",
|
||||
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {
|
||||
return &gohost.InfoStat{
|
||||
Hostname: "nas",
|
||||
HostID: "",
|
||||
Platform: "linux",
|
||||
PlatformFamily: "linux",
|
||||
PlatformVersion: "",
|
||||
KernelVersion: "4.4.302+",
|
||||
KernelArch: "x86_64",
|
||||
}, nil
|
||||
},
|
||||
readFileFn: fixedReadFile,
|
||||
}
|
||||
|
||||
agent, err := New(Config{
|
||||
APIToken: "token",
|
||||
LogLevel: -1,
|
||||
Collector: mc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
|
||||
report, err := agent.buildReport(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("buildReport() failed: %v", err)
|
||||
}
|
||||
|
||||
if report.Host.OSName != "Synology DSM" {
|
||||
t.Fatalf("Host.OSName = %q, want %q", report.Host.OSName, "Synology DSM")
|
||||
}
|
||||
if report.Host.OSVersion != "7.2.2-72806 Update 3" {
|
||||
t.Fatalf("Host.OSVersion = %q, want %q", report.Host.OSVersion, "7.2.2-72806 Update 3")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user