Compare commits

...

9 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
henrygd
97e6f64bdc fix(ui): swap usage chart shown twice in tabs view (#2356) 2026-09-19 13:27:00 -04:00
henrygd
4a5915b141 add SMART to readme features 2026-09-19 12:11:10 -04:00
henrygd
e68372dce4 update readme features 2026-09-19 12:09:26 -04:00
henrygd
c52f3acb94 fix(docker): enable Debian contrib for ZFS utilities in slim NVIDIA image 2026-09-19 12:03:05 -04:00
hank
c09eb8c6df chore(helm): update app version to 0.20.0 (#2355)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-19 11:39:13 -04:00
14 changed files with 198 additions and 53 deletions

View File

@@ -68,10 +68,11 @@ type dockerManager struct {
excludeContainers []string // Patterns to exclude containers by name
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
imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
imageUpdatesRunning bool // Whether a background image-update batch is in progress
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
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
imageUpdatesRunning bool // Whether a background image-update batch is in progress
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
// Maps cache time intervals to container-specific CPU usage tracking
@@ -688,6 +689,8 @@ func newDockerManager(agent *Agent) *dockerManager {
userAgent: "Docker-Client/",
}
dockerImageCheck, _ := utils.GetEnv("DOCKER_IMAGE_CHECK")
// Read container exclusion patterns from environment variable
var excludeContainers []string
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
@@ -707,10 +710,11 @@ func newDockerManager(agent *Agent) *dockerManager {
Timeout: timeout,
Transport: userAgentTransport,
},
containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
excludeContainers: excludeContainers,
containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
excludeContainers: excludeContainers,
imageUpdatesDisabled: dockerImageCheck == "false",
// Initialize cache-time-aware tracking structures
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
// work nor its completion is part of the container metrics wait group.
func (dm *dockerManager) refreshImageUpdates(containers []*container.ApiInfo, now time.Time) {
if dm.imageUpdatesDisabled {
return
}
dm.imageUpdatesMutex.Lock()
defer dm.imageUpdatesMutex.Unlock()
if dm.imageUpdatesRunning {

View File

@@ -27,6 +27,29 @@ func waitForImageUpdates(t *testing.T, dm *dockerManager) {
}, 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) {
local := "sha256:" + strings.Repeat("a", 64)
remote := "sha256:" + strings.Repeat("b", 64)

View File

@@ -1,9 +1,11 @@
package main
import (
"errors"
"fmt"
"log"
"os"
"runtime"
"strings"
"github.com/henrygd/beszel"
@@ -14,6 +16,12 @@ import (
"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
type cmdOptions struct {
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
keyFile, ok := utils.GetEnv("KEY_FILE")
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)
@@ -138,6 +146,14 @@ func (opts *cmdOptions) getAddress() string {
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".
func handleFingerprint() {
subCmd := ""
@@ -182,6 +198,12 @@ func main() {
var err error
serverConfig.Keys, err = opts.loadPublicKeys()
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)
}

View File

@@ -2,6 +2,7 @@ package main
import (
"crypto/ed25519"
"errors"
"os"
"path/filepath"
"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) {
tests := []struct {
name string

View File

@@ -70,7 +70,9 @@ RUN set -eux; \
# --------------------------
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS zfsutils-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
# zfsutils-linux is distributed in Debian's contrib component.
RUN sed -i 's/Components: main/Components: main contrib/' /etc/apt/sources.list.d/debian.sources \
&& apt-get update && apt-get install -y --no-install-recommends \
zfsutils-linux \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -211,7 +211,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
<FanChart {...coreProps} />
<BatteryChart system={system} {...coreProps} />
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
</div>
</TabsContent>

View File

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

View File

@@ -15,9 +15,10 @@ It has a friendly web interface, simple configuration, and is ready to use out o
- **Lightweight**: Smaller and less resource-intensive than leading solutions.
- **Simple**: Easy setup with little manual configuration required.
- **Alerts**: Configurable alerts for most metrics. Supports many notification services.
- **Docker stats**: Tracks CPU, memory, and network usage history for each container.
- **ZFS**: Tracks pool capacity, health, and I/O, plus per-dataset usage.
- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, fan speed, load average, and status.
- **Network monitoring**: Monitor response time and interruptions directly from agents.
- **S.M.A.R.T.**: Disk health data and notifications on drive failure.
- **Multi-user**: Users manage their own systems. Admins can share systems across users.
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
- **Automatic backups**: Save to and restore from disk or S3-compatible storage.
@@ -51,7 +52,7 @@ The [quick start guide](https://beszel.dev/guide/getting-started) and other docu
- **Temperature** - Host system sensors.
- **Fan speed** - Host system sensors (Linux, via `/sys/class/hwmon`).
- **GPU usage / power draw** - Nvidia, AMD, and Intel.
- **Battery** - Host system battery charge.
- **Battery charge** - Host system and some peripherals.
- **Containers** - Status and metrics of all running Docker / Podman containers.
- **S.M.A.R.T.** - Host system disk health (includes eMMC wear/EOL and Linux mdraid array health via sysfs when available).
- **ZFS** - Pool capacity, usage, health, I/O throughput, scrub status, and per-dataset usage.

View File

@@ -2,9 +2,9 @@ apiVersion: v1
description: Installs beszel-agent in kubernetes
home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
name: beszel-agent
appVersion: "0.19.0"
appVersion: "0.20.0"
# Bump this version when publishing chart changes.
version: 0.1.6
version: 0.1.7
sources:
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
- 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.tokenKey` | `token` | Key name in the secret for the authentication token |
| `image.repository` | `henrygd/beszel-agent` | Container image |
| `image.tag` | Chart AppVersion (0.19.0) | Image version |
| `image.tag` | Chart AppVersion (0.20.0) | Image version |
| `hostNetwork` | `false` | Use host network for network monitoring |
| `tolerations` | Allows all taints | Tolerations for running on tainted nodes |
@@ -385,7 +385,7 @@ helm upgrade beszel-agent ./beszel-agent \
# Change image version
helm upgrade beszel-agent ./beszel-agent \
--set image.tag="0.19.0"
--set image.tag="0.20.0"
```
### Restart All Agents
@@ -522,7 +522,7 @@ kubectl get secret beszel-agent -o jsonpath='{.data.ssh-key}' | base64 -d
## Chart Information
- **Chart Version**: 0.1.0
- **App Version**: 0.19.0
- **App Version**: 0.20.0
- **Kubernetes Version**: 1.19+
- **Maintainer**: cloudwithdan (nikoloskid@pm.me)

View File

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

View File

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

View File

@@ -298,6 +298,94 @@ warn() {
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() {
[ "$#" -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..."
killall beszel-agent 2>/dev/null || true # Usually already stopped by the service manager.
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."
elif is_freebsd; then
pw user del beszel || fail "Could not remove the beszel user."
@@ -772,32 +862,7 @@ if is_alpine; then
fi
elif is_openwrt; then
# Create beszel group first if it doesn't exist (check /etc/group directly)
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
configure_openwrt_account
elif is_freebsd; then
if is_opnsense || is_pfsense; then