Kea: DHCPv6 Server Component

| Kea 3.2.0-git

DHCPv6 Server Component

Kea includes the "kea-dhcp6" component, which is the DHCPv6 server implementation.

This component is built around the isc::dhcp::Dhcpv6Srv class which controls all major operations performed by the server such as: DHCP messages processing, callouts execution for many hook points, FQDN processing and interactions with the "kea-dhcp-ddns" component, lease allocation, system signals handling etc.

The "kea-dhcp6" component requires linking with many different libraries to obtain access to common functions like: interfaces and sockets management, configuration parsing, leases management and allocation, hooks infrastructure, statistics management etc.

The following sections walk through some of the details of the "kea-dhcp6" component implementation.

# Configuration Parsers in DHCPv6

This is a three minutes overview. If you are here only to learn absolute minimum about the new parser, here's how you use it:

// The following code:
json = isc::data::Element::fromJSONFile(file_name, true);
// can be replaced with this:
Parser6Context parser;
json = parser.parseFile(file_name, Parser6Context::PARSER_DHCP6);
isc::data::Element::fromJSONFile
static ElementPtr fromJSONFile(const std::string &file_name, bool preproc=false)
Reads contents of specified file and interprets it as JSON.
Definition data.cc:883

Note: parsers are currently being migrated to isc::data::SimpleParser. See Simple JSON Parser page for details.

For more detailed background information about flex and bison, see Flex/Bison Parsers.

The common configuration parsers for the DHCP servers are located in the src/lib/dhcpsrv/parsers/ directory. Parsers specific to the DHCPv6 component are located in the src/bin/dhcp6/json_config_parser.cc. These parsers derive from the common configuration parsers and customize their behavior. For example: the `Subnet6ConfigParser` is used to parse parameters describing a single subnet. It derives from the `isc::dhcp::SubnetConfigParser`, which implements the common base for both DHCPv4 and DHCPv6 subnets. The Subnet6ConfigParser implements the `initSubnet` abstract method, which creates an instance of the DHCPv6 subnet. This method is invoked by the parent class.

Some parsers for the DHCPv6 server derive from the isc::dhcp::DhcpConfigParser class directly. This is an abstract class, defining a basic interface for all configuration parsers. All DHCPv6 parsers deriving from this class directly have their entire implementation in the src/bin/dhcp6/json_config_parser.cc.

# Configuration Parser for DHCPv6 (bison)

During the 1.2 milestone it was decided to significantly refactor the parsers as their old implementation became unsustainable. In general, the following issues of the existing code were noted:

1. parsers are overwhelmingly complex. Even though each parser is relatively simple class, the complexity comes from too large number of interacting parsers.
2. the code is disorganized, i.e. spread out between multiple directories (src/bin/dhcp6 and src/lib/dhcpsrv/parsers).
3. The split into build/commit never worked well. In particular, it is not trivial to revert configuration change. This split originates from BIND10 days and was inherited from DNS auth that did receive only changes in the configuration, rather than the full configuration. As a result, the split was abused and many of the parsers have commit() being a no-op operation.
4. There is no way to generate a list of all directives. We do have .spec files, but they're not actually used by the code. The code has the directives spread out in multiple places in multiple files in multiple directories. Answering a simple question ("can I do X in the scope Y?") requires a short session of reverse engineering. What's worse, we have the .spec files that are kinda kept up to date. This is actually more damaging that way, because there's no strict correlation between the code and .spec file. So there may be parameters that are supported, but are not in .spec files. The opposite is also true - .spec files can be buggy and have different parameters. This is particularly true for default values.
5. It's exceedingly complex to add comments that don't start at the first column or span several lines. Both Tomek and Marcin tried to implement it, but failed miserably. The same is true for including files (have include statement in the config that includes other files)
6. The current parsers don't handle the default values, i.e. if there's no directive, the parser is not created at all. We have kludgy workarounds for that, but the code for it is in different place than the parser, which leads to the logic being spread out in different places.
7. syntax checking is poor. The existing JSON parser allowed things like empty option-data entries:
   "option-data": [ {} ]
   having trailing commas:
   "option-data": [
   {
   "code": 12,
   "data": "2001:db8:1:0:ff00::1"
   },
   ]
   or having incorrect types, e.g. specifying timer values as strings.

