feat: monitor connected Wi-Fi signal per interface (#2367)

This commit is contained in:
Vito Cappello
2026-09-25 17:43:57 -04:00
committed by GitHub
parent badd4c8245
commit 86ab0fae8b
24 changed files with 814 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ import (
"github.com/henrygd/beszel/agent/battery"
"github.com/henrygd/beszel/agent/btrfs"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/agent/wifi"
"github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
@@ -267,6 +268,8 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
}
}
systemStats.WiFi = wifi.Collect()
// update system info
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
a.systemInfo.Cpu = systemStats.Cpu
@@ -274,6 +277,7 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Battery = systemStats.Battery
a.systemInfo.WiFi = systemStats.WiFi
a.systemInfo.Uptime, _ = getUptime()
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
a.systemInfo.Threads = a.systemDetails.Threads

46
agent/wifi/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Connected Wi-Fi signal
Each fresh stats poll reports a snapshot of connected station interfaces in
`stats.wifi` and `info.wifi`. Map keys identify interfaces, not networks. `s` is
optional SSID metadata; `r` is nullable native RSSI in dBm. Quality percentages
are never converted to dBm. An associated interface without an accessible RSSI
still appears with an unavailable signal. No scans or network changes occur.
The hub panel gates exclusively on current `systems.info.wifi` and system `up`
status, independently of the selected historical period. Empty/null snapshots
clear it. Historical averages use only available readings per interface; gaps
are not zero signal. Interface colors and keys remain stable on reconnect.
SSID in an aggregate is the latest observed metadata, not a separate series.
## Platforms
- Linux: native nl80211 through `github.com/mdlayher/wifi`, compiled into the
static agent, including scratch and all other agent images. No `iw`, shared
libraries, extra capabilities, or external helper required. Host network
namespace access (Docker `network_mode: host`) is necessary to see host Wi-Fi.
Only managed station interfaces with explicit associated BSS status appear.
Kernel BSS cache reads do not trigger scans; station statistics supply native
RSSI matched to the associated AP. Denied/missing station statistics retain
association with unavailable RSSI, never substitute stale scan-cache signal.
Missing nl80211/driver support or denied association reads yield no readings.
A single two-second socket deadline bounds enumeration and interface queries
after opening the client. The library's initial nl80211 family discovery is
synchronous and does not expose a deadline.
- macOS: system `osascript` uses public CoreWLAN via JXA. Station mode proves
association; RSSI and optional SSID are read independently. No private airport
binary, elevated command or compiled helper required. Privacy settings may
redact SSIDs. The subprocess has a two-second deadline.
- Windows: native WLAN API, interface GUID identity, connected interface state,
optional current-connection SSID and native RSSI query. No localized `netsh`
parsing. Missing WLAN service/API yields no readings; denied SSID/RSSI query
leaves a connected interface with missing metadata/signal. Non-UTF-8 raw
SSIDs are omitted so they cannot invalidate CBOR text in the agent response.
Native synchronous WLAN calls cannot be interrupted by the Go deadline.
- FreeBSD and other platforms: unsupported, empty snapshot. No approximation
from ifconfig quality and no stale data retained.
Collectors retry each fresh poll, allowing interfaces and capabilities to appear
without an agent restart. Standard agent response caching still applies. Existing
hub record JSON storage requires no database schema migration. Older agents
without the field keep the panel hidden. Native macOS/Windows runtime checks and
real adapter testing are still required; cross compilation is not hardware proof.

40
agent/wifi/wifi.go Normal file
View File

@@ -0,0 +1,40 @@
// Package wifi collects only currently associated station interfaces. Collection
// failures are empty snapshots, never cached connected state.
package wifi
import (
"context"
"os"
"os/exec"
"time"
"unicode/utf8"
"github.com/henrygd/beszel/internal/entities/system"
)
type commandRunner func(context.Context, string, ...string) ([]byte, error)
func run(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
cmd.WaitDelay = 100 * time.Millisecond
return cmd.Output()
}
// validSSID omits non-UTF-8 SSIDs: 802.11 permits arbitrary octets, but CBOR
// text strings require UTF-8. Metadata must never invalidate the whole response.
func validSSID(ssid string) string {
if !utf8.ValidString(ssid) {
return ""
}
return ssid
}
// Collect uses a single deadline across interface queries where supported.
// Unsupported platforms and denied association access produce no readings;
// later polls retry.
func Collect() map[string]system.WiFi {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return collect(ctx)
}

43
agent/wifi/wifi_darwin.go Normal file
View File

