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
44 from signal
import SIGTERM
45 from subprocess
import CalledProcessError
, Popen
, PIPE
, STDOUT
46 from threading
import Event
47 from time
import sleep
49 from wifiradar
.config
import make_section_name
, ConfigManager
50 import wifiradar
.misc
as misc
51 from wifiradar
.pubsub
import Message
54 logger
= logging
.getLogger(__name__
)
57 def get_enc_mode(use_wpa
, key
):
58 """ Return the WiFi encryption mode based on the combination of
59 'use_wpa' and 'key'. Possible return values are 'wpa', 'wep',
69 def scanning_thread(config_manager
, apQueue
, commandQueue
, exit_event
):
70 """ Scan for a limited time and return AP names and bssid found.
71 Access points we find will be put on the outgoing Queue, apQueue.
75 'config_manager' -- ConfigManager - Configuration Manager
77 'apQueue' -- Queue - Queue on which to put AP profiles
79 'commandQueue' -- Queue - Queue from which to read commands
85 logger
.info("Begin thread.")
86 # Setup our essid pattern matcher
87 essid_pattern
= re
.compile("ESSID\s*(:|=)\s*\"([^\"]+)\"", re
.I | re
.M | re
.S
)
88 bssid_pattern
= re
.compile("Address\s*(:|=)\s*([a-zA-Z0-9:]+)", re
.I | re
.M | re
.S
)
89 protocol_pattern
= re
.compile("Protocol\s*(:|=)\s*IEEE 802.11\s*([abgn]+)", re
.I | re
.M | re
.S
)
90 mode_pattern
= re
.compile("Mode\s*(:|=)\s*([^\n]+)", re
.I | re
.M | re
.S
)
91 channel_pattern
= re
.compile("Channel\s*(:|=)*\s*(\d+)", re
.I | re
.M | re
.S
)
92 enckey_pattern
= re
.compile("Encryption key\s*(:|=)\s*(on|off)", re
.I | re
.M | re
.S
)
93 signal_pattern
= re
.compile("Signal level\s*(:|=)\s*(-?[0-9]+)", re
.I | re
.M | re
.S
)
98 # check for exit call before trying to process
99 if exit_event
.isSet():
100 logger
.info("Exiting.")
103 command
= commandQueue
.get_nowait()
104 logger
.info("received command: %s" % (command
, ))
109 device
= config_manager
.get_network_device()
110 except misc
.NoDeviceError
as e
:
111 logger
.critical('Wifi device not found, ' +
112 'please set this in the preferences.')
113 if ( device
and command
== "scan" ):
114 logger
.debug("Beginning scan pass")
115 # update the signal strengths
117 config_manager
.get_opt('DEFAULT', 'iwlist_command'),
120 scandata
= Popen(iwlist_command
, stdout
=PIPE
,
121 stderr
=STDOUT
).stdout
124 logger
.critical("iwlist command not found, please set this in the preferences.")
126 # zero out the signal levels for all access points
127 for bssid
in access_points
:
128 access_points
[bssid
]['signal'] = 0
129 # split the scan data based on the address line
130 hits
= scandata
.split(' - ')
132 # set the defaults for profile template
133 profile
= get_new_profile()
134 m
= essid_pattern
.search(hit
)
137 profile
['essid'] = m
.groups()[1]
138 m
= bssid_pattern
.search(hit
) # get BSSID from scan
139 if m
: profile
['bssid'] = m
.groups()[1]
140 m
= protocol_pattern
.search(hit
) # get protocol from scan
141 if m
: profile
['protocol'] = m
.groups()[1]
142 m
= mode_pattern
.search(hit
) # get mode from scan
143 if m
: profile
['mode'] = m
.groups()[1]
144 m
= channel_pattern
.search(hit
) # get channel from scan
145 if m
: profile
['channel'] = m
.groups()[1]
146 m
= enckey_pattern
.search(hit
) # get encryption key from scan
147 if m
: profile
['encrypted'] = (m
.groups()[1] == 'on')
148 m
= signal_pattern
.search(hit
) # get signal strength from scan
149 if m
: profile
['signal'] = m
.groups()[1]
150 access_points
[ profile
['bssid'] ] = profile
151 for bssid
in access_points
:
152 access_points
[bssid
]['available'] = (access_points
[bssid
]['signal'] > 0)
153 # Put all, now or previously, sensed access_points into apQueue
155 logger
.debug("Scanned profile: %s" % (access_points
[ bssid
], ))
156 apQueue
.put_nowait(access_points
[bssid
])
160 commandQueue
.task_done()
164 class ConnectionManager(object):
165 """ Manage a connection; including reporting connection state,
166 connecting/disconnecting from an AP, and returning current IP, ESSID, and BSSID.
168 def __init__(self
, msg_pipe
):
169 """ Create a new connection manager which communicates with a
170 controlling process or thread through 'msg_pipe' (a Pipe
173 self
.msg_pipe
= msg_pipe
174 self
._watching
= Event()
178 """ Watch for incoming messages.
180 while self
._watching
.is_set():
182 msg
= self
.msg_pipe
.recv()
183 except (EOFError, IOError) as e
:
184 # This is bad, really bad.
185 logger
.critical('read on closed ' +
186 'Pipe ({}), failing...'.format(rfd
))
187 self
._watching
.clear()
188 raise misc
.PipeError(e
)
190 self
._check
_message
(msg
)
192 def _check_message(self
, msg
):
193 """ Process incoming messages.
195 if msg
.topic
== 'EXIT':
196 self
.msg_pipe
.close()
197 self
._watching
.clear()
198 elif msg
.topic
== 'CONFIG-UPDATE':
199 # Replace configuration manager with the one in msg.details.
200 self
.set_config(msg
.details
)
201 elif msg
.topic
== 'CONNECT':
202 # Try to connect to the profile in msg.details.
203 self
.connect(msg
.details
)
204 elif msg
.topic
== 'DISCONNECT':
205 # Try to disconnect from the profile in msg.details.
206 self
.disconnect(msg
.details
)
207 elif msg
.topic
== 'IF-CHANGE':
208 # Try to connect to the profile in msg.details.
210 self
.if_change(msg
.details
)
211 except misc
.DeviceError
as e
:
212 self
.msg_pipe
.send(Message('ERROR', e
))
213 except ValueError as e
:
215 elif msg
.topic
== 'QUERY-IP':
216 # Send out a message with the current IP address.
217 self
.msg_pipe
.send(Message('ANN-IP', self
.get_current_ip()))
218 elif msg
.topic
== 'QUERY-ESSID':
219 # Send out a message with the current ESSID.
220 self
.msg_pipe
.send(Message('ANN-ESSID', self
.get_current_essid()))
221 elif msg
.topic
== 'QUERY-BSSID':
222 # Send out a message with the current BSSID.
223 self
.msg_pipe
.send(Message('ANN-BSSID', self
.get_current_bssid()))
225 def set_config(self
, config_manager
):
226 """ Set the configuration manager to 'config'. This method
227 must be called before any other public method or a caller
228 will likely receive an AttributeError about a missing
231 Raises TypeError if 'config_manager' is not a ConfigManager
234 if not isinstance(config_manager
, ConfigManager
):
235 raise TypeError('config must be a ConfigManager object')
236 self
.config
= config_manager
238 def _run_script(self
, script_name
, profile
, device
):
239 """ Run the script (e.g. connection prescript) in 'profile' which
240 is named in 'script_name'.
242 if profile
[script_name
]:
243 logger
.info('running {}'.format(script_name
))
244 profile_name
= make_section_name(profile
['essid'], profile
['bssid'])
245 enc_mode
= get_enc_mode(profile
['use_wpa'], profile
['key'])
247 "WIFIRADAR_IP": self
.get_current_ip(),
248 "WIFIRADAR_ESSID": self
.get_current_essid(),
249 "WIFIRADAR_BSSID": self
.get_current_bssid(),
250 "WIFIRADAR_PROFILE": profile_name
,
251 "WIFIRADAR_ENCMODE": enc_mode
,
252 "WIFIRADAR_SECMODE": profile
['security'],
253 "WIFIRADAR_IF": device
}
255 misc
.shellcmd(profile
[script_name
].split(' '), custom_env
)
256 except CalledProcessError
as e
:
257 logger
.error('script "{}" failed: {}'.format(script_name
, e
))
258 self
.msg_pipe
.send(Message('ERROR',
259 'script "{}" failed: {}'.format(script_name
, e
)))
261 def _prepare_nic(self
, profile
, device
):
262 """ Configure the NIC for upcoming connection.
264 # Start building iwconfig command line.
266 self
.config
.get_opt('DEFAULT', 'iwconfig_command'),
268 'essid', "'{}'".format(profile
['essid']),
272 iwconfig_command
.append('key')
273 if (not profile
['key']) or (profile
['key'] == 's:'):
274 iwconfig_command
.append('off')
276 # Setting this stops association from working, so remove it for now
277 #if profile['security'] != '':
278 #iwconfig_command.append(profile['security'])
279 iwconfig_command
.append("'{}'".format(profile
['key']))
280 #iwconfig_commands.append( "key %s %s" % ( profile['security'], profile['key'] ) )
283 profile
['mode'] = profile
['mode'].lower()
284 if profile
['mode'] == 'master' or profile
['mode'] == 'auto':
285 profile
['mode'] = 'managed'
286 iwconfig_command
.extend(['mode', profile
['mode']])
289 if 'channel' in profile
:
290 iwconfig_command
.extend(['channel', profile
['channel']])
292 # Set AP address (do this last since iwconfig seems to want it only there).
293 iwconfig_command
.extend(['ap', profile
['bssid']])
295 # Some cards require a commit
296 if self
.config
.get_opt_as_bool('DEFAULT', 'commit_required'):
297 iwconfig_command
.append('commit')
299 logger
.debug('iwconfig_command: {}'.format(iwconfig_command
))
302 misc
.shellcmd(iwconfig_command
)
303 except CalledProcessError
as e
:
304 logger
.error('Failed to prepare NIC: {}'.format(e
))
305 self
.msg_pipe
.send(Message('ERROR',
306 'Failed to prepare NIC: {}'.format(e
)))
307 raise misc
.DeviceError('Could not configure wireless options.')
309 def _stop_dhcp(self
, device
):
310 """ Stop any DHCP client daemons running with our 'device'.
312 logger
.info('Stopping any DHCP clients on "{}"'.format(device
))
313 if os
.access(self
.config
.get_opt('DHCP', 'pidfile'), os
.R_OK
):
314 if self
.config
.get_opt('DHCP', 'kill_args'):
315 dhcp_command
= [self
.config
.get_opt('DHCP', 'command')]
316 dhcp_command
.extend(self
.config
.get_opt('DHCP', 'kill_args').split(' '))
317 dhcp_command
.append(device
)
318 logger
.info("DHCP command: %s" % (dhcp_command
, ))
320 # call DHCP client command and wait for return
321 logger
.info("Stopping DHCP with kill_args")
323 misc
.shellcmd(dhcp_command
)
324 except CalledProcessError
as e
:
325 logger
.error('Attempt to stop DHCP failed: {}'.format(e
))
327 logger
.info("Stopping DHCP manually...")
328 os
.kill(int(open(self
.config
.get_opt('DHCP', 'pidfile'), mode
='r').readline()), SIGTERM
)
330 def _start_dhcp(self
, device
):
331 """ Start a DHCP client daemon on 'device'.
333 logger
.debug('Starting DHCP command on "{}"'.format(device
))
334 self
.msg_pipe
.send(Message('STATUS',
335 'Acquiring IP Address (DHCP)'))
336 dhcp_command
= [self
.config
.get_opt('DHCP', 'command')]
337 dhcp_command
.extend(self
.config
.get_opt('DHCP', 'args').split(' '))
338 dhcp_command
.append(device
)
339 logger
.info("dhcp_command: %s" % (dhcp_command
, ))
341 dhcp_proc
= Popen(dhcp_command
, stdout
=None, stderr
=None)
344 logger
.critical('DHCP client not found, ' +
345 'please set this in the preferences.')
347 # The DHCP client daemon should timeout on its own, hence
348 # the +3 seconds on timeout so we don't cut the daemon off
349 # while it is finishing up.
350 timeout
= self
.config
.get_opt_as_int('DHCP', 'timeout') + 3
352 dhcp_status
= dhcp_proc
.poll()
353 while dhcp_status
is None:
355 dhcp_proc
.terminate()
357 timeout
= timeout
- tick
359 dhcp_status
= dhcp_proc
.poll()
360 if self
.get_current_ip() is not None:
361 self
.msg_pipe
.send(Message('STATUS',
362 'Got IP address. Done.'))
364 self
.msg_pipe
.send(Message('STATUS',
365 'Could not get IP address!'))
368 """ Stop all WPA supplicants.
370 logger
.info("Kill off any existing WPA supplicants running...")
371 if os
.access(self
.config
.get_opt('WPA', 'pidfile'), os
.R_OK
):
372 logger
.info("Killing existing WPA supplicant...")
374 if self
.config
.get_opt('WPA', 'kill_command'):
375 wpa_command
= [self
.config
.get_opt('WPA', 'kill_command').split(' ')]
377 misc
.shellcmd(wpa_command
)
378 except CalledProcessError
as e
:
379 logger
.error('Attempt to stop WPA supplicant ' + \
380 'failed: {}'.format(e
))
382 os
.kill(int(open(self
.config
.get_opt('WPA', 'pidfile'), mode
='r').readline()), SIGTERM
)
384 logger
.info("Failed to kill WPA supplicant")
386 def _start_wpa(self
):
387 """ Start WPA supplicant and let it associate with the AP.
389 self
.msg_pipe
.send(Message('STATUS',
390 'WPA supplicant starting'))
392 wpa_command
= [self
.config
.get_opt('WPA', 'command')]
393 wpa_command
.extend(self
.config
.get_opt('WPA', 'args').split(' '))
395 misc
.shellcmd(wpa_command
)
396 except CalledProcessError
as e
:
397 logger
.error('WPA supplicant failed to start: {}'.format(e
))
399 def _start_manual_network(self
, profile
, device
):
400 """ Manually configure network settings after association.
402 # Bring down the interface before trying to make changes.
404 self
.config
.get_opt('DEFAULT', 'ifconfig_command'),
407 misc
.shellcmd(ifconfig_command
)
408 except CalledProcessError
as e
:
409 logger
.error('Device "{}" failed to go down: {}'.format(device
, e
))
410 # Bring the interface up with our manual IP.
412 self
.config
.get_opt('DEFAULT', 'ifconfig_command'),
413 device
, profile
['ip'],
414 'netmask', profile
['netmask']]
416 misc
.shellcmd(ifconfig_command
)
417 except CalledProcessError
as e
:
418 logger
.error('Device "{}" failed to configure: {}'.format(device
, e
))
419 # Configure routing information.
421 self
.config
.get_opt('DEFAULT', 'route_command'),
422 'add', 'default', 'gw', profile
['gateway']]
424 misc
.shellcmd(route_command
)
425 except CalledProcessError
as e
:
426 logger
.error('Failed to configure routing information: {}'.format(e
))
427 # Build the /etc/resolv.conf file, if needed.
429 if profile
['domain']:
430 resolv_contents
+= "domain {}\n".format(profile
['domain'])
432 resolv_contents
+= "nameserver {}\n".format(profile
['dns1'])
434 resolv_contents
+= "nameserver {}\n".format(profile
['dns2'])
436 with
open('/etc/resolv.conf', 'w') as resolv_file
:
437 resolv_file
.write(resolv_contents
)
439 def connect(self
, profile
):
440 """ Connect to the access point specified by 'profile'.
442 if not profile
['bssid']:
443 raise ValueError('missing BSSID')
444 logger
.info("Connecting to the {} ({}) network".format(
445 profile
['essid'], profile
['bssid']))
447 device
= self
.config
.get_network_device()
450 self
.msg_pipe
.send(Message('STATUS', 'starting con_prescript'))
451 self
._run
_script
('con_prescript', profile
, device
)
452 self
.msg_pipe
.send(Message('STATUS', 'con_prescript has run'))
454 self
._prepare
_nic
(profile
, device
)
456 self
._stop
_dhcp
(device
)
459 logger
.debug("Disable scan while connection attempt in progress...")
460 self
.msg_pipe
.send(Message('SCAN-STOP', ''))
462 if profile
['use_wpa'] :
465 if profile
['use_dhcp'] :
466 self
._start
_dhcp
(device
)
468 self
._start
_manual
_network
(profile
, device
)
470 # Begin scanning again
471 self
.msg_pipe
.send(Message('SCAN-START', ''))
473 # Run the connection postscript.
474 self
.msg_pipe
.send(Message('STATUS', 'starting con_postscript'))
475 self
._run
_script
('con_postscript', profile
, device
)
476 self
.msg_pipe
.send(Message('STATUS', 'con_postscript has run'))
478 self
.msg_pipe
.send(Message('STATUS', 'connected'))
480 def disconnect(self
, profile
):
481 """ Disconnect from the AP with which a connection has been
482 established/attempted.
484 logger
.info("Disconnecting")
486 # Pause scanning while manipulating card
487 self
.msg_pipe
.send(Message('SCAN-STOP', ''))
489 device
= self
.config
.get_network_device()
491 self
.msg_pipe
.send(Message('STATUS', 'starting dis_prescript'))
492 self
._run
_script
('dis_prescript', profile
, device
)
493 self
.msg_pipe
.send(Message('STATUS', 'dis_prescript has run'))
495 self
._stop
_dhcp
(device
)
499 # Clear out the wireless stuff.
501 self
.config
.get_opt('DEFAULT.iwconfig_command'),
509 misc
.shellcmd(iwconfig_command
)
510 except CalledProcessError
as e
:
511 logger
.error('Failed to clean up wireless configuration: {}'.format(e
))
513 # Since it may be brought back up by the next scan, unset its IP.
515 self
.config
.get_opt('DEFAULT.ifconfig_command'),
519 misc
.shellcmd(ifconfig_command
)
520 except CalledProcessError
as e
:
521 logger
.error('Failed to unset IP address: {}'.format(e
))
523 # Now take the interface down. Taking down the interface too
524 # quickly can crash my system, so pause a moment.
526 self
.if_change('down')
528 self
.msg_pipe
.send(Message('STATUS', 'starting dis_postscript'))
529 self
._run
_script
('dis_postscript', profile
, device
)
530 self
.msg_pipe
.send(Message('STATUS', 'dis_postscript has run'))
532 logger
.info("Disconnect complete.")
534 # Begin scanning again
535 self
.msg_pipe
.send(Message('SCAN-START', ''))
537 def if_change(self
, state
):
538 """ Change the interface to 'state', i.e. 'up' or 'down'.
540 'if_change' raises ValueError if 'state' is not recognized,
541 raises OSError if there is a problem running ifconfig, and
542 raises DeviceError if ifconfig reports the change failed.
544 state
= state
.lower()
545 if ((state
== 'up') or (state
== 'down')):
546 device
= self
.config
.get_network_device()
549 self
.config
.get_opt('DEFAULT', 'ifconfig_command'),
552 logger
.info('changing interface ' +
553 '{} state to {}'.format(device
, state
))
554 ifconfig_info
= Popen(ifconfig_command
, stdout
=PIPE
,
555 stderr
=STDOUT
).stdout
558 logger
.critical("ifconfig command not found, " +
559 "please set this in the preferences.")
562 for line
in ifconfig_info
:
564 raise misc
.DeviceError('Could not change ' +
565 'device state: {}'.format(line
))
567 raise ValueError('unrecognized state for device: {}'.format(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__":