Compare commits

...

8 Commits

Author SHA1 Message Date
Martin
946f2e6be1 Report the configured listen address after install (#2243)
The final message always echoed $PORT, which falls back to the default when -p
is not passed. Existing service files are kept as they are, so a plain upgrade
on a host with a custom port reported that the agent runs on 45876 regardless
of the actual configuration.

Read the address from the active service file instead. LISTEN is checked before
PORT to match the agent's own precedence in GetAddress, and the value is read as
text so host:port and unix socket paths are reported as configured.
2026-08-19 11:48:01 -04:00
Sven van Ginkel
ba90daf4d6 fix(scripts): update agent env vars on reinstall instead of skipping (#2107)
Co-authored-by: henrygd <hank@henrygd.me>
2026-08-19 11:37:11 -04:00
Toomore Chiang
aa1d67a122 fix(agent): strip invalid UTF-8 from battery names (#2241)
Battery names come from firmware (sysfs model_name on Linux), which does not
guarantee valid UTF-8. The hub decodes agent payloads using the default
fxamacker/cbor decode mode, which rejects invalid UTF-8, so a single bad byte
in a battery name makes the hub drop the entire payload and mark the system
down until the agent is downgraded.
2026-08-19 11:02:33 -04:00
Alec Rubin
68a3f8962a fix(agent): read /proc/uptime on linux instead of sysinfo(2) (#2180)
gopsutil's host.Uptime() calls the sysinfo(2) syscall. Inside an LXC
container lxcfs virtualizes /proc/uptime but cannot intercept a
syscall, so every container reported the host's uptime.

Reads /proc/uptime on linux and falls back to host.Uptime() if the file
is missing or unparseable, so other platforms are unchanged.
2026-08-18 15:18:33 -04:00
Ilya Muratov
0eb3426619 fix(agent): discover fans on legacy hwmon parent devices (#2238) 2026-08-18 11:32:52 -04:00
Jan Dziąsło
96beadc8c9 fix(agent): add fallback for CPU model detection on MIPS architectures (#2138)
gopsutil's cpu.Info() does not parse the 'cpu model' field from
/proc/cpuinfo, which is the only source of CPU model names on MIPS.
Add a fallback that reads /proc/cpuinfo directly and combines
'cpu model' (e.g. 'MIPS 1004Kc V2.15') with 'system type'
(e.g. 'MediaTek MT7621 ver:1 eco:3') for a complete identifier.

The fallback only triggers when gopsutil returns an empty ModelName,
so x86/ARM/other architectures are unaffected.
2026-08-18 10:34:41 -04:00
Sven van Ginkel
65a6f60304 fix(agent): fix QNAP MD RAID arrays incorrectly reported as FAILED (#2065) 2026-08-18 10:10:12 -04:00
hank
54dae08631 chore(helm): update app version to 0.18.8 (#2235) 2026-08-17 17:36:01 -04:00
18 changed files with 637 additions and 47 deletions

View File

@@ -33,7 +33,10 @@ var errNoBatteries = errors.New("no readable batteries")
func normalizeBatteries(batteries []Battery) []Battery { func normalizeBatteries(batteries []Battery) []Battery {
nameCounts := make(map[string]int, len(batteries)) nameCounts := make(map[string]int, len(batteries))
for i := range batteries { for i := range batteries {
name := strings.TrimSpace(batteries[i].Name) // Names come from firmware (e.g. sysfs model_name) and are not guaranteed to
// be valid UTF-8. Invalid bytes are rejected when the hub decodes the CBOR
// payload, which drops every metric for the system, so strip them here.
name := strings.TrimSpace(strings.ToValidUTF8(batteries[i].Name, ""))
if name == "" { if name == "" {
name = "Battery " + strconv.Itoa(i+1) name = "Battery " + strconv.Itoa(i+1)
} }

View File

@@ -2,6 +2,7 @@ package battery
import ( import (
"testing" "testing"
"unicode/utf8"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -33,3 +34,15 @@ func TestNormalizeBatteriesFallbackNames(t *testing.T) {
bats := normalizeBatteries([]Battery{{}, {}, {Name: "Mouse"}, {Name: "Mouse"}}) bats := normalizeBatteries([]Battery{{}, {}, {Name: "Mouse"}, {Name: "Mouse"}})
assert.Equal(t, []string{"Battery 1", "Battery 2", "Mouse", "Mouse (2)"}, []string{bats[0].Name, bats[1].Name, bats[2].Name, bats[3].Name}) assert.Equal(t, []string{"Battery 1", "Battery 2", "Mouse", "Mouse (2)"}, []string{bats[0].Name, bats[1].Name, bats[2].Name, bats[3].Name})
} }
func TestNormalizeBatteriesStripsInvalidUTF8(t *testing.T) {
// Firmware occasionally reports names that are not valid UTF-8 (a ThinkPad
// reporting "LNV-5B11K63024@\xd0" in model_name is a real example).
bats := normalizeBatteries([]Battery{{Name: "LNV-5B11K63024@\xd0"}, {Name: "\xff\xfe"}})
assert.Equal(t, "LNV-5B11K63024@", bats[0].Name)
// A name made up entirely of invalid bytes falls back to the generic name.
assert.Equal(t, "Battery 2", bats[1].Name)
for _, b := range bats {
assert.True(t, utf8.ValidString(b.Name))
}
}

View File

@@ -72,14 +72,30 @@ func discoverHwmonFans(root string) ([]fanSensor, error) {
var sensors []fanSensor var sensors []fanSensor
for _, entry := range entries { for _, entry := range entries {
chipDir := filepath.Join(root, entry.Name()) chipDir := filepath.Join(root, entry.Name())
chipName := utils.ReadStringFile(filepath.Join(chipDir, "name")) sensorDir := chipDir
inputs, _ := filepath.Glob(filepath.Join(sensorDir, "fan*_input"))
// Some legacy hwmon drivers (notably applesmc) register a hwmon class
// device but create fan attributes on the parent platform device. In
// sysfs that parent is exposed through hwmonN/device.
if len(inputs) == 0 {
deviceDir := filepath.Join(chipDir, "device")
if deviceInputs, _ := filepath.Glob(filepath.Join(deviceDir, "fan*_input")); len(deviceInputs) > 0 {
sensorDir = deviceDir
inputs = deviceInputs
}
}
chipName := utils.ReadStringFile(filepath.Join(sensorDir, "name"))
if chipName == "" {
chipName = utils.ReadStringFile(filepath.Join(chipDir, "name"))
}
if chipName == "" { if chipName == "" {
chipName = entry.Name() chipName = entry.Name()
} }
inputs, _ := filepath.Glob(filepath.Join(chipDir, "fan*_input"))
for _, inputPath := range inputs { for _, inputPath := range inputs {
base := strings.TrimSuffix(filepath.Base(inputPath), "_input") base := strings.TrimSuffix(filepath.Base(inputPath), "_input")
label := utils.ReadStringFile(filepath.Join(chipDir, base+"_label")) label := utils.ReadStringFile(filepath.Join(sensorDir, base+"_label"))
key := chipName + "_" + base key := chipName + "_" + base
if label != "" { if label != "" {
key = chipName + "_" + label key = chipName + "_" + label

View File

@@ -50,6 +50,24 @@ func TestReadHwmonFans(t *testing.T) {
}, fans) }, fans)
} }
// TestReadHwmonFansLegacyParent verifies legacy hwmon layouts such as applesmc,
// where the hwmon class node exists but fan attributes live on hwmonN/device.
func TestReadHwmonFansLegacyParent(t *testing.T) {
root := t.TempDir()
deviceDir := filepath.Join(root, "devices", "applesmc.768")
writeFile(t, filepath.Join(deviceDir, "name"), "applesmc\n")
writeFile(t, filepath.Join(deviceDir, "fan1_input"), "1202\n")
writeFile(t, filepath.Join(deviceDir, "fan1_label"), "Exhaust\n")
chipDir := filepath.Join(root, "hwmon1")
require.NoError(t, os.MkdirAll(chipDir, 0o755))
require.NoError(t, os.Symlink(deviceDir, filepath.Join(chipDir, "device")))
fans, err := readHwmonFans(root)
require.NoError(t, err)
assert.Equal(t, map[string]uint16{"applesmc_Exhaust": 1202}, fans)
}
// TestReadHwmonFansMissingRoot returns an error rather than panicking when the // TestReadHwmonFansMissingRoot returns an error rather than panicking when the
// hwmon root doesn't exist (e.g. running on a kernel without hwmon support). // hwmon root doesn't exist (e.g. running on a kernel without hwmon support).
func TestReadHwmonFansMissingRoot(t *testing.T) { func TestReadHwmonFansMissingRoot(t *testing.T) {

View File

@@ -50,6 +50,9 @@ func generateFingerprint(hostname, cpuModel string) string {
if info, err := cpu.Info(); err == nil && len(info) > 0 { if info, err := cpu.Info(); err == nil && len(info) > 0 {
cpuModel = info[0].ModelName cpuModel = info[0].ModelName
} }
if cpuModel == "" {
cpuModel = getCpuModelFromCpuinfo()
}
} }
fingerprint = hostname + cpuModel fingerprint = hostname + cpuModel
} }

View File

@@ -17,15 +17,17 @@ import (
var mdraidSysfsRoot = "/sys" var mdraidSysfsRoot = "/sys"
type mdraidHealth struct { type mdraidHealth struct {
level string level string
arrayState string arrayState string
degraded uint64 degraded uint64
raidDisks uint64 faultyDisks uint64
syncAction string populatedDisks uint64
syncCompleted string raidDisks uint64
syncSpeed string syncAction string
mismatchCnt uint64 syncCompleted string
capacity uint64 syncSpeed string
mismatchCnt uint64
capacity uint64
} }
// scanMdraidDevices discovers Linux md arrays exposed in sysfs. // scanMdraidDevices discovers Linux md arrays exposed in sysfs.
@@ -92,6 +94,9 @@ func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error
if health.degraded > 0 { if health.degraded > 0 {
attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded}) attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded})
} }
if health.faultyDisks > 0 {
attrs = append(attrs, &smart.SmartAttribute{Name: "FaultyDisks", RawValue: health.faultyDisks})
}
if health.syncAction != "" { if health.syncAction != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction}) attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction})
} }
@@ -152,6 +157,7 @@ func readMdraidHealth(blockName string) (mdraidHealth, bool) {
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok { if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok {
out.degraded = val out.degraded = val
} }
out.faultyDisks, out.populatedDisks = countMdraidMemberStates(blockName, mdraidSysfsRoot)
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok { if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
out.mismatchCnt = val out.mismatchCnt = val
} }
@@ -177,7 +183,19 @@ func mdraidSmartStatus(health mdraidHealth) string {
case "resync", "recover", "reshape": case "resync", "recover", "reshape":
return "WARNING" return "WARNING"
} }
// Use actual faulty member count rather than the degraded counter, which
// equals raid_disks minus active_disks. On QNAP systems raid_disks may be
// set to a large value (e.g. 32) while only a few slots are ever used,
// making degraded misleadingly large despite zero failed disks.
if health.faultyDisks > 0 {
return "FAILED"
}
if health.degraded > 0 { if health.degraded > 0 {
if isSparseSlotDegraded(health) {
// A sysfs snapshot cannot distinguish reserved slots from a removed
// member on sparse arrays, so report the ambiguity as a warning.
return "WARNING"
}
return "FAILED" return "FAILED"
} }
if health.mismatchCnt > 0 { if health.mismatchCnt > 0 {
@@ -196,6 +214,43 @@ func mdraidSmartStatus(health mdraidHealth) string {
return "UNKNOWN" return "UNKNOWN"
} }
// countMdraidMemberStates reads member device directories under
// block/<name>/md and returns how many are explicitly marked "faulty", plus
// how many are populated at all (regardless of state). populatedDisks lets
// callers distinguish RAID slots that were never used (QNAP reserves far
// more raid_disks than it ever populates) from members that went missing.
func countMdraidMemberStates(blockName, root string) (faultyDisks, populatedDisks uint64) {
devDir := filepath.Join(root, "block", blockName, "md")
entries, err := os.ReadDir(devDir)
if err != nil {
return 0, 0
}
for _, ent := range entries {
if !strings.HasPrefix(ent.Name(), "dev-") {
continue
}
populatedDisks++
statePath := filepath.Join(devDir, ent.Name(), "state")
state := utils.ReadStringFile(statePath)
if strings.Contains(state, "faulty") {
faultyDisks++
}
}
return faultyDisks, populatedDisks
}
// isSparseSlotDegraded reports whether a non-zero "degraded" count may be
// explained by RAID slots that were never populated. QNAP configures system
// arrays with raid_disks set to a large fixed maximum (e.g. 32) far beyond the
// handful of slots it ever populates, so sparse slots outnumber populated ones.
func isSparseSlotDegraded(health mdraidHealth) bool {
if health.populatedDisks == 0 || health.raidDisks <= health.populatedDisks {
return false
}
sparseSlots := health.raidDisks - health.populatedDisks
return sparseSlots > health.populatedDisks
}
// isMdraidBlockName matches /dev/mdN-style block device names. // isMdraidBlockName matches /dev/mdN-style block device names.
func isMdraidBlockName(name string) bool { func isMdraidBlockName(name string) bool {
if !strings.HasPrefix(name, "md") { if !strings.HasPrefix(name, "md") {

View File

@@ -40,6 +40,15 @@ func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
write(filepath.Join(mdDir, "sync_completed"), "10%\n") write(filepath.Join(mdDir, "sync_completed"), "10%\n")
write(filepath.Join(mdDir, "sync_speed"), "100M\n") write(filepath.Join(mdDir, "sync_speed"), "100M\n")
write(filepath.Join(mdDir, "mismatch_cnt"), "0\n") write(filepath.Join(mdDir, "mismatch_cnt"), "0\n")
// Simulate two healthy member devices (no faulty state).
for _, dev := range []string{"dev-sda", "dev-sdb"} {
devPath := filepath.Join(mdDir, dev)
if err := os.MkdirAll(devPath, 0o755); err != nil {
t.Fatal(err)
}
write(filepath.Join(devPath, "state"), "in_sync\n")
}
write(filepath.Join(queueDir, "logical_block_size"), "512\n") write(filepath.Join(queueDir, "logical_block_size"), "512\n")
write(filepath.Join(tmp, "block", "md0", "size"), "2048\n") write(filepath.Join(tmp, "block", "md0", "size"), "2048\n")
@@ -81,15 +90,77 @@ func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
} }
} }
func TestCountMdraidMemberStates(t *testing.T) {
tmp := t.TempDir()
write := func(path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
mdDir := filepath.Join(tmp, "block", "md0", "md")
// No dev-* entries: zero faulty, zero populated.
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 0 {
t.Fatalf("no members: got (faulty=%d populated=%d), want (0,0)", faulty, populated)
}
// Two healthy members.
write(filepath.Join(mdDir, "dev-sda", "state"), "in_sync\n")
write(filepath.Join(mdDir, "dev-sdb", "state"), "in_sync\n")
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 2 {
t.Fatalf("all in_sync: got (faulty=%d populated=%d), want (0,2)", faulty, populated)
}
// One faulty member.
write(filepath.Join(mdDir, "dev-sdb", "state"), "faulty\n")
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 1 || populated != 2 {
t.Fatalf("one faulty: got (faulty=%d populated=%d), want (1,2)", faulty, populated)
}
// QNAP-style: 28 degraded slots but no dev-* entries for them, 4 in_sync.
write(filepath.Join(mdDir, "dev-sdb", "state"), "in_sync\n")
write(filepath.Join(mdDir, "dev-sdc", "state"), "in_sync\n")
write(filepath.Join(mdDir, "dev-sdd", "state"), "in_sync\n")
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 4 {
t.Fatalf("qnap sparse: got (faulty=%d populated=%d), want (0,4)", faulty, populated)
}
}
func TestMdraidSmartStatus(t *testing.T) { func TestMdraidSmartStatus(t *testing.T) {
if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" {
t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got) t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got)
} }
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, syncAction: "recover"}); got != "WARNING" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1, syncAction: "recover"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got) t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got)
} }
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1}); got != "FAILED" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1}); got != "FAILED" {
t.Fatalf("mdraidSmartStatus(degraded) = %q, want FAILED", got) t.Fatalf("mdraidSmartStatus(degraded+faulty) = %q, want FAILED", got)
}
// QNAP-style: raid_disks=32 but only 4 populated; degraded=28 but no faulty devices.
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 28, faultyDisks: 0, raidDisks: 32, populatedDisks: 4}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(qnap sparse) = %q, want WARNING", got)
}
// A member disappearing from the same sparse array is indistinguishable
// from another reserved slot, so it must not be reported as healthy.
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 29, faultyDisks: 0, raidDisks: 32, populatedDisks: 3}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(qnap sparse missing member) = %q, want WARNING", got)
}
// A genuinely missing member (removed dev-* entry, not just an unpopulated
// QNAP reserve slot) must still fail: raid_disks=4, only 3 populated, all
// of them in_sync, so faultyDisks==0 but degraded==1.
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 1, faultyDisks: 0, raidDisks: 4, populatedDisks: 3}); got != "FAILED" {
t.Fatalf("mdraidSmartStatus(missing member) = %q, want FAILED", got)
}
// Degraded with no member-state info at all (e.g. sysfs read failed) must
// still fail rather than being silently treated as a sparse QNAP array.
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 1, faultyDisks: 0, raidDisks: 4, populatedDisks: 0}); got != "FAILED" {
t.Fatalf("mdraidSmartStatus(degraded, no member info) = %q, want FAILED", got)
} }
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got) t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)

