Detect NAS host vendors from platform files

This commit is contained in:
rcourtman
2026-04-13 11:25:27 +01:00
parent 754aa0e39c
commit 3981df57a2
3 changed files with 322 additions and 0 deletions
+1
View File
@@ -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 == "" {
+166
View File
@@ -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
}
+155
View File
@@ -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")
}
}