To solve those issues a two phase approach was proposed:

PHASE 1: replace isc::data::fromJSON with bison-based parser. This will allow to have a single file that defines the actual syntax, much better syntax checking, and provide more flexibility, like various comment types and file inclusions. As a result, the parser still returns JSON structures that are guaranteed to be correct from the grammar perspective. Sticking with the JSON structures also allows us to continue using existing parsers. Furthermore, it is possible to implement default values at this level as simply inserting extra JSON structures in places that are necessary. This part is covered by ticket 5036.

PHASE 2: simplify existing parsers by getting rid of the build/commit split. Get rid of the inheritance contexts. Essentially the parser should take JSON structure as a parameter and return the configuration structure. For example, for options this should essentially look like this:

CfgOptionPtr parse(ConstElementPtr options)
ConstElementPtr
boost::shared_ptr< const Element > ConstElementPtr
Definition data.h:30

The whole complexity behind inheriting contexts should be removed from the existing parsers and implemented in the bison parser. It should return extra JSON elements. The details are TBD, but there is one example for setting up an renew-timer value on the subnet level that is inherited from the global ("Dhcp6") level. This phase is covered by ticket 5039.

The code change for 5036 introduces flex/bison based parser. It is essentially defined in two files: dhcp6_lexer.ll, which defines regular expressions that are used on the input (be it a file or a string in memory). In essence, this code is being called repeatedly and each time it returns a token. This repeats until either the parsing is complete or syntax error is encountered. For example, for the following text:

{
"Dhcp6":
{
"renew-timer": 100
}
}

this code would return the following sentence of tokens: LCURLY_BRACKET, DHCP6, COLON, LCURLY_BRACKET, RENEW_TIMER, COLON, INTEGER (a token with a value of 100), RCURLY_BRACKET, RCURLY_BRACKET, END

This stream of tokens is being consumed by the parser that is defined in dhcp6_parser.yy. This file defines a grammar. Here's very simplified version of the Dhcp6 grammar:

dhcp6_object: DHCP6 COLON LCURLY_BRACKET global_params RCURLY_BRACKET;
global_params: global_param
| global_params COMMA global_param
;
// These are the parameters that are allowed in the top-level for
// Dhcp6.
global_param: preferred_lifetime
| valid_lifetime
| renew_timer
| rebind_timer
| subnet6_list
| interfaces_config
| lease_database
| hosts_database
| mac_sources
| relay_supplied_options
| host_reservation_identifiers
| client_classes
| option_data_list
| hooks_libraries
| expired_leases_processing
| server_id
| dhcp4o6_port
;
renew_timer: RENEW_TIMER COLON INTEGER;
subnet6_list
int subnet6_list(CalloutHandle &handle)
This is a command handler for 'subnet6-list' command.
Definition subnet_cmds_callouts.cc:59

This may be slightly difficult to read at the beginning, but after getting used to the notation, it's very powerful and easy to extend. The first line defines that dhcp6_object consists of certain tokens (DHCP6, COLON and LCURLY_BRACKET) followed by 'global_params' expression, followed by RCURLY_BRACKET.

The 'global_params' is defined recursively. It can either be a single 'global_param' expression, or (a shorter) global_params followed by a comma and global_param. Bison will apply this and will be able to parse comma separated lists of arbitrary lengths.

A single parameter is defined by 'global_param' expression. This represents any parameter that may appear in the global scope of Dhcp6 object. The complete definition for all of them is complex, but the example above includes renew_timer definition. It is defined as a series of RENEW_TIMER, COLON, INTEGER tokens.

The above is a simplified version of the actual grammar. If used in the version above, it would parse the whole file, but would do nothing with that information. To build actual structures, bison allows to inject C++ code at any phase of the parsing. For example, when the parser detects Dhcp6 object, it wants to create a new MapElement. When the whole object is parsed, we can perform some sanity checks, inject defaults for parameters that were not defined, log and do other stuff.

dhcp6_object: DHCP6 COLON LCURLY_BRACKET {
// This code is executed when we're about to start parsing
// the content of the map
ElementPtr m(new MapElement());
ctx.stack_.back()->set("Dhcp6", m);
ctx.stack_.push_back(m);
} global_params RCURLY_BRACKET {
// Whole Dhcp6 parsing completed. If we ever want to do any wrap up
// (maybe some sanity checking, insert defaults if not specified),
// this would be the best place for it.
ctx.stack_.pop_back();
};
ElementPtr
boost::shared_ptr< Element > ElementPtr
Definition data.h:29

