From 706b93612ae32ec8c8ce9f716890df1506417b16 Mon Sep 17 00:00:00 2001 From: Miaosen Wang Date: Sun, 8 Feb 2026 05:25:18 -0800 Subject: [PATCH] T8243: dhcp: add RFC9463 DNR support for Kea DHCPv4/DHCPv6 Add CLI support for Discovery of Network-designated Resolvers (DNR) under DHCP option trees and render it to Kea option-data. - Add new DNR nodes for DHCPv4 and DHCPv6 option config: - priority - authentication-domain-name - address (v4/v6) - service-parameter (alpn/port/dohpath/raw) - Map DNR config in python/vyos/kea.py: - DHCPv4: emit a single v4-dnr option with instances joined by "|" - DHCPv6: emit one v6-dnr option per instance - Add smoke tests for DHCPv4 and DHCPv6 DNR rendering. Note: SLAAC/RA DNR is not part of this commit; current router-advert backend (radvd) does not expose RFC9463 DNR option support. --- .../include/dhcp/dnr-common.xml.i | 24 ++++ .../include/dhcp/dnr-service-parameters.xml.i | 32 ++++++ .../include/dhcp/dnr-v4.xml.i | 32 ++++++ .../include/dhcp/dnr-v6.xml.i | 32 ++++++ .../include/dhcp/option-v4.xml.i | 1 + .../include/dhcp/option-v6.xml.i | 1 + python/vyos/kea.py | 97 ++++++++++++++++ .../scripts/cli/test_service_dhcp-server.py | 94 ++++++++++++++++ .../scripts/cli/test_service_dhcpv6-server.py | 104 ++++++++++++++++++ src/conf_mode/service_dhcp-server.py | 56 ++++++++++ src/conf_mode/service_dhcpv6-server.py | 60 +++++++++- 11 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 interface-definitions/include/dhcp/dnr-common.xml.i create mode 100644 interface-definitions/include/dhcp/dnr-service-parameters.xml.i create mode 100644 interface-definitions/include/dhcp/dnr-v4.xml.i create mode 100644 interface-definitions/include/dhcp/dnr-v6.xml.i diff --git a/interface-definitions/include/dhcp/dnr-common.xml.i b/interface-definitions/include/dhcp/dnr-common.xml.i new file mode 100644 index 0000000000..1350263755 --- /dev/null +++ b/interface-definitions/include/dhcp/dnr-common.xml.i @@ -0,0 +1,24 @@ + + + + DNR service priority + + u32:0-65535 + Lower value means higher preference + + + + + DNR priority must be between 0 and 65535 + + + + + Authentication domain name (ADN) for encrypted DNS resolver + + + + Invalid authentication domain name + + + diff --git a/interface-definitions/include/dhcp/dnr-service-parameters.xml.i b/interface-definitions/include/dhcp/dnr-service-parameters.xml.i new file mode 100644 index 0000000000..dd66e92fe9 --- /dev/null +++ b/interface-definitions/include/dhcp/dnr-service-parameters.xml.i @@ -0,0 +1,32 @@ + + + + DNR service parameter + + + + + Application-Layer Protocol Negotiation (ALPN) + + dot doq h2 h3 + + + [A-Za-z0-9._-]+ + + ALPN identifier may only contain letters, digits, dot, underscore, and hyphen + + + + #include + + + Relative DoH URI template path (example /dns-query{?dns}) + + [[:graph:]]+ + + DoH path must not contain whitespace + + + + + diff --git a/interface-definitions/include/dhcp/dnr-v4.xml.i b/interface-definitions/include/dhcp/dnr-v4.xml.i new file mode 100644 index 0000000000..2ddb58a085 --- /dev/null +++ b/interface-definitions/include/dhcp/dnr-v4.xml.i @@ -0,0 +1,32 @@ + + + + Discovery of Network-designated Resolvers (DNR) + + u32:1-9999 + DNR instance identifier + + + + + DNR instance identifier must be between 1 and 9999 + + + #include + + + IPv4 address of encrypted DNS resolver + + ipv4 + IPv4 resolver address + + + + + + + + #include + + + diff --git a/interface-definitions/include/dhcp/dnr-v6.xml.i b/interface-definitions/include/dhcp/dnr-v6.xml.i new file mode 100644 index 0000000000..10ca929659 --- /dev/null +++ b/interface-definitions/include/dhcp/dnr-v6.xml.i @@ -0,0 +1,32 @@ + + + + Discovery of Network-designated Resolvers (DNR) + + u32:1-9999 + DNR instance identifier + + + + + DNR instance identifier must be between 1 and 9999 + + + #include + + + IPv6 address of encrypted DNS resolver + + ipv6 + IPv6 resolver address + + + + + + + + #include + + + diff --git a/interface-definitions/include/dhcp/option-v4.xml.i b/interface-definitions/include/dhcp/option-v4.xml.i index 0f446c9a95..d847c75db0 100644 --- a/interface-definitions/include/dhcp/option-v4.xml.i +++ b/interface-definitions/include/dhcp/option-v4.xml.i @@ -7,6 +7,7 @@ #include #include #include + #include #include #include diff --git a/interface-definitions/include/dhcp/option-v6.xml.i b/interface-definitions/include/dhcp/option-v6.xml.i index 202843ddf4..0e64be9bf3 100644 --- a/interface-definitions/include/dhcp/option-v6.xml.i +++ b/interface-definitions/include/dhcp/option-v6.xml.i @@ -6,6 +6,7 @@ #include #include + #include #include diff --git a/python/vyos/kea.py b/python/vyos/kea.py index a8b38d9997..2bf583a4dc 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -96,6 +96,94 @@ def kea_test_config(process: str, config_path: str) -> tuple[bool, str]: find = re.search(r'Error encountered:\s([^\n$]+)', output) return (False, find[1] if find else None) + +def _kea_dnr_sort_key(instance_id): + value = str(instance_id) + if value.isdigit(): + return (0, int(value)) + return (1, value) + + +def _kea_dnr_normalize_list(value): + if value is None: + return [] + + if isinstance(value, list): + return value + + return [value] + + +def _kea_dnr_parse_service_parameters(service_parameter): + if not service_parameter: + return '' + + params = [] + + alpn = _kea_dnr_normalize_list(service_parameter.get('alpn')) + if alpn: + params.append('alpn=' + r'\,'.join(alpn)) + + if 'port' in service_parameter: + params.append(f'port={int(service_parameter["port"])}') + + if 'dohpath' in service_parameter: + dohpath = service_parameter['dohpath'] + # Kea expects "{?dns}" in dohpath. To keep CLI ergonomic, append it for + # plain paths (e.g. "/dns-query"), but reject custom templates that + # do not include "{?dns}". + if '{?dns}' not in dohpath: + if '{' in dohpath or '}' in dohpath: + raise ConfigError( + 'DNR service-parameter "dohpath" URI template must include "{?dns}"' + ) + dohpath = f'{dohpath}{{?dns}}' + + # RFC 9463 allows pipe in dohpath, it must be escaped for Kea parser. + dohpath = dohpath.replace('|', r'\|') + params.append(f'dohpath={dohpath}') + + return ' '.join(params) + + +def _kea_parse_dnr_instances(dnr_config): + if not dnr_config: + return [] + + entries = [] + + for instance_id, instance_config in sorted( + dnr_config.items(), key=lambda entry: _kea_dnr_sort_key(entry[0]) + ): + if 'priority' not in instance_config: + raise ConfigError( + f'DNR instance "{instance_id}" requires "priority" to be configured' + ) + + if 'authentication_domain_name' not in instance_config: + raise ConfigError( + f'DNR instance "{instance_id}" requires ' + '"authentication-domain-name" to be configured' + ) + + priority = int(instance_config['priority']) + data_fields = [str(priority), instance_config['authentication_domain_name']] + + address = _kea_dnr_normalize_list(instance_config.get('address')) + service_parameter = _kea_dnr_parse_service_parameters( + instance_config.get('service_parameter') + ) + + if address or service_parameter: + data_fields.append(' '.join(address)) + + if service_parameter: + data_fields.append(service_parameter) + + entries.append(', '.join(data_fields)) + + return entries + def kea_parse_options(config): options = [] @@ -154,6 +242,10 @@ def kea_parse_options(config): {'name': 'unifi-controller', 'data': unifi_controller, 'space': 'ubnt'} ) + dnr_entries = _kea_parse_dnr_instances(config.get('dnr')) + if dnr_entries: + options.append({'name': 'v4-dnr', 'data': ' | '.join(dnr_entries)}) + return options @@ -280,6 +372,11 @@ def kea6_parse_options(config): {'name': 'tftp-servers', 'code': 2, 'space': 'cisco', 'data': cisco_tftp} ) + dnr_entries = _kea_parse_dnr_instances(config.get('dnr')) + if dnr_entries: + for dnr_entry in dnr_entries: + options.append({'name': 'v6-dnr', 'data': dnr_entry}) + return options diff --git a/smoketest/scripts/cli/test_service_dhcp-server.py b/smoketest/scripts/cli/test_service_dhcp-server.py index 285b56dfeb..cf5323664f 100755 --- a/smoketest/scripts/cli/test_service_dhcp-server.py +++ b/smoketest/scripts/cli/test_service_dhcp-server.py @@ -482,6 +482,100 @@ def test_dhcp_single_pool_options(self): # Check for running process self.verify_service_running() + def test_dhcp_single_pool_dnr_option(self): + shared_net_name = 'SMOKE-DNR' + range_0_start = inc_ip(subnet, 10) + range_0_stop = inc_ip(subnet, 20) + dnr_ipv4 = inc_ip(subnet, 100) + + pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] + self.cli_set(pool + ['subnet-id', '1']) + self.cli_set(pool + ['range', '0', 'start', range_0_start]) + self.cli_set(pool + ['range', '0', 'stop', range_0_stop]) + + self.cli_set(pool + ['option', 'dnr', '10', 'priority', '100']) + self.cli_set( + pool + + [ + 'option', + 'dnr', + '10', + 'authentication-domain-name', + 'resolver1.example', + ] + ) + self.cli_set(pool + ['option', 'dnr', '10', 'address', dnr_ipv4]) + self.cli_set(pool + ['option', 'dnr', '10', 'service-parameter', 'alpn', 'dot']) + self.cli_set(pool + ['option', 'dnr', '10', 'service-parameter', 'alpn', 'doq']) + self.cli_set(pool + ['option', 'dnr', '10', 'service-parameter', 'port', '853']) + self.cli_set( + pool + + [ + 'option', + 'dnr', + '10', + 'service-parameter', + 'dohpath', + '/dns-query', + ] + ) + + self.cli_set(pool + ['option', 'dnr', '20', 'priority', '200']) + self.cli_set( + pool + + [ + 'option', + 'dnr', + '20', + 'authentication-domain-name', + 'resolver2.example', + ] + ) + + self.cli_commit() + + config = read_file(KEA4_CONF) + obj = loads(config) + + dnr_data = ( + f'100, resolver1.example, {dnr_ipv4}, ' + 'alpn=dot\\,doq port=853 dohpath=/dns-query{?dns} | ' + '200, resolver2.example' + ) + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'option-data'], + {'name': 'v4-dnr', 'data': dnr_data}, + ) + + self.verify_service_running() + + def test_dhcp_dnr_http_alpn_requires_dohpath(self): + shared_net_name = 'SMOKE-DNR-VERIFY' + range_0_start = inc_ip(subnet, 10) + range_0_stop = inc_ip(subnet, 20) + + pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] + self.cli_set(pool + ['subnet-id', '1']) + self.cli_set(pool + ['range', '0', 'start', range_0_start]) + self.cli_set(pool + ['range', '0', 'stop', range_0_stop]) + + self.cli_set(pool + ['option', 'dnr', '10', 'priority', '100']) + self.cli_set( + pool + + [ + 'option', + 'dnr', + '10', + 'authentication-domain-name', + 'resolver.example', + ] + ) + self.cli_set(pool + ['option', 'dnr', '10', 'service-parameter', 'alpn', 'h2']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + def test_dhcp_single_pool_options_scoped(self): shared_net_name = 'SMOKE-2' diff --git a/smoketest/scripts/cli/test_service_dhcpv6-server.py b/smoketest/scripts/cli/test_service_dhcpv6-server.py index 4e0a19444a..829e9d69fc 100755 --- a/smoketest/scripts/cli/test_service_dhcpv6-server.py +++ b/smoketest/scripts/cli/test_service_dhcpv6-server.py @@ -216,6 +216,110 @@ def test_single_pool(self): # Check for running process self.assertTrue(process_named_running(PROCESS_NAME)) + def test_dnr_options(self): + shared_net_name = 'SMOKE-DNR' + range_start = inc_ip(subnet, 256) # ::100 + range_stop = inc_ip(subnet, 65535) # ::ffff + dnr_ipv6_1 = inc_ip(subnet, 100) + dnr_ipv6_2 = inc_ip(subnet, 101) + + pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] + self.cli_set(pool + ['subnet-id', '1']) + self.cli_set(pool + ['interface', interface]) + self.cli_set(pool + ['range', '1', 'start', range_start]) + self.cli_set(pool + ['range', '1', 'stop', range_stop]) + + self.cli_set(pool + ['option', 'dnr', '10', 'priority', '100']) + self.cli_set( + pool + + [ + 'option', + 'dnr', + '10', + 'authentication-domain-name', + 'resolver1.example', + ] + ) + self.cli_set(pool + ['option', 'dnr', '10', 'address', dnr_ipv6_1]) + self.cli_set(pool + ['option', 'dnr', '10', 'address', dnr_ipv6_2]) + self.cli_set(pool + ['option', 'dnr', '10', 'service-parameter', 'alpn', 'dot']) + self.cli_set(pool + ['option', 'dnr', '10', 'service-parameter', 'alpn', 'h2']) + self.cli_set(pool + ['option', 'dnr', '10', 'service-parameter', 'port', '853']) + self.cli_set( + pool + + [ + 'option', + 'dnr', + '10', + 'service-parameter', + 'dohpath', + '/dns-query', + ] + ) + + self.cli_set(pool + ['option', 'dnr', '20', 'priority', '200']) + self.cli_set( + pool + + [ + 'option', + 'dnr', + '20', + 'authentication-domain-name', + 'resolver2.example', + ] + ) + + self.cli_commit() + + config = read_file(KEA6_CONF) + obj = loads(config) + + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], + { + 'name': 'v6-dnr', + 'data': ( + f'100, resolver1.example, {dnr_ipv6_1} {dnr_ipv6_2}, ' + 'alpn=dot\\,h2 port=853 dohpath=/dns-query{?dns}' + ), + }, + ) + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], + {'name': 'v6-dnr', 'data': '200, resolver2.example'}, + ) + + self.assertTrue(process_named_running(PROCESS_NAME)) + + def test_dnr_http_alpn_requires_dohpath(self): + shared_net_name = 'SMOKE-DNR-VERIFY' + range_start = inc_ip(subnet, 256) + range_stop = inc_ip(subnet, 65535) + + pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] + self.cli_set(pool + ['subnet-id', '1']) + self.cli_set(pool + ['interface', interface]) + self.cli_set(pool + ['range', '1', 'start', range_start]) + self.cli_set(pool + ['range', '1', 'stop', range_stop]) + + self.cli_set(pool + ['option', 'dnr', '10', 'priority', '100']) + self.cli_set( + pool + + [ + 'option', + 'dnr', + '10', + 'authentication-domain-name', + 'resolver.example', + ] + ) + self.cli_set(pool + ['option', 'dnr', '10', 'service-parameter', 'alpn', 'h3']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + def test_prefix_delegation(self): shared_net_name = 'SMOKE-2' diff --git a/src/conf_mode/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py index c022d0a969..59b4beb350 100755 --- a/src/conf_mode/service_dhcp-server.py +++ b/src/conf_mode/service_dhcp-server.py @@ -242,6 +242,40 @@ def verify_ddns_domain_servers(domain_type, domain): raise ConfigError(f'{domain_type} DNS servers {", ".join(invalid_servers)} in DDNS configuration need to have an IP address') return None + +def verify_dnr_dohpath_requirement(option, scope): + if 'dnr' not in option: + return None + + for instance, config in option['dnr'].items(): + service_parameter = config.get('service_parameter') + if not service_parameter: + continue + + if 'dohpath' in service_parameter: + dohpath = service_parameter['dohpath'] + if '{?dns}' not in dohpath and ('{' in dohpath or '}' in dohpath): + raise ConfigError( + f'{scope}: DNR instance "{instance}" has invalid ' + '"service-parameter dohpath" URI template. ' + 'It must include "{?dns}"' + ) + + alpn = service_parameter.get('alpn') + if not alpn: + continue + + alpn_values = alpn if isinstance(alpn, list) else [alpn] + if any(proto in {'h2', 'h3'} for proto in alpn_values): + if 'dohpath' not in service_parameter: + raise ConfigError( + f'{scope}: DNR instance "{instance}" requires ' + '"service-parameter dohpath" when ALPN includes h2/h3' + ) + + return None + + def verify(dhcp): # bail out early - looks like removal from running config if not dhcp or 'disable' in dhcp: @@ -264,6 +298,11 @@ def verify(dhcp): # A shared-network requires a subnet definition for network, network_config in dhcp['shared_network_name'].items(): + if 'option' in network_config: + verify_dnr_dohpath_requirement( + network_config['option'], f'Shared-network "{network}"' + ) + if 'disable' in network_config: disabled_shared_networks += 1 @@ -292,6 +331,11 @@ def verify(dhcp): f'DHCP static-route "{route}" requires router to be defined!' ) + if 'option' in subnet_config: + verify_dnr_dohpath_requirement( + subnet_config['option'], f'Subnet "{subnet}"' + ) + # If a client class has been specified then it must exist if 'client_class' in subnet_config: client_class = subnet_config['client_class'] @@ -313,6 +357,12 @@ def verify(dhcp): if client_class not in dhcp.get('client_class', {}): raise ConfigError(f'Client class "{client_class}" set in range "{range}" but does not exist') + if 'option' in range_config: + verify_dnr_dohpath_requirement( + range_config['option'], + f'Range "{range}" in subnet "{subnet}"', + ) + # Start/Stop address must be inside network for key in ['start', 'stop']: if ip_address(range_config[key]) not in ip_network(subnet): @@ -365,6 +415,12 @@ def verify(dhcp): used_mac = [] used_duid = [] for mapping, mapping_config in subnet_config['static_mapping'].items(): + if 'option' in mapping_config: + verify_dnr_dohpath_requirement( + mapping_config['option'], + f'Static-mapping "{mapping}" in subnet "{subnet}"', + ) + if 'ip_address' in mapping_config: if ip_address(mapping_config['ip_address']) not in ip_network( subnet diff --git a/src/conf_mode/service_dhcpv6-server.py b/src/conf_mode/service_dhcpv6-server.py index d7b2f2a6d1..8dce539177 100755 --- a/src/conf_mode/service_dhcpv6-server.py +++ b/src/conf_mode/service_dhcpv6-server.py @@ -102,6 +102,40 @@ def get_config(config=None): return dhcpv6 + +def verify_dnr_dohpath_requirement(option, scope): + if 'dnr' not in option: + return None + + for instance, config in option['dnr'].items(): + service_parameter = config.get('service_parameter') + if not service_parameter: + continue + + if 'dohpath' in service_parameter: + dohpath = service_parameter['dohpath'] + if '{?dns}' not in dohpath and ('{' in dohpath or '}' in dohpath): + raise ConfigError( + f'{scope}: DNR instance "{instance}" has invalid ' + '"service-parameter dohpath" URI template. ' + 'It must include "{?dns}"' + ) + + alpn = service_parameter.get('alpn') + if not alpn: + continue + + alpn_values = alpn if isinstance(alpn, list) else [alpn] + if any(proto in {'h2', 'h3'} for proto in alpn_values): + if 'dohpath' not in service_parameter: + raise ConfigError( + f'{scope}: DNR instance "{instance}" requires ' + '"service-parameter dohpath" when ALPN includes h2/h3' + ) + + return None + + def verify(dhcpv6): # bail out early - looks like removal from running config if not dhcpv6 or 'disable' in dhcpv6: @@ -117,6 +151,11 @@ def verify(dhcpv6): subnet_ids = [] listen_ok = False for network, network_config in dhcpv6['shared_network_name'].items(): + if 'option' in network_config: + verify_dnr_dohpath_requirement( + network_config['option'], f'Shared-network "{network}"' + ) + # A shared-network requires a subnet definition if 'subnet' not in network_config: raise ConfigError(f'No DHCPv6 lease subnets configured for "{network}". '\ @@ -124,6 +163,11 @@ def verify(dhcpv6): 'each shared network!') for subnet, subnet_config in network_config['subnet'].items(): + if 'option' in subnet_config: + verify_dnr_dohpath_requirement( + subnet_config['option'], f'Subnet "{subnet}"' + ) + if 'subnet_id' not in subnet_config: raise ConfigError(f'Unique subnet ID not specified for subnet "{subnet}"') @@ -137,6 +181,12 @@ def verify(dhcpv6): range6_stop = [] for num, range_config in subnet_config['range'].items(): + if 'option' in range_config: + verify_dnr_dohpath_requirement( + range_config['option'], + f'Range "{num}" in subnet "{subnet}"', + ) + if 'start' in range_config: start = range_config['start'] @@ -145,11 +195,11 @@ def verify(dhcpv6): stop = range_config['stop'] # Start address must be inside network - if not ip_address(start) in ip_network(subnet): + if ip_address(start) not in ip_network(subnet): raise ConfigError(f'Range start address "{start}" is not in subnet "{subnet}"!') # Stop address must be inside network - if not ip_address(stop) in ip_network(subnet): + if ip_address(stop) not in ip_network(subnet): raise ConfigError(f'Range stop address "{stop}" is not in subnet "{subnet}"!') # Stop address must be greater or equal to start address @@ -220,6 +270,12 @@ def verify(dhcpv6): # Static mappings don't require anything (but check if IP is in subnet if it's set) if 'static_mapping' in subnet_config: for mapping, mapping_config in subnet_config['static_mapping'].items(): + if 'option' in mapping_config: + verify_dnr_dohpath_requirement( + mapping_config['option'], + f'Static-mapping "{mapping}" in subnet "{subnet}"', + ) + if 'ipv6_address' in mapping_config: # Static address must be in subnet if ip_address(mapping_config['ipv6_address']) not in ip_network(subnet):