✨ Sync 2026-10-20 03:34:23

This commit is contained in:
github-actions[bot] committed 2026-10-20 03:34:23 +08:00
1 parent d210fa85f8
commit cbd3ce4959
21 files changed
+2002 -90

No files matched your search

+16 -8
View File
@@ -1,9 +1,17 @@
config agent 'agent'
option enabled '0'
option port '45876'
option public_key ''
option hub_url '' # optional, for outbound mode (websockets)
option token '' # optional, for outbound mode (websockets)
option extra_filesystems '' # optional, comma-separated, e.g. 'sda1__NAS'
option fingerprint '' # auto-populated on first start, do not edit
config beszel-agent 'main'
option boot_delay '15'
option enable '0'
option data_dir '/etc/beszel-agent'
option docker_host ''
option hub_url ''
option intel_gpu_device ''
option key ''
option listen '0.0.0.0:45876'
option log_level 'info'
option system_name 'openwrt'
option token ''
# Load additional file systems (optional)
# Add multiple mount points using 'list' syntax
# list extra_filesystems '/mnt/sda1'
# list extra_filesystems '/mnt/sdb1'
+102 -53
View File
@@ -1,75 +1,124 @@
#!/bin/sh /etc/rc.common
START=99
STOP=10
USE_PROCD=1
NAME=beszel-agent
PROG=/usr/bin/beszel-agent
DATA_DIR=/var/lib/beszel-agent
FP_FILE="$DATA_DIR/fingerprint"
OLD_FP_FILE=/usr/share/beszel-agent/fingerprint
append_fs() {
local value="$1"
if [ -z "$FS_LIST" ]; then
FS_LIST="$value"
else
FS_LIST="$FS_LIST,$value"
fi
}
append_nic() {
local value="$1"
if [ -z "$NIC_LIST" ]; then
NIC_LIST="$value"
else
NIC_LIST="$NIC_LIST,$value"
fi
}
boot() {
local delay
delay="$(uci -q get $NAME.@main.boot_delay)"
sleep "${delay:-15}"
start "$@"
}
start_service() {
local enabled port key url token extra_fs fp
local enable data_dir docker_host gpu_collector hub_url intel_gpu_device key listen log_level port token skip_gpu skip_systemd system_name
config_load 'beszel-agent'
config_get_bool enabled 'agent' 'enabled' '0'
config_get port 'agent' 'port' '45876'
config_get key 'agent' 'public_key' ''
config_get url 'agent' 'hub_url' ''
config_get token 'agent' 'token' ''
config_get extra_fs 'agent' 'extra_filesystems' ''
config_get fp 'agent' 'fingerprint' ''
config_load "$NAME"
[ "$enabled" -eq 0 ] && return 0
config_get_bool enable "main" "enable" 0
[ "$enable" -eq 1 ] || return 0
[ -z "$key" ] && {
logger -t beszel-agent "Public key not set!"
return 1
}
config_get data_dir "main" "data_dir" "/etc/beszel-agent"
config_get docker_host "main" "docker_host" ""
config_get gpu_collector "main" "gpu_collector" ""
config_get hub_url "main" "hub_url" ""
config_get intel_gpu_device "main" "intel_gpu_device" ""
config_get key "main" "key" ""
config_get listen "main" "listen" ""
config_get log_level "main" "log_level" "info"
config_get port "main" "port" "45876"
config_get skip_gpu "main" "skip_gpu" "false"
case "$(uname -m)" in
x86_64|i?86)
if ! ls /sys/class/drm/card[0-9]* >/dev/null 2>&1; then
skip_gpu="true"
fi
;;
*)
skip_gpu="true"
;;
esac
config_get skip_systemd "main" "skip_systemd" "false"
config_get system_name "main" "system_name" ""
config_get token "main" "token" ""
if [ -n "$url" ] || [ -n "$token" ]; then
if [ -z "$url" ] || [ -z "$token" ]; then
logger -t beszel-agent "Outbound mode requires both hub_url and token, but only one set!"
return 1
if [ -n "$data_dir" ]; then
mkdir -p "$data_dir"
if [ -f /etc/sysupgrade.conf ]; then
if ! grep -qFx "$data_dir" /etc/sysupgrade.conf; then
echo "$data_dir" >> /etc/sysupgrade.conf
fi
fi
fi
fi
mkdir -p "$DATA_DIR"
FS_LIST=""
config_list_foreach "main" "extra_filesystems" append_fs
# migrate from pre-UCI storage on flash
[ -z "$fp" ] && [ -s "$OLD_FP_FILE" ] && fp=$(cat "$OLD_FP_FILE")
NIC_LIST=""
config_list_foreach "main" "nics" append_nic
if [ -z "$fp" ]; then
DATA_DIR="$DATA_DIR" "$PROG" fingerprint > /dev/null 2>&1
[ -s "$FP_FILE" ] && fp=$(cat "$FP_FILE")
fi
procd_open_instance "beszel-agent"
procd_set_param command "$PROG"
if [ -n "$fp" ]; then
printf '%s' "$fp" > "$FP_FILE"
[ "$(uci -q get beszel-agent.agent.fingerprint)" = "$fp" ] || {
uci set beszel-agent.agent.fingerprint="$fp"
uci commit beszel-agent
logger -t beszel-agent "Fingerprint saved to UCI: $fp"
}
else
logger -t beszel-agent "Failed to obtain fingerprint, agent will generate one"
fi
procd_set_param env HOST_PROC="/proc" HOST_SYS="/sys"
procd_open_instance beszel-agent
procd_set_param command "$PROG" -listen "$port" -key "$key"
[ -n "$url" ] && [ -n "$token" ] && {
procd_append_param command -url "$url" -token "$token"
}
if [ -n "$listen" ]; then
procd_append_param env LISTEN="$listen"
else
procd_append_param env PORT="$port"
fi
procd_set_param env DATA_DIR="$DATA_DIR"
[ -n "$extra_fs" ] && procd_append_param env EXTRA_FILESYSTEMS="$extra_fs"
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
[ -n "$docker_host" ] && procd_append_param env DOCKER_HOST="$docker_host"
[ -n "$data_dir" ] && procd_append_param env DATA_DIR="$data_dir"
[ -n "$FS_LIST" ] && procd_append_param env EXTRA_FILESYSTEMS="$FS_LIST"
[ -n "$hub_url" ] && procd_append_param env HUB_URL="$hub_url"
[ -n "$key" ] && procd_append_param env KEY="$key"
[ -n "$log_level" ] && procd_append_param env LOG_LEVEL="$log_level"
[ -n "$NIC_LIST" ] && procd_append_param env NICS="$NIC_LIST"
[ -n "$skip_gpu" ] && procd_append_param env SKIP_GPU="$skip_gpu"
[ -n "$skip_systemd" ] && procd_append_param env SKIP_SYSTEMD="$skip_systemd"
[ -n "$system_name" ] && procd_append_param env SYSTEM_NAME="$system_name"
[ -n "$token" ] && procd_append_param env TOKEN="$token"
if [ "$skip_gpu" = "false" ]; then
[ -n "$gpu_collector" ] && procd_append_param env GPU_COLLECTOR="$gpu_collector"
if [ "$gpu_collector" = "intel_gpu_top" ] || [ -z "$gpu_collector" ]; then
[ -n "$intel_gpu_device" ] && procd_append_param env INTEL_GPU_DEVICE="$intel_gpu_device"
fi
fi
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
}
service_triggers() {
procd_add_reload_trigger 'beszel-agent'
procd_add_reload_trigger "beszel-agent"
}
reload_service() {
stop
start
}
+138
View File
@@ -0,0 +1,138 @@
//go:build linux && openwrt
package agent
import (
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/internal/entities/system"
)
const intelGpuStatsCmd = "sh"
type intelGpuStats struct {
PowerGPU float64
PowerPkg float64
Engines map[string]float64
}
var (
gpuStateMu sync.Mutex
lastRc6 uint64
lastGpuEnergy uint64
lastPkgEnergy uint64
lastTime time.Time
)
func getEnergyPaths() (gpuPath string, pkgPath string) {
pkgBase := "/sys/class/powercap/intel-rapl/intel-rapl:0"
gpuBase := "/sys/class/powercap/intel-rapl/intel-rapl:0/intel-rapl:0:1"
if _, err := os.Stat(pkgBase + "/energy_uj"); err == nil {
pkgPath = pkgBase + "/energy_uj"
}
if _, err := os.Stat(gpuBase + "/energy_uj"); err == nil {
gpuPath = gpuBase + "/energy_uj"
}
return gpuPath, pkgPath
}
func readUint64(path string) uint64 {
if path == "" {
return 0
}
data, err := os.ReadFile(path)
if err != nil {
return 0
}
val, _ := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
return val
}
func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
gm.Lock()
defer gm.Unlock()
id := "0"
gpuData, ok := gm.GpuDataMap[id]
if !ok {
gpuData = &system.GPUData{Name: "GPU", Engines: make(map[string]float64)}
gm.GpuDataMap[id] = gpuData
}
gpuData.Power += sample.PowerGPU
gpuData.PowerPkg += sample.PowerPkg
if gpuData.Engines == nil {
gpuData.Engines = make(map[string]float64, len(sample.Engines))
}
for name, engine := range sample.Engines {
gpuData.Engines[name] += engine
}
gpuData.Count++
return true
}
func (gm *GPUManager) collectIntelStats() (err error) {
gpuStateMu.Lock()
defer gpuStateMu.Unlock()
device := os.Getenv("INTEL_GPU_DEVICE")
if device == "" {
device = "card0"
}
device = filepath.Base(device)
rc6Path := "/sys/class/drm/" + device + "/power/rc6_residency_ms"
gpuEnergyPath, pkgEnergyPath := getEnergyPaths()
currRc6 := readUint64(rc6Path)
currGpuEnergy := readUint64(gpuEnergyPath)
currPkgEnergy := readUint64(pkgEnergyPath)
now := time.Now()
if !lastTime.IsZero() {
timeDelta := uint64(now.Sub(lastTime).Milliseconds())
if timeDelta > 500 {
rc6Delta := currRc6 - lastRc6
usage := 100.0 - (float64(rc6Delta) / float64(timeDelta) * 100.0)
if usage < 0 { usage = 0 }
var powerGPU float64
if currGpuEnergy > 0 && currGpuEnergy >= lastGpuEnergy {
energyDelta := currGpuEnergy - lastGpuEnergy
powerGPU = float64(energyDelta) / float64(timeDelta) / 1000.0
}
var powerPkg float64
if currPkgEnergy > 0 && currPkgEnergy >= lastPkgEnergy {
energyDelta := currPkgEnergy - lastPkgEnergy
powerPkg = float64(energyDelta) / float64(timeDelta) / 1000.0
}
sample := intelGpuStats{
PowerGPU: powerGPU,
PowerPkg: powerPkg,
Engines: map[string]float64{
"Render/3D": usage,
},
}
gm.updateIntelFromStats(&sample)
}
}
lastRc6 = currRc6
lastGpuEnergy = currGpuEnergy
lastPkgEnergy = currPkgEnergy
lastTime = now
time.Sleep(2 * time.Second)
return nil
}
+352
View File
@@ -0,0 +1,352 @@
//go:build linux && openwrt
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"github.com/coreos/go-systemd/v22/dbus"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/systemd"
)
var errNoActiveTime = errors.New("no active time")
type systemdManager struct {
sync.Mutex
serviceStatsMap map[string]*systemd.Service
isRunning bool
hasFreshStats bool
patterns []string
logsEnabled bool
}
func isSystemdAvailable() bool {
return true
}
func newSystemdManager() (*systemdManager, error) {
if skipSystemd, _ := utils.GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
return nil, nil
}
manager := &systemdManager{
serviceStatsMap: make(map[string]*systemd.Service),
patterns: getServicePatterns(),
}
manager.startWorker(nil)
return manager, nil
}
func (sm *systemdManager) startWorker(conn *dbus.Conn) {
if sm.isRunning {
return
}
sm.isRunning = true
_ = sm.getServiceStats(nil, true)
go func() {
for {
time.Sleep(time.Minute * 10)
_ = sm.getServiceStats(nil, true)
}
}()
}
func (sm *systemdManager) getServiceStatsCount() int {
return len(sm.serviceStatsMap)
}
func (sm *systemdManager) getFailedServiceCount() uint16 {
return 0
}
func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service {
var services []*systemd.Service
if !refresh {
sm.Lock()
defer sm.Unlock()
for _, service := range sm.serviceStatsMap {
services = append(services, service)
}
sm.hasFreshStats = false
return services
}
out, err := exec.Command("ubus", "call", "service", "list").Output()
if err != nil {
return nil
}
type UbusInstance struct {
Running bool `json:"running"`
Pid int `json:"pid"`
}
type UbusService struct {
Instances map[string]UbusInstance `json:"instances"`
}
var ubusData map[string]UbusService
if err := json.Unmarshal(out, &ubusData); err != nil {
return nil
}
currentUnits := make(map[string]struct{})
pageSize := uint64(os.Getpagesize())
if pageSize == 0 {
pageSize = 4096
}
sm.Lock()
defer sm.Unlock()
for sName, sData := range ubusData {
unitName := sName + ".service"
currentUnits[unitName] = struct{}{}
pid := 0
isRunning := false
for _, inst := range sData.Instances {
if inst.Running && inst.Pid > 0 {
pid = inst.Pid
isRunning = true
break
}
}
service, exists := sm.serviceStatsMap[unitName]
if !exists {
service = &systemd.Service{Name: sName}
sm.serviceStatsMap[unitName] = service
}
if isRunning {
service.State = systemd.ParseServiceStatus("active")
service.Sub = systemd.ParseServiceSubState("running")
if data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err == nil {
binEnd := strings.LastIndex(string(data), ")")
if binEnd != -1 && len(string(data)) > binEnd+2 {
fields := strings.Fields(string(data)[binEnd+2:])
if len(fields) > 21 {
utime, _ := strconv.ParseUint(fields[11], 10, 64)
stime, _ := strconv.ParseUint(fields[12], 10, 64)
rss, _ := strconv.ParseUint(fields[21], 10, 64)
service.Mem = rss * pageSize
if service.Mem > service.MemPeak {
service.MemPeak = service.Mem
}
service.UpdateCPUPercent((utime + stime) * 10000000)
}
}
}
} else {
service.State = systemd.ParseServiceStatus("active")
service.Sub = systemd.ParseServiceSubState("exited")
service.Mem = 0
service.UpdateCPUPercent(0)
}
services = append(services, service)
}
for unitName := range sm.serviceStatsMap {
if _, exists := currentUnits[unitName]; !exists {
delete(sm.serviceStatsMap, unitName)
}
}
sm.hasFreshStats = true
return services
}
func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStatus) (*systemd.Service, error) {
return nil, nil
}
func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
details := make(systemd.ServiceDetails)
uName := strings.TrimSuffix(serviceName, ".service")
initScript := "/etc/init.d/" + uName
// 1. Get cache data (used to obtain historical memory peaks)
sm.Lock()
service, exists := sm.serviceStatsMap[serviceName]
if !exists {
service, exists = sm.serviceStatsMap[uName]
}
sm.Unlock()
// Initialize the default value
details["ActiveState"] = "inactive"
details["SubState"] = "dead"
details["Result"] = "success"
details["MemoryCurrent"] = uint64(0)
details["MemoryPeak"] = uint64(0)
details["MainPID"] = uint64(0)
details["TasksCurrent"] = uint64(0)
// 2. Real-time query Ubus to get the status, PID, number of concurrent threads and memory RSS
var isLoadedInProcd bool = false
var isRunningRealTime bool = false
var currentMem uint64 = 0
out, err := exec.Command("ubus", "call", "service", "list", fmt.Sprintf(`{"name":"%s"}`, uName)).Output()
if err == nil {
type UbusInstance struct {
Running bool `json:"running"`
Pid int `json:"pid"`
}
type UbusService struct {
Instances map[string]UbusInstance `json:"instances"`
}
var ubusData map[string]UbusService
if json.Unmarshal(out, &ubusData) == nil {
if sData, ok := ubusData[uName]; ok {
isLoadedInProcd = true
for _, inst := range sData.Instances {
if inst.Running {
isRunningRealTime = true
if inst.Pid > 0 {
details["MainPID"] = uint64(inst.Pid)
// Accurate real-time reading of memory (RSS)
if data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", inst.Pid)); err == nil {
binEnd := strings.LastIndex(string(data), ")")
if binEnd != -1 && len(string(data)) > binEnd+2 {
fields := strings.Fields(string(data)[binEnd+2:])
if len(fields) > 21 {
pageSize := uint64(os.Getpagesize())
if pageSize == 0 {
pageSize = 4096
}
rss, _ := strconv.ParseUint(fields[21], 10, 64)
currentMem = rss * pageSize
}
}
}
// Accurately read the number of threads in real time (Tasks)
if data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", inst.Pid)); err == nil {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "Threads:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
tasks, _ := strconv.ParseUint(fields[1], 10, 64)
details["TasksCurrent"] = tasks
}
break
}
}
}
break
}
}
}
}
}
}
// 3. Core indicator status calibration and memory peak calculation
if isRunningRealTime {
details["ActiveState"] = "active"
details["SubState"] = "running"
details["MemoryCurrent"] = currentMem
if exists && service.MemPeak > currentMem {
details["MemoryPeak"] = service.MemPeak
} else {
details["MemoryPeak"] = currentMem
}
} else if isLoadedInProcd {
details["ActiveState"] = "active"
details["SubState"] = "exited"
details["MemoryCurrent"] = uint64(0)
if exists { details["MemoryPeak"] = service.MemPeak }
} else {
details["ActiveState"] = "inactive"
details["SubState"] = "dead"
details["MemoryCurrent"] = uint64(0)
if exists { details["MemoryPeak"] = service.MemPeak }
}
// 4. Complete Systemd-compatible metadata
details["Id"] = serviceName
details["Description"] = uName + " (OpenWrt Procd)"
details["LoadState"] = "loaded"
details["FragmentPath"] = initScript
details["NRestarts"] = uint64(0)
details["StatusText"] = ""
// Check the self-starting status of the service
unitFileState := "disabled"
if info, err := os.Stat(initScript); err == nil && !info.Mode().IsDir() {
if err := exec.Command(initScript, "enabled").Run(); err == nil {
unitFileState = "enabled"
}
}
details["UnitFileState"] = unitFileState
// Dynamic detection service control capability (Capabilities)
canStart, canStop, canReload := false, false, false
if info, err := os.Stat(initScript); err == nil && !info.Mode().IsDir() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, _ := exec.CommandContext(ctx, initScript).CombinedOutput()
outputStr := strings.ToLower(string(out))
if strings.Contains(outputStr, "start") { canStart = true }
if strings.Contains(outputStr, "stop") { canStop = true }
if strings.Contains(outputStr, "reload") { canReload = true }
}
details["CanStart"] = canStart
details["CanStop"] = canStop
details["CanReload"] = canReload
return details, nil
}
func unescapeServiceName(name string) string {
return name
}
func getServicePatterns() []string {
patterns := []string{}
if envPatterns, _ := utils.GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
for pattern := range strings.SplitSeq(envPatterns, ",") {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
continue
}
if !strings.HasSuffix(pattern, "timer") && !strings.HasSuffix(pattern, ".service") {
pattern += ".service"
}
patterns = append(patterns, pattern)
}
}
if len(patterns) == 0 {
patterns = []string{"*.service"}
}
return patterns
}
func (sm *systemdManager) getServiceLogs(serviceName string) ([]byte, error) {
/*
未来进阶提示:如果你想在前端也看到 OpenWrt 的服务日志,
可以将 serviceName 去掉后缀,然后通过执行类似以下的命令返回真实的日志字节流:
out, err := exec.Command("logread", "-e", strings.TrimSuffix(serviceName, ".service")).Output()
return out, err
*/
return nil, nil
}