The above will do the following in order: consume DHCP6 token, consume COLON token, consume LCURLY_BRACKET, execute the code in first { ... }, parse global_params and do whatever the code for it tells, parser RCURLY_BRACKET, execute the code in the second { ... }.

There is a simple stack defined in ctx.stack_, which is isc::dhcp::Parser6Context defined in src/bin/dhcp6/parser_context.h. When walking through the config file, each new context (e.g. entering into Dhcp6, Subnet6, Pool), a new Element is added to the end of the stack. Once the parsing of a given context is complete, it is removed from the stack. At the end of parsing, there should be a single element on the stack as the top-level parsing (syntax_map) only inserts the MapElement object, but does not remove it.

# Parsing Partial Configuration in DHCPv6

For more generic description of the solution, see Parsing a Partial Configuration.

One another important capability required is the ability to parse not only the whole configuration, but a subset of it. This is done by introducing artificial tokens (e.g. TOPLEVEL_JSON and TOPLEVEL_DHCP6). For complete list of available starting contexts, see isc::dhcp::Parser6Context::ParserType. The Parse6Context::parse() method takes one parameter that specifies, whether the data to be parsed is expected to have a generic JSON or the whole configuration (DHCP6). This feature is currently mostly used by unit-tests (which often skip the '{ "Dhcp6": {' preamble), but it is expected to be soon used for parsing subnets only, host reservations only, options or basically any other elements. For example, to add the ability to parse only pools, the following could be added:

start: TOPLEVEL_GENERIC_JSON sub_json
| TOPLEVEL_DHCP6 sub_dhcp6
| TOPLEVEL_POOL6 sub_pool6
;

The parser code contains the code definition and the Kea-dhcp6 updated to use that new parser. That parser is able to to load all examples from doc/example/kea6. It is also able to parser # comments (bash style, starting at the beginning or middle of the line), // comments (C++ style, can start anywhere) or / * * / comments (C style, can span multiple lines).

This parser is currently used in production code. See configure() method in kea_controller.cc.

There are several new unit-tests written, but the code mostly reuses existing one to verify that existing functionality was not compromised. There are several new interesting ones, though. ParserTest.file loads all the config file examples we have in doc/examples/kea6. This ensures that the parser is able to load them and also checks that our examples are sane.

# Configuration Files Inclusion

The new parser provides an ability to include files. The syntax was chosen to look similar to how Apache includes PHP scripts in HTML code. This particular syntax was chosen to emphasize that the inclusion directive is an additional feature and not really a part of JSON syntax.

To include one file from another, user the following syntax:

{
"Dhcp6": {
"interfaces-config": {
"interfaces": [ "*" ]},
"preferred-lifetime": 3000,
"rebind-timer": 2000,
"renew-timer": 1000,
<?include "subnets.json"?>
"valid-lifetime": 4000
}
}

