xref: /aosp_15_r20/external/autotest/client/cros/dhcp_test_server.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
1# Lint as: python2, python3
2# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""
7Programmable testing DHCP server.
8
9Simple DHCP server you can program with expectations of future packets and
10responses to those packets.  The server is basically a thin wrapper around a
11server socket with some utility logic to make setting up tests easier.  To write
12a test, you start a server, construct a sequence of handling rules.
13
14Handling rules let you set up expectations of future packets of certain types.
15Handling rules are processed in order, and only the first remaining handler
16handles a given packet.  In theory you could write the entire test into a single
17handling rule and keep an internal state machine for how far that handler has
18gotten through the test.  This would be poor style however.  Correct style is to
19write (or reuse) a handler for each packet the server should see, leading us to
20a happy land where any conceivable packet handler has already been written for
21us.
22
23Example usage:
24
25# Start up the DHCP server, which will ignore packets until a test is started
26server = DhcpTestServer(interface=interface_name)
27server.start()
28
29# Given a list of handling rules, start a test with a 30 sec timeout.
30handling_rules = []
31handling_rules.append(DhcpHandlingRule_RespondToDiscovery(intended_ip,
32                                                          intended_subnet_mask,
33                                                          dhcp_server_ip,
34                                                          lease_time_seconds)
35server.start_test(handling_rules, 30.0)
36
37# Trigger DHCP clients to do various test related actions
38...
39
40# Get results
41server.wait_for_test_to_finish()
42if (server.last_test_passed):
43    ...
44else:
45    ...
46
47
48Note that if you make changes, make sure that the tests in dhcp_unittest.py
49still pass.
50"""
51
52from __future__ import absolute_import
53from __future__ import division
54from __future__ import print_function
55
56import logging
57import six
58from six.moves import range
59import socket
60import threading
61import time
62import traceback
63
64from autotest_lib.client.cros import dhcp_packet
65from autotest_lib.client.cros import dhcp_handling_rule
66
67# From socket.h
68SO_BINDTODEVICE = 25
69
70# These imports are purely for handling of namespaces
71import os
72import subprocess
73from ctypes import CDLL, get_errno
74from ctypes.util import find_library
75
76
77# Let's throw an exception (with formatted error message) in case of
78# 'setns' failure instead of returning an error code
79def errcheck(ret, func, args):
80    if ret == -1:
81        e = get_errno()
82        raise OSError(e, os.strerror(e))
83
84
85libc = CDLL(find_library('c'))
86libc.setns.errcheck = errcheck
87CLONE_NEWNET = 0x40000000
88
89
90class DhcpTestServer(threading.Thread):
91    def __init__(self,
92                 interface=None,
93                 ingress_address="<broadcast>",
94                 ingress_port=67,
95                 broadcast_address="255.255.255.255",
96                 broadcast_port=68,
97                 namespace=None):
98        super(DhcpTestServer, self).__init__()
99        self._mutex = threading.Lock()
100        self._ingress_address = ingress_address
101        self._ingress_port = ingress_port
102        self._broadcast_port = broadcast_port
103        self._broadcast_address = broadcast_address
104        self._socket = None
105        self._interface = interface
106        self._namespace = namespace
107        self._stopped = False
108        self._test_in_progress = False
109        self._last_test_passed = False
110        self._test_timeout = 0
111        self._handling_rules = []
112        self._logger = logging.getLogger("dhcp.test_server")
113        self._exception = None
114        self.daemon = False
115
116    @property
117    def stopped(self):
118        with self._mutex:
119            return self._stopped
120
121    @property
122    def is_healthy(self):
123        with self._mutex:
124            return self._socket is not None
125
126    @property
127    def test_in_progress(self):
128        with self._mutex:
129            return self._test_in_progress
130
131    @property
132    def last_test_passed(self):
133        with self._mutex:
134            return self._last_test_passed
135
136    @property
137    def current_rule(self):
138        """
139        Return the currently active DhcpHandlingRule.
140        """
141        with self._mutex:
142            return self._handling_rules[0]
143
144    def start(self):
145        """
146        Start the DHCP server.  Only call this once.
147        """
148        if self.is_alive():
149            return False
150        self._logger.info("DhcpTestServer started; opening sockets.")
151        try:
152            if self._namespace:
153                self._logger.info("Moving to namespace %s.", self._namespace)
154                # Figure out where the mount bind is - ChromeOS does not
155                # follow standard /var/run/netns path so lets try to be more
156                # generic and get it from runtime
157                tgtpath = subprocess.check_output('mount | grep "netns/%s"' %
158                                                  self._namespace,
159                                                  shell=True).split()[2]
160                self._tgtns = open(tgtpath)
161                self._myns = open('/proc/self/ns/net')
162                libc.setns(self._tgtns.fileno(), CLONE_NEWNET)
163            self._logger.info("Opening socket on '%s' port %d.",
164                              self._ingress_address, self._ingress_port)
165            self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
166            self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
167            self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
168            if self._interface is not None:
169                self._logger.info("Binding to %s", self._interface)
170                if six.PY2:
171                    self._socket.setsockopt(socket.SOL_SOCKET, SO_BINDTODEVICE,
172                                            self._interface)
173                else:
174                    self._socket.setsockopt(
175                            socket.SOL_SOCKET, SO_BINDTODEVICE,
176                            self._interface.encode('ISO-8859-1'))
177            self._socket.bind((self._ingress_address, self._ingress_port))
178            # Wait 100 ms for a packet, then return, thus keeping the thread
179            # active but mostly idle.
180            self._socket.settimeout(0.1)
181        except socket.error as socket_error:
182            self._logger.error("Socket error: %s.", str(socket_error))
183            self._logger.error(traceback.format_exc())
184            if not self._socket is None:
185                self._socket.close()
186            self._socket = None
187            self._logger.error("Failed to open server socket.  Aborting.")
188            return
189        except OSError as os_err:
190            self._logger.error("System error: %s.", str(os_err))
191            self._logger.error(traceback.format_exc())
192            self._logger.error("Failed to change namespace.  Aborting.")
193            return
194        finally:
195            if self._namespace:
196                self._tgtns.close()
197                libc.setns(self._myns.fileno(), CLONE_NEWNET)
198                self._myns.close()
199        super(DhcpTestServer, self).start()
200
201    def stop(self):
202        """
203        Stop the DHCP server and free its socket.
204        """
205        with self._mutex:
206            self._stopped = True
207
208    def start_test(self, handling_rules, test_timeout_seconds):
209        """
210        Start a new test using |handling_rules|.  The server will call the
211        test successfull if it receives a RESPONSE_IGNORE_SUCCESS (or
212        RESPONSE_RESPOND_SUCCESS) from a handling_rule before
213        |test_timeout_seconds| passes.  If the timeout passes without that
214        message, the server runs out of handling rules, or a handling rule
215        return RESPONSE_FAIL, the test is ended and marked as not passed.
216
217        All packets received before start_test() is called are received and
218        ignored.
219        """
220        with self._mutex:
221            self._test_timeout = time.time() + test_timeout_seconds
222            self._handling_rules = handling_rules
223            self._test_in_progress = True
224            self._last_test_passed = False
225            self._exception = None
226
227    def wait_for_test_to_finish(self):
228        """
229        Block on the test finishing in a CPU friendly way.  Timeouts, successes,
230        and failures count as finishes.
231        """
232        while self.test_in_progress:
233            time.sleep(0.1)
234        if self._exception:
235            raise self._exception
236
237    def abort_test(self):
238        """
239        Abort a test prematurely, counting the test as a failure.
240        """
241        with self._mutex:
242            self._logger.info("Manually aborting test.")
243            self._end_test_unsafe(False)
244
245    def _teardown(self):
246        with self._mutex:
247            self._socket.close()
248            self._socket = None
249
250    def _end_test_unsafe(self, passed):
251        if not self._test_in_progress:
252            return
253        if passed:
254            self._logger.info("DHCP server says test passed.")
255        else:
256            self._logger.info("DHCP server says test failed.")
257        self._test_in_progress = False
258        self._last_test_passed = passed
259
260    def _send_response_unsafe(self, packet):
261        if packet is None:
262            self._logger.error("Handling rule failed to return a packet.")
263            return False
264        self._logger.debug("Sending response: %s", packet)
265        binary_string = packet.to_binary_string()
266        if binary_string is None or len(binary_string) < 1:
267            self._logger.error("Packet failed to serialize to binary string.")
268            return False
269
270        self._socket.sendto(binary_string,
271                            (self._broadcast_address, self._broadcast_port))
272        return True
273
274    def _loop_body(self):
275        with self._mutex:
276            if self._test_in_progress and self._test_timeout < time.time():
277                # The test has timed out, so we abort it.  However, we should
278                # continue to accept packets, so we fall through.
279                self._logger.error("Test in progress has timed out.")
280                self._end_test_unsafe(False)
281            try:
282                data, _ = self._socket.recvfrom(1024)
283                self._logger.info("Server received packet of length %d.",
284                                  len(data))
285            except socket.timeout:
286                # No packets available, lets return and see if the server has
287                # been shut down in the meantime.
288                return
289
290            # Receive packets when no test is in progress, just don't process
291            # them.
292            if not self._test_in_progress:
293                return
294
295            packet = dhcp_packet.DhcpPacket(byte_str=data)
296            if not packet.is_valid:
297                self._logger.warning("Server received an invalid packet over a "
298                                     "DHCP port?")
299                return
300
301            logging.debug("Server received a DHCP packet: %s.", packet)
302            if len(self._handling_rules) < 1:
303                self._logger.info("No handling rule for packet: %s.",
304                                  str(packet))
305                self._end_test_unsafe(False)
306                return
307
308            handling_rule = self._handling_rules[0]
309            response_code = handling_rule.handle(packet)
310            logging.info("Handler gave response: %d", response_code)
311            if response_code & dhcp_handling_rule.RESPONSE_POP_HANDLER:
312                self._handling_rules.pop(0)
313
314            if response_code & dhcp_handling_rule.RESPONSE_HAVE_RESPONSE:
315                for response_instance in range(
316                        handling_rule.response_packet_count):
317                    response = handling_rule.respond(packet)
318                    if not self._send_response_unsafe(response):
319                        self._logger.error(
320                                "Failed to send packet, ending test.")
321                        self._end_test_unsafe(False)
322                        return
323
324            if response_code & dhcp_handling_rule.RESPONSE_TEST_FAILED:
325                self._logger.info("Handling rule %s rejected packet %s.",
326                                  (handling_rule, packet))
327                self._end_test_unsafe(False)
328                return
329
330            if response_code & dhcp_handling_rule.RESPONSE_TEST_SUCCEEDED:
331                self._end_test_unsafe(True)
332                return
333
334    def run(self):
335        """
336        Main method of the thread.  Never call this directly, since it assumes
337        some setup done in start().
338        """
339        with self._mutex:
340            if self._socket is None:
341                self._logger.error("Failed to create server socket, exiting.")
342                return
343
344        self._logger.info("DhcpTestServer entering handling loop.")
345        while not self.stopped:
346            try:
347                self._loop_body()
348                # Python does not have waiting queues on Lock objects.
349                # Give other threads a change to hold the mutex by
350                # forcibly releasing the GIL while we sleep.
351                time.sleep(0.01)
352            except Exception as e:
353                with self._mutex:
354                    self._end_test_unsafe(False)
355                    self._exception = e
356        with self._mutex:
357            self._end_test_unsafe(False)
358        self._logger.info("DhcpTestServer closing sockets.")
359        self._teardown()
360        self._logger.info("DhcpTestServer exiting.")
361