feat: add fingerprint command to agent (#1726)

Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
Sven van Ginkel
2026-02-06 20:32:57 +01:00
committed by GitHub
parent 221be1da58
commit 6b7845b03e
7 changed files with 242 additions and 50 deletions

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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 {

87
agent/fingerprint.go Normal file
View File

@@ -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
}

103
agent/fingerprint_test.go Normal file
View File

@@ -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)
})
}

View File

@@ -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
}