Writing custom handlers

Writing a custom handler is, together with writing custom options, the most common way of customising the DHCPKit server. Handlers process the incoming message and adapt the outgoing message. There are many things a handler could do. Some of the common use cases are:

Basic handler structure

All handlers must be subclasses of Handler or RelayHandler. Each handler must be registered as a server extension so that the server code is aware of their existence.

A handler usually implements its functionality by overriding the handle() method (or handle_relay() in the case of RelayHandler). This method gets a TransactionBundle as its only parameter bundle. The bundle contains all the information available about a request and the response. Handlers providing information (e.g. DNS information) commonly look at whether the client included an OptionRequestOption in its request and based on that information decide to add an extra option to the response.

Because there are several very common patterns here are some base classes you can use:

Loading handlers from the configuration file

There are two parts to creating new handlers that can be used in the configuration file. The first part is the XML definition of what the configuration section looks like. The second part is a factory function or object that will create the handler from the configuration.

Defining the configuration section

If you want your handler to be loadable from the configuration file you need to provide a ZConfig component.xml schema file that determines what your configuration section will look like. A configuration section definition can look like this:

<component xmlns="https://raw.githubusercontent.com/zopefoundation/ZConfig/master/doc/schema.dtd"
           prefix="dhcpkit.ipv6.server.extensions.dns.config">

    <sectiontype name="recursive-name-servers"
                 extends="option_handler_factory_base"
                 implements="handler_factory"
                 datatype=".RecursiveNameServersOptionHandlerFactory">

        <description><![CDATA[
            This sections adds recursive name servers to the response sent to the
            client. If there are multiple sections of this type then they will be
            combined into one set of recursive name servers which is sent to the
            client.
        ]]></description>

        <example><![CDATA[
            <recursive-name-servers>
                address 2001:4860:4860::8888
                address 2001:4860:4860::8844
            </recursive-name-servers>
        ]]></example>

        <multikey name="address" attribute="addresses" required="yes"
                  datatype="ipaddress.IPv6Address">
            <description>
                The IPv6 address of a recursive name server.
            </description>
            <example>
                2001:db8:1::53
            </example>
        </multikey>
    </sectiontype>

    <sectiontype name="domain-search-list"
                 extends="option_handler_factory_base"
                 implements="handler_factory"
                 datatype=".DomainSearchListOptionHandlerFactory">

        <description><![CDATA[
            This sections adds domain names to the domain search list sent to the
            client. If there are multiple sections of this type then they will be
            combined into one set of domain names which is sent to the client.
        ]]></description>

        <example><![CDATA[
            <domain-search-list>
                domain-name example.com
                domain-name example.net
                domain-name example.org
            </domain-search-list>
        ]]></example>

        <multikey name="domain-name" attribute="domain_names" required="yes"
                  datatype="dhcpkit.common.server.config_datatypes.domain_name">
            <description>
                The domain name to add to the search list.
            </description>
            <example>
                example.com
            </example>
        </multikey>
    </sectiontype>
</component>

This component describes two section types: recursive-name-servers and domain-search-list. They both have implements="handler_factory" which makes them usable as a handler. The datatypes of the sections are relative to prefix="dhcpkit.ipv6.server.extensions.dns.config" because they start with a ..

The datatypes of <key> and <multikey> elements can be one of the ZConfig predefined types or anything that can be called like a function which accepts the string value of what the user put into the configuration file as its single parameter. Its return value is stored as the value. This behaviour also allows you to provide a class as the datatype. Its constructor will be called with a single string argument. In the example above you can see this for the <multikey name="address" ... where the datatype is the IPv6Address class from ipaddress.

The <description> and <example> tags are used when generating documentation. The whole configuration section of this manual is created based on such tags!

Writing the handler factory

After parsing a section and converting its values using the specified datatypes, the datatype of the sectiontype will be called with a ZConfig.SectionValue object containing all the values as its only parameter. The return value of that datatype must be callable as a function, which acts as a factory for the actual handler.

Note

The reason that a factory is used is for privilege separation. The configuration file is read as the user that started the server process, usually root, while the factory is called with the privileges of the user and group that the server is configured to run as. This makes sure that e.g. all files created by a handler have the right ownership.

The easiest way to write a handler factory is to create a subclass of HandlerFactory and create the Handler in the implementation of the create() method. Because HandlerFactory is a subclass of ConfigSection you can use its functionality to assist with processing configuration sections. If some of the values in the configuration are optional and the default value has to be determined at runtime you can modify section in clean_config_section(). If the configuration values need extra validation then do so in validate_config_section(). For convenience you can access the configuration values both as self.section.xyz and as self.xyz.

If you want your section to have a “name” like in:

<static-csv data/assignments.csv>
    prefix-preferred-lifetime 3d
    prefix-valid-lifetime 30d
</static-csv>

You can set the name_datatype to the function or class that should be used to parse the name.

This is a complete example that uses both the name and other section values:

class CSVStaticAssignmentHandlerFactory(HandlerFactory):
    """
    Factory for a handler that reads assignments from a CSV file
    """

    name_datatype = staticmethod(existing_file)

    def create(self) -> CSVStaticAssignmentHandler:
        """
        Create a handler of this class based on the configuration in the config section.

        :return: A handler object
        """

        # Get the lifetimes
        address_preferred_lifetime = self.address_preferred_lifetime
        address_valid_lifetime = self.address_valid_lifetime
        prefix_preferred_lifetime = self.prefix_preferred_lifetime
        prefix_valid_lifetime = self.prefix_valid_lifetime

        return CSVStaticAssignmentHandler(
            self.name,
            address_preferred_lifetime, address_valid_lifetime,
            prefix_preferred_lifetime, prefix_valid_lifetime
        )

