mirror of
https://github.com/kernelkit/infix.git
synced 2026-08-01 21:33:02 +02:00
yangerd: Add WiFi implementation
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user