The inclusion is implemented as a stack of files. Typically, when a single file is parsed, the files_ (a vector of strings) and sfiles_ (a vector of FILE*) both contain a single entry. However, when lexer detects <?include "filename.json?&gt;, it calls @ref isc::dhcp::Parser6Context::includeFile method. Up to ten nesting levels are supported. This arbitrarily chosen limit is a protection against recursive inclusions. @section dhcp6ParserConflicts Avoiding syntactical conflicts in parsers Syntactic parser has a powerful ability to not only parse the string and check if it's a valid JSON syntax, but also check that the resulting structures match expected syntax (if subnet6 are really an array, not a map, if timers are expressed as integers, not as strings etc.). However, there are times when we need to parse a string as arbitrary JSON. For example, if we're in Dhcp6 and the config contains entries for DhcpDdns or Dhcp4. If we were to use naive approach, the lexer would go through that content and most likely find some tokens that are also used in Dhcp6. for example 'renew-timer' would be detected and the parser would complain that it was not expected. To avoid this problem, syntactic context was introduced. When the syntactic parser expects certain type of content, it calls @ref isc::dhcp::Parser6Context::enter() method to indicate what type of content is expected. For example, when Dhcp6 parser discovers uninteresting content like Dhcp4, it enters NO_KEYWORD mode. In this mode, everything is parsed as generic maps, lists, integers, booleans or strings. This results in generic JSON structures without any syntax checking. A corresponding/balanced @ref isc::dhcp::Parser6Context::leave() call is required before leaving the context to come back to the previous context. Entering a new syntactic context is useful in several ways. First, it allows the parser to avoid conflicts. Second, it allows the lexer to return different tokens depending on context (e.g. if "renew-timer" string is detected, the lexer will return STRING token if in JSON mode or RENEW_TIMER if in DHCP6 mode). Finally, the syntactic context allows the error message to be more descriptive if the input string does not parse properly. Details of the refactor of the classes derived from DhcpConfigParser are documented in https://gitlab.isc.org/isc-projects/kea/-/wikis/Designs/simple-parser-design. For a generic description of the conflict avoidance, see @ref parserFlex. @section dhcpv6ConfigInherit DHCPv6 Configuration Inheritance One notable useful feature of DHCP configuration is its parameter inheritance. For example, the "renew-timer" value may be specified at a global scope and it then applies to all subnets. However, some subnets may have it overwritten with subnet specific values that takes precedence over global values that are considered defaults. The parameters inheritance is implemented by means of the "global context". The global context is represented by the isc::dhcp::ParserContext class and it holds pointers to storages of different kind, e.g. text parameters, numeric parameters etc. When the server is parsing the top level configuration parameters it passes pointers to the storages of the appropriate kind, to the parsers being invoked to parse the global values. Parsers will store the parsed values into these storages. Once the global parameters are stored in the global context, the parsers for the nested configuration parameters are invoked. These parsers check the presence of the parameters overriding the values of the global parameters. If a value is not present, the value from the global context is used. A good example of inheritance is the implementation of the @ref isc::dhcp::SubnetConfigParser. The @c getParam method is used throughout the class to obtain values of the parameters defining a subnet. It first checks if the specific value is present in the local values storage. If it is not present, it uses the value from the global context. @code isc::dhcp::Triplet<uint32_t> SubnetConfigParser::getParam(const std::string& name) { uint32_t value = 0; try { // look for local value value = uint32_values_->getParam(name); } catch (const DhcpConfigError &) { try { // no local, use global value value = global_context_->uint32_values_->getParam(name); } catch (const DhcpConfigError &) { isc_throw(DhcpConfigError, "Mandatory parameter " << name << " missing (no global default and no subnet-" << "specific value)"); } } return (Triplet<uint32_t>(value)); } @endcode Note that if the value is neither present in the local storage nor in the global context, an error is signaled. Parameter inheritance is done once, during the reconfiguration phase. Reconfigurations are rare, so extra logic here is not a problem. On the other hand, values of those parameters may be used thousands times per second, so access to these parameters must be as efficient as possible. In fact, currently the code has to only call @c Subnet6::getT1(), regardless if the "renew-timer" has been specified as a global or subnet specific value. Debugging a configuration parser may be confusing. Therefore there is a special class called DebugParser. It does not configure anything, but just accepts any parameter of any type. If requested to commit a configuration, it will print out received parameter name and its value. This class is not currently used, but it is convenient to have it every time a new parameter is added to DHCP configuration. For that purpose it should be left in the code. @section dhcpv6DDNSIntegration DHCPv6 Server Support for the Dynamic DNS Updates The DHCPv6 server supports processing of the DHCPv6 Client FQDN Option described in the RFC4704. This Option is sent by the DHCPv6 client to instruct the server to update the DNS mappings for the acquired lease. A client may send its fully qualified domain name, a partial name or it may choose that server will generate the name. In the last case, the client sends an empty domain-name field in the DHCPv6 Client FQDN Option. As described in RFC4704, client may choose that the server delegates the forward DNS update to the client and that the server performs the reverse update only. Current version of the DHCPv6 server does not support delegation of the forward update to the client. The implementation of this feature is planned for the future releases. The kea-dhcp-ddns process is responsible for the actual communication with the DNS server, i.e. to send DNS Update messages. The kea-dhcp6 module is responsible for generating so called @ref isc::dhcp_ddns::NameChangeRequest and sending it to the kea-dhcp-ddns module. The @ref isc::dhcp_ddns::NameChangeRequest object represents changes to the DNS bindings, related to acquisition, renewal or release of the lease. The kea-dhcp6 module implements the simple FIFO queue of the NameChangeRequest objects. The module logic, which processes the incoming DHCPv6 Client FQDN Options puts these requests into the FIFO queue. \xrefitem todo 45. In the simplest case, when client gets one address from the server, a DHCPv6 server may generate 0, 1 or 2 NameChangeRequests during single message processing. The server generates no NameChangeRequests if it is not configured to update DNS or it rejects the DNS update for any other reason. The server may generate one NameChangeRequest in a situation when a client acquires a new lease or it releases an existing lease. In the former case, the NameChangeRequest type is CHG_ADD, which indicates that the kea-dhcp-ddns module should add a new DNS binding for the client, and it is assumed that there is no DNS binding for this client already. In the latter case, the NameChangeRequest type is CHG_REMOVE to indicate to the kea-dhcp-ddns module that the existing DNS binding should be removed from the DNS. The binding consists of the forward and reverse mapping. A server may only remove the mapping which it had added. Therefore, the lease database holds an information which updates (no update, reverse only update, forward only update, both reverse and forward update) have been performed when the lease was acquired. Server checks this information to make a decision which mapping it is supposed to remove when a lease is released. The server may generate two NameChangeRequests in case the client is renewing a lease and it already has a DNS binding for that lease. Note, that renewal may be triggered as a result of sending a RENEW message as well as the REQUEST message. In both cases DHCPv6 server will check if there is an existing lease for the client which has sent a message, and it will check in the lease database if the DNS Updates had been performed for this client. If the notion of client's FQDN changes comparing to the information stored in the lease database, the DHCPv6 has to remove an existing binding from the DNS and then add a new binding according to the new FQDN information received from the client. If the FQDN sent in the message which triggered a renewal doesn't change (comparing to the information in the lease database) the NameChangeRequest is not generated. In the more complex scenarios, when server sends multiple IA_NA options, each holding multiple IAADDR options, server will generate more NameChangeRequests for a single message being processed. That is 0, 1, 2 for the individual IA_NA. Generation of the distinct NameChangeRequests for each IADDR is not supported yet. The DHCPv6 Client FQDN Option comprises "NOS" flags which communicate to the server what updates (if any) client expects the server to perform. Server may be configured to obey client's preference or do FQDN processing in a different way. If the server overrides client's preference it will communicate it by sending the DHCPv6 Client FQDN Option in its responses to a client, with appropriate flags set. @section dhcpv6OptionsParse Custom functions to parse message options The DHCPv6 server implementation provides a generic support to define option formats and set option values. A number of options formats have been defined for standard options in libdhcp++. However, the formats for vendor specific options are dynamically configured by the server's administrator and thus can't be stored in libdhcp++. Such option formats are stored in the @ref isc::dhcp::CfgMgr. The libdhcp++ provides functions for recursive parsing of options which may be encapsulated by other options up to the any level of encapsulation but these functions are unaware of the option formats defined in the @ref isc::dhcp::CfgMgr because they belong to a different library. Therefore, the generic functions @ref isc::dhcp::LibDHCP::unpackOptions4 and @ref isc::dhcp::LibDHCP::unpackOptions6 are only useful to parse standard options whose definitions are provided in the libdhcp++. In order to overcome this problem a callback mechanism has been implemented in @c Option and @c Pkt6 classes. By installing a callback function on the instance of the @c Pkt6 the server may provide a custom implementation of the options parsing algorithm. This callback function will take precedence over the @c LibDHCP::unpackOptions6 and @c LibDHCP::unpackOptions4 functions. With this approach, the callback is implemented within the context of the server and it has access to all objects which define its configuration (including dynamically created option definitions). @section dhcpv6Classifier DHCPv6 Client Classification The Kea DHCPv6 currently supports two classification modes: simplified client classification (that was an early implementation that used values of vendor class option) and full client classification. @subsection dhcpv6ClassifierSimple Simple Client Classification in DHCPv6 The Kea DHCPv6 server supports simplified client classification. It is called "simplified", because the incoming packets are classified based on the content of the vendor class (16) option. More flexible classification was added in 1.0 and is described in @ref dhcpv6ClassifierFull. For each incoming packet, @ref isc::dhcp::Dhcpv6Srv::classifyPacket() method is called. It attempts to extract content of the vendor class option and interprets as a name of the class. Although the RFC 9915 says that the vendor class may contain more than one chunk of data, the existing code handles only one data block, because that is what actual devices use. For now, the code has been tested with two classes used in cable modem networks: eRouter1.0 and docsis3.0, but any other content of the vendor class option will be interpreted as a class name. In principle any given packet can belong to zero or more classes. As the current classifier is very modest, there's only one way to assign a class (based on vendor class option), the ability to assign more than one class to a packet is not yet exercised. Nevertheless, there is such a possibility and it will be used in a near future. To check whether a packet belongs to given class, isc::dhcp::Pkt6::inClass method should be used. The code sometimes refers to this classification as "simple" or 'built-in", because it does not require any configuration and thus is built into the server logic.