View File

@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
"runtime" "runtime"
@@ -78,6 +79,12 @@ func (a *Agent) refreshSystemDetails() {
if info, err := cpu.Info(); err == nil && len(info) > 0 { if info, err := cpu.Info(); err == nil && len(info) > 0 {
a.systemDetails.CpuModel = info[0].ModelName a.systemDetails.CpuModel = info[0].ModelName
} }
// gopsutil doesn't parse the "cpu model" field from /proc/cpuinfo, which
// is the only source of the CPU model name on MIPS. Fall back to reading
// it directly when ModelName is empty.
if a.systemDetails.CpuModel == "" {
a.systemDetails.CpuModel = getCpuModelFromCpuinfo()
}
// cores / threads // cores / threads
cores, _ := cpu.Counts(false) cores, _ := cpu.Counts(false)
threads := hostInfo.NCPU threads := hostInfo.NCPU
@@ -258,13 +265,66 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
a.systemInfo.MemPct = systemStats.MemPct a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Battery = systemStats.Battery a.systemInfo.Battery = systemStats.Battery
a.systemInfo.Uptime, _ = host.Uptime() a.systemInfo.Uptime, _ = getUptime()
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1] a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
a.systemInfo.Threads = a.systemDetails.Threads a.systemInfo.Threads = a.systemDetails.Threads
return systemStats return systemStats
} }
// cpuModelFallbackKeys are the field names to look for in /proc/cpuinfo when
// gopsutil fails to return a ModelName. The "cpu model" key is used on MIPS
// (e.g. "MIPS 1004Kc V2.15"), while "system type" provides SoC information
// on various embedded architectures.
var cpuModelFallbackKeys = []string{"cpu model", "system type"}
// getCpuModelFromCpuinfo reads /proc/cpuinfo and returns a CPU model string.
// This is a fallback for architectures where gopsutil's cpu.Info() does not
// populate ModelName, most notably MIPS.
func getCpuModelFromCpuinfo() string {
file, err := os.Open("/proc/cpuinfo")
if err != nil {
return ""
}
defer file.Close()
return parseCpuModel(file)
}
// parseCpuModel scans r (expected to be /proc/cpuinfo content) and returns
// a combined CPU model string. It collects values from all matching keys
// and joins them with " / " when multiple are found.
func parseCpuModel(r io.Reader) string {
lines := readLines(r)
var parts []string
for _, key := range cpuModelFallbackKeys {
for _, line := range lines {
after, found := strings.CutPrefix(line, key)
if !found {
continue
}
after = strings.TrimSpace(after)
if len(after) < 2 || after[0] != ':' {
continue
}
if value := strings.TrimSpace(after[1:]); value != "" {
parts = append(parts, value)
break
}
}
}
return strings.Join(parts, " / ")
}
// readLines reads all lines from r into a slice.
func readLines(r io.Reader) []string {
scanner := bufio.NewScanner(r)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines
}
// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may // calculateHostMemoryUsage derives counters defensively because /proc/meminfo may
// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero. // change while gopsutil reads it. Invalid unsigned subtractions saturate at zero.
func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) { func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) {

View File

@@ -1,6 +1,7 @@
package agent package agent
import ( import (
"strings"
"testing" "testing"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
@@ -113,3 +114,81 @@ func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
assert.False(t, agent.detailsDirty) assert.False(t, agent.detailsDirty)
assert.Nil(t, original.Details) assert.Nil(t, original.Details)
} }
func TestParseCpuModel(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "MIPS with both cpu model and system type",
input: `system type : MediaTek MT7621 ver:1 eco:3
machine : ASUS RT-AX53U
processor : 0
cpu model : MIPS 1004Kc V2.15
BogoMIPS : 586.13
wait instruction : yes`,
expected: "MIPS 1004Kc V2.15 / MediaTek MT7621 ver:1 eco:3",
},
{
name: "MIPS with different SoC",
input: `system type : Atheros AR7161 rev 2
machine : NETGEAR WNDR3700
processor : 0
cpu model : MIPS 24Kc V7.4
BogoMIPS : 452.19`,
expected: "MIPS 24Kc V7.4 / Atheros AR7161 rev 2",
},
{
name: "only system type when cpu model missing",
input: `system type : Broadcom BCM47xx
processor : 0
BogoMIPS : 296.11`,
expected: "Broadcom BCM47xx",
},
{
name: "only cpu model when system type missing",
input: `processor : 0
cpu model : MIPS 34Kc V2.15
BogoMIPS : 300.00`,
expected: "MIPS 34Kc V2.15",
},
{
name: "x86 cpuinfo returns empty",
input: `processor : 0
vendor_id : GenuineIntel
cpu family : 6
model : 142
model name : Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz
stepping : 10`,
expected: "",
},
{
name: "empty input",
input: "",
expected: "",
},
{
name: "cpu model with extra whitespace",
input: `processor : 0
cpu model : MIPS 34Kc V2.15
BogoMIPS : 300.00`,
expected: "MIPS 34Kc V2.15",
},
{
name: "cpu model without value",
input: `processor : 0
cpu model :
BogoMIPS : 300.00`,
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseCpuModel(strings.NewReader(tt.input))
assert.Equal(t, tt.expected, result)
})
}
}

