Re-factor _start_manual_network and add tests
[wifi-radar.git] / wifiradar / connections.py
blob7d3bc14ecb062077ca0136a529f6a4b34d4bee63
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
4 # connections.py - collection of classes for supporting WiFi connections
6 # Part of WiFi Radar: A utility for managing WiFi profiles on GNU/Linux.
8 # Copyright (C) 2004-2005 Ahmad Baitalmal <ahmad@baitalmal.com>
9 # Copyright (C) 2005 Nicolas Brouard <nicolas.brouard@mandrake.org>
10 # Copyright (C) 2005-2009 Brian Elliott Finley <brian@thefinleys.com>
11 # Copyright (C) 2006 David Decotigny <com.d2@free.fr>
12 # Copyright (C) 2006 Simon Gerber <gesimu@gmail.com>
13 # Copyright (C) 2006-2007 Joey Hurst <jhurst@lucubrate.org>
14 # Copyright (C) 2012 Anari Jalakas <anari.jalakas@gmail.com>
15 # Copyright (C) 2006, 2009 Ante Karamatic <ivoks@ubuntu.com>
16 # Copyright (C) 2009-2010,2014 Sean Robinson <seankrobinson@gmail.com>
17 # Copyright (C) 2010 Prokhor Shuchalov <p@shuchalov.ru>
19 # This program is free software; you can redistribute it and/or modify
20 # it under the terms of the GNU General Public License as published by
21 # the Free Software Foundation; version 2 of the License.
23 # This program is distributed in the hope that it will be useful,
24 # but WITHOUT ANY WARRANTY; without even the implied warranty of
25 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
26 # GNU General Public License in LICENSE.GPL for more details.
28 # You should have received a copy of the GNU General Public License
29 # along with this program; if not, write to the Free Software
30 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
33 try:
34 # Py2
35 import Queue as queue
36 except ImportError:
37 # Py3
38 import queue
40 import logging
41 import os
42 import re
43 import sys
44 from signal import SIGTERM
45 from subprocess import CalledProcessError, Popen, PIPE, STDOUT
46 from time import sleep
48 from wifiradar.config import make_section_name, ConfigManager
49 import wifiradar.misc as misc
51 # create a logger
52 logger = logging.getLogger(__name__)
55 def get_enc_mode(use_wpa, key):
56 """ Return the WiFi encryption mode based on the combination of
57 'use_wpa' and 'key'. Possible return values are 'wpa', 'wep',
58 and 'none'.
59 """
60 if use_wpa:
61 return 'wpa'
62 elif key == '':
63 return 'none'
64 else:
65 return 'wep'
68 class ConnectionManager(object):
69 """ Manage a connection; including reporting connection state,
70 connecting/disconnecting from an AP, and returning current IP, ESSID, and BSSID.
71 """
72 def __init__(self, msg_queue):
73 """ Create a new connection manager which communicates with a
74 controlling process or thread through 'msg_queue' (a Queue
75 object).
76 """
77 self.msg_queue = msg_queue
79 def set_config(self, config_manager):
80 """ Set the configuration manager to 'config'. This method
81 must be called before any other public method or a caller
82 will likely receive an AttributeError about a missing
83 'config' attribute.
85 Raises TypeError if 'config_manager' is not a ConfigManager
86 object.
87 """
88 if not isinstance(config_manager, ConfigManager):
89 raise TypeError('config must be a ConfigManager object')
90 self.config = config_manager
92 def _run_script(self, script_name, profile, device):
93 """ Run the script (e.g. connection prescript) in 'profile' which
94 is named in 'script_name'.
95 """
96 if profile[script_name]:
97 logger.info('running {}'.format(script_name))
98 profile_name = make_section_name(profile['essid'], profile['bssid'])
99 enc_mode = get_enc_mode(profile['use_wpa'], profile['key'])
100 custom_env = {
101 "WIFIRADAR_IP": self.get_current_ip(),
102 "WIFIRADAR_ESSID": self.get_current_essid(),
103 "WIFIRADAR_BSSID": self.get_current_bssid(),
104 "WIFIRADAR_PROFILE": profile_name,
105 "WIFIRADAR_ENCMODE": enc_mode,
106 "WIFIRADAR_SECMODE": profile['security'],
107 "WIFIRADAR_IF": device}
108 try:
109 misc.shellcmd(profile[script_name].split(' '), custom_env)
110 except CalledProcessError as e:
111 logger.error('script "{}" failed: {}'.format(script_name, e))
112 self.msg_queue.put('script "{}" failed: {}'.format(
113 script_name, e))
115 def _prepare_nic(self, profile, device):
116 """ Configure the NIC for upcoming connection.
118 # Start building iwconfig command line.
119 iwconfig_command = [
120 self.config.get_opt('DEFAULT', 'iwconfig_command'),
121 device,
122 'essid', "'{}'".format(profile['essid']),
125 # Setting key
126 iwconfig_command.append('key')
127 if (not profile['key']) or (profile['key'] == 's:'):
128 iwconfig_command.append('off')
129 else:
130 # Setting this stops association from working, so remove it for now
131 #if profile['security'] != '':
132 #iwconfig_command.append(profile['security'])
133 iwconfig_command.append("'{}'".format(profile['key']))
134 #iwconfig_commands.append( "key %s %s" % ( profile['security'], profile['key'] ) )
136 # Set mode.
137 profile['mode'] = profile['mode'].lower()
138 if profile['mode'] == 'master' or profile['mode'] == 'auto':
139 profile['mode'] = 'managed'
140 iwconfig_command.extend(['mode', profile['mode']])
142 # Set channel.
143 if 'channel' in profile:
144 iwconfig_command.extend(['channel', profile['channel']])
146 # Set AP address (do this last since iwconfig seems to want it only there).
147 iwconfig_command.extend(['ap', profile['bssid']])
149 # Some cards require a commit
150 if self.config.get_opt_as_bool('DEFAULT', 'commit_required'):
151 iwconfig_command.append('commit')
153 logger.debug('iwconfig_command: {}'.format(iwconfig_command))
155 try:
156 misc.shellcmd(iwconfig_command)
157 except CalledProcessError as e:
158 logger.error('Failed to prepare NIC: {}'.format(e))
159 self.msg_queue.put('Failed to prepare NIC: {}'.format(e))
160 raise misc.DeviceError('Could not configure wireless options.')
162 def _stop_dhcp(self, device):
163 """ Stop any DHCP client daemons running with our 'device'.
165 logger.info('Stopping any DHCP clients on "{}"'.format(device))
166 if os.access(self.config.get_opt('DHCP', 'pidfile'), os.R_OK):
167 if self.config.get_opt('DHCP', 'kill_args'):
168 dhcp_command = [self.config.get_opt('DHCP', 'command')]
169 dhcp_command.extend(self.config.get_opt('DHCP', 'kill_args').split(' '))
170 dhcp_command.append(device)
171 logger.info("DHCP command: %s" % (dhcp_command, ))
173 # call DHCP client command and wait for return
174 logger.info("Stopping DHCP with kill_args")
175 try:
176 misc.shellcmd(dhcp_command)
177 except CalledProcessError as e:
178 logger.error('Attempt to stop DHCP failed: {}'.format(e))
179 else:
180 logger.info("Stopping DHCP manually...")
181 os.kill(int(open(self.config.get_opt('DHCP', 'pidfile'), mode='r').readline()), SIGTERM)
183 def _start_dhcp(self, device):
184 """ Start a DHCP client daemon on 'device'.
186 logger.debug('Starting DHCP command on "{}"'.format(device))
187 self.msg_queue.put("Acquiring IP Address (DHCP)")
188 dhcp_command = [self.config.get_opt('DHCP', 'command')]
189 dhcp_command.extend(self.config.get_opt('DHCP', 'args').split(' '))
190 dhcp_command.append(device)
191 logger.info("dhcp_command: %s" % (dhcp_command, ))
192 try:
193 dhcp_proc = Popen(dhcp_command, stdout=None, stderr=None)
194 except OSError as e:
195 if e.errno == 2:
196 logger.critical('DHCP client not found, ' + \
197 'please set this in the preferences.')
198 else:
199 # The DHCP client daemon should timeout on its own, hence
200 # the +3 seconds on timeout so we don't cut the daemon off
201 # while it is finishing up.
202 timeout = self.config.get_opt_as_int('DHCP', 'timeout') + 3
203 tick = 0.25
204 dhcp_status = dhcp_proc.poll()
205 while dhcp_status is None:
206 if timeout < 0:
207 dhcp_proc.terminate()
208 else:
209 timeout = timeout - tick
210 sleep(tick)
211 dhcp_status = dhcp_proc.poll()
212 if self.get_current_ip() is not None:
213 self.msg_queue.put("Got IP address. Done.")
214 else:
215 self.msg_queue.put("Could not get IP address!")
217 def _stop_wpa(self):
218 """ Stop all WPA supplicants.
220 logger.info("Kill off any existing WPA supplicants running...")
221 if os.access(self.config.get_opt('WPA', 'pidfile'), os.R_OK):
222 logger.info("Killing existing WPA supplicant...")
223 try:
224 if self.config.get_opt('WPA', 'kill_command'):
225 wpa_command = [self.config.get_opt('WPA', 'kill_command').split(' ')]
226 try:
227 misc.shellcmd(wpa_command)
228 except CalledProcessError as e:
229 logger.error('Attempt to stop WPA supplicant ' + \
230 'failed: {}'.format(e))
231 else:
232 os.kill(int(open(self.config.get_opt('WPA', 'pidfile'), mode='r').readline()), SIGTERM)
233 except OSError:
234 logger.info("Failed to kill WPA supplicant")
236 def _start_wpa(self):
237 """ Start WPA supplicant and let it associate with the AP.
239 self.msg_queue.put("WPA supplicant starting")
241 wpa_command = [self.config.get_opt('WPA', 'command')]
242 wpa_command.extend(self.config.get_opt('WPA', 'args').split(' '))
243 try:
244 misc.shellcmd(wpa_command)
245 except CalledProcessError as e:
246 logger.error('WPA supplicant failed to start: {}'.format(e))
248 def _start_manual_network(self, profile, device):
249 """ Manually configure network settings after association.
251 # Bring down the interface before trying to make changes.
252 ifconfig_command = [
253 self.config.get_opt('DEFAULT', 'ifconfig_command'),
254 device, 'down']
255 try:
256 misc.shellcmd(ifconfig_command)
257 except CalledProcessError as e:
258 logger.error('Device "{}" failed to go down: {}'.format(device, e))
259 # Bring the interface up with our manual IP.
260 ifconfig_command = [
261 self.config.get_opt('DEFAULT', 'ifconfig_command'),
262 device, profile['ip'],
263 'netmask', profile['netmask']]
264 try:
265 misc.shellcmd(ifconfig_command)
266 except CalledProcessError as e:
267 logger.error('Device "{}" failed to configure: {}'.format(device, e))
268 # Configure routing information.
269 route_command = [
270 self.config.get_opt('DEFAULT', 'route_command'),
271 'add', 'default', 'gw', profile['gateway']]
272 try:
273 misc.shellcmd(route_command)
274 except CalledProcessError as e:
275 logger.error('Failed to configure routing information: {}'.format(e))
276 # Build the /etc/resolv.conf file, if needed.
277 resolv_contents = ''
278 if profile['domain']:
279 resolv_contents += "domain {}\n".format(profile['domain'])
280 if profile['dns1']:
281 resolv_contents += "nameserver {}\n".format(profile['dns1'])
282 if profile['dns2']:
283 resolv_contents += "nameserver {}\n".format(profile['dns2'])
284 if resolv_contents:
285 with open('/etc/resolv.conf', 'w') as resolv_file:
286 resolv_file.write(resolv_contents)
288 def connect(self, profile):
289 """ Connect to the access point specified by 'profile'.
291 if profile['bssid'] == '':
292 raise TypeError("Empty AP address")
293 msg = "Connecting to the %s (%s) network" % ( profile['essid'], profile['bssid'] )
294 logger.info(msg)
295 # Make a temporary copy of the DEFAULT.interface option.
296 default_interface = self.config.get_opt('DEFAULT', 'interface')
297 device = self.config.get_network_device()
298 # Temporarily set the configured interface to a real one.
299 self.config.set_opt('DEFAULT', 'interface', device)
300 # ready to dance
301 # Let's run the connection prescript
302 self._run_script('con_prescript', profile, device)
303 # Some cards need to have the interface up
304 if self.config.get_opt_as_bool('DEFAULT', 'ifup_required'):
305 self.if_change('up')
307 self._prepare_nic(profile, device)
309 # Now normal network stuff
311 self._stop_dhcp(device)
312 self._stop_wpa()
314 logger.debug("Disable scan while connection attempt in progress...")
315 try:
316 self.msg_queue.put("pause")
317 except queue.Full:
318 pass
320 if profile['use_wpa'] :
321 self._start_wpa()
323 if profile['use_dhcp'] :
324 self._start_dhcp(device)
325 else:
326 self._start_manual_network(profile, device)
328 # Re-enable iwlist
329 try:
330 self.msg_queue.put("scan")
331 except queue.Full:
332 pass
333 # Let's run the connection postscript
334 self._run_script('con_postscript', profile, device)
335 # Set the configured interface back to original value.
336 self.config.set_opt('DEFAULT', 'interface', default_interface)
338 def disconnect(self, profile):
339 """ Disconnect from the AP with which a connection has been
340 established/attempted.
342 msg = "Disconnecting"
343 logger.info(msg)
344 # Make a temporary copy of the DEFAULT.interface option.
345 default_interface = self.config.get_opt('DEFAULT', 'interface')
346 device = self.config.get_network_device()
347 # Temporarily set the configured interface to a real one.
348 self.config.set_opt('DEFAULT', 'interface', device)
349 # Pause scanning while manipulating card
350 try:
351 self.msg_queue.put("pause")
352 self.msg_queue.join()
353 except queue.Full:
354 pass
355 profile = self.config.get_profile(make_section_name(self.get_current_essid(), self.get_current_bssid()))
356 if not profile:
357 profile = self.config.get_profile(make_section_name(self.get_current_essid(), ''))
358 if not profile:
359 raise KeyError
360 # Let's run the disconnection prescript
361 self._run_script('dis_prescript', profile, device)
363 self._stop_dhcp(device)
364 self._stop_wpa()
366 logger.info("Let's clear out the wireless stuff")
367 misc.shellcmd([self.config.get_opt('DEFAULT', 'iwconfig_command'), device, 'essid', 'any', 'key', 'off', 'mode', 'managed', 'channel', 'auto', 'ap', 'off'])
368 logger.info("Now take the interface down")
369 logger.info("Since it may be brought back up by the next scan, lets unset its IP")
370 misc.shellcmd([self.config.get_opt('DEFAULT', 'ifconfig_command'), device, '0.0.0.0'])
371 # taking down the interface too quickly can crash my system, so pause a moment
372 sleep(1)
373 self.if_change('down')
374 # Let's run the disconnection postscript
375 self._run_script('dis_postscript', profile, device)
376 logger.info("Disconnect complete.")
377 # Begin scanning again
378 try:
379 self.msg_queue.put("scan")
380 except queue.Full:
381 pass
382 # Set the configured interface back to original value.
383 self.config.set_opt('DEFAULT', 'interface', default_interface)
385 def if_change(self, state):
386 """ Change the interface to 'state', i.e. 'up' or 'down'.
388 'if_change' raises ValueError if 'state' is not recognized,
389 raises OSError if there is a problem running ifconfig, and
390 raises DeviceError if ifconfig reports the change failed.
392 state = state.lower()
393 if ((state == 'up') or (state == 'down')):
394 device = self.config.get_network_device()
395 if device:
396 ifconfig_command = [
397 self.config.get_opt('DEFAULT', 'ifconfig_command'),
398 device, state]
399 try:
400 logger.info('changing interface ' +
401 '{} state to {}'.format(device, state))
402 ifconfig_info = Popen(ifconfig_command, stdout=PIPE,
403 stderr=STDOUT).stdout
404 except OSError as e:
405 if e.errno == 2:
406 logger.critical("ifconfig command not found, " +
407 "please set this in the preferences.")
408 raise e
409 else:
410 for line in ifconfig_info:
411 if len(line) > 0:
412 raise misc.DeviceError('Could not change ' +
413 'device state: {}'.format(line))
414 else:
415 raise ValueError('unrecognized state for device: {}'.format(state))
417 def get_current_ip(self):
418 """ Return the current IP address as a string or None.
420 device = self.config.get_network_device()
421 if device:
422 ifconfig_command = [
423 self.config.get_opt('DEFAULT', 'ifconfig_command'),
424 device]
425 try:
426 ifconfig_info = Popen(ifconfig_command, stdout=PIPE).stdout
427 # Be careful to the language (inet adr: in French for example)
429 # Hi Brian
431 # I'm using wifi-radar on a system with German translations (de_CH-UTF-8).
432 # There the string in ifconfig is inet Adresse for the IP which isn't
433 # found by the current get_current_ip function in wifi-radar. I changed
434 # the according line (#289; gentoo, v1.9.6-r1) to
435 # >ip_re = re.compile(r'inet [Aa]d?dr[^.]*:([^.]*\.[^.]*\.[^.]*\.[0-9]*)')
436 # which works on my system (LC_ALL=de_CH.UTF-8) and still works with LC_ALL=C.
438 # I'd be happy if you could incorporate this small change because as now
439 # I've got to change the file every time it is updated.
441 # Best wishes
443 # Simon
444 ip_re = re.compile(r'inet [Aa]d?dr[^.]*:([^.]*\.[^.]*\.[^.]*\.[0-9]*)')
445 line = ifconfig_info.read()
446 if ip_re.search(line):
447 return ip_re.search(line).group(1)
448 except OSError as e:
449 if e.errno == 2:
450 logger.critical("ifconfig command not found, " +
451 "please set this in the preferences.")
452 return None
454 def get_current_essid(self):
455 """ Return the current ESSID as a string or None.
457 device = self.config.get_network_device()
458 if device:
459 iwconfig_command = [
460 self.config.get_opt('DEFAULT', 'iwconfig_command'),
461 device]
462 try:
463 iwconfig_info = Popen(iwconfig_command, stdout=PIPE, stderr=STDOUT).stdout
464 essid_re = re.compile(r'ESSID\s*(:|=)\s*"([^"]+)"',
465 re.I | re.M | re.S)
466 line = iwconfig_info.read()
467 if essid_re.search(line):
468 return essid_re.search(line).group(2)
469 except OSError as e:
470 if e.errno == 2:
471 logger.critical("iwconfig command not found, " +
472 "please set this in the preferences.")
473 return None
475 def get_current_bssid(self):
476 """ Return the current BSSID as a string or None.
478 device = self.config.get_network_device()
479 if device:
480 iwconfig_command = [
481 self.config.get_opt('DEFAULT', 'iwconfig_command'),
482 device]
483 try:
484 iwconfig_info = Popen(iwconfig_command, stdout=PIPE, stderr=STDOUT).stdout
485 bssid_re = re.compile(r'Access Point\s*(:|=)\s*([a-fA-F0-9:]{17})',
486 re.I | re.M | re.S )
487 line = iwconfig_info.read()
488 if bssid_re.search(line):
489 return bssid_re.search(line).group(2)
490 except OSError as e:
491 if e.errno == 2:
492 logger.critical("iwconfig command not found, " +
493 "please set this in the preferences.")
494 return None
497 # Make so we can be imported
498 if __name__ == "__main__":
499 pass