@@ -0,0 +1,43 @@
//go:build darwin
package wifi
import (
"context"
"encoding/json"
"github.com/henrygd/beszel/internal/entities/system"
)
// JXA exposes the system CoreWLAN framework without cgo, private airport tools,
// sudo, or scanning nearby networks. SSID can be redacted by macOS privacy rules.
const coreWLANScript = `ObjC.import('CoreWLAN');
var result = {};
var interfaces = $.CWWiFiClient.sharedWiFiClient.interfaces;
if (interfaces) {
for (var i = 0; i < interfaces.count; i++) {
var iface = interfaces.objectAtIndex(i);
if (!iface.powerOn || Number(iface.interfaceMode) !== 1) continue;
var name = ObjC.unwrap(iface.interfaceName);
if (!name) continue;
var reading = {};
var ssid = ObjC.unwrap(iface.ssid);
if (ssid) reading.s = ssid;
var signal = Number(iface.rssiValue);
if (signal >= -150 && signal < 0) reading.r = signal;
result[name] = reading;
}
}
JSON.stringify(result);`
func collect(ctx context.Context) map[string]system.WiFi {
output, err := run(ctx, "/usr/bin/osascript", "-l", "JavaScript", "-e", coreWLANScript)
if err != nil {
return nil
}
var result map[string]system.WiFi
if json.Unmarshal(output, &result) != nil {
return nil
}
return result
}

78
agent/wifi/wifi_linux.go Normal file
View File

@@ -0,0 +1,78 @@
//go:build linux
package wifi
import (
"bytes"
"context"
"time"
"github.com/henrygd/beszel/internal/entities/system"
native "github.com/mdlayher/wifi"
)
type linuxClient interface {
Interfaces() ([]*native.Interface, error)
BSS(*native.Interface) (*native.BSS, error)
StationInfo(*native.Interface) ([]*native.StationInfo, error)
SetDeadline(time.Time) error
Close() error
}
func collect(ctx context.Context) map[string]system.WiFi {
client, err := native.New()
if err != nil {
return nil
}
defer client.Close()
return collectLinux(ctx, client)
}
func collectLinux(ctx context.Context, client linuxClient) map[string]system.WiFi {
result := make(map[string]system.WiFi)
if ctx.Err() != nil {
return result
}
if deadline, ok := ctx.Deadline(); ok {
if client.SetDeadline(deadline) != nil {
return result
}
}
interfaces, err := client.Interfaces()
if err != nil {
return result
}
for _, iface := range interfaces {
if ctx.Err() != nil {
break
}
if iface == nil || iface.Type != native.InterfaceTypeStation || iface.Name == "" {
continue
}
// GET_SCAN reads the kernel's BSS cache, without triggering a scan.
// Only the explicit associated status proves a current connection.
bss, err := client.BSS(iface)
if err != nil || bss == nil || bss.Status != native.BSSStatusAssociated {
continue
}
reading := system.WiFi{SSID: validSSID(bss.SSID)}
// Station statistics may require permissions unavailable in default
// containers. Keep association even when RSSI cannot be read. Do not
// substitute cached scan signal, which may be arbitrarily old.
stations, err := client.StationInfo(iface)
if err == nil {
for _, station := range stations {
if station == nil || len(bss.BSSID) == 0 || !bytes.Equal(station.HardwareAddr, bss.BSSID) {
continue
}
signal := float64(station.Signal)
if signal >= -150 && signal < 0 {
reading.Signal = &signal
}
break
}
}
result[iface.Name] = reading
}
return result
}

View File

