├── pydhcp ├── py.typed ├── v4 │ ├── server │ │ ├── __init__.py │ │ ├── backend │ │ │ ├── __init__.py │ │ │ ├── simple.py │ │ │ ├── cache.py │ │ │ ├── memory.py │ │ │ └── pxe.py │ │ └── server.py │ ├── tests │ │ ├── __init__.py │ │ ├── server.py │ │ └── message.py │ ├── __init__.py │ ├── exceptions.py │ ├── client │ │ └── __init__.py │ ├── enum.py │ ├── message.py │ └── options.py ├── v6 │ └── __init__.py ├── __init__.py ├── enum.py └── abc.py ├── .gitignore ├── LICENSE ├── pyproject.toml └── README.md /pydhcp/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pydhcp/v4/server/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCP Extensible Server Implementation 3 | """ 4 | 5 | #** Variables **# 6 | __all__ = ['Server'] 7 | 8 | #** Imports **# 9 | from .server import Server 10 | -------------------------------------------------------------------------------- /pydhcp/v6/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCPv6 Implementation 3 | """ 4 | 5 | #** Variables **# 6 | __all__ = [] 7 | 8 | # DHCPv6 not yet implemented 9 | raise NotImplementedError('DHCPv6 is not implemented yet. Sorry :(') 10 | -------------------------------------------------------------------------------- /pydhcp/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCP v4/v6 Messaging Library 3 | """ 4 | 5 | #** Variables **# 6 | __all__ = [ 7 | 'Arch', 8 | 'HwType', 9 | 'StatusCode' 10 | ] 11 | 12 | #** Imports **# 13 | from .enum import * 14 | -------------------------------------------------------------------------------- /pydhcp/v4/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCPv4 Library UnitTests 3 | """ 4 | 5 | #** Variables **# 6 | __all__ = ['MessageTests', 'MemoryTests'] 7 | 8 | #** Imports **# 9 | from .message import MessageTests 10 | from .server import MemoryTests 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | eggs/ 15 | lib/ 16 | lib64/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | 24 | # Installer 25 | pip-log.txt 26 | pip-delete-this-directory.txt 27 | 28 | # Unit test / coverage reports 29 | htmlcov/ 30 | .tox/ 31 | .coverage 32 | .cache 33 | nosetests.xml 34 | coverage.xml 35 | 36 | # Translations 37 | *.mo 38 | *.pot 39 | 40 | # Django stuff: 41 | *.log 42 | 43 | #Virtual env 44 | venv/ 45 | venv27/ 46 | 47 | # Sphinx documentation 48 | docs/build/ 49 | 50 | #intellij 51 | .idea/ 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Andrew C Scott 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /pydhcp/v4/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCPv4 Implementation 3 | """ 4 | 5 | #** Variables **# 6 | __all__ = [ 7 | 'OpCode', 8 | 'MessageType', 9 | 'OptionCode', 10 | 11 | 'DhcpError', 12 | 'UnknownQueryType', 13 | 'MalformedQuery', 14 | 'NoAddrsAvailable', 15 | 'NotAllowed', 16 | 'Terminated', 17 | 'NotSupported', 18 | 'AddressInUse', 19 | 20 | 'ZeroIp', 21 | 'Message', 22 | 23 | 'Option', 24 | 'Unknown', 25 | 'SubnetMask', 26 | 'TimezoneOffset', 27 | 'Router', 28 | 'TimeServer', 29 | 'INetNameServer', 30 | 'DomainNameServer', 31 | 'LogServer', 32 | 'QuoteServer', 33 | 'LPRServer', 34 | 'Hostname', 35 | 'DomainName', 36 | 'BroadcastAddr', 37 | 'VendorInfo', 38 | 'RequestedIPAddr', 39 | 'IPLeaseTime', 40 | 'DHCPMessageType', 41 | 'ServerIdentifier', 42 | 'ParamRequestList', 43 | 'DHCPMessage', 44 | 'MaxMessageSize', 45 | 'RenewalTime', 46 | 'RebindTime', 47 | 'VendorClassIdentifier', 48 | 'TFTPServerName', 49 | 'DHCPStatusCode', 50 | 'BootfileName', 51 | 'ClientSystemArch', 52 | 'DNSDomainSearchList', 53 | 'TFTPServerIP', 54 | 'PXEPathPrefix', 55 | 'End', 56 | ] 57 | 58 | #** Imports **# 59 | from .enum import * 60 | from .exceptions import * 61 | from .message import * 62 | from .options import * 63 | -------------------------------------------------------------------------------- /pydhcp/v4/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCPv4 StatusCode Exceptions 3 | """ 4 | from typing import Any, Optional 5 | 6 | from ..enum import StatusCode 7 | 8 | #** Variables **# 9 | __all__ = [ 10 | 'DhcpError', 11 | 12 | 'UnknownQueryType', 13 | 'MalformedQuery', 14 | 'NoAddrsAvailable', 15 | 'NotAllowed', 16 | 'Terminated', 17 | 'NotSupported', 18 | 'AddressInUse', 19 | ] 20 | 21 | #** Classes **# 22 | 23 | class DhcpError(Exception): 24 | code: StatusCode = StatusCode.UnspecFail 25 | 26 | def __init__(self, msg: Any = None, code: Optional[StatusCode] = None): 27 | self.message = msg 28 | self.code = code or self.code 29 | 30 | def __str__(self) -> str: 31 | if self.message and self.__class__.code == self.code: 32 | return str(self.message) 33 | return super().__str__() 34 | 35 | class UnknownQueryType(DhcpError): 36 | code = StatusCode.UnknownQueryType 37 | 38 | class MalformedQuery(DhcpError): 39 | code = StatusCode.MalformedQuery 40 | 41 | class NoAddrsAvailable(DhcpError): 42 | code = StatusCode.NoAddrsAvail 43 | 44 | class NotAllowed(DhcpError): 45 | code = StatusCode.NotAllowed 46 | 47 | class Terminated(DhcpError): 48 | code = StatusCode.QueryTerminated 49 | 50 | class NotSupported(DhcpError): 51 | code = StatusCode.NotSupported 52 | 53 | class AddressInUse(DhcpError): 54 | code = StatusCode.AddressInUse 55 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pydhcp3" 7 | version = "0.0.6" 8 | requires-python = ">=3.8" 9 | dependencies = [ 10 | 'pypool3>=0.0.2', 11 | 'pyderive3>=0.0.7', 12 | 'pystructs3>=0.0.8', 13 | 'pyserve3>=0.0.7', 14 | 'typing_extensions>=4.7.1', 15 | ] 16 | authors = [ 17 | {name = "Andrew C Scott", email = "imgurbot12@gmail.com"}, 18 | ] 19 | description = "Simple Python DHCP Library. DHCP Packet-Parsing/Client/Server" 20 | readme = {file = "README.md", content-type = "text/markdown"} 21 | license = { file = 'LICENSE' } 22 | keywords = ["dhcp", "client", "server", "parser"] 23 | classifiers = [ 24 | "Typing :: Typed", 25 | "License :: OSI Approved :: MIT License", 26 | "Intended Audience :: Developers", 27 | "Operating System :: OS Independent", 28 | "Programming Language :: Python :: 3", 29 | "Programming Language :: Python :: 3 :: Only", 30 | "Programming Language :: Python :: 3.8", 31 | "Programming Language :: Python :: 3.9", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | "Programming Language :: Python :: 3.12", 35 | "Programming Language :: Python :: 3.13" 36 | ] 37 | 38 | [project.urls] 39 | Repository = "https://github.com/imgurbot12/pydhcp" 40 | 41 | [tool.setuptools.package-data] 42 | "pydhcp3" = ["py.typed"] 43 | -------------------------------------------------------------------------------- /pydhcp/v4/server/backend/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCP Server Data Backend Implementations 3 | """ 4 | from abc import abstractmethod 5 | from typing import ClassVar, NamedTuple, Optional, Protocol 6 | 7 | from pyserve import Address 8 | 9 | from ... import Message 10 | 11 | #** Variables **# 12 | __all__ = [ 13 | 'Address', 14 | 'Answer', 15 | 'Backend', 16 | 17 | 'CacheBackend', 18 | 'MemoryBackend', 19 | 20 | 'PxeTftpConfig', 21 | 'PxeDynConfig', 22 | 'PxeConfig', 23 | 'PXEBackend', 24 | 25 | 'SimpleAnswer', 26 | 'SimpleBackend', 27 | ] 28 | 29 | #** Classes **# 30 | 31 | class Answer(NamedTuple): 32 | """ 33 | Backend DNS Answers Return Type 34 | """ 35 | message: Message 36 | source: str 37 | 38 | class Backend(Protocol): 39 | """ 40 | BaseClass Interface Definition for Backend Implementations 41 | """ 42 | source: ClassVar[str] 43 | 44 | @abstractmethod 45 | def discover(self, address: Address, request: Message) -> Optional[Answer]: 46 | """ 47 | Process DHCP DISCOVER Message and Send Response 48 | 49 | :param address: client address 50 | :param request: dhcp request message 51 | :return: dhcp response message 52 | """ 53 | raise NotImplementedError 54 | 55 | @abstractmethod 56 | def request(self, address: Address, request: Message) -> Optional[Answer]: 57 | """ 58 | Process DHCP REQUEST Message and Send Response 59 | 60 | :param address: client address 61 | :param request: dhcp request message 62 | :return: dhcp response message 63 | """ 64 | raise NotImplementedError 65 | 66 | def decline(self, address: Address, request: Message) -> Optional[Answer]: 67 | """ 68 | Process DHCP DECLINE Message and Send Response 69 | 70 | :param address: client address 71 | :param request: dhcp request message 72 | :return: dhcp response message 73 | """ 74 | raise NotImplementedError 75 | 76 | @abstractmethod 77 | def release(self, address: Address, request: Message) -> Optional[Answer]: 78 | """ 79 | Process DHCP RELEASE Message and Send Response 80 | 81 | :param address: client address 82 | :param request: dhcp request message 83 | :return: dhcp response message 84 | """ 85 | raise NotImplementedError 86 | 87 | #** Imports **# 88 | from .cache import * 89 | from .memory import * 90 | from .pxe import * 91 | from .simple import * 92 | -------------------------------------------------------------------------------- /pydhcp/enum.py: -------------------------------------------------------------------------------- 1 | """ 2 | Global Enum Types for DHCP v4/v6 3 | """ 4 | from enum import IntEnum 5 | 6 | #** Variables **# 7 | __all__ = ['Arch', 'HwType', 'StatusCode'] 8 | 9 | #** Classes **# 10 | 11 | class Arch(IntEnum): 12 | """ 13 | Hardware Architecture Type (RFC 4578, Section 2.1.) 14 | """ 15 | INTEL_X86PC = 0 16 | NEC_PC98 = 1 17 | EFI_ITANIUM = 2 18 | DEC_ALPHA = 3 19 | ARC_X86 = 4 20 | INTEL_LEAN_CLIENT = 5 21 | EFI_IA32 = 6 22 | EFI_BC = 7 23 | EFI_XSCALE = 8 24 | EFI_X86_64 = 9 25 | 26 | class HwType(IntEnum): 27 | """ 28 | Number Hardware Type (hrd) (RFC 1700) 29 | """ 30 | Ethernet = 1 31 | ExperimentalEthernet = 2 32 | AmateurRadioAX25 = 3 33 | ProteonTokenRing = 4 34 | Chaos = 5 35 | IEEE802 = 6 36 | ARCNET = 7 37 | Hyperchannel = 8 38 | Lanstar = 9 39 | Autonet = 10 40 | LocalTalk = 11 41 | LocalNet = 12 42 | UltraLink = 13 43 | SMDS = 14 44 | FrameRelay = 15 45 | ATM = 16 46 | HDLC = 17 47 | FibreChannel = 18 48 | ATM2 = 19 49 | SerialLine = 20 50 | ATM3 = 21 51 | MILSTD188220 = 22 52 | Metricom = 23 53 | IEEE1394 = 24 54 | MAPOS = 25 55 | Twinaxial = 26 56 | EUI64 = 27 57 | HIPARP = 28 58 | ISO7816 = 29 59 | ARPSec = 30 60 | IPsec = 31 61 | Infiniband = 32 62 | CAI = 33 63 | WiegandInterface = 34 64 | PureIP = 35 65 | 66 | class StatusCode(IntEnum): 67 | """ 68 | IANA Status Codes for DHCPv6 69 | https://www.iana.org/assignments/dhcpv6-parameters/dhcpv6-parameters.xhtml#dhcpv6-parameters-5 70 | """ 71 | # RFC 3315 par. 24..4 72 | Success = 0 73 | UnspecFail = 1 74 | NoAddrsAvail = 2 75 | NoBinding = 3 76 | NotOnLink = 4 77 | UseMulticast = 5 78 | NoPrefixAvail = 6 79 | # RFC 5007 80 | UnknownQueryType = 7 81 | MalformedQuery = 8 82 | NotConfigured = 9 83 | NotAllowed = 10 84 | # RFC 5460 85 | QueryTerminated = 11 86 | # RFC 7653 87 | DataMissing = 12 88 | CatchUpComplete = 13 89 | NotSupported = 14 90 | TLSConnectionRefused = 15 91 | # RFC 8156 92 | AddressInUse = 16 93 | ConfigurationConflict = 17 94 | MissingBindingInformation = 18 95 | OutdatedBindingInformation = 19 96 | ServerShuttingDown = 20 97 | DNSUpdateNotSupported = 21 98 | ExcessiveTimeSkew = 22 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pydhcp 2 | ------- 3 | 4 | [![PyPI version](https://img.shields.io/pypi/v/pydhcp3?style=for-the-badge)](https://pypi.org/project/pydhcp3/) 5 | [![Python versions](https://img.shields.io/pypi/pyversions/pydhcp3?style=for-the-badge)](https://pypi.org/project/pydhcp3/) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://github.com/imgurbot12/pydhcp/blob/master/LICENSE) 7 | [![Made with Love](https://img.shields.io/badge/built%20with-%E2%99%A5-orange?style=for-the-badge)](https://github.com/imgurbot12/pydhcp) 8 | 9 | Simple Python DHCP Library. DHCP Packet-Parsing/Client/Server 10 | 11 | ### Installation 12 | 13 | ``` 14 | pip install pydhcp3 15 | ``` 16 | 17 | ### DHCPv4 Examples 18 | 19 | Packet Parsing 20 | 21 | ```python 22 | from pydhcp.v4 import Message 23 | 24 | hex = \ 25 | '0101060000003d1d0000000000000000000000000000000000000000000b8201fc4200' +\ 26 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 27 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 28 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 29 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 30 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 31 | '0000000000000000000000000000000000000000000000000000638253633501013d07' +\ 32 | '01000b8201fc4232040000000037040103062aff00000000000000' 33 | 34 | raw = bytes.fromhex(hex) 35 | message = Message.unpack(raw) 36 | print(message) 37 | ``` 38 | 39 | Client 40 | 41 | ```python 42 | from pydhcp.v4 import Message 43 | from pydhcp.v4.client import Client, new_message_id 44 | 45 | mac = 'aa:bb:cc:dd:ee:ff' 46 | client = Client(interface=None) 47 | 48 | # send crafted messages 49 | id = new_message_id() 50 | hwaddr = bytes.fromhex(mac.replace(':', '')) 51 | request = Message.discover(id, hwaddr) 52 | response = client.request(request) 53 | print(response) 54 | 55 | # or simplify the standard network assignment request process 56 | record = client.request_assignment(mac) 57 | print(record) 58 | ``` 59 | 60 | Server 61 | 62 | ```python 63 | import logging 64 | from ipaddress import IPv4Address, IPv4Network 65 | 66 | from pyserve import listen_udp_threaded 67 | 68 | from pydhcp.v4.server import Server 69 | from pydhcp.v4.server.backend import MemoryBackend, CacheBackend 70 | 71 | # prepare simple memory backend as base provider 72 | backend = MemoryBackend( 73 | network=IPv4Network('192.168.1.0/24'), 74 | gateway=IPv4Address('192.168.1.1'), 75 | dns=[IPv4Address('8.8.8.8'), IPv4Address('8.8.4.4')], 76 | ) 77 | 78 | # wrap backend w/ cache (not really useful here but for non-memory backends) 79 | backend = CacheBackend(backend) 80 | 81 | # configure optional logger for server implementaion 82 | logging.basicConfig(level=logging.DEBUG) 83 | logger = logging.getLogger('myserver') 84 | logger.setLevel(logging.INFO) 85 | 86 | # launch server and run forever using pyserve 87 | listen_udp_threaded( 88 | address=('0.0.0.0', 67), 89 | factory=Server, 90 | allow_broadcast=True, 91 | backend=backend, 92 | logger=logger, 93 | server_id=IPv4Address('192.168.1.1') # dhcp server address 94 | ) 95 | ``` 96 | -------------------------------------------------------------------------------- /pydhcp/abc.py: -------------------------------------------------------------------------------- 1 | """ 2 | Abstract Baseclasses and Collections for PyDHCP Library 3 | """ 4 | from abc import ABC 5 | from enum import IntEnum 6 | from typing import ( 7 | Any, ClassVar, Generic, Iterable, Iterator, KeysView, 8 | List, Optional, Sequence, Set, Type, TypeVar, ValuesView, cast, overload) 9 | 10 | #** Variables **# 11 | __all__ = ['DHCPOption', 'OptionList'] 12 | 13 | O = TypeVar('O', bound='DHCPOption') 14 | T = TypeVar('T', bound='DHCPOption') 15 | 16 | #** Classes **# 17 | 18 | class DHCPOption(ABC): 19 | opcode: ClassVar[IntEnum] 20 | 21 | class OptionList(Sequence[O], Generic[O]): 22 | """ 23 | Hybrid Between Dictionary/List for Quick DHCP Option Selection 24 | """ 25 | 26 | def __init__(self, data: Sequence[O] = ()): 27 | self.data: List[O] = [] 28 | self.opcodes: Set[int] = set() 29 | self.extend(data) 30 | 31 | def append(self, value: O) -> None: 32 | if value.opcode not in self.opcodes: 33 | self.opcodes.add(value.opcode) 34 | self.data.append(value) 35 | return 36 | for n, op in enumerate(self.data, 0): 37 | if op.opcode == value.opcode: 38 | self.data[n] = value 39 | return 40 | 41 | def extend(self, values: Iterable[O]) -> None: 42 | for op in values: 43 | self.append(op) 44 | 45 | def remove(self, value: O) -> None: 46 | self.data.remove(value) 47 | self.opcodes.remove(value.opcode) 48 | 49 | def find(self, value: Any, 50 | start: int = 0, stop: Optional[int] = None) -> Optional[int]: 51 | for n, op in enumerate(self.data[start:stop], start): 52 | if op == value: 53 | return n 54 | 55 | def index(self, 56 | value: Any, start: int = 0, stop: Optional[int] = None) -> int: 57 | index = self.find(value, start, stop) 58 | if index is not None: 59 | return index 60 | raise ValueError(f'{value!r} is not in list') 61 | 62 | def insert(self, index: int, value: O) -> None: 63 | idx = self.find(value) 64 | if idx is not None: 65 | self.data.remove(value) 66 | self.data.insert(index, value) 67 | self.opcodes.add(value.opcode) 68 | 69 | @overload 70 | def get(self, key: int, default: Any = None) -> Optional[O]: 71 | ... 72 | 73 | @overload 74 | def get(self, key: Type[T], default: Any = None) -> Optional[T]: 75 | ... 76 | 77 | def get(self, key, default = None): 78 | key = key.opcode if hasattr(key, 'opcode') else key 79 | return self[key] if key in self.opcodes else default 80 | 81 | def keys(self) -> KeysView[int]: 82 | return cast(KeysView, (op.opcode for op in self.data)) 83 | 84 | def values(self) -> ValuesView[O]: 85 | return cast(ValuesView, iter(self.data)) 86 | 87 | def setdefault(self, op: O, index: Optional[int] = None): 88 | if op.opcode in self.opcodes: 89 | return 90 | if index is None: 91 | return self.append(op) 92 | return self.insert(index, op) 93 | 94 | def __repr__(self) -> str: 95 | return f'{self.__class__.__name__}({self.data!r})' 96 | 97 | def __iter__(self) -> Iterator[O]: 98 | return iter(self.data) 99 | 100 | def __len__(self) -> int: 101 | return len(self.data) 102 | 103 | def __contains__(self, key: object, /) -> bool: #type: ignore 104 | key = key.opcode if isinstance(key, DHCPOption) else key 105 | return key in self.opcodes 106 | 107 | @overload 108 | def __getitem__(self, key: slice, /) -> List[O]: 109 | ... 110 | 111 | @overload 112 | def __getitem__(self, key: int, /) -> O: 113 | ... 114 | 115 | def __getitem__(self, key, /): #type: ignore 116 | if isinstance(key, slice): 117 | return self.data.__getitem__(key) 118 | key = key.opcode if isinstance(key, DHCPOption) else key 119 | if key not in self.opcodes: 120 | raise KeyError(key) 121 | for op in self.data: 122 | if op.opcode == key: 123 | return op 124 | raise KeyError(key) 125 | -------------------------------------------------------------------------------- /pydhcp/v4/server/backend/simple.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simplified DHCP Backend Protocol for IP-Assignments ONLY 3 | """ 4 | from abc import abstractmethod 5 | from datetime import timedelta 6 | from ipaddress import IPv4Address, IPv4Interface 7 | from logging import Logger 8 | from typing import ClassVar, List, Optional, Protocol 9 | 10 | from pyderive import field 11 | from pyderive.extensions.validate import BaseModel 12 | 13 | from . import Address, Answer, Backend 14 | from ... import * 15 | from .... import HwType, StatusCode 16 | 17 | #** Variables **# 18 | __all__ = ['SimpleAnswer', 'SimpleBackend'] 19 | 20 | #** Classes **# 21 | 22 | class SimpleAnswer(BaseModel): 23 | """ 24 | Simplified Backend IP-Assignment Record 25 | """ 26 | source: str 27 | lease: timedelta 28 | ipv4: IPv4Interface 29 | routers: List[IPv4Address] 30 | dns: List[IPv4Address] 31 | dns_search: List[bytes] = field(default_factory=list) 32 | 33 | class SimpleBackend(Backend, Protocol): 34 | """ 35 | Simplified DHCP Backend for Simple IP-Assignment ONLY 36 | """ 37 | source: ClassVar[str] 38 | logger: Logger 39 | 40 | @abstractmethod 41 | def request_address(self, 42 | mac: str, ipv4: Optional[IPv4Address]) -> Optional[SimpleAnswer]: 43 | """ 44 | Retrieve IP-Address Assignment for Specified MAC-Address 45 | 46 | :param mac: mac-address of dhcp client 47 | :param ipv4: requested ip-address of client (if specified) 48 | :return: new network assignment (if granted) 49 | """ 50 | raise NotImplementedError 51 | 52 | @abstractmethod 53 | def release_address(self, mac: str): 54 | """ 55 | Release ANY existing Assignment for Specified MAC-Address 56 | 57 | :param mac: mac-address of dhcp client 58 | """ 59 | raise NotImplementedError 60 | 61 | def _assign(self, address: Address, request: Message) -> Message: 62 | """ 63 | retrieve assignment and generate dhcp response message 64 | 65 | :param address: client address 66 | :param request: dhcp request message 67 | :return: dhcp response message 68 | """ 69 | mac = request.client_hw.hex() 70 | ipv4 = request.requested_address() 71 | assign = self.request_address(mac, ipv4) 72 | if assign is None: 73 | return request.reply([ 74 | DHCPStatusCode( 75 | value=StatusCode.NoAddrsAvail, 76 | message=b'all addresses in use')]) 77 | lease = int(assign.lease.total_seconds()) 78 | self.logger.info( 79 | f'{address[0]} | {mac} -> ip={assign.ipv4} ' 80 | f'gw={",".join(str(ip) for ip in assign.routers)} ' 81 | f'dns={",".join(str(ip) for ip in assign.dns)} ' 82 | f'lease={lease} source={assign.source}' 83 | ) 84 | return request.reply( 85 | your_addr=assign.ipv4.ip, 86 | options=[ 87 | DomainNameServer(assign.dns), 88 | DNSDomainSearchList(assign.dns_search), 89 | Router(assign.routers), 90 | SubnetMask(assign.ipv4.netmask), 91 | IPLeaseTime(lease), 92 | RenewalTime(int(lease * 0.5)), # 1/2 of lease time 93 | RebindTime(int(lease * 0.875)) # 7/8 of lease time 94 | ] 95 | ) 96 | 97 | def discover(self, address: Address, request: Message) -> Optional[Answer]: 98 | if request.hw_type == HwType.Ethernet: 99 | message = self._assign(address, request) 100 | return Answer(message, self.source) 101 | 102 | def request(self, address: Address, request: Message) -> Optional[Answer]: 103 | if request.hw_type == HwType.Ethernet: 104 | message = self._assign(address, request) 105 | return Answer(message, self.source) 106 | 107 | def decline(self, address: Address, request: Message) -> Optional[Answer]: 108 | if request.hw_type == HwType.Ethernet: 109 | self.release_address(request.client_hw.hex()) 110 | 111 | def release(self, address: Address, request: Message) -> Optional[Answer]: 112 | if request.hw_type == HwType.Ethernet: 113 | self.release_address(request.client_hw.hex()) 114 | -------------------------------------------------------------------------------- /pydhcp/v4/server/backend/cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Backend Extension to support In-Memory Answer Caching 3 | """ 4 | from copy import copy 5 | from ipaddress import IPv4Address 6 | from datetime import datetime, timedelta 7 | from logging import Logger, getLogger 8 | from threading import Lock 9 | from typing import ClassVar, Dict, Optional, Set 10 | 11 | from pyderive import InitVar, dataclass, field 12 | 13 | from .memory import MemoryBackend, clean_mac 14 | from .simple import SimpleAnswer, SimpleBackend 15 | 16 | #** Variables **# 17 | __all__ = ['CacheBackend'] 18 | 19 | #: default set of other backend sources to ignore 20 | IGNORE = {MemoryBackend.source} 21 | 22 | #** Classes **# 23 | 24 | @dataclass(slots=True) 25 | class CacheRecord: 26 | """ 27 | Record Entry for In-Memory Cache 28 | """ 29 | answer: SimpleAnswer 30 | lifetime: timedelta 31 | expires: datetime = field(init=False) 32 | accessed: datetime = field(init=False) 33 | 34 | def __post_init__(self): 35 | """ 36 | calculate expiration-time and last-accessed time 37 | """ 38 | lease = self.answer.lease 39 | ttl = min(lease, self.lifetime) 40 | now = datetime.now() 41 | self.expires = now + ttl 42 | self.accessed = now 43 | 44 | def is_expired(self) -> bool: 45 | """ 46 | calculate if expiration has passed or ttl is expired 47 | """ 48 | now = datetime.now() 49 | if self.expires <= now: 50 | return True 51 | self.answer.lease -= now - self.accessed 52 | if self.answer.lease <= self.lifetime: 53 | return True 54 | self.accessed = now 55 | return False 56 | 57 | @dataclass(slots=True) 58 | class CacheBackend(SimpleBackend): 59 | """ 60 | In-Memory Cache Extension for Backend IP-Lease Results 61 | """ 62 | source: ClassVar[str] = 'CACHE' 63 | 64 | backend: SimpleBackend 65 | expiration: timedelta = timedelta(seconds=30) 66 | maxsize: int = 10000 67 | ignore_sources: Set[str] = field(default_factory=lambda: IGNORE) 68 | logger: Logger = field(default_factory=lambda: getLogger('pydhcp')) 69 | 70 | mutex: Lock = field(default_factory=Lock, init=False) 71 | cache: Dict[str, CacheRecord] = field(default_factory=dict, init=False) 72 | 73 | def __post_init__(self): 74 | self.logger = self.logger.getChild('cache') 75 | 76 | def get_cache(self, mac: str) -> Optional[SimpleAnswer]: 77 | """ 78 | retrieve from cache directly if present 79 | 80 | :param mac: mac-address key linked to answer in cache 81 | :return: ip-assignment answer stored in cache 82 | """ 83 | mac = clean_mac(mac) 84 | with self.mutex: 85 | if mac not in self.cache: 86 | return 87 | record = self.cache[mac] 88 | if record.is_expired(): 89 | self.logger.debug(f'{mac} expired') 90 | del self.cache[mac] 91 | return 92 | return record.answer 93 | 94 | def set_cache(self, mac: str, answer: SimpleAnswer): 95 | """ 96 | save the given answers to cache for the specified mac-address 97 | 98 | :param mac: mac-address key linked to answer 99 | :param answer: ip-assignment answer to store in cache 100 | """ 101 | mac = clean_mac(mac) 102 | with self.mutex: 103 | if len(self.cache) >= self.maxsize: 104 | self.logger.debug(f'maxsize: {self.maxsize} exceeded. clearing cache!') 105 | self.cache.clear() 106 | answer = copy(answer) 107 | answer.source = self.source 108 | self.cache[mac] = CacheRecord(answer, self.expiration) 109 | 110 | def request_address(self, 111 | mac: str, ipv4: Optional[IPv4Address]) -> Optional[SimpleAnswer]: 112 | answer = self.get_cache(mac) 113 | if answer and (ipv4 is None or answer.ipv4.ip == ipv4): 114 | return answer 115 | answer = self.backend.request_address(mac, ipv4) 116 | if answer and answer.source not in self.ignore_sources: 117 | self.set_cache(mac, answer) 118 | return answer 119 | 120 | def release_address(self, mac: str): 121 | return self.backend.release_address(mac) 122 | -------------------------------------------------------------------------------- /pydhcp/v4/client/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCPv4 Simple UDP Client Implementation 3 | """ 4 | import socket 5 | from datetime import timedelta 6 | from ipaddress import IPv4Address 7 | from random import randint 8 | from typing import List, NamedTuple, Optional 9 | 10 | from pyderive import dataclass 11 | 12 | from .. import OpCode, Message, MessageType 13 | from .. import DomainNameServer, DNSDomainSearchList, IPLeaseTime, Router 14 | 15 | #** Variables **# 16 | __all__ = ['Client', 'new_message_id'] 17 | 18 | #** Functions **# 19 | 20 | def new_message_id() -> int: 21 | """ 22 | generate a new valid id for a dns message packet 23 | 24 | :return: new valid message-id integer 25 | """ 26 | return randint(1, 2 ** 32) 27 | 28 | #** Classes **# 29 | 30 | class IPAssignment(NamedTuple): 31 | """ 32 | Simplified DHCP Option Results from DHCPv4 Server Ack 33 | """ 34 | message: Message 35 | lease: timedelta 36 | ipv4: IPv4Address 37 | subnet: IPv4Address 38 | routers: List[IPv4Address] 39 | dns: List[IPv4Address] 40 | dns_search: List[bytes] 41 | 42 | @dataclass(slots=True) 43 | class Client: 44 | """ 45 | Baseclass Socket-Based DNS Client Implementation 46 | """ 47 | block_size: int = 65535 48 | timeout: int = 10 49 | interface: Optional[str] = None 50 | 51 | def request(self, request: Message) -> Message: 52 | """ 53 | make dhcp request and wait for server response 54 | 55 | :param request: dhcp request message 56 | :return: dhcp response message 57 | """ 58 | if request.op != OpCode.BootRequest: 59 | raise ValueError('Message is not DHCP Request') 60 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 61 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 62 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 63 | if self.interface: 64 | iface = self.interface.encode() 65 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, iface) 66 | sock.settimeout(self.timeout) 67 | try: 68 | sock.bind(('', 68)) 69 | sock.sendto(request.pack(), ('255.255.255.255', 67)) 70 | while True: 71 | data, _ = sock.recvfrom(self.block_size) 72 | response = Message.unpack(data) 73 | if response.id == request.id \ 74 | and response.op == OpCode.BootReply: 75 | return response 76 | finally: 77 | sock.close() 78 | 79 | def request_assignment(self, mac: str): 80 | """ 81 | complete traditional dhcp round-robin request for ip-address 82 | 83 | :param mac: mac-address linked to network interface 84 | :return: ip-assignment recieved from dhcp-server 85 | """ 86 | # make initial discover request 87 | id = new_message_id() 88 | hwaddr = bytes.fromhex(mac.replace(':', '').replace('-', '')) 89 | request = Message.discover(id, hwaddr) 90 | response = self.request(request) 91 | if not response.your_addr \ 92 | or response.message_type() != MessageType.Offer: 93 | raise RuntimeError('DHCP Failed to Offer IPAddress') 94 | # make request for specified ip-address 95 | request = Message.request(id, hwaddr, response.your_addr) 96 | response = self.request(request) 97 | if not response.your_addr \ 98 | or response.message_type() != MessageType.Ack: 99 | raise RuntimeError('DHCP Failed to Acknowledge Request') 100 | # return new assignment 101 | subnet = response.subnet_mask() 102 | routers = response.options.get(Router) 103 | dns = response.options.get(DomainNameServer) 104 | search = response.options.get(DNSDomainSearchList) 105 | lease = response.options.get(IPLeaseTime) 106 | if subnet is None: 107 | raise RuntimeError('Subnet Not Specified') 108 | if routers is None: 109 | raise RuntimeError('No Routing Gateways Specified') 110 | if lease is None: 111 | raise RuntimeError('IP Lease Not Specified') 112 | return IPAssignment( 113 | message=response, 114 | ipv4=response.your_addr, 115 | lease=timedelta(seconds=lease.seconds), 116 | subnet=subnet, 117 | routers=routers.ips, 118 | dns=dns.ips if dns else [], 119 | dns_search=search.domains if search else [] 120 | ) 121 | 122 | -------------------------------------------------------------------------------- /pydhcp/v4/tests/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCPv4 Server In-Memory Backend UnitTests 3 | """ 4 | import time 5 | import random 6 | from datetime import timedelta 7 | from ipaddress import IPv4Address, IPv4Network 8 | from unittest import TestCase 9 | 10 | from .. import * 11 | 12 | from ..server.backend import Address, MemoryBackend 13 | 14 | #** Variables **# 15 | __all__ = ['MemoryTests'] 16 | 17 | ADDR = Address('0.0.0.0', 67) 18 | HWADDR = 'aa:bb:cc:dd:ee:ff'.replace(':', '') 19 | 20 | #** Classes **# 21 | 22 | class MemoryTests(TestCase): 23 | """ 24 | Server Memory Backend Implementation UnitTests 25 | """ 26 | 27 | def setUp(self): 28 | """ 29 | setup memory backend for testing 30 | """ 31 | self.dns = IPv4Address('1.1.1.1') 32 | self.network = IPv4Network('192.168.1.0/29') 33 | self.gateway = IPv4Address('192.168.1.1') 34 | self.backend = MemoryBackend( 35 | network=self.network, 36 | dns=[self.dns], 37 | gateway=self.gateway, 38 | default_lease=timedelta(seconds=1) 39 | ) 40 | self.netmask = self.network.netmask 41 | 42 | def test_assign(self): 43 | """ 44 | ensure memory backend preserves assignment betweeen discover/request 45 | """ 46 | for n in range(2): 47 | with self.subTest('request_address', n=n): 48 | answer = self.backend.request_address(HWADDR, None) 49 | if answer is None: 50 | return self.assertTrue(False, 'response is none') 51 | self.assertEqual(answer.ipv4.ip, IPv4Address('192.168.1.2')) 52 | self.assertEqual(answer.lease, timedelta(seconds=1)) 53 | self.assertEqual(answer.ipv4.netmask, self.netmask) 54 | self.assertListEqual(answer.dns, [self.dns]) 55 | self.assertListEqual(answer.routers, [self.gateway]) 56 | 57 | def test_release(self): 58 | """ 59 | ensure memory backend reuses addresses after release 60 | """ 61 | self.test_assign() 62 | with self.subTest('get_next_address'): 63 | newhw = bytes([random.randint(0, 255) for _ in range(6)]) 64 | answer = self.backend.request_address(newhw.hex(), None) 65 | if answer is None: 66 | return self.assertTrue(False, 'response is none') 67 | self.assertEqual(answer.ipv4.ip, IPv4Address('192.168.1.3')) 68 | with self.subTest('manual_release_and_renew'): 69 | self.backend.release_address(HWADDR) 70 | answer = self.backend.request_address(HWADDR, None) 71 | if answer is None: 72 | return self.assertTrue(False, 'response is none') 73 | self.assertEqual(answer.ipv4.ip, IPv4Address('192.168.1.2')) 74 | with self.subTest('get_next_address_2'): 75 | newhw = bytes([random.randint(0, 255) for _ in range(6)]) 76 | answer = self.backend.request_address(newhw.hex(), None) 77 | if answer is None: 78 | return self.assertTrue(False, 'response is none') 79 | self.assertEqual(answer.ipv4.ip, IPv4Address('192.168.1.4')) 80 | with self.subTest('auto_release_and_renew'): 81 | time.sleep(1) 82 | self.backend._reclaim_all() 83 | answer = self.backend.request_address(HWADDR, None) 84 | if answer is None: 85 | return self.assertTrue(False, 'response is none') 86 | self.assertEqual(answer.ipv4.ip, IPv4Address('192.168.1.2')) 87 | 88 | def test_static(self): 89 | """ 90 | test static assignment override 91 | """ 92 | static = IPv4Address('192.168.1.3') 93 | self.backend.set_static(HWADDR, static) 94 | answer = self.backend.request_address(HWADDR, None) 95 | if answer is None: 96 | return self.assertTrue(False, 'response is none') 97 | self.assertEqual(answer.ipv4.ip, static) 98 | 99 | def test_exhaust(self): 100 | """ 101 | ensure backend does not crash on ip-exhastion 102 | """ 103 | for n in range(2, 7): 104 | newhw = bytes([random.randint(0, 255) for _ in range(6)]) 105 | answer = self.backend.request_address(newhw.hex(), None) 106 | if answer is None: 107 | return self.assertTrue(False, 'response is none') 108 | self.assertEqual(answer.ipv4.ip, IPv4Address(f'192.168.1.{n}')) 109 | answer = self.backend.request_address(HWADDR, None) 110 | self.assertIsNone(answer, 'addresses should be exhausted') 111 | -------------------------------------------------------------------------------- /pydhcp/v4/server/backend/memory.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example Memory Based Backend for DHCP Server 3 | """ 4 | from datetime import datetime, timedelta 5 | from ipaddress import IPv4Address, IPv4Interface, IPv4Network 6 | from logging import Logger, getLogger 7 | from threading import Lock 8 | from typing import ClassVar, Dict, Iterator, List, Optional 9 | 10 | from pyderive import dataclass 11 | from pyderive.extensions.serde import field 12 | from pyderive.extensions.validate import BaseModel 13 | 14 | from .simple import SimpleAnswer, SimpleBackend 15 | 16 | #** Variables **# 17 | __all__ = ['MemoryBackend'] 18 | 19 | #: default lease assignment for dhcp memory-backend 20 | DEFAULT_LEASE = timedelta(seconds=3600) 21 | 22 | #** Functions **# 23 | 24 | def clean_mac(mac: str) -> str: 25 | """ 26 | clean mac-address for use as a standardized key 27 | 28 | :param mac: mac-address to clean 29 | :return: cleaned mac-address 30 | """ 31 | return mac.lower().replace(':', '').replace('-', '') 32 | 33 | #** Classes **# 34 | 35 | class IPRecord(BaseModel): 36 | """ 37 | Individual IP-Assignment Record 38 | """ 39 | ipv4: IPv4Interface = field(aliases=['ip']) 40 | dns: Optional[List[IPv4Address]] = None 41 | search: Optional[List[bytes]] = None 42 | lease: Optional[timedelta] = None 43 | gateway: Optional[IPv4Address] = field(default=None, aliases=['gw']) 44 | 45 | class Record(BaseModel): 46 | """ 47 | Tempory IPRecord with Expiration 48 | """ 49 | record: IPRecord 50 | expires: datetime 51 | 52 | @dataclass(slots=True) 53 | class MemoryBackend(SimpleBackend): 54 | """ 55 | Simple In-Memory DHCP Server Data/Address Backend 56 | """ 57 | source: ClassVar[str] = 'MEMORY' 58 | 59 | network: IPv4Network 60 | gateway: IPv4Address 61 | dns: List[IPv4Address] 62 | dns_search: List[bytes] = field(default_factory=list) 63 | default_lease: timedelta = field(default_factory=lambda: DEFAULT_LEASE) 64 | logger: Logger = field(default_factory=lambda: getLogger('pydhcp')) 65 | 66 | static: Dict[str, IPRecord] = field(init=False, default_factory=dict) 67 | records: Dict[str, Record] = field(init=False, default_factory=dict) 68 | lock: Lock = field(init=False, default_factory=Lock) 69 | 70 | addresses: Iterator[IPv4Address] = field(init=False) 71 | reclaimed: List[IPv4Interface] = field(init=False, default_factory=list) 72 | 73 | def __post_init__(self): 74 | self.addresses = self.network.hosts() 75 | 76 | def set_static(self, mac: str, ipaddr: IPv4Address, **settings): 77 | """ 78 | assign static address within configured dhcp network 79 | 80 | :param mac: mac-address assigned to dhcp 81 | :param ipaddr: ip-address to staticly assign to mac 82 | :param settings: additional settings for static assignment 83 | """ 84 | if ipaddr not in self.network: 85 | raise ValueError(f'{ipaddr} not within {self.network}') 86 | mac = clean_mac(mac) 87 | ipv4 = IPv4Interface(f'{ipaddr}/{self.network.netmask}') 88 | self.static[mac] = IPRecord(ipv4, **settings) 89 | 90 | def _reclaim_all(self): 91 | """ 92 | reclaim addresses from expired dhcp leases 93 | """ 94 | now = datetime.now() 95 | clean = [mac for mac, r in self.records.items() if r.expires <= now] 96 | for mac in clean: 97 | record = self.records.pop(mac) 98 | self.reclaimed.append(record.record.ipv4) 99 | self.reclaimed.sort() 100 | 101 | def _reclaim_address(self, mac: str): 102 | """ 103 | reclaim single address for database address-pool 104 | 105 | :param mac: mac-address 106 | """ 107 | record = self.records.pop(mac, None) 108 | if record is not None: 109 | self.reclaimed.append(record.record.ipv4) 110 | 111 | def _next_ip(self, 112 | mac: str, ipv4: Optional[IPv4Address]) -> Optional[IPv4Interface]: 113 | """ 114 | retrieve ip-assignment based on dhcp request and database 115 | 116 | :param request: dhcp request message 117 | :return: ip-address assignment (if address available) 118 | """ 119 | # check if client has existing assignment 120 | now = datetime.now() 121 | record = self.records.get(mac) 122 | if record is not None and record.expires >= now: 123 | # extend existing lease and return (if exists) 124 | lease = record.record.lease or self.default_lease 125 | record.expires = now + lease 126 | return record.record.ipv4 127 | # check if requested-ip is available 128 | if ipv4 is not None: 129 | addr = IPv4Interface(f'{ipv4}/{self.network.netmask}') 130 | if addr in self.reclaimed: 131 | self.reclaimed.remove(addr) 132 | return addr 133 | # retrieve first available in reclaimed or next in hostlist 134 | if self.reclaimed: 135 | return self.reclaimed.pop(0) 136 | # retrieve next ip in host-list (skipping reserved ips) 137 | try: 138 | ipaddr = None 139 | reserved = {r.ipv4.ip for r in self.static.values()} 140 | reserved |= set([self.gateway, *self.dns]) 141 | while ipaddr is None or ipaddr in reserved: 142 | ipaddr = next(self.addresses) 143 | return IPv4Interface(f'{ipaddr}/{self.network.netmask}') 144 | except StopIteration: 145 | return 146 | 147 | def request_address(self, 148 | mac: str, ipv4: Optional[IPv4Address]) -> Optional[SimpleAnswer]: 149 | with self.lock: 150 | self._reclaim_all() 151 | # retrieve assignment from static or retirve available ip-address 152 | record = self.static.get(mac) 153 | if record is None: 154 | address = self._next_ip(mac, ipv4) 155 | record = IPRecord(address) if address else record 156 | if record is None: 157 | return 158 | # assign record to database and return assignment 159 | lease = record.lease or self.default_lease 160 | self.records[mac] = Record(record, datetime.now() + lease) 161 | return SimpleAnswer( 162 | source=self.source, 163 | lease=lease, 164 | ipv4=record.ipv4, 165 | routers=[record.gateway or self.gateway], 166 | dns=record.dns or self.dns, 167 | dns_search=record.search or self.dns_search, 168 | ) 169 | 170 | def release_address(self, mac: str): 171 | with self.lock: 172 | self._reclaim_address(mac) 173 | self._reclaim_all() 174 | -------------------------------------------------------------------------------- /pydhcp/v4/tests/message.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCP Message Packet Parsing UnitTests 3 | """ 4 | from ipaddress import IPv4Address 5 | from unittest import TestCase 6 | 7 | from .. import * 8 | 9 | #** Variables **# 10 | __all__ = ['MessageTests'] 11 | 12 | DHCP_DISCOVER = '0101060000003d1d000000000000000000000000000000000000000000' +\ 13 | '0b8201fc42000000000000000000000000000000000000000000000000000000000000' +\ 14 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 15 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 16 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 17 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 18 | '0000000000000000000000000000000000000000000000000000000000000000638253' +\ 19 | '633501013d0701000b8201fc4232040000000037040103062aff00000000000000' 20 | 21 | DHCP_OFFER = '0201060000003d1d0000000000000000c0a8000ac0a8000100000000000b8' +\ 22 | '201fc42000000000000000000000000000000000000000000000000000000000000000' +\ 23 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 24 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 25 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 26 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 27 | '0000000000000000000000000000000000000000000000000000000000000638253633' +\ 28 | '501020104ffffff003a04000007083b0400000c4e330400000e103604c0a80001ff000' +\ 29 | '0000000000000000000000000000000000000000000000000' 30 | 31 | DHCP_REQUEST = '0101060000003d1e0000000000000000000000000000000000000000000' +\ 32 | 'b8201fc420000000000000000000000000000000000000000000000000000000000000' +\ 33 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 34 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 35 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 36 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 37 | '0000000000000000000000000000000000000000000000000000000000000006382536' +\ 38 | '33501033d0701000b8201fc423204c0a8000a3604c0a8000137040103062aff00' 39 | 40 | DHCP_ACK = '0201060000003d1e0000000000000000c0a8000a0000000000000000000b820' +\ 41 | '1fc4200000000000000000000000000000000000000000000000000000000000000000' +\ 42 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 43 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 44 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 45 | '0000000000000000000000000000000000000000000000000000000000000000000000' +\ 46 | '0000000000000000000000000000000000000000000000000000000000063825363350' +\ 47 | '1053a04000007083b0400000c4e330400000e103604c0a800010104ffffff00ff00000' +\ 48 | '00000000000000000000000000000000000000000000000' 49 | 50 | #** Classes **# 51 | 52 | class MessageTests(TestCase): 53 | """ 54 | DHCP Packet Parsing UnitTests 55 | """ 56 | 57 | def test_discover(self): 58 | """ 59 | ensure dhcp discover message parses properly 60 | """ 61 | data = bytes.fromhex(DHCP_DISCOVER) 62 | message = Message.unpack(data) 63 | self.assertEqual(message.op, OpCode.BootRequest) 64 | self.assertEqual(message.id, 15645) 65 | self.assertEqual(message.client_hw, bytes.fromhex('000b8201fc42')) 66 | self.assertEqual(len(message.options), 4) 67 | self.assertEqual(message.hops, 0) 68 | self.assertEqual(message.seconds, 0) 69 | self.assertEqual(message.flags, 0) 70 | self.assertEqual(message.client_addr, ZeroIp) 71 | self.assertEqual(message.your_addr, ZeroIp) 72 | self.assertEqual(message.server_addr, ZeroIp) 73 | self.assertEqual(message.gateway_addr, ZeroIp) 74 | self.assertEqual(message.server_name, b'') 75 | self.assertEqual(message.boot_file, b'') 76 | self.assertEqual(message.message_type(), MessageType.Discover) 77 | self.assertEqual(message.requested_address(), ZeroIp) 78 | 79 | def test_offer(self): 80 | """ 81 | ensure dhcp offer message parses properly 82 | """ 83 | data = bytes.fromhex(DHCP_OFFER) 84 | message = Message.unpack(data) 85 | self.assertEqual(message.op, OpCode.BootReply) 86 | self.assertEqual(message.id, 15645) 87 | self.assertEqual(message.client_hw, bytes.fromhex('000b8201fc42')) 88 | self.assertEqual(len(message.options), 6) 89 | self.assertEqual(message.hops, 0) 90 | self.assertEqual(message.seconds, 0) 91 | self.assertEqual(message.flags, 0) 92 | self.assertEqual(message.client_addr, ZeroIp) 93 | self.assertEqual(message.your_addr, IPv4Address('192.168.0.10')) 94 | self.assertEqual(message.server_addr, ZeroIp) 95 | self.assertEqual(message.gateway_addr, ZeroIp) 96 | self.assertEqual(message.server_name, b'') 97 | self.assertEqual(message.boot_file, b'') 98 | self.assertEqual(message.message_type(), MessageType.Offer) 99 | self.assertEqual(message.subnet_mask(), IPv4Address('255.255.255.0')) 100 | self.assertEqual(message.server_identifier(), IPv4Address('192.168.0.1')) 101 | 102 | def test_request(self): 103 | """ 104 | ensure dhcp request message parses properly 105 | """ 106 | data = bytes.fromhex(DHCP_REQUEST) 107 | message = Message.unpack(data) 108 | self.assertEqual(message.op, OpCode.BootRequest) 109 | self.assertEqual(message.id, 15646) 110 | self.assertEqual(message.client_hw, bytes.fromhex('000b8201fc42')) 111 | self.assertEqual(len(message.options), 5) 112 | self.assertEqual(message.hops, 0) 113 | self.assertEqual(message.seconds, 0) 114 | self.assertEqual(message.flags, 0) 115 | self.assertEqual(message.client_addr, ZeroIp) 116 | self.assertEqual(message.your_addr, ZeroIp) 117 | self.assertEqual(message.server_addr, ZeroIp) 118 | self.assertEqual(message.gateway_addr, ZeroIp) 119 | self.assertEqual(message.server_name, b'') 120 | self.assertEqual(message.boot_file, b'') 121 | self.assertEqual(message.message_type(), MessageType.Request) 122 | self.assertEqual(message.requested_address(), IPv4Address('192.168.0.10')) 123 | self.assertEqual(message.server_identifier(), IPv4Address('192.168.0.1')) 124 | 125 | def test_ack(self): 126 | """ 127 | ensure dhcp ack message parses properly 128 | """ 129 | data = bytes.fromhex(DHCP_ACK) 130 | message = Message.unpack(data) 131 | self.assertEqual(message.op, OpCode.BootReply) 132 | self.assertEqual(message.id, 15646) 133 | self.assertEqual(message.client_hw, bytes.fromhex('000b8201fc42')) 134 | self.assertEqual(len(message.options), 6) 135 | self.assertEqual(message.hops, 0) 136 | self.assertEqual(message.seconds, 0) 137 | self.assertEqual(message.flags, 0) 138 | self.assertEqual(message.client_addr, ZeroIp) 139 | self.assertEqual(message.your_addr, IPv4Address('192.168.0.10')) 140 | self.assertEqual(message.server_addr, ZeroIp) 141 | self.assertEqual(message.gateway_addr, ZeroIp) 142 | self.assertEqual(message.server_name, b'') 143 | self.assertEqual(message.boot_file, b'') 144 | self.assertEqual(message.message_type(), MessageType.Ack) 145 | self.assertEqual(message.subnet_mask(), IPv4Address('255.255.255.0')) 146 | self.assertEqual(message.server_identifier(), IPv4Address('192.168.0.1')) 147 | 148 | 149 | -------------------------------------------------------------------------------- /pydhcp/v4/server/backend/pxe.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCP PXE TFTP Assignment Backend 3 | """ 4 | from copy import copy 5 | from ipaddress import IPv4Address 6 | from logging import Logger, getLogger 7 | from typing import Any, ClassVar, Dict, Optional 8 | from typing_extensions import Annotated 9 | 10 | from pyderive import dataclass, field 11 | from pyderive.extensions.validate import BaseModel, PreValidator 12 | 13 | from . import Address, Answer, Backend 14 | from ... import * 15 | from .... import Arch 16 | 17 | #** Variables **# 18 | __all__ = ['PxeTftpConfig', 'PxeDynConfig', 'PxeConfig', 'PXEBackend'] 19 | 20 | #: set of pxe supported option-codes 21 | PXE_OPTIONS = { 22 | OptionCode.TFTPServerName, 23 | OptionCode.TFTPServerAddress, 24 | OptionCode.TFTPServerIPAddress, 25 | OptionCode.BootfileName, 26 | OptionCode.PXELinuxPathPrefix, 27 | } 28 | 29 | #** Functions **# 30 | 31 | def arch_validator(arch: Any) -> Arch: 32 | """ 33 | pyderive Arch validator function 34 | """ 35 | if isinstance(arch, int): 36 | return Arch(arch) 37 | if isinstance(arch, str): 38 | return Arch[arch] 39 | if isinstance(arch, Arch): 40 | return arch 41 | raise ValueError('Invalid Arch: {arch!r}') 42 | 43 | #** Classes **# 44 | 45 | ArchT = Annotated[Arch, PreValidator[arch_validator]] 46 | 47 | class PxeTftpConfig(BaseModel, typecast=True): 48 | """ 49 | Dynamic PXE TFTP Configuration Override Settings 50 | """ 51 | filename: bytes 52 | """DHCP BootFile (67) PXE Assignment""" 53 | hostname: Optional[bytes] = None 54 | """DHCP TFTP Server Name (66) PXE Assignment""" 55 | ipaddr: Optional[IPv4Address] = None 56 | """DHCP TFTP Server IP (128) PXE Assignment""" 57 | 58 | class PxeDynConfig(BaseModel): 59 | """ 60 | Dynamic PXE Configuration Settings based on DHCP Request Data 61 | """ 62 | arches: Dict[ArchT, PxeTftpConfig] = field(default_factory=dict) 63 | """Dynamic TFTP Configuration Assignment based on DHCP Client Arch (93)""" 64 | vendors: Dict[str, str] = field(default_factory=dict) 65 | """Mapping of DHCP Vendor Class (60) to Configuration Names""" 66 | configs: Dict[str, PxeTftpConfig] = field(default_factory=dict) 67 | """Dynamic TFTP Configuration Assignment based on Configuration Names""" 68 | 69 | class PxeConfig(BaseModel, typecast=True): 70 | """ 71 | DHCP PXE Configuration Settings 72 | """ 73 | ipaddr: IPv4Address 74 | """DHCP TFTP Server IP (128) PXE Assignment""" 75 | primary: bool = False 76 | """Act as Primary DHCP Server and Assign DHCP `file`/`sname` fields""" 77 | prefix: Optional[bytes] = None 78 | """DHCP PXE Path Prefix (210) PXE Assignment""" 79 | hostname: Optional[bytes] = None 80 | """DHCP TFTP Server Name (66) PXE Assignment""" 81 | filename: Optional[bytes] = None 82 | """DHCP BootFile (67) PXE Assignment""" 83 | dynamic: PxeDynConfig = field(default_factory=PxeDynConfig) 84 | """Dynamic PXE Configuration Overrides based on DHCP Request Data""" 85 | 86 | @dataclass(slots=True) 87 | class PXEBackend(Backend): 88 | """ 89 | DHCP PXE Assignment Backend based on Static Configuration 90 | """ 91 | source: ClassVar[str] = 'PXE' 92 | 93 | config: PxeConfig 94 | backend: Optional[Backend] = None 95 | logger: Logger = field(default_factory=lambda: getLogger('pydhcp')) 96 | 97 | def get_pxe_config(self, hwaddr: str, request: Message) -> PxeConfig: 98 | """ 99 | Generate PXE Configuration based on DHCP Request Client Information 100 | 101 | :param hwaddr: hardware-address (MAC) 102 | :param request: dhcp request message 103 | :return: pxe configuration settings 104 | """ 105 | # retrieve client information relevant to dynamic assignment 106 | subcfg = None 107 | arches = request.options.get(ClientSystemArch) 108 | vendor = request.options.get(VendorClassIdentifier) 109 | # attempt to retrieve dynamic sub-config based on arch 110 | if arches and self.config.dynamic.arches: 111 | self.logger.debug(f'{hwaddr} arches={arches!r}') 112 | for arch in arches.arches: 113 | subcfg = self.config.dynamic.arches.get(arch) 114 | if subcfg is not None: 115 | break 116 | # attempt to retrieve dynamic sub-config based on vendor 117 | if not subcfg and vendor and self.config.dynamic: 118 | vendor = vendor.vendor.decode() 119 | self.logger.debug(f'{hwaddr} vendor={vendor!r}') 120 | for vendor_id, match in self.config.dynamic.vendors.items(): 121 | if match in vendor: 122 | subcfg = self.config.dynamic.configs.get(vendor_id) 123 | self.logger.debug( 124 | f'{hwaddr} vendor match {vendor_id} {match}') 125 | if subcfg: 126 | break 127 | # override primary config with subconfig (if exists) 128 | config = self.config 129 | if subcfg: 130 | config = copy(config) 131 | config.ipaddr = subcfg.ipaddr or config.ipaddr 132 | config.hostname = subcfg.hostname or config.hostname 133 | config.filename = subcfg.filename or config.filename 134 | return config 135 | 136 | def pxe(self, 137 | address: Address, 138 | request: Message, 139 | response: Optional[Message] = None, 140 | ) -> Optional[Message]: 141 | """ 142 | DHCP Response and PXE Option Assignment 143 | 144 | :param address: client address 145 | :param request: dhcp request message 146 | :param response: existing dhcp response (if set) 147 | :return: dhcp response (if applicable) 148 | """ 149 | options = request.requested_options() 150 | if not any(op in PXE_OPTIONS for op in options): 151 | return response 152 | # retrieve client information relevant to dynamic assignment 153 | hwaddr = request.client_hw.hex() 154 | config = self.get_pxe_config(hwaddr, request) 155 | # build DHCP options based on configuration 156 | message = [f'{address[0]}:{address[1]} | {hwaddr}'] 157 | response = response or request.reply() 158 | response.server_addr = config.ipaddr 159 | response.options.append(TFTPServerIP(config.ipaddr.packed)) 160 | message.append(f'-> pxe={str(config.ipaddr)}') 161 | if config.primary: 162 | response.boot_file = config.filename or response.boot_file 163 | response.server_name = config.hostname or response.server_name 164 | if config.prefix: 165 | message.append(f'root={config.prefix.decode()!r}') 166 | response.options.append(PXEPathPrefix(config.prefix)) 167 | if config.hostname: 168 | message.append(f'host={config.hostname.decode()!r}') 169 | response.options.append(TFTPServerName(config.hostname)) 170 | if config.filename: 171 | message.append(f'file={config.filename.decode()!r}') 172 | response.options.append(BootfileName(config.filename + b'\x00')) 173 | self.logger.info(' '.join(message)) 174 | return response 175 | 176 | def discover(self, address: Address, request: Message) -> Optional[Answer]: 177 | answer = self.backend.discover(address, request) \ 178 | if self.backend else None 179 | res, src = answer if answer else (None, self.source) 180 | response = self.pxe(address, request, res) 181 | return Answer(response, src) if response else None 182 | 183 | def request(self, address: Address, request: Message) -> Optional[Answer]: 184 | answer = self.backend.request(address, request) \ 185 | if self.backend else None 186 | res, src = answer if answer else (None, self.source) 187 | response = self.pxe(address, request, res) 188 | return Answer(response, src) if response else None 189 | 190 | def release(self, address: Address, request: Message) -> Optional[Answer]: 191 | if self.backend is not None: 192 | return self.backend.release(address, request) 193 | 194 | def decline(self, address: Address, request: Message) -> Optional[Answer]: 195 | if self.backend is not None: 196 | return self.backend.decline(address, request) 197 | -------------------------------------------------------------------------------- /pydhcp/v4/enum.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCPv4 Enums 3 | """ 4 | from enum import IntEnum 5 | 6 | #** Variables **# 7 | __all__ = ['OpCode', 'MessageType', 'OptionCode'] 8 | 9 | #** Classes **# 10 | 11 | class OpCode(IntEnum): 12 | """ 13 | Message Operation-Code (REQUEST/REPLY) 14 | """ 15 | BootRequest = 1 16 | BootReply = 2 17 | 18 | class MessageType(IntEnum): 19 | """ 20 | DHCP Message Types - DISCOVER, OFFER, etc 21 | """ 22 | Discover = 1 23 | Offer = 2 24 | Request = 3 25 | Decline = 4 26 | Ack = 5 27 | Nak = 6 28 | Release = 7 29 | Inform = 8 30 | 31 | class OptionCode(IntEnum): 32 | """ 33 | DHCP Request Paramter OptionCodes 34 | """ 35 | OptionPad = 0 36 | SubnetMask = 1 37 | TimeOffset = 2 38 | Router = 3 39 | TimeServer = 4 40 | NameServer = 5 41 | DomainNameServer = 6 42 | LogServer = 7 43 | QuoteServer = 8 44 | LPRServer = 9 45 | ImpressServer = 10 46 | ResourceLocationServer = 11 47 | HostName = 12 48 | BootFileSize = 13 49 | MeritDumpFile = 14 50 | DomainName = 15 51 | SwapServer = 16 52 | RootPath = 17 53 | ExtensionsPath = 18 54 | IPForwarding = 19 55 | NonLocalSourceRouting = 20 56 | PolicyFilter = 21 57 | MaximumDatagramAssemblySize = 22 58 | DefaultIPTTL = 23 59 | PathMTUAgingTimeout = 24 60 | PathMTUPlateauTable = 25 61 | InterfaceMTU = 26 62 | AllSubnetsAreLocal = 27 63 | BroadcastAddress = 28 64 | PerformMaskDiscovery = 29 65 | MaskSupplier = 30 66 | PerformRouterDiscovery = 31 67 | RouterSolicitationAddress = 32 68 | StaticRoutingTable = 33 69 | TrailerEncapsulation = 34 70 | ArpCacheTimeout = 35 71 | EthernetEncapsulation = 36 72 | DefaulTCPTTL = 37 73 | TCPKeepaliveInterval = 38 74 | TCPKeepaliveGarbage = 39 75 | NetworkInformationServiceDomain = 40 76 | NetworkInformationServers = 41 77 | NTPServers = 42 78 | VendorSpecificInformation = 43 79 | NetBIOSOverTCPIPNameServer = 44 80 | NetBIOSOverTCPIPDatagramDistributionServer = 45 81 | NetBIOSOverTCPIPNodeType = 46 82 | NetBIOSOverTCPIPScope = 47 83 | XWindowSystemFontServer = 48 84 | XWindowSystemDisplayManger = 49 85 | RequestedIPAddress = 50 86 | IPAddressLeaseTime = 51 87 | OptionOverload = 52 88 | DHCPMessageType = 53 89 | ServerIdentifier = 54 90 | ParameterRequestList = 55 91 | Message = 56 92 | MaximumDHCPMessageSize = 57 93 | RenewTimeValue = 58 94 | RebindingTimeValue = 59 95 | ClassIdentifier = 60 96 | ClientIdentifier = 61 97 | NetWareIPDomainName = 62 98 | NetWareIPInformation = 63 99 | NetworkInformationServicePlusDomain = 64 100 | NetworkInformationServicePlusServers = 65 101 | TFTPServerName = 66 102 | BootfileName = 67 103 | MobileIPHomeAgent = 68 104 | SimpleMailTransportProtocolServer = 69 105 | PostOfficeProtocolServer = 70 106 | NetworkNewsTransportProtocolServer = 71 107 | DefaultWorldWideWebServer = 72 108 | DefaultFingerServer = 73 109 | DefaultInternetRelayChatServer = 74 110 | StreetTalkServer = 75 111 | StreetTalkDirectoryAssistanceServer = 76 112 | UserClassInformation = 77 113 | SLPDirectoryAgent = 78 114 | SLPServiceScope = 79 115 | RapidCommit = 80 116 | FQDN = 81 117 | RelayAgentInformation = 82 118 | InternetStorageNameService = 83 119 | # Option 84 returned in RFC 3679 120 | NDSServers = 85 121 | NDSTreeName = 86 122 | NDSContext = 87 123 | BCMCSControllerDomainNameList = 88 124 | BCMCSControllerIPv4AddressList = 89 125 | Authentication = 90 126 | ClientLastTransactionTime = 91 127 | AssociatedIP = 92 128 | ClientSystemArchitectureType = 93 129 | ClientNetworkInterfaceIdentifier = 94 130 | LDAP = 95 131 | # Option 96 returned in RFC 3679 132 | ClientMachineIdentifier = 97 133 | OpenGroupUserAuthentication = 98 134 | GeoConfCivic = 99 135 | IEEE10031TZString = 100 136 | ReferenceToTZDatabase = 101 137 | # Options 102-111 returned in RFC 3679 138 | Ipv6OnlyPreferred = 108 # [RFC8925] 139 | OPTION_DHCP4O6_S46_SADDR = 109 # [RFC8539] 140 | NetInfoParentServerAddress = 112 141 | NetInfoParentServerTag = 113 142 | URL = 114 143 | # Option 115 returned in RFC 3679 144 | AutoConfigure = 116 145 | NameServiceSearch = 117 146 | SubnetSelection = 118 147 | DNSDomainSearchList = 119 148 | SIPServers = 120 149 | ClasslessStaticRoute = 121 150 | CCC = 122 151 | GeoConf = 123 152 | VendorIdentifyingVendorClass = 124 153 | VendorIdentifyingVendorSpecific = 125 154 | # Options 126-127 returned in RFC 3679 155 | TFTPServerIPAddress = 128 156 | CallServerIPAddress = 129 157 | DiscriminationString = 130 158 | RemoteStatisticsServerIPAddress = 131 159 | O_8021PVLANID = 132 160 | O_8021QL2Priority = 133 161 | DiffservCodePoint = 134 162 | HTTPProxyForPhoneSpecificApplications = 135 163 | PANAAuthenticationAgent = 136 164 | LoSTServer = 137 165 | CAPWAPAccessControllerAddresses = 138 166 | OPTIONIPv4AddressMoS = 139 167 | OPTIONIPv4FQDNMoS = 140 168 | SIPUAConfigurationServiceDomains = 141 169 | OPTIONIPv4AddressANDSF = 142 170 | OPTIONIPv6AddressANDSF = 143 171 | # Options 144-149 returned in RFC 3679 172 | GeoLoc = 144 #[RFC6225] 173 | FORCERENEW_NONCE_CAPABLE = 145 #[RFC6704] 174 | RDNSSSelection = 146 #[RFC6731] 175 | OPTION_V4_DOTS_RI = 147 #[RFC8973] 176 | OPTION_V4_DOTS_ADDRESS = 148 #[RFC8973] 177 | TFTPServerAddress = 150 178 | StatusCode = 151 179 | BaseTime = 152 180 | StartTimeOfState = 153 181 | QueryStartTime = 154 182 | QueryEndTime = 155 183 | DHCPState = 156 184 | DataSource = 157 185 | # Options 158-174 returned in RFC 3679 186 | OPTION_V4_PCP_SERVER = 158 #[RFC7291] 187 | OPTION_V4_PORTPARAMS = 159 #[RFC7618] 188 | OPTION_MUD_URL_V4 = 161 #[RFC8520] 189 | OPTION_V4_DNR = 162 #[RFC-ietf-add-dnr-13] 190 | CiscoLastTransactionTime = 163 #[Cisco DHCP Options] 191 | Etherboot = 175 192 | IPTelephone = 176 193 | EtherbootPacketCableAndCableHome = 177 194 | # Options 178-207 returned in RFC 3679 195 | HPDMServer = 202 #[HP Device-Manager 4.7] 196 | HPDMGateway = 203 #[HP Device-Manager 4.7] 197 | PXELinuxMagicString = 208 198 | PXELinuxConfigFile = 209 199 | PXELinuxPathPrefix = 210 200 | PXELinuxRebootTime = 211 201 | OPTION6RD = 212 202 | OPTIONv4AccessDomain = 213 203 | # Options 214-219 returned in RFC 3679 204 | SubnetAllocation = 220 205 | VirtualSubnetAllocation = 221 206 | # Options 222-223 returned in RFC 3679 207 | # Options 224-254 are reserved for private use 208 | FortinetManagerIP = 240 #[FortiNet FortiManager] 209 | FortinetManagerDomain = 241 #[FortiNet FortiManager] 210 | MSClasslessStaticRoute = 249 211 | MSEncodingLongOption = 250 212 | CiscoAutoConfigure = 251 #[Cisco DHCP Options] 213 | ProxyAutoDiscover = 252 #[RFC-draft-ietf-wrec-wpad-01] 214 | End = 255 215 | -------------------------------------------------------------------------------- /pydhcp/v4/server/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple and Extensible DHCP Server Implementation 3 | """ 4 | from ipaddress import IPv4Address 5 | from logging import Logger, getLogger 6 | from typing import Optional, cast 7 | 8 | from pyderive import dataclass, field 9 | from pyserve import Address, Session as BaseSession, UdpWriter, Writer 10 | 11 | from .. import Message, MessageType, ZeroIp 12 | from .. import DhcpError, NotAllowed, UnknownQueryType 13 | from .. import DHCPMessageType, DHCPStatusCode, ServerIdentifier 14 | from ... import StatusCode 15 | 16 | from .backend import Backend 17 | 18 | #** Variables **# 19 | __all__ = ['Server'] 20 | 21 | #: dhcp response port 22 | PORT = 68 23 | 24 | #: broadcast request for responding to messages 25 | BROADCAST = IPv4Address('255.255.255.255') 26 | 27 | #** Function **# 28 | 29 | def mtype(response: Message) -> str: 30 | """ 31 | retrieve message type string from message object 32 | 33 | :param response: response message object 34 | :return: response message type as string 35 | """ 36 | mtype = response.message_type() 37 | return mtype.name.upper() if mtype else 'UNKNOWN' 38 | 39 | def assign_zero(original: IPv4Address, new: IPv4Address) -> IPv4Address: 40 | """ 41 | re-assign ip-address if original is zerod ip-address 42 | 43 | :param original: original ip-address 44 | :param new: new ip-address to assign 45 | :return: non-zeroed ip-address 46 | """ 47 | return new if original == ZeroIp else original 48 | 49 | #** Classes **# 50 | 51 | @dataclass 52 | class Server(BaseSession): 53 | """ 54 | Extendable Implementation of DHCP Server Session Manager for PyServe 55 | """ 56 | backend: Backend 57 | server_id: IPv4Address 58 | broadcast: IPv4Address = field(default_factory=lambda: BROADCAST) 59 | logger: Logger = field(default_factory=lambda: getLogger('pydhcp')) 60 | 61 | def __post_init__(self): 62 | setattr(self.backend, 'logger', self.logger) 63 | 64 | ### DHCP Handlers 65 | 66 | def process_discover(self, request: Message) -> Optional[Message]: 67 | """ 68 | Process DHCP DISCOVER Message 69 | """ 70 | self.logger.debug(f'{self.addr_str} < DISCOVER' 71 | f' mac={request.client_hw.hex()} ip={request.requested_address()}') 72 | answer = self.backend.discover(self.client, request) 73 | if answer is None: 74 | return 75 | response = answer.message 76 | response.server_addr = assign_zero(response.server_addr, self.server_id) 77 | response.options.insert(0, DHCPMessageType(MessageType.Offer)) 78 | response.options.insert(1, ServerIdentifier(self.server_id)) 79 | self.logger.debug(self.addr_str + 80 | f' > {mtype(response)} mac={request.client_hw.hex()}') 81 | return response 82 | 83 | def process_request(self, request: Message) -> Optional[Message]: 84 | """ 85 | Process DHCP REQUEST Message 86 | """ 87 | self.logger.debug(f'{self.addr_str} < REQUEST' 88 | f' mac={request.client_hw.hex()} ip={request.requested_address()}') 89 | answer = self.backend.request(self.client, request) 90 | if answer is None: 91 | return 92 | # ensure required response components are present 93 | response = answer.message 94 | response.server_addr = assign_zero(response.server_addr, self.server_id) 95 | response.options.setdefault(DHCPMessageType(MessageType.Ack), 0) 96 | response.options.setdefault(ServerIdentifier(self.server_id), 1) 97 | # ensure assignment matches request 98 | netmask = request.subnet_mask() 99 | req_addr = request.requested_address() 100 | req_cast = request.broadcast_address() 101 | if (req_addr and req_addr != response.your_addr) \ 102 | or (req_cast and req_cast != netmask): 103 | response.options.insert(0, DHCPMessageType(MessageType.Nak)) 104 | self.logger.debug(self.addr_str + 105 | f' > {mtype(response)} mac={request.client_hw.hex()}') 106 | return response 107 | 108 | def process_decline(self, request: Message) -> Optional[Message]: 109 | """ 110 | Process DHCP DECLINE Message 111 | """ 112 | self.logger.debug(f'{self.addr_str} < DECLINE' 113 | f' mac={request.client_hw.hex()} ip={request.requested_address()}') 114 | answer = self.backend.decline(self.client, request) 115 | response = answer.message if answer else request.reply() 116 | response.server_addr = assign_zero(response.server_addr, self.server_id) 117 | response.options.setdefault(DHCPMessageType(MessageType.Nak), 0) 118 | response.options.setdefault(ServerIdentifier(self.server_id), 1) 119 | self.logger.debug(self.addr_str + 120 | f' > {mtype(response)} mac={request.client_hw.hex()}') 121 | return response 122 | 123 | def process_release(self, request: Message) -> Optional[Message]: 124 | """ 125 | Process DHCP RELEASE Message 126 | """ 127 | self.logger.debug(f'{self.addr_str} < RELEASE' 128 | f' mac={request.client_hw.hex()} ip={request.requested_address()}') 129 | answer = self.backend.release(self.client, request) 130 | response = answer.message if answer else request.reply() 131 | response.server_addr = assign_zero(response.server_addr, self.server_id) 132 | response.options.setdefault(DHCPMessageType(MessageType.Ack), 0) 133 | response.options.setdefault(ServerIdentifier(self.server_id), 1) 134 | self.logger.debug(self.addr_str + 135 | f' > {mtype(response)} mac={request.client_hw.hex()}') 136 | return response 137 | 138 | def process_inform(self, request: Message) -> Optional[Message]: 139 | """ 140 | Process DHCP INFORM Message 141 | """ 142 | raise NotAllowed('Inform Not Allowed') 143 | 144 | def process_unknown(self, request: Message) -> Optional[Message]: 145 | """ 146 | Process Unknown/Invalid DHCP Messages 147 | """ 148 | raise UnknownQueryType(f'Unknown Message: {request.message_type()!r}') 149 | 150 | ### Standard Handlers 151 | 152 | def connection_made(self, addr: Address, writer: Writer): 153 | """ 154 | handle session initialization on connection-made 155 | """ 156 | self.client: Address = addr 157 | self.writer: UdpWriter = cast(UdpWriter, writer) 158 | self.addr_str: str = '%s:%d' % self.client 159 | self.logger.debug(f'{self.addr_str} | connection-made') 160 | 161 | def _send(self, request: Message, response: Optional[Message]): 162 | """ 163 | broadcast dhcp response packet to the relevant ips 164 | """ 165 | if not response: 166 | self.logger.error(f'{self.addr_str} | no response given.') 167 | self.writer.close() 168 | return 169 | # NOTE: BOOTP official RFC says packets must be a minimum size 170 | # of 300 octets to be considered valid and not potetially dropped. 171 | # so historically we've padded the packed response with zero bytes 172 | # to match the minimum, however that seemingly breaks DHCP for 173 | # some networks. instead we switch to a warning log 174 | data = response.pack() # .rjust(300, b'\x00') 175 | if len(data) < 300: 176 | self.logger.warning( 177 | f'{self.addr_str} | response less than 300 octets {len(data)}') 178 | host = assign_zero(request.client_addr, request.gateway_addr) 179 | host = assign_zero(host, IPv4Address(self.client.host)) 180 | host = assign_zero(host, self.broadcast) 181 | host = str(host) 182 | self.logger.debug( 183 | f'{self.addr_str} > sent {len(data)} bytes to {host}:{PORT}') 184 | self.writer.write(data, addr=(host, PORT)) 185 | 186 | def data_recieved(self, data: bytes): 187 | """ 188 | parse raw packet-data and process request 189 | """ 190 | self.logger.debug(f'{self.addr_str} < recieved {len(data)} bytes') 191 | request = Message.unpack(data) 192 | message_type = request.message_type() 193 | if message_type is None: 194 | return 195 | response: Optional[Message] = None 196 | try: 197 | if message_type == MessageType.Discover: 198 | response = self.process_discover(request) 199 | elif message_type == MessageType.Request: 200 | response = self.process_request(request) 201 | elif message_type == MessageType.Decline: 202 | response = self.process_decline(request) 203 | elif message_type == MessageType.Release: 204 | response = self.process_release(request) 205 | elif message_type == MessageType.Inform: 206 | response = self.process_inform(request) 207 | else: 208 | response = self.process_unknown(request) 209 | except DhcpError as e: 210 | response = response or request.reply() 211 | response.options.setdefault(DHCPMessageType(MessageType.Nak), 0) 212 | response.options.setdefault(DHCPStatusCode(e.code, str(e).encode())) 213 | except Exception as e: 214 | code = StatusCode.UnspecFail 215 | response = response or request.reply() 216 | response.options.setdefault(DHCPMessageType(MessageType.Nak)) 217 | response.options.setdefault(DHCPStatusCode(code, str(e).encode())) 218 | raise e 219 | finally: 220 | self._send(request, response) 221 | 222 | def connection_lost(self, err: Optional[Exception]): 223 | """ 224 | debug log connection lost 225 | """ 226 | self.logger.debug(f'{self.addr_str} | connection-lost err={err}') 227 | -------------------------------------------------------------------------------- /pydhcp/v4/message.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCPv4 Message Object Implementation 3 | """ 4 | from ipaddress import IPv4Address 5 | from typing import List, Optional, Sequence, Union, cast 6 | from typing_extensions import Annotated, Self 7 | 8 | from pyderive import dataclass 9 | from pystructs import U16, U32, U8, Const, Context, IPv4, StaticBytes, Struct 10 | 11 | from ..abc import OptionList 12 | from ..enum import HwType 13 | from .enum import MessageType, OpCode, OptionCode 14 | from .options import * 15 | 16 | #** Variables **# 17 | __all__ = ['ZeroIp', 'Message'] 18 | 19 | #: pre-converted default ip-address for dhcpv4 packet 20 | ZeroIp = IPv4Address('0.0.0.0') 21 | 22 | #: magic cookie to include in DHCP message 23 | MAGIC_COOKIE = bytes((0x63, 0x82, 0x53, 0x63)) 24 | 25 | OptionListv4 = OptionList[Option] 26 | OptionParam = Union[OptionListv4, Sequence[Option], None] 27 | 28 | #** Classes **# 29 | 30 | class HexBytes(bytes): 31 | def __repr__(self) -> str: 32 | return f'0x{self.hex()}' 33 | 34 | class MessageHeader(Struct): 35 | opcode: Annotated[OpCode, U8] 36 | hw_type: Annotated[HwType, U8] 37 | hw_length: U8 38 | hops: U8 39 | message_id: U32 40 | seconds: U16 41 | flags: U16 42 | client_addr: IPv4 43 | your_addr: IPv4 44 | server_addr: IPv4 45 | gateway_addr: IPv4 46 | hw_addr: Annotated[HexBytes, StaticBytes(16)] 47 | server_name: Annotated[bytes, StaticBytes(64)] 48 | boot_file: Annotated[bytes, StaticBytes(128)] 49 | magic_cookie: Annotated[bytes, Const(MAGIC_COOKIE)] = MAGIC_COOKIE 50 | 51 | @dataclass(slots=True) 52 | class Message: 53 | """ 54 | DHCP Message Object Definition (Request & Response Packet) 55 | """ 56 | op: OpCode 57 | id: int 58 | client_hw: bytes 59 | options: OptionListv4 60 | hw_type: HwType = HwType.Ethernet 61 | hops: int = 0 62 | seconds: int = 0 63 | flags: int = 0 64 | client_addr: IPv4Address = ZeroIp 65 | your_addr: IPv4Address = ZeroIp 66 | server_addr: IPv4Address = ZeroIp 67 | gateway_addr: IPv4Address = ZeroIp 68 | server_name: bytes = b'' 69 | boot_file: bytes = b'' 70 | 71 | def message_type(self) -> Optional[MessageType]: 72 | """ 73 | retrieve `MessageType` option value from options (if present) 74 | 75 | :return: assigned packet message-type 76 | """ 77 | option = self.options.get(DHCPMessageType) 78 | return option.mtype if option else None 79 | 80 | def requested_options(self) -> List[OptionCode]: 81 | """ 82 | retrieve `ParamRequestList` option value from options (if present) 83 | 84 | :return: list of requested option-codes 85 | """ 86 | option = self.options.get(ParamRequestList) 87 | return option.params if option else [] 88 | 89 | def requested_address(self) -> Optional[IPv4Address]: 90 | """ 91 | retrieve `RequestedIPAddr` option value from options (if present) 92 | 93 | :return: client requested ipv4 address 94 | """ 95 | option = self.options.get(RequestedIPAddr) 96 | return option.ip if option else None 97 | 98 | def subnet_mask(self) -> Optional[IPv4Address]: 99 | """ 100 | retrieve `SubnetMask` option value from options (if present) 101 | """ 102 | option = self.options.get(SubnetMask) 103 | return option.mask if option else None 104 | 105 | def broadcast_address(self) -> Optional[IPv4Address]: 106 | """ 107 | retrieve `BroadcastAddr` option value from options (if present) 108 | """ 109 | option = self.options.get(BroadcastAddr) 110 | return option.addr if option else None 111 | 112 | def server_identifier(self) -> Optional[IPv4Address]: 113 | """ 114 | retrieve `ServerIdentifier` option value from options (if present) 115 | """ 116 | option = self.options.get(ServerIdentifier) 117 | return option.ip if option else None 118 | 119 | @classmethod 120 | def discover(cls, 121 | id: int, 122 | hwaddr: bytes, 123 | ipaddr: Optional[IPv4Address] = None, 124 | options: OptionParam = None, 125 | **kwargs, 126 | ) -> 'Message': 127 | """ 128 | DHCP DISCOVER Message Constructor 129 | 130 | :param id: transaction-id 131 | :param hwaddr: client hardware address 132 | :param ipaddr: requested dhcp ip-address 133 | :param options: additional message options 134 | :param kwargs: additional message kwargs 135 | :return: new dhcp discover message 136 | """ 137 | ops: Sequence[Option] = options or [] 138 | message = Message( 139 | op=OpCode.BootRequest, 140 | id=id, 141 | client_hw=hwaddr, 142 | options=OptionList([ 143 | DHCPMessageType(MessageType.Discover), 144 | ParamRequestList([ 145 | OptionCode.SubnetMask, 146 | OptionCode.BroadcastAddress, 147 | OptionCode.TimeOffset, 148 | OptionCode.Router, 149 | OptionCode.DomainName, 150 | OptionCode.DomainNameServer, 151 | OptionCode.HostName, 152 | ]), 153 | *ops 154 | ]), 155 | **kwargs 156 | ) 157 | if ipaddr is not None: 158 | message.options.insert(1, RequestedIPAddr(ipaddr)) 159 | return message 160 | 161 | @classmethod 162 | def request(cls, 163 | id: int, 164 | hwaddr: bytes, 165 | ipaddr: IPv4Address, 166 | options: OptionParam = None, 167 | **kwargs, 168 | ) -> 'Message': 169 | """ 170 | DHCP REQUEST Message Constructor 171 | 172 | :param id: transaction-id 173 | :param hwaddr: client hardware address 174 | :param server: dhcp server address for request 175 | :param ipaddr: requested dhcp ip-address 176 | :param options: additional message options 177 | :param kwargs: additional message kwargs 178 | :return: new dhcp request message 179 | """ 180 | ops: Sequence[Option] = options or [] 181 | return Message( 182 | op=OpCode.BootRequest, 183 | id=id, 184 | client_hw=hwaddr, 185 | options=OptionList([ 186 | DHCPMessageType(MessageType.Request), 187 | RequestedIPAddr(ipaddr), 188 | ParamRequestList([ 189 | OptionCode.SubnetMask, 190 | OptionCode.BroadcastAddress, 191 | OptionCode.TimeOffset, 192 | OptionCode.Router, 193 | OptionCode.DomainName, 194 | OptionCode.DomainNameServer, 195 | OptionCode.HostName, 196 | ]), 197 | *ops, 198 | ]), 199 | **kwargs, 200 | ) 201 | 202 | def reply(self, options: OptionParam = None, **kwargs) -> 'Message': 203 | """ 204 | generate template `BootReply` Message for current Message request 205 | 206 | :param options: options to pass into generated message 207 | :param kwargs: additional settings for message generation 208 | :return: new generated message reply object 209 | """ 210 | ops: OptionListv4 = OptionList(options or []) 211 | return Message( 212 | op=OpCode.BootReply, 213 | id=self.id, 214 | client_hw=self.client_hw, 215 | hw_type=self.hw_type, 216 | options=ops, 217 | **kwargs 218 | ) 219 | 220 | def pack(self, ctx: Optional[Context] = None) -> bytes: 221 | """ 222 | pack message object into serialized bytes 223 | 224 | :param ctx: serialization context object 225 | :return: serialized bytes 226 | """ 227 | ctx = ctx or Context() 228 | data = bytearray() 229 | data += MessageHeader( 230 | opcode=self.op, 231 | hw_type=self.hw_type, 232 | hw_length=len(self.client_hw), 233 | hops=self.hops, 234 | message_id=self.id, 235 | seconds=self.seconds, 236 | flags=self.flags, 237 | client_addr=self.client_addr, 238 | your_addr=self.your_addr, 239 | server_addr=self.server_addr, 240 | gateway_addr=self.gateway_addr, 241 | hw_addr=cast(HexBytes, self.client_hw), 242 | server_name=self.server_name, 243 | boot_file=self.boot_file, 244 | ).pack(ctx) 245 | data += b''.join(pack_option(op, ctx) for op in self.options) 246 | if not any(op.opcode == OptionCode.End for op in self.options): 247 | data += bytes((OptionCode.End, )) 248 | return bytes(data) 249 | 250 | @classmethod 251 | def unpack(cls, raw: bytes, ctx: Optional[Context] = None) -> Self: 252 | """ 253 | unpack serialized bytes into deserialized message object 254 | 255 | :param raw: raw byte buffer 256 | :param ctx: deserialization context object 257 | :return: unpacked message object 258 | """ 259 | ctx = ctx or Context() 260 | header = MessageHeader.unpack(raw, ctx) 261 | options = [] 262 | while raw[ctx.index] not in (0, OptionCode.End): 263 | option = unpack_option(raw, ctx) 264 | options.append(option) 265 | return cls( 266 | op=header.opcode, 267 | id=header.message_id, 268 | client_hw=header.hw_addr, 269 | options=OptionList(options), 270 | hw_type=header.hw_type, 271 | hops=header.hops, 272 | seconds=header.seconds, 273 | flags=header.flags, 274 | client_addr=header.client_addr, 275 | your_addr=header.your_addr, 276 | gateway_addr=header.gateway_addr, 277 | server_name=header.server_name, 278 | boot_file=header.boot_file, 279 | ) 280 | -------------------------------------------------------------------------------- /pydhcp/v4/options.py: -------------------------------------------------------------------------------- 1 | """ 2 | DHCPv4 Option Implementations 3 | """ 4 | from functools import lru_cache 5 | from typing import ClassVar, List, Optional, Type 6 | from typing_extensions import Annotated, Self 7 | 8 | from pystructs import ( 9 | I32, U16, U32, U8, Context, Domain, GreedyBytes, 10 | GreedyList, HintedBytes, IPv4, Struct) 11 | 12 | from ..abc import DHCPOption 13 | from ..enum import Arch, StatusCode 14 | 15 | from .enum import MessageType, OptionCode 16 | 17 | #** Variables **# 18 | __all__ = [ 19 | 'pack_option', 20 | 'unpack_option', 21 | 22 | 'Option', 23 | 'Unknown', 24 | 25 | 'SubnetMask', 26 | 'TimezoneOffset', 27 | 'Router', 28 | 'TimeServer', 29 | 'INetNameServer', 30 | 'DomainNameServer', 31 | 'LogServer', 32 | 'QuoteServer', 33 | 'LPRServer', 34 | 'Hostname', 35 | 'DomainName', 36 | 'BroadcastAddr', 37 | 'VendorInfo', 38 | 'RequestedIPAddr', 39 | 'IPLeaseTime', 40 | 'DHCPMessageType', 41 | 'ServerIdentifier', 42 | 'ParamRequestList', 43 | 'DHCPMessage', 44 | 'MaxMessageSize', 45 | 'RenewalTime', 46 | 'RebindTime', 47 | 'VendorClassIdentifier', 48 | 'TFTPServerName', 49 | 'DHCPStatusCode', 50 | 'BootfileName', 51 | 'ClientSystemArch', 52 | 'DNSDomainSearchList', 53 | 'TFTPServerIP', 54 | 'PXEPathPrefix', 55 | 'End', 56 | ] 57 | 58 | ByteContent = Annotated[bytes, GreedyBytes()] 59 | OptionCodeInt = Annotated[OptionCode, U8] 60 | 61 | #** Functions **# 62 | 63 | def pack_option(option: 'Option', ctx: Optional[Context] = None) -> bytes: 64 | """ 65 | """ 66 | return OptionHeader(option.opcode, option.pack()).pack(ctx) 67 | 68 | def unpack_option(raw: bytes, ctx: Optional[Context] = None) -> 'Option': 69 | """ 70 | """ 71 | header = OptionHeader.unpack(raw, ctx) 72 | oclass = OPTION_MAP.get(header.opcode, None) 73 | oclass = oclass or Unknown.new(header.opcode, len(header.option)) 74 | return oclass.unpack(header.option) 75 | 76 | #** Classes **# 77 | 78 | class OptionHeader(Struct): 79 | opcode: OptionCodeInt 80 | option: Annotated[bytes, HintedBytes(U8)] 81 | 82 | class Option(Struct, DHCPOption): 83 | """ 84 | Abstract Baseclass for DHCPv4 Option Content 85 | """ 86 | opcode: ClassVar[OptionCode] #type: ignore 87 | 88 | class _IPv4ListOption(Option): 89 | """ 90 | BaseClass for AddressList Options 91 | """ 92 | opcode: ClassVar[OptionCode] 93 | ips: Annotated[List[IPv4], GreedyList(IPv4)] 94 | 95 | class SubnetMask(Option): 96 | """ 97 | SubnetMask (1) - The Subnet Mask to Apply for an Ipv4 Address Assignment 98 | """ 99 | opcode: ClassVar[OptionCode] = OptionCode.SubnetMask 100 | mask: IPv4 101 | 102 | class TimezoneOffset(Option): 103 | """ 104 | TimezoneOffset (2) - Informs Client of Network Timezone Offset 105 | """ 106 | opcode: ClassVar[OptionCode] = OptionCode.TimeOffset 107 | offset: I32 108 | 109 | class Router(_IPv4ListOption): 110 | """ 111 | Router (3) - IPv4 Router/Gateway Addresses 112 | """ 113 | opcode: ClassVar[OptionCode] = OptionCode.Router 114 | 115 | class TimeServer(_IPv4ListOption): 116 | """ 117 | TimeServer (4) - Network TimeServers 118 | """ 119 | opcode: ClassVar[OptionCode] = OptionCode.TimeServer 120 | 121 | class INetNameServer(_IPv4ListOption): 122 | """ 123 | NameServer (5) - IEN 116 Name Servers (Deprecated/Legacy) 124 | """ 125 | opcode: ClassVar[OptionCode] = OptionCode.NameServer 126 | 127 | class DomainNameServer(_IPv4ListOption): 128 | """ 129 | DomainNameServer (6) - Name Server Addresses (DNS) 130 | """ 131 | opcode: ClassVar[OptionCode] = OptionCode.DomainNameServer 132 | 133 | class LogServer(_IPv4ListOption): 134 | """ 135 | LogServer (7) - MIT-LCS UDP log servers 136 | """ 137 | opcode: ClassVar[OptionCode] = OptionCode.LogServer 138 | 139 | class QuoteServer(_IPv4ListOption): 140 | """ 141 | CookieServer (8) - Quote of The Day Server (RFC 865) 142 | """ 143 | opcode: ClassVar[OptionCode] = OptionCode.QuoteServer 144 | 145 | class LPRServer(_IPv4ListOption): 146 | """ 147 | LPRServer (9) - Line Printer Server (RFC 1179) 148 | """ 149 | opcode: ClassVar[OptionCode] = OptionCode.LPRServer 150 | 151 | class Hostname(Option): 152 | """ 153 | Hostname (12) - Client Hostname Assignment 154 | """ 155 | opcode: ClassVar[OptionCode] = OptionCode.HostName 156 | hostname: ByteContent 157 | 158 | class DomainName(Option): 159 | """ 160 | DomainName (15) - DNS Resolution Domain for Client 161 | """ 162 | opcode: ClassVar[OptionCode] = OptionCode.DomainName 163 | domain: ByteContent 164 | 165 | class BroadcastAddr(Option): 166 | """ 167 | BroadCastAddress (28) - Specifies Network Broadcast Address 168 | """ 169 | opcode: ClassVar[OptionCode] = OptionCode.BroadcastAddress 170 | addr: IPv4 171 | 172 | class VendorInfo(Option): 173 | """ 174 | Vendor Specific Information (43) - Arbitrary Vendor Data over DHCP 175 | """ 176 | opcode: ClassVar[OptionCode] = OptionCode.VendorSpecificInformation 177 | info: ByteContent 178 | 179 | class RequestedIPAddr(Option): 180 | """ 181 | Requested IP Address (50) - Client Requested IP Address 182 | """ 183 | opcode: ClassVar[OptionCode] = OptionCode.RequestedIPAddress 184 | ip: IPv4 185 | 186 | class IPLeaseTime(Option): 187 | """ 188 | IPLeaseTime (51) - Client Requested/Server Assigned Lease Time 189 | """ 190 | opcode: ClassVar[OptionCode] = OptionCode.IPAddressLeaseTime 191 | seconds: U32 192 | 193 | class DHCPMessageType(Option): 194 | """ 195 | DHCP Message Type (53) - Declares DHCP Message Type 196 | """ 197 | opcode: ClassVar[OptionCode] = OptionCode.DHCPMessageType 198 | mtype: Annotated[MessageType, U8] 199 | 200 | class ServerIdentifier(Option): 201 | """ 202 | DHCP Server Identifier (54) - Identifies DHCP Server Subject 203 | """ 204 | opcode: ClassVar[OptionCode] = OptionCode.ServerIdentifier 205 | ip: IPv4 206 | 207 | class ParamRequestList(Option): 208 | """ 209 | DHCP Parameter Request List (55) - DHCP Request for Specified Options 210 | """ 211 | opcode: ClassVar[OptionCode] = OptionCode.ParameterRequestList 212 | params: Annotated[List[OptionCode], GreedyList(OptionCodeInt)] 213 | 214 | class DHCPMessage(Option): 215 | """ 216 | Server Message (56) - DCHP Message on Server Error / Rejection 217 | """ 218 | opcode: ClassVar[OptionCode] = OptionCode.Message 219 | message: ByteContent 220 | 221 | class MaxMessageSize(Option): 222 | """ 223 | DHCP Max Message Size (57) - Maximum Length Packet Sender will Accept 224 | """ 225 | opcode: ClassVar[OptionCode] = OptionCode.Message 226 | size: U16 227 | 228 | class RenewalTime(Option): 229 | """ 230 | DHCP Renewal Time (58) - Client Address Renewal Interval 231 | """ 232 | opcode: ClassVar[OptionCode] = OptionCode.RenewTimeValue 233 | seconds: U32 234 | 235 | class RebindTime(Option): 236 | """ 237 | DHCP Rebind Time (59) - Client Address Rebind Interval 238 | """ 239 | opcode: ClassVar[OptionCode] = OptionCode.RebindingTimeValue 240 | seconds: U32 241 | 242 | class VendorClassIdentifier(Option): 243 | """ 244 | Vendor Class Identifier (60) - Optionally Identify Vendor Type and Config 245 | """ 246 | opcode: ClassVar[OptionCode] = OptionCode.ClassIdentifier 247 | vendor: ByteContent 248 | 249 | class TFTPServerName(Option): 250 | """ 251 | TFTP Server Name (66) - TFTP Server Option when `sname` field is reserved 252 | """ 253 | opcode: ClassVar[OptionCode] = OptionCode.TFTPServerName 254 | name: ByteContent 255 | 256 | class BootfileName(Option): 257 | """ 258 | Bootfile Name (67) - Bootfile Assignment with `file` field is reserved 259 | """ 260 | opcode: ClassVar[OptionCode] = OptionCode.BootfileName 261 | name: ByteContent 262 | 263 | class ClientSystemArch(Option): 264 | """ 265 | Client System Architecture (93) - Declare PXE Client Arch (RFC 4578) 266 | """ 267 | opcode: ClassVar[OptionCode] = OptionCode.ClientSystemArchitectureType 268 | arches: Annotated[List[Arch], GreedyList(Annotated[Arch, U16])] 269 | 270 | class DNSDomainSearchList(Option): 271 | """ 272 | DNS Domain Search List (119) - List of DNS Search Domain Suffixes (RFC 3397) 273 | """ 274 | opcode: ClassVar[OptionCode] = OptionCode.DNSDomainSearchList 275 | domains: Annotated[List[bytes], GreedyList(Domain)] 276 | 277 | class TFTPServerIP(Option): 278 | """ 279 | TFTP Server IP Address (128) - Commonly used for TFTP Server IP Address 280 | """ 281 | opcode: ClassVar[OptionCode] = OptionCode.TFTPServerIPAddress 282 | ip: ByteContent 283 | 284 | class DHCPStatusCode(Option): 285 | """ 286 | DHCP Status Code (151) - DHCP Server Response Status Code (RFC 6926) 287 | """ 288 | opcode: ClassVar[OptionCode] = OptionCode.StatusCode 289 | value: Annotated[StatusCode, U8] 290 | message: Annotated[bytes, GreedyBytes()] 291 | 292 | class PXEPathPrefix(Option): 293 | """ 294 | PXE Server Path Prefix (210) - PXELINUX TFTP Path Prefix (RFC 5071) 295 | """ 296 | opcode: ClassVar[OptionCode] = OptionCode.TFTPServerIPAddress 297 | prefix: ByteContent 298 | 299 | class End(Option): 300 | """ 301 | END (255) - Indicates End of DHCP Options List 302 | """ 303 | opcode: ClassVar[OptionCode] = OptionCode.End 304 | 305 | class Unknown: 306 | """ 307 | Mock Option Object for Unknown/Unsupported DHCP Content Types 308 | """ 309 | __slots__ = ('data', ) 310 | 311 | opcode: ClassVar[OptionCode] 312 | size: ClassVar[int] 313 | 314 | def __init__(self, data: bytes): 315 | self.data = data 316 | 317 | def __repr__(self) -> str: 318 | return f'Unknown(opcode={self.opcode!r}, data=0x{self.data.hex()})' 319 | 320 | def pack(self, ctx: Optional[Context] = None) -> bytes: 321 | ctx = ctx or Context() 322 | return ctx.track_bytes(self.data) 323 | 324 | @classmethod 325 | def unpack(cls, raw: bytes, ctx: Optional[Context] = None) -> Self: 326 | ctx = ctx or Context() 327 | return cls(ctx.slice(raw, cls.size)) 328 | 329 | @classmethod 330 | @lru_cache(maxsize=None) 331 | def new(cls, opcode: OptionCode, size: int) -> Type: 332 | return type('Unknown', (cls, ), {'opcode': opcode, 'size': size}) 333 | 334 | #** Init **# 335 | 336 | #: cheeky way of collecting all option types into map based on their OptionCode 337 | OPTION_MAP = {v.opcode:v 338 | for v in globals().values() 339 | if isinstance(v, type) and issubclass(v, Option) and hasattr(v, 'opcode')} 340 | --------------------------------------------------------------------------------