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.