Class: WifiWand::MacOsModel

Inherits:
BaseModel show all
Defined in:
lib/wifi-wand/models/mac_os_model.rb

Constant Summary collapse

AIRPORT_CMD =
'/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport'

Instance Attribute Summary

Attributes inherited from BaseModel

#verbose_mode, #wifi_port

Instance Method Summary collapse

Methods inherited from BaseModel

#connect, #connected_to?, #connected_to_internet?, #cycle_network, #preferred_network_password, #public_ip_address_info, #random_mac_address, #remove_preferred_networks, #run_os_command, #till, #try_os_command_until

Constructor Details

#initialize(options = OpenStruct.new) ⇒ MacOsModel

Takes an OpenStruct containing options such as verbose mode and port name.



16
17
18
# File 'lib/wifi-wand/models/mac_os_model.rb', line 16

def initialize(options = OpenStruct.new)
  super
end

Instance Method Details

#airport_commandObject

Although at this time the airport command utility is predictable, allow putting it elsewhere in the path for overriding and easier fix if that location should change.



24
25
26
27
28
29
30
31
32
33
# File 'lib/wifi-wand/models/mac_os_model.rb', line 24

def airport_command
  airport_in_path = `which airport`.chomp
  if ! airport_in_path.empty?
    airport_in_path
  elsif File.exist?(AIRPORT_CMD)
    AIRPORT_CMD
  else
    raise Error.new("Airport command not found.")
  end
end

#available_network_infoObject

Returns data pertaining to available wireless networks. For some reason, this often returns no results, so I’ve put the operation in a loop. I was unable to detect a sort strategy in the airport utility’s output, so I sort the lines alphabetically, to show duplicates and for easier lookup.

Sample Output:

