From 6b7845b03e480fec3684b5d7d6cae58a78bbab04 Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Fri, 6 Feb 2026 20:32:57 +0100 Subject: [PATCH] feat: add fingerprint command to agent (#1726) Co-authored-by: henrygd --- agent/agent.go | 38 ++----------- agent/data_dir.go | 4 +- agent/data_dir_test.go | 10 ++-- agent/fingerprint.go | 87 ++++++++++++++++++++++++++++++ agent/fingerprint_test.go | 103 ++++++++++++++++++++++++++++++++++++ agent/update.go | 9 ++-- internal/cmd/agent/agent.go | 41 ++++++++++++-- 7 files changed, 242 insertions(+), 50 deletions(-) create mode 100644 agent/fingerprint.go create mode 100644 agent/fingerprint_test.go diff --git a/agent/agent.go b/agent/agent.go index 31e67392..7c30d926 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -5,11 +5,8 @@ package agent import ( - "crypto/sha256" - "encoding/hex" "log/slog" "os" - "path/filepath" "strings" "sync" "time" @@ -19,7 +16,6 @@ import ( "github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/entities/system" - "github.com/shirou/gopsutil/v4/host" gossh "golang.org/x/crypto/ssh" ) @@ -65,7 +61,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) { agent.netIoStats = make(map[uint16]system.NetIoStats) agent.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64]) - agent.dataDir, err = getDataDir(dataDir...) + agent.dataDir, err = GetDataDir(dataDir...) if err != nil { slog.Warn("Data directory not found") } else { @@ -228,40 +224,12 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD return data } -// StartAgent initializes and starts the agent with optional WebSocket connection +// Start initializes and starts the agent with optional WebSocket connection func (a *Agent) Start(serverOptions ServerOptions) error { a.keys = serverOptions.Keys return a.connectionManager.Start(serverOptions) } func (a *Agent) getFingerprint() string { - // first look for a fingerprint in the data directory - if a.dataDir != "" { - if fp, err := os.ReadFile(filepath.Join(a.dataDir, "fingerprint")); err == nil { - if s := strings.TrimSpace(string(fp)); s != "" { - return s - } - } - } - - // if no fingerprint is found, generate one - fingerprint, err := host.HostID() - // we ignore a commonly known "product_uuid" known not to be unique - if err != nil || fingerprint == "" || fingerprint == "03000200-0400-0500-0006-000700080009" { - fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel - } - - // hash fingerprint - sum := sha256.Sum256([]byte(fingerprint)) - fingerprint = hex.EncodeToString(sum[:24]) - - // save fingerprint to data directory - if a.dataDir != "" { - err = os.WriteFile(filepath.Join(a.dataDir, "fingerprint"), []byte(fingerprint), 0644) - if err != nil { - slog.Warn("Failed to save fingerprint", "err", err) - } - } - - return fingerprint + return GetFingerprint(a.dataDir, a.systemDetails.Hostname, a.systemDetails.CpuModel) } diff --git a/agent/data_dir.go b/agent/data_dir.go index cb713669..d96844b4 100644 --- a/agent/data_dir.go +++ b/agent/data_dir.go @@ -8,10 +8,10 @@ import ( "runtime" ) -// getDataDir returns the path to the data directory for the agent and an error +// GetDataDir returns the path to the data directory for the agent and an error // if the directory is not valid. Attempts to find the optimal data directory if // no data directories are provided. -func getDataDir(dataDirs ...string) (string, error) { +func GetDataDir(dataDirs ...string) (string, error) { if len(dataDirs) > 0 { return testDataDirs(dataDirs) } diff --git a/agent/data_dir_test.go b/agent/data_dir_test.go index 7c12ffae..f5612ccc 100644 --- a/agent/data_dir_test.go +++ b/agent/data_dir_test.go @@ -17,7 +17,7 @@ func TestGetDataDir(t *testing.T) { // Test with explicit dataDir parameter t.Run("explicit data dir", func(t *testing.T) { tempDir := t.TempDir() - result, err := getDataDir(tempDir) + result, err := GetDataDir(tempDir) require.NoError(t, err) assert.Equal(t, tempDir, result) }) @@ -26,7 +26,7 @@ func TestGetDataDir(t *testing.T) { t.Run("explicit data dir - create new", func(t *testing.T) { tempDir := t.TempDir() newDir := filepath.Join(tempDir, "new-data-dir") - result, err := getDataDir(newDir) + result, err := GetDataDir(newDir) require.NoError(t, err) assert.Equal(t, newDir, result) @@ -52,7 +52,7 @@ func TestGetDataDir(t *testing.T) { os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir) - result, err := getDataDir() + result, err := GetDataDir() require.NoError(t, err) assert.Equal(t, tempDir, result) }) @@ -60,7 +60,7 @@ func TestGetDataDir(t *testing.T) { // Test with invalid explicit dataDir t.Run("invalid explicit data dir", func(t *testing.T) { invalidPath := "/invalid/path/that/cannot/be/created" - _, err := getDataDir(invalidPath) + _, err := GetDataDir(invalidPath) assert.Error(t, err) }) @@ -79,7 +79,7 @@ func TestGetDataDir(t *testing.T) { // This will try platform-specific defaults, which may or may not work // We're mainly testing that it doesn't panic and returns some result - result, err := getDataDir() + result, err := GetDataDir() // We don't assert success/failure here since it depends on system permissions // Just verify we get a string result if no error if err == nil { diff --git a/agent/fingerprint.go b/agent/fingerprint.go new file mode 100644 index 00000000..37920e94 --- /dev/null +++ b/agent/fingerprint.go @@ -0,0 +1,87 @@ +package agent + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/host" +) + +const fingerprintFileName = "fingerprint" + +// knownBadUUID is a commonly known "product_uuid" that is not unique across systems. +const knownBadUUID = "03000200-0400-0500-0006-000700080009" + +// GetFingerprint returns the agent fingerprint. It first tries to read a saved +// fingerprint from the data directory. If not found (or dataDir is empty), it +// generates one from system properties. The hostname and cpuModel parameters are +// used as fallback material if host.HostID() fails. If either is empty, they +// are fetched from the system automatically. +// +// If a new fingerprint is generated and a dataDir is provided, it is saved. +func GetFingerprint(dataDir, hostname, cpuModel string) string { + if dataDir != "" { + if fp, err := readFingerprint(dataDir); err == nil { + return fp + } + } + fp := generateFingerprint(hostname, cpuModel) + if dataDir != "" { + _ = SaveFingerprint(dataDir, fp) + } + return fp +} + +// generateFingerprint creates a fingerprint from system properties. +// It tries host.HostID() first, falling back to hostname + cpuModel. +// If hostname or cpuModel are empty, they are fetched from the system. +func generateFingerprint(hostname, cpuModel string) string { + fingerprint, err := host.HostID() + if err != nil || fingerprint == "" || fingerprint == knownBadUUID { + if hostname == "" { + hostname, _ = os.Hostname() + } + if cpuModel == "" { + if info, err := cpu.Info(); err == nil && len(info) > 0 { + cpuModel = info[0].ModelName + } + } + fingerprint = hostname + cpuModel + } + + sum := sha256.Sum256([]byte(fingerprint)) + return hex.EncodeToString(sum[:24]) +} + +// readFingerprint reads the saved fingerprint from the data directory. +func readFingerprint(dataDir string) (string, error) { + fp, err := os.ReadFile(filepath.Join(dataDir, fingerprintFileName)) + if err != nil { + return "", err + } + s := strings.TrimSpace(string(fp)) + if s == "" { + return "", errors.New("fingerprint file is empty") + } + return s, nil +} + +// SaveFingerprint writes the fingerprint to the data directory. +func SaveFingerprint(dataDir, fingerprint string) error { + return os.WriteFile(filepath.Join(dataDir, fingerprintFileName), []byte(fingerprint), 0o644) +} + +// DeleteFingerprint removes the saved fingerprint file from the data directory. +// Returns nil if the file does not exist (idempotent). +func DeleteFingerprint(dataDir string) error { + err := os.Remove(filepath.Join(dataDir, fingerprintFileName)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} diff --git a/agent/fingerprint_test.go b/agent/fingerprint_test.go new file mode 100644 index 00000000..a45c92c4 --- /dev/null +++ b/agent/fingerprint_test.go @@ -0,0 +1,103 @@ +//go:build testing +// +build testing + +package agent + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetFingerprint(t *testing.T) { + t.Run("reads existing fingerprint from file", func(t *testing.T) { + dir := t.TempDir() + expected := "abc123def456" + err := os.WriteFile(filepath.Join(dir, fingerprintFileName), []byte(expected), 0644) + require.NoError(t, err) + + fp := GetFingerprint(dir, "", "") + assert.Equal(t, expected, fp) + }) + + t.Run("trims whitespace from file", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, fingerprintFileName), []byte(" abc123 \n"), 0644) + require.NoError(t, err) + + fp := GetFingerprint(dir, "", "") + assert.Equal(t, "abc123", fp) + }) + + t.Run("generates fingerprint when file does not exist", func(t *testing.T) { + dir := t.TempDir() + fp := GetFingerprint(dir, "", "") + assert.NotEmpty(t, fp) + }) + + t.Run("generates fingerprint when dataDir is empty", func(t *testing.T) { + fp := GetFingerprint("", "", "") + assert.NotEmpty(t, fp) + }) + + t.Run("generates consistent fingerprint for same inputs", func(t *testing.T) { + fp1 := GetFingerprint("", "myhost", "mycpu") + fp2 := GetFingerprint("", "myhost", "mycpu") + assert.Equal(t, fp1, fp2) + }) + + t.Run("prefers saved fingerprint over generated", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, SaveFingerprint(dir, "saved-fp")) + + fp := GetFingerprint(dir, "anyhost", "anycpu") + assert.Equal(t, "saved-fp", fp) + }) +} + +func TestSaveFingerprint(t *testing.T) { + t.Run("saves fingerprint to file", func(t *testing.T) { + dir := t.TempDir() + err := SaveFingerprint(dir, "abc123") + require.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(dir, fingerprintFileName)) + require.NoError(t, err) + assert.Equal(t, "abc123", string(content)) + }) + + t.Run("overwrites existing fingerprint", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, SaveFingerprint(dir, "old")) + require.NoError(t, SaveFingerprint(dir, "new")) + + content, err := os.ReadFile(filepath.Join(dir, fingerprintFileName)) + require.NoError(t, err) + assert.Equal(t, "new", string(content)) + }) +} + +func TestDeleteFingerprint(t *testing.T) { + t.Run("deletes existing fingerprint", func(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, fingerprintFileName) + err := os.WriteFile(fp, []byte("abc123"), 0644) + require.NoError(t, err) + + err = DeleteFingerprint(dir) + require.NoError(t, err) + + // Verify file is gone + _, err = os.Stat(fp) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("no error when file does not exist", func(t *testing.T) { + dir := t.TempDir() + err := DeleteFingerprint(dir) + assert.NoError(t, err) + }) +} diff --git a/agent/update.go b/agent/update.go index b7cdb716..0164f5a7 100644 --- a/agent/update.go +++ b/agent/update.go @@ -63,9 +63,9 @@ func detectRestarter() restarter { if path, err := exec.LookPath("rc-service"); err == nil { return &openRCRestarter{cmd: path} } - if path, err := exec.LookPath("procd"); err == nil { - return &openWRTRestarter{cmd: path} - } + if path, err := exec.LookPath("procd"); err == nil { + return &openWRTRestarter{cmd: path} + } if path, err := exec.LookPath("service"); err == nil { if runtime.GOOS == "freebsd" { return &freeBSDRestarter{cmd: path} @@ -79,7 +79,7 @@ func detectRestarter() restarter { func Update(useMirror bool) error { exePath, _ := os.Executable() - dataDir, err := getDataDir() + dataDir, err := GetDataDir() if err != nil { dataDir = os.TempDir() } @@ -125,4 +125,3 @@ func Update(useMirror bool) error { return nil } - diff --git a/internal/cmd/agent/agent.go b/internal/cmd/agent/agent.go index cf07e531..425dd9f8 100644 --- a/internal/cmd/agent/agent.go +++ b/internal/cmd/agent/agent.go @@ -38,6 +38,9 @@ func (opts *cmdOptions) parse() bool { } fmt.Print("ok") return true + case "fingerprint": + handleFingerprint() + return true } // pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true @@ -71,9 +74,9 @@ func (opts *cmdOptions) parse() bool { builder.WriteString(os.Args[0]) builder.WriteString(" [command] [flags]\n") builder.WriteString("\nCommands:\n") - builder.WriteString(" health Check if the agent is running\n") - // builder.WriteString(" help Display this help message\n") - builder.WriteString(" update Update to the latest version\n") + builder.WriteString(" fingerprint View or reset the agent fingerprint\n") + builder.WriteString(" health Check if the agent is running\n") + builder.WriteString(" update Update to the latest version\n") builder.WriteString("\nFlags:\n") fmt.Print(builder.String()) pflag.PrintDefaults() @@ -134,6 +137,38 @@ func (opts *cmdOptions) getAddress() string { return agent.GetAddress(opts.listen) } +// handleFingerprint handles the "fingerprint" command with subcommands "view" and "reset". +func handleFingerprint() { + subCmd := "" + if len(os.Args) > 2 { + subCmd = os.Args[2] + } + + switch subCmd { + case "", "view": + dataDir, _ := agent.GetDataDir() + fp := agent.GetFingerprint(dataDir, "", "") + fmt.Println(fp) + case "help", "-h", "--help": + fmt.Print(fingerprintUsage()) + case "reset": + dataDir, err := agent.GetDataDir() + if err != nil { + log.Fatal(err) + } + if err := agent.DeleteFingerprint(dataDir); err != nil { + log.Fatal(err) + } + fmt.Println("Fingerprint reset. A new one will be generated on next start.") + default: + log.Fatalf("Unknown command: %q\n\n%s", subCmd, fingerprintUsage()) + } +} + +func fingerprintUsage() string { + return fmt.Sprintf("Usage: %s fingerprint [view|reset]\n\nCommands:\n view Print fingerprint (default)\n reset Reset saved fingerprint\n", os.Args[0]) +} + func main() { var opts cmdOptions subcommandHandled := opts.parse()