yangerd: Add WiFi implementation

This commit is contained in:
Mattias Walström
2026-06-24 15:53:02 +02:00
parent bdaae55051
commit f2cc29291a
9 changed files with 2278 additions and 302 deletions
+46 -48
View File
@@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/nl80211"
"github.com/kernelkit/infix/src/yangerd/internal/tree"
)
@@ -349,22 +350,19 @@ func sensorName(baseName, sensorNum string) string {
return baseName + sensorNum
}
func (c *HardwareCollector) get_wifi_phy_info(ctx context.Context) map[string]map[string]interface{} {
func (c *HardwareCollector) get_wifi_phy_info(ctx context.Context, client *nl80211.Client) map[string]map[string]interface{} {
phyInfo := make(map[string]map[string]interface{})
if err := ctx.Err(); err != nil {
return phyInfo
}
listOut, err := c.cmd.Run(ctx, "/usr/libexec/infix/iw.py", "list")
phys, err := client.ListPhys()
if err != nil {
return phyInfo
}
var phys []interface{}
if err := json.Unmarshal(listOut, &phys); err != nil {
return phyInfo
}
for _, phyRaw := range phys {
phy, ok := phyRaw.(string)
if !ok || phy == "" {
for _, phy := range phys {
if phy == "" {
continue
}
phyInfo[phy] = map[string]interface{}{
@@ -382,26 +380,18 @@ func (c *HardwareCollector) get_wifi_phy_info(ctx context.Context) map[string]ma
}
}
devOut, err := c.cmd.Run(ctx, "/usr/libexec/infix/iw.py", "dev")
devMap, err := client.PhyInterfaces()
if err == nil {
var devMap map[string]interface{}
if json.Unmarshal(devOut, &devMap) == nil {
for phyNum, ifacesRaw := range devMap {
phyName, ok := phyNumToName[phyNum]
if !ok {
continue
}
ifaces, ok := ifacesRaw.([]interface{})
if !ok || len(ifaces) == 0 {
continue
}
iface, ok := ifaces[0].(string)
if !ok {
continue
}
if entry, ok := phyInfo[phyName]; ok {
entry["iface"] = iface
}
for phyNum, ifaces := range devMap {
phyName, ok := phyNumToName[phyNum]
if !ok {
continue
}
if len(ifaces) == 0 {
continue
}
if entry, ok := phyInfo[phyName]; ok {
entry["iface"] = ifaces[0]
}
}
}
@@ -618,7 +608,11 @@ func (c *HardwareCollector) hwmon_sensor_components(ctx context.Context) []inter
}
}
wifiInfo := c.get_wifi_phy_info(ctx)
wifiInfo := make(map[string]map[string]interface{})
if client, err := nl80211.Dial(); err == nil {
wifiInfo = c.get_wifi_phy_info(ctx, client)
_ = client.Close()
}
for _, componentRaw := range components {
component, ok := componentRaw.(map[string]interface{})
if !ok {
@@ -668,26 +662,25 @@ func (c *HardwareCollector) thermal_sensor_components(ctx context.Context) []int
return components
}
func (c *HardwareCollector) get_survey_data(ctx context.Context, ifname string) []interface{} {
func (c *HardwareCollector) get_survey_data(ctx context.Context, client *nl80211.Client, ifname string) []interface{} {
if err := ctx.Err(); err != nil {
return nil
}
if ifname == "" {
return nil
}
out, err := c.cmd.Run(ctx, "/usr/libexec/infix/iw.py", "survey", ifname)
iface, err := net.InterfaceByName(ifname)
if err != nil {
return nil
}
var survey []interface{}
if err := json.Unmarshal(out, &survey); err != nil {
survey, err := client.Survey(iface.Index)
if err != nil {
return nil
}
channels := make([]interface{}, 0, len(survey))
for _, entryRaw := range survey {
entry, ok := entryRaw.(map[string]interface{})
if !ok {
continue
}
for _, entry := range survey {
channel := map[string]interface{}{
"frequency": entry["frequency"],
"in-use": entry["in_use"],
@@ -703,16 +696,15 @@ func (c *HardwareCollector) get_survey_data(ctx context.Context, ifname string)
return channels
}
func (c *HardwareCollector) get_phy_info(ctx context.Context, phyName string) map[string]interface{} {
out, err := c.cmd.Run(ctx, "/usr/libexec/infix/iw.py", "info", phyName)
func (c *HardwareCollector) get_phy_info(ctx context.Context, client *nl80211.Client, phyName string) map[string]interface{} {
if err := ctx.Err(); err != nil {
return map[string]interface{}{}
}
phyInfo, err := client.PhyInfo(phyName)
if err != nil {
return map[string]interface{}{}
}
var phyInfo map[string]interface{}
if err := json.Unmarshal(out, &phyInfo); err != nil {
return map[string]interface{}{}
}
return phyInfo
}
@@ -805,7 +797,13 @@ func channelFromFrequency(freq int) (int, bool) {
func (c *HardwareCollector) wifi_radio_components(ctx context.Context) []interface{} {
components := make([]interface{}, 0)
wifiInfo := c.get_wifi_phy_info(ctx)
client, err := nl80211.Dial()
if err != nil {
return components
}
defer client.Close()
wifiInfo := c.get_wifi_phy_info(ctx, client)
for phyName, phyData := range wifiInfo {
component := map[string]interface{}{
@@ -815,7 +813,7 @@ func (c *HardwareCollector) wifi_radio_components(ctx context.Context) []interfa
}
wifiRadioData := make(map[string]interface{})
iwInfo := c.get_phy_info(ctx, phyName)
iwInfo := c.get_phy_info(ctx, client, phyName)
phyDetails := convert_iw_phy_info_for_yanger(iwInfo)
if manufacturer := strDefault(phyDetails["manufacturer"], "Unknown"); manufacturer != "Unknown" {
@@ -865,7 +863,7 @@ func (c *HardwareCollector) wifi_radio_components(ctx context.Context) []interfa
wifiRadioData["num-virtual-interfaces"] = toInt(iwInfo["num_virtual_interfaces"])
iface := strDefault(phyData["iface"], "")
if channels := c.get_survey_data(ctx, iface); len(channels) > 0 {
if channels := c.get_survey_data(ctx, client, iface); len(channels) > 0 {
wifiRadioData["survey"] = map[string]interface{}{
"channel": channels,
}
+271
View File
@@ -0,0 +1,271 @@
package iwmonitor
import (
"encoding/json"
"strconv"
"strings"
"github.com/kernelkit/infix/src/yangerd/internal/wpactrl"
)
func parseIWInfo(output string) json.RawMessage {
info := make(map[string]string)
for _, line := range strings.Split(output, "\n") {
if k, v, ok := parseKV(strings.TrimSpace(line)); ok {
switch k {
case "ssid":
info["ssid"] = v
case "type":
info["type"] = v
case "channel":
info["channel"] = v
case "txpower":
info["tx-power"] = v
}
}
}
data, _ := json.Marshal(info)
return json.RawMessage(data)
}
func parseIWDevList(output string) []string {
var ifaces []string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Interface ") {
if name := strings.TrimPrefix(line, "Interface "); name != "" {
ifaces = append(ifaces, name)
}
}
}
return ifaces
}
func parseKV(line string) (string, string, bool) {
idx := strings.Index(line, ":")
if idx < 0 {
return "", "", false
}
k := strings.TrimSpace(line[:idx])
v := strings.TrimSpace(line[idx+1:])
return k, v, k != ""
}
func parseIWLink(output string) map[string]string {
m := make(map[string]string)
for _, line := range strings.Split(output, "\n") {
if k, v, ok := parseKV(strings.TrimSpace(line)); ok {
m[k] = v
}
}
return m
}
func parseStationDump(output string) json.RawMessage {
type station struct {
MAC string `json:"mac"`
Signal string `json:"signal,omitempty"`
RxBytes string `json:"rx-bytes,omitempty"`
TxBytes string `json:"tx-bytes,omitempty"`
Connected string `json:"connected-time,omitempty"`
Inactive string `json:"inactive-time,omitempty"`
RxBitrate string `json:"rx-bitrate,omitempty"`
TxBitrate string `json:"tx-bitrate,omitempty"`
Authorized string `json:"authorized,omitempty"`
}
var stations []station
var current *station
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Station ") {
parts := strings.Fields(line)
if len(parts) >= 2 {
s := station{MAC: parts[1]}
stations = append(stations, s)
current = &stations[len(stations)-1]
}
continue
}
if current == nil {
continue
}
if k, v, ok := parseKV(line); ok {
switch k {
case "signal":
current.Signal = v
case "rx bytes":
current.RxBytes = v
case "tx bytes":
current.TxBytes = v
case "connected time":
current.Connected = v
case "inactive time":
current.Inactive = v
case "rx bitrate":
current.RxBitrate = v
case "tx bitrate":
current.TxBitrate = v
case "authorized":
current.Authorized = v
}
}
}
data, _ := json.Marshal(stations)
return json.RawMessage(data)
}
// parseBitrate extracts the speed in 100kbps units from iw/hostapd rate info.
// iw link: "866.7 MBit/s VHT-MCS 9 ..."
// hostapd: "1560 vhtmcs 8 vhtnss 2" (value in 100kbps)
func parseBitrate(s string) uint32 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
fields := strings.Fields(s)
if len(fields) == 0 {
return 0
}
if strings.Contains(s, "MBit/s") {
val, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0
}
return uint32(val * 10)
}
val, err := strconv.ParseUint(fields[0], 10, 32)
if err != nil {
return 0
}
return uint32(val)
}
func extractEncryption(flags string) []string {
flags = strings.ToUpper(flags)
var result []string
if strings.Contains(flags, "WPA3") || strings.Contains(flags, "SAE") {
result = append(result, "WPA3-Personal")
}
if strings.Contains(flags, "WPA2") {
if strings.Contains(flags, "EAP") {
result = append(result, "WPA2-Enterprise")
} else {
result = append(result, "WPA2-Personal")
}
}
if strings.Contains(flags, "WEP") {
return []string{"WEP"}
}
if len(result) == 0 && strings.Contains(flags, "ESS") {
return []string{"Open"}
}
if len(result) == 0 {
return []string{"Unknown"}
}
return result
}
func formatScanResults(results []wpactrl.ScanResult) []map[string]any {
seen := make(map[string]int)
var out []map[string]any
for _, r := range results {
if r.SSID == "" {
continue
}
entry := map[string]any{
"ssid": r.SSID,
"bssid": r.BSSID,
"signal-strength": r.Signal,
"channel": wpactrl.FrequencyToChannel(r.Frequency),
}
if enc := extractEncryption(r.Flags); len(enc) > 0 {
entry["encryption"] = enc
}
if idx, dup := seen[r.SSID]; dup {
prev := out[idx]["signal-strength"].(int)
if r.Signal > prev {
out[idx] = entry
}
continue
}
seen[r.SSID] = len(out)
out = append(out, entry)
}
return out
}
// ParseIWEvent parses a single line from `iw event -t` output.
// Retained for tests; no longer used in the main event loop.
func ParseIWEvent(line string) (IWEvent, bool) {
parts := strings.SplitN(line, ": ", 3)
if len(parts) < 3 {
return IWEvent{}, false
}
ts, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return IWEvent{}, false
}
ifacePhy := parts[1]
parenIdx := strings.Index(ifacePhy, " (")
if parenIdx < 0 {
return IWEvent{}, false
}
iface := ifacePhy[:parenIdx]
phy := strings.Trim(ifacePhy[parenIdx+2:], ")")
eventStr := parts[2]
ev := IWEvent{Timestamp: ts, Interface: iface, Phy: phy}
switch {
case strings.HasPrefix(eventStr, "new station "):
ev.Type = "new station"
ev.Addr = strings.TrimPrefix(eventStr, "new station ")
case strings.HasPrefix(eventStr, "del station "):
ev.Type = "del station"
ev.Addr = strings.TrimPrefix(eventStr, "del station ")
case strings.HasPrefix(eventStr, "connected to "):
ev.Type = "connected"
ev.Addr = strings.TrimPrefix(eventStr, "connected to ")
case eventStr == "disconnected":
ev.Type = "disconnected"
case strings.HasPrefix(eventStr, "ch_switch_started_notify"):
ev.Type = "ch_switch_started_notify"
case eventStr == "scan started":
ev.Type = "scan started"
case eventStr == "scan aborted":
ev.Type = "scan aborted"
case strings.HasPrefix(eventStr, "reg_change"):
ev.Type = "reg_change"
case strings.HasPrefix(eventStr, "auth"):
ev.Type = "auth"
default:
ev.Type = eventStr
}
return ev, true
}
// resolveSSID extracts the SSID for an interface.
// wpa_supplicant STATUS has "ssid=<value>".
// hostapd STATUS has "bss[N]=<ifname>" / "ssid[N]=<value>" pairs.
func resolveSSID(iface string, si wpactrl.SocketInfo, status map[string]string) string {
if v := status["ssid"]; v != "" {
return v
}
for i := 0; i < 16; i++ {
idx := strconv.Itoa(i)
if status["bss["+idx+"]"] == iface {
if v := status["ssid["+idx+"]"]; v != "" {
return v
}
break
}
}
return ""
}
+394 -254
View File
@@ -1,30 +1,31 @@
// Package iwmonitor manages a persistent `iw event -t` subprocess
// for reactive 802.11 wireless monitoring. Events are parsed from
// the human-readable text output and dispatched to re-query handlers.
// Started only when YANGERD_ENABLE_WIFI=true.
package iwmonitor
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"os/exec"
"net"
"strconv"
"strings"
"sync"
"time"
"github.com/kernelkit/infix/src/yangerd/internal/wpactrl"
"github.com/mdlayher/genetlink"
"github.com/mdlayher/netlink"
"golang.org/x/sys/unix"
)
const (
reconnectInitial = 100 * time.Millisecond
reconnectInitial = 500 * time.Millisecond
reconnectMax = 30 * time.Second
reconnectFactor = 2.0
queryTimeout = 5 * time.Second
)
// IWEvent represents a single parsed line from `iw event -t`.
// IWEvent is retained for ParseIWEvent compatibility (used in tests).
type IWEvent struct {
Timestamp float64
Interface string
@@ -33,299 +34,438 @@ type IWEvent struct {
Addr string
}
// IWMonitor subscribes to WiFi events via `iw event -t` and updates
// the tree when stations associate/disassociate, links connect/
// disconnect, or regulatory domains change.
type IWMonitor struct {
log *slog.Logger
onUpdate func(ifname string, data json.RawMessage)
log *slog.Logger
onUpdate func(ifname string, data json.RawMessage)
onPhyChange func()
mu sync.Mutex
attached map[string]context.CancelFunc
}
// New creates an IWMonitor.
func New(log *slog.Logger) *IWMonitor {
return &IWMonitor{log: log}
return &IWMonitor{
log: log,
attached: make(map[string]context.CancelFunc),
}
}
// SetOnUpdate sets the callback invoked when wifi data changes for
// an interface. The data is a JSON object with station/info fields.
func (m *IWMonitor) SetOnUpdate(fn func(string, json.RawMessage)) {
m.onUpdate = fn
}
// Run starts the iw event monitor. It blocks until ctx is cancelled.
// On subprocess exit it reconnects with exponential backoff.
func (m *IWMonitor) Run(ctx context.Context) error {
delay := reconnectInitial
for {
err := m.runOnce(ctx)
if ctx.Err() != nil {
return ctx.Err()
}
m.log.Warn("iw event: subprocess exited, restarting",
"err", err, "delay", delay)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
delay = time.Duration(math.Min(
float64(delay)*reconnectFactor,
float64(reconnectMax)))
}
func (m *IWMonitor) SetOnPhyChange(fn func()) {
m.onPhyChange = fn
}
func (m *IWMonitor) runOnce(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "iw", "event", "-t")
stdout, err := cmd.StdoutPipe()
func (m *IWMonitor) Run(ctx context.Context) error {
conn, family, err := m.dialNL80211()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
return fmt.Errorf("nl80211 setup: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start iw event: %w", err)
}
defer cmd.Wait()
defer conn.Close()
m.refreshAllInterfaces(ctx)
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
ev, ok := ParseIWEvent(scanner.Text())
go func() {
<-ctx.Done()
conn.Close()
}()
for {
msgs, _, err := conn.Receive()
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("nl80211 receive: %w", err)
}
for _, msg := range msgs {
m.handleNL80211(ctx, msg, family)
}
}
}
func (m *IWMonitor) dialNL80211() (*genetlink.Conn, genetlink.Family, error) {
conn, err := genetlink.Dial(nil)
if err != nil {
return nil, genetlink.Family{}, fmt.Errorf("dial genetlink: %w", err)
}
family, err := conn.GetFamily(unix.NL80211_GENL_NAME)
if err != nil {
conn.Close()
return nil, genetlink.Family{}, fmt.Errorf("resolve nl80211: %w", err)
}
groups := map[string]bool{
unix.NL80211_MULTICAST_GROUP_MLME: true,
unix.NL80211_MULTICAST_GROUP_REG: true,
unix.NL80211_MULTICAST_GROUP_CONFIG: true,
}
for _, g := range family.Groups {
if groups[g.Name] {
if err := conn.JoinGroup(g.ID); err != nil {
conn.Close()
return nil, genetlink.Family{}, fmt.Errorf("join %s: %w", g.Name, err)
}
m.log.Info("nl80211: joined multicast group", "name", g.Name, "id", g.ID)
}
}
return conn, family, nil
}
func (m *IWMonitor) handleNL80211(ctx context.Context, msg genetlink.Message, family genetlink.Family) {
ifname := m.extractIfname(msg.Data)
cmd := msg.Header.Command
switch cmd {
case unix.NL80211_CMD_NEW_STATION, unix.NL80211_CMD_DEL_STATION:
if ifname != "" {
m.log.Debug("nl80211: station event", "cmd", cmd, "iface", ifname)
m.refreshInterface(ctx, ifname)
}
case unix.NL80211_CMD_CONNECT:
if ifname != "" {
m.log.Debug("nl80211: connect", "iface", ifname)
m.refreshInterface(ctx, ifname)
}
case unix.NL80211_CMD_DISCONNECT:
if ifname != "" {
m.log.Debug("nl80211: disconnect", "iface", ifname)
m.publishWifi(ifname, nil)
}
case unix.NL80211_CMD_REG_CHANGE:
m.log.Debug("nl80211: reg_change")
m.refreshAllInterfaces(ctx)
case unix.NL80211_CMD_NEW_INTERFACE:
if ifname != "" {
m.log.Info("nl80211: new interface", "iface", ifname)
m.startAttach(ctx, ifname)
m.refreshInterface(ctx, ifname)
}
case unix.NL80211_CMD_DEL_INTERFACE:
if ifname != "" {
m.log.Info("nl80211: del interface", "iface", ifname)
m.stopAttach(ifname)
m.publishWifi(ifname, nil)
}
case unix.NL80211_CMD_NEW_WIPHY, unix.NL80211_CMD_DEL_WIPHY:
m.log.Info("nl80211: phy change", "cmd", cmd)
if m.onPhyChange != nil {
m.onPhyChange()
}
}
}
func (m *IWMonitor) extractIfname(data []byte) string {
ad, err := netlink.NewAttributeDecoder(data)
if err != nil {
return ""
}
for ad.Next() {
if ad.Type() == unix.NL80211_ATTR_IFINDEX {
iface, err := net.InterfaceByIndex(int(ad.Uint32()))
if err != nil {
return ""
}
return iface.Name
}
}
return ""
}
func (m *IWMonitor) startAttach(ctx context.Context, ifname string) {
m.mu.Lock()
if _, exists := m.attached[ifname]; exists {
m.mu.Unlock()
return
}
attachCtx, cancel := context.WithCancel(ctx)
m.attached[ifname] = cancel
m.mu.Unlock()
go m.attachLoop(attachCtx, ifname)
}
func (m *IWMonitor) stopAttach(ifname string) {
m.mu.Lock()
if cancel, ok := m.attached[ifname]; ok {
cancel()
delete(m.attached, ifname)
}
m.mu.Unlock()
}
func (m *IWMonitor) attachLoop(ctx context.Context, ifname string) {
delay := reconnectInitial
for {
if ctx.Err() != nil {
return
}
socks := wpactrl.ScanSockets()
si, ok := socks[ifname]
if !ok {
select {
case <-ctx.Done():
return
case <-time.After(delay):
}
delay = nextDelay(delay)
continue
}
m.handleEvent(ctx, ev)
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("read iw event: %w", err)
}
return fmt.Errorf("iw event process exited")
}
func (m *IWMonitor) handleEvent(ctx context.Context, ev IWEvent) {
switch ev.Type {
case "new station", "del station":
m.refreshStationList(ctx, ev.Interface)
case "connected", "ch_switch_started_notify":
m.refreshInterfaceInfo(ctx, ev.Interface)
case "disconnected":
m.publishWifi(ev.Interface, json.RawMessage(`{}`), nil)
case "reg_change":
m.refreshAllInterfaces(ctx)
default:
m.log.Debug("iw event: unhandled", "type", ev.Type, "iface", ev.Interface)
ac, err := wpactrl.Attach(si.Path)
if err != nil {
m.log.Debug("attach failed", "iface", ifname, "err", err)
select {
case <-ctx.Done():
return
case <-time.After(delay):
}
delay = nextDelay(delay)
continue
}
delay = reconnectInitial
m.log.Info("attached to control socket", "iface", ifname, "daemon", si.Daemon)
m.refreshInterface(ctx, ifname)
ac.SetHandler(func(ev wpactrl.Event) {
m.handleAttachEvent(ctx, ifname, ev)
})
err = ac.Run(ctx)
ac.Close()
if ctx.Err() != nil {
return
}
m.log.Warn("control socket lost", "iface", ifname, "err", err)
m.publishWifi(ifname, nil)
}
}
func (m *IWMonitor) refreshStationList(ctx context.Context, iface string) {
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "iw", "dev", iface, "station", "dump").Output()
if err != nil {
m.log.Warn("iw station dump failed", "iface", iface, "err", err)
return
func (m *IWMonitor) handleAttachEvent(ctx context.Context, ifname string, ev wpactrl.Event) {
switch {
case ev.Name == "AP-STA-CONNECTED" || ev.Name == "AP-STA-DISCONNECTED":
m.refreshInterface(ctx, ifname)
case ev.Name == "CTRL-EVENT-CONNECTED":
m.refreshInterface(ctx, ifname)
case ev.Name == "CTRL-EVENT-DISCONNECTED":
m.publishWifi(ifname, nil)
case ev.Name == "CTRL-EVENT-SCAN-RESULTS":
m.refreshInterface(ctx, ifname)
case ev.Name == "CTRL-EVENT-SIGNAL-CHANGE":
m.handleSignalChange(ifname, ev.Data)
case ev.Name == "CTRL-EVENT-TERMINATING":
// Daemon is shutting down; the read loop will get an error next
}
stations := parseStationDump(string(out))
m.publishWifi(iface, nil, stations)
}
func (m *IWMonitor) refreshInterfaceInfo(ctx context.Context, iface string) {
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "iw", "dev", iface, "info").Output()
if err != nil {
m.log.Warn("iw dev info failed", "iface", iface, "err", err)
return
}
info := parseIWInfo(string(out))
m.publishWifi(iface, info, nil)
func (m *IWMonitor) handleSignalChange(ifname string, data string) {
// TODO(lazzer): update signal in-place without full rebuild
// For now, this is a no-op; signal is read during refreshInterface.
_ = ifname
_ = data
}
func (m *IWMonitor) publishWifi(iface string, info, stations json.RawMessage) {
func (m *IWMonitor) refreshInterface(ctx context.Context, iface string) {
wifi := m.buildWifiData(ctx, iface)
m.publishWifi(iface, wifi)
}
func (m *IWMonitor) buildWifiData(ctx context.Context, iface string) map[string]any {
socks := wpactrl.ScanSockets()
si, ok := socks[iface]
var status map[string]string
if ok {
conn, err := wpactrl.Dial(si.Path)
if err == nil {
status, _ = conn.Status()
conn.Close()
}
}
mode := m.detectMode(si, status)
result := make(map[string]any)
if mode == "ap" {
result["access-point"] = m.buildAPData(ctx, iface, si, status)
} else {
result["station"] = m.buildStationData(ctx, iface, si, status)
}
return result
}
func (m *IWMonitor) detectMode(si wpactrl.SocketInfo, status map[string]string) string {
if si.Daemon == "hostapd" {
return "ap"
}
return "station"
}
func (m *IWMonitor) buildAPData(ctx context.Context, iface string, si wpactrl.SocketInfo, status map[string]string) map[string]any {
ap := make(map[string]any)
ssid := resolveSSID(iface, si, status)
if ssid != "" {
ap["ssid"] = ssid
}
if si.Daemon == "hostapd" {
conn, err := wpactrl.Dial(si.Path)
if err != nil {
m.log.Warn("hostapd dial for stations", "iface", iface, "err", err)
} else {
stas, err := conn.AllStations()
conn.Close()
if err != nil {
m.log.Warn("hostapd AllStations", "iface", iface, "err", err)
}
if err == nil {
stas = filterAuthorized(stas)
if len(stas) > 0 {
ap["stations"] = m.formatStations(stas)
}
}
}
}
return ap
}
func (m *IWMonitor) buildStationData(ctx context.Context, iface string, si wpactrl.SocketInfo, status map[string]string) map[string]any {
sta := make(map[string]any)
ssid := resolveSSID(iface, si, status)
if ssid != "" {
sta["ssid"] = ssid
}
if si.Daemon == "wpa_supplicant" {
conn, err := wpactrl.Dial(si.Path)
if err == nil {
poll, err := conn.SignalPoll()
if err == nil {
if v, ok := poll["RSSI"]; ok {
if sig, err := strconv.Atoi(v); err == nil {
sta["signal-strength"] = sig
}
}
if v, ok := poll["LINKSPEED"]; ok {
if speed, err := strconv.ParseUint(v, 10, 32); err == nil {
sta["tx-speed"] = uint32(speed * 10)
}
}
}
results, err := conn.ScanResults()
conn.Close()
if err == nil && len(results) > 0 {
sta["scan-results"] = formatScanResults(results)
}
}
}
return sta
}
func (m *IWMonitor) formatStations(stas []map[string]string) map[string]any {
type stationEntry struct {
MAC string `json:"mac-address"`
Signal int16 `json:"signal-strength,omitempty"`
ConnectedTime uint32 `json:"connected-time,omitempty"`
RxPackets string `json:"rx-packets,omitempty"`
TxPackets string `json:"tx-packets,omitempty"`
RxBytes string `json:"rx-bytes,omitempty"`
TxBytes string `json:"tx-bytes,omitempty"`
RxSpeed uint32 `json:"rx-speed,omitempty"`
TxSpeed uint32 `json:"tx-speed,omitempty"`
}
var out []stationEntry
for _, st := range stas {
s := stationEntry{MAC: st["addr"]}
if v := st["signal"]; v != "" {
if sig, err := strconv.ParseInt(v, 10, 16); err == nil {
s.Signal = int16(sig)
}
}
if v := st["connected_time"]; v != "" {
if ct, err := strconv.ParseUint(v, 10, 32); err == nil {
s.ConnectedTime = uint32(ct)
}
}
if v := st["rx_packets"]; v != "" {
s.RxPackets = v
}
if v := st["tx_packets"]; v != "" {
s.TxPackets = v
}
if v := st["rx_bytes"]; v != "" {
s.RxBytes = v
}
if v := st["tx_bytes"]; v != "" {
s.TxBytes = v
}
if v := st["rx_rate_info"]; v != "" {
if speed := parseBitrate(v); speed > 0 {
s.RxSpeed = speed
}
}
if v := st["tx_rate_info"]; v != "" {
if speed := parseBitrate(v); speed > 0 {
s.TxSpeed = speed
}
}
out = append(out, s)
}
return map[string]any{"station": out}
}
func (m *IWMonitor) publishWifi(iface string, data map[string]any) {
if m.onUpdate == nil {
return
}
wifi := make(map[string]json.RawMessage)
if info != nil {
wifi["info"] = info
}
if stations != nil {
wifi["stations"] = stations
if data == nil {
m.onUpdate(iface, json.RawMessage(`{}`))
return
}
raw, err := json.Marshal(wifi)
raw, err := json.Marshal(data)
if err != nil {
m.log.Warn("iwmonitor: marshal wifi data", "iface", iface, "err", err)
m.log.Warn("marshal wifi data", "iface", iface, "err", err)
return
}
m.onUpdate(iface, json.RawMessage(raw))
}
func (m *IWMonitor) refreshAllInterfaces(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "iw", "dev").Output()
if err != nil {
m.log.Warn("iw dev list failed", "err", err)
return
}
for _, iface := range parseIWDevList(string(out)) {
m.refreshInterfaceInfo(ctx, iface)
m.refreshStationList(ctx, iface)
for ifname := range wpactrl.ScanSockets() {
m.startAttach(ctx, ifname)
m.refreshInterface(ctx, ifname)
}
}
// ParseIWEvent parses a single line from `iw event -t` output.
func ParseIWEvent(line string) (IWEvent, bool) {
parts := strings.SplitN(line, ": ", 3)
if len(parts) < 3 {
return IWEvent{}, false
}
ts, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return IWEvent{}, false
}
ifacePhy := parts[1]
parenIdx := strings.Index(ifacePhy, " (")
if parenIdx < 0 {
return IWEvent{}, false
}
iface := ifacePhy[:parenIdx]
phy := strings.Trim(ifacePhy[parenIdx+2:], ")")
eventStr := parts[2]
ev := IWEvent{Timestamp: ts, Interface: iface, Phy: phy}
switch {
case strings.HasPrefix(eventStr, "new station "):
ev.Type = "new station"
ev.Addr = strings.TrimPrefix(eventStr, "new station ")
case strings.HasPrefix(eventStr, "del station "):
ev.Type = "del station"
ev.Addr = strings.TrimPrefix(eventStr, "del station ")
case strings.HasPrefix(eventStr, "connected to "):
ev.Type = "connected"
ev.Addr = strings.TrimPrefix(eventStr, "connected to ")
case eventStr == "disconnected":
ev.Type = "disconnected"
case strings.HasPrefix(eventStr, "ch_switch_started_notify"):
ev.Type = "ch_switch_started_notify"
case eventStr == "scan started":
ev.Type = "scan started"
case eventStr == "scan aborted":
ev.Type = "scan aborted"
case strings.HasPrefix(eventStr, "reg_change"):
ev.Type = "reg_change"
case strings.HasPrefix(eventStr, "auth"):
ev.Type = "auth"
default:
ev.Type = eventStr
}
return ev, true
func nextDelay(current time.Duration) time.Duration {
d := time.Duration(math.Min(float64(current)*reconnectFactor, float64(reconnectMax)))
return d
}
// parseStationDump parses `iw dev <iface> station dump` output into JSON.
func parseStationDump(output string) json.RawMessage {
type station struct {
MAC string `json:"mac"`
Signal string `json:"signal,omitempty"`
RxBytes string `json:"rx-bytes,omitempty"`
TxBytes string `json:"tx-bytes,omitempty"`
Connected string `json:"connected-time,omitempty"`
Inactive string `json:"inactive-time,omitempty"`
RxBitrate string `json:"rx-bitrate,omitempty"`
TxBitrate string `json:"tx-bitrate,omitempty"`
Authorized string `json:"authorized,omitempty"`
}
var stations []station
var current *station
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Station ") {
parts := strings.Fields(line)
if len(parts) >= 2 {
s := station{MAC: parts[1]}
stations = append(stations, s)
current = &stations[len(stations)-1]
}
continue
}
if current == nil {
continue
}
if k, v, ok := parseKV(line); ok {
switch k {
case "signal":
current.Signal = v
case "rx bytes":
current.RxBytes = v
case "tx bytes":
current.TxBytes = v
case "connected time":
current.Connected = v
case "inactive time":
current.Inactive = v
case "rx bitrate":
current.RxBitrate = v
case "tx bitrate":
current.TxBitrate = v
case "authorized":
current.Authorized = v
}
func filterAuthorized(stas []map[string]string) []map[string]string {
var out []map[string]string
for _, st := range stas {
if strings.Contains(st["flags"], "AUTHORIZED") {
out = append(out, st)
}
}
data, _ := json.Marshal(stations)
return json.RawMessage(data)
}
// parseIWInfo parses `iw dev <iface> info` output into JSON.
func parseIWInfo(output string) json.RawMessage {
info := make(map[string]string)
for _, line := range strings.Split(output, "\n") {
if k, v, ok := parseKV(strings.TrimSpace(line)); ok {
switch k {
case "ssid":
info["ssid"] = v
case "type":
info["type"] = v
case "channel":
info["channel"] = v
case "txpower":
info["tx-power"] = v
}
}
}
data, _ := json.Marshal(info)
return json.RawMessage(data)
}
// parseIWDevList extracts interface names from `iw dev` output.
func parseIWDevList(output string) []string {
var ifaces []string
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Interface ") {
if name := strings.TrimPrefix(line, "Interface "); name != "" {
ifaces = append(ifaces, name)
}
}
}
return ifaces
}
func parseKV(line string) (string, string, bool) {
idx := strings.Index(line, ":")
if idx < 0 {
return "", "", false
}
k := strings.TrimSpace(line[:idx])
v := strings.TrimSpace(line[idx+1:])
return k, v, k != ""
return out
}
+655
View File
@@ -0,0 +1,655 @@
package nl80211
import (
"encoding/binary"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/mdlayher/genetlink"
"github.com/mdlayher/netlink"
"golang.org/x/sys/unix"
)
type Client struct {
conn *genetlink.Conn
family genetlink.Family
}
func Dial() (*Client, error) {
conn, err := genetlink.Dial(nil)
if err != nil {
return nil, fmt.Errorf("dial genetlink: %w", err)
}
family, err := conn.GetFamily("nl80211")
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("resolve nl80211 family: %w", err)
}
return &Client{conn: conn, family: family}, nil
}
func (c *Client) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
func (c *Client) ListPhys() ([]string, error) {
msgs, err := c.execute(unix.NL80211_CMD_GET_WIPHY, nil, netlink.Request|netlink.Dump)
if err != nil {
return nil, err
}
set := make(map[string]bool)
for _, msg := range msgs {
attrs, err := netlink.NewAttributeDecoder(msg.Data)
if err != nil {
continue
}
for attrs.Next() {
if attrs.Type() != unix.NL80211_ATTR_WIPHY_NAME {
continue
}
name := attrs.String()
if name != "" {
set[name] = true
}
}
if err := attrs.Err(); err != nil {
continue
}
}
out := make([]string, 0, len(set))
for name := range set {
out = append(out, name)
}
sort.Strings(out)
return out, nil
}
func (c *Client) PhyInterfaces() (map[string][]string, error) {
msgs, err := c.execute(unix.NL80211_CMD_GET_INTERFACE, nil, netlink.Request|netlink.Dump)
if err != nil {
return nil, err
}
out := make(map[string][]string)
for _, msg := range msgs {
ad, err := netlink.NewAttributeDecoder(msg.Data)
if err != nil {
continue
}
phyIdx := -1
ifname := ""
for ad.Next() {
switch ad.Type() {
case unix.NL80211_ATTR_WIPHY:
phyIdx = int(ad.Uint32())
case unix.NL80211_ATTR_IFNAME:
ifname = ad.String()
}
}
if err := ad.Err(); err != nil {
continue
}
if phyIdx < 0 || ifname == "" {
continue
}
k := strconv.Itoa(phyIdx)
out[k] = append(out[k], ifname)
}
for k := range out {
sort.Strings(out[k])
}
return out, nil
}
func (c *Client) PhyInfo(phyName string) (map[string]interface{}, error) {
ae := netlink.NewAttributeEncoder()
ae.Flag(unix.NL80211_ATTR_SPLIT_WIPHY_DUMP, true)
req, _ := ae.Encode()
msgs, err := c.execute(unix.NL80211_CMD_GET_WIPHY, req, netlink.Request|netlink.Dump)
if err != nil {
return nil, err
}
// Collect all messages belonging to the target PHY. The kernel
// identifies fragments by repeating NL80211_ATTR_WIPHY (index)
// or NL80211_ATTR_WIPHY_NAME in each fragment.
targetIdx := -1
var phyMsgs [][]byte
for _, msg := range msgs {
idx, name := parseWiphyIdent(msg.Data)
if name == phyName {
targetIdx = idx
phyMsgs = append(phyMsgs, msg.Data)
} else if targetIdx >= 0 && idx == targetIdx {
phyMsgs = append(phyMsgs, msg.Data)
}
}
if len(phyMsgs) == 0 {
return nil, fmt.Errorf("phy %q not found", phyName)
}
info := map[string]interface{}{
"bands": []interface{}{},
"driver": readDriver(phyName),
"manufacturer": readManufacturer(phyName),
"interface_combinations": []interface{}{},
"max_txpower": 0,
"num_virtual_interfaces": 0,
}
phyIdx := -1
bandMap := make(map[uint16]*bandInfo)
for _, data := range phyMsgs {
ad, err := netlink.NewAttributeDecoder(data)
if err != nil {
continue
}
for ad.Next() {
switch ad.Type() {
case unix.NL80211_ATTR_WIPHY:
phyIdx = int(ad.Uint32())
case unix.NL80211_ATTR_WIPHY_BANDS:
mergeBands(bandMap, ad.Bytes())
case unix.NL80211_ATTR_INTERFACE_COMBINATIONS:
if combs := parseInterfaceCombinations(ad.Bytes()); len(combs) > 0 {
info["interface_combinations"] = combs
}
case unix.NL80211_ATTR_WIPHY_TX_POWER_LEVEL:
info["max_txpower"] = int(ad.Uint32() / 100)
}
}
}
if bands := finalizeBands(bandMap); len(bands) > 0 {
info["bands"] = bands
}
if phyIdx >= 0 {
ifs, err := c.PhyInterfaces()
if err == nil {
info["num_virtual_interfaces"] = len(ifs[strconv.Itoa(phyIdx)])
}
}
return info, nil
}
func (c *Client) Survey(ifindex int) ([]map[string]interface{}, error) {
ae := netlink.NewAttributeEncoder()
ae.Uint32(unix.NL80211_ATTR_IFINDEX, uint32(ifindex))
req, err := ae.Encode()
if err != nil {
return nil, fmt.Errorf("encode get_survey request: %w", err)
}
msgs, err := c.execute(unix.NL80211_CMD_GET_SURVEY, req, netlink.Request|netlink.Dump)
if err != nil {
return nil, err
}
out := make([]map[string]interface{}, 0)
for _, msg := range msgs {
ad, err := netlink.NewAttributeDecoder(msg.Data)
if err != nil {
continue
}
for ad.Next() {
if ad.Type() != unix.NL80211_ATTR_SURVEY_INFO {
continue
}
entry := parseSurveyEntry(ad.Bytes())
if entry != nil {
out = append(out, entry)
}
}
if err := ad.Err(); err != nil {
continue
}
}
return out, nil
}
func (c *Client) execute(cmd uint8, data []byte, flags netlink.HeaderFlags) ([]genetlink.Message, error) {
msgs, err := c.conn.Execute(
genetlink.Message{Header: genetlink.Header{Command: cmd, Version: c.family.Version}, Data: data},
c.family.ID,
flags,
)
if err != nil {
return nil, fmt.Errorf("nl80211 command %d: %w", cmd, err)
}
return msgs, nil
}
func parseWiphyIdent(data []byte) (int, string) {
ad, err := netlink.NewAttributeDecoder(data)
if err != nil {
return -1, ""
}
idx := -1
name := ""
for ad.Next() {
switch ad.Type() {
case unix.NL80211_ATTR_WIPHY:
idx = int(ad.Uint32())
case unix.NL80211_ATTR_WIPHY_NAME:
name = ad.String()
}
}
return idx, name
}
type bandInfo struct {
frequencies []interface{}
htCapable bool
vhtCapable bool
heCapable bool
}
func mergeBands(m map[uint16]*bandInfo, data []byte) {
ad, err := netlink.NewAttributeDecoder(data)
if err != nil {
return
}
for ad.Next() {
bandType := ad.Type()
bi, ok := m[bandType]
if !ok {
bi = &bandInfo{}
m[bandType] = bi
}
mergeBandAttrs(bi, ad.Bytes())
}
}
func mergeBandAttrs(bi *bandInfo, data []byte) {
nad, err := netlink.NewAttributeDecoder(data)
if err != nil {
return
}
for nad.Next() {
switch nad.Type() {
case unix.NL80211_BAND_ATTR_FREQS:
if freqs := parseBandFrequencies(nad.Bytes()); len(freqs) > 0 {
bi.frequencies = freqs
}
case unix.NL80211_BAND_ATTR_HT_CAPA:
if nad.Uint16() != 0 {
bi.htCapable = true
}
case unix.NL80211_BAND_ATTR_VHT_CAPA:
if nad.Uint32() != 0 {
bi.vhtCapable = true
}
case unix.NL80211_BAND_ATTR_IFTYPE_DATA:
if len(nad.Bytes()) > 0 {
bi.heCapable = true
}
}
}
}
func finalizeBands(m map[uint16]*bandInfo) []interface{} {
keys := make([]int, 0, len(m))
for k := range m {
keys = append(keys, int(k))
}
sort.Ints(keys)
out := make([]interface{}, 0, len(keys))
for _, k := range keys {
bi := m[uint16(k)]
if len(bi.frequencies) == 0 && !bi.htCapable && !bi.vhtCapable && !bi.heCapable {
continue
}
out = append(out, map[string]interface{}{
"band": k,
"name": detectBandName(bi.frequencies),
"ht_capable": bi.htCapable,
"vht_capable": bi.vhtCapable,
"he_capable": bi.heCapable,
"frequencies": bi.frequencies,
})
}
return out
}
func parseBandFrequencies(data []byte) []interface{} {
out := make([]interface{}, 0)
ad, err := netlink.NewAttributeDecoder(data)
if err != nil {
return out
}
for ad.Next() {
freq, ok := parseFrequencyEntry(ad.Bytes())
if ok {
out = append(out, freq)
}
}
return out
}
func parseFrequencyEntry(data []byte) (int, bool) {
ad, err := netlink.NewAttributeDecoder(data)
if err != nil {
return 0, false
}
freq := 0
disabled := false
for ad.Next() {
switch ad.Type() {
case unix.NL80211_FREQUENCY_ATTR_FREQ:
freq = int(ad.Uint32())
case unix.NL80211_FREQUENCY_ATTR_DISABLED:
disabled = true
}
}
if disabled || freq == 0 {
return 0, false
}
return freq, true
}
func detectBandName(freqs []interface{}) string {
has24 := false
has5 := false
has6 := false
for _, f := range freqs {
freq, ok := f.(int)
if !ok {
continue
}
switch {
case freq >= 2400 && freq <= 2500:
has24 = true
case freq >= 5000 && freq <= 5900:
has5 = true
case freq >= 5925 && freq <= 7125:
has6 = true
}
}
switch {
case has24:
return "2.4 GHz"
case has5:
return "5 GHz"
case has6:
return "6 GHz"
default:
return "Unknown"
}
}
func parseInterfaceCombinations(data []byte) []interface{} {
out := make([]interface{}, 0)
ad, err := netlink.NewAttributeDecoder(data)
if err != nil {
return out
}
for ad.Next() {
comb := parseInterfaceCombination(ad.Bytes())
if comb != nil {
out = append(out, comb)
}
}
return out
}
func parseInterfaceCombination(data []byte) map[string]interface{} {
limits := make([]interface{}, 0)
ad, err := netlink.NewAttributeDecoder(data)
if err != nil {
return nil
}
for ad.Next() {
if ad.Type() != unix.NL80211_IFACE_COMB_LIMITS {
continue
}
limits = parseInterfaceLimits(ad.Bytes())
}
if len(limits) == 0 {
return nil
}
return map[string]interface{}{"limits": limits}
}
func parseInterfaceLimits(data []byte) []interface{} {
out := make([]interface{}, 0)
ad, err := netlink.NewAttributeDecoder(data)
if err != nil {
return out
}
for ad.Next() {
entry := parseInterfaceLimitEntry(ad.Bytes())
if entry != nil {
out = append(out, entry)
}
}
return out
}
func parseInterfaceLimitEntry(data []byte) map[string]interface{} {
max := 0
types := make([]interface{}, 0)
ad, err := netlink.NewAttributeDecoder(data)
if err != nil {
return nil
}
for ad.Next() {
switch ad.Type() {
case unix.NL80211_IFACE_LIMIT_MAX:
max = int(ad.Uint32())
case unix.NL80211_IFACE_LIMIT_TYPES:
types = parseIfaceLimitTypes(ad.Bytes())
}
}
if max == 0 || len(types) == 0 {
return nil
}
return map[string]interface{}{
"max": max,
"types": types,
}
}
func parseIfaceLimitTypes(data []byte) []interface{} {
out := make([]interface{}, 0)
seen := make(map[string]bool)
ad, err := netlink.NewAttributeDecoder(data)
if err != nil {
return out
}
for ad.Next() {
if len(ad.Bytes()) == 4 {
iftype := int(ad.Uint32())
if s, ok := iftypeName(iftype); ok {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
continue
}
iftype := int(ad.Type())
if s, ok := iftypeName(iftype); ok {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
}
sort.Slice(out, func(i, j int) bool {
return out[i].(string) < out[j].(string)
})
return out
}
func iftypeName(v int) (string, bool) {
switch v {
case 0:
return "unspecified", true
case 1:
return "adhoc", true
case 2:
return "station", true
case 3:
return "AP", true
case 4:
return "AP_VLAN", true
case 5:
return "WDS", true
case 6:
return "monitor", true
case 7:
return "mesh_point", true
case 8:
return "P2P_client", true
case 9:
return "P2P_GO", true
case 10:
return "P2P_device", true
default:
return "", false
}
}
func parseSurveyEntry(data []byte) map[string]interface{} {
entry := map[string]interface{}{
"frequency": 0,
"noise": 0,
"in_use": false,
"active_time": 0,
"busy_time": 0,
"receive_time": 0,
"transmit_time": 0,
}
ad, err := netlink.NewAttributeDecoder(data)
if err != nil {
return nil
}
hasFrequency := false
for ad.Next() {
switch ad.Type() {
case unix.NL80211_SURVEY_INFO_FREQUENCY:
entry["frequency"] = int(readUint(ad.Bytes()))
hasFrequency = true
case unix.NL80211_SURVEY_INFO_NOISE:
entry["noise"] = int(ad.Int8())
case unix.NL80211_SURVEY_INFO_IN_USE:
entry["in_use"] = true
case unix.NL80211_SURVEY_INFO_TIME:
entry["active_time"] = int(readUint(ad.Bytes()))
case unix.NL80211_SURVEY_INFO_TIME_BUSY:
entry["busy_time"] = int(readUint(ad.Bytes()))
case unix.NL80211_SURVEY_INFO_TIME_RX:
entry["receive_time"] = int(readUint(ad.Bytes()))
case unix.NL80211_SURVEY_INFO_TIME_TX:
entry["transmit_time"] = int(readUint(ad.Bytes()))
}
}
if !hasFrequency {
return nil
}
return entry
}
func readUint(b []byte) uint64 {
switch len(b) {
case 1:
return uint64(b[0])
case 2:
return uint64(binary.NativeEndian.Uint16(b))
case 4:
return uint64(binary.NativeEndian.Uint32(b))
case 8:
return binary.NativeEndian.Uint64(b)
default:
return 0
}
}
func readDriver(phyName string) string {
path := filepath.Join("/sys/class/ieee80211", phyName, "device", "driver")
target, err := os.Readlink(path)
if err != nil {
return ""
}
if base := filepath.Base(target); base != "." && base != "/" {
return base
}
return ""
}
func readManufacturer(phyName string) string {
driver := readDriver(phyName)
if driver == "" {
return "Unknown"
}
d := strings.ToLower(driver)
switch {
case strings.Contains(d, "mt") || strings.Contains(d, "mediatek"):
return "MediaTek Inc."
case strings.Contains(d, "rtw") || strings.Contains(d, "realtek"):
return "Realtek Semiconductor Corp."
case strings.Contains(d, "ath") || strings.Contains(d, "qca"):
return "Qualcomm Atheros"
case strings.Contains(d, "iwl") || strings.Contains(d, "intel"):
return "Intel Corporation"
case strings.Contains(d, "brcm") || strings.Contains(d, "broadcom"):
return "Broadcom Inc."
default:
return "Unknown"
}
}
+159
View File
@@ -0,0 +1,159 @@
package wpactrl
import (
"context"
"fmt"
"net"
"os"
"strings"
"time"
)
const attachBufSize = 4096
// Event is an unsolicited event from wpa_supplicant or hostapd,
// received after sending the ATTACH command.
type Event struct {
Priority int
Name string
Data string
Raw string
}
// EventHandler is called for each unsolicited event.
type EventHandler func(Event)
// AttachConn is a persistent event listener on a wpa_supplicant or
// hostapd control socket. After sending ATTACH, the daemon pushes
// unsolicited events like CTRL-EVENT-SIGNAL-CHANGE, AP-STA-CONNECTED,
// etc. The connection reads these in a loop and dispatches them to a
// handler.
type AttachConn struct {
conn *net.UnixConn
local string
handler EventHandler
}
// Attach connects to the control socket at serverPath and sends the
// ATTACH command. On success, the daemon will send unsolicited events
// to this connection. Call Run to start reading them.
func Attach(serverPath string) (*AttachConn, error) {
seq := clientSeq.Add(1)
localPath := fmt.Sprintf("/tmp/wpactrl_attach_%d_%d", os.Getpid(), seq)
os.Remove(localPath)
laddr := &net.UnixAddr{Name: localPath, Net: "unixgram"}
raddr := &net.UnixAddr{Name: serverPath, Net: "unixgram"}
conn, err := net.DialUnix("unixgram", laddr, raddr)
if err != nil {
os.Remove(localPath)
return nil, fmt.Errorf("dial %s: %w", serverPath, err)
}
conn.SetDeadline(time.Now().Add(DefaultTimeout))
if _, err := conn.Write([]byte("ATTACH")); err != nil {
conn.Close()
os.Remove(localPath)
return nil, fmt.Errorf("send ATTACH: %w", err)
}
buf := make([]byte, 64)
n, err := conn.Read(buf)
if err != nil {
conn.Close()
os.Remove(localPath)
return nil, fmt.Errorf("read ATTACH response: %w", err)
}
resp := strings.TrimSpace(string(buf[:n]))
if resp != "OK" {
conn.Close()
os.Remove(localPath)
return nil, fmt.Errorf("ATTACH rejected: %q", resp)
}
conn.SetDeadline(time.Time{})
return &AttachConn{conn: conn, local: localPath}, nil
}
// SetHandler sets the callback for received events.
func (a *AttachConn) SetHandler(fn EventHandler) {
a.handler = fn
}
// Run reads events until ctx is cancelled or the socket errors (daemon
// died). Returns nil on context cancellation, error on socket failure.
func (a *AttachConn) Run(ctx context.Context) error {
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
a.conn.SetReadDeadline(time.Now())
case <-done:
}
}()
defer close(done)
buf := make([]byte, attachBufSize)
for {
n, err := a.conn.Read(buf)
if err != nil {
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("read: %w", err)
}
if a.handler == nil {
continue
}
ev, ok := ParseEvent(string(buf[:n]))
if ok {
a.handler(ev)
}
}
}
// Close sends DETACH and closes the connection.
func (a *AttachConn) Close() error {
a.conn.SetDeadline(time.Now().Add(DefaultTimeout))
a.conn.Write([]byte("DETACH"))
err := a.conn.Close()
os.Remove(a.local)
return err
}
// ParseEvent parses a single unsolicited event line. Format:
// <N>EVENT-NAME optional-data
// where N is a priority digit (0-4). Some events like
// AP-STA-CONNECTED have no priority prefix.
func ParseEvent(line string) (Event, bool) {
line = strings.TrimSpace(line)
if line == "" {
return Event{}, false
}
ev := Event{Raw: line}
if len(line) >= 3 && line[0] == '<' {
end := strings.IndexByte(line, '>')
if end > 1 {
for _, c := range line[1:end] {
if c < '0' || c > '9' {
goto noPriority
}
}
fmt.Sscanf(line[1:end], "%d", &ev.Priority)
line = line[end+1:]
}
}
noPriority:
if idx := strings.IndexByte(line, ' '); idx > 0 {
ev.Name = line[:idx]
ev.Data = line[idx+1:]
} else {
ev.Name = line
}
return ev, ev.Name != ""
}
+156
View File
@@ -0,0 +1,156 @@
package wpactrl
import (
"context"
"net"
"os"
"testing"
"time"
)
func TestParseEvent(t *testing.T) {
tests := []struct {
line string
wantOK bool
wantPri int
wantName string
wantData string
}{
{"<3>CTRL-EVENT-SIGNAL-CHANGE above=0 signal=-88 noise=-92 txrate=6000", true, 3, "CTRL-EVENT-SIGNAL-CHANGE", "above=0 signal=-88 noise=-92 txrate=6000"},
{"<3>CTRL-EVENT-CONNECTED - Connection to 02:00:00:00:01:00 completed", true, 3, "CTRL-EVENT-CONNECTED", "- Connection to 02:00:00:00:01:00 completed"},
{"<3>CTRL-EVENT-SCAN-RESULTS ", true, 3, "CTRL-EVENT-SCAN-RESULTS", ""},
{"<3>CTRL-EVENT-DISCONNECTED bssid=02:00:00:00:01:00 reason=3", true, 3, "CTRL-EVENT-DISCONNECTED", "bssid=02:00:00:00:01:00 reason=3"},
{"<2>AP-STA-CONNECTED 9e:61:6b:cf:d8:15", true, 2, "AP-STA-CONNECTED", "9e:61:6b:cf:d8:15"},
{"<2>AP-STA-DISCONNECTED 9e:61:6b:cf:d8:15", true, 2, "AP-STA-DISCONNECTED", "9e:61:6b:cf:d8:15"},
{"AP-STA-CONNECTED 9e:61:6b:cf:d8:15", true, 0, "AP-STA-CONNECTED", "9e:61:6b:cf:d8:15"},
{"<3>CTRL-EVENT-TERMINATING", true, 3, "CTRL-EVENT-TERMINATING", ""},
{"", false, 0, "", ""},
{" ", false, 0, "", ""},
}
for _, tt := range tests {
ev, ok := ParseEvent(tt.line)
if ok != tt.wantOK {
t.Errorf("ParseEvent(%q): ok=%v, want %v", tt.line, ok, tt.wantOK)
continue
}
if !ok {
continue
}
if ev.Priority != tt.wantPri {
t.Errorf("ParseEvent(%q): priority=%d, want %d", tt.line, ev.Priority, tt.wantPri)
}
if ev.Name != tt.wantName {
t.Errorf("ParseEvent(%q): name=%q, want %q", tt.line, ev.Name, tt.wantName)
}
if ev.Data != tt.wantData {
t.Errorf("ParseEvent(%q): data=%q, want %q", tt.line, ev.Data, tt.wantData)
}
}
}
func TestAttachAndReceiveEvents(t *testing.T) {
dir := t.TempDir()
serverPath := dir + "/hostapd_test"
serverAddr := &net.UnixAddr{Name: serverPath, Net: "unixgram"}
server, err := net.ListenUnixgram("unixgram", serverAddr)
if err != nil {
t.Fatalf("listen: %v", err)
}
defer server.Close()
go func() {
buf := make([]byte, 4096)
n, raddr, err := server.ReadFromUnix(buf)
if err != nil {
return
}
if string(buf[:n]) == "ATTACH" {
server.WriteToUnix([]byte("OK\n"), raddr)
}
time.Sleep(50 * time.Millisecond)
server.WriteToUnix([]byte("<2>AP-STA-CONNECTED 9e:61:6b:cf:d8:15"), raddr)
time.Sleep(50 * time.Millisecond)
server.WriteToUnix([]byte("<3>CTRL-EVENT-SIGNAL-CHANGE above=0 signal=-55"), raddr)
n, _, err = server.ReadFromUnix(buf)
if err != nil {
return
}
if string(buf[:n]) == "DETACH" {
server.WriteToUnix([]byte("OK\n"), raddr)
}
}()
ac, err := Attach(serverPath)
if err != nil {
t.Fatalf("Attach: %v", err)
}
defer ac.Close()
var events []Event
ac.SetHandler(func(ev Event) {
events = append(events, ev)
})
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
ac.Run(ctx)
if len(events) < 2 {
t.Fatalf("got %d events, want >= 2", len(events))
}
if events[0].Name != "AP-STA-CONNECTED" {
t.Errorf("events[0].Name = %q, want AP-STA-CONNECTED", events[0].Name)
}
if events[0].Data != "9e:61:6b:cf:d8:15" {
t.Errorf("events[0].Data = %q", events[0].Data)
}
if events[1].Name != "CTRL-EVENT-SIGNAL-CHANGE" {
t.Errorf("events[1].Name = %q, want CTRL-EVENT-SIGNAL-CHANGE", events[1].Name)
}
os.Remove(ac.local)
}
func TestAttachContextCancel(t *testing.T) {
dir := t.TempDir()
serverPath := dir + "/wpa_test"
serverAddr := &net.UnixAddr{Name: serverPath, Net: "unixgram"}
server, err := net.ListenUnixgram("unixgram", serverAddr)
if err != nil {
t.Fatalf("listen: %v", err)
}
defer server.Close()
go func() {
buf := make([]byte, 4096)
n, raddr, err := server.ReadFromUnix(buf)
if err != nil {
return
}
if string(buf[:n]) == "ATTACH" {
server.WriteToUnix([]byte("OK\n"), raddr)
}
n, _, _ = server.ReadFromUnix(buf)
}()
ac, err := Attach(serverPath)
if err != nil {
t.Fatalf("Attach: %v", err)
}
defer ac.Close()
ac.SetHandler(func(ev Event) {})
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
err = ac.Run(ctx)
if err != nil {
t.Errorf("expected nil on context cancel, got %v", err)
}
}
+143
View File
@@ -0,0 +1,143 @@
package wpactrl
import (
"strconv"
"strings"
)
// ScanResult is a single entry from SCAN_RESULTS.
type ScanResult struct {
BSSID string
Frequency int
Signal int
Flags string
SSID string
}
// ParseKV parses a wpa_supplicant/hostapd key=value response.
func ParseKV(resp string) map[string]string {
m := make(map[string]string)
for _, line := range strings.Split(resp, "\n") {
line = strings.TrimSpace(line)
if idx := strings.IndexByte(line, '='); idx > 0 {
m[line[:idx]] = line[idx+1:]
}
}
return m
}
// ParseScanResults parses wpa_supplicant SCAN_RESULTS output.
// Format: bssid / frequency / signal level / flags / ssid
// First line is a header, subsequent lines are tab-separated.
func ParseScanResults(resp string) []ScanResult {
var results []ScanResult
for _, line := range strings.Split(resp, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "bssid") {
continue
}
fields := strings.SplitN(line, "\t", 5)
if len(fields) < 4 {
continue
}
freq, _ := strconv.Atoi(fields[1])
sig, _ := strconv.Atoi(fields[2])
ssid := ""
if len(fields) >= 5 {
ssid = fields[4]
}
results = append(results, ScanResult{
BSSID: fields[0],
Frequency: freq,
Signal: sig,
Flags: fields[3],
SSID: ssid,
})
}
return results
}
// ParseStationResp parses a hostapd STA-FIRST/STA-NEXT response.
// First line is the station MAC, subsequent lines are key=value pairs.
func ParseStationResp(resp string) map[string]string {
lines := strings.Split(resp, "\n")
if len(lines) == 0 {
return nil
}
m := make(map[string]string)
addr := strings.TrimSpace(lines[0])
if addr != "" {
m["addr"] = addr
}
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if idx := strings.IndexByte(line, '='); idx > 0 {
m[line[:idx]] = line[idx+1:]
}
}
return m
}
// ParseAllStations parses hostapd ALL_STA response containing multiple
// stations. Each station block starts with a MAC address line (xx:xx:xx:xx:xx:xx)
// followed by key=value lines.
func ParseAllStations(resp string) []map[string]string {
var stations []map[string]string
var current map[string]string
for _, line := range strings.Split(resp, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if isMACAddress(line) {
if current != nil {
stations = append(stations, current)
}
current = map[string]string{"addr": line}
continue
}
if current != nil {
if idx := strings.IndexByte(line, '='); idx > 0 {
current[line[:idx]] = line[idx+1:]
}
}
}
if current != nil {
stations = append(stations, current)
}
return stations
}
func isMACAddress(s string) bool {
if len(s) != 17 {
return false
}
for i, c := range s {
if i%3 == 2 {
if c != ':' {
return false
}
} else {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
}
return true
}
// FrequencyToChannel converts a WiFi frequency in MHz to a channel number.
func FrequencyToChannel(freq int) int {
switch {
case freq == 2484:
return 14
case freq >= 2412 && freq <= 2472:
return (freq-2412)/5 + 1
case freq >= 5170 && freq <= 5825:
return (freq - 5000) / 5
case freq >= 5955 && freq <= 7115:
return (freq - 5950) / 5
}
return 0
}
+212
View File
@@ -0,0 +1,212 @@
// Package wpactrl provides a native Go client for wpa_supplicant and
// hostapd control sockets. It speaks the same text-based protocol as
// wpa_cli/hostapd_cli — Unix datagram sockets with ASCII
// command/response framing. No subprocess, no CGo.
//
// wpa_supplicant listens at /var/run/wpa_supplicant/<ifname>
// hostapd listens at /var/run/hostapd/<ifname>
//
// The client binds its own temporary socket, sends a command string,
// and reads back the text response.
package wpactrl
import (
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
)
const (
DefaultTimeout = 5 * time.Second
maxResponse = 64 * 1024
)
// WPADirs lists directories where wpa_supplicant control sockets may live.
var WPADirs = []string{"/run/wpa_supplicant", "/var/run/wpa_supplicant"}
// HostapdDirs lists directories where hostapd control sockets may live.
var HostapdDirs = []string{"/run/hostapd", "/var/run/hostapd"}
var clientSeq atomic.Uint64
// SocketInfo describes a discovered control socket.
type SocketInfo struct {
Path string
Iface string
Daemon string // "wpa_supplicant" or "hostapd"
}
// ScanSockets discovers wpa_supplicant and hostapd control sockets by
// listing the well-known directories. Returns a map from interface
// name to SocketInfo.
func ScanSockets() map[string]SocketInfo {
result := make(map[string]SocketInfo)
for _, dir := range HostapdDirs {
scanDir(dir, "hostapd", result)
}
for _, dir := range WPADirs {
scanDir(dir, "wpa_supplicant", result)
}
return result
}
func scanDir(dir, daemon string, out map[string]SocketInfo) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
name := e.Name()
if _, exists := out[name]; exists {
continue
}
path := filepath.Join(dir, name)
fi, err := os.Stat(path)
if err != nil {
continue
}
if fi.Mode()&os.ModeSocket != 0 {
out[name] = SocketInfo{
Path: path,
Iface: name,
Daemon: daemon,
}
}
}
}
// Conn is a connection to a wpa_supplicant or hostapd control socket.
type Conn struct {
conn *net.UnixConn
local string // path to our client socket (for cleanup)
timeout time.Duration
}
// Dial connects to a wpa_supplicant or hostapd control socket at the
// given path (e.g. "/var/run/wpa_supplicant/wlan0"). The caller must
// call Close when done.
func Dial(serverPath string) (*Conn, error) {
return DialTimeout(serverPath, DefaultTimeout)
}
// DialTimeout connects with a custom timeout.
func DialTimeout(serverPath string, timeout time.Duration) (*Conn, error) {
// Create a unique client socket path in /tmp.
seq := clientSeq.Add(1)
localPath := fmt.Sprintf("/tmp/wpactrl_%d_%d", os.Getpid(), seq)
// Clean up stale socket file if it exists.
os.Remove(localPath)
laddr := &net.UnixAddr{Name: localPath, Net: "unixgram"}
raddr := &net.UnixAddr{Name: serverPath, Net: "unixgram"}
conn, err := net.DialUnix("unixgram", laddr, raddr)
if err != nil {
os.Remove(localPath)
return nil, fmt.Errorf("dial %s: %w", serverPath, err)
}
return &Conn{
conn: conn,
local: localPath,
timeout: timeout,
}, nil
}
// Close closes the connection and removes the client socket file.
func (c *Conn) Close() error {
err := c.conn.Close()
os.Remove(c.local)
return err
}
// Command sends a command string and returns the response.
func (c *Conn) Command(cmd string) (string, error) {
c.conn.SetDeadline(time.Now().Add(c.timeout))
_, err := c.conn.Write([]byte(cmd))
if err != nil {
return "", fmt.Errorf("write %q: %w", cmd, err)
}
buf := make([]byte, maxResponse)
n, err := c.conn.Read(buf)
if err != nil {
return "", fmt.Errorf("read response to %q: %w", cmd, err)
}
return string(buf[:n]), nil
}
// Ping sends a PING command and returns true if the response is PONG.
func (c *Conn) Ping() bool {
resp, err := c.Command("PING")
return err == nil && len(resp) >= 4 && resp[:4] == "PONG"
}
// Status sends the STATUS command and returns the parsed key=value pairs.
func (c *Conn) Status() (map[string]string, error) {
resp, err := c.Command("STATUS")
if err != nil {
return nil, err
}
return ParseKV(resp), nil
}
// SignalPoll sends SIGNAL_POLL and returns parsed key=value pairs.
// Returns RSSI, LINKSPEED, NOISE, FREQUENCY, etc.
// Only meaningful for wpa_supplicant (station mode).
func (c *Conn) SignalPoll() (map[string]string, error) {
resp, err := c.Command("SIGNAL_POLL")
if err != nil {
return nil, err
}
return ParseKV(resp), nil
}
// ScanResults sends SCAN_RESULTS and returns parsed results.
// This is only meaningful for wpa_supplicant (station mode).
func (c *Conn) ScanResults() ([]ScanResult, error) {
resp, err := c.Command("SCAN_RESULTS")
if err != nil {
return nil, err
}
return ParseScanResults(resp), nil
}
// AllStations enumerates all associated stations via STA-FIRST/STA-NEXT.
// Only meaningful for hostapd.
func (c *Conn) AllStations() ([]map[string]string, error) {
resp, err := c.Command("STA-FIRST")
if err != nil {
return nil, fmt.Errorf("STA-FIRST: %w", err)
}
if resp == "" || resp == "\n" || resp == "FAIL\n" {
return nil, nil
}
if strings.HasPrefix(resp, "UNKNOWN") {
return nil, fmt.Errorf("STA-FIRST not supported: %q", strings.TrimSpace(resp))
}
var stations []map[string]string
st := ParseStationResp(resp)
for st != nil {
stations = append(stations, st)
addr := st["addr"]
if addr == "" {
break
}
resp, err = c.Command("STA-NEXT " + addr)
if err != nil || resp == "" || resp == "\n" || resp == "FAIL\n" || strings.HasPrefix(resp, "UNKNOWN") {
break
}
st = ParseStationResp(resp)
}
return stations, nil
}
@@ -0,0 +1,242 @@
package wpactrl
import (
"net"
"os"
"testing"
"time"
)
func TestParseKV(t *testing.T) {
resp := `bssid=02:00:00:00:01:00
freq=2412
ssid=TestNetwork
id=0
mode=station
pairwise_cipher=CCMP
group_cipher=CCMP
key_mgmt=WPA2-PSK
wpa_state=COMPLETED
address=02:00:00:00:00:01
`
m := ParseKV(resp)
if m["ssid"] != "TestNetwork" {
t.Errorf("ssid = %q, want TestNetwork", m["ssid"])
}
if m["wpa_state"] != "COMPLETED" {
t.Errorf("wpa_state = %q, want COMPLETED", m["wpa_state"])
}
if m["freq"] != "2412" {
t.Errorf("freq = %q, want 2412", m["freq"])
}
if m["mode"] != "station" {
t.Errorf("mode = %q, want station", m["mode"])
}
}
func TestParseKVEmpty(t *testing.T) {
m := ParseKV("")
if len(m) != 0 {
t.Errorf("expected empty map, got %v", m)
}
}
func TestParseScanResults(t *testing.T) {
resp := "bssid / frequency / signal level / flags / ssid\n" +
"02:00:00:00:01:00\t2412\t-50\t[WPA2-PSK-CCMP][ESS]\tMyNetwork\n" +
"02:00:00:00:02:00\t5180\t-70\t[WPA2-EAP-CCMP][ESS]\tOffice\n" +
"02:00:00:00:03:00\t2437\t-85\t[ESS]\t\n"
results := ParseScanResults(resp)
if len(results) != 3 {
t.Fatalf("got %d results, want 3", len(results))
}
r := results[0]
if r.BSSID != "02:00:00:00:01:00" {
t.Errorf("bssid = %q", r.BSSID)
}
if r.Frequency != 2412 {
t.Errorf("freq = %d, want 2412", r.Frequency)
}
if r.Signal != -50 {
t.Errorf("signal = %d, want -50", r.Signal)
}
if r.SSID != "MyNetwork" {
t.Errorf("ssid = %q, want MyNetwork", r.SSID)
}
if results[1].Frequency != 5180 {
t.Errorf("results[1].freq = %d, want 5180", results[1].Frequency)
}
}
func TestParseScanResultsEmpty(t *testing.T) {
results := ParseScanResults("bssid / frequency / signal level / flags / ssid\n")
if len(results) != 0 {
t.Errorf("expected empty, got %d", len(results))
}
}
func TestParseStationResp(t *testing.T) {
resp := "02:00:00:00:00:01\nflags=[AUTH][ASSOC][AUTHORIZED]\naid=1\n" +
"rx_bytes=12345\ntx_bytes=67890\nconnected_time=120\n"
m := ParseStationResp(resp)
if m["addr"] != "02:00:00:00:00:01" {
t.Errorf("addr = %q", m["addr"])
}
if m["rx_bytes"] != "12345" {
t.Errorf("rx_bytes = %q", m["rx_bytes"])
}
if m["connected_time"] != "120" {
t.Errorf("connected_time = %q", m["connected_time"])
}
}
func TestParseStationRespEmpty(t *testing.T) {
m := ParseStationResp("")
if m["addr"] != "" {
t.Errorf("expected empty addr, got %q", m["addr"])
}
}
func TestParseAllStations(t *testing.T) {
resp := "c8:69:cd:69:35:da\n" +
"flags=[AUTH][ASSOC][AUTHORIZED]\n" +
"rx_bytes=4825331939\n" +
"tx_bytes=216392802676\n" +
"signal=-57\n" +
"connected_time=1846085\n" +
"d8:3a:dd:72:8e:b1\n" +
"flags=[AUTH][ASSOC][AUTHORIZED]\n" +
"rx_bytes=237629088\n" +
"tx_bytes=190760338\n" +
"signal=-78\n" +
"connected_time=3435639\n"
stas := ParseAllStations(resp)
if len(stas) != 2 {
t.Fatalf("got %d stations, want 2", len(stas))
}
if stas[0]["addr"] != "c8:69:cd:69:35:da" {
t.Errorf("sta[0] addr = %q", stas[0]["addr"])
}
if stas[0]["signal"] != "-57" {
t.Errorf("sta[0] signal = %q", stas[0]["signal"])
}
if stas[1]["addr"] != "d8:3a:dd:72:8e:b1" {
t.Errorf("sta[1] addr = %q", stas[1]["addr"])
}
if stas[1]["rx_bytes"] != "237629088" {
t.Errorf("sta[1] rx_bytes = %q", stas[1]["rx_bytes"])
}
}
func TestParseAllStationsEmpty(t *testing.T) {
stas := ParseAllStations("")
if len(stas) != 0 {
t.Errorf("expected empty, got %d", len(stas))
}
}
func TestIsMACAddress(t *testing.T) {
if !isMACAddress("c8:69:cd:69:35:da") {
t.Error("valid MAC rejected")
}
if isMACAddress("not-a-mac") {
t.Error("invalid string accepted")
}
if isMACAddress("signal=-57") {
t.Error("key=value accepted as MAC")
}
}
func TestFrequencyToChannel(t *testing.T) {
tests := []struct {
freq int
ch int
}{
{2412, 1},
{2437, 6},
{2462, 11},
{2484, 14},
{5180, 36},
{5240, 48},
{5745, 149},
{5825, 165},
{5955, 1},
{6115, 33},
{1000, 0},
}
for _, tt := range tests {
got := FrequencyToChannel(tt.freq)
if got != tt.ch {
t.Errorf("FrequencyToChannel(%d) = %d, want %d", tt.freq, got, tt.ch)
}
}
}
func TestDialAndCommand(t *testing.T) {
dir := t.TempDir()
serverPath := dir + "/test_server"
clientDone := make(chan struct{})
serverAddr := &net.UnixAddr{Name: serverPath, Net: "unixgram"}
server, err := net.ListenUnixgram("unixgram", serverAddr)
if err != nil {
t.Fatalf("listen: %v", err)
}
defer server.Close()
go func() {
defer close(clientDone)
buf := make([]byte, 4096)
n, raddr, err := server.ReadFromUnix(buf)
if err != nil {
t.Errorf("server read: %v", err)
return
}
cmd := string(buf[:n])
var resp string
switch cmd {
case "PING":
resp = "PONG\n"
case "STATUS":
resp = "wpa_state=COMPLETED\nssid=Test\n"
default:
resp = "UNKNOWN COMMAND\n"
}
server.WriteToUnix([]byte(resp), raddr)
n, raddr, err = server.ReadFromUnix(buf)
if err != nil {
t.Errorf("server read 2: %v", err)
return
}
if string(buf[:n]) == "STATUS" {
server.WriteToUnix([]byte("wpa_state=COMPLETED\nssid=Test\n"), raddr)
}
}()
conn, err := DialTimeout(serverPath, 2*time.Second)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
if !conn.Ping() {
t.Error("Ping failed")
}
status, err := conn.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if status["ssid"] != "Test" {
t.Errorf("ssid = %q, want Test", status["ssid"])
}
<-clientDone
os.Remove(conn.local)
}