Writing custom options¶
Implementing new options usually comes down to writing a new Option
class to store the option’s content,
validate the option’s contents, and parse and generate the bytes that represent the option on the wire.
Class properties¶
Each option class must have a property that defines the option type code implemented by the class. The list of option codes is maintained by IANA. A common way of setting the option type code is by defining a constant for the code and then using that in the class definition for readability:
OPTION_DNS_SERVERS = 23
class RecursiveNameServersOption(Option):
option_type = OPTION_DNS_SERVERS
Constructor and properties¶
Because an option (any ProtocolElement
) is defined by its type and contents, the constructor
must reflect that: all relevant properties must correspond to parameters of the option’s constructor. This requirement
makes it possible to automate comparison of protocol elements and to print their state in a readable
__str__()
and parseable __repr__()
format.
An example is RecursiveNameServersOption.__init__()
. As you can see dns_servers
is both the name of the
constructor parameter as the name of the state variable:
def __init__(self, dns_servers: Iterable[IPv6Address] = None):
self.dns_servers = list(dns_servers or [])
"""List of IPv6 addresses of resolving DNS servers"""
Validation¶
Next is the validation. Each option must be able to verify if its state is acceptable and can be encoded to bytes that can be sent on the wire.
Note
Additionally the validator may make sure that the information makes sense, but be aware that incoming messages that violate these checks will be rejected before even reaching the message handler, so make sure that is what you want.
An example is RecursiveNameServersOption.validate()
which checks that
dns_servers
is a list of IPv6Address
:
def validate(self):
"""
Validate that the contents of this object conform to protocol specs.
"""
if not isinstance(self.dns_servers, list):
raise ValueError("DNS servers must be a list")
for address in self.dns_servers:
if not isinstance(address, IPv6Address):
raise ValueError("DNS server must be an IPv6 address")
Parsing and generating binary representation¶
These are the most complex parts of an Option
implementation. The load_from()
method
must be able to parse valid binary representations of the option. Its parameters are a string of bytes and an optional
offset and length. It should start parsing at the specified offset and read up to the specified length from the buffer.
The load_from()
method must return the number of bytes that it has used/parsed so that the
caller knows which offset to give to any subsequent option parsers.
All options start with the same fields, which include the option type and the length of the option. That part is called
the option header and is parsed with parse_option_header()
. This will automatically make sure that the
length
the caller provided is enough to contain this option’s data.
An option parser should make sure that all read data is verified and that all the data up to the option length is read and parsed. After parsing the data the properties of the object should correspond to the binary string’s contents.
Here is the implementation of RecursiveNameServersOption.load_from()
:
def load_from(self, buffer: bytes, offset: int = 0, length: int = None) -> int:
"""
Load the internal state of this object from the given buffer. The buffer may
contain more data after the structured element is parsed. This data is ignored.
:param buffer: The buffer to read data from
:param offset: The offset in the buffer where to start reading
:param length: The amount of data we are allowed to read from the buffer
:return: The number of bytes used from the buffer
"""
my_offset, option_len = self.parse_option_header(buffer, offset, length)
header_offset = my_offset
if option_len % 16 != 0:
raise ValueError('DNS Servers Option length must be a multiple of 16')
# Parse the addresses
self.dns_servers = []
max_offset = option_len + header_offset
while max_offset > my_offset:
address = IPv6Address(buffer[offset + my_offset:offset + my_offset + 16])
self.dns_servers.append(address)
my_offset += 16
return my_offset
The reverse operation of load_from()
is save()
. It should generate
bytes to represent its properties. Here is the implementation of RecursiveNameServersOption.save()
:
def save(self) -> Union[bytes, bytearray]:
"""
Save the internal state of this object as a buffer.
:return: The buffer with the data from this element
"""
buffer = bytearray()
buffer.extend(pack('!HH', self.option_type, len(self.dns_servers) * 16))
for address in self.dns_servers:
buffer.extend(address.packed)
return buffer
Note
Determining which option type is next in the incoming bytes, creating the right object for it and then loading its
state with load_from()
from bytes is so common that there is a shortcut for that:
parse()
. This uses the option registry to determine the correct object class. See
Option.determine_class()
.
Note
load_from()
must be able to parse all valid binary representations of the option.
Calling save()
should produce the original binary representation again. The following should
be true:
# A RecursiveNameServersOption:
from dhcpkit.ipv6.options import Option
from dhcpkit.ipv6.extensions.dns import RecursiveNameServersOption
binary_representation = b'\x00\x17\x00 ' \
b' \x01H`H`\x00\x00\x00\x00\x00\x00\x00\x00\x88\x88' \
b' \x01H`H`\x00\x00\x00\x00\x00\x00\x00\x00\x88D'
read_bytes, parsed_option = Option.parse(binary_representation)
assert type(parsed_option) == RecursiveNameServersOption
assert binary_representation == parsed_option.save()
Registering new options¶
New options must be registered so that the server knows which classes are available for parsing DHCP options. This is done by defining entry points in the setup script:
setup(
name='dhcpkit_demo_extension',
...
entry_points={
'dhcpkit.ipv6.options': [
'65535 = dhcpkit_demo_extension.package.module:MyOptionClass',
],
},
)
Each protocol element also keeps track of which (sub)options it may contain. According to RFC 3646#section-5 the recursive name servers option may appear in Solicit, Advertise, Request, Renew, Rebind, Information-Request, and Reply messages. We need to let the classes for those messages know that they may contain this option:
SolicitMessage.add_may_contain(RecursiveNameServersOption, 0, 1)
AdvertiseMessage.add_may_contain(RecursiveNameServersOption, 0, 1)
RequestMessage.add_may_contain(RecursiveNameServersOption, 0, 1)
RenewMessage.add_may_contain(RecursiveNameServersOption, 0, 1)
RebindMessage.add_may_contain(RecursiveNameServersOption, 0, 1)
InformationRequestMessage.add_may_contain(RecursiveNameServersOption, 0, 1)
ReplyMessage.add_may_contain(RecursiveNameServersOption, 0, 1)
Here we have specified that the RecursiveNameServersOption has a min_occurrence
of 0
and a max_occurrence
of 1
in each of these message types. If no min_occurrence
and max_occurrence
are specified when calling
add_may_contain()
they default to 0
and infinite
respectively.