✨ 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

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 OneNAS.space
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+51 -23
View File
@@ -1,49 +1,80 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=beszel-agent
PKG_NAME:=beszel
PKG_VERSION:=0.21.0
PKG_RELEASE:=11
PKG_RELEASE:=12
PKG_SOURCE:=$(PKG_NAME)_linux_$(or $(BESZEL_GOARCH),arm64).tar.gz
PKG_SOURCE_URL:=https://github.com/henrygd/beszel/releases/download/v$(PKG_VERSION)/
PKG_SOURCE:=beszel-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/henrygd/beszel/tar.gz/v$(PKG_VERSION)?
PKG_HASH:=skip
PKG_MAINTAINER:=Jackie264 <OneNAS-space>
PKG_LICENSE:=MIT
PKG_LICENSE_FILES:=LICENSE
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION)
PKG_BUILD_DIR:=$(BUILD_DIR)/beszel-$(PKG_VERSION)
PKG_BUILD_DEPENDS:=golang/host
PKG_BUILD_PARALLEL:=1
PKG_USE_MIPS16:=0
GO_PKG:=github.com/henrygd/beszel
GO_PKG_BUILD_PKG:=$(GO_PKG)/internal/cmd/agent
GO_PKG_TAGS:=openwrt
GO_PKG_ENABLE_CGO:=0
include $(INCLUDE_DIR)/package.mk
include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
define Package/beszel-agent
SECTION:=admin
CATEGORY:=Administration
SECTION:=net
CATEGORY:=Network
TITLE:=Beszel monitoring agent
URL:=https://github.com/henrygd/beszel
URL:=https://beszel.dev
DEPENDS:=$(GO_ARCH_DEPENDS)
endef
define Package/beszel-agent/description
Lightweight server monitoring agent for Beszel hub
Beszel is a lightweight server monitoring hub.
This package contains the agent that runs on the monitored system.
endef
define Build/Prepare
mkdir -p $(PKG_BUILD_DIR)
tar -xzf $(DL_DIR)/$(PKG_SOURCE) -C $(PKG_BUILD_DIR)
$(call Build/Prepare/Default)
$(CP) ./files/openwrt_services.go $(PKG_BUILD_DIR)/agent/
$(CP) ./files/openwrt_intel_gpu.go $(PKG_BUILD_DIR)/agent/
$(SED) 's|//go:build linux|//go:build linux \&\& !openwrt|' $(PKG_BUILD_DIR)/agent/systemd.go
rm -f $(PKG_BUILD_DIR)/agent/gpu_intel.go
endef
define Build/Compile
if command -v upx >/dev/null 2>&1; then \
if [ "$(BESZEL_GOARCH)" = "mips" ] || [ "$(BESZEL_GOARCH)" = "mipsle" ]; then \
echo "Skipping UPX compression for MIPS architectures (prevents execution hang)..."; \
else \
upx --best --lzma $(PKG_BUILD_DIR)/beszel-agent || true; \
fi; \
define Package/beszel-agent/conffiles
/etc/config/beszel-agent
endef
define Package/beszel-agent/preinst
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
if [ -f "/etc/init.d/beszel-agent" ]; then
echo "Stopping old Beszel Agent instance before upgrade..."
/etc/init.d/beszel-agent stop
fi
fi
exit 0
endef
define Package/beszel-agent/postinst
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
echo "Beszel Agent updated successfully. Starting service..."
/etc/init.d/beszel-agent enable
/etc/init.d/beszel-agent start
fi
exit 0
endef
define Package/beszel-agent/install
$(call GoPackage/Package/Install/Bin,$(PKG_INSTALL_DIR))
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/beszel-agent $(1)/usr/bin/
$(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/agent $(1)/usr/bin/beszel-agent
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/beszel-agent.init $(1)/etc/init.d/beszel-agent
@@ -52,8 +83,5 @@ define Package/beszel-agent/install
$(INSTALL_CONF) ./files/beszel-agent.config $(1)/etc/config/beszel-agent
endef
define Package/beszel-agent/conffiles
/etc/config/beszel-agent
endef
$(eval $(call GoPackage,beszel-agent))
$(eval $(call BuildPackage,beszel-agent))
+26
View File
@@ -0,0 +1,26 @@
# beszel-agent
The lightweight telemetry agent adapted to OpenWrt is used to report system and Docker indicators to your Beszel Hub.
## luci-app-beszel-agent
LuCI UI configuration interface of Beszel Agent
- [Right-click on this and then "Open Link in New Tab"](https://github.com/OneNAS-space/luci-app-beszel-agent)
#### (Based on the official source code)
Automatically synchronize and track the latest version of upstream
- Support OpenWrt's procd so that Service detail can display the status correctly.
<img width="1460" height="755" alt="Screenshot 2026-07-10 at 21 54 34" src="https://github.com/user-attachments/assets/09109fd9-4811-412e-9a45-11660054b481" />
- Support AMD/Intel GPU
<img width="1446" height="419" alt="Screenshot 2026-07-17 at 23 36 34" src="https://github.com/user-attachments/assets/cca94b20-489b-4a83-9432-30ed7f9071be" />
### Attribution
#### Reference
- [beszel official](https://github.com/henrygd/beszel)
#### Acknowledgment
Inspired by
- [@vernette beszel-agent-openwrt](https://github.com/vernette/beszel-agent-openwrt)
#### Notice
If the author of the original code has any objections, please contact me to deal with it.
+21
View File
@@ -0,0 +1,21 @@
# Security Policy
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ------- | ------------------ |
| 5.1.x | :white_check_mark: |
| 5.0.x | :x: |
| 4.0.x | :white_check_mark: |
| < 4.0 | :x: |
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.
+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
}
@@ -0,0 +1,143 @@
From 128555126155413ce32b134435c425ad9bb24501 Mon Sep 17 00:00:00 2001
From: Jackie Han <jackie.han@gmail.com>
Date: Wed, 14 Oct 2026 07:40:03 +0800
Subject: [PATCH] Refactor collect function to handle wireless devices
---
agent/wifi/wifi_linux.go | 111 ++++++++++++++++++++++++++++++++++++---
1 file changed, 105 insertions(+), 6 deletions(-)
diff --git a/agent/wifi/wifi_linux.go b/agent/wifi/wifi_linux.go
index 926d658fe..ea42b1f2b 100644
--- a/agent/wifi/wifi_linux.go
+++ b/agent/wifi/wifi_linux.go
@@ -5,8 +5,12 @@ package wifi
import (
"context"
"errors"
+ "math"
"net"
"os"
+ "os/exec"
+ "regexp"
+ "strconv"
"time"
"github.com/henrygd/beszel/internal/entities/system"
@@ -99,15 +103,110 @@ func hasWirelessDevice(dir string) bool {
}
func collect(ctx context.Context) map[string]system.WiFi {
- if !hasWirelessDevice(wiphyClassDir) {
- return nil
+ results := make(map[string]system.WiFi)
+
+ if hasWirelessDevice(wiphyClassDir) {
+ if client, err := newNL80211Client(); err == nil {
+ for k, v := range collectLinux(ctx, client) {
+ results[k] = v
+ }
+ client.Close()
+ }
}
- client, err := newNL80211Client()
- if err != nil {
+
+ if _, err := exec.LookPath("iwinfo"); err == nil {
+ for k, v := range collectOpenWrtAP(ctx) {
+ results[k] = v
+ }
+ }
+
+ if len(results) == 0 {
return nil
}
- defer client.Close()
- return collectLinux(ctx, client)
+ return results
+}
+
+var (
+ iwinfoIfaceRe = regexp.MustCompile(`(?m)^(\S+)[ \t]+ESSID:`)
+ iwinfoModeRe = regexp.MustCompile(`Mode:[ \t]*Master`)
+ iwinfoSSIDRe = regexp.MustCompile(`ESSID:\s*"([^"]+)"`)
+ iwinfoAssocRe = regexp.MustCompile(`(?m)^(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}.*?(-?\d+)\s+dBm`)
+)
+
+type iwinfoBlock struct {
+ name string
+ text string
+}
+
+// splitIwinfoBlocks splits bare `iwinfo` output into one block per interface.
+func splitIwinfoBlocks(out string) []iwinfoBlock {
+ idx := iwinfoIfaceRe.FindAllStringSubmatchIndex(out, -1)
+ blocks := make([]iwinfoBlock, 0, len(idx))
+ for i, m := range idx {
+ end := len(out)
+ if i+1 < len(idx) {
+ end = idx[i+1][0]
+ }
+ blocks = append(blocks, iwinfoBlock{name: out[m[2]:m[3]], text: out[m[0]:end]})
+ }
+ return blocks
+}
+
+// parseIwinfoInfo reports whether a block is a Master (AP) interface and its SSID.
+func parseIwinfoInfo(block string) (ssid string, isAP bool) {
+ if !iwinfoModeRe.MatchString(block) {
+ return "", false
+ }
+ if m := iwinfoSSIDRe.FindStringSubmatch(block); len(m) > 1 {
+ ssid = m[1]
+ }
+ return ssid, true
+}
+
+// parseAssocRSSI returns the rounded average RSSI of associated clients.
+// ok is false when no valid RSSI value was found.
+func parseAssocRSSI(out string) (avg float64, ok bool) {
+ sum, count := 0, 0
+ for _, m := range iwinfoAssocRe.FindAllStringSubmatch(out, -1) {
+ rssi, err := strconv.Atoi(m[1])
+ if err != nil || rssi >= 0 || rssi < -150 {
+ continue
+ }
+ sum += rssi
+ count++
+ }
+ if count == 0 {
+ return 0, false
+ }
+ return math.Round(float64(sum) / float64(count)), true
+}
+
+func collectOpenWrtAP(ctx context.Context) map[string]system.WiFi {
+ results := make(map[string]system.WiFi)
+
+ out, err := exec.CommandContext(ctx, "iwinfo").Output()
+ if err != nil && len(out) == 0 {
+ return results
+ }
+
+ for _, b := range splitIwinfoBlocks(string(out)) {
+ if ctx.Err() != nil {
+ return results
+ }
+ ssid, isAP := parseIwinfoInfo(b.text)
+ if !isAP {
+ continue
+ }
+
+ reading := system.WiFi{SSID: validSSID(ssid)}
+ if assocOut, err := exec.CommandContext(ctx, "iwinfo", b.name, "assoclist").Output(); err == nil {
+ if avg, ok := parseAssocRSSI(string(assocOut)); ok {
+ reading.Signal = &avg
+ }
+ }
+ results[b.name] = reading
+ }
+ return results
}
func collectLinux(ctx context.Context, client linuxClient) map[string]system.WiFi {