@@ -0,0 +1,133 @@
//go:build linux
package wifi
import (
"context"
"errors"
"net"
"testing"
"time"
native "github.com/mdlayher/wifi"
)
type fakeLinuxClient struct {
interfaces []*native.Interface
bss map[string]*native.BSS
stations map[string][]*native.StationInfo
interfacesErr, bssErr, stationErr, deadlineErr error
deadline time.Time
stationCalls int
}
func (f *fakeLinuxClient) Interfaces() ([]*native.Interface, error) {
return f.interfaces, f.interfacesErr
}
func (f *fakeLinuxClient) BSS(i *native.Interface) (*native.BSS, error) {
return f.bss[i.Name], f.bssErr
}
func (f *fakeLinuxClient) StationInfo(i *native.Interface) ([]*native.StationInfo, error) {
f.stationCalls++
return f.stations[i.Name], f.stationErr
}
func (f *fakeLinuxClient) SetDeadline(d time.Time) error { f.deadline = d; return f.deadlineErr }
func (f *fakeLinuxClient) Close() error { return nil }
func connectedClient() *fakeLinuxClient {
mac := net.HardwareAddr{1, 2, 3, 4, 5, 6}
return &fakeLinuxClient{
interfaces: []*native.Interface{{Name: "wlan0", Type: native.InterfaceTypeStation}},
bss: map[string]*native.BSS{"wlan0": {Status: native.BSSStatusAssociated, SSID: "home", BSSID: mac}},
stations: map[string][]*native.StationInfo{"wlan0": {{HardwareAddr: mac, Signal: -52}}},
}
}
func TestLinuxSnapshots(t *testing.T) {
for _, tc := range []struct {
name string
modify func(*fakeLinuxClient)
want int
wantSignal bool
}{
{"connected", func(f *fakeLinuxClient) {}, 1, true},
{"multiple", func(f *fakeLinuxClient) {
f.interfaces = append(f.interfaces, &native.Interface{Name: "wlan1", Type: native.InterfaceTypeStation})
f.bss["wlan1"] = f.bss["wlan0"]
}, 2, true},
{"unsupported", func(f *fakeLinuxClient) { f.interfacesErr = errors.New("unsupported") }, 0, false},
{"association denied", func(f *fakeLinuxClient) { f.bssErr = errors.New("denied") }, 0, false},
{"disconnected", func(f *fakeLinuxClient) { f.bss["wlan0"] = nil }, 0, false},
{"authenticated only", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusAuthenticated }, 0, false},
{"cached nearby BSS", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusNotAssociated }, 0, false},
{"access point", func(f *fakeLinuxClient) { f.interfaces[0].Type = native.InterfaceTypeAP }, 0, false},
{"ad hoc", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusIBSSJoined }, 0, false},
{"station permission denied", func(f *fakeLinuxClient) {
f.stationErr = errors.New("permission denied")
f.bss["wlan0"].Signal = -4200
}, 1, false},
{"no station data", func(f *fakeLinuxClient) { f.stations = nil }, 1, false},
{"different AP", func(f *fakeLinuxClient) { f.stations["wlan0"][0].HardwareAddr = net.HardwareAddr{9, 8, 7, 6, 5, 4} }, 1, false},
{"missing signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = 0 }, 1, false},
{"invalid signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = -151 }, 1, false},
{"deadline failure", func(f *fakeLinuxClient) { f.deadlineErr = errors.New("deadline") }, 0, false},
} {
t.Run(tc.name, func(t *testing.T) {
f := connectedClient()
tc.modify(f)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
got := collectLinux(ctx, f)
if len(got) != tc.want {
t.Fatalf("got %#v", got)
}
if tc.want > 0 && (got["wlan0"].Signal != nil) != tc.wantSignal {
t.Fatalf("signal: %#v", got["wlan0"])
}
if tc.wantSignal && *got["wlan0"].Signal != -52 {
t.Fatal(got)
}
if tc.want == 0 && f.stationCalls != 0 {
t.Fatal("queried station without association")
}
deadline, _ := ctx.Deadline()
if f.deadline != deadline {
t.Fatal("deadline not shared")
}
})
}
}
func TestReconnect(t *testing.T) {
f := connectedClient()
if len(collectLinux(context.Background(), f)) != 1 {
t.Fatal("initial")
}
f.bss["wlan0"].Status = native.BSSStatusNotAssociated
if len(collectLinux(context.Background(), f)) != 0 {
t.Fatal("stale association")
}
f.bss["wlan0"].Status = native.BSSStatusAssociated
f.bss["wlan0"].SSID = "new"
if collectLinux(context.Background(), f)["wlan0"].SSID != "new" {
t.Fatal("stale SSID")
}
}
func TestLinuxInvalidSSID(t *testing.T) {
f := connectedClient()
f.bss["wlan0"].SSID = "raw\xff"
got := collectLinux(context.Background(), f)
if len(got) != 1 || got["wlan0"].SSID != "" || got["wlan0"].Signal == nil {
t.Fatal(got)
}
}
func TestLinuxCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
f := connectedClient()
if len(collectLinux(ctx, f)) != 0 || f.stationCalls != 0 {
t.Fatal("ignored cancellation")
}
}

35
agent/wifi/wifi_test.go Normal file
View File

@@ -0,0 +1,35 @@
package wifi
import (
"testing"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/entities/system"
)
func TestSSIDWireSafety(t *testing.T) {
for _, tc := range []struct{ input, want string }{
{"home", "home"}, {"网络 café", "网络 café"}, {"", ""},
{"raw\xffssid", ""}, {"truncated\xe2\x82", ""},
} {
t.Run(tc.input, func(t *testing.T) {
ssid := validSSID(tc.input)
if ssid != tc.want {
t.Fatalf("got %q, want %q", ssid, tc.want)
}
signal := -50.0
payload := map[string]system.WiFi{"wlan0": {SSID: ssid, Signal: &signal}}
wire, err := cbor.Marshal(payload)
if err != nil {
t.Fatal(err)
}
var decoded map[string]system.WiFi
if err := cbor.Unmarshal(wire, &decoded); err != nil {
t.Fatal(err)
}
if decoded["wlan0"].Signal == nil || *decoded["wlan0"].Signal != signal || decoded["wlan0"].SSID != tc.want {
t.Fatal(decoded)
}
})
}
}

View File

@@ -0,0 +1,11 @@
//go:build !linux && !windows && !darwin
package wifi
import (
"context"
"github.com/henrygd/beszel/internal/entities/system"
)
func collect(context.Context) map[string]system.WiFi { return nil }

View File

@@ -0,0 +1,99 @@
//go:build windows
package wifi
import (
"context"
"unsafe"
"github.com/henrygd/beszel/internal/entities/system"
"golang.org/x/sys/windows"
)
var wlan = windows.NewLazySystemDLL("wlanapi.dll")
var wlanOpen = wlan.NewProc("WlanOpenHandle")
var wlanClose = wlan.NewProc("WlanCloseHandle")
var wlanEnum = wlan.NewProc("WlanEnumInterfaces")
var wlanQuery = wlan.NewProc("WlanQueryInterface")
var wlanFree = wlan.NewProc("WlanFreeMemory")
type wlanInterface struct {
GUID windows.GUID
Description [256]uint16
State uint32
}
type wlanConnection struct {
State uint32
Mode uint32
Profile [256]uint16
SSIDLength uint32
SSID [32]byte
// Only the prefix through DOT11_SSID is read.
}
func collect(ctx context.Context) map[string]system.WiFi {
result := make(map[string]system.WiFi)
for _, proc := range []*windows.LazyProc{wlanOpen, wlanClose, wlanEnum, wlanQuery, wlanFree} {
if proc.Find() != nil {
return result
}
}
var handle windows.Handle
var version uint32
if rc, _, _ := wlanOpen.Call(2, 0, uintptr(unsafe.Pointer(&version)), uintptr(unsafe.Pointer(&handle))); rc != 0 {
return result
}
defer wlanClose.Call(uintptr(handle), 0)
var list unsafe.Pointer
if rc, _, _ := wlanEnum.Call(uintptr(handle), 0, uintptr(unsafe.Pointer(&list))); rc != 0 || list == nil {
return result
}
defer wlanFree.Call(uintptr(list))
count := *(*uint32)(list)
if count > 1024 {
return result
}
interfaces := unsafe.Slice((*wlanInterface)(unsafe.Add(list, 8)), int(count))
for _, iface := range interfaces {
if ctx.Err() != nil {
break
}
if iface.State != 1 {
continue
} // wlan_interface_state_connected
reading := system.WiFi{}
// SSID access may be denied by location privacy policy. Association comes
// from the interface state, so missing SSID does not suppress valid RSSI.
if data, size := queryWLAN(handle, &iface.GUID, 7); data != nil {
if size >= uint32(unsafe.Sizeof(wlanConnection{})) {
connection := (*wlanConnection)(data)
if connection.State == 1 && connection.SSIDLength <= 32 {
reading.SSID = validSSID(string(connection.SSID[:connection.SSIDLength]))
}
}
wlanFree.Call(uintptr(data))
}
// Native RSSI LONG, not the quality percentage in association attributes.
if data, size := queryWLAN(handle, &iface.GUID, 0x10000102); data != nil {
if size >= 4 {
signal := float64(*(*int32)(data))
if signal >= -150 && signal < 0 {
reading.Signal = &signal
}
}
wlanFree.Call(uintptr(data))
}
result[iface.GUID.String()] = reading
}
return result
}
func queryWLAN(handle windows.Handle, guid *windows.GUID, opcode uintptr) (unsafe.Pointer, uint32) {
var data unsafe.Pointer
var size uint32
if rc, _, _ := wlanQuery.Call(uintptr(handle), uintptr(unsafe.Pointer(guid)), opcode, 0, uintptr(unsafe.Pointer(&size)), uintptr(unsafe.Pointer(&data)), 0); rc != 0 {
return nil, 0
}
return data, size
}