From 3981df57a2dfb9f215933f3a6409d62007a4946e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 13 Apr 2026 11:25:27 +0100 Subject: [PATCH] Detect NAS host vendors from platform files --- internal/hostagent/agent.go | 1 + internal/hostagent/os_identity.go | 166 +++++++++++++++++++++++++ internal/hostagent/os_identity_test.go | 155 +++++++++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 internal/hostagent/os_identity.go create mode 100644 internal/hostagent/os_identity_test.go diff --git a/internal/hostagent/agent.go b/internal/hostagent/agent.go index 0d0535603..b9098fdbb 100644 --- a/internal/hostagent/agent.go +++ b/internal/hostagent/agent.go @@ -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 == "" { diff --git a/internal/hostagent/os_identity.go b/internal/hostagent/os_identity.go new file mode 100644 index 000000000..fad3f5afd --- /dev/null +++ b/internal/hostagent/os_identity.go @@ -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 +} diff --git a/internal/hostagent/os_identity_test.go b/internal/hostagent/os_identity_test.go new file mode 100644 index 000000000..fec1cd658 --- /dev/null +++ b/internal/hostagent/os_identity_test.go @@ -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") + } +}