1# Copyright (c) 2013 The Chromium OS Authors. All rights reserved.;
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4#
5# This is an integration test which ensures that a proxy set on a
6# shared network connection is exposed via LibCrosSevice and used
7# by tlsdated during time synchronization.
8
9import dbus
10import gobject
11import logging
12import subprocess
13import threading
14import time
15
16from autotest_lib.client.bin import test, utils
17from autotest_lib.client.common_lib import error
18from autotest_lib.client.cros import cros_ui
19from autotest_lib.client.cros.networking import shill_proxy
20
21from dbus.mainloop.glib import DBusGMainLoop
22from SocketServer import ThreadingTCPServer, StreamRequestHandler
23
24class ProxyHandler(StreamRequestHandler):
25    """Matching request handler for the ThreadedHitServer
26       that notes when an expected request is seen.
27    """
28    wbufsize = -1
29    def handle(self):
30        """Reads the first line, up to 40 characters, looking
31           for the CONNECT string that tlsdated sends. If it
32           is found, the server's hit() method is called.
33
34           All requests receive a HTTP 504 error.
35        """
36        # Read up to 40 characters
37        data = self.rfile.readline(40).strip()
38        logging.info('ProxyHandler::handle(): <%s>', data)
39        # TODO(wad) Add User-agent check when it lands in tlsdate.
40        # Also, abstract the time server and move this code into cros/.
41        if data.__contains__('CONNECT clients3.google.com:443 HTTP/1.1'):
42          self.server.hit()
43        self.wfile.write("HTTP/1.1 504 Gateway Timeout\r\n" +
44                         "Connection: close\r\n\r\n")
45
46class ThreadedHitServer(ThreadingTCPServer):
47    """A threaded TCP server which services requests
48       and allows the handler to track "hits".
49    """
50    def __init__(self, server_address, HandlerClass):
51        """Constructor
52
53        @param server_address: tuple of server IP and port to listen on.
54        @param HandlerClass: the RequestHandler class to instantiate per req.
55        """
56        self._hits = 0
57        ThreadingTCPServer.__init__(self, server_address, HandlerClass)
58
59    def hit(self):
60        """Increment the hit count. Usually called by the HandlerClass"""
61        self._hits += 1
62
63    def reset_hits(self):
64        """Set the hit count to 0"""
65        self._hits = 0
66
67    def hits(self):
68        """Get the number of matched requests
69        @return the count of matched requests
70        """
71        return self._hits
72
73class ProxyListener(object):
74    """A fake listener for tracking if an expected CONNECT request is
75       seen at the provided server address. Any hits are exposed to be
76       consumed by the caller.
77    """
78    def __init__(self, server_address):
79        """Constructor
80
81        @param server_address: tuple of server IP and port to listen on.
82        """
83        self._server = ThreadedHitServer(server_address, ProxyHandler)
84        self._thread = threading.Thread(target=self._server.serve_forever)
85
86    def run(self):
87        """Run the server on a thread"""
88        self._thread.start()
89
90    def stop(self):
91        """Stop the server and its threads"""
92        self._server.shutdown()
93        self._server.socket.close()
94        self._thread.join()
95
96    def reset_hits(self):
97        """Reset the number of matched requests to 0"""
98        return self._server.reset_hits()
99
100    def hits(self):
101        """Get the number of matched requests
102        @return the count of matched requests
103        """
104        return self._server.hits()
105
106class SignalListener(object):
107    """A class to listen for a DBus signal
108    """
109    DEFAULT_TIMEOUT = 60
110    _main_loop = None
111    _signals = { }
112
113    def __init__(self, g_main_loop):
114        """Constructor
115
116        @param g_mail_loop: glib main loop object.
117        """
118        self._main_loop = g_main_loop
119
120
121    def listen_for_signal(self, signal, interface, path):
122        """Listen with a default handler
123        @param signal: signal name to listen for
124        @param interface: DBus interface to expect it from
125        @param path: DBus path associated with the signal
126        """
127        self.__listen_to_signal(self.__handle_signal, signal, interface, path)
128
129
130    def wait_for_signals(self, desc,
131                         timeout=DEFAULT_TIMEOUT):
132        """Block for |timeout| seconds waiting for the signals to come in.
133
134        @param desc: string describing the high-level reason you're waiting
135                     for the signals.
136        @param timeout: maximum seconds to wait for the signals.
137
138        @raises TimeoutError if the timeout is hit.
139        """
140        utils.poll_for_condition(
141            condition=lambda: self.__received_signals(),
142            desc=desc,
143            timeout=self.DEFAULT_TIMEOUT)
144        all_signals = self._signals.copy()
145        self.__reset_signal_state()
146        return all_signals
147
148
149    def __received_signals(self):
150        """Run main loop until all pending events are done, checks for signals.
151
152        Runs self._main_loop until it says it has no more events pending,
153        then returns the state of the internal variables tracking whether
154        desired signals have been received.
155
156        @return True if both signals have been handled, False otherwise.
157        """
158        context = self._main_loop.get_context()
159        while context.iteration(False):
160            pass
161        return len(self._signals) > 0
162
163
164    def __reset_signal_state(self):
165        """Resets internal signal tracking state."""
166        self._signals = { }
167
168
169    def __listen_to_signal(self, callback, signal, interface, path):
170        """Connect a callback to a given session_manager dbus signal.
171
172        Sets up a signal receiver for signal, and calls the provided callback
173        when it comes in.
174
175        @param callback: a callable to call when signal is received.
176        @param signal: the signal to listen for.
177        """
178        bus = dbus.SystemBus(mainloop=self._main_loop)
179        bus.add_signal_receiver(
180            handler_function=callback,
181            signal_name=signal,
182            dbus_interface=interface,
183            bus_name=None,
184            path=path,
185            member_keyword='signal_name')
186
187
188    def __handle_signal(self, *args, **kwargs):
189        """Callback to be used when a new key signal is received."""
190        signal_name = kwargs.pop('signal_name', '')
191        #signal_data = str(args[0])
192        logging.info("SIGNAL: " + signal_name + ", " + str(args));
193        if self._signals.has_key(signal_name):
194          self._signals[signal_name].append(args)
195        else:
196          self._signals[signal_name] = [args]
197
198
199class network_ProxyResolver(test.test):
200    """A test fixture for validating the integration of
201       shill, Chrome, and tlsdated's proxy resolution.
202    """
203    version = 1
204    auto_login = False
205    service_settings = { }
206
207    TIMEOUT = 360
208
209    def initialize(self):
210       """Constructor
211          Sets up the test such that all DBus signals can be
212          received and a fake proxy server can be instantiated.
213          Additionally, the UI is restarted to ensure consistent
214          shared network use.
215       """
216       super(network_ProxyResolver, self).initialize()
217       cros_ui.stop()
218       cros_ui.start()
219       DBusGMainLoop(set_as_default=True)
220       self._listener = SignalListener(gobject.MainLoop())
221       self._shill = shill_proxy.ShillProxy.get_proxy()
222       if self._shill is None:
223         raise error.TestFail('Could not connect to shill')
224       # Listen for ProxyResolve responses
225       self._listener.listen_for_signal('ProxyChange',
226                                        'org.chromium.AutotestProxyInterface',
227                                        '/org/chromium/LibCrosService')
228       # Listen for network property changes
229       self._listener.listen_for_signal('PropertyChanged',
230                                        'org.chromium.flimflam.Service',
231                                        '/')
232       # Listen on the proxy port.
233       self._proxy_server = ProxyListener(('', 3128))
234
235    # Set the proxy with Shill. This only works for shared connections
236    # (like Eth).
237    def set_proxy(self, service_name, proxy_config):
238        """Changes the ProxyConfig property on the specified shill service.
239
240        @param service_name: the name, as a str, of the shill service
241        @param proxy_config: the ProxyConfig property value string
242
243        @raises TestFail if the service is not found.
244        """
245        shill = self._shill
246        service = shill.find_object('Service', { 'Name' : service_name })
247        if not service:
248            raise error.TestFail('Service ' + service_name +
249                                 ' not found to test proxy with.')
250        props = service.GetProperties()
251        old_proxy = ''
252        if props.has_key('ProxyConfig'):
253          old_proxy = props['ProxyConfig']
254        if self.service_settings.has_key(service_name) == False:
255          logging.info('Preexisting ProxyConfig: ' + service_name +
256                       ' -> ' + old_proxy)
257          self.service_settings[service_name] = old_proxy
258        logging.info('Setting proxy to ' + proxy_config)
259        service.SetProperties({'ProxyConfig': proxy_config})
260
261
262    def reset_services(self):
263        """Walks the dict of service->ProxyConfig values and sets the
264           proxy back to the originally observed value.
265        """
266        if len(self.service_settings) == 0:
267          return
268        for k,v in self.service_settings.items():
269          logging.info('Resetting ProxyConfig: ' + k + ' -> ' + v)
270          self.set_proxy(k, v)
271
272
273    def check_chrome(self, proxy_type, proxy_config, timeout):
274        """Check that Chrome has acknowledged the supplied proxy config
275           by asking for resolution over DBus.
276
277        @param proxy_type: PAC-style string type (e.g., 'PROXY', 'SOCKS')
278        @param proxy_config: PAC-style config string (e.g., 127.0.0.1:1234)
279        @param timeout: time in seconds to wait for Chrome to issue a signal.
280
281        @return True if a matching response is seen and False otherwise
282        """
283        bus = dbus.SystemBus()
284        dbus_proxy = bus.get_object('org.chromium.LibCrosService',
285                                    '/org/chromium/LibCrosService')
286        cros_service = dbus.Interface(dbus_proxy,
287                                      'org.chromium.LibCrosServiceInterface')
288        attempts = timeout
289        while attempts > 0:
290          cros_service.ResolveNetworkProxy(
291                                       'https://clients3.google.com',
292                                       'org.chromium.AutotestProxyInterface',
293                                       'ProxyChange')
294          signals = self._listener.wait_for_signals(
295                        'waiting for proxy resolution from Chrome')
296          if signals['ProxyChange'][0][1] == proxy_type + ' ' + proxy_config:
297            return True
298          attempts -= 1
299          time.sleep(1)
300        logging.error('Last DBus signal seen before giving up: ' + str(signals))
301        return False
302
303    def check_tlsdated(self, timeout):
304        """Check that tlsdated uses the set proxy.
305        @param timeout: time in seconds to wait for tlsdate to restart and query
306        @return True if tlsdated hits the proxy server and False otherwise
307        """
308        # Restart tlsdated to force a network resync
309        # (The other option is to force it to think there is no network sync.)
310        try:
311            self._proxy_server.run()
312        except Exception as e:
313            logging.error("Proxy error =>" + str(e))
314            return False
315        logging.info("proxy started!")
316        status = subprocess.call(['initctl', 'restart', 'tlsdated'])
317        if status != 0:
318          logging.info("failed to restart tlsdated")
319          return False
320        attempts = timeout
321        logging.info("waiting for hits on the proxy server")
322        while attempts > 0:
323          if self._proxy_server.hits() > 0:
324            self._proxy_server.reset_hits()
325            return True
326          time.sleep(1)
327          attempts -= 1
328        logging.info("no hits")
329        return False
330
331
332    def cleanup(self):
333        """Reset all the service data and teardown the proxy."""
334        self.reset_services()
335        logging.info("tearing down the proxy server")
336        self._proxy_server.stop()
337        logging.info("proxy server down")
338        super(network_ProxyResolver, self).cleanup()
339
340
341    def test_same_ip_proxy_at_signin_chrome_system_tlsdated(
342                                                        self,
343                                                        service_name,
344                                                        test_timeout=TIMEOUT):
345        """ Set the user policy, waits for condition, then logs out.
346
347        @param service_name: shill service name to test on
348        @param test_timeout: the total time in seconds split among all timeouts.
349        """
350        proxy_type = 'http'
351        proxy_port = '3128'
352        proxy_host = '127.0.0.1'
353        proxy_url = proxy_type + '://' + proxy_host + ':' + proxy_port
354        # TODO(wad) Only do the below if it was a single protocol proxy.
355        # proxy_config = proxy_type + '=' + proxy_host + ':' + proxy_port
356        proxy_config = proxy_host + ':' + proxy_port
357        self.set_proxy(service_name, '{"mode":"fixed_servers","server":"' +
358                                     proxy_config + '"}')
359
360        logging.info("checking chrome")
361        if self.check_chrome('PROXY', proxy_config, test_timeout/3) == False:
362          raise error.TestFail('Chrome failed to resolve the proxy')
363
364        # Restart tlsdate to force a network fix
365        logging.info("checking tlsdated")
366        if self.check_tlsdated(test_timeout/3) == False:
367          raise error.TestFail('tlsdated never tried the proxy')
368        logging.info("done!")
369
370    def run_once(self, test_type, **params):
371        logging.info('client: Running client test %s', test_type)
372        getattr(self, test_type)(**params)
373