Rename PreferencesEditor save to apply
[wifi-radar.git] / wifiradar / connections.py
blob657922e7200995ad4b4c604a0d8b6c7f15b69955
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
34 import logging
35 import os
36 import re
37 import sys
38 from signal import SIGTERM
39 from subprocess import CalledProcessError, Popen, PIPE, STDOUT
40 from threading import Event
41 from time import sleep
43 from wifiradar.config import make_section_name, ConfigManager
44 import wifiradar.misc as misc
45 from wifiradar.pubsub import Message
47 # create a logger
48 logger = logging.getLogger(__name__)
51 def get_enc_mode(use_wpa, key):
52 """ Return the WiFi encryption mode based on the combination of
53 'use_wpa' and 'key'. Possible return values are 'wpa', 'wep',
54 and 'none'.
55 """
56 if use_wpa:
57 return 'wpa'
58 elif key == '':
59 return 'none'
60 else:
61 return 'wep'
64 def scanner(config_manager, msg_pipe):
65 """ Scan for access point information and send a profile for each.
66 :data:`config_manager` is a :class:`ConfigManager` object with
67 minimal configuration information (i.e. device and iwlist command).
68 :data:`msg_pipe` is a :class:`multiprocessing.Connection` object
69 used to receive commands and report AP profiles.
70 """
71 # Setup our pattern matchers. The important value must be the second
72 # regular expression group in the pattern.
73 patterns = dict()
74 patterns['essid'] = re.compile("ESSID\s*(:|=)\s*\"([^\"]+)\"", re.I | re.M | re.S)
75 patterns['bssid'] = re.compile("Address\s*(:|=)\s*([a-zA-Z0-9:]+)", re.I | re.M | re.S)
76 patterns['protocol'] = re.compile("Protocol\s*(:|=)\s*IEEE 802.11\s*([abgn]+)", re.I | re.M | re.S)
77 patterns['mode'] = re.compile("Mode\s*(:|=)\s*([^\n]+)", re.I | re.M | re.S)
78 patterns['channel'] = re.compile("Channel\s*(:|=)*\s*(\d+)", re.I | re.M | re.S)
79 patterns['encrypted'] = re.compile("Encryption key\s*(:|=)\s*(on|off)", re.I | re.M | re.S)
80 patterns['signal'] = re.compile("Signal level\s*(:|=)\s*(-?[0-9]+)", re.I | re.M | re.S)
82 trans_enc = dict(on=True, off=False)
84 running = True
85 scan = True
86 while running:
87 if msg_pipe.poll(1):
88 try:
89 msg = msg_pipe.recv()
90 except (EOFError, IOError) as e:
91 # This is bad, really bad.
92 logger.critical('read on closed ' +
93 'Pipe ({}), failing...'.format(msg_pipe))
94 raise misc.PipeError(e)
95 else:
96 if msg.topic == 'EXIT':
97 msg_pipe.close()
98 break
99 elif msg.topic == 'SCAN-START':
100 scan = True
101 elif msg.topic == 'SCAN-STOP':
102 scan = False
103 try:
104 device = config_manager.get_network_device()
105 except misc.NoDeviceError as e:
106 logger.critical('Wifi device not found, ' +
107 'please set this in the preferences.')
108 scan = False
109 running = False
111 if scan:
112 # update the signal strengths
113 iwlist_command = [
114 config_manager.get_opt('DEFAULT', 'iwlist_command'),
115 device, 'scan']
116 try:
117 scandata = Popen(iwlist_command, stdout=PIPE,
118 stderr=STDOUT).stdout
119 except OSError as e:
120 logger.critical('iwlist command not found, ' +
121 'please set this in the preferences.')
122 running = False
123 scan = False
124 else:
125 # Start with a blank profile to fill in.
126 profile = misc.get_new_profile()
127 # It's cleaner to code the gathering of AP profiles
128 # from bottom to top with the iwlist output.
129 for line in reversed(list(scandata)):
130 line = line.strip()
131 for pattern in patterns:
132 m = patterns[pattern].search(line)
133 if m is not None:
134 profile[pattern] = m.group(2)
135 # Stop looking for more matches on this line.
136 break
138 if line.startswith('Cell '):
139 # Each AP starts with the keyword "Cell",
140 # which mean we now have one whole profile.
141 # But, first translate the 'encrypted'
142 # property to a boolean value.
143 profile['encrypted'] = trans_enc[profile['encrypted']]
144 msg_pipe.send(Message('ACCESSPOINT', profile))
145 # Restart with a blank profile to fill in.
146 profile = misc.get_new_profile()
149 class ConnectionManager(object):
150 """ Manage a connection; including reporting connection state,
151 connecting/disconnecting from an AP, and returning current IP, ESSID, and BSSID.
153 def __init__(self, msg_pipe):
154 """ Create a new connection manager which communicates with a
155 controlling process or thread through 'msg_pipe' (a Pipe
156 object).
158 self.msg_pipe = msg_pipe
159 self._watching = Event()
160 self._watching.set()
162 def run(self):
163 """ Watch for incoming messages.
165 while self._watching.is_set():
166 try:
167 msg = self.msg_pipe.recv()
168 except (EOFError, IOError) as e:
169 # This is bad, really bad.
170 logger.critical('read on closed ' +
171 'Pipe ({}), failing...'.format(self.msg_pipe))
172 self._watching.clear()
173 raise misc.PipeError(e)
174 else:
175 self._check_message(msg)
177 def _check_message(self, msg):
178 """ Process incoming messages.
180 if msg.topic == 'EXIT':
181 self.msg_pipe.close()
182 self._watching.clear()
183 elif msg.topic == 'CONFIG-UPDATE':
184 # Replace configuration manager with the one in msg.details.
185 self.set_config(msg.details)
186 elif msg.topic == 'CONNECT':
187 # Try to connect to the profile in msg.details.
188 self.connect(msg.details)
189 elif msg.topic == 'DISCONNECT':
190 # Try to disconnect from the profile in msg.details.
191 self.disconnect(msg.details)
192 elif msg.topic == 'IF-CHANGE':
193 # Try to connect to the profile in msg.details.
194 try:
195 self.if_change(msg.details)
196 except misc.DeviceError as e:
197 self.msg_pipe.send(Message('ERROR', e))
198 except ValueError as e:
199 logger.warning(e)
200 elif msg.topic == 'QUERY-IP':
201 # Send out a message with the current IP address.
202 self.msg_pipe.send(Message('ANN-IP', self.get_current_ip()))
203 elif msg.topic == 'QUERY-ESSID':
204 # Send out a message with the current ESSID.
205 self.msg_pipe.send(Message('ANN-ESSID', self.get_current_essid()))
206 elif msg.topic == 'QUERY-BSSID':
207 # Send out a message with the current BSSID.
208 self.msg_pipe.send(Message('ANN-BSSID', self.get_current_bssid()))
209 else:
210 logger.warning('unrecognized Message: "{}"'.format(msg))
212 def set_config(self, config_manager):
213 """ Set the configuration manager to 'config_manager'. This method
214 must be called before any other public method or a caller will
215 likely receive an AttributeError about a missing 'config_manager'
216 attribute.
218 Raises TypeError if 'config_manager' is not a ConfigManager object.
220 if not isinstance(config_manager, ConfigManager):
221 raise TypeError('config must be a ConfigManager object')
222 self.config = config_manager
224 def _run_script(self, script_name, profile, device):
225 """ Run the script (e.g. connection prescript) in 'profile' which
226 is named in 'script_name'.
228 if profile[script_name]:
229 logger.info('running {}'.format(script_name))
230 profile_name = make_section_name(profile['essid'], profile['bssid'])
231 enc_mode = get_enc_mode(profile['use_wpa'], profile['key'])
232 custom_env = {
233 "WIFIRADAR_IP": self.get_current_ip(),
234 "WIFIRADAR_ESSID": self.get_current_essid(),
235 "WIFIRADAR_BSSID": self.get_current_bssid(),
236 "WIFIRADAR_PROFILE": profile_name,
237 "WIFIRADAR_ENCMODE": enc_mode,
238 "WIFIRADAR_SECMODE": profile['security'],
239 "WIFIRADAR_IF": device}
240 try:
241 misc.shellcmd(profile[script_name].split(' '), custom_env)
242 except CalledProcessError as e:
243 logger.error('script "{}" failed: {}'.format(script_name, e))
244 self.msg_pipe.send(Message('ERROR',
245 'script "{}" failed: {}'.format(script_name, e)))
247 def _prepare_nic(self, profile, device):
248 """ Configure the NIC for upcoming connection.
250 # Start building iwconfig command line.
251 iwconfig_command = [
252 self.config.get_opt('DEFAULT', 'iwconfig_command'),
253 device,
254 'essid', "'{}'".format(profile['essid']),
257 # Setting key
258 iwconfig_command.append('key')
259 if (not profile['key']) or (profile['key'] == 's:'):
260 iwconfig_command.append('off')
261 else:
262 # Setting this stops association from working, so remove it for now
263 #if profile['security'] != '':
264 #iwconfig_command.append(profile['security'])
265 iwconfig_command.append("'{}'".format(profile['key']))
266 #iwconfig_commands.append( "key %s %s" % ( profile['security'], profile['key'] ) )
268 # Set mode.
269 profile['mode'] = profile['mode'].lower()
270 if profile['mode'] == 'master' or profile['mode'] == 'auto':
271 profile['mode'] = 'managed'
272 iwconfig_command.extend(['mode', profile['mode']])
274 # Set channel.
275 if 'channel' in profile:
276 iwconfig_command.extend(['channel', profile['channel']])
278 # Set AP address (do this last since iwconfig seems to want it only there).
279 iwconfig_command.extend(['ap', profile['bssid']])
281 # Some cards require a commit
282 if self.config.get_opt_as_bool('DEFAULT', 'commit_required'):
283 iwconfig_command.append('commit')
285 logger.debug('iwconfig_command: {}'.format(iwconfig_command))
287 try:
288 misc.shellcmd(iwconfig_command)
289 except CalledProcessError as e:
290 logger.error('Failed to prepare NIC: {}'.format(e))
291 self.msg_pipe.send(Message('ERROR',
292 'Failed to prepare NIC: {}'.format(e)))
293 raise misc.DeviceError('Could not configure wireless options.')
295 def _stop_dhcp(self, device):
296 """ Stop any DHCP client daemons running with our 'device'.
298 logger.info('Stopping any DHCP clients on "{}"'.format(device))
299 if os.access(self.config.get_opt('DHCP', 'pidfile'), os.R_OK):
300 if self.config.get_opt('DHCP', 'kill_args'):
301 dhcp_command = [self.config.get_opt('DHCP', 'command')]
302 dhcp_command.extend(self.config.get_opt('DHCP', 'kill_args').split(' '))
303 dhcp_command.append(device)
304 logger.info("DHCP command: %s" % (dhcp_command, ))
306 # call DHCP client command and wait for return
307 logger.info("Stopping DHCP with kill_args")
308 try:
309 misc.shellcmd(dhcp_command)
310 except CalledProcessError as e:
311 logger.error('Attempt to stop DHCP failed: {}'.format(e))
312 else:
313 logger.info("Stopping DHCP manually...")
314 os.kill(int(open(self.config.get_opt('DHCP', 'pidfile'), mode='r').readline()), SIGTERM)
316 def _start_dhcp(self, device):
317 """ Start a DHCP client daemon on 'device'.
319 logger.debug('Starting DHCP command on "{}"'.format(device))
320 self.msg_pipe.send(Message('STATUS',
321 'Acquiring IP Address (DHCP)'))
322 dhcp_command = [self.config.get_opt('DHCP', 'command')]
323 dhcp_command.extend(self.config.get_opt('DHCP', 'args').split(' '))
324 dhcp_command.append(device)
325 logger.info("dhcp_command: %s" % (dhcp_command, ))
326 try:
327 dhcp_proc = Popen(dhcp_command, stdout=None, stderr=None)
328 except OSError as e:
329 if e.errno == 2:
330 logger.critical('DHCP client not found, ' +
331 'please set this in the preferences.')
332 else:
333 # The DHCP client daemon should timeout on its own, hence
334 # the +3 seconds on timeout so we don't cut the daemon off
335 # while it is finishing up.
336 timeout = self.config.get_opt_as_int('DHCP', 'timeout') + 3
337 tick = 0.25
338 dhcp_status = dhcp_proc.poll()
339 while dhcp_status is None:
340 if timeout < 0:
341 dhcp_proc.terminate()
342 else:
343 timeout = timeout - tick
344 sleep(tick)
345 dhcp_status = dhcp_proc.poll()
346 if self.get_current_ip() is not None:
347 self.msg_pipe.send(Message('STATUS',
348 'Got IP address. Done.'))
349 else:
350 self.msg_pipe.send(Message('STATUS',
351 'Could not get IP address!'))
353 def _stop_wpa(self):
354 """ Stop all WPA supplicants.
356 logger.info("Kill off any existing WPA supplicants running...")
357 if os.access(self.config.get_opt('WPA', 'pidfile'), os.R_OK):
358 logger.info("Killing existing WPA supplicant...")
359 try:
360 if self.config.get_opt('WPA', 'kill_command'):
361 wpa_command = [self.config.get_opt('WPA', 'kill_command').split(' ')]
362 try:
363 misc.shellcmd(wpa_command)
364 except CalledProcessError as e:
365 logger.error('Attempt to stop WPA supplicant ' + \
366 'failed: {}'.format(e))
367 else:
368 os.kill(int(open(self.config.get_opt('WPA', 'pidfile'), mode='r').readline()), SIGTERM)
369 except OSError:
370 logger.info("Failed to kill WPA supplicant")
372 def _start_wpa(self):
373 """ Start WPA supplicant and let it associate with the AP.
375 self.msg_pipe.send(Message('STATUS',
376 'WPA supplicant starting'))
378 wpa_command = [self.config.get_opt('WPA', 'command')]
379 wpa_command.extend(self.config.get_opt('WPA', 'args').split(' '))
380 try:
381 misc.shellcmd(wpa_command)
382 except CalledProcessError as e:
383 logger.error('WPA supplicant failed to start: {}'.format(e))
385 def _start_manual_network(self, profile, device):
386 """ Manually configure network settings after association.
388 # Bring down the interface before trying to make changes.
389 ifconfig_command = [
390 self.config.get_opt('DEFAULT', 'ifconfig_command'),
391 device, 'down']
392 try:
393 misc.shellcmd(ifconfig_command)
394 except CalledProcessError as e:
395 logger.error('Device "{}" failed to go down: {}'.format(device, e))
396 # Bring the interface up with our manual IP.
397 ifconfig_command = [
398 self.config.get_opt('DEFAULT', 'ifconfig_command'),
399 device, profile['ip'],
400 'netmask', profile['netmask']]
401 try:
402 misc.shellcmd(ifconfig_command)
403 except CalledProcessError as e:
404 logger.error('Device "{}" failed to configure: {}'.format(device, e))
405 # Configure routing information.
406 route_command = [
407 self.config.get_opt('DEFAULT', 'route_command'),
408 'add', 'default', 'gw', profile['gateway']]
409 try:
410 misc.shellcmd(route_command)
411 except CalledProcessError as e:
412 logger.error('Failed to configure routing information: {}'.format(e))
413 # Build the /etc/resolv.conf file, if needed.
414 resolv_contents = ''
415 if profile['domain']:
416 resolv_contents += "domain {}\n".format(profile['domain'])
417 if profile['dns1']:
418 resolv_contents += "nameserver {}\n".format(profile['dns1'])
419 if profile['dns2']:
420 resolv_contents += "nameserver {}\n".format(profile['dns2'])
421 if resolv_contents:
422 with open('/etc/resolv.conf', 'w') as resolv_file:
423 resolv_file.write(resolv_contents)
425 def connect(self, profile):
426 """ Connect to the access point specified by 'profile'.
428 if not profile['bssid']:
429 raise ValueError('missing BSSID')
430 logger.info("Connecting to the {} ({}) network".format(
431 profile['essid'], profile['bssid']))
433 device = self.config.get_network_device()
435 # Ready to dance...
436 self.msg_pipe.send(Message('STATUS', 'starting con_prescript'))
437 self._run_script('con_prescript', profile, device)
438 self.msg_pipe.send(Message('STATUS', 'con_prescript has run'))
440 self._prepare_nic(profile, device)
442 self._stop_dhcp(device)
443 self._stop_wpa()
445 logger.debug("Disable scan while connection attempt in progress...")
446 self.msg_pipe.send(Message('SCAN-STOP', ''))
448 if profile['use_wpa'] :
449 self._start_wpa()
451 if profile['use_dhcp'] :
452 self._start_dhcp(device)
453 else:
454 self._start_manual_network(profile, device)
456 # Begin scanning again
457 self.msg_pipe.send(Message('SCAN-START', ''))
459 # Run the connection postscript.
460 self.msg_pipe.send(Message('STATUS', 'starting con_postscript'))
461 self._run_script('con_postscript', profile, device)
462 self.msg_pipe.send(Message('STATUS', 'con_postscript has run'))
464 self.msg_pipe.send(Message('STATUS', 'connected'))
466 def disconnect(self, profile):
467 """ Disconnect from the AP with which a connection has been
468 established/attempted.
470 logger.info("Disconnecting")
472 # Pause scanning while manipulating card
473 self.msg_pipe.send(Message('SCAN-STOP', ''))
475 device = self.config.get_network_device()
477 self.msg_pipe.send(Message('STATUS', 'starting dis_prescript'))
478 self._run_script('dis_prescript', profile, device)
479 self.msg_pipe.send(Message('STATUS', 'dis_prescript has run'))
481 self._stop_dhcp(device)
483 self._stop_wpa()
485 # Clear out the wireless stuff.
486 iwconfig_command = [
487 self.config.get_opt('DEFAULT.iwconfig_command'),
488 device,
489 'essid', 'any',
490 'key', 'off',
491 'mode', 'managed',
492 'channel', 'auto',
493 'ap', 'off']
494 try:
495 misc.shellcmd(iwconfig_command)
496 except CalledProcessError as e:
497 logger.error('Failed to clean up wireless configuration: {}'.format(e))
499 # Since it may be brought back up by the next scan, unset its IP.
500 ifconfig_command = [
501 self.config.get_opt('DEFAULT.ifconfig_command'),
502 device,
503 '0.0.0.0']
504 try:
505 misc.shellcmd(ifconfig_command)
506 except CalledProcessError as e:
507 logger.error('Failed to unset IP address: {}'.format(e))
509 # Now take the interface down. Taking down the interface too
510 # quickly can crash my system, so pause a moment.
511 sleep(0.25)
512 self.if_change('down')
514 self.msg_pipe.send(Message('STATUS', 'starting dis_postscript'))
515 self._run_script('dis_postscript', profile, device)
516 self.msg_pipe.send(Message('STATUS', 'dis_postscript has run'))
518 logger.info("Disconnect complete.")
520 # Begin scanning again
521 self.msg_pipe.send(Message('SCAN-START', ''))
523 def if_change(self, state):
524 """ Change the interface to 'state', i.e. 'up' or 'down'.
526 'if_change' raises ValueError if 'state' is not recognized,
527 raises OSError if there is a problem running ifconfig, and
528 raises DeviceError if ifconfig reports the change failed.
530 state = state.lower()
531 if ((state == 'up') or (state == 'down')):
532 device = self.config.get_network_device()
533 if device:
534 ifconfig_command = [
535 self.config.get_opt('DEFAULT', 'ifconfig_command'),
536 device, state]
537 try:
538 logger.info('changing interface ' +
539 '{} state to {}'.format(device, state))
540 ifconfig_info = Popen(ifconfig_command, stdout=PIPE,
541 stderr=STDOUT).stdout
542 except OSError as e:
543 if e.errno == 2:
544 logger.critical("ifconfig command not found, " +
545 "please set this in the preferences.")
546 raise e
547 else:
548 for line in ifconfig_info:
549 if len(line) > 0:
550 raise misc.DeviceError('Could not change ' +
551 'device state: {}'.format(line))
552 else:
553 raise ValueError('unrecognized state for device: {}'.format(state))
555 def get_current_ip(self):
556 """ Return the current IP address as a string or None.
558 device = self.config.get_network_device()
559 if device:
560 ifconfig_command = [
561 self.config.get_opt('DEFAULT', 'ifconfig_command'),
562 device]
563 try:
564 ifconfig_info = Popen(ifconfig_command, stdout=PIPE).stdout
565 # Be careful to the language (inet adr: in French for example)
567 # Hi Brian
569 # I'm using wifi-radar on a system with German translations (de_CH-UTF-8).
570 # There the string in ifconfig is inet Adresse for the IP which isn't
571 # found by the current get_current_ip function in wifi-radar. I changed
572 # the according line (#289; gentoo, v1.9.6-r1) to
573 # >ip_re = re.compile(r'inet [Aa]d?dr[^.]*:([^.]*\.[^.]*\.[^.]*\.[0-9]*)')
574 # which works on my system (LC_ALL=de_CH.UTF-8) and still works with LC_ALL=C.
576 # I'd be happy if you could incorporate this small change because as now
577 # I've got to change the file every time it is updated.
579 # Best wishes
581 # Simon
582 ip_re = re.compile(r'inet [Aa]d?dr[^.]*:([^.]*\.[^.]*\.[^.]*\.[0-9]*)')
583 line = ifconfig_info.read()
584 if ip_re.search(line):
585 return ip_re.search(line).group(1)
586 except OSError as e:
587 if e.errno == 2:
588 logger.critical("ifconfig command not found, " +
589 "please set this in the preferences.")
590 return None
592 def get_current_essid(self):
593 """ Return the current ESSID as a string or None.
595 device = self.config.get_network_device()
596 if device:
597 iwconfig_command = [
598 self.config.get_opt('DEFAULT', 'iwconfig_command'),
599 device]
600 try:
601 iwconfig_info = Popen(iwconfig_command, stdout=PIPE, stderr=STDOUT).stdout
602 essid_re = re.compile(r'ESSID\s*(:|=)\s*"([^"]+)"',
603 re.I | re.M | re.S)
604 line = iwconfig_info.read()
605 if essid_re.search(line):
606 return essid_re.search(line).group(2)
607 except OSError as e:
608 if e.errno == 2:
609 logger.critical("iwconfig command not found, " +
610 "please set this in the preferences.")
611 return None
613 def get_current_bssid(self):
614 """ Return the current BSSID as a string or None.
616 device = self.config.get_network_device()
617 if device:
618 iwconfig_command = [
619 self.config.get_opt('DEFAULT', 'iwconfig_command'),
620 device]
621 try:
622 iwconfig_info = Popen(iwconfig_command, stdout=PIPE, stderr=STDOUT).stdout
623 bssid_re = re.compile(r'Access Point\s*(:|=)\s*([a-fA-F0-9:]{17})',
624 re.I | re.M | re.S )
625 line = iwconfig_info.read()
626 if bssid_re.search(line):
627 return bssid_re.search(line).group(2)
628 except OSError as e:
629 if e.errno == 2:
630 logger.critical("iwconfig command not found, " +
631 "please set this in the preferences.")
632 return None
635 # Make so we can be imported
636 if __name__ == "__main__":
637 pass