44
agent/uptime_linux.go Normal file
View File

@@ -0,0 +1,44 @@
//go:build linux
package agent
import (
"math"
"os"
"strconv"
"strings"
"github.com/shirou/gopsutil/v4/host"
)
// uptimeFilePath is a variable so tests can point it at a fixture.
var uptimeFilePath = "/proc/uptime"
// getUptime returns the system uptime in seconds.
//
// This reads /proc/uptime instead of using host.Uptime(), which calls the
// sysinfo(2) syscall. Inside an LXC container lxcfs virtualizes /proc/uptime
// but cannot intercept a syscall, so sysinfo(2) reports the host's uptime
// rather than the container's.
//
// Falls back to host.Uptime() if /proc/uptime is missing or unparseable, so
// behavior is unchanged anywhere the file isn't available.
func getUptime() (uint64, error) {
data, err := os.ReadFile(uptimeFilePath)
if err != nil {
return host.Uptime()
}
fields := strings.Fields(string(data))
if len(fields) == 0 {
return host.Uptime()
}
seconds, err := strconv.ParseFloat(fields[0], 64)
if err != nil ||
math.IsNaN(seconds) ||
math.IsInf(seconds, 0) ||
seconds < 0 ||
seconds >= 1<<64 {
return host.Uptime()
}
return uint64(seconds), nil
}

