diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..791fb16 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,26 @@ +Copyright (c) 2017 Dave Pifke. + +Redistribution and use in source and binary forms, with or without +modification, is permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4130514 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# pifke.org/wpasupplicant + +[![GoDoc](https://godoc.org/pifke.org/wpasupplicant?status.svg)](https://godoc.org/pifke.org/wpasupplicant) +[![Build Status](https://api.travis-ci.org/dpifke/golang-wpasupplicant.svg)](https://travis-ci.org/dpifke/golang-wpasupplicant) +[![Test Coverage](https://coveralls.io/repos/github/dpifke/golang-wpasupplicant/badge.svg)](https://coveralls.io/github/dpifke/golang-wpasupplicant) + +Golang interface for talking to wpa_supplicant. + +At the moment, this simply provides an interface for fetching wifi scan +results. More functionality (probably) coming soon. + +## Example + +``` +import ( + "fmt" + + "pifke.org/wpasupplicant" +) + +// Prints the BSSID (MAC address) and SSID of each access point in range: +w := wpasupplicant.Unixgram("wlan0") +for _, bss := range w.ScanResults() { + fmt.Fprintf("%s\t%s\n", bss.BSSID(), bss.SSID()) +} +``` + +## Downloading + +If you use this library in your own code, please use the canonical URL in your +Go code, instead of Github: + +``` +go get pifke.org/wpasupplicant +``` + +Or (until I finish setting up the self-hosted repository): + +``` +# From the root of your project: +git submodule add https://github.com/dpifke/golang-wpasupplicant vendor/pifke.org/wpasupplicant +``` + +Then: + +``` +import ( + "pifke.org/wpasupplicant" +) +``` + +As opposed to the pifke.org URL, I make no guarantee this Github repository +will exist or be up-to-date in the future. + +## Documentation + +Available on [godoc.org](https://godoc.org/pifke.org/wpasupplicant). + +## License + +Three-clause BSD. See LICENSE.txt. + +Contact me if you want to use this code under different terms. + +## Author + +Dave Pifke. My email address is my first name "at" my last name "dot org." + +I'm [@dpifke](https://twitter.com/dpifke) on Twitter. My PGP key +is available on [Keybase](https://keybase.io/dpifke). diff --git a/unixgram.go b/unixgram.go index 14074c1..e42e23c 100644 --- a/unixgram.go +++ b/unixgram.go @@ -1,10 +1,38 @@ +// Copyright (c) 2017 Dave Pifke. +// +// Redistribution and use in source and binary forms, with or without +// modification, is permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + package wpasupplicant import ( "bufio" "bytes" - "errors" "fmt" + "io" "io/ioutil" "net" "os" @@ -14,18 +42,30 @@ import ( "syscall" ) +// message is a queued response (or read error) from the wpa_supplicant +// daemon. Messages may be either solicited or unsolicited. type message struct { priority int data []byte err error } +// unixgramConn is the implementation of Conn for the AF_UNIX SOCK_DGRAM +// control interface. +// +// See https://w1.fi/wpa_supplicant/devel/ctrl_iface_page.html. type unixgramConn struct { c *net.UnixConn fd uintptr solicited, unsolicited chan message } +// socketPath is where to find the the AF_UNIX sockets for each interface. It +// can be overridden for testing. +var socketPath = "/run/wpa_supplicant" + +// Unixgram returns a connection to wpa_supplicant for the specified +// interface, using the socket-based control interface. func Unixgram(ifName string) (Conn, error) { var err error uc := &unixgramConn{} @@ -38,7 +78,7 @@ func Unixgram(ifName string) (Conn, error) { uc.c, err = net.DialUnix("unixgram", &net.UnixAddr{Name: local.Name(), Net: "unixgram"}, - &net.UnixAddr{Name: path.Join("/run/wpa_supplicant", ifName), Net: "unixgram"}) + &net.UnixAddr{Name: path.Join(socketPath, ifName), Net: "unixgram"}) if err != nil { return nil, err } @@ -54,13 +94,32 @@ func Unixgram(ifName string) (Conn, error) { go uc.readLoop() + // TODO: issue an ACCEPT command so as to receive unsolicited + // messages. (We don't do this yet, since we don't yet have any way + // to consume them.) + return uc, nil } +// readLoop is spawned after we connect. It receives messages from the +// socket, and routes them to the appropriate channel based on whether they +// are solicited (in response to a request) or unsolicited. func (uc *unixgramConn) readLoop() { for { + // The syscall below will block until a datagram is received. + // It uses a zero-length buffer to look at the datagram + // without discarding it (MSG_PEEK), returning the actual + // datagram size (MSG_TRUNC). See the recvfrom(2) man page. + // + // The actual read occurs using UnixConn.Read(), once we've + // allocated an appropriately-sized buffer. n, _, err := syscall.Recvfrom(int(uc.fd), []byte{}, syscall.MSG_PEEK|syscall.MSG_TRUNC) if err != nil { + // Treat read errors as a response to whatever command + // was last issued. + uc.solicited <- message{ + err: err, + } continue } @@ -70,8 +129,13 @@ func (uc *unixgramConn) readLoop() { uc.solicited <- message{ err: err, } + continue } + // Unsolicited messages are preceded by a priority + // specification, e.g. "<1>message". If there's no priority, + // default to 2 (info) and assume it's the response to + // whatever command was last issued. var p int var c chan message if len(buf) >= 3 && buf[0] == '<' && buf[2] == '>' { @@ -96,6 +160,7 @@ func (uc *unixgramConn) readLoop() { } } +// cmd executes a command and waits for a reply. func (uc *unixgramConn) cmd(cmd string) ([]byte, error) { // TODO: block if any other commands are running @@ -108,6 +173,32 @@ func (uc *unixgramConn) cmd(cmd string) ([]byte, error) { return msg.data, msg.err } +// ParseError is returned when we can't parse the wpa_supplicant response. +// Some functions may return multiple ParseErrors. +type ParseError struct { + // Line is the line of output from wpa_supplicant which we couldn't + // parse. + Line string + + // Err is any nested error. + Err error +} + +func (err *ParseError) Error() string { + b := &bytes.Buffer{} + b.WriteString("failed to parse wpa_supplicant response") + + if err.Line != "" { + fmt.Fprintf(b, ": %q", err.Line) + } + + if err.Err != nil { + fmt.Fprintf(b, ": %s", err.Err.Error()) + } + + return b.String() +} + func (uc *unixgramConn) Ping() error { resp, err := uc.cmd("PING") if err != nil { @@ -117,20 +208,30 @@ func (uc *unixgramConn) Ping() error { if bytes.Compare(resp, []byte("PONG\n")) == 0 { return nil } - return fmt.Errorf("expected %q, got %q", "PONG", resp) + return &ParseError{Line: string(resp)} } -func (uc *unixgramConn) ScanResults() ([]ScanResult, error) { +func (uc *unixgramConn) ScanResults() ([]ScanResult, []error) { resp, err := uc.cmd("SCAN_RESULTS") if err != nil { - return nil, err + return nil, []error{err} } - s := bufio.NewScanner(bytes.NewBuffer(resp)) + return parseScanResults(bytes.NewBuffer(resp)) +} + +// parseScanResults parses the SCAN_RESULTS output from wpa_supplicant. This +// is split out from ScanResults() to make testing easier. +func parseScanResults(resp io.Reader) (res []ScanResult, errs []error) { + // In an attempt to make our parser more resilient, we start by + // parsing the header line and using that to determine the column + // order. + s := bufio.NewScanner(resp) if !s.Scan() { - return nil, errors.New("failed to parse scan results") + errs = append(errs, &ParseError{}) + return } - var bssidCol, freqCol, rssiCol, flagsCol, ssidCol, maxCol int + bssidCol, freqCol, rssiCol, flagsCol, ssidCol, maxCol := -1, -1, -1, -1, -1, -1 for n, col := range strings.Split(s.Text(), " / ") { switch col { case "bssid": @@ -147,31 +248,49 @@ func (uc *unixgramConn) ScanResults() ([]ScanResult, error) { maxCol = n } - var res []ScanResult + var err error for s.Scan() { - fields := strings.Split(s.Text(), "\t") + ln := s.Text() + fields := strings.Split(ln, "\t") if len(fields) < maxCol { - continue // TODO: log error + errs = append(errs, &ParseError{Line: ln}) + continue } - bssid, err := net.ParseMAC(fields[bssidCol]) - if err != nil { - continue // TODO: log error + var bssid net.HardwareAddr + if bssidCol != -1 { + if bssid, err = net.ParseMAC(fields[bssidCol]); err != nil { + errs = append(errs, &ParseError{Line: ln, Err: err}) + continue + } } - freq, err := strconv.Atoi(fields[freqCol]) - if err != nil { - continue // TODO: log error + var freq int + if freqCol != -1 { + if freq, err = strconv.Atoi(fields[freqCol]); err != nil { + errs = append(errs, &ParseError{Line: ln, Err: err}) + continue + } } - rssi, err := strconv.Atoi(fields[rssiCol]) - if err != nil { - continue // TODO: log error + var rssi int + if rssiCol != -1 { + if rssi, err = strconv.Atoi(fields[rssiCol]); err != nil { + errs = append(errs, &ParseError{Line: ln, Err: err}) + continue + } } var flags []string - if len(fields[flagsCol]) >= 2 && fields[flagsCol][0] != '[' && fields[flagsCol][len(fields[flagsCol])-1] != ']' { - flags = strings.Split(fields[flagsCol][1:len(fields[flagsCol])-2], "][") + if flagsCol != -1 { + if len(fields[flagsCol]) >= 2 && fields[flagsCol][0] == '[' && fields[flagsCol][len(fields[flagsCol])-1] == ']' { + flags = strings.Split(fields[flagsCol][1:len(fields[flagsCol])-1], "][") + } + } + + var ssid string + if ssidCol != -1 { + ssid = fields[ssidCol] } res = append(res, &scanResult{ @@ -179,9 +298,9 @@ func (uc *unixgramConn) ScanResults() ([]ScanResult, error) { frequency: freq, rssi: rssi, flags: flags, - ssid: fields[ssidCol], + ssid: ssid, }) } - return res, nil + return } diff --git a/unixgram_test.go b/unixgram_test.go new file mode 100644 index 0000000..431bc4c --- /dev/null +++ b/unixgram_test.go @@ -0,0 +1,133 @@ +// Copyright (c) 2017 Dave Pifke. +// +// Redistribution and use in source and binary forms, with or without +// modification, is permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +package wpasupplicant + +import ( + "bytes" + "net" + "testing" +) + +var parseScanResultTests = []struct { + input string + expect []*scanResult +}{ + { + // actual output from wpa_supplicant 2.4-0ubuntu6 + input: "bssid / frequency / signal level / flags / ssid\n" + + "8a:15:14:8a:46:51\t5560\t-58\t[WPA-PSK-CCMP+TKIP][WPA2-PSK-CCMP+TKIP][ESS]\tWIP-Backoffice\n" + + "8a:15:14:8a:46:50\t5560\t-58\t[WPA-PSK-CCMP+TKIP][WPA2-PSK-CCMP+TKIP][ESS]\tWorkInProgressMember\n", + expect: []*scanResult{ + &scanResult{ + bssid: net.HardwareAddr{0x8a, 0x15, 0x14, 0x8a, 0x46, 0x51}, + frequency: 5560, + rssi: -58, + flags: []string{"WPA-PSK-CCMP+TKIP", "WPA2-PSK-CCMP+TKIP", "ESS"}, + ssid: "WIP-Backoffice", + }, + &scanResult{ + bssid: net.HardwareAddr{0x8a, 0x15, 0x14, 0x8a, 0x46, 0x50}, + frequency: 5560, + rssi: -58, + flags: []string{"WPA-PSK-CCMP+TKIP", "WPA2-PSK-CCMP+TKIP", "ESS"}, + ssid: "WorkInProgressMember", + }, + }, + }, { + // reordered/added/missing columns from some theoretical + // future version of wpa_supplicant + input: "frequency / bssid / foobar / ssid\n" + + "5560\t8a:15:14:8a:46:51\thello\tWIP-Backoffice\n" + + "5560\t8a:15:14:8a:46:50\tgoodbye\tWorkInProgressMember\n", + expect: []*scanResult{ + &scanResult{ + bssid: net.HardwareAddr{0x8a, 0x15, 0x14, 0x8a, 0x46, 0x51}, + frequency: 5560, + ssid: "WIP-Backoffice", + }, + &scanResult{ + bssid: net.HardwareAddr{0x8a, 0x15, 0x14, 0x8a, 0x46, 0x50}, + frequency: 5560, + ssid: "WorkInProgressMember", + }, + }, + }, +} + +func TestParseScanResults(t *testing.T) { + for _, test := range parseScanResultTests { + output, errs := parseScanResults(bytes.NewBufferString(test.input)) + if len(errs) > 0 { + t.Error("errors parsing scan results") + } + + if len(output) != len(test.expect) { + t.Errorf("wrong number of results (got %d, expect %d)", len(output), len(test.expect)) + } + + for i := range output { + if test.expect[i].bssid != nil { + if bytes.Compare(output[i].BSSID(), test.expect[i].bssid) != 0 { + t.Errorf("wrong bssid (got %q, expect %q)", output[i].BSSID(), test.expect[i].bssid) + } + } + if test.expect[i].frequency != 0 { + if output[i].Frequency() != test.expect[i].frequency { + t.Errorf("wrong frequency (got %d, expect %d)", output[i].Frequency(), test.expect[i].frequency) + } + } + if test.expect[i].rssi != 0 { + if output[i].RSSI() != test.expect[i].rssi { + t.Errorf("wrong rssi (got %d, expect %d)", output[i].RSSI(), test.expect[i].rssi) + } + } + if test.expect[i].ssid != "" { + if output[i].SSID() != test.expect[i].ssid { + t.Errorf("wrong rssi (got %s, expect %s)", output[i].SSID(), test.expect[i].ssid) + } + } + + flags := output[i].Flags() + flagsMatch := true + if len(test.expect[i].flags) != len(flags) { + flagsMatch = false + } else { + for j := range test.expect[i].flags { + if flags[j] != test.expect[i].flags[j] { + flagsMatch = false + break + } + } + } + if !flagsMatch { + t.Errorf("got flags %q, expected %q", flags, test.expect[i].flags) + } + } + } +} diff --git a/wpasupplicant.go b/wpasupplicant.go index 9b8ae20..5037268 100644 --- a/wpasupplicant.go +++ b/wpasupplicant.go @@ -1,9 +1,43 @@ +// Copyright (c) 2017 Dave Pifke. +// +// Redistribution and use in source and binary forms, with or without +// modification, is permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +// Package wpasupplicant provides an interface for talking to the +// wpa_supplicant daemon. +// +// At the moment, this simply provides an interface for fetching wifi scan +// results. More functionality is (probably) coming soon. package wpasupplicant import ( "net" ) +// Cipher is one of the WPA_CIPHER constants from the wpa_supplicant source. type Cipher int const ( @@ -24,6 +58,8 @@ const ( GTK_NOT_USED ) +// KeyMgmt is one of the WPA_KEY_MGMT constants from the wpa_supplicant +// source. type KeyMgmt int const ( @@ -49,14 +85,27 @@ const ( type Algorithm int +// ScanResult is a scanned BSS. type ScanResult interface { + // BSSID is the MAC address of the BSS. BSSID() net.HardwareAddr + + // SSID is the SSID of the BSS. SSID() string + + // Frequency is the frequency, in Mhz, of the BSS. Frequency() int + + // RSSI is the received signal strength, in dB, of the BSS. RSSI() int + + // Flags is an array of flags, in string format, returned by the + // wpa_supplicant SCAN_RESULTS command. Future versions of this code + // will parse these into something more meaningful. Flags() []string } +// scanResult is a package-private implementation of ScanResult. type scanResult struct { bssid net.HardwareAddr ssid string @@ -71,8 +120,15 @@ func (r *scanResult) Frequency() int { return r.frequency } func (r *scanResult) RSSI() int { return r.rssi } func (r *scanResult) Flags() []string { return r.flags } +// Conn is a connection to wpa_supplicant over one of its communication +// channels. type Conn interface { + // Ping tests the connection. It returns nil if wpa_supplicant is + // responding. Ping() error - ScanResults() ([]ScanResult, error) + // ScanResult returns the latest scanning results. It returns a slice + // of scanned BSSs, and/or a slice of errors representing problems + // communicating with wpa_supplicant or parsing its output. + ScanResults() ([]ScanResult, []error) }