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 19from acts import logger 20 21 22class Error(Exception): 23 """An error caused by the dhcp server.""" 24 25 26class NoInterfaceError(Exception): 27 """Error thrown when the dhcp server has no interfaces on any subnet.""" 28 29 30class DhcpServer(object): 31 """Manages the dhcp server program. 32 33 Only one of these can run in an environment at a time. 34 35 Attributes: 36 config: The dhcp server configuration that is being used. 37 """ 38 39 PROGRAM_FILE = 'dhcpd' 40 41 def __init__(self, runner, interface, working_dir='/tmp'): 42 """ 43 Args: 44 runner: Object that has a run_async and run methods for running 45 shell commands. 46 interface: string, The name of the interface to use. 47 working_dir: The directory to work out of. 48 """ 49 self._log = logger.create_logger(lambda msg: '[DHCP Server|%s] %s' % ( 50 interface, msg)) 51 self._runner = runner 52 self._working_dir = working_dir 53 self._shell = shell.ShellCommand(runner, working_dir) 54 self._stdio_log_file = 'dhcpd_%s.log' % interface 55 self._config_file = 'dhcpd_%s.conf' % interface 56 self._lease_file = 'dhcpd_%s.leases' % interface 57 self._pid_file = 'dhcpd_%s.pid' % interface 58 self._identifier = '%s.*%s' % (self.PROGRAM_FILE, self._config_file) 59 60 # There is a slight timing issue where if the proc filesystem in Linux 61 # doesn't get updated in time as when this is called, the NoInterfaceError 62 # will happening. By adding this retry, the error appears to have gone away 63 # but will still show a warning if the problem occurs. The error seems to 64 # happen more with bridge interfaces than standard interfaces. 65 @retry(exceptions=NoInterfaceError, tries=3, delay=1) 66 def start(self, config, timeout=60): 67 """Starts the dhcp server. 68 69 Starts the dhcp server daemon and runs it in the background. 70 71 Args: 72 config: dhcp_config.DhcpConfig, Configs to start the dhcp server 73 with. 74 75 Raises: 76 Error: Raised when a dhcp server error is found. 77 """ 78 if self.is_alive(): 79 self.stop() 80 81 self._write_configs(config) 82 self._shell.delete_file(self._stdio_log_file) 83 self._shell.delete_file(self._pid_file) 84 self._shell.touch_file(self._lease_file) 85 86 dhcpd_command = '%s -cf "%s" -lf %s -f -pf "%s"' % ( 87 self.PROGRAM_FILE, self._config_file, self._lease_file, 88 self._pid_file) 89 base_command = 'cd "%s"; %s' % (self._working_dir, dhcpd_command) 90 job_str = '%s > "%s" 2>&1' % (base_command, self._stdio_log_file) 91 self._runner.run_async(job_str) 92 93 try: 94 self._wait_for_process(timeout=timeout) 95 self._wait_for_server(timeout=timeout) 96 except: 97 self._log.warn("Failed to start DHCP server.") 98 self._log.info("DHCP configuration:\n" + 99 config.render_config_file() + "\n") 100 self._log.info("DHCP logs:\n" + self.get_logs() + "\n") 101 self.stop() 102 raise 103 104 def stop(self): 105 """Kills the daemon if it is running.""" 106 if self.is_alive(): 107 self._shell.kill(self._identifier) 108 109 def is_alive(self): 110 """ 111 Returns: 112 True if the daemon is running. 113 """ 114 return self._shell.is_alive(self._identifier) 115 116 def get_logs(self): 117 """Pulls the log files from where dhcp server is running. 118 119 Returns: 120 A string of the dhcp server logs. 121 """ 122 try: 123 # Try reading the PID file. This will fail if the server failed to 124 # start. 125 pid = self._shell.read_file(self._pid_file) 126 # `dhcpd` logs to the syslog, where its messages are interspersed 127 # with all other programs that use the syslog. Log lines contain 128 # `dhcpd[<pid>]`, which we can search for to extract all the logs 129 # from this particular dhcpd instance. 130 # The logs are preferable to the stdio output, since they contain 131 # a superset of the information from stdio, including leases 132 # that the server provides. 133 return self._shell.run( 134 f"grep dhcpd.{pid} /var/log/messages").stdout 135 except Exception: 136 self._log.info( 137 "Failed to read logs from syslog (likely because the server " + 138 "failed to start). Falling back to stdio output.") 139 return self._shell.read_file(self._stdio_log_file) 140 141 def _wait_for_process(self, timeout=60): 142 """Waits for the process to come up. 143 144 Waits until the dhcp server process is found running, or there is 145 a timeout. If the program never comes up then the log file 146 will be scanned for errors. 147 148 Raises: See _scan_for_errors 149 """ 150 start_time = time.time() 151 while time.time() - start_time < timeout and not self.is_alive(): 152 self._scan_for_errors(False) 153 time.sleep(0.1) 154 155 self._scan_for_errors(True) 156 157 def _wait_for_server(self, timeout=60): 158 """Waits for dhcp server to report that the server is up. 159 160 Waits until dhcp server says the server has been brought up or an 161 error occurs. 162 163 Raises: see _scan_for_errors 164 """ 165 start_time = time.time() 166 while time.time() - start_time < timeout: 167 success = self._shell.search_file( 168 'Wrote [0-9]* leases to leases file', self._stdio_log_file) 169 if success: 170 return 171 172 self._scan_for_errors(True) 173 174 def _scan_for_errors(self, should_be_up): 175 """Scans the dhcp server log for any errors. 176 177 Args: 178 should_be_up: If true then dhcp server is expected to be alive. 179 If it is found not alive while this is true an error 180 is thrown. 181 182 Raises: 183 Error: Raised when a dhcp server error is found. 184 """ 185 # If this is checked last we can run into a race condition where while 186 # scanning the log the process has not died, but after scanning it 187 # has. If this were checked last in that condition then the wrong 188 # error will be thrown. To prevent this we gather the alive state first 189 # so that if it is dead it will definitely give the right error before 190 # just giving a generic one. 191 is_dead = not self.is_alive() 192 193 no_interface = self._shell.search_file( 194 'Not configured to listen on any interfaces', self._stdio_log_file) 195 if no_interface: 196 raise NoInterfaceError( 197 'Dhcp does not contain a subnet for any of the networks the' 198 ' current interfaces are on.') 199 200 if should_be_up and is_dead: 201 raise Error('Dhcp server failed to start.', self) 202 203 def _write_configs(self, config): 204 """Writes the configs to the dhcp server config file.""" 205 self._shell.delete_file(self._config_file) 206 config_str = config.render_config_file() 207 self._shell.write_file(self._config_file, config_str) 208