101
agent/uptime_linux_test.go Normal file
View File

@@ -0,0 +1,101 @@
//go:build linux
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestGetUptimeFromProc(t *testing.T) {
tests := []struct {
name string
contents string
want uint64
}{
{"typical", "12345.67 98765.43\n", 12345},
{"zero", "0.00 0.00\n", 0},
{"no trailing newline", "42.99 7.00", 42},
{"single field", "600.5", 600},
{"large value", "266030.12 1000000.00\n", 266030},
}
prev := uptimeFilePath
t.Cleanup(func() { uptimeFilePath = prev })
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "uptime")
if err := os.WriteFile(path, []byte(tt.contents), 0o644); err != nil {
t.Fatal(err)
}
uptimeFilePath = path
got, err := getUptime()
if err != nil {
t.Fatalf("getUptime() returned error: %v", err)
}
if got != tt.want {
t.Errorf("getUptime() = %d, want %d", got, tt.want)
}
})
}
}
func writeUptime(contents string) func(t *testing.T) string {
return func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "uptime")
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
return path
}
}
// Malformed, missing, or out-of-range input must fall back to host.Uptime()
// rather than returning a bogus value, so the agent still reports something sane.
func TestGetUptimeFallsBack(t *testing.T) {
prev := uptimeFilePath
t.Cleanup(func() { uptimeFilePath = prev })
for _, tt := range []struct {
name string
prepare func(t *testing.T) string
}{
{"missing file", func(t *testing.T) string {
return filepath.Join(t.TempDir(), "does-not-exist")
}},
{"empty file", func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "uptime")
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatal(err)
}
return path
}},
{"unparseable", func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "uptime")
if err := os.WriteFile(path, []byte("not-a-number 1.0\n"), 0o644); err != nil {
t.Fatal(err)
}
return path
}},
{"NaN", writeUptime("NaN 1.0\n")},
{"positive infinity", writeUptime("+Inf 1.0\n")},
{"negative infinity", writeUptime("-Inf 1.0\n")},
{"negative", writeUptime("-42.5 1.0\n")},
{"exceeds uint64 range", writeUptime("1e20 1.0\n")},
} {
t.Run(tt.name, func(t *testing.T) {
uptimeFilePath = tt.prepare(t)
got, err := getUptime()
if err != nil {
t.Fatalf("getUptime() returned error: %v", err)
}
if got == 0 {
t.Error("getUptime() = 0, expected fallback to host.Uptime()")
}
})
}
}

