blob: d123acf331d69b9bf9e1df19b587d94627045488 [file] [log] [blame]
# Copyright 2022 The Fuchsia Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import time
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
from antlion import logger
from antlion.controllers.ap_lib.dhcp_config import DhcpConfig
from antlion.controllers.utils_lib.commands import shell
class Error(Exception):
"""An error caused by the dhcp server."""
class NoInterfaceError(Exception):
"""Error thrown when the dhcp server has no interfaces on any subnet."""
class DhcpServer(object):
"""Manages the dhcp server program.
Only one of these can run in an environment at a time.
Attributes:
config: The dhcp server configuration that is being used.
"""
PROGRAM_FILE = "dhcpd"
def __init__(self, runner, interface, working_dir="/tmp"):
"""
Args:
runner: Object that has a run_async and run methods for running
shell commands.
interface: string, The name of the interface to use.
working_dir: The directory to work out of.
"""
self._log = logger.create_logger(lambda msg: f"[DHCP Server|{interface}] {msg}")
self._runner = runner
self._working_dir = working_dir
self._shell = shell.ShellCommand(runner, working_dir)
self._stdio_log_file = f"dhcpd_{interface}.log"
self._config_file = f"dhcpd_{interface}.conf"
self._lease_file = f"dhcpd_{interface}.leases"
self._pid_file = f"dhcpd_{interface}.pid"
self._identifier = f"{self.PROGRAM_FILE}.*{self._config_file}"
# There is a slight timing issue where if the proc filesystem in Linux
# doesn't get updated in time as when this is called, the NoInterfaceError
# will happening. By adding this retry, the error appears to have gone away
# but will still show a warning if the problem occurs. The error seems to
# happen more with bridge interfaces than standard interfaces.
@retry(
retry=retry_if_exception_type(NoInterfaceError),
stop=stop_after_attempt(3),
wait=wait_fixed(1),
)
def start(self, config: DhcpConfig, timeout_sec: int = 60) -> None:
"""Starts the dhcp server.
Starts the dhcp server daemon and runs it in the background.
Args:
config: Configs to start the dhcp server with.
Raises:
Error: Raised when a dhcp server error is found.
"""
if self.is_alive():
self.stop()
self._write_configs(config)
self._shell.delete_file(self._stdio_log_file)
self._shell.delete_file(self._pid_file)
self._shell.touch_file(self._lease_file)
dhcpd_command = (
f"{self.PROGRAM_FILE} "
f'-cf "{self._config_file}" '
f"-lf {self._lease_file} "
f'-pf "{self._pid_file}" '
"-f -d"
)
base_command = f'cd "{self._working_dir}"; {dhcpd_command}'
job_str = f'{base_command} > "{self._stdio_log_file}" 2>&1'
self._runner.run_async(job_str)
try:
self._wait_for_process(timeout=timeout_sec)
self._wait_for_server(timeout=timeout_sec)
except:
self._log.warn("Failed to start DHCP server.")
self._log.info(f"DHCP configuration:\n{config.render_config_file()}\n")
self._log.info(f"DHCP logs:\n{self.get_logs()}\n")
self.stop()
raise
def stop(self):
"""Kills the daemon if it is running."""
if self.is_alive():
self._shell.kill(self._identifier)
def is_alive(self):
"""
Returns:
True if the daemon is running.
"""
return self._shell.is_alive(self._identifier)
def get_logs(self) -> str:
"""Pulls the log files from where dhcp server is running.
Returns:
A string of the dhcp server logs.
"""
return self._shell.read_file(self._stdio_log_file)
def _wait_for_process(self, timeout=60):
"""Waits for the process to come up.
Waits until the dhcp server process is found running, or there is
a timeout. If the program never comes up then the log file
will be scanned for errors.
Raises: See _scan_for_errors
"""
start_time = time.time()
while time.time() - start_time < timeout and not self.is_alive():
self._scan_for_errors(False)
time.sleep(0.1)
self._scan_for_errors(True)
def _wait_for_server(self, timeout=60):
"""Waits for dhcp server to report that the server is up.
Waits until dhcp server says the server has been brought up or an
error occurs.
Raises: see _scan_for_errors
"""
start_time = time.time()
while time.time() - start_time < timeout:
success = self._shell.search_file(
"Wrote [0-9]* leases to leases file", self._stdio_log_file
)
if success:
return
self._scan_for_errors(True)
def _scan_for_errors(self, should_be_up):
"""Scans the dhcp server log for any errors.
Args:
should_be_up: If true then dhcp server is expected to be alive.
If it is found not alive while this is true an error
is thrown.
Raises:
Error: Raised when a dhcp server error is found.
"""
# If this is checked last we can run into a race condition where while
# scanning the log the process has not died, but after scanning it
# has. If this were checked last in that condition then the wrong
# error will be thrown. To prevent this we gather the alive state first
# so that if it is dead it will definitely give the right error before
# just giving a generic one.
is_dead = not self.is_alive()
no_interface = self._shell.search_file(
"Not configured to listen on any interfaces", self._stdio_log_file
)
if no_interface:
raise NoInterfaceError(
"Dhcp does not contain a subnet for any of the networks the"
" current interfaces are on."
)
if should_be_up and is_dead:
raise Error("Dhcp server failed to start.", self)
def _write_configs(self, config):
"""Writes the configs to the dhcp server config file."""
self._shell.delete_file(self._config_file)
config_str = config.render_config_file()
self._shell.write_file(self._config_file, config_str)