> [“SSID BSSID RSSI CHANNEL HT CC SECURITY (auth/unicast/group)”,

"ByCO-U00tRzUzMEg                 64:6c:b2:db:f3:0c -56  6       Y  -- NONE",
"Chancery                         0a:18:d6:0b:b9:c3 -82  11      Y  -- NONE",
"Chancery                         2a:a4:3c:03:33:99 -59  60,+1   Y  -- NONE",
"DIRECT-sq-BRAVIA                 02:71:cc:87:4a:8c -76  6       Y  -- WPA2(PSK/AES/AES) ",  #


74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/wifi-wand/models/mac_os_model.rb', line 74

def available_network_info
  return nil unless wifi_on? # no need to try
  command = "#{airport_command} -s"
  max_attempts = 50


  reformat_line = ->(line) do
    ssid = line[0..31].strip
    "%-32.32s%s" % [ssid, line[32..-1]]
  end


  process_tabular_data = ->(output) do
    lines = output.split("\n")
    header_line = lines[0]
    data_lines = lines[1..-1]
    data_lines.map! do |line|
      # Reformat the line so that the name is left instead of right justified
      reformat_line.(line)
    end
    # TODO: Need to sort case insensitively?:
    data_lines.sort!
    [reformat_line.(header_line)] + data_lines
  end


  output = try_os_command_until(command, ->(output) do
    ! ([nil, ''].include?(output))
  end)

  if output
    process_tabular_data.(output)
  else
    raise Error.new("Unable to get available network information after #{max_attempts} attempts.")
  end
end

#available_network_namesObject

The Mac OS airport utility (at /System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport) outputs the network names right padded with spaces so there is no way to differentiate a network name with leading space(s) from one without:

               SSID BSSID             RSSI CHANNEL HT CC SECURITY (auth/unicast/group)
ngHub_319442NL0293C 04:a1:51:58:5b:05 -65  11      Y  US WPA2(PSK/AES/AES)
    NETGEAR89_2GEXT 9c:3d:cf:11:69:b4 -67  8       Y  US NONE

To remedy this, they offer a “-x” option that outputs the information in (pseudo) XML. This XML has ‘dict’ elements that contain many elements. The SSID can be found in the XML element <string> which immediately follows an XML element whose text is “SSID_STR”. Unfortunately, since there is no way to connect the two other than their physical location, the key is rather useless for XML parsing.

I tried extracting the arrays of keys and strings, and finding the string element at the same position in the string array as the ‘SSID_STR’ was in the keys array. However, not all keys had string elements, so the index in the key array was the wrong index. Here is an excerpt from the XML output:

<key>RSSI</key> <integer>-91</integer> <key>SSID</key> <data> TkVUR0VBUjY1 </data> <key>SSID_STR</key> <string>NETGEAR65</string>

The kludge I came up with was that the ssid was always the 2nd value in the <string> element array, so that’s what is used here.

But now even that approach has been superseded by the XPath approach now used.

REXML is used here to avoid the need for the user to install Nokogiri.



147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/wifi-wand/models/mac_os_model.rb', line 147

def available_network_names
  # For some reason, the airport command very often returns nothing, so we need to try until
  # we get data in the response:

  command = "#{airport_command} -s -x"
  stop_condition = ->(response) { ! [nil, ''].include?(response) }
  output = try_os_command_until(command, stop_condition)
  doc = REXML::Document.new(output)
  xpath = '//key[text() = "SSID_STR"][1]/following-sibling::*[1]' # provided by @ScreenStaring on Twitter
  REXML::XPath.match(doc, xpath) \
      .map(&:text) \
      .sort { |x,y| x.casecmp(y) } \
      .uniq
end

#connected_network_nameObject

Returns the network currently connected to, or nil if none.



261
262
263
264
265
# File 'lib/wifi-wand/models/mac_os_model.rb', line 261

def connected_network_name
  lines = run_os_command("#{airport_command} -I").split("\n")
  ssid_lines = lines.grep(/ SSID:/)
  ssid_lines.empty? ? nil : ssid_lines.first.split('SSID: ').last.lstrip
end

#detect_wifi_portObject

Identifies the (first) wireless network hardware port in the system, e.g. en0 or en1 This may not detect wifi ports with nonstandard names, such as USB wifi devices.



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/wifi-wand/models/mac_os_model.rb', line 38

def detect_wifi_port

  lines = run_os_command("networksetup -listallhardwareports").split("\n")
  # Produces something like this:
  # Hardware Port: Wi-Fi
  # Device: en0
  # Ethernet Address: ac:bc:32:b9:a9:9d
  #
  # Hardware Port: Bluetooth PAN
  # Device: en3
  # Ethernet Address: ac:bc:32:b9:a9:9e

  wifi_port_line_num = (0...lines.size).detect do |index|
    /: Wi-Fi$/.match(lines[index])
  end

  if wifi_port_line_num.nil?
    raise Error.new(%Q{Wifi port (e.g. "en0") not found in output of: networksetup -listallhardwareports})
  else
    lines[wifi_port_line_num + 1].split(': ').last
  end
end

#disconnectObject

Disconnects from the currently connected network. Does not turn off wifi.



269
270
271
272
# File 'lib/wifi-wand/models/mac_os_model.rb', line 269

def disconnect
  run_os_command("sudo #{airport_command} -z")
  nil
end

#ip_addressObject

Returns the IP address assigned to the wifi port, or nil if none.



240
241
242
243
244
245
246
247
248
249
250
# File 'lib/wifi-wand/models/mac_os_model.rb', line 240

def ip_address
  begin
    run_os_command("ipconfig getifaddr #{wifi_port}").chomp
  rescue OsCommandError => error
    if error.exitstatus == 1
      nil
    else
      raise
    end
  end
end

#is_wifi_port?(port) ⇒ Boolean

Returns whether or not the specified interface is a WiFi interfae.

Returns:

  • (Boolean)


179
180
181
182
183
# File 'lib/wifi-wand/models/mac_os_model.rb', line 179

def is_wifi_port?(port)
  run_os_command("networksetup -listpreferredwirelessnetworks #{port} 2>/dev/null")
  exit_status = $?.exitstatus
  exit_status != 10
end

#mac_addressObject

TODO: Add capability to change the MAC address using a command in the form of:

sudo ifconfig en0 ether aa:bb:cc:dd:ee:ff

However, the MAC address will be set to the real hardware address on restart. One way to implement this is to have an optional address argument, then this method returns the current address if none is provided, but sets to the specified address if it is.



281
282
283
# File 'lib/wifi-wand/models/mac_os_model.rb', line 281

def mac_address
  run_os_command("ifconfig #{wifi_port} | awk '/ether/{print $2}'")
end

#nameservers_using_networksetupObject



388
389
390
391
# File 'lib/wifi-wand/models/mac_os_model.rb', line 388

def nameservers_using_networksetup
  output = run_os_command("networksetup -getdnsservers Wi-Fi")
  output.split("\n")
end

#nameservers_using_resolv_confObject

Though this is strictly not OS-agnostic, it will be used by most OS’s, and can be overridden by subclasses (e.g. Windows).

Returns:

  • array of nameserver IP addresses from /etc/resolv.conf, or nil if not found



370
371
372
373
374
375
376
# File 'lib/wifi-wand/models/mac_os_model.rb', line 370

def nameservers_using_resolv_conf
  begin
    File.readlines('/etc/resolv.conf').grep(/^nameserver /).map { |line| line.split.last }
  rescue Errno::ENOENT
    nil
  end
end

#nameservers_using_scutilObject



379
380
381
382
383
384
385
# File 'lib/wifi-wand/models/mac_os_model.rb', line 379

def nameservers_using_scutil
  output = run_os_command('scutil --dns')
  nameserver_lines_scoped_and_unscoped = output.split("\n").grep(/^\s*nameserver\[/)
  unique_nameserver_lines = nameserver_lines_scoped_and_unscoped.uniq # take the union
  nameservers = unique_nameserver_lines.map { |line| line.split(' : ').last.strip }
  nameservers
end

#open_resource(resource_url) ⇒ Object



346
347
348
# File 'lib/wifi-wand/models/mac_os_model.rb', line 346

def open_resource(resource_url)
  run_os_command('open ' + resource_url)
end

#os_level_connect(network_name, password = nil) ⇒ Object

This method is called by BaseModel#connect to do the OS-specific connection logic.



210
211
212
213
214
215
216
# File 'lib/wifi-wand/models/mac_os_model.rb', line 210

def os_level_connect(network_name, password = nil)
  command = "networksetup -setairportnetwork #{wifi_port} " + "#{Shellwords.shellescape(network_name)}"
  if password
    command << ' ' << Shellwords.shellescape(password)
  end
  run_os_command(command)
end

#os_level_preferred_network_password(preferred_network_name) ⇒ Object

@return:

If the network is in the preferred networks list
  If a password is associated w/this network, return the password
  If not, return nil
else
  raise an error


225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/wifi-wand/models/mac_os_model.rb', line 225

def os_level_preferred_network_password(preferred_network_name)
  command = %Q{security find-generic-password -D "AirPort network password" -a "#{preferred_network_name}" -w 2>&1}
  begin
    return run_os_command(command).chomp
  rescue OsCommandError => error
    if error.exitstatus == 44 # network has no password stored
      nil
    else
      raise
    end
  end
end

#preferred_networksObject

Returns data pertaining to “preferred” networks, many/most of which will probably not be available.



164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/wifi-wand/models/mac_os_model.rb', line 164

def preferred_networks
  lines = run_os_command("networksetup -listpreferredwirelessnetworks #{wifi_port}").split("\n")
  # Produces something like this, unsorted, and with leading tabs:
  # Preferred networks on en0:
  #         LibraryWiFi
  #         @thePAD/Magma

  lines.delete_at(0)                         # remove title line
  lines.map! { |line| line.gsub("\t", '') }  # remove leading tabs
  lines.sort! { |s1, s2| s1.casecmp(s2) }    # sort alphabetically, case insensitively
  lines
end

#remove_preferred_network(network_name) ⇒ Object



253
254
255
256
257
# File 'lib/wifi-wand/models/mac_os_model.rb', line 253

def remove_preferred_network(network_name)
  network_name = network_name.to_s
  run_os_command("sudo networksetup -removepreferredwirelessnetwork " +
                     "#{wifi_port} #{Shellwords.shellescape(network_name)}")
end

#set_nameservers(nameservers) ⇒ Object



322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/wifi-wand/models/mac_os_model.rb', line 322

def set_nameservers(nameservers)
  arg = if nameservers == :clear
    'empty'
  else
    bad_addresses = nameservers.reject do |ns|
      begin
        IPAddr.new(ns).ipv4?
        true
      rescue => e
        puts e
        false
      end
    end

    unless bad_addresses.empty?
      raise Error.new("Bad IP addresses provided: #{bad_addresses.join(', ')}")
    end
    nameservers.join(' ')
  end
  run_os_command("networksetup -setdnsservers Wi-Fi #{arg}")
  nameservers
end

#wifi_infoObject

Returns some useful wifi-related information.



287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/wifi-wand/models/mac_os_model.rb', line 287

def wifi_info

  connected = begin
    connected_to_internet?
  rescue
    false
  end

  info = {
      'wifi_on'     => wifi_on?,
      'internet_on' => connected,
      'port'        => wifi_port,
      'network'     => connected_network_name,
      'ip_address'  => ip_address,
      'mac_address' => mac_address,
      'nameservers' => nameservers_using_scutil,
      'timestamp'   => Time.now,
  }
  more_output = run_os_command(airport_command + " -I")
  more_info   = colon_output_to_hash(more_output)
  info.merge!(more_info)
  info.delete('AirPort') # will be here if off, but info is already in wifi_on key

  if info['internet_on']
    begin
      info['public_ip'] = public_ip_address_info
    rescue => e
      puts "Error obtaining public IP address info, proceeding with everything else:"
      puts e.to_s
    end
  end
  info
end

#wifi_offObject

Turns wifi off.



202
203
204
205
206
# File 'lib/wifi-wand/models/mac_os_model.rb', line 202

def wifi_off
  return unless wifi_on?
  run_os_command("networksetup -setairportpower #{wifi_port} off")
  wifi_on? ? Error.new(raise("Wifi could not be disabled.")) : nil
end

#wifi_onObject

Turns wifi on.



194
195
196
197
198
# File 'lib/wifi-wand/models/mac_os_model.rb', line 194

def wifi_on
  return if wifi_on?
  run_os_command("networksetup -setairportpower #{wifi_port} on")
  wifi_on? ? nil : Error.new(raise("Wifi could not be enabled."))
end

#wifi_on?Boolean

Returns true if wifi is on, else false.

Returns:

  • (Boolean)


187
188
189
190
# File 'lib/wifi-wand/models/mac_os_model.rb', line 187

def wifi_on?
  lines = run_os_command("#{airport_command} -I").split("\n")
  lines.grep("AirPort: Off").none?
end