## Full Client Classification in DHCPv6

Kea 1.0 introduced full client classification. Each client class consists of a name and an expression that can be evaluated on an incoming packet. If it evaluates to true, this packet is considered a member of said class. Class definitions are stored in isc::dhcp::ClientClassDef objects that are kept in isc::dhcp::ClientClassDictionary and can be retrieved from CfgMgr using isc::dhcp::SrvConfig::getClientClassDictionary(). This is convenient as there are often multiple classes associated with a given scope.

Client classification is done in isc::dhcp::Dhcpv6Srv::classifyPacket. First, the old "built-in" (see Simple Client Classification in DHCPv6) classification is called (see private isc::dhcp::Dhcpv6Srv::classifyByVendor()). Then the code iterates over all class definitions and for each class definition it calls isc::dhcp::evaluate, which is implemented in libeval (see libkea-eval - Expression Evaluation and Client Classification Library). If the evaluation is successful, the class name is added to the packet (by calling isc::dhcp::pkt::addClass).

If packet belongs to at least one class, this fact is logged. If there are any exceptions raised during class evaluation, an error is logged and the code attempts to evaluate the next class.

## How client classification information is used in DHCPv6

It is possible to define class restrictions in subnet, so a given subnet is only accessible to clients that belong to a given class. That is implemented as isc::dhcp::Pkt6::classes_ being passed in isc::dhcp::Dhcpv6Srv::selectSubnet() to isc::dhcp::CfgMgr::getSubnet6(). Currently this capability is usable, but the number of scenarios it supports is limited.