10
agent/uptime_stub.go Normal file
View File

@@ -0,0 +1,10 @@
//go:build !linux
package agent
import "github.com/shirou/gopsutil/v4/host"
// getUptime returns the system uptime in seconds.
func getUptime() (uint64, error) {
return host.Uptime()
}

View File

@@ -2,9 +2,9 @@ apiVersion: v1
description: Installs beszel-agent in kubernetes description: Installs beszel-agent in kubernetes
home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
name: beszel-agent name: beszel-agent
appVersion: "0.18.7" appVersion: "0.18.8"
# Bump this version when publishing chart changes. # Bump this version when publishing chart changes.
version: 0.1.4 version: 0.1.5
sources: sources:
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent - https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
- https://www.beszel.dev/ - https://www.beszel.dev/

View File

@@ -80,7 +80,7 @@ Essential parameters to configure:
| `secret.sshKey` | `ssh-key` | Key name in the secret for the SSH public key | | `secret.sshKey` | `ssh-key` | Key name in the secret for the SSH public key |
| `secret.tokenKey` | `token` | Key name in the secret for the authentication token | | `secret.tokenKey` | `token` | Key name in the secret for the authentication token |
| `image.repository` | `henrygd/beszel-agent` | Container image | | `image.repository` | `henrygd/beszel-agent` | Container image |
| `image.tag` | Chart AppVersion (0.18.7) | Image version | | `image.tag` | Chart AppVersion (0.18.8) | Image version |
| `hostNetwork` | `false` | Use host network for network monitoring | | `hostNetwork` | `false` | Use host network for network monitoring |
| `tolerations` | Allows all taints | Tolerations for running on tainted nodes | | `tolerations` | Allows all taints | Tolerations for running on tainted nodes |
@@ -385,7 +385,7 @@ helm upgrade beszel-agent ./beszel-agent \
# Change image version # Change image version
helm upgrade beszel-agent ./beszel-agent \ helm upgrade beszel-agent ./beszel-agent \
--set image.tag="0.18.7" --set image.tag="0.18.8"
``` ```
### Restart All Agents ### Restart All Agents
@@ -522,7 +522,7 @@ kubectl get secret beszel-agent -o jsonpath='{.data.ssh-key}' | base64 -d
## Chart Information ## Chart Information
- **Chart Version**: 0.1.0 - **Chart Version**: 0.1.0
- **App Version**: 0.18.7 - **App Version**: 0.18.8
- **Kubernetes Version**: 1.19+ - **Kubernetes Version**: 1.19+
- **Maintainer**: cloudwithdan (nikoloskid@pm.me) - **Maintainer**: cloudwithdan (nikoloskid@pm.me)

View File

@@ -2,9 +2,9 @@ apiVersion: v1
description: Installs beszel-hub in kubernetes description: Installs beszel-hub in kubernetes
home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub
name: beszel-hub name: beszel-hub
appVersion: "0.18.7" appVersion: "0.18.8"
# Bump this version when publishing chart changes. # Bump this version when publishing chart changes.
version: 0.1.4 version: 0.1.5
sources: sources:
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub - https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub
- https://www.beszel.dev/ - https://www.beszel.dev/

View File

