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 <robinson@tuxfamily.org>
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:
30 # Free Software Foundation, Inc.
31 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
35 from __future__
import unicode_literals
37 from configparser
import NoOptionError
, NoSectionError
41 from signal
import SIGTERM
42 from subprocess
import CalledProcessError
, Popen
, PIPE
, STDOUT
43 from threading
import Event
44 from time
import sleep
46 from wifiradar
.config
import make_section_name
, ConfigManager
47 from wifiradar
.misc
import *
48 from wifiradar
.pubsub
import Message
51 logger
= logging
.getLogger(__name__
)
54 def get_enc_mode(use_wpa
, key
):
55 """ Return the WiFi encryption mode based on the combination of
56 'use_wpa' and 'key'. Possible return values are 'wpa', 'wep',
67 def scanner(config_manager
, msg_pipe
):
68 """ Scan for access point information and send a profile for each.
69 :data:`config_manager` is a :class:`ConfigManager` object with
70 minimal configuration information (i.e. device and iwlist command).
71 :data:`msg_pipe` is a :class:`multiprocessing.Connection` object
72 used to receive commands and report AP profiles.
74 # Setup our pattern matchers. The important value must be the second
75 # regular expression group in the pattern.
77 patterns
['essid'] = re
.compile("ESSID\s*(:|=)\s*\"([^\"]+)\"", re
.I | re
.M | re
.S
)
78 patterns
['bssid'] = re
.compile("Address\s*(:|=)\s*([a-zA-Z0-9:]+)", re
.I | re
.M | re
.S
)
79 patterns
['protocol'] = re
.compile("Protocol\s*(:|=)\s*IEEE 802.11\s*([abgn]+)", re
.I | re
.M | re
.S
)
80 patterns
['mode'] = re
.compile("Mode\s*(:|=)\s*([^\n]+)", re
.I | re
.M | re
.S
)
81 patterns
['channel'] = re
.compile("Channel\s*(:|=)*\s*(\d+)", re
.I | re
.M | re
.S
)
82 patterns
['encrypted'] = re
.compile("Encryption key\s*(:|=)\s*(on|off)", re
.I | re
.M | re
.S
)
83 patterns
['signal'] = re
.compile("Signal level\s*(:|=)\s*(-?[0-9]+)", re
.I | re
.M | re
.S
)
85 trans_enc
= dict(on
=True, off
=False)
93 except (EOFError, IOError) as e
:
94 # This is bad, really bad.
95 logger
.critical(_('read on closed Pipe '
96 '({PIPE}), failing...').format(PIPE
=msg_pipe
))
99 if msg
.topic
== 'EXIT':
102 elif msg
.topic
== 'SCAN-START':
104 elif msg
.topic
== 'SCAN-STOP':
107 device
= config_manager
.get_network_device()
108 except NoDeviceError
as e
:
109 logger
.critical(_('Wifi device not found, '
110 'please set this in the preferences.'))
115 # update the signal strengths
117 config_manager
.get_opt('GENERAL', 'iwlist_command'),
120 scandata
= Popen(iwlist_command
, stdout
=PIPE
,
121 stderr
=STDOUT
).stdout
123 logger
.critical(_('iwlist command not found, '
124 'please set this in the preferences.'))
128 # Start with a blank profile to fill in.
129 profile
= get_new_profile()
130 # It's cleaner to code the gathering of AP profiles
131 # from bottom to top with the iwlist output.
132 for line
in reversed(list(scandata
)):
134 for pattern
in patterns
:
135 m
= patterns
[pattern
].search(line
)
137 profile
[pattern
] = m
.group(2)
138 # Stop looking for more matches on this line.
141 if line
.startswith('Cell '):
142 # Each AP starts with the keyword "Cell",
143 # which mean we now have one whole profile.
144 # But, first translate the 'encrypted'
145 # property to a boolean value.
146 profile
['encrypted'] = trans_enc
[profile
['encrypted']]
147 msg_pipe
.send(Message('ACCESSPOINT', profile
))
148 # Restart with a blank profile to fill in.
149 profile
= get_new_profile()
152 class ConnectionManager(object):
153 """ Manage a connection; including reporting connection state,
154 connecting/disconnecting from an AP, and returning current IP, ESSID, and BSSID.
156 def __init__(self
, msg_pipe
):
157 """ Create a new connection manager which communicates with a
158 controlling process or thread through 'msg_pipe' (a Pipe
161 self
.msg_pipe
= msg_pipe
162 self
._watching
= Event()
166 """ Watch for incoming messages.
168 while self
._watching
.is_set():
170 msg
= self
.msg_pipe
.recv()
171 except (EOFError, IOError) as e
:
172 # This is bad, really bad.
173 logger
.critical(_('read on closed Pipe '
174 '({PIPE}), failing...').format(PIPE
=self
.msg_pipe
))
175 self
._watching
.clear()
178 self
._check
_message
(msg
)
180 def _check_message(self
, msg
):
181 """ Process incoming messages.
183 if msg
.topic
== 'EXIT':
184 self
.msg_pipe
.close()
185 self
._watching
.clear()
186 elif msg
.topic
== 'CONFIG-UPDATE':
187 # Replace configuration manager with the one in msg.details.
188 self
.set_config(msg
.details
)
189 elif msg
.topic
== 'CONNECT':
190 # Try to connect to the profile in msg.details.
191 self
.connect(msg
.details
)
192 elif msg
.topic
== 'DISCONNECT':
193 # Try to disconnect from the profile in msg.details.
194 self
.disconnect(msg
.details
)
195 elif msg
.topic
== 'IF-CHANGE':
196 # Try to connect to the profile in msg.details.
198 self
.if_change(msg
.details
)
199 except DeviceError
as e
:
200 self
.msg_pipe
.send(Message('ERROR', e
))
201 except ValueError as e
:
203 elif msg
.topic
== 'QUERY-IP':
204 # Send out a message with the current IP address.
205 self
.msg_pipe
.send(Message('ANN-IP', self
.get_current_ip()))
206 elif msg
.topic
== 'QUERY-ESSID':
207 # Send out a message with the current ESSID.
208 self
.msg_pipe
.send(Message('ANN-ESSID', self
.get_current_essid()))
209 elif msg
.topic
== 'QUERY-BSSID':
210 # Send out a message with the current BSSID.
211 self
.msg_pipe
.send(Message('ANN-BSSID', self
.get_current_bssid()))
213 logger
.warning(_('unrecognized Message: "{MSG}"').format(MSG
=msg
))
215 def set_config(self
, config_manager
):
216 """ Set the configuration manager to 'config_manager'. This method
217 must be called before any other public method or a caller will
218 likely receive an AttributeError about a missing 'config_manager'
221 Raises TypeError if 'config_manager' is not a ConfigManager object.
223 if not isinstance(config_manager
, ConfigManager
):
224 raise TypeError(_('config must be a ConfigManager object'))
225 self
.config
= config_manager
227 def _run_script(self
, script_name
, profile
, device
):
228 """ Run the script (e.g. connection prescript) in 'profile' which
229 is named in 'script_name'.
231 if profile
[script_name
]:
232 logger
.info('running {NAME}'.format(NAME
=script_name
))
233 profile_name
= make_section_name(profile
['essid'], profile
['bssid'])
234 enc_mode
= get_enc_mode(profile
['use_wpa'], profile
['key'])
236 'WIFIRADAR_IP': self
.get_current_ip(),
237 'WIFIRADAR_ESSID': self
.get_current_essid(),
238 'WIFIRADAR_BSSID': self
.get_current_bssid(),
239 'WIFIRADAR_PROFILE': profile_name
,
240 'WIFIRADAR_ENCMODE': enc_mode
,
241 'WIFIRADAR_SECMODE': profile
['security'],
242 'WIFIRADAR_IF': device
}
244 shellcmd(profile
[script_name
].split(' '), custom_env
)
245 except CalledProcessError
as e
:
246 logger
.error(_('script "{NAME}" failed: {EXC}').format(
247 NAME
=script_name
, EXC
=e
))
248 self
.msg_pipe
.send(Message('ERROR',
249 _('script "{NAME}" failed: {EXC}').format(
250 NAME
=script_name
, EXC
=e
)))
252 def _prepare_nic(self
, profile
, device
):
253 """ Configure the NIC for upcoming connection.
255 # Start building iwconfig command line.
257 self
.config
.get_opt('GENERAL', 'iwconfig_command'),
259 'essid', "'{ESSID}'".format(ESSID
=profile
['essid']),
263 iwconfig_command
.append('key')
264 if (not profile
['key']) or (profile
['key'] == 's:'):
265 iwconfig_command
.append('off')
267 # Setting this stops association from working, so remove it for now
268 #if profile['security'] != '':
269 #iwconfig_command.append(profile['security'])
270 iwconfig_command
.append("'{KEY}'".format(KEY
=profile
['key']))
271 #iwconfig_commands.append( "key %s %s" % ( profile['security'], profile['key'] ) )
274 profile
['mode'] = profile
['mode'].lower()
275 if profile
['mode'] == 'master' or profile
['mode'] == 'auto':
276 profile
['mode'] = 'managed'
277 iwconfig_command
.extend(['mode', profile
['mode']])
280 if 'channel' in profile
:
281 iwconfig_command
.extend(['channel', profile
['channel']])
283 # Set AP address (do this last since iwconfig seems to want it only there).
284 iwconfig_command
.extend(['ap', profile
['bssid']])
286 # Some cards require a commit
287 if self
.config
.get_opt_as_bool('GENERAL', 'commit_required'):
288 iwconfig_command
.append('commit')
290 logger
.debug('iwconfig_command: {IWC}'.format(IWC
=iwconfig_command
))
293 shellcmd(iwconfig_command
)
294 except CalledProcessError
as e
:
295 logger
.error('Failed to prepare NIC: {EXC}'.format(EXC
=e
))
296 self
.msg_pipe
.send(Message('ERROR',
297 _('Failed to prepare NIC: {EXC}').format(EXC
=e
)))
298 raise DeviceError(_('Could not configure wireless options.'))
300 def _stop_dhcp(self
, device
):
301 """ Stop any DHCP client daemons running with our 'device'.
303 logger
.info(_('Stopping any DHCP clients on "{DEV}"').format(DEV
=device
))
304 if os
.access(self
.config
.get_opt('DHCP', 'pidfile'), os
.R_OK
):
305 if self
.config
.get_opt('DHCP', 'kill_args'):
306 dhcp_command
= [self
.config
.get_opt('DHCP', 'command')]
307 dhcp_command
.extend(self
.config
.get_opt('DHCP', 'kill_args').split(' '))
308 dhcp_command
.append(device
)
309 logger
.info(_('DHCP command: {DHCP}').format(DHCP
=dhcp_command
))
311 # call DHCP client command and wait for return
312 logger
.info(_('Stopping DHCP with kill_args'))
314 shellcmd(dhcp_command
)
315 except CalledProcessError
as e
:
316 logger
.error(_('Attempt to stop DHCP failed: '
317 '{EXC}').format(EXC
=e
))
319 logger
.info(_('Stopping DHCP manually...'))
320 os
.kill(int(open(self
.config
.get_opt('DHCP', 'pidfile'), mode
='r').readline()), SIGTERM
)
322 def _start_dhcp(self
, device
):
323 """ Start a DHCP client daemon on 'device'.
325 logger
.debug('Starting DHCP command on "{DEV}"'.format(DEV
=device
))
326 self
.msg_pipe
.send(Message('STATUS', _('Acquiring IP Address (DHCP)')))
327 dhcp_command
= [self
.config
.get_opt('DHCP', 'command')]
328 dhcp_command
.extend(self
.config
.get_opt('DHCP', 'args').split(' '))
329 dhcp_command
.append(device
)
330 logger
.info(_('dhcp_command: {DHCP}').format(DHCP
=dhcp_command
))
332 dhcp_proc
= Popen(dhcp_command
, stdout
=None, stderr
=None)
335 logger
.critical(_('DHCP client not found, '
336 'please set this in the preferences.'))
338 # The DHCP client daemon should timeout on its own, hence
339 # the +3 seconds on timeout so we don't cut the daemon off
340 # while it is finishing up.
341 timeout
= self
.config
.get_opt_as_int('DHCP', 'timeout') + 3
343 dhcp_status
= dhcp_proc
.poll()
344 while dhcp_status
is None:
346 dhcp_proc
.terminate()
348 timeout
= timeout
- tick
350 dhcp_status
= dhcp_proc
.poll()
351 if self
.get_current_ip() is not None:
352 self
.msg_pipe
.send(Message('STATUS',
353 _('Got IP address. Done.')))
355 self
.msg_pipe
.send(Message('STATUS',
356 _('Could not get IP address!')))
359 """ Stop all WPA supplicants.
361 logger
.info(_('Kill off any existing WPA supplicants running...'))
362 if os
.access(self
.config
.get_opt('WPA', 'pidfile'), os
.R_OK
):
363 logger
.info(_('Killing existing WPA supplicant...'))
365 if self
.config
.get_opt('WPA', 'kill_command'):
366 wpa_command
= [self
.config
.get_opt('WPA', 'kill_command').split(' ')]
368 shellcmd(wpa_command
)
369 except CalledProcessError
as e
:
370 logger
.error(_('Attempt to stop WPA supplicant '
371 'failed: {EXC}').format(EXC
=e
))
373 os
.kill(int(open(self
.config
.get_opt('WPA', 'pidfile'), mode
='r').readline()), SIGTERM
)
375 logger
.info(_('Failed to kill WPA supplicant.'))
377 def _start_wpa(self
):
378 """ Start WPA supplicant and let it associate with the AP.
380 self
.msg_pipe
.send(Message('STATUS', _('WPA supplicant starting')))
382 wpa_command
= [self
.config
.get_opt('WPA', 'command')]
383 wpa_command
.extend(self
.config
.get_opt('WPA', 'args').split(' '))
385 shellcmd(wpa_command
)
386 except CalledProcessError
as e
:
387 logger
.error(_('WPA supplicant failed to start: '
388 '{EXC}').format(EXC
=e
))
390 def _start_manual_network(self
, profile
, device
):
391 """ Manually configure network settings after association.
393 # Bring down the interface before trying to make changes.
395 self
.config
.get_opt('GENERAL', 'ifconfig_command'),
398 shellcmd(ifconfig_command
)
399 except CalledProcessError
as e
:
400 logger
.error(_('Device "{DEV}" failed to go down: '
401 '{EXC}').format(DEV
=device
, EXC
=e
))
402 # Bring the interface up with our manual IP.
404 self
.config
.get_opt('GENERAL', 'ifconfig_command'),
405 device
, profile
['ip'],
406 'netmask', profile
['netmask']]
408 shellcmd(ifconfig_command
)
409 except CalledProcessError
as e
:
410 logger
.error(_('Device "{DEV}" failed to configure: '
411 '{EXC}').format(DEV
=device
, EXC
=e
))
412 # Configure routing information.
414 self
.config
.get_opt('GENERAL', 'route_command'),
415 'add', 'default', 'gw', profile
['gateway']]
417 shellcmd(route_command
)
418 except CalledProcessError
as e
:
419 logger
.error(_('Failed to configure routing information: '
420 '{EXC}').format(EXC
=e
))
421 # Build the /etc/resolv.conf file, if needed.
423 if profile
['domain']:
424 resolv_contents
+= 'domain {DOM}\n'.format(DOM
=profile
['domain'])
426 resolv_contents
+= 'nameserver {NS1}\n'.format(NS1
=profile
['dns1'])
428 resolv_contents
+= 'nameserver {NS2}\n'.format(NS2
=profile
['dns2'])
430 with
open('/etc/resolv.conf', 'w') as resolv_file
:
431 resolv_file
.write(resolv_contents
)
433 def connect(self
, profile
):
434 """ Connect to the access point specified by 'profile'.
436 if not profile
['bssid']:
437 raise ValueError('missing BSSID')
438 logger
.info(_('Connecting to the {ESSID} ({BSSID}) network').format(
439 ESSID
=profile
['essid'], BSSID
=profile
['bssid']))
441 device
= self
.config
.get_network_device()
444 self
.msg_pipe
.send(Message('STATUS', 'starting con_prescript'))
445 self
._run
_script
('con_prescript', profile
, device
)
446 self
.msg_pipe
.send(Message('STATUS', 'con_prescript has run'))
448 self
._prepare
_nic
(profile
, device
)
450 self
._stop
_dhcp
(device
)
453 logger
.debug(_('Disable scan while connection attempt in progress...'))
454 self
.msg_pipe
.send(Message('SCAN-STOP', ''))
456 if profile
['use_wpa'] :
459 if profile
['use_dhcp'] :
460 self
._start
_dhcp
(device
)
462 self
._start
_manual
_network
(profile
, device
)
464 # Begin scanning again
465 self
.msg_pipe
.send(Message('SCAN-START', ''))
467 # Run the connection postscript.
468 self
.msg_pipe
.send(Message('STATUS', 'starting con_postscript'))
469 self
._run
_script
('con_postscript', profile
, device
)
470 self
.msg_pipe
.send(Message('STATUS', 'con_postscript has run'))
472 self
.msg_pipe
.send(Message('STATUS', 'connected'))
474 def disconnect(self
, profile
):
475 """ Disconnect from the AP with which a connection has been
476 established/attempted.
478 logger
.info(_('Disconnecting...'))
480 # Pause scanning while manipulating card
481 self
.msg_pipe
.send(Message('SCAN-STOP', ''))
483 device
= self
.config
.get_network_device()
485 self
.msg_pipe
.send(Message('STATUS', 'starting dis_prescript'))
486 self
._run
_script
('dis_prescript', profile
, device
)
487 self
.msg_pipe
.send(Message('STATUS', 'dis_prescript has run'))
489 self
._stop
_dhcp
(device
)
493 # Clear out the wireless stuff.
495 self
.config
.get_opt('GENERAL.iwconfig_command'),
503 shellcmd(iwconfig_command
)
504 except CalledProcessError
as e
:
505 logger
.error(_('Failed to clean up wireless configuration: '
506 '{EXC}').format(EXC
=e
))
508 # Since it may be brought back up by the next scan, unset its IP.
510 self
.config
.get_opt('GENERAL.ifconfig_command'),
514 shellcmd(ifconfig_command
)
515 except CalledProcessError
as e
:
516 logger
.error(_('Failed to unset IP address: {EXC}').format(EXC
=e
))
518 # Now take the interface down. Taking down the interface too
519 # quickly can crash my system, so pause a moment.
521 self
.if_change('down')
523 self
.msg_pipe
.send(Message('STATUS', 'starting dis_postscript'))
524 self
._run
_script
('dis_postscript', profile
, device
)
525 self
.msg_pipe
.send(Message('STATUS', 'dis_postscript has run'))
527 logger
.info(_('Disconnect complete.'))
529 # Begin scanning again
530 self
.msg_pipe
.send(Message('SCAN-START', ''))
532 def if_change(self
, state
):
533 """ Change the interface to 'state', i.e. 'up' or 'down'.
535 'if_change' raises ValueError if 'state' is not recognized,
536 raises OSError if there is a problem running ifconfig, and
537 raises DeviceError if ifconfig reports the change failed.
539 state
= state
.lower()
540 if ((state
== 'up') or (state
== 'down')):
541 device
= self
.config
.get_network_device()
544 self
.config
.get_opt('GENERAL', 'ifconfig_command'),
547 logger
.info('changing interface {DEV} '
548 'state to {STATE}'.format(DEV
=device
, STATE
=state
))
549 ifconfig_info
= Popen(ifconfig_command
, stdout
=PIPE
,
550 stderr
=STDOUT
).stdout
553 logger
.critical(_('ifconfig command not found, '
554 'please set this in the preferences.'))
557 for line
in ifconfig_info
:
559 raise DeviceError(_('Could not change '
560 'device state: {ERR}').format(ERR
=line
))
562 raise ValueError(_('unrecognized state for device: '
563 '{STATE}').format(STATE
=state
))
565 def get_current_ip(self
):
566 """ Return the current IP address as a string or None.
568 device
= self
.config
.get_network_device()
571 self
.config
.get_opt('GENERAL', 'ifconfig_command'),
574 ifconfig_info
= Popen(ifconfig_command
, stdout
=PIPE
).stdout
575 # Be careful to the language (inet adr: in French for example)
579 # I'm using wifi-radar on a system with German translations (de_CH-UTF-8).
580 # There the string in ifconfig is inet Adresse for the IP which isn't
581 # found by the current get_current_ip function in wifi-radar. I changed
582 # the according line (#289; gentoo, v1.9.6-r1) to
583 # >ip_re = re.compile(r'inet [Aa]d?dr[^.]*:([^.]*\.[^.]*\.[^.]*\.[0-9]*)')
584 # which works on my system (LC_ALL=de_CH.UTF-8) and still works with LC_ALL=C.
586 # I'd be happy if you could incorporate this small change because as now
587 # I've got to change the file every time it is updated.
592 ip_re
= re
.compile(r
'inet [Aa]d?dr[^.]*:([^.]*\.[^.]*\.[^.]*\.[0-9]*)')
593 line
= ifconfig_info
.read()
594 if ip_re
.search(line
):
595 return ip_re
.search(line
).group(1)
598 logger
.critical(_('ifconfig command not found, '
599 'please set this in the preferences.'))
602 def get_current_essid(self
):
603 """ Return the current ESSID as a string or None.
605 device
= self
.config
.get_network_device()
608 self
.config
.get_opt('GENERAL', 'iwconfig_command'),
611 iwconfig_info
= Popen(iwconfig_command
, stdout
=PIPE
, stderr
=STDOUT
).stdout
612 essid_re
= re
.compile(r
'ESSID\s*(:|=)\s*"([^"]+)"',
614 line
= iwconfig_info
.read()
615 if essid_re
.search(line
):
616 return essid_re
.search(line
).group(2)
619 logger
.critical(_('iwconfig command not found, '
620 'please set this in the preferences.'))
623 def get_current_bssid(self
):
624 """ Return the current BSSID as a string or None.
626 device
= self
.config
.get_network_device()
629 self
.config
.get_opt('GENERAL', 'iwconfig_command'),
632 iwconfig_info
= Popen(iwconfig_command
, stdout
=PIPE
, stderr
=STDOUT
).stdout
633 bssid_re
= re
.compile(r
'Access Point\s*(:|=)\s*([a-fA-F0-9:]{17})',
635 line
= iwconfig_info
.read()
636 if bssid_re
.search(line
):
637 return bssid_re
.search(line
).group(2)
640 logger
.critical(_('iwconfig command not found, '
641 'please set this in the preferences.'))
645 # Make so we can be imported
646 if __name__
== '__main__':