Finally, it is possible to define client class-specific options, so clients belonging to a class foo, will get options associated with class foo. This is implemented in isc::dhcp::Dhcpv6Srv::buildCfgOptionList.

# Configuration backend for DHCPv6

Earlier Kea versions had a concept of backends, which were implementations of different ways how configuration could be delivered to Kea. It seems that the concept of backends didn't get much enthusiasm from users and having multiple backends was cumbersome to maintain, so it was removed in 1.0.

# Reconfiguring DHCPv6 server with SIGHUP signal

Online reconfiguration (reconfiguration without a need to restart the server) is an important feature which is supported by all modern DHCP servers. When using the JSON configuration backend, a configuration file name is specified with a command line option of the DHCP server binary. The configuration file is used to configure the server at startup. If the initial configuration fails, the server will fail to start. If the server starts and configures successfully it will use the initial configuration until it is reconfigured.

The reconfiguration request can be triggered externally (from other process) by editing a configuration file and sending a SIGHUP signal to DHCP server process. After receiving the SIGHUP signal, the server will re-read the configuration file specified at startup. If the reconfiguration fails, the server will continue to run and use the last good configuration.

The signal handler for SIGHUP (also for SIGTERM and SIGINT) are installed in the kea_controller.cc using the `isc::util::SignalSet` class. The `isc::dhcp::Dhcp6Srv` calls `isc::dhcp::Daemon::handleSignal` on each pass through the main loop. This method fetches the last received signal and calls a handler function defined in the kea_controller.cc. The handler function calls a static function `configure` defined in the kea_controller.cc.

The signal handler reconfigures the server using the configuration file specified at server startup. The location of this file is held in the `Daemon` class.

# Other DHCPv6 topics

For hooks API support in DHCPv6, see The Hooks API for the DHCPv6 Server.

For a description of how DHCPv4-over-DHCPv6 is implemented, see DHCPv4-over-DHCPv6 DHCPv6 Server Side