wifi-wand 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ require_relative 'command_line_interface'
2
+ require_relative 'operating_systems'
3
+
4
+
5
+ module WifiWand
6
+
7
+ require 'json'
8
+ require 'optparse'
9
+ require 'ostruct'
10
+ require 'yaml'
11
+
12
+ class Main
13
+
14
+ # Parses the command line with Ruby's internal 'optparse'.
15
+ # Looks for "-v" flag to set verbosity to true.
16
+ # optparse removes what it processes from ARGV, which simplifies our command parsing.
17
+ def parse_command_line
18
+ options = OpenStruct.new
19
+ OptionParser.new do |parser|
20
+ parser.on("-v", "--[no-]verbose", "Run verbosely") do |v|
21
+ options.verbose = v
22
+ end
23
+
24
+ parser.on("-s", "--shell", "Start interactive shell") do |v|
25
+ options.interactive_mode = true
26
+ end
27
+
28
+ parser.on("-o", "--output_format FORMAT", "Format output data") do |v|
29
+
30
+ transformers = {
31
+ 'i' => ->(object) { object.inspect },
32
+ 'j' => ->(object) { JSON.pretty_generate(object) },
33
+ 'p' => ->(object) { sio = StringIO.new; sio.puts(object); sio.string },
34
+ 'y' => ->(object) { object.to_yaml }
35
+ }
36
+
37
+ choice = v[0].downcase
38
+
39
+ unless transformers.keys.include?(choice)
40
+ raise %Q{Output format "#{choice}" not in list of available formats} +
41
+ " (#{transformers.keys.inspect})."
42
+ end
43
+
44
+ options.post_processor = transformers[choice]
45
+ end
46
+
47
+ parser.on("-h", "--help", "Show help") do |_help_requested|
48
+ ARGV << 'h' # pass on the request to the command processor
49
+ end
50
+ end.parse!
51
+ options
52
+ end
53
+
54
+
55
+ def call
56
+ operating_systems = OperatingSystems.new
57
+ unless operating_systems.current_id == :mac
58
+ raise "OS not in supported list of #{operating_systems.supported_os_names.inspect}"
59
+ end
60
+
61
+ options = parse_command_line
62
+
63
+ WifiWand::CommandLineInterface.new(options).call
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,250 @@
1
+ require 'json'
2
+ require 'tempfile'
3
+ require_relative '../../wifi-wand'
4
+
5
+ module WifiWand
6
+
7
+ class BaseModel
8
+
9
+ class OsCommandError < RuntimeError
10
+ attr_reader :exitstatus, :command, :text
11
+
12
+ def initialize(exitstatus, command, text)
13
+ @exitstatus = exitstatus
14
+ @command = command
15
+ @text = text
16
+ end
17
+ end
18
+
19
+
20
+ def initialize(verbose = false)
21
+ @verbose_mode = verbose
22
+ end
23
+
24
+
25
+ def run_os_command(command)
26
+ output = `#{command} 2>&1` # join stderr with stdout
27
+ if $?.exitstatus != 0
28
+ raise OsCommandError.new($?.exitstatus, command, output)
29
+ end
30
+ if @verbose_mode
31
+ puts "\n\n#{'-' * 79}\nCommand: #{command}\n\nOutput:\n#{output}#{'-' * 79}\n\n"
32
+ end
33
+ output
34
+ end
35
+
36
+
37
+ # This method returns whether or not there is a working Internet connection.
38
+ # Because of a Mac issue which causes a request to hang if the network is turned
39
+ # off during its lifetime, we give it only 5 seconds per try,
40
+ # and limit the number of tries to 3.
41
+ #
42
+ # This implementation will probably strike you as overly complex. The following
43
+ # code looks like it is all that should be necessary, but unfortunately
44
+ # this implementation often hangs when wifi is turned off while curl is active
45
+ #
46
+ # def connected_to_internet?
47
+ # script = "curl --silent --head http://www.google.com/ > /dev/null ; echo $?"
48
+ # result = `#{script}`.chomp
49
+ # puts result
50
+ # result == '0'
51
+ # end
52
+
53
+ # TODO Investigate using Curl options: --connect-timeout 1 --max-time 2 --retry 0
54
+ # to greatly simplify this method.
55
+ def connected_to_internet?
56
+
57
+ tempfile = Tempfile.open('wifi-wand-')
58
+
59
+ begin
60
+ start_status_script = -> do
61
+ script = "curl --silent --head http://www.google.com/ > /dev/null ; echo $? > #{tempfile.path} &"
62
+ pid = Process.spawn(script)
63
+ Process.detach(pid)
64
+ pid
65
+ end
66
+
67
+ process_is_running = ->(pid) do
68
+ script = %Q{ps -p #{pid} > /dev/null; echo $?}
69
+ output = `#{script}`.chomp
70
+ output == "0"
71
+ end
72
+
73
+ get_connected_state_from_curl = -> do
74
+ tempfile.close
75
+ File.read(tempfile.path).chomp == '0'
76
+ end
77
+
78
+ # Do one run, iterating during the timeout period to see if the command has completed
79
+ do_one_run = -> do
80
+ end_time = Time.now + 3
81
+ pid = start_status_script.()
82
+ while Time.now < end_time
83
+ if process_is_running.(pid)
84
+ sleep 0.5
85
+ else
86
+ return get_connected_state_from_curl.()
87
+ end
88
+ end
89
+ Process.kill('KILL', pid) if process_is_running.(pid)
90
+ :hung
91
+ end
92
+
93
+ 3.times do
94
+ connected = do_one_run.()
95
+ return connected if connected != :hung
96
+ end
97
+
98
+ raise "Could not determine Internet status."
99
+
100
+ ensure
101
+ tempfile.unlink
102
+ end
103
+
104
+ end
105
+
106
+
107
+ # Turns wifi off and then on, reconnecting to the originally connecting network.
108
+ def cycle_network
109
+ # TODO: Make this network name saving and restoring conditional on it not having a password.
110
+ # If the disabled code below is enabled, an error will be raised if a password is required,
111
+ # even though it is stored.
112
+ # network_name = current_network
113
+ wifi_off
114
+ wifi_on
115
+ # connect(network_name) if network_name
116
+ end
117
+
118
+
119
+ def connected_to?(network_name)
120
+ network_name == connected_network_name
121
+ end
122
+
123
+
124
+ # Connects to the passed network name, optionally with password.
125
+ # Turns wifi on first, in case it was turned off.
126
+ # Relies on subclass implementation of os_level_connect().
127
+ def connect(network_name, password = nil)
128
+ # Allow symbols and anything responding to to_s for user convenience
129
+ network_name = network_name.to_s if network_name
130
+ password = password.to_s if password
131
+
132
+ if network_name.nil? || network_name.empty?
133
+ raise "A network name is required but was not provided."
134
+ end
135
+ wifi_on
136
+ os_level_connect(network_name, password)
137
+
138
+ # Verify that the network is now connected:
139
+ actual_network_name = connected_network_name
140
+ unless actual_network_name == network_name
141
+ message = %Q{Expected to connect to "#{network_name}" but }
142
+ if actual_network_name
143
+ message << %Q{connected to "#{connected_network_name}" instead.}
144
+ else
145
+ message << "unable to connect to any network. Did you "
146
+ end
147
+ message << (password ? "provide the correct password?" : "need to provide a password?")
148
+ raise message
149
+ end
150
+ nil
151
+ end
152
+
153
+
154
+ # Removes the specified network(s) from the preferred network list.
155
+ # @param network_names names of networks to remove; may be empty or contain nonexistent networks
156
+ # @return names of the networks that were removed (excludes non-preexisting networks)
157
+ def remove_preferred_networks(*network_names)
158
+ networks_to_remove = network_names & preferred_networks # exclude any nonexistent networks
159
+ networks_to_remove.each { |name| remove_preferred_network(name) }
160
+ end
161
+
162
+
163
+ def preferred_network_password(preferred_network_name)
164
+ preferred_network_name = preferred_network_name.to_s
165
+ if preferred_networks.include?(preferred_network_name)
166
+ os_level_preferred_network_password(preferred_network_name)
167
+ else
168
+ raise "Network #{preferred_network_name} not in preferred networks list."
169
+ end
170
+ end
171
+
172
+
173
+ # Waits for the Internet connection to be in the desired state.
174
+ # @param target_status must be in [:conn, :disc, :off, :on]; waits for that state
175
+ # @param wait_interval_in_secs sleeps this interval between retries; if nil or absent,
176
+ # a default will be provided
177
+ #
178
+ def till(target_status, wait_interval_in_secs = nil)
179
+
180
+ # One might ask, why not just put the 0.5 up there as the default argument.
181
+ # We could do that, but we'd still need the line below in case nil
182
+ # was explicitly specified. The default argument of nil above emphasizes that
183
+ # the absence of an argument and a specification of nil will behave identically.
184
+ wait_interval_in_secs ||= 0.5
185
+
186
+ finished_predicates = {
187
+ conn: -> { connected_to_internet? },
188
+ disc: -> { ! connected_to_internet? },
189
+ on: -> { wifi_on? },
190
+ off: -> { ! wifi_on? }
191
+ }
192
+
193
+ finished_predicate = finished_predicates[target_status]
194
+
195
+ if finished_predicate.nil?
196
+ raise ArgumentError.new(
197
+ "Option must be one of #{finished_predicates.keys.inspect}. Was: #{target_status.inspect}")
198
+ end
199
+
200
+ loop do
201
+ return if finished_predicate.()
202
+ sleep(wait_interval_in_secs)
203
+ end
204
+ end
205
+
206
+
207
+ # Tries an OS command until the stop condition is true.
208
+ # @command the command to run in the OS
209
+ # @stop_condition a lambda taking the commands stdout as its sole parameter
210
+ # @return the stdout produced by the command
211
+ def try_os_command_until(command, stop_condition, max_tries = 100)
212
+ max_tries.times do
213
+ stdout = run_os_command(command)
214
+ if stop_condition.(stdout)
215
+ return stdout
216
+ end
217
+ end
218
+ nil
219
+ end
220
+
221
+
222
+ # Reaches out to ipinfo.io to get public IP address information
223
+ # in the form of a hash.
224
+ # You may need to enclose this call in a begin/rescue.
225
+ def public_ip_address_info
226
+ JSON.parse(`curl -s ipinfo.io`)
227
+ end
228
+
229
+
230
+ # @return array of nameserver IP addresses from /etc/resolv.conf, or nil if not found
231
+ # Though this is strictly *not* OS-agnostic, it will be used by most OS's,
232
+ # and can be overridden by subclasses (e.g. Windows).
233
+ def nameservers_using_resolv_conf
234
+ begin
235
+ File.readlines('/etc/resolv.conf').grep(/^nameserver /).map { |line| line.split.last }
236
+ rescue Errno::ENOENT
237
+ nil
238
+ end
239
+ end
240
+
241
+
242
+ def nameservers_using_scutil
243
+ output = run_os_command('scutil --dns')
244
+ nameserver_lines_scoped_and_unscoped = output.split("\n").grep(/^\s*nameserver\[/)
245
+ unique_nameserver_lines = nameserver_lines_scoped_and_unscoped.uniq # take the union
246
+ nameservers = unique_nameserver_lines.map { |line| line.split(' : ').last.strip }
247
+ nameservers
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,299 @@
1
+ require 'shellwords'
2
+
3
+ require_relative 'base_model'
4
+
5
+ module WifiWand
6
+
7
+ class MacOsModel < BaseModel
8
+
9
+ AIRPORT_CMD = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport'
10
+
11
+ def initialize(verbose = false)
12
+ super
13
+ end
14
+
15
+
16
+ # Identifies the (first) wireless network hardware port in the system, e.g. en0 or en1
17
+ def wifi_hardware_port
18
+ @wifi_hardware_port ||= begin
19
+ lines = run_os_command("networksetup -listallhardwareports").split("\n")
20
+ # Produces something like this:
21
+ # Hardware Port: Wi-Fi
22
+ # Device: en0
23
+ # Ethernet Address: ac:bc:32:b9:a9:9d
24
+ #
25
+ # Hardware Port: Bluetooth PAN
26
+ # Device: en3
27
+ # Ethernet Address: ac:bc:32:b9:a9:9e
28
+ wifi_port_line_num = (0...lines.size).detect do |index|
29
+ /: Wi-Fi$/.match(lines[index])
30
+ end
31
+ if wifi_port_line_num.nil?
32
+ raise %Q{Wifi port (e.g. "en0") not found in output of: networksetup -listallhardwareports}
33
+ else
34
+ lines[wifi_port_line_num + 1].split(': ').last
35
+ end
36
+ end
37
+ end
38
+
39
+
40
+ # Returns data pertaining to available wireless networks.
41
+ # For some reason, this often returns no results, so I've put the operation in a loop.
42
+ # I was unable to detect a sort strategy in the airport utility's output, so I sort
43
+ # the lines alphabetically, to show duplicates and for easier lookup.
44
+ def available_network_info
45
+ return nil unless wifi_on? # no need to try
46
+ command = "#{AIRPORT_CMD} -s"
47
+ max_attempts = 50
48
+
49
+
50
+ reformat_line = ->(line) do
51
+ ssid = line[0..31].strip
52
+ "%-32.32s%s" % [ssid, line[32..-1]]
53
+ end
54
+
55
+
56
+ process_tabular_data = ->(output) do
57
+ lines = output.split("\n")
58
+ header_line = lines[0]
59
+ data_lines = lines[1..-1]
60
+ data_lines.map! do |line|
61
+ # Reformat the line so that the name is left instead of right justified
62
+ reformat_line.(line)
63
+ end
64
+ data_lines.sort!
65
+ [reformat_line.(header_line)] + data_lines
66
+ end
67
+
68
+
69
+ output = try_os_command_until(command, ->(output) do
70
+ ! ([nil, ''].include?(output))
71
+ end)
72
+
73
+ if output
74
+ process_tabular_data.(output)
75
+ else
76
+ raise "Unable to get available network information after #{max_attempts} attempts."
77
+ end
78
+ end
79
+
80
+
81
+ def parse_network_names(info)
82
+ if info.nil?
83
+ nil
84
+ else
85
+ info[1..-1] \
86
+ .map { |line| line[0..32].rstrip } \
87
+ .uniq \
88
+ .sort { |s1, s2| s1.casecmp(s2) }
89
+ end
90
+ end
91
+
92
+
93
+ # @return an array of unique available network names only, sorted alphabetically
94
+ # Kludge alert: the tabular data does not differentiate between strings with and without leading whitespace
95
+ # Therefore, we get the data once in tabular format, and another time in XML format.
96
+ # The XML element will include any leading whitespace. However, it includes all <string> elements,
97
+ # many of which are not network names.
98
+ # As an improved approximation of the correct result, for each network name found in tabular mode,
99
+ # we look to see if there is a corresponding string element with leading whitespace, and, if so,
100
+ # replace it.
101
+ #
102
+ # This will not behave correctly if a given name has occurrences with different amounts of whitespace,
103
+ # e.g. ' x' and ' x'.
104
+ #
105
+ # The reason we don't use an XML parser to get the exactly correct result is that we don't want
106
+ # users to need to install any external dependencies in order to run this script.
107
+ def available_network_names
108
+
109
+ # Parses the XML text (using grep, not XML parsing) to find
110
+ # <string> elements, and extracts the network name candidates
111
+ # containing leading spaces from it.
112
+ get_leading_space_names = ->(text) do
113
+ text.split("\n") \
114
+ .grep(%r{<string>}) \
115
+ .sort \
116
+ .uniq \
117
+ .map { |line| line.gsub("<string>", '').gsub('</string>', '').gsub("\t", '') } \
118
+ .select { |s| s[0] == ' ' }
119
+ end
120
+
121
+
122
+ output_is_valid = ->(output) { ! ([nil, ''].include?(output)) }
123
+ tabular_data = try_os_command_until("#{AIRPORT_CMD} -s", output_is_valid)
124
+ xml_data = try_os_command_until("#{AIRPORT_CMD} -s -x", output_is_valid)
125
+
126
+ if tabular_data.nil? || xml_data.nil?
127
+ raise "Unable to get available network information; please try again."
128
+ end
129
+
130
+ tabular_data_lines = tabular_data[1..-1] # omit header line
131
+ names_no_spaces = parse_network_names(tabular_data_lines.split("\n")).map(&:strip)
132
+ names_maybe_spaces = get_leading_space_names.(xml_data)
133
+
134
+ names = names_no_spaces.map do |name_no_spaces|
135
+ match = names_maybe_spaces.detect do |name_maybe_spaces|
136
+ %r{[ \t]?#{name_no_spaces}$}.match(name_maybe_spaces)
137
+ end
138
+
139
+ match ? match : name_no_spaces
140
+ end
141
+
142
+ names.sort { |s1, s2| s1.casecmp(s2) } # sort alphabetically, case insensitively
143
+ end
144
+
145
+
146
+ # Returns data pertaining to "preferred" networks, many/most of which will probably not be available.
147
+ def preferred_networks
148
+ lines = run_os_command("networksetup -listpreferredwirelessnetworks #{wifi_hardware_port}").split("\n")
149
+ # Produces something like this, unsorted, and with leading tabs:
150
+ # Preferred networks on en0:
151
+ # LibraryWiFi
152
+ # @thePAD/Magma
153
+
154
+ lines.delete_at(0) # remove title line
155
+ lines.map! { |line| line.gsub("\t", '') } # remove leading tabs
156
+ lines.sort! { |s1, s2| s1.casecmp(s2) } # sort alphabetically, case insensitively
157
+ lines
158
+ end
159
+
160
+
161
+ # Returns true if wifi is on, else false.
162
+ def wifi_on?
163
+ lines = run_os_command("#{AIRPORT_CMD} -I").split("\n")
164
+ lines.grep("AirPort: Off").none?
165
+ end
166
+
167
+
168
+ # Turns wifi on.
169
+ def wifi_on
170
+ return if wifi_on?
171
+ run_os_command("networksetup -setairportpower #{wifi_hardware_port} on")
172
+ wifi_on? ? nil : raise("Wifi could not be enabled.")
173
+ end
174
+
175
+
176
+ # Turns wifi off.
177
+ def wifi_off
178
+ return unless wifi_on?
179
+ run_os_command("networksetup -setairportpower #{wifi_hardware_port} off")
180
+ wifi_on? ? raise("Wifi could not be disabled.") : nil
181
+ end
182
+
183
+
184
+ # This method is called by BaseModel#connect to do the OS-specific connection logic.
185
+ def os_level_connect(network_name, password = nil)
186
+ command = "networksetup -setairportnetwork #{wifi_hardware_port} " + "#{Shellwords.shellescape(network_name)}"
187
+ if password
188
+ command << ' ' << Shellwords.shellescape(password)
189
+ end
190
+ run_os_command(command)
191
+ end
192
+
193
+
194
+ # @return:
195
+ # If the network is in the preferred networks list
196
+ # If a password is associated w/this network, return the password
197
+ # If not, return nil
198
+ # else
199
+ # raise an error
200
+ def os_level_preferred_network_password(preferred_network_name)
201
+ command = %Q{security find-generic-password -D "AirPort network password" -a "#{preferred_network_name}" -w 2>&1}
202
+ begin
203
+ return run_os_command(command).chomp
204
+ rescue OsCommandError => error
205
+ if error.exitstatus == 44 # network has no password stored
206
+ nil
207
+ else
208
+ raise
209
+ end
210
+ end
211
+ end
212
+
213
+
214
+ # Returns the IP address assigned to the wifi port, or nil if none.
215
+ def ip_address
216
+ begin
217
+ run_os_command("ipconfig getifaddr #{wifi_hardware_port}").chomp
218
+ rescue OsCommandError => error
219
+ if error.exitstatus == 1
220
+ nil
221
+ else
222
+ raise
223
+ end
224
+ end
225
+ end
226
+
227
+
228
+ def remove_preferred_network(network_name)
229
+ network_name = network_name.to_s
230
+ run_os_command("sudo networksetup -removepreferredwirelessnetwork " +
231
+ "#{wifi_hardware_port} #{Shellwords.shellescape(network_name)}")
232
+ end
233
+
234
+
235
+ # Returns the network currently connected to, or nil if none.
236
+ def current_network
237
+ lines = run_os_command("#{AIRPORT_CMD} -I").split("\n")
238
+ ssid_lines = lines.grep(/ SSID:/)
239
+ ssid_lines.empty? ? nil : ssid_lines.first.split('SSID: ').last.strip
240
+ end
241
+
242
+
243
+ # Disconnects from the currently connected network. Does not turn off wifi.
244
+ def disconnect
245
+ run_os_command("sudo #{AIRPORT_CMD} -z")
246
+ nil
247
+ end
248
+
249
+
250
+ # Returns some useful wifi-related information.
251
+ def wifi_info
252
+
253
+ info = {
254
+ 'wifi_on' => wifi_on?,
255
+ 'internet_on' => connected_to_internet?,
256
+ 'port' => wifi_hardware_port,
257
+ 'network' => current_network,
258
+ 'ip_address' => ip_address,
259
+ 'nameservers' => nameservers_using_scutil,
260
+ 'timestamp' => Time.now,
261
+ }
262
+ more_output = run_os_command(AIRPORT_CMD + " -I")
263
+ more_info = colon_output_to_hash(more_output)
264
+ info.merge!(more_info)
265
+ info.delete('AirPort') # will be here if off, but info is already in wifi_on key
266
+
267
+ if info['wifi_on']
268
+ begin
269
+ info['public_ip'] = public_ip_address_info
270
+ rescue => e
271
+ puts "Error obtaining public IP address info, proceeding with everything else:"
272
+ puts e.to_s
273
+ end
274
+ end
275
+ info
276
+ end
277
+
278
+
279
+ def open_resource(resource_url)
280
+ run_os_command('open ' + resource_url)
281
+ end
282
+
283
+
284
+ # Parses output like the text below into a hash:
285
+ # SSID: Pattara211
286
+ # MCS: 5
287
+ # channel: 7
288
+ def colon_output_to_hash(output)
289
+ lines = output.split("\n")
290
+ lines.each_with_object({}) do |line, new_hash|
291
+ key, value = line.split(': ')
292
+ key = key.strip
293
+ value.strip! if value
294
+ new_hash[key] = value
295
+ end
296
+ end
297
+ private :colon_output_to_hash
298
+ end
299
+ end