1#   Copyright 2016 - The Android Open Source Project
2#
3#   Licensed under the Apache License, Version 2.0 (the "License");
4#   you may not use this file except in compliance with the License.
5#   You may obtain a copy of the License at
6#
7#       http://www.apache.org/licenses/LICENSE-2.0
8#
9#   Unless required by applicable law or agreed to in writing, software
10#   distributed under the License is distributed on an "AS IS" BASIS,
11#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12#   See the License for the specific language governing permissions and
13#   limitations under the License.
14
15import time
16from retry import retry
17
18from acts.controllers.utils_lib.commands import shell
19
20_ROUTER_DNS = '8.8.8.8, 4.4.4.4'
21
22
23class Error(Exception):
24    """An error caused by the dhcp server."""
25
26
27class NoInterfaceError(Exception):
28    """Error thrown when the dhcp server has no interfaces on any subnet."""
29
30
31class DhcpServer(object):
32    """Manages the dhcp server program.
33
34    Only one of these can run in an environment at a time.
35
36    Attributes:
37        config: The dhcp server configuration that is being used.
38    """
39
40    PROGRAM_FILE = 'dhcpd'
41
42    def __init__(self, runner, interface, working_dir='/tmp'):
43        """
44        Args:
45            runner: Object that has a run_async and run methods for running
46                    shell commands.
47            interface: string, The name of the interface to use.
48            working_dir: The directory to work out of.
49        """
50        self._runner = runner
51        self._working_dir = working_dir
52        self._shell = shell.ShellCommand(runner, working_dir)
53        self._log_file = 'dhcpd_%s.log' % interface
54        self._config_file = 'dhcpd_%s.conf' % interface
55        self._lease_file = 'dhcpd_%s.leases' % interface
56        self._pid_file = 'dhcpd_%s.pid' % interface
57        self._identifier = '%s.*%s' % (self.PROGRAM_FILE, self._config_file)
58
59    # There is a slight timing issue where if the proc filesystem in Linux
60    # doesn't get updated in time as when this is called, the NoInterfaceError
61    # will happening.  By adding this retry, the error appears to have gone away
62    # but will still show a warning if the problem occurs.  The error seems to
63    # happen more with bridge interfaces than standard interfaces.
64    @retry(exceptions=NoInterfaceError, tries=3, delay=1)
65    def start(self, config, timeout=60):
66        """Starts the dhcp server.
67
68        Starts the dhcp server daemon and runs it in the background.
69
70        Args:
71            config: dhcp_config.DhcpConfig, Configs to start the dhcp server
72                    with.
73
74        Returns:
75            True if the daemon could be started. Note that the daemon can still
76            start and not work. Invalid configurations can take a long amount
77            of time to be produced, and because the daemon runs indefinitely
78            it's infeasible to wait on. If you need to check if configs are ok
79            then periodic checks to is_running and logs should be used.
80        """
81        if self.is_alive():
82            self.stop()
83
84        self._write_configs(config)
85        self._shell.delete_file(self._log_file)
86        self._shell.touch_file(self._lease_file)
87
88        dhcpd_command = '%s -cf "%s" -lf %s -f -pf "%s"' % (
89            self.PROGRAM_FILE, self._config_file, self._lease_file,
90            self._pid_file)
91        base_command = 'cd "%s"; %s' % (self._working_dir, dhcpd_command)
92        job_str = '%s > "%s" 2>&1' % (base_command, self._log_file)
93        self._runner.run_async(job_str)
94
95        try:
96            self._wait_for_process(timeout=timeout)
97            self._wait_for_server(timeout=timeout)
98        except:
99            self.stop()
100            raise
101
102    def stop(self):
103        """Kills the daemon if it is running."""
104        if self.is_alive():
105            self._shell.kill(self._identifier)
106
107    def is_alive(self):
108        """
109        Returns:
110            True if the daemon is running.
111        """
112        return self._shell.is_alive(self._identifier)
113
114    def get_logs(self):
115        """Pulls the log files from where dhcp server is running.
116
117        Returns:
118            A string of the dhcp server logs.
119        """
120        return self._shell.read_file(self._log_file)
121
122    def _wait_for_process(self, timeout=60):
123        """Waits for the process to come up.
124
125        Waits until the dhcp server process is found running, or there is
126        a timeout. If the program never comes up then the log file
127        will be scanned for errors.
128
129        Raises: See _scan_for_errors
130        """
131        start_time = time.time()
132        while time.time() - start_time < timeout and not self.is_alive():
133            self._scan_for_errors(False)
134            time.sleep(0.1)
135
136        self._scan_for_errors(True)
137
138    def _wait_for_server(self, timeout=60):
139        """Waits for dhcp server to report that the server is up.
140
141        Waits until dhcp server says the server has been brought up or an
142        error occurs.
143
144        Raises: see _scan_for_errors
145        """
146        start_time = time.time()
147        while time.time() - start_time < timeout:
148            success = self._shell.search_file(
149                'Wrote [0-9]* leases to leases file', self._log_file)
150            if success:
151                return
152
153            self._scan_for_errors(True)
154
155    def _scan_for_errors(self, should_be_up):
156        """Scans the dhcp server log for any errors.
157
158        Args:
159            should_be_up: If true then dhcp server is expected to be alive.
160                          If it is found not alive while this is true an error
161                          is thrown.
162
163        Raises:
164            Error: Raised when a dhcp server error is found.
165        """
166        # If this is checked last we can run into a race condition where while
167        # scanning the log the process has not died, but after scanning it
168        # has. If this were checked last in that condition then the wrong
169        # error will be thrown. To prevent this we gather the alive state first
170        # so that if it is dead it will definitely give the right error before
171        # just giving a generic one.
172        is_dead = not self.is_alive()
173
174        no_interface = self._shell.search_file(
175            'Not configured to listen on any interfaces', self._log_file)
176        if no_interface:
177            raise NoInterfaceError(
178                'Dhcp does not contain a subnet for any of the networks the'
179                ' current interfaces are on.')
180
181        if should_be_up and is_dead:
182            raise Error('Dhcp server failed to start.', self)
183
184    def _write_configs(self, config):
185        """Writes the configs to the dhcp server config file."""
186
187        self._shell.delete_file(self._config_file)
188
189        lines = []
190
191        if config.default_lease_time:
192            lines.append('default-lease-time %d;' % config.default_lease_time)
193        if config.max_lease_time:
194            lines.append('max-lease-time %s;' % config.max_lease_time)
195
196        for subnet in config.subnets:
197            address = subnet.network.network_address
198            mask = subnet.network.netmask
199            router = subnet.router
200            start = subnet.start
201            end = subnet.end
202            lease_time = subnet.lease_time
203
204            lines.append('subnet %s netmask %s {' % (address, mask))
205            lines.append('\toption subnet-mask %s;' % mask)
206            lines.append('\toption routers %s;' % router)
207            lines.append('\toption domain-name-servers %s;' % _ROUTER_DNS)
208            lines.append('\trange %s %s;' % (start, end))
209            if lease_time:
210                lines.append('\tdefault-lease-time %d;' % lease_time)
211                lines.append('\tmax-lease-time %d;' % lease_time)
212            lines.append('}')
213
214        for mapping in config.static_mappings:
215            identifier = mapping.identifier
216            fixed_address = mapping.ipv4_address
217            host_fake_name = 'host%s' % identifier.replace(':', '')
218            lease_time = mapping.lease_time
219
220            lines.append('host %s {' % host_fake_name)
221            lines.append('\thardware ethernet %s;' % identifier)
222            lines.append('\tfixed-address %s;' % fixed_address)
223            if lease_time:
224                lines.append('\tdefault-lease-time %d;' % lease_time)
225                lines.append('\tmax-lease-time %d;' % lease_time)
226            lines.append('}')
227
228        config_str = '\n'.join(lines)
229
230        self._shell.write_file(self._config_file, config_str)
231