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
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
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',
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.
71 # Setup our pattern matchers. The important value must be the second
72 # regular expression group in the pattern.
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)
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
)
96 if msg
.topic
== 'EXIT':
99 elif msg
.topic
== 'SCAN-START':
101 elif msg
.topic
== 'SCAN-STOP':
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.')
112 # update the signal strengths
114 config_manager
.get_opt('DEFAULT', 'iwlist_command'),
117 scandata
= Popen(iwlist_command
, stdout
=PIPE
,
118 stderr
=STDOUT
).stdout
120 logger
.critical('iwlist command not found, ' +
121 'please set this in the preferences.')
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
)):
131 for pattern
in patterns
:
132 m
= patterns
[pattern
].search(line
)
134 profile
[pattern
] = m
.group(2)
135 # Stop looking for more matches on this line.
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
158 self
.msg_pipe
= msg_pipe
159 self
._watching
= Event()
163 """ Watch for incoming messages.
165 while self
._watching
.is_set():
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
)
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.
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
:
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()))
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'
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'])
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
}
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.
252 self
.config
.get_opt('DEFAULT', 'iwconfig_command'),
254 'essid', "'{}'".format(profile
['essid']),
258 iwconfig_command
.append('key')
259 if (not profile
['key']) or (profile
['key'] == 's:'):
260 iwconfig_command
.append('off')
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'] ) )
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']])
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
))
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")
309 misc
.shellcmd(dhcp_command
)
310 except CalledProcessError
as e
:
311 logger
.error('Attempt to stop DHCP failed: {}'.format(e
))
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
, ))
327 dhcp_proc
= Popen(dhcp_command
, stdout
=None, stderr
=None)
330 logger
.critical('DHCP client not found, ' +
331 'please set this in the preferences.')
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
338 dhcp_status
= dhcp_proc
.poll()
339 while dhcp_status
is None:
341 dhcp_proc
.terminate()
343 timeout
= timeout
- 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.'))
350 self
.msg_pipe
.send(Message('STATUS',
351 'Could not get IP address!'))
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...")
360 if self
.config
.get_opt('WPA', 'kill_command'):
361 wpa_command
= [self
.config
.get_opt('WPA', 'kill_command').split(' ')]
363 misc
.shellcmd(wpa_command
)
364 except CalledProcessError
as e
:
365 logger
.error('Attempt to stop WPA supplicant ' + \
366 'failed: {}'.format(e
))
368 os
.kill(int(open(self
.config
.get_opt('WPA', 'pidfile'), mode
='r').readline()), SIGTERM
)
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(' '))
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.
390 self
.config
.get_opt('DEFAULT', 'ifconfig_command'),
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.
398 self
.config
.get_opt('DEFAULT', 'ifconfig_command'),
399 device
, profile
['ip'],
400 'netmask', profile
['netmask']]
402 misc
.shellcmd(ifconfig_command
)
403 except CalledProcessError
as e
:
404 logger
.error('Device "{}" failed to configure: {}'.format(device
, e
))
405 # Configure routing information.
407 self
.config
.get_opt('DEFAULT', 'route_command'),
408 'add', 'default', 'gw', profile
['gateway']]
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.
415 if profile
['domain']:
416 resolv_contents
+= "domain {}\n".format(profile
['domain'])
418 resolv_contents
+= "nameserver {}\n".format(profile
['dns1'])
420 resolv_contents
+= "nameserver {}\n".format(profile
['dns2'])
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()
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
)
445 logger
.debug("Disable scan while connection attempt in progress...")
446 self
.msg_pipe
.send(Message('SCAN-STOP', ''))
448 if profile
['use_wpa'] :
451 if profile
['use_dhcp'] :
452 self
._start
_dhcp
(device
)
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
)
485 # Clear out the wireless stuff.
487 self
.config
.get_opt('DEFAULT.iwconfig_command'),
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.
501 self
.config
.get_opt('DEFAULT.ifconfig_command'),
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.
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()
535 self
.config
.get_opt('DEFAULT', 'ifconfig_command'),
538 logger
.info('changing interface ' +
539 '{} state to {}'.format(device
, state
))
540 ifconfig_info
= Popen(ifconfig_command
, stdout
=PIPE
,
541 stderr
=STDOUT
).stdout
544 logger
.critical("ifconfig command not found, " +
545 "please set this in the preferences.")
548 for line
in ifconfig_info
:
550 raise misc
.DeviceError('Could not change ' +
551 'device state: {}'.format(line
))
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()
561 self
.config
.get_opt('DEFAULT', 'ifconfig_command'),
564 ifconfig_info
= Popen(ifconfig_command
, stdout
=PIPE
).stdout
565 # Be careful to the language (inet adr: in French for example)
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.
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)
588 logger
.critical("ifconfig command not found, " +
589 "please set this in the preferences.")
592 def get_current_essid(self
):
593 """ Return the current ESSID as a string or None.
595 device
= self
.config
.get_network_device()
598 self
.config
.get_opt('DEFAULT', 'iwconfig_command'),
601 iwconfig_info
= Popen(iwconfig_command
, stdout
=PIPE
, stderr
=STDOUT
).stdout
602 essid_re
= re
.compile(r
'ESSID\s*(:|=)\s*"([^"]+)"',
604 line
= iwconfig_info
.read()
605 if essid_re
.search(line
):
606 return essid_re
.search(line
).group(2)
609 logger
.critical("iwconfig command not found, " +
610 "please set this in the preferences.")
613 def get_current_bssid(self
):
614 """ Return the current BSSID as a string or None.
616 device
= self
.config
.get_network_device()
619 self
.config
.get_opt('DEFAULT', 'iwconfig_command'),
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})',
625 line
= iwconfig_info
.read()
626 if bssid_re
.search(line
):
627 return bssid_re
.search(line
).group(2)
630 logger
.critical("iwconfig command not found, " +
631 "please set this in the preferences.")
635 # Make so we can be imported
636 if __name__
== "__main__":