From d6fa1e5ee6756be3d920bb8a9425c3db87ff264b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Noss?= Date: Fri, 17 Jan 2025 20:02:53 +0100 Subject: [PATCH] dhcp client: add a udhcpd fixture and a test with it --- pyroute2/dhcp/client.py | 12 +- tests/test_linux/conftest.py | 3 +- .../fixtures/dhcp_servers/__init__.py | 131 +++++++++++++++ .../fixtures/dhcp_servers/dnsmasq.py | 68 ++++++++ .../fixtures/dhcp_servers/udhcpd.py | 101 ++++++++++++ tests/test_linux/fixtures/dnsmasq.py | 156 ------------------ tests/test_linux/fixtures/interfaces.py | 26 ++- tests/test_linux/test_raw/test_dhcp.py | 72 ++++++-- 8 files changed, 384 insertions(+), 185 deletions(-) create mode 100644 tests/test_linux/fixtures/dhcp_servers/__init__.py create mode 100644 tests/test_linux/fixtures/dhcp_servers/dnsmasq.py create mode 100644 tests/test_linux/fixtures/dhcp_servers/udhcpd.py delete mode 100644 tests/test_linux/fixtures/dnsmasq.py diff --git a/pyroute2/dhcp/client.py b/pyroute2/dhcp/client.py index 4e56f530f..485aefe05 100644 --- a/pyroute2/dhcp/client.py +++ b/pyroute2/dhcp/client.py @@ -59,13 +59,21 @@ def __init__( # "public api" - async def wait_for_state(self, state: Optional[fsm.State]) -> None: + async def wait_for_state( + self, state: Optional[fsm.State], timeout: Optional[float] = None + ) -> None: '''Waits until the client is in the target state. Since the state is set to None upon exit, you can also pass None to wait for the client to stop. ''' - await self._states[state].wait() + try: + await asyncio.wait_for(self._states[state].wait(), timeout=timeout) + except TimeoutError as err: + raise TimeoutError( + f"Timed out waiting for the {state} state. " + f"Current state: {self.state}" + ) from err @fsm.state_guard(fsm.State.INIT, fsm.State.INIT_REBOOT) async def bootstrap(self): diff --git a/tests/test_linux/conftest.py b/tests/test_linux/conftest.py index ece1de2e4..0d40120df 100644 --- a/tests/test_linux/conftest.py +++ b/tests/test_linux/conftest.py @@ -2,7 +2,8 @@ from uuid import uuid4 import pytest -from fixtures.dnsmasq import dnsmasq, dnsmasq_options # noqa: F401 +from fixtures.dhcp_servers.dnsmasq import dnsmasq, dnsmasq_config # noqa: F401 +from fixtures.dhcp_servers.udhcpd import udhcpd, udhcpd_config # noqa: F401 from fixtures.interfaces import dhcp_range, veth_pair # noqa: F401 from pr2test.context_manager import NDBContextManager, SpecContextManager from utils import require_user diff --git a/tests/test_linux/fixtures/dhcp_servers/__init__.py b/tests/test_linux/fixtures/dhcp_servers/__init__.py new file mode 100644 index 000000000..3daa08392 --- /dev/null +++ b/tests/test_linux/fixtures/dhcp_servers/__init__.py @@ -0,0 +1,131 @@ +import abc +import asyncio +from argparse import ArgumentParser +from dataclasses import dataclass +from ipaddress import IPv4Address +from typing import ClassVar, Generic, Literal, Optional, TypeVar + +from ..interfaces import DHCPRangeConfig + + +@dataclass +class DHCPServerConfig: + range: DHCPRangeConfig + interface: str + lease_time: int = 120 # in seconds + max_leases: int = 50 + + +DHCPServerConfigT = TypeVar("DHCPServerConfigT", bound=DHCPServerConfig) + + +class DHCPServerFixture(abc.ABC, Generic[DHCPServerConfigT]): + + BINARY_PATH: ClassVar[Optional[str]] = None + + @classmethod + def get_config_class(cls) -> type[DHCPServerConfigT]: + return cls.__orig_bases__[0].__args__[0] + + def __init__(self, config: DHCPServerConfigT) -> None: + self.config = config + self.stdout: list[str] = [] + self.stderr: list[str] = [] + self.process: Optional[asyncio.subprocess.Process] = None + self.output_poller: Optional[asyncio.Task] = None + + async def _read_output(self, name: Literal['stdout', 'stderr']): + '''Read stdout or stderr until the process exits.''' + stream = getattr(self.process, name) + output = getattr(self, name) + while line := await stream.readline(): + output.append(line.decode().strip()) + + async def _read_outputs(self): + '''Read stdout & stderr until the process exits.''' + assert self.process + await asyncio.gather( + self._read_output('stderr'), self._read_output('stdout') + ) + + @abc.abstractmethod + def get_cmdline_options(self) -> tuple[str]: + '''All commandline options passed to the server.''' + + async def __aenter__(self): + '''Start the server process and start polling its output.''' + if not self.BINARY_PATH: + raise RuntimeError( + f"server binary is missing for {type(self.__name__)}" + ) + self.process = await asyncio.create_subprocess_exec( + self.BINARY_PATH, + *self.get_cmdline_options(), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={'LANG': 'C'}, # usually ensures the output is in english + ) + self.output_poller = asyncio.Task(self._read_outputs()) + return self + + async def __aexit__(self, *_): + if self.process: + if self.process.returncode is None: + self.process.terminate() + await self.process.wait() + await self.output_poller + + +def get_psr() -> ArgumentParser: + psr = ArgumentParser() + psr.add_argument('interface', help='Interface to listen on') + psr.add_argument( + '--router', type=IPv4Address, default=None, help='Router IPv4 address.' + ) + psr.add_argument( + '--range-start', + type=IPv4Address, + default=IPv4Address('192.168.186.10'), + help='Start of the DHCP client range.', + ) + psr.add_argument( + '--range-end', + type=IPv4Address, + default=IPv4Address('192.168.186.100'), + help='End of the DHCP client range.', + ) + psr.add_argument( + '--lease-time', + default=120, + type=int, + help='DHCP lease time in seconds (minimum 2 minutes)', + ) + psr.add_argument( + '--netmask', type=IPv4Address, default=IPv4Address("255.255.255.0") + ) + return psr + + +async def run_fixture_as_main(fixture_cls: type[DHCPServerFixture]): + config_cls = fixture_cls.get_config_class() + args = get_psr().parse_args() + range_config = DHCPRangeConfig( + start=args.range_start, + end=args.range_end, + router=args.router, + netmask=args.netmask, + ) + conf = config_cls( + range=range_config, + interface=args.interface, + lease_time=args.lease_time, + ) + read_lines: int = 0 + async with fixture_cls(conf) as dhcp_server: + # quick & dirty stderr polling + while True: + if len(dhcp_server.stderr) > read_lines: + read_lines += len(lines := dhcp_server.stderr[read_lines:]) + print(*lines, sep='\n') + else: + await asyncio.sleep(0.2) diff --git a/tests/test_linux/fixtures/dhcp_servers/dnsmasq.py b/tests/test_linux/fixtures/dhcp_servers/dnsmasq.py new file mode 100644 index 000000000..d98e48140 --- /dev/null +++ b/tests/test_linux/fixtures/dhcp_servers/dnsmasq.py @@ -0,0 +1,68 @@ +import asyncio +from dataclasses import dataclass +from shutil import which +from typing import AsyncGenerator, ClassVar, Optional + +import pytest +import pytest_asyncio +from fixtures.interfaces import DHCPRangeConfig + +from . import DHCPServerConfig, DHCPServerFixture, run_fixture_as_main + + +@dataclass +class DnsmasqConfig(DHCPServerConfig): + '''Options for the dnsmasq server.''' + + def __iter__(self): + opts = [ + f'--interface={self.interface}', + f'--dhcp-range={self.range.start},' + f'{self.range.end},{self.lease_time}', + f'--dhcp-lease-max={self.max_leases}', + ] + if router := self.range.router: + opts.append(f"--dhcp-option=option:router,{router}") + return iter(opts) + + +class DnsmasqFixture(DHCPServerFixture[DnsmasqConfig]): + '''Runs the dnsmasq server as an async context manager.''' + + BINARY_PATH: ClassVar[Optional[str]] = which('dnsmasq') + + def _get_base_cmdline_options(self) -> tuple[str]: + '''The base commandline options for dnsmasq.''' + return ( + '--keep-in-foreground', # self explanatory + '--no-resolv', # don't mess w/ resolv.conf + '--log-facility=-', # log to stdout + '--no-hosts', # don't read /etc/hosts + '--bind-interfaces', # don't bind on wildcard + '--no-ping', # don't ping to check if ips are attributed + ) + + def get_cmdline_options(self) -> tuple[str]: + '''All commandline options passed to dnsmasq.''' + return (*self._get_base_cmdline_options(), *self.config) + + +@pytest.fixture +def dnsmasq_config( + veth_pair: tuple[str, str], dhcp_range: DHCPRangeConfig +) -> DnsmasqConfig: + '''dnsmasq options useful for test purposes.''' + return DnsmasqConfig(range=dhcp_range, interface=veth_pair[0]) + + +@pytest_asyncio.fixture +async def dnsmasq( + dnsmasq_config: DnsmasqConfig, +) -> AsyncGenerator[DnsmasqFixture, None]: + '''A dnsmasq instance running for the duration of the test.''' + async with DnsmasqFixture(config=dnsmasq_config) as dnsf: + yield dnsf + + +if __name__ == '__main__': + asyncio.run(run_fixture_as_main(DnsmasqFixture)) diff --git a/tests/test_linux/fixtures/dhcp_servers/udhcpd.py b/tests/test_linux/fixtures/dhcp_servers/udhcpd.py new file mode 100644 index 000000000..8912a0ef5 --- /dev/null +++ b/tests/test_linux/fixtures/dhcp_servers/udhcpd.py @@ -0,0 +1,101 @@ +import asyncio +from dataclasses import dataclass +from pathlib import Path +from shutil import which +from tempfile import TemporaryDirectory +from typing import AsyncGenerator, ClassVar, Optional + +import pytest +import pytest_asyncio + +from ..interfaces import DHCPRangeConfig +from . import DHCPServerConfig, DHCPServerFixture, run_fixture_as_main + + +@dataclass +class UdhcpdConfig(DHCPServerConfig): + arp_ping_timeout_ms: int = 200 # default is 2000 + + +class UdhcpdFixture(DHCPServerFixture[UdhcpdConfig]): + '''Runs the udhcpd server as an async context manager.''' + + BINARY_PATH: ClassVar[Optional[str]] = which('busybox') + + def __init__(self, config): + super().__init__(config) + self._temp_dir: Optional[TemporaryDirectory[str]] = None + + @property + def workdir(self) -> Path: + '''A temporary directory for udhcpd's files.''' + assert self._temp_dir + return Path(self._temp_dir.name) + + @property + def config_file(self) -> Path: + '''The udhcpd config file path.''' + return self.workdir.joinpath("udhcpd.conf") + + async def __aenter__(self): + self._temp_dir = TemporaryDirectory(prefix=type(self).__name__) + self._temp_dir.__enter__() + self.config_file.write_text(self.generate_config()) + return await super().__aenter__() + + def generate_config(self) -> str: + '''Generate the contents of udhcpd's config file.''' + cfg = self.config + base_workfile = self.workdir.joinpath(self.config.interface) + lease_file = base_workfile.with_suffix(".leases") + pidfile = base_workfile.with_suffix(".pid") + lines = [ + ("start", cfg.range.start), + ("end", cfg.range.end), + ("max_leases", cfg.max_leases), + ("interface", cfg.interface), + ("lease_file", lease_file), + ("pidfile", pidfile), + ("opt lease", cfg.lease_time), + ("opt router", cfg.range.router), + ] + return "\n".join(f"{opt}\t{value}" for opt, value in lines) + + async def __aexit__(self, *_): + await super().__aexit__(*_) + self._temp_dir.__exit__(*_) + + def get_cmdline_options(self) -> tuple[str]: + '''All commandline options passed to udhcpd.''' + return ( + 'udhcpd', + '-f', # run in foreground + '-a', + str(self.config.arp_ping_timeout_ms), + str(self.config_file), + ) + + +@pytest.fixture +def udhcpd_config( + veth_pair: tuple[str, str], dhcp_range: DHCPRangeConfig +) -> UdhcpdConfig: + '''udhcpd options useful for test purposes.''' + return UdhcpdConfig( + range=dhcp_range, + interface=veth_pair[0], + lease_time=1, # very short leases for tests + ) + + +@pytest_asyncio.fixture +async def udhcpd( + udhcpd_config: UdhcpdConfig, +) -> AsyncGenerator[UdhcpdFixture, None]: + '''An udhcpd instance running for the duration of the test.''' + async with UdhcpdFixture(config=udhcpd_config) as dhcp_server: + yield dhcp_server + + +if __name__ == '__main__': + asyncio.run(run_fixture_as_main(UdhcpdFixture)) diff --git a/tests/test_linux/fixtures/dnsmasq.py b/tests/test_linux/fixtures/dnsmasq.py deleted file mode 100644 index e453d2ad8..000000000 --- a/tests/test_linux/fixtures/dnsmasq.py +++ /dev/null @@ -1,156 +0,0 @@ -import asyncio -from argparse import ArgumentParser -from dataclasses import dataclass -from ipaddress import IPv4Address -from shutil import which -from typing import AsyncGenerator, ClassVar, Literal, Optional - -import pytest -import pytest_asyncio -from fixtures.interfaces import DHCPRangeConfig - - -@dataclass -class DnsmasqOptions: - '''Options for the dnsmasq server.''' - - range_start: IPv4Address - range_end: IPv4Address - interface: str - lease_time: str = '12h' - - def __iter__(self): - opts = ( - f'--interface={self.interface}', - f'--dhcp-range={self.range_start},' - f'{self.range_end},{self.lease_time}', - ) - return iter(opts) - - -class DnsmasqFixture: - '''Runs the dnsmasq server as an async context manager.''' - - DNSMASQ_PATH: ClassVar[Optional[str]] = which('dnsmasq') - - def __init__(self, options: DnsmasqOptions) -> None: - self.options = options - self.stdout: list[str] = [] - self.stderr: list[str] = [] - self.process: Optional[asyncio.subprocess.Process] = None - self.output_poller: Optional[asyncio.Task] = None - - async def _read_output(self, name: Literal['stdout', 'stderr']): - '''Read stdout or stderr until the process exits.''' - stream = getattr(self.process, name) - output = getattr(self, name) - while line := await stream.readline(): - output.append(line.decode().strip()) - - async def _read_outputs(self): - '''Read stdout & stderr until the process exits.''' - assert self.process - await asyncio.gather( - self._read_output('stderr'), self._read_output('stdout') - ) - - def _get_base_cmdline_options(self) -> tuple[str]: - '''The base commandline options for dnsmasq.''' - return ( - '--keep-in-foreground', # self explanatory - '--no-resolv', # don't mess w/ resolv.conf - '--log-facility=-', # log to stdout - '--no-hosts', # don't read /etc/hosts - '--bind-interfaces', # don't bind on wildcard - '--no-ping', # don't ping to check if ips are attributed - ) - - def get_cmdline_options(self) -> tuple[str]: - '''All commandline options passed to dnsmasq.''' - return (*self._get_base_cmdline_options(), *self.options) - - async def __aenter__(self): - '''Start the dnsmasq process and start polling its output.''' - assert self.DNSMASQ_PATH - self.process = await asyncio.create_subprocess_exec( - self.DNSMASQ_PATH, - *self.get_cmdline_options(), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env={'LANG': 'C'}, - ) - self.output_poller = asyncio.Task(self._read_outputs()) - return self - - async def __aexit__(self, *_): - if self.process: - if self.process.returncode is None: - self.process.terminate() - await self.process.wait() - await self.output_poller - - -@pytest.fixture -def dnsmasq_options( - veth_pair: tuple[str, str], dhcp_range: DHCPRangeConfig -) -> DnsmasqOptions: - '''dnsmasq options useful for test purposes.''' - return DnsmasqOptions( - range_start=dhcp_range.range_start, - range_end=dhcp_range.range_end, - interface=veth_pair[0], - ) - - -@pytest_asyncio.fixture -async def dnsmasq( - dnsmasq_options: DnsmasqOptions, -) -> AsyncGenerator[DnsmasqFixture, None]: - '''A dnsmasq instance running for the duration of the test.''' - async with DnsmasqFixture(options=dnsmasq_options) as dnsf: - yield dnsf - - -def get_psr() -> ArgumentParser: - psr = ArgumentParser() - psr.add_argument('interface', help='Interface to listen on') - psr.add_argument( - '--range-start', - type=IPv4Address, - default=IPv4Address('192.168.186.10'), - help='Start of the DHCP client range.', - ) - psr.add_argument( - '--range-end', - type=IPv4Address, - default=IPv4Address('192.168.186.100'), - help='End of the DHCP client range.', - ) - psr.add_argument( - '--lease-time', - default='2m', - help='DHCP lease time (minimum 2 minutes according to man)', - ) - return psr - - -async def main(): - '''Commandline entrypoint to start dnsmasq the same way the fixture does. - - Useful for debugging. - ''' - args = get_psr().parse_args() - opts = DnsmasqOptions(**args.__dict__) - read_lines: int = 0 - async with DnsmasqFixture(opts) as dnsm: - # quick & dirty stderr polling - while True: - if len(dnsm.stderr) > read_lines: - read_lines += len(lines := dnsm.stderr[read_lines:]) - print(*lines, sep='\n') - else: - await asyncio.sleep(0.2) - - -if __name__ == '__main__': - asyncio.run(main()) diff --git a/tests/test_linux/fixtures/interfaces.py b/tests/test_linux/fixtures/interfaces.py index c276b2cbc..caa2229ab 100644 --- a/tests/test_linux/fixtures/interfaces.py +++ b/tests/test_linux/fixtures/interfaces.py @@ -1,6 +1,6 @@ import asyncio import random -from ipaddress import IPv4Address, IPv4Interface +from ipaddress import IPv4Address from typing import AsyncGenerator, NamedTuple import pytest @@ -8,9 +8,10 @@ class DHCPRangeConfig(NamedTuple): - range_start: IPv4Address - range_end: IPv4Address - gw: IPv4Interface + start: IPv4Address + end: IPv4Address + router: IPv4Address + netmask: IPv4Address async def ip(*args: str): @@ -23,12 +24,13 @@ async def ip(*args: str): @pytest.fixture def dhcp_range() -> DHCPRangeConfig: - ''' 'An IPv4 DHCP range configuration.''' + '''An IPv4 DHCP range configuration.''' rangeidx = random.randint(1, 254) return DHCPRangeConfig( - range_start=IPv4Address(f'10.{rangeidx}.0.10'), - range_end=IPv4Address(f'10.{rangeidx}.0.20'), - gw=IPv4Interface(f'10.{rangeidx}.0.1/16'), + start=IPv4Address(f'10.{rangeidx}.0.10'), + end=IPv4Address(f'10.{rangeidx}.0.20'), + router=IPv4Address(f'10.{rangeidx}.0.1'), + netmask=IPv4Address('255.255.255.0'), ) @@ -60,7 +62,13 @@ async def veth_pair( 'name', client_ifname, ) - await ip('addr', 'add', str(dhcp_range.gw), 'dev', server_ifname) + await ip( + 'addr', + 'add', + f"{dhcp_range.router}/{dhcp_range.netmask}", + 'dev', + server_ifname, + ) await ip('link', 'set', server_ifname, 'up') await ip('link', 'set', client_ifname, 'up') yield VethPair(server_ifname, client_ifname) diff --git a/tests/test_linux/test_raw/test_dhcp.py b/tests/test_linux/test_raw/test_dhcp.py index e53015733..42c8059a5 100644 --- a/tests/test_linux/test_raw/test_dhcp.py +++ b/tests/test_linux/test_raw/test_dhcp.py @@ -4,13 +4,15 @@ from pathlib import Path import pytest -from fixtures.dnsmasq import DnsmasqFixture +from fixtures.dhcp_servers.dnsmasq import DnsmasqFixture +from fixtures.dhcp_servers.udhcpd import UdhcpdFixture from fixtures.interfaces import VethPair from pr2test.marks import require_root -from pyroute2.dhcp import client, fsm +from pyroute2.dhcp import fsm +from pyroute2.dhcp.client import AsyncDHCPClient from pyroute2.dhcp.constants import bootp, dhcp -from pyroute2.dhcp.leases import JSONFileLease +from pyroute2.dhcp.leases import JSONFileLease, JSONStdoutLease pytestmark = [require_root()] @@ -21,24 +23,19 @@ async def test_get_lease( veth_pair: VethPair, tmpdir: str, monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, ): '''The client can get a lease and write it to a file.''' work_dir = Path(tmpdir) + caplog.set_level("DEBUG") # Patch JSONFileLease so leases get written to the temp dir # instead of whatever the working directory is monkeypatch.setattr(JSONFileLease, '_get_lease_dir', lambda: work_dir) # boot up the dhcp client and wait for a lease - async with client.AsyncDHCPClient(veth_pair.client) as cli: + async with AsyncDHCPClient(veth_pair.client) as cli: await cli.bootstrap() - try: - await asyncio.wait_for( - cli.wait_for_state(fsm.State.BOUND), timeout=5 - ) - except TimeoutError: - raise AssertionError( - f'Timed out. dnsmasq output: {dnsmasq.stderr}' - ) + await cli.wait_for_state(fsm.State.BOUND, timeout=10) assert cli.state == fsm.State.BOUND lease = cli.lease assert lease.ack['xid'] == cli.xid @@ -48,9 +45,9 @@ async def test_get_lease( assert lease.ack['op'] == bootp.MessageType.BOOTREPLY assert lease.ack['options']['message_type'] == dhcp.MessageType.ACK assert ( - dnsmasq.options.range_start + dnsmasq.config.range.start <= IPv4Address(lease.ip) - <= dnsmasq.options.range_end + <= dnsmasq.config.range.end ) assert lease.ack['chaddr'] # TODO: check chaddr matches veth_pair.client's MAC @@ -72,8 +69,8 @@ async def test_client_console(dnsmasq: DnsmasqFixture, veth_pair: VethPair): '--lease-type', 'pyroute2.dhcp.leases.JSONStdoutLease', '--exit-on-lease', + '--log-level=DEBUG', stdout=asyncio.subprocess.PIPE, - # stderr=asyncio.subprocess.PIPE, ) try: stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5) @@ -84,7 +81,48 @@ async def test_client_console(dnsmasq: DnsmasqFixture, veth_pair: VethPair): json_lease = json.loads(stdout) assert json_lease['interface'] == veth_pair.client assert ( - dnsmasq.options.range_start + dnsmasq.config.range.start <= IPv4Address(json_lease['ack']['yiaddr']) - <= dnsmasq.options.range_end + <= dnsmasq.config.range.end ) + + +@pytest.mark.asyncio +async def test_client_lifecycle(udhcpd: UdhcpdFixture, veth_pair: VethPair): + '''Test getting a lease, expiring & getting a lease again.''' + async with AsyncDHCPClient( + veth_pair.client, lease_type=JSONStdoutLease + ) as cli: + # No lease, we're in the INIT state + assert cli.state == fsm.State.INIT + # Start requesting an IP + await cli.bootstrap() + # Then, the client in the SELECTING state while sending DISCOVERs + await cli.wait_for_state(fsm.State.SELECTING, timeout=1) + # Once we get an OFFER the client switches to REQUESTING + await cli.wait_for_state(fsm.State.REQUESTING, timeout=1) + # After getting an ACK, we're BOUND ! + await cli.wait_for_state(fsm.State.BOUND, timeout=1) + + # Ideally, we would test the REBINDING & RENEWING states here, + # but they depend on timers that udhcpd does not implement. + + # The lease expires, and we're back to INIT + await cli.wait_for_state(fsm.State.INIT, timeout=5) + await cli.wait_for_state(fsm.State.SELECTING, timeout=1) + await cli.wait_for_state(fsm.State.REQUESTING, timeout=1) + await cli.wait_for_state(fsm.State.BOUND, timeout=1) + + # Stop here, that's enough + lease = cli.lease + assert lease.ack['xid'] == cli.xid + + # The obtained IP must be in the range + assert ( + udhcpd.config.range.start + <= IPv4Address(lease.ip) + <= udhcpd.config.range.end + ) + assert lease.routers == [str(udhcpd.config.range.router)] + assert lease.interface == veth_pair.client + assert lease.ack["options"]["lease_time"] == udhcpd.config.lease_time