@@ -47,7 +47,7 @@ Key configuration options in `values.yaml`:
|-----------|---------|-------------| |-----------|---------|-------------|
| `replicaCount` | `1` | Number of Beszel Hub replicas | | `replicaCount` | `1` | Number of Beszel Hub replicas |
| `image.repository` | `henrygd/beszel` | Container image repository | | `image.repository` | `henrygd/beszel` | Container image repository |
| `image.tag` | Chart AppVersion (0.18.7) | Container image tag | | `image.tag` | Chart AppVersion (0.18.8) | Container image tag |
| `image.pullPolicy` | `IfNotPresent` | Image pull policy | | `image.pullPolicy` | `IfNotPresent` | Image pull policy |
| `service.port` | `8090` | Service port | | `service.port` | `8090` | Service port |
| `persistentVolumeClaim.enabled` | `true` | Enable persistent volume | | `persistentVolumeClaim.enabled` | `true` | Enable persistent volume |
@@ -169,7 +169,7 @@ tolerations:
```yaml ```yaml
replicaCount: 3 replicaCount: 3
image: image:
tag: "0.18.7" tag: "0.18.8"
service: service:
type: LoadBalancer type: LoadBalancer
ingress: ingress:
@@ -330,7 +330,7 @@ By default, Beszel Hub uses a PersistentVolumeClaim for data storage. Ensure you
## Chart Information ## Chart Information
- **Chart Version**: 0.1.0 - **Chart Version**: 0.1.0
- **App Version**: 0.18.7 - **App Version**: 0.18.8
- **Kubernetes Version**: 1.19+ - **Kubernetes Version**: 1.19+
- **Maintainer**: cloudwithdan (nikoloskid@pm.me) - **Maintainer**: cloudwithdan (nikoloskid@pm.me)

View File

@@ -9,9 +9,21 @@ param (
[string]$NSSMPath = "", [string]$NSSMPath = "",
[switch]$ConfigureFirewall, [switch]$ConfigureFirewall,
[ValidateSet("Auto", "Scoop", "WinGet")] [ValidateSet("Auto", "Scoop", "WinGet")]
[string]$InstallMethod = "Auto" [string]$InstallMethod = "Auto",
# Set automatically from $PSBoundParameters below, or forwarded through an elevated relaunch.
# Used so a reinstall only overwrites Token/Url/Port on an existing service if the caller
# actually asked to change them, instead of wiping them with their unset defaults.
[switch]$TokenProvided,
[switch]$UrlProvided,
[switch]$PortProvided
) )
if (-not $Elevated) {
$TokenProvided = $PSBoundParameters.ContainsKey('Token')
$UrlProvided = $PSBoundParameters.ContainsKey('Url')
$PortProvided = $PSBoundParameters.ContainsKey('Port')
}
# Check if required parameters are provided # Check if required parameters are provided
if ([string]::IsNullOrWhiteSpace($Key)) { if ([string]::IsNullOrWhiteSpace($Key)) {
Write-Host "ERROR: SSH Key is required." -ForegroundColor Red Write-Host "ERROR: SSH Key is required." -ForegroundColor Red
@@ -312,7 +324,10 @@ function Install-NSSMService {
[string]$HubUrl = "", [string]$HubUrl = "",
[Parameter(Mandatory=$true)] [Parameter(Mandatory=$true)]
[int]$Port, [int]$Port,
[string]$NSSMPath = "" [string]$NSSMPath = "",
[switch]$TokenProvided,
[switch]$UrlProvided,
[switch]$PortProvided
) )
Write-Host "Installing beszel-agent service..." Write-Host "Installing beszel-agent service..."
@@ -332,10 +347,21 @@ function Install-NSSMService {
Write-Host "Service already exists. Checking if path update is needed..." Write-Host "Service already exists. Checking if path update is needed..."
# Get current service path # Get current service path
$pathNeedsUpdate = $true
try { try {
$currentPath = & $nssmCommand get beszel-agent Application $currentPath = & $nssmCommand get beszel-agent Application
if ($LASTEXITCODE -eq 0 -and $currentPath.Trim() -eq $AgentPath) { if ($LASTEXITCODE -eq 0 -and $currentPath.Trim() -eq $AgentPath) {
Write-Host "Service already configured with correct path. Skipping service recreation." -ForegroundColor Green Write-Host "Service path is already correct. Updating environment variables..."
& $nssmCommand set beszel-agent AppEnvironmentExtra "+KEY=$Key"
if ($TokenProvided) { & $nssmCommand set beszel-agent AppEnvironmentExtra "+TOKEN=$Token" }
if ($UrlProvided) { & $nssmCommand set beszel-agent AppEnvironmentExtra "+HUB_URL=$HubUrl" }
if ($PortProvided) { & $nssmCommand set beszel-agent AppEnvironmentExtra "+PORT=$Port" }
# Restart the service so the running process picks up the new environment variables
if ($existingService.Status -eq "Running") {
Write-Host "Restarting service to apply updated environment variables..."
& $nssmCommand restart beszel-agent
}
return return
} }
@@ -589,6 +615,12 @@ try {
$argumentList += "`"$NSSMPath`"" $argumentList += "`"$NSSMPath`""
} }
# Forward which optional values were explicitly provided, so the elevated
# instance knows whether to overwrite them on an existing service
if ($TokenProvided) { $argumentList += "-TokenProvided" }
if ($UrlProvided) { $argumentList += "-UrlProvided" }
if ($PortProvided) { $argumentList += "-PortProvided" }
if ($ConfigureFirewall) { if ($ConfigureFirewall) {
$argumentList += "-ConfigureFirewall" $argumentList += "-ConfigureFirewall"
} }
@@ -601,7 +633,7 @@ try {
# Third: If we have admin rights, install service and configure firewall # Third: If we have admin rights, install service and configure firewall
if ($isAdmin -or $Elevated) { if ($isAdmin -or $Elevated) {
# Install the service # Install the service
Install-NSSMService -AgentPath $AgentPath -Key $Key -Token $Token -HubUrl $Url -Port $Port -NSSMPath $NSSMPath Install-NSSMService -AgentPath $AgentPath -Key $Key -Token $Token -HubUrl $Url -Port $Port -NSSMPath $NSSMPath -TokenProvided:$TokenProvided -UrlProvided:$UrlProvided -PortProvided:$PortProvided
if ($ConfigureFirewall) { if ($ConfigureFirewall) {
Configure-Firewall -Port $Port Configure-Firewall -Port $Port

View File

@@ -97,6 +97,37 @@ ensure_trailing_slash() {
fi fi
} }
# Read the listen address from the active service configuration. Existing
# service files are kept as they are, so the configured address can differ from
# $PORT, which falls back to the default when -p is not passed. LISTEN is
# checked before PORT to match the agent's own precedence, and the value is read
# as text so host:port and unix socket paths survive.
configured_address() {
if is_alpine || is_openwrt; then
address_file=/etc/init.d/beszel-agent
elif is_freebsd; then
address_file="$AGENT_DIR/env"
else
address_file=/etc/systemd/system/beszel-agent.service
fi
[ -f "$address_file" ] || return 0
address_value=$(sed -n 's/.*LISTEN="\{0,1\}\([^"]*\)"\{0,1\}.*/\1/p' "$address_file" | head -n 1)
if [ -z "$address_value" ]; then
address_value=$(sed -n 's/.*PORT="\{0,1\}\([^"]*\)"\{0,1\}.*/\1/p' "$address_file" | head -n 1)
fi
printf '%s\n' "$address_value"
}
# Escape text for use in the replacement portion of a sed s command whose
# delimiter is |. This only escapes sed replacement metacharacters; quoting
# for the destination configuration syntax is handled separately.
escape_sed_replacement() {
printf '%s' "$1" | sed 's/[\\&|]/\\&/g'
}
# Generate FreeBSD rc service content # Generate FreeBSD rc service content
generate_freebsd_rc_service() { generate_freebsd_rc_service() {
cat <<'EOF' cat <<'EOF'
@@ -264,6 +295,12 @@ KEY=""
TOKEN="" TOKEN=""
HUB_URL="" HUB_URL=""
AUTO_UPDATE_FLAG="" # empty string means prompt, "true" means auto-enable, "false" means skip AUTO_UPDATE_FLAG="" # empty string means prompt, "true" means auto-enable, "false" means skip
# Track which of the reconfigurable values were explicitly passed as arguments,
# so a reinstall only overwrites the fields the caller actually asked to change.
KEY_PROVIDED=false
PORT_PROVIDED=false
TOKEN_PROVIDED=false
HUB_URL_PROVIDED=false
VERSION="latest" VERSION="latest"
# Check for help flag # Check for help flag
@@ -294,10 +331,10 @@ build_sudo_args() {
if [ -n "$QUOTED_ARGS" ]; then if [ -n "$QUOTED_ARGS" ]; then
QUOTED_ARGS="$QUOTED_ARGS " QUOTED_ARGS="$QUOTED_ARGS "
fi fi
QUOTED_ARGS="$QUOTED_ARGS'$(echo "$1" | sed "s/'/'\\\\''/g")'" QUOTED_ARGS="$QUOTED_ARGS'$(printf '%s' "$1" | sed "s/'/'\\\\''/g")'"
shift shift
done done
echo "$QUOTED_ARGS" printf '%s\n' "$QUOTED_ARGS"
} }
# Check if running as root and re-execute with sudo if needed # Check if running as root and re-execute with sudo if needed
@@ -319,18 +356,22 @@ while [ $# -gt 0 ]; do
-k) -k)
shift shift
KEY="$1" KEY="$1"
KEY_PROVIDED=true
;; ;;
-p) -p)
shift shift
PORT="$1" PORT="$1"
PORT_PROVIDED=true
;; ;;
-t) -t)
shift shift
TOKEN="$1" TOKEN="$1"
TOKEN_PROVIDED=true
;; ;;
-url) -url)
shift shift
HUB_URL="$1" HUB_URL="$1"
HUB_URL_PROVIDED=true
;; ;;
-v | --version) -v | --version)
shift shift
@@ -566,7 +607,7 @@ if [ -z "$KEY" ]; then
fi fi
# Remove newlines from KEY # Remove newlines from KEY
KEY=$(echo "$KEY" | tr -d '\n') KEY=$(printf '%s' "$KEY" | tr -d '\n')
# TOKEN and HUB_URL are optional for backwards compatibility - no interactive prompts # TOKEN and HUB_URL are optional for backwards compatibility - no interactive prompts
# They will be set as empty environment variables if not provided # They will be set as empty environment variables if not provided
@@ -804,7 +845,15 @@ EOF
chmod +x /etc/init.d/beszel-agent chmod +x /etc/init.d/beszel-agent
rc-update add beszel-agent default rc-update add beszel-agent default
else else
echo "Alpine OpenRC service file already exists. Skipping creation." echo "Alpine OpenRC service file already exists. Updating environment variables..."
SED_PORT=$(escape_sed_replacement "$PORT")
SED_KEY=$(escape_sed_replacement "$KEY")
SED_TOKEN=$(escape_sed_replacement "$TOKEN")
SED_HUB_URL=$(escape_sed_replacement "$HUB_URL")
[ "$PORT_PROVIDED" = "true" ] && sed -i "s|^export PORT=.*|export PORT=\"$SED_PORT\"|" /etc/init.d/beszel-agent
[ "$KEY_PROVIDED" = "true" ] && sed -i "s|^export KEY=.*|export KEY=\"$SED_KEY\"|" /etc/init.d/beszel-agent
[ "$TOKEN_PROVIDED" = "true" ] && sed -i "s|^export TOKEN=.*|export TOKEN=\"$SED_TOKEN\"|" /etc/init.d/beszel-agent
[ "$HUB_URL_PROVIDED" = "true" ] && sed -i "s|^export HUB_URL=.*|export HUB_URL=\"$SED_HUB_URL\"|" /etc/init.d/beszel-agent
fi fi
# Create log files with proper permissions # Create log files with proper permissions
@@ -886,7 +935,24 @@ EOF
chmod +x /etc/init.d/beszel-agent chmod +x /etc/init.d/beszel-agent
/etc/init.d/beszel-agent enable /etc/init.d/beszel-agent enable
else else
echo "OpenWRT init script already exists. Skipping creation." echo "OpenWRT init script already exists. Updating environment variables..."
# The env vars live on a single procd_set_param line, so merge any values
# that weren't explicitly provided in from the existing line before rewriting it.
CUR_ENV_LINE=$(sed -n '/^[[:space:]]*procd_set_param env PORT=/{p;q;}' /etc/init.d/beszel-agent)
if [ -z "$CUR_ENV_LINE" ] || ! printf '%s\n' "$CUR_ENV_LINE" | grep -q 'PORT="[^"]*" KEY="[^"]*" TOKEN="[^"]*" HUB_URL="[^"]*"'; then
echo "Error: Could not parse the existing environment configuration in /etc/init.d/beszel-agent."
echo "Expected a procd_set_param env line containing PORT, KEY, TOKEN, and HUB_URL."
exit 1
fi
[ "$PORT_PROVIDED" = "true" ] || PORT=$(printf '%s\n' "$CUR_ENV_LINE" | sed -n 's/.*PORT="\([^"]*\)".*/\1/p')
[ "$KEY_PROVIDED" = "true" ] || KEY=$(printf '%s\n' "$CUR_ENV_LINE" | sed -n 's/.*KEY="\([^"]*\)".*/\1/p')
[ "$TOKEN_PROVIDED" = "true" ] || TOKEN=$(printf '%s\n' "$CUR_ENV_LINE" | sed -n 's/.*TOKEN="\([^"]*\)".*/\1/p')
[ "$HUB_URL_PROVIDED" = "true" ] || HUB_URL=$(printf '%s\n' "$CUR_ENV_LINE" | sed -n 's/.*HUB_URL="\([^"]*\)".*/\1/p')
SED_PORT=$(escape_sed_replacement "$PORT")
SED_KEY=$(escape_sed_replacement "$KEY")
SED_TOKEN=$(escape_sed_replacement "$TOKEN")
SED_HUB_URL=$(escape_sed_replacement "$HUB_URL")
sed -i "s|procd_set_param env PORT=.*|procd_set_param env PORT=\"$SED_PORT\" KEY=\"$SED_KEY\" TOKEN=\"$SED_TOKEN\" HUB_URL=\"$SED_HUB_URL\"|" /etc/init.d/beszel-agent
fi fi
# Start the service # Start the service
@@ -929,17 +995,25 @@ elif is_freebsd; then
# Ensure rc.d directory exists on minimal FreeBSD installs # Ensure rc.d directory exists on minimal FreeBSD installs
mkdir -p /usr/local/etc/rc.d mkdir -p /usr/local/etc/rc.d
# Create environment configuration file with proper permissions if it doesn't exist # Create or update environment configuration file
if [ ! -f "$AGENT_DIR/env" ]; then if [ -f "$AGENT_DIR/env" ]; then
echo "Creating environment configuration file..." echo "Environment configuration file already exists. Updating environment variables..."
SED_PORT=$(escape_sed_replacement "$PORT")
SED_KEY=$(escape_sed_replacement "$KEY")
SED_TOKEN=$(escape_sed_replacement "$TOKEN")
SED_HUB_URL=$(escape_sed_replacement "$HUB_URL")
[ "$PORT_PROVIDED" = "true" ] && sed -i '' -e "s|^LISTEN=.*|LISTEN=$SED_PORT|" "$AGENT_DIR/env"
[ "$KEY_PROVIDED" = "true" ] && sed -i '' -e "s|^KEY=.*|KEY=\"$SED_KEY\"|" "$AGENT_DIR/env"
[ "$TOKEN_PROVIDED" = "true" ] && sed -i '' -e "s|^TOKEN=.*|TOKEN=$SED_TOKEN|" "$AGENT_DIR/env"
[ "$HUB_URL_PROVIDED" = "true" ] && sed -i '' -e "s|^HUB_URL=.*|HUB_URL=$SED_HUB_URL|" "$AGENT_DIR/env"
else
echo "Writing environment configuration file..."
cat >"$AGENT_DIR/env" <<EOF cat >"$AGENT_DIR/env" <<EOF
LISTEN=$PORT LISTEN=$PORT
KEY="$KEY" KEY="$KEY"
TOKEN=$TOKEN TOKEN=$TOKEN
HUB_URL=$HUB_URL HUB_URL=$HUB_URL
EOF EOF
else
echo "FreeBSD environment file already exists. Skipping creation."
fi fi
chmod 640 "$AGENT_DIR/env" chmod 640 "$AGENT_DIR/env"
chown "root:${AGENT_USER}" "$AGENT_DIR/env" chown "root:${AGENT_USER}" "$AGENT_DIR/env"
@@ -1074,7 +1148,15 @@ $(if [ -n "$NVIDIA_DEVICES" ]; then printf "%b" "# NVIDIA device permissions\n${
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
else else
echo "Systemd service file already exists. Skipping creation." echo "Systemd service file already exists. Updating environment variables..."
SED_PORT=$(escape_sed_replacement "$PORT")
SED_KEY=$(escape_sed_replacement "$KEY")
SED_TOKEN=$(escape_sed_replacement "$TOKEN")
SED_HUB_URL=$(escape_sed_replacement "$HUB_URL")
[ "$PORT_PROVIDED" = "true" ] && sed -i "s|^Environment=\"PORT=.*\"|Environment=\"PORT=$SED_PORT\"|" /etc/systemd/system/beszel-agent.service
[ "$KEY_PROVIDED" = "true" ] && sed -i "s|^Environment=\"KEY=.*\"|Environment=\"KEY=$SED_KEY\"|" /etc/systemd/system/beszel-agent.service
[ "$TOKEN_PROVIDED" = "true" ] && sed -i "s|^Environment=\"TOKEN=.*\"|Environment=\"TOKEN=$SED_TOKEN\"|" /etc/systemd/system/beszel-agent.service
[ "$HUB_URL_PROVIDED" = "true" ] && sed -i "s|^Environment=\"HUB_URL=.*\"|Environment=\"HUB_URL=$SED_HUB_URL\"|" /etc/systemd/system/beszel-agent.service
fi fi
# Load and start the service # Load and start the service
@@ -1140,4 +1222,7 @@ EOF
fi fi
fi fi
printf "\n\033[32mBeszel Agent has been installed successfully! It is now running on $PORT.\033[0m\n" RUNNING_ADDRESS=$(configured_address)
[ -n "$RUNNING_ADDRESS" ] || RUNNING_ADDRESS=$PORT
printf "\n\033[32mBeszel Agent has been installed successfully! It is now running on $RUNNING_ADDRESS.\033[0m\n"