Handler initialisation

Handlers are initialised in two steps. The first step is when the factory creates the handler object. This happens in the main server process just before the worker processes are spawned. Those worker processes get a copy of the handlers when the worker is being initialised. This is done by pickling the MessageHandler and all the filters and handlers it contains. The advantage is that workers don’t need to initialise everything themselves (especially if that initialisation can take a long time, like when parsing a CSV file) but it also means that things that cannot be pickled can therefore not be initialised when creating the handler. Therefore handlers have a separate worker_init() method that is called inside each worker. Initialisation that need to happen in each worker process (for example opening database connections) can be done there.

Registering new handlers

New handlers must be registered so that the server knows which sections are available when parsing the server configuration. This is done by defining entry points in the setup script:

setup(
    name='dhcpkit_demo_extension',
    ...
    entry_points={
        'dhcpkit.ipv6.server.extensions': [
            'handler-name = dhcpkit_demo_extension.package',
        ],
    },
)

If the package contains a file called component.xml then that file is used as an extension to the configuration file syntax.

More advanced message handling

If necessary a handler can do pre() and post() processing. Pre processing can be useful in cases where an incoming request has to be checked to see if it should be handled at all or whether processing should be aborted. Post processing can be used for cleaning up, checking that all required options are included in the response, committing leases to persistent storage, etc.

The post processing stage is especially important to handlers that assign resources. In the handle() method the handler puts its assignments in the response. That doesn’t mean that that response is actually sent to the client. Another handler might change the response or abort the processing later.

Handlers that have to store state should do that during post processing after verifying the response. If rapid commit is active the response might even have changed from an AdvertiseMessage to a ReplyMessage. Handlers that store data based on whether a resource was only advertised or whether it was actually assigned must look at the response being sent to determine that.

Handling rapid commit

Usually rapid commit is handled by its own built-in handler. If a handler does not want a rapid commit to happen it can set the allow_rapid_commit attribute of the transaction bundle to False. The built-in handler will take that into account when deciding whether it performs a rapid commit or not.

Rules for handlers that assign resources

Options meant for assigning addresses and prefixes like IANAOption, IATAOption and IAPDOption are a bit more complex to handle. The way handlers are designed in dhcpkit is that each such option can be handled by one handler. A handler that assigns addresses should use the bundle.get_unhandled_options method to find those options in the request that haven’t been handled yet:

After handling an option the handler must mark that option as handled by calling bundle.mark_handled with the handled option as parameter. This will let handlers that are executed later know which options still need to be handled.

When handling ConfirmMessage, ReleaseMessage and DeclineMessage the handler should behave as follows:

  • It should mark as handled the options that it is responsible for
  • If the confirm/release/decline is successful it should not modify the response
  • If the confirm/release/decline is not successful it should put the appropriate options and/or status code in the response
  • If a previous handler has already put a negative status code in the response then that status code should be left intact

The built-in message handler will automatically apply handlers that check for any unhandled options and set the status code if it hasn’t been set by any other handler.

Aborting message handling

There are cases where a handler decides that the current request should not be handled by this server at all. One example is when a handler determines that the ServerIdOption in the request refers to a difference DUID than that of the server. In those cases the handler can throw a CannotRespondError exception. This will stop all processing and prevent a response from being sent to the client.

A handler should not abort in the post processing phase. When post processing starts all handlers should be able to assume that the response is finished and that they can rely on the response being sent.

Example of a Handler

This is the implementation of RecursiveNameServersOptionHandler. As you can see most of the code is for processing the configuration data so that this handler can be added through the configuration file as described in the Recursive-name-servers manual page.

class RecursiveNameServersOptionHandler(SimpleOptionHandler):
    """
    Handler for putting RecursiveNameServersOption in responses
    """

    def __init__(self, dns_servers: Iterable[IPv6Address], always_send: bool = False):
        option = RecursiveNameServersOption(dns_servers=dns_servers)
        option.validate()

        super().__init__(option, always_send=always_send)

    def __str__(self):
        return "{} with {}".format(self.__class__.__name__, ', '.join(map(str, self.option.dns_servers)))

    def combine(self, existing_options: Iterable[RecursiveNameServersOption]) -> RecursiveNameServersOption:
        """
        Combine multiple options into one.

        :param existing_options: The existing options to include name servers from
        :return: The combined option
        """
        addresses = []

        # Add from existing options first
        for option in existing_options:
            for address in option.dns_servers:
                if address not in addresses:
                    addresses.append(address)

        # Then add our own
        for address in self.option.dns_servers:
            if address not in addresses:
                addresses.append(address)

        # And return a new option with the combined addresses
        return RecursiveNameServersOption(dns_servers=addresses)

Example of a RelayHandler

This is the implementation of InterfaceIdOptionHandler which copies InterfaceIdOption from incoming relay messages to outgoing relay messages. The implementation is very simple:

class InterfaceIdOptionHandler(CopyRelayOptionHandler):
    """
    The handler for InterfaceIdOptions in relay messages
    """

    def __init__(self):
        super().__init__(InterfaceIdOption)