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
40 from signal
import SIGTERM
41 from subprocess
import CalledProcessError
, Popen
, PIPE
, STDOUT
42 from threading
import Event
43 from time
import sleep
45 from wifiradar
.config
import make_section_name
, ConfigManager
46 from wifiradar
.misc
import *
47 from wifiradar
.pubsub
import Message
50 from ConfigParser
import NoOptionError
, NoSectionError
52 from configparser
import NoOptionError
, NoSectionError
55 logger
= logging
.getLogger(__name__
)
58 def get_enc_mode(use_wpa
, key
):
59 """ Return the WiFi encryption mode based on the combination of
60 'use_wpa' and 'key'. Possible return values are 'wpa', 'wep',
71 def scanner(config_manager
, msg_pipe
):
72 """ Scan for access point information and send a profile for each.
73 :data:`config_manager` is a :class:`ConfigManager` object with
74 minimal configuration information (i.e. device and iwlist command).
75 :data:`msg_pipe` is a :class:`multiprocessing.Connection` object
76 used to receive commands and report AP profiles.
78 # Setup our pattern matchers. The important value must be the second
79 # regular expression group in the pattern.
81 patterns
['essid'] = re
.compile("ESSID\s*(:|=)\s*\"([^\"]+)\"", re
.I | re
.M | re
.S
)
82 patterns
['bssid'] = re
.compile("Address\s*(:|=)\s*([a-zA-Z0-9:]+)", re
.I | re
.M | re
.S
)
83 patterns
['protocol'] = re
.compile("Protocol\s*(:|=)\s*IEEE 802.11\s*([abgn]+)", re
.I | re
.M | re
.S
)
84 patterns
['mode'] = re
.compile("Mode\s*(:|=)\s*([^\n]+)", re
.I | re
.M | re
.S
)
85 patterns
['channel'] = re
.compile("Channel\s*(:|=)*\s*(\d+)", re
.I | re
.M | re
.S
)
86 patterns
['encrypted'] = re
.compile("Encryption key\s*(:|=)\s*(on|off)", re
.I | re
.M | re
.S
)
87 patterns
['signal'] = re
.compile("Signal level\s*(:|=)\s*(-?[0-9]+)", re
.I | re
.M | re
.S
)
89 trans_enc
= dict(on
=True, off
=False)
97 except (EOFError, IOError) as e
:
98 # This is bad, really bad.
99 logger
.critical(_('read on closed Pipe '
100 '({PIPE}), failing...').format(PIPE
=msg_pipe
))
103 if msg
.topic
== 'EXIT':
106 elif msg
.topic
== 'SCAN-START':
108 elif msg
.topic
== 'SCAN-STOP':
111 device
= config_manager
.get_network_device()
112 except NoDeviceError
as e
:
113 logger
.critical(_('Wifi device not found, '
114 'please set this in the preferences.'))
119 # update the signal strengths
121 config_manager
.get_opt('DEFAULT', 'iwlist_command'),
124 scandata
= Popen(iwlist_command
, stdout
=PIPE
,
125 stderr
=STDOUT
).stdout
127 logger
.critical(_('iwlist command not found, '
128 'please set this in the preferences.'))
132 # Start with a blank profile to fill in.
133 profile
= get_new_profile()
134 # It's cleaner to code the gathering of AP profiles
135 # from bottom to top with the iwlist output.
136 for line
in reversed(list(scandata
)):
138 for pattern
in patterns
:
139 m
= patterns
[pattern
].search(line
)
141 profile
[pattern
] = m
.group(2)
142 # Stop looking for more matches on this line.
145 if line
.startswith('Cell '):
146 # Each AP starts with the keyword "Cell",
147 # which mean we now have one whole profile.
148 # But, first translate the 'encrypted'
149 # property to a boolean value.
150 profile
['encrypted'] = trans_enc
[profile
['encrypted']]
151 msg_pipe
.send(Message('ACCESSPOINT', profile
))
152 # Restart with a blank profile to fill in.
153 profile
= get_new_profile()
156 class ConnectionManager(object):
157 """ Manage a connection; including reporting connection state,
158 connecting/disconnecting from an AP, and returning current IP, ESSID, and BSSID.
160 def __init__(self
, msg_pipe
):
161 """ Create a new connection manager which communicates with a
162 controlling process or thread through 'msg_pipe' (a Pipe
165 self
.msg_pipe
= msg_pipe
166 self
._watching
= Event()
170 """ Watch for incoming messages.
172 while self
._watching
.is_set():
174 msg
= self
.msg_pipe
.recv()
175 except (EOFError, IOError) as e
:
176 # This is bad, really bad.
177 logger
.critical(_('read on closed Pipe '
178 '({PIPE}), failing...').format(PIPE
=self
.msg_pipe
))
179 self
._watching
.clear()
182 self
._check
_message
(msg
)
184 def _check_message(self
, msg
):
185 """ Process incoming messages.
187 if msg
.topic
== 'EXIT':
188 self
.msg_pipe
.close()
189 self
._watching
.clear()
190 elif msg
.topic
== 'CONFIG-UPDATE':
191 # Replace configuration manager with the one in msg.details.
192 self
.set_config(msg
.details
)
193 elif msg
.topic
== 'CONNECT':
194 # Try to connect to the profile in msg.details.
195 self
.connect(msg
.details
)
196 elif msg
.topic
== 'DISCONNECT':
197 # Try to disconnect from the profile in msg.details.
198 self
.disconnect(msg
.details
)
199 elif msg
.topic
== 'IF-CHANGE':
200 # Try to connect to the profile in msg.details.
202 self
.if_change(msg
.details
)
203 except DeviceError
as e
:
204 self
.msg_pipe
.send(Message('ERROR', e
))
205 except ValueError as e
:
207 elif msg
.topic
== 'QUERY-IP':
208 # Send out a message with the current IP address.
209 self
.msg_pipe
.send(Message('ANN-IP', self
.get_current_ip()))
210 elif msg
.topic
== 'QUERY-ESSID':
211 # Send out a message with the current ESSID.
212 self
.msg_pipe
.send(Message('ANN-ESSID', self
.get_current_essid()))
213 elif msg
.topic
== 'QUERY-BSSID':
214 # Send out a message with the current BSSID.
215 self
.msg_pipe
.send(Message('ANN-BSSID', self
.get_current_bssid()))
217 logger
.warning(_('unrecognized Message: "{MSG}"').format(MSG
=msg
))
219 def set_config(self
, config_manager
):
220 """ Set the configuration manager to 'config_manager'. This method
221 must be called before any other public method or a caller will
222 likely receive an AttributeError about a missing 'config_manager'
225 Raises TypeError if 'config_manager' is not a ConfigManager object.
227 if not isinstance(config_manager
, ConfigManager
):
228 raise TypeError(_('config must be a ConfigManager object'))
229 self
.config
= config_manager
231 def _run_script(self
, script_name
, profile
, device
):
232 """ Run the script (e.g. connection prescript) in 'profile' which
233 is named in 'script_name'.
235 if profile
[script_name
]:
236 logger
.info('running {NAME}'.format(NAME
=script_name
))
237 profile_name
= make_section_name(profile
['essid'], profile
['bssid'])
238 enc_mode
= get_enc_mode(profile
['use_wpa'], profile
['key'])
240 "WIFIRADAR_IP": self
.get_current_ip(),
241 "WIFIRADAR_ESSID": self
.get_current_essid(),
242 "WIFIRADAR_BSSID": self
.get_current_bssid(),
243 "WIFIRADAR_PROFILE": profile_name
,
244 "WIFIRADAR_ENCMODE": enc_mode
,
245 "WIFIRADAR_SECMODE": profile
['security'],
246 "WIFIRADAR_IF": device
}
248 shellcmd(profile
[script_name
].split(' '), custom_env
)
249 except CalledProcessError
as e
:
250 logger
.error('script "{NAME}" failed: {EXC}'.format(
251 NAME
=script_name
, EXC
=e
))
252 self
.msg_pipe
.send(Message('ERROR',
253 'script "{NAME}" failed: {EXC}'.format(NAME
=script_name
,
256 def _prepare_nic(self
, profile
, device
):
257 """ Configure the NIC for upcoming connection.
259 # Start building iwconfig command line.
261 self
.config
.get_opt('DEFAULT', 'iwconfig_command'),
263 'essid', "'{ESSID}'".format(ESSID
=profile
['essid']),
267 iwconfig_command
.append('key')
268 if (not profile
['key']) or (profile
['key'] == 's:'):
269 iwconfig_command
.append('off')
271 # Setting this stops association from working, so remove it for now
272 #if profile['security'] != '':
273 #iwconfig_command.append(profile['security'])
274 iwconfig_command
.append("'{KEY}'".format(KEY
=profile
['key']))
275 #iwconfig_commands.append( "key %s %s" % ( profile['security'], profile['key'] ) )
278 profile
['mode'] = profile
['mode'].lower()
279 if profile
['mode'] == 'master' or profile
['mode'] == 'auto':
280 profile
['mode'] = 'managed'
281 iwconfig_command
.extend(['mode', profile
['mode']])
284 if 'channel' in profile
:
285 iwconfig_command
.extend(['channel', profile
['channel']])
287 # Set AP address (do this last since iwconfig seems to want it only there).
288 iwconfig_command
.extend(['ap', profile
['bssid']])
290 # Some cards require a commit
291 if self
.config
.get_opt_as_bool('DEFAULT', 'commit_required'):
292 iwconfig_command
.append('commit')
294 logger
.debug('iwconfig_command: {IWC}'.format(IWC
=iwconfig_command
))
297 shellcmd(iwconfig_command
)
298 except CalledProcessError
as e
:
299 logger
.error('Failed to prepare NIC: {EXC}'.format(EXC
=e
))
300 self
.msg_pipe
.send(Message('ERROR',
301 _('Failed to prepare NIC: {EXC}').format(EXC
=e
)))
302 raise DeviceError(_('Could not configure wireless options.'))
304 def _stop_dhcp(self
, device
):
305 """ Stop any DHCP client daemons running with our 'device'.
307 logger
.info(_('Stopping any DHCP clients on "{DEV}"').format(DEV
=device
))
308 if os
.access(self
.config
.get_opt('DHCP', 'pidfile'), os
.R_OK
):
309 if self
.config
.get_opt('DHCP', 'kill_args'):
310 dhcp_command
= [self
.config
.get_opt('DHCP', 'command')]
311 dhcp_command
.extend(self
.config
.get_opt('DHCP', 'kill_args').split(' '))
312 dhcp_command
.append(device
)
313 logger
.info(_('DHCP command: {DHCP}').format(DHCP
=dhcp_command
))
315 # call DHCP client command and wait for return
316 logger
.info(_('Stopping DHCP with kill_args'))
318 shellcmd(dhcp_command
)
319 except CalledProcessError
as e
:
320 logger
.error(_('Attempt to stop DHCP failed: '
321 '{EXC}').format(EXC
=e
))
323 logger
.info(_('Stopping DHCP manually...'))
324 os
.kill(int(open(self
.config
.get_opt('DHCP', 'pidfile'), mode
='r').readline()), SIGTERM
)
326 def _start_dhcp(self
, device
):
327 """ Start a DHCP client daemon on 'device'.
329 logger
.debug('Starting DHCP command on "{DEV}"'.format(DEV
=device
))
330 self
.msg_pipe
.send(Message('STATUS', _('Acquiring IP Address (DHCP)')))
331 dhcp_command
= [self
.config
.get_opt('DHCP', 'command')]
332 dhcp_command
.extend(self
.config
.get_opt('DHCP', 'args').split(' '))
333 dhcp_command
.append(device
)
334 logger
.info(_('dhcp_command: {DHCP}').format(DHCP
=dhcp_command
))
336 dhcp_proc
= Popen(dhcp_command
, stdout
=None, stderr
=None)
339 logger
.critical(_('DHCP client not found, '
340 'please set this in the preferences.'))
342 # The DHCP client daemon should timeout on its own, hence
343 # the +3 seconds on timeout so we don't cut the daemon off
344 # while it is finishing up.
345 timeout
= self
.config
.get_opt_as_int('DHCP', 'timeout') + 3
347 dhcp_status
= dhcp_proc
.poll()
348 while dhcp_status
is None:
350 dhcp_proc
.terminate()
352 timeout
= timeout
- tick
354 dhcp_status
= dhcp_proc
.poll()
355 if self
.get_current_ip() is not None:
356 self
.msg_pipe
.send(Message('STATUS',
357 _('Got IP address. Done.')))
359 self
.msg_pipe
.send(Message('STATUS',
360 _('Could not get IP address!')))
363 """ Stop all WPA supplicants.
365 logger
.info(_('Kill off any existing WPA supplicants running...'))
366 if os
.access(self
.config
.get_opt('WPA', 'pidfile'), os
.R_OK
):
367 logger
.info(_('Killing existing WPA supplicant...'))
369 if self
.config
.get_opt('WPA', 'kill_command'):
370 wpa_command
= [self
.config
.get_opt('WPA', 'kill_command').split(' ')]
372 shellcmd(wpa_command
)
373 except CalledProcessError
as e
:
374 logger
.error(_('Attempt to stop WPA supplicant '
375 'failed: {EXC}').format(EXC
=e
))
377 os
.kill(int(open(self
.config
.get_opt('WPA', 'pidfile'), mode
='r').readline()), SIGTERM
)
379 logger
.info(_('Failed to kill WPA supplicant.'))
381 def _start_wpa(self
):
382 """ Start WPA supplicant and let it associate with the AP.
384 self
.msg_pipe
.send(Message('STATUS', _('WPA supplicant starting')))
386 wpa_command
= [self
.config
.get_opt('WPA', 'command')]
387 wpa_command
.extend(self
.config
.get_opt('WPA', 'args').split(' '))
389 shellcmd(wpa_command
)
390 except CalledProcessError
as e
:
391 logger
.error(_('WPA supplicant failed to start: '
392 '{EXC}').format(EXC
=e
))
394 def _start_manual_network(self
, profile
, device
):
395 """ Manually configure network settings after association.
397 # Bring down the interface before trying to make changes.
399 self
.config
.get_opt('DEFAULT', 'ifconfig_command'),
402 shellcmd(ifconfig_command
)
403 except CalledProcessError
as e
:
404 logger
.error(_('Device "{DEV}" failed to go down: '
405 '{EXC}').format(DEV
=device
, EXC
=e
))
406 # Bring the interface up with our manual IP.
408 self
.config
.get_opt('DEFAULT', 'ifconfig_command'),
409 device
, profile
['ip'],
410 'netmask', profile
['netmask']]
412 shellcmd(ifconfig_command
)
413 except CalledProcessError
as e
:
414 logger
.error(_('Device "{DEV}" failed to configure: '
415 '{EXC}').format(DEV
=device
, EXC
=e
))
416 # Configure routing information.
418 self
.config
.get_opt('DEFAULT', 'route_command'),
419 'add', 'default', 'gw', profile
['gateway']]
421 shellcmd(route_command
)
422 except CalledProcessError
as e
:
423 logger
.error(_('Failed to configure routing information: '
424 '{EXC}').format(EXC
=e
))
425 # Build the /etc/resolv.conf file, if needed.
427 if profile
['domain']:
428 resolv_contents
+= "domain {DOM}\n".format(DOM
=profile
['domain'])
430 resolv_contents
+= "nameserver {NS1}\n".format(NS1
=profile
['dns1'])
432 resolv_contents
+= "nameserver {NS2}\n".format(NS2
=profile
['dns2'])
434 with
open('/etc/resolv.conf', 'w') as resolv_file
:
435 resolv_file
.write(resolv_contents
)
437 def connect(self
, profile
):
438 """ Connect to the access point specified by 'profile'.
440 if not profile
['bssid']:
441 raise ValueError('missing BSSID')
442 logger
.info(_('Connecting to the {ESSID} ({BSSID}) network').format(
443 ESSID
=profile
['essid'], BSSID
=profile
['bssid']))
445 device
= self
.config
.get_network_device()
448 self
.msg_pipe
.send(Message('STATUS', 'starting con_prescript'))
449 self
._run
_script
('con_prescript', profile
, device
)
450 self
.msg_pipe
.send(Message('STATUS', 'con_prescript has run'))
452 self
._prepare
_nic
(profile
, device
)
454 self
._stop
_dhcp
(device
)
457 logger
.debug(_('Disable scan while connection attempt in progress...'))
458 self
.msg_pipe
.send(Message('SCAN-STOP', ''))
460 if profile
['use_wpa'] :
463 if profile
['use_dhcp'] :
464 self
._start
_dhcp
(device
)
466 self
._start
_manual
_network
(profile
, device
)
468 # Begin scanning again
469 self
.msg_pipe
.send(Message('SCAN-START', ''))
471 # Run the connection postscript.
472 self
.msg_pipe
.send(Message('STATUS', 'starting con_postscript'))
473 self
._run
_script
('con_postscript', profile
, device
)
474 self
.msg_pipe
.send(Message('STATUS', 'con_postscript has run'))
476 self
.msg_pipe
.send(Message('STATUS', 'connected'))
478 def disconnect(self
, profile
):
479 """ Disconnect from the AP with which a connection has been
480 established/attempted.
482 logger
.info(_('Disconnecting...'))
484 # Pause scanning while manipulating card
485 self
.msg_pipe
.send(Message('SCAN-STOP', ''))
487 device
= self
.config
.get_network_device()
489 self
.msg_pipe
.send(Message('STATUS', 'starting dis_prescript'))
490 self
._run
_script
('dis_prescript', profile
, device
)
491 self
.msg_pipe
.send(Message('STATUS', 'dis_prescript has run'))
493 self
._stop
_dhcp
(device
)
497 # Clear out the wireless stuff.
499 self
.config
.get_opt('DEFAULT.iwconfig_command'),
507 shellcmd(iwconfig_command
)
508 except CalledProcessError
as e
:
509 logger
.error(_('Failed to clean up wireless configuration: '
510 '{EXC}').format(EXC
=e
))
512 # Since it may be brought back up by the next scan, unset its IP.
514 self
.config
.get_opt('DEFAULT.ifconfig_command'),
518 shellcmd(ifconfig_command
)
519 except CalledProcessError
as e
:
520 logger
.error(_('Failed to unset IP address: {EXC}').format(EXC
=e
))
522 # Now take the interface down. Taking down the interface too
523 # quickly can crash my system, so pause a moment.
525 self
.if_change('down')
527 self
.msg_pipe
.send(Message('STATUS', 'starting dis_postscript'))
528 self
._run
_script
('dis_postscript', profile
, device
)
529 self
.msg_pipe
.send(Message('STATUS', 'dis_postscript has run'))
531 logger
.info(_('Disconnect complete.'))
533 # Begin scanning again
534 self
.msg_pipe
.send(Message('SCAN-START', ''))
536 def if_change(self
, state
):
537 """ Change the interface to 'state', i.e. 'up' or 'down'.
539 'if_change' raises ValueError if 'state' is not recognized,
540 raises OSError if there is a problem running ifconfig, and
541 raises DeviceError if ifconfig reports the change failed.
543 state
= state
.lower()
544 if ((state
== 'up') or (state
== 'down')):
545 device
= self
.config
.get_network_device()
548 self
.config
.get_opt('DEFAULT', 'ifconfig_command'),
551 logger
.info('changing interface {DEV} '
552 'state to {STATE}'.format(DEV
=device
, STATE
=state
))
553 ifconfig_info
= Popen(ifconfig_command
, stdout
=PIPE
,
554 stderr
=STDOUT
).stdout
557 logger
.critical(_('ifconfig command not found, '
558 'please set this in the preferences.'))
561 for line
in ifconfig_info
:
563 raise DeviceError(_('Could not change '
564 'device state: {ERR}').format(ERR
=line
))
566 raise ValueError(_('unrecognized state for device: '
567 '{STATE}').format(STATE
=state
))
569 def get_current_ip(self
):
570 """ Return the current IP address as a string or None.
572 device
= self
.config
.get_network_device()
575 self
.config
.get_opt('DEFAULT', 'ifconfig_command'),
578 ifconfig_info
= Popen(ifconfig_command
, stdout
=PIPE
).stdout
579 # Be careful to the language (inet adr: in French for example)
583 # I'm using wifi-radar on a system with German translations (de_CH-UTF-8).
584 # There the string in ifconfig is inet Adresse for the IP which isn't
585 # found by the current get_current_ip function in wifi-radar. I changed
586 # the according line (#289; gentoo, v1.9.6-r1) to
587 # >ip_re = re.compile(r'inet [Aa]d?dr[^.]*:([^.]*\.[^.]*\.[^.]*\.[0-9]*)')
588 # which works on my system (LC_ALL=de_CH.UTF-8) and still works with LC_ALL=C.
590 # I'd be happy if you could incorporate this small change because as now
591 # I've got to change the file every time it is updated.
596 ip_re
= re
.compile(r
'inet [Aa]d?dr[^.]*:([^.]*\.[^.]*\.[^.]*\.[0-9]*)')
597 line
= ifconfig_info
.read()
598 if ip_re
.search(line
):
599 return ip_re
.search(line
).group(1)
602 logger
.critical(_('ifconfig command not found, '
603 'please set this in the preferences.'))
606 def get_current_essid(self
):
607 """ Return the current ESSID as a string or None.
609 device
= self
.config
.get_network_device()
612 self
.config
.get_opt('DEFAULT', 'iwconfig_command'),
615 iwconfig_info
= Popen(iwconfig_command
, stdout
=PIPE
, stderr
=STDOUT
).stdout
616 essid_re
= re
.compile(r
'ESSID\s*(:|=)\s*"([^"]+)"',
618 line
= iwconfig_info
.read()
619 if essid_re
.search(line
):
620 return essid_re
.search(line
).group(2)
623 logger
.critical(_('iwconfig command not found, '
624 'please set this in the preferences.'))
627 def get_current_bssid(self
):
628 """ Return the current BSSID as a string or None.
630 device
= self
.config
.get_network_device()
633 self
.config
.get_opt('DEFAULT', 'iwconfig_command'),
636 iwconfig_info
= Popen(iwconfig_command
, stdout
=PIPE
, stderr
=STDOUT
).stdout
637 bssid_re
= re
.compile(r
'Access Point\s*(:|=)\s*([a-fA-F0-9:]{17})',
639 line
= iwconfig_info
.read()
640 if bssid_re
.search(line
):
641 return bssid_re
.search(line
).group(2)
644 logger
.critical(_('iwconfig command not found, '
645 'please set this in the preferences.'))
649 # Make so we can be imported
650 if __name__
== "__main__":