Skip to content

Commit 933b3cc

Browse files
initsujJustin Slauson
andauthored
Add Access Point scanning (#117)
* Add access point scanning and associated tests --------- Co-authored-by: Justin Slauson <justin.slauson@outlook.com>
1 parent ac6721d commit 933b3cc

File tree

6 files changed

+281
-4
lines changed

6 files changed

+281
-4
lines changed

client.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package wifi
22

33
import (
4+
"context"
45
"time"
56
)
67

@@ -52,6 +53,18 @@ func (c *Client) BSS(ifi *Interface) (*BSS, error) {
5253
return c.c.BSS(ifi)
5354
}
5455

56+
// AccessPoints retrieves the currently known BSS around the specified Interface.
57+
func (c *Client) AccessPoints(ifi *Interface) ([]*BSS, error) {
58+
return c.c.AccessPoints(ifi)
59+
}
60+
61+
// Scan requests the wifi interface to scan for new access points.
62+
//
63+
// Use context.WithDeadline to set a timeout.
64+
func (c *Client) Scan(ctx context.Context, ifi *Interface) error {
65+
return c.c.Scan(ctx, ifi)
66+
}
67+
5568
// StationInfo retrieves all station statistics about a WiFi interface.
5669
//
5770
// Since v0.2.0: if there are no stations, an empty slice is returned instead

client_linux.go

Lines changed: 215 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ package wifi
55

66
import (
77
"bytes"
8+
"context"
89
"crypto/sha1"
910
"encoding/binary"
1011
"errors"
1112
"net"
1213
"os"
14+
"sync"
1315
"time"
1416
"unicode/utf8"
1517

@@ -20,8 +22,12 @@ import (
2022
"golang.org/x/sys/unix"
2123
)
2224

23-
// errNotSupported is returned when an operation is not supported
24-
var ErrNotSupported = errors.New("not supported")
25+
var (
26+
ErrNotSupported = errors.New("not supported")
27+
ErrScanGroupNotFound = errors.New("scan multicast group unavailable")
28+
ErrScanAborted = errors.New("scan aborted by the kernel")
29+
ErrScanValidation = errors.New("scan validation failed")
30+
)
2531

2632
// A client is the Linux implementation of osClient, which makes use of
2733
// netlink, generic netlink, and nl80211 to provide access to WiFi device
@@ -30,6 +36,9 @@ type client struct {
3036
c *genetlink.Conn
3137
familyID uint16
3238
familyVersion uint8
39+
40+
// scan is used to synchronize access to the Scan method.
41+
scan sync.Mutex
3342
}
3443

3544
// newClient dials a generic netlink connection and verifies that nl80211
@@ -67,6 +76,8 @@ func initClient(c *genetlink.Conn) (*client, error) {
6776
c: c,
6877
familyID: family.ID,
6978
familyVersion: family.Version,
79+
80+
scan: sync.Mutex{},
7081
}, nil
7182
}
7283

@@ -180,6 +191,21 @@ func (c *client) BSS(ifi *Interface) (*BSS, error) {
180191
return parseBSS(msgs)
181192
}
182193

194+
// AccessPoints requests that nl80211 return all currently known BSS
195+
// from the specified Interface.
196+
func (c *client) AccessPoints(ifi *Interface) ([]*BSS, error) {
197+
msgs, err := c.get(
198+
unix.NL80211_CMD_GET_SCAN,
199+
netlink.Dump,
200+
ifi,
201+
nil,
202+
)
203+
if err != nil {
204+
return nil, err
205+
}
206+
return parseGetScanResult(msgs)
207+
}
208+
183209
// StationInfo requests that nl80211 return all station info for the specified
184210
// Interface.
185211
func (c *client) StationInfo(ifi *Interface) ([]*StationInfo, error) {
@@ -233,6 +259,105 @@ func (c *client) SurveyInfo(ifi *Interface) ([]*SurveyInfo, error) {
233259
return surveys, nil
234260
}
235261

262+
// Scan requests that nl80211 perform a scan for new access points using
263+
// the specified Interface. This process is long running and uses
264+
// a separate connection to nl80211.
265+
//
266+
// Use context.WithDeadline to set a timeout.
267+
//
268+
// If a scan is already in progress, this function will return a syscall.EBUSY
269+
// error. If the response cannot be validated, the returned error
270+
// will include ErrScanValidation.
271+
//
272+
// Use func AccessPoints to retrieve the results.
273+
func (c *client) Scan(ctx context.Context, ifi *Interface) error {
274+
c.scan.Lock()
275+
defer c.scan.Unlock()
276+
277+
// use secondary connection for multicast receives
278+
conn, err := genetlink.Dial(&netlink.Config{Strict: true})
279+
if err != nil {
280+
return err
281+
}
282+
283+
defer conn.Close()
284+
285+
if deadline, ok := ctx.Deadline(); ok {
286+
err := conn.SetDeadline(deadline)
287+
if err != nil {
288+
return err
289+
}
290+
}
291+
292+
family, err := conn.GetFamily(unix.NL80211_GENL_NAME)
293+
if err != nil {
294+
return err
295+
}
296+
297+
var id uint32
298+
for _, group := range family.Groups {
299+
if group.Name == unix.NL80211_MULTICAST_GROUP_SCAN {
300+
err = conn.JoinGroup(group.ID)
301+
if err != nil {
302+
return err
303+
}
304+
305+
id = group.ID
306+
break
307+
}
308+
}
309+
310+
if id == 0 {
311+
return ErrScanGroupNotFound
312+
}
313+
314+
// Leave group on exit. Err is non-actionable
315+
defer func() { _ = conn.LeaveGroup(id) }()
316+
317+
enc := netlink.NewAttributeEncoder()
318+
enc.Nested(unix.NL80211_ATTR_SCAN_SSIDS, func(ae *netlink.AttributeEncoder) error {
319+
ae.Bytes(unix.NL80211_SCHED_SCAN_MATCH_ATTR_SSID, nlenc.Bytes(""))
320+
return nil
321+
})
322+
323+
ifi.encode(enc)
324+
325+
data, err := enc.Encode()
326+
if err != nil {
327+
return err
328+
}
329+
330+
req := genetlink.Message{
331+
Header: genetlink.Header{
332+
Command: unix.NL80211_CMD_TRIGGER_SCAN,
333+
Version: c.familyVersion,
334+
},
335+
Data: data,
336+
}
337+
338+
ctx, cancel := context.WithCancel(ctx)
339+
defer cancel()
340+
341+
result := make(chan error, 1)
342+
go func(ctx context.Context, conn *genetlink.Conn, ifiIndex int, familyVersion uint8, result chan<- error) {
343+
344+
defer close(result)
345+
result <- listenNewScanResults(ctx, conn, ifiIndex, familyVersion)
346+
347+
}(ctx, conn, ifi.Index, c.familyVersion, result)
348+
349+
flags := netlink.Request | netlink.Acknowledge
350+
351+
_, err = conn.Send(req, family.ID, flags)
352+
if err != nil {
353+
cancel()
354+
}
355+
356+
err2 := <-result
357+
358+
return errors.Join(err, err2)
359+
}
360+
236361
// SetDeadline sets the read and write deadlines associated with the connection.
237362
func (c *client) SetDeadline(t time.Time) error {
238363
return c.c.SetDeadline(t)
@@ -295,6 +420,94 @@ func (c *client) execute(
295420
)
296421
}
297422

423+
// listenNewScanResults listens for new scan results or scan abort messages
424+
// from the netlink connection. It processes the messages associated with the
425+
// specified interface index and family version, verifying attributes and
426+
// handling context cancellations.
427+
//
428+
// The caller should not receive on the given connection and is responsible
429+
// for closing it.
430+
func listenNewScanResults(ctx context.Context, conn *genetlink.Conn, ifiIndex int, familyVersion uint8) error {
431+
for ctx.Err() == nil {
432+
msgs, _, err := conn.Receive()
433+
if err != nil {
434+
return err
435+
}
436+
437+
// test for context cancellation and abandon work if so
438+
if ctx.Err() != nil {
439+
return err
440+
}
441+
442+
for _, msg := range msgs {
443+
if msg.Header.Version != familyVersion {
444+
break
445+
}
446+
447+
switch msg.Header.Command {
448+
case unix.NL80211_CMD_SCAN_ABORTED:
449+
return ErrScanAborted
450+
case unix.NL80211_CMD_NEW_SCAN_RESULTS:
451+
// attempt to verify the interface
452+
attrs, err := netlink.UnmarshalAttributes(msg.Data)
453+
if err != nil {
454+
return errors.Join(ErrScanValidation, err)
455+
}
456+
457+
var intf Interface
458+
if err := (&intf).parseAttributes(attrs); err != nil {
459+
return errors.Join(ErrScanValidation, err)
460+
}
461+
462+
if ifiIndex != intf.Index {
463+
continue
464+
}
465+
466+
return nil
467+
default:
468+
continue
469+
}
470+
471+
}
472+
}
473+
474+
return ctx.Err()
475+
}
476+
477+
// parseGetScanResult parses all the BSS from nl80211 CMD_GET_SCAN response messages.
478+
func parseGetScanResult(msgs []genetlink.Message) ([]*BSS, error) {
479+
// reimplementing https://github.com/mdlayher/wifi/pull/79
480+
bsss := make([]*BSS, 0, len(msgs))
481+
for _, m := range msgs {
482+
attrs, err := netlink.UnmarshalAttributes(m.Data)
483+
if err != nil {
484+
return nil, err
485+
}
486+
487+
var bss BSS
488+
for _, a := range attrs {
489+
if a.Type != unix.NL80211_ATTR_BSS {
490+
continue
491+
}
492+
493+
nattrs, err := netlink.UnmarshalAttributes(a.Data)
494+
if err != nil {
495+
return nil, err
496+
}
497+
498+
if !attrsContain(nattrs, unix.NL80211_BSS_STATUS) {
499+
bss.Status = BSSStatusNotAssociated
500+
}
501+
502+
if err := (&bss).parseAttributes(nattrs); err != nil {
503+
continue
504+
}
505+
}
506+
bsss = append(bsss, &bss)
507+
}
508+
return bsss, nil
509+
}
510+
298511
// parseInterfaces parses zero or more Interfaces from nl80211 interface
299512
// messages.
300513
func parseInterfaces(msgs []genetlink.Message) ([]*Interface, error) {

client_linux_integration_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
package wifi_test
55

66
import (
7+
"context"
78
"errors"
89
"fmt"
910
"os"
1011
"sync"
1112
"testing"
13+
"time"
1214

1315
"github.com/mdlayher/wifi"
1416
)
@@ -78,6 +80,7 @@ func execN(t *testing.T, n int, expect []string, workerID int) {
7880
panicf("[worker_id %d; iteration %d] failed to retrieve survey info for device %s: %v", workerID, i, ifi.Name, err)
7981
}
8082
}
83+
8184
names[ifi.Name]++
8285
}
8386
}
@@ -112,3 +115,39 @@ func testClient(t *testing.T) *wifi.Client {
112115
func panicf(format string, a ...interface{}) {
113116
panic(fmt.Sprintf(format, a...))
114117
}
118+
119+
func TestClient_AccessPoints(t *testing.T) {
120+
if os.Geteuid() != 0 {
121+
t.Skipf("skipping, must be run as root")
122+
}
123+
124+
c, err := wifi.New()
125+
if err != nil {
126+
t.Fatalf("failed to create client: %v", err)
127+
}
128+
129+
ifis, err := c.Interfaces()
130+
if err != nil {
131+
t.Fatalf("failed to retrieve interfaces: %v", err)
132+
}
133+
134+
for _, ifi := range ifis {
135+
if ifi.Name == "" || ifi.Type != wifi.InterfaceTypeStation {
136+
continue
137+
}
138+
139+
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(30*time.Second))
140+
defer cancel()
141+
142+
err = c.Scan(ctx, ifi)
143+
if err != nil {
144+
t.Fatalf("failed to scan access points for device %s: %v", ifi.Name, err)
145+
}
146+
147+
_, err := c.AccessPoints(ifi)
148+
if err != nil {
149+
t.Fatalf("failed to retrieve access points for device %s: %v", ifi.Name, err)
150+
}
151+
152+
}
153+
}

client_others.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package wifi
55

66
import (
7+
"context"
78
"fmt"
89
"runtime"
910
"time"
@@ -21,8 +22,10 @@ func newClient() (*client, error) { return nil, errUnimplemented }
2122
func (*client) Close() error { return errUnimplemented }
2223
func (*client) Interfaces() ([]*Interface, error) { return nil, errUnimplemented }
2324
func (*client) BSS(_ *Interface) (*BSS, error) { return nil, errUnimplemented }
25+
func (client) AccessPoints(ifi *Interface) ([]*BSS, error) { return nil, errUnimplemented }
2426
func (*client) StationInfo(_ *Interface) ([]*StationInfo, error) { return nil, errUnimplemented }
2527
func (*client) SurveyInfo(_ *Interface) ([]*SurveyInfo, error) { return nil, errUnimplemented }
28+
func (*client) Scan(ctx context.Context, ifi *Interface) error { return errUnimplemented }
2629
func (*client) Connect(_ *Interface, _ string) error { return errUnimplemented }
2730
func (*client) Disconnect(_ *Interface) error { return errUnimplemented }
2831
func (*client) ConnectWPAPSK(_ *Interface, _, _ string) error { return errUnimplemented }

wifi.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,9 @@ const (
239239
// BSSStatusAssociated indicates that a client is associated with a BSS.
240240
BSSStatusAssociated
241241

242+
// BSSStatusNotAssociated indicates that a client is not associated with a BSS.
243+
BSSStatusNotAssociated
244+
242245
// BSSStatusIBSSJoined indicates that a client has joined an independent BSS.
243246
BSSStatusIBSSJoined
244247
)
@@ -250,6 +253,8 @@ func (s BSSStatus) String() string {
250253
return "authenticated"
251254
case BSSStatusAssociated:
252255
return "associated"
256+
case BSSStatusNotAssociated:
257+
return "unassociated"
253258
case BSSStatusIBSSJoined:
254259
return "IBSS joined"
255260
default:

0 commit comments

Comments
 (0)