Compare commits

...

4 Commits

Author SHA1 Message Date
Sven van Ginkel
c9de35fad2 feat: change network packet loss to a red line (#2377) 2026-09-21 14:49:11 -04:00
henrygd
a5f216f425 exit 0 without key on Windows to avoid Winget Validation-Executable-Error (#2376, #2247) 2026-09-21 11:27:37 -04:00
henrygd
cbe4824ac3 add env var to disable container image update checks (#2371) 2026-09-21 10:49:53 -04:00
henrygd
2c69197d2d fix(install): improve handling of openwrt user account (#2370)
- Allocate unused UID/GIDs instead of using 999
- Detect existing ID collisions and repair missing shadow entries
- Support userdel and deluser during uninstall
- Fix Docker group membership handling
2026-09-20 18:04:07 -04:00
7 changed files with 181 additions and 38 deletions

View File

@@ -69,6 +69,7 @@ type dockerManager struct {
usingPodman bool // Whether the Docker Engine API is running on Podman usingPodman bool // Whether the Docker Engine API is running on Podman
registryClient *http.Client // Client for registry requests; nil uses a client with a 10-second timeout registryClient *http.Client // Client for registry requests; nil uses a client with a 10-second timeout
imageUpdatesDisabled bool // Whether image update checks are disabled by configuration
imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
imageUpdatesRunning bool // Whether a background image-update batch is in progress imageUpdatesRunning bool // Whether a background image-update batch is in progress
@@ -688,6 +689,8 @@ func newDockerManager(agent *Agent) *dockerManager {
userAgent: "Docker-Client/", userAgent: "Docker-Client/",
} }
dockerImageCheck, _ := utils.GetEnv("DOCKER_IMAGE_CHECK")
// Read container exclusion patterns from environment variable // Read container exclusion patterns from environment variable
var excludeContainers []string var excludeContainers []string
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" { if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
@@ -711,6 +714,7 @@ func newDockerManager(agent *Agent) *dockerManager {
sem: make(chan struct{}, 5), sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{}, apiContainerList: []*container.ApiInfo{},
excludeContainers: excludeContainers, excludeContainers: excludeContainers,
imageUpdatesDisabled: dockerImageCheck == "false",
// Initialize cache-time-aware tracking structures // Initialize cache-time-aware tracking structures
lastCpuContainer: make(map[uint16]map[string]uint64), lastCpuContainer: make(map[uint16]map[string]uint64),

View File

@@ -31,6 +31,9 @@ func normalizedImageReference(image string) string {
// refreshImageUpdates starts at most one background batch. Neither its network // refreshImageUpdates starts at most one background batch. Neither its network
// work nor its completion is part of the container metrics wait group. // work nor its completion is part of the container metrics wait group.
func (dm *dockerManager) refreshImageUpdates(containers []*container.ApiInfo, now time.Time) { func (dm *dockerManager) refreshImageUpdates(containers []*container.ApiInfo, now time.Time) {
if dm.imageUpdatesDisabled {
return
}
dm.imageUpdatesMutex.Lock() dm.imageUpdatesMutex.Lock()
defer dm.imageUpdatesMutex.Unlock() defer dm.imageUpdatesMutex.Unlock()
if dm.imageUpdatesRunning { if dm.imageUpdatesRunning {

View File

@@ -27,6 +27,29 @@ func waitForImageUpdates(t *testing.T, dm *dockerManager) {
}, time.Second*3, time.Millisecond) }, time.Second*3, time.Millisecond)
} }
func TestDisableDockerImageUpdateCheck(t *testing.T) {
t.Setenv("BESZEL_AGENT_DOCKER_IMAGE_CHECK", "false")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/version" {
fmt.Fprint(w, `{"Version":"25.0.0"}`)
return
}
http.NotFound(w, r)
}))
defer server.Close()
t.Setenv("BESZEL_AGENT_DOCKER_HOST", server.URL)
dm := newDockerManager(nil)
require.True(t, dm.imageUpdatesDisabled)
dm.registryClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("disabled image update check made a registry request")
return nil, nil
})}
dm.refreshImageUpdates([]*container.ApiInfo{{Image: "nginx", Names: []string{"/nginx"}}}, time.Now())
require.False(t, dm.imageUpdatesRunning)
require.Nil(t, dm.imageUpdates)
}
func TestImageUpdateCacheAndStats(t *testing.T) { func TestImageUpdateCacheAndStats(t *testing.T) {
local := "sha256:" + strings.Repeat("a", 64) local := "sha256:" + strings.Repeat("a", 64)
remote := "sha256:" + strings.Repeat("b", 64) remote := "sha256:" + strings.Repeat("b", 64)

View File

@@ -1,9 +1,11 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
"runtime"
"strings" "strings"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
@@ -14,6 +16,12 @@ import (
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
type noKeyProvidedError struct{}
func (noKeyProvidedError) Error() string {
return "no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage"
}
// cli options // cli options
type cmdOptions struct { type cmdOptions struct {
key string // key is the public key(s) for SSH authentication. key string // key is the public key(s) for SSH authentication.
@@ -124,7 +132,7 @@ func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) {
// Try key file // Try key file
keyFile, ok := utils.GetEnv("KEY_FILE") keyFile, ok := utils.GetEnv("KEY_FILE")
if !ok { if !ok {
return nil, fmt.Errorf("no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage") return nil, noKeyProvidedError{}
} }
pubKey, err := os.ReadFile(keyFile) pubKey, err := os.ReadFile(keyFile)
@@ -138,6 +146,14 @@ func (opts *cmdOptions) getAddress() string {
return agent.GetAddress(opts.listen) return agent.GetAddress(opts.listen)
} }
func isBenignStartupError(err error, goos string) bool {
if goos != "windows" {
return false
}
var noKeyErr noKeyProvidedError
return errors.As(err, &noKeyErr)
}
// handleFingerprint handles the "fingerprint" command with subcommands "view" and "reset". // handleFingerprint handles the "fingerprint" command with subcommands "view" and "reset".
func handleFingerprint() { func handleFingerprint() {
subCmd := "" subCmd := ""
@@ -182,6 +198,12 @@ func main() {
var err error var err error
serverConfig.Keys, err = opts.loadPublicKeys() serverConfig.Keys, err = opts.loadPublicKeys()
if err != nil { if err != nil {
if isBenignStartupError(err, runtime.GOOS) {
// WinGet launches the executable without configuration during validation.
// Exit successfully in that case while retaining the error on other platforms.
log.Print("Failed to load public keys:", err)
return
}
log.Fatal("Failed to load public keys:", err) log.Fatal("Failed to load public keys:", err)
} }

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"crypto/ed25519" "crypto/ed25519"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -187,6 +188,26 @@ func TestLoadPublicKeys(t *testing.T) {
} }
} }
func TestIsBenignStartupError(t *testing.T) {
tests := []struct {
name string
err error
goos string
want bool
}{
{name: "missing key on windows", err: noKeyProvidedError{}, goos: "windows", want: true},
{name: "wrapped missing key on windows", err: errors.Join(errors.New("startup failed"), noKeyProvidedError{}), goos: "windows", want: true},
{name: "missing key on linux", err: noKeyProvidedError{}, goos: "linux", want: false},
{name: "different error on windows", err: errors.New("invalid key"), goos: "windows", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isBenignStartupError(tt.err, tt.goos))
})
}
}
func TestGetNetwork(t *testing.T) { func TestGetNetwork(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -27,6 +27,8 @@ type MonitorChartBaseProps = MonitorChartProps & {
tickFormatter: (value: number) => string tickFormatter: (value: number) => string
contentFormatter: ({ value }: { value: number | string }) => string | number contentFormatter: ({ value }: { value: number | string }) => string | number
domain?: [number | "auto", number | "auto"] domain?: [number | "auto", number | "auto"]
/** Overrides the per-monitor line colors (e.g. a fixed color for single-monitor charts). */
color?: string
} }
function MonitorChart({ function MonitorChart({
@@ -41,6 +43,7 @@ function MonitorChart({
tickFormatter, tickFormatter,
contentFormatter, contentFormatter,
domain, domain,
color,
showFilter = monitors.length > 1, showFilter = monitors.length > 1,
}: MonitorChartBaseProps) { }: MonitorChartBaseProps) {
const storedFilter = useStore($monitorFilter) const storedFilter = useStore($monitorFilter)
@@ -67,11 +70,12 @@ function MonitorChart({
label, label,
dataKey: (record: NetworkMonitorStatsRecord) => record.stats?.[p.id]?.[metric] ?? null, dataKey: (record: NetworkMonitorStatsRecord) => record.stats?.[p.id]?.[metric] ?? null,
dot, dot,
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`, color:
color ?? (count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`),
}) })
} }
return { dataPoints: points, visibleKeys: visibleIDs } return { dataPoints: points, visibleKeys: visibleIDs }
}, [monitors, filter, metric, chartData.chartTime]) }, [monitors, filter, metric, chartData.chartTime, color])
const filteredMonitorStats = useMemo(() => { const filteredMonitorStats = useMemo(() => {
if (!visibleKeys.length) return monitorStats if (!visibleKeys.length) return monitorStats
@@ -200,6 +204,7 @@ export function LossChart({ monitorStats, grid, monitors, chartData, empty, titl
title={title} title={title}
description={t`Packet loss (%)`} description={t`Packet loss (%)`}
domain={[0, 100]} domain={[0, 100]}
color="var(--destructive)"
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)}%`} tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)}%`}
contentFormatter={({ value }) => { contentFormatter={({ value }) => {
if (typeof value !== "number") { if (typeof value !== "number") {

View File

@@ -298,6 +298,94 @@ warn() {
echo "Warning: $*" >&2 echo "Warning: $*" >&2
} }
# Keep IDs within 16 bits, including on older OpenWrt versions whose account
# helpers start automatic allocation at 65536.
openwrt_unused_id() {
awk -F: '
{ used[$3] = 1 }
END {
for (id = 32768; id < 65534; id++) {
if (!(id in used)) { print id; exit }
}
exit 1
}
' "$1"
}
validate_openwrt_account() {
# Duplicate numeric IDs share permissions even when the names differ.
for account_file in /etc/passwd /etc/group; do
if ! awk -F: '
$1 == "beszel" { id = $3; count++ }
{ ids[$3]++ }
END { if (count && (count != 1 || id == 0 || ids[id] != 1)) exit 1 }
' "$account_file"; then
fail "The beszel account has a duplicate or root ID in $account_file. Stop beszel-agent and assign beszel an unused UID/GID before reinstalling. Update ownership only in $AGENT_DIR; do not change all files owned by the shared ID."
fi
done
if grep -q '^beszel:' /etc/passwd; then
account_gid=$(awk -F: '$1 == "beszel" { print $4 }' /etc/passwd)
group_gid=$(awk -F: '$1 == "beszel" { print $3 }' /etc/group)
[ "$account_gid" = "$group_gid" ] || fail "The beszel user's primary group is not the dedicated beszel group. Repair the account before reinstalling."
fi
}
configure_openwrt_account() (
validate_openwrt_account
[ -r /lib/functions.sh ] || fail "OpenWrt account helpers (/lib/functions.sh) are required."
# OpenWrt's library expects unset variables and defines generic functions.
# Source it in a subshell so neither affects the rest of the installer.
set +u
. /lib/functions.sh
IPKG_INSTROOT=""
if ! grep -q '^beszel:' /etc/group; then
account_gid=$(openwrt_unused_id /etc/group) || fail "No unused service GID available."
group_add beszel "$account_gid" || fail "Could not create the beszel group."
fi
account_gid=$(awk -F: '$1 == "beszel" { print $3 }' /etc/group)
[ -n "$account_gid" ] || fail "The beszel group was not created."
if ! grep -q '^beszel:' /etc/passwd; then
account_uid=$(openwrt_unused_id /etc/passwd) || fail "No unused service UID available."
user_add beszel "$account_uid" "$account_gid" "Beszel agent" /nonexistent /bin/false || fail "Could not create the beszel user."
fi
grep -q '^beszel:' /etc/passwd || fail "The beszel account is incomplete."
validate_openwrt_account
# Previous installers omitted the shadow entry. Repair it with a locked
# password, without rewriting an existing account or changing its IDs.
if ! grep -q '^beszel:' /etc/shadow; then
lock /var/lock/passwd || fail "Could not lock the account database."
shadow_status=0
if ! grep -q '^beszel:' /etc/shadow; then
printf 'beszel:!:0:0:99999:7:::\n' >> /etc/shadow || shadow_status=$?
fi
lock -u /var/lock/passwd || fail "Could not unlock the account database."
[ "$shadow_status" -eq 0 ] || fail "Could not create the beszel shadow entry."
fi
if grep -q '^docker:' /etc/group; then
# Match complete member names and avoid a leading comma for empty groups.
if ! awk -F: '$1 == "docker" { n = split($4, members, ","); for (i = 1; i <= n; i++) if (members[i] == "beszel") found = 1 } END { exit !found }' /etc/group; then
echo "Adding beszel to docker group"
if grep -q '^docker:[^:]*:[^:]*:$' /etc/group; then
sed -i '/^docker:/s/$/beszel/' /etc/group
else
sed -i '/^docker:/s/$/,beszel/' /etc/group
fi
fi
fi
)
remove_openwrt_account() {
if command -v userdel >/dev/null 2>&1; then
userdel beszel || fail "Could not remove the beszel user."
elif command -v deluser >/dev/null 2>&1; then
deluser beszel || fail "Could not remove the beszel user."
else
warn "Neither userdel nor deluser is available; the beszel account has been retained."
fi
}
require_value() { require_value() {
[ "$#" -ge 2 ] && [ -n "$2" ] || fail "Option $1 requires a value." [ "$#" -ge 2 ] && [ -n "$2" ] || fail "Option $1 requires a value."
} }
@@ -672,7 +760,9 @@ if [ "$UNINSTALL" = true ]; then
echo "Removing the dedicated user for the agent service..." echo "Removing the dedicated user for the agent service..."
killall beszel-agent 2>/dev/null || true # Usually already stopped by the service manager. killall beszel-agent 2>/dev/null || true # Usually already stopped by the service manager.
if id -u beszel >/dev/null 2>&1; then if id -u beszel >/dev/null 2>&1; then
if is_alpine || is_openwrt; then if is_openwrt; then
remove_openwrt_account
elif is_alpine; then
deluser beszel || fail "Could not remove the beszel user." deluser beszel || fail "Could not remove the beszel user."
elif is_freebsd; then elif is_freebsd; then
pw user del beszel || fail "Could not remove the beszel user." pw user del beszel || fail "Could not remove the beszel user."
@@ -772,32 +862,7 @@ if is_alpine; then
fi fi
elif is_openwrt; then elif is_openwrt; then
# Create beszel group first if it doesn't exist (check /etc/group directly) configure_openwrt_account
if ! grep -q "^beszel:" /etc/group >/dev/null 2>&1; then
echo "beszel:x:999:" >> /etc/group
fi
# Create beszel user if it doesn't exist (double-check to prevent duplicates)
if ! id -u beszel >/dev/null 2>&1 && ! grep -q "^beszel:" /etc/passwd >/dev/null 2>&1; then
echo "beszel:x:999:999::/nonexistent:/bin/false" >> /etc/passwd
fi
# Add the user to the docker group if docker group exists and user is not already in it
if grep -q "^docker:" /etc/group >/dev/null 2>&1; then
echo "Adding beszel to docker group"
# Check if beszel is already in docker group
if ! grep "^docker:" /etc/group | grep -q "beszel"; then
# Add beszel to docker group by modifying /etc/group
# Handle both cases: group with existing members and group without members
if grep "^docker:" /etc/group | grep -q ":.*:.*$"; then
# Group has existing members, append with comma
sed -i 's/^docker:\([^:]*:[^:]*:\)\(.*\)$/docker:\1\2,beszel/' /etc/group
else
# Group has no members, just append
sed -i 's/^docker:\([^:]*:[^:]*:\)$/docker:\1beszel/' /etc/group
fi
fi
fi
elif is_freebsd; then elif is_freebsd; then
if is_opnsense || is_pfsense; then if is_opnsense || is_pfsense; then