diff --git a/src/yangerd/internal/collector/hardware.go b/src/yangerd/internal/collector/hardware.go index 61ab62d5..48ed695d 100644 --- a/src/yangerd/internal/collector/hardware.go +++ b/src/yangerd/internal/collector/hardware.go @@ -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, } diff --git a/src/yangerd/internal/iwmonitor/data.go b/src/yangerd/internal/iwmonitor/data.go new file mode 100644 index 00000000..47709a91 --- /dev/null +++ b/src/yangerd/internal/iwmonitor/data.go @@ -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=". +// hostapd STATUS has "bss[N]=" / "ssid[N]=" 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 "" +} diff --git a/src/yangerd/internal/iwmonitor/iwmonitor.go b/src/yangerd/internal/iwmonitor/iwmonitor.go index e92c41a0..54a972af 100644 --- a/src/yangerd/internal/iwmonitor/iwmonitor.go +++ b/src/yangerd/internal/iwmonitor/iwmonitor.go @@ -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 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 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 } diff --git a/src/yangerd/internal/nl80211/nl80211.go b/src/yangerd/internal/nl80211/nl80211.go new file mode 100644 index 00000000..18ca885b --- /dev/null +++ b/src/yangerd/internal/nl80211/nl80211.go @@ -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" + } +} diff --git a/src/yangerd/internal/wpactrl/attach.go b/src/yangerd/internal/wpactrl/attach.go new file mode 100644 index 00000000..532235a3 --- /dev/null +++ b/src/yangerd/internal/wpactrl/attach.go @@ -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: +// 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 != "" +} diff --git a/src/yangerd/internal/wpactrl/attach_test.go b/src/yangerd/internal/wpactrl/attach_test.go new file mode 100644 index 00000000..9bed7412 --- /dev/null +++ b/src/yangerd/internal/wpactrl/attach_test.go @@ -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) + } +} diff --git a/src/yangerd/internal/wpactrl/parse.go b/src/yangerd/internal/wpactrl/parse.go new file mode 100644 index 00000000..31bad3cb --- /dev/null +++ b/src/yangerd/internal/wpactrl/parse.go @@ -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 +} diff --git a/src/yangerd/internal/wpactrl/wpactrl.go b/src/yangerd/internal/wpactrl/wpactrl.go new file mode 100644 index 00000000..866b7ded --- /dev/null +++ b/src/yangerd/internal/wpactrl/wpactrl.go @@ -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/ +// hostapd listens at /var/run/hostapd/ +// +// 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 +} diff --git a/src/yangerd/internal/wpactrl/wpactrl_test.go b/src/yangerd/internal/wpactrl/wpactrl_test.go new file mode 100644 index 00000000..ea437993 --- /dev/null +++ b/src/yangerd/internal/wpactrl/wpactrl_test.go @@ -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) +}