diff --git a/interface-definitions/include/version/dhcpv6-server-version.xml.i b/interface-definitions/include/version/dhcpv6-server-version.xml.i index bfef27b77..1f30368a3 100644 --- a/interface-definitions/include/version/dhcpv6-server-version.xml.i +++ b/interface-definitions/include/version/dhcpv6-server-version.xml.i @@ -1,3 +1,3 @@ <!-- include start from include/version/dhcpv6-server-version.xml.i --> -<syntaxVersion component='dhcpv6-server' version='4'></syntaxVersion> +<syntaxVersion component='dhcpv6-server' version='5'></syntaxVersion> <!-- include end --> diff --git a/interface-definitions/service_dhcpv6-server.xml.in b/interface-definitions/service_dhcpv6-server.xml.in index 28b97a64b..daca7b43f 100644 --- a/interface-definitions/service_dhcpv6-server.xml.in +++ b/interface-definitions/service_dhcpv6-server.xml.in @@ -1,322 +1,337 @@ <?xml version="1.0"?> <interfaceDefinition> <node name="service"> <children> <node name="dhcpv6-server" owner="${vyos_conf_scripts_dir}/service_dhcpv6-server.py"> <properties> <help>DHCP for IPv6 (DHCPv6) server</help> <priority>900</priority> </properties> <children> #include <include/generic-disable-node.xml.i> #include <include/listen-interface-multi-broadcast.xml.i> <leafNode name="disable-route-autoinstall"> <properties> <help>Do not install routes for delegated prefixes</help> <valueless/> </properties> </leafNode> <node name="global-parameters"> <properties> <help>Additional global parameters for DHCPv6 server</help> </properties> <children> #include <include/name-server-ipv6.xml.i> </children> </node> <leafNode name="preference"> <properties> <help>Preference of this DHCPv6 server compared with others</help> <valueHelp> <format>u32:0-255</format> <description>DHCPv6 server preference (0-255)</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 0-255"/> </constraint> <constraintErrorMessage>Preference must be between 0 and 255</constraintErrorMessage> </properties> </leafNode> <tagNode name="shared-network-name"> <properties> <help>DHCPv6 shared network name</help> <constraint> #include <include/constraint/alpha-numeric-hyphen-underscore-dot.xml.i> </constraint> <constraintErrorMessage>Invalid DHCPv6 shared network name. May only contain letters, numbers and .-_</constraintErrorMessage> </properties> <children> #include <include/generic-disable-node.xml.i> #include <include/generic-description.xml.i> <leafNode name="interface"> <properties> <help>Optional interface for this shared network to accept requests from</help> <completionHelp> <script>${vyos_completion_dir}/list_interfaces</script> </completionHelp> <valueHelp> <format>txt</format> <description>Interface name</description> </valueHelp> <constraint> #include <include/constraint/interface-name.xml.i> </constraint> </properties> </leafNode> <node name="common-options"> <properties> <help>Common options to distribute to all clients, including stateless clients</help> </properties> <children> <leafNode name="info-refresh-time"> <properties> <help>Time (in seconds) that stateless clients should wait between refreshing the information they were given</help> <valueHelp> <format>u32:1-4294967295</format> <description>DHCPv6 information refresh time</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 1-4294967295"/> </constraint> </properties> </leafNode> #include <include/dhcp/domain-search.xml.i> #include <include/name-server-ipv6.xml.i> </children> </node> <tagNode name="subnet"> <properties> <help>IPv6 DHCP subnet for this shared network</help> <valueHelp> <format>ipv6net</format> <description>IPv6 address and prefix length</description> </valueHelp> <constraint> <validator name="ipv6-prefix"/> </constraint> </properties> <children> #include <include/dhcp/option-v6.xml.i> + <leafNode name="interface"> + <properties> + <help>Optional interface for this subnet to accept requests from</help> + <completionHelp> + <script>${vyos_completion_dir}/list_interfaces</script> + </completionHelp> + <valueHelp> + <format>txt</format> + <description>Interface name</description> + </valueHelp> + <constraint> + #include <include/constraint/interface-name.xml.i> + </constraint> + </properties> + </leafNode> <tagNode name="range"> <properties> <help>Parameters setting ranges for assigning IPv6 addresses</help> <constraint> #include <include/constraint/alpha-numeric-hyphen-underscore-dot.xml.i> </constraint> <constraintErrorMessage>Invalid range name, may only be alphanumeric, dot and hyphen</constraintErrorMessage> </properties> <children> #include <include/dhcp/option-v6.xml.i> <leafNode name="prefix"> <properties> <help>IPv6 prefix defining range of addresses to assign</help> <valueHelp> <format>ipv6net</format> <description>IPv6 address and prefix length</description> </valueHelp> <constraint> <validator name="ipv6-prefix"/> </constraint> </properties> </leafNode> <leafNode name="start"> <properties> <help>First in range of consecutive IPv6 addresses to assign</help> <valueHelp> <format>ipv6</format> <description>IPv6 address</description> </valueHelp> <constraint> <validator name="ipv6-address"/> </constraint> </properties> </leafNode> <leafNode name="stop"> <properties> <help>Last in range of consecutive IPv6 addresses</help> <valueHelp> <format>ipv6</format> <description>IPv6 address</description> </valueHelp> <constraint> <validator name="ipv6-address"/> </constraint> </properties> </leafNode> </children> </tagNode> <node name="lease-time"> <properties> <help>Parameters relating to the lease time</help> </properties> <children> <leafNode name="default"> <properties> <help>Default time (in seconds) that will be assigned to a lease</help> <valueHelp> <format>u32:1-4294967295</format> <description>DHCPv6 valid lifetime</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 1-4294967295"/> </constraint> </properties> </leafNode> <leafNode name="maximum"> <properties> <help>Maximum time (in seconds) that will be assigned to a lease</help> <valueHelp> <format>u32:1-4294967295</format> <description>Maximum lease time in seconds</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 1-4294967295"/> </constraint> </properties> </leafNode> <leafNode name="minimum"> <properties> <help>Minimum time (in seconds) that will be assigned to a lease</help> <valueHelp> <format>u32:1-4294967295</format> <description>Minimum lease time in seconds</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 1-4294967295"/> </constraint> </properties> </leafNode> </children> </node> <node name="prefix-delegation"> <properties> <help>Parameters relating to IPv6 prefix delegation</help> </properties> <children> <tagNode name="prefix"> <properties> <help>IPv6 prefix to be used in prefix delegation</help> <valueHelp> <format>ipv6</format> <description>IPv6 prefix used in prefix delegation</description> </valueHelp> <constraint> <validator name="ipv6-address"/> </constraint> </properties> <children> <leafNode name="prefix-length"> <properties> <help>Length in bits of prefix</help> <valueHelp> <format>u32:32-64</format> <description>Prefix length (32-64)</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 32-64"/> </constraint> <constraintErrorMessage>Prefix length must be between 32 and 64</constraintErrorMessage> </properties> </leafNode> <leafNode name="delegated-length"> <properties> <help>Length in bits of prefixes to be delegated</help> <valueHelp> <format>u32:32-64</format> <description>Delegated prefix length (32-64)</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 32-96"/> </constraint> <constraintErrorMessage>Delegated prefix length must be between 32 and 96</constraintErrorMessage> </properties> </leafNode> <leafNode name="excluded-prefix"> <properties> <help>IPv6 prefix to be excluded from prefix delegation</help> <valueHelp> <format>ipv6</format> <description>IPv6 prefix excluded from prefix delegation</description> </valueHelp> <constraint> <validator name="ipv6-address"/> </constraint> </properties> </leafNode> <leafNode name="excluded-prefix-length"> <properties> <help>Length in bits of excluded prefix</help> <valueHelp> <format>u32:33-64</format> <description>Excluded prefix length (33-128)</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 33-128"/> </constraint> <constraintErrorMessage>Prefix length must be between 33 and 128</constraintErrorMessage> </properties> </leafNode> </children> </tagNode> </children> </node> <tagNode name="static-mapping"> <properties> <help>Hostname for static mapping reservation</help> <constraint> <validator name="fqdn"/> </constraint> <constraintErrorMessage>Invalid static mapping hostname</constraintErrorMessage> </properties> <children> #include <include/dhcp/option-v6.xml.i> #include <include/generic-disable-node.xml.i> #include <include/interface/mac.xml.i> #include <include/interface/duid.xml.i> <leafNode name="ipv6-address"> <properties> <help>Client IPv6 address for this static mapping</help> <valueHelp> <format>ipv6</format> <description>IPv6 address for this static mapping</description> </valueHelp> <constraint> <validator name="ipv6-address"/> </constraint> </properties> </leafNode> <leafNode name="ipv6-prefix"> <properties> <help>Client IPv6 prefix for this static mapping</help> <valueHelp> <format>ipv6net</format> <description>IPv6 prefix for this static mapping</description> </valueHelp> <constraint> <validator name="ipv6-prefix"/> </constraint> </properties> </leafNode> </children> </tagNode> <leafNode name="subnet-id"> <properties> <help>Unique ID mapped to leases in the lease file</help> <valueHelp> <format>u32</format> <description>Unique subnet ID</description> </valueHelp> <constraint> <validator name="numeric" argument="--range 1-4294967295"/> </constraint> </properties> </leafNode> </children> </tagNode> </children> </tagNode> </children> </node> </children> </node> </interfaceDefinition> diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 720bebec3..7365c1f02 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -1,364 +1,367 @@ # Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library. If not, see <http://www.gnu.org/licenses/>. import json import os import socket from datetime import datetime from vyos.template import is_ipv6 from vyos.template import isc_static_route from vyos.template import netmask_from_cidr from vyos.utils.dict import dict_search_args from vyos.utils.file import file_permissions from vyos.utils.file import read_file from vyos.utils.process import run kea4_options = { 'name_server': 'domain-name-servers', 'domain_name': 'domain-name', 'domain_search': 'domain-search', 'ntp_server': 'ntp-servers', 'pop_server': 'pop-server', 'smtp_server': 'smtp-server', 'time_server': 'time-servers', 'wins_server': 'netbios-name-servers', 'default_router': 'routers', 'server_identifier': 'dhcp-server-identifier', 'tftp_server_name': 'tftp-server-name', 'bootfile_size': 'boot-size', 'time_offset': 'time-offset', 'wpad_url': 'wpad-url', 'ipv6_only_preferred': 'v6-only-preferred', 'captive_portal': 'v4-captive-portal' } kea6_options = { 'info_refresh_time': 'information-refresh-time', 'name_server': 'dns-servers', 'domain_search': 'domain-search', 'nis_domain': 'nis-domain-name', 'nis_server': 'nis-servers', 'nisplus_domain': 'nisp-domain-name', 'nisplus_server': 'nisp-servers', 'sntp_server': 'sntp-servers', 'captive_portal': 'v6-captive-portal' } def kea_parse_options(config): options = [] for node, option_name in kea4_options.items(): if node not in config: continue value = ", ".join(config[node]) if isinstance(config[node], list) else config[node] options.append({'name': option_name, 'data': value}) if 'client_prefix_length' in config: options.append({'name': 'subnet-mask', 'data': netmask_from_cidr('0.0.0.0/' + config['client_prefix_length'])}) if 'ip_forwarding' in config: options.append({'name': 'ip-forwarding', 'data': "true"}) if 'static_route' in config: default_route = '' if 'default_router' in config: default_route = isc_static_route('0.0.0.0/0', config['default_router']) routes = [isc_static_route(route, route_options['next_hop']) for route, route_options in config['static_route'].items()] options.append({'name': 'rfc3442-static-route', 'data': ", ".join(routes if not default_route else routes + [default_route])}) options.append({'name': 'windows-static-route', 'data': ", ".join(routes)}) if 'time_zone' in config: with open("/usr/share/zoneinfo/" + config['time_zone'], "rb") as f: tz_string = f.read().split(b"\n")[-2].decode("utf-8") options.append({'name': 'pcode', 'data': tz_string}) options.append({'name': 'tcode', 'data': config['time_zone']}) unifi_controller = dict_search_args(config, 'vendor_option', 'ubiquiti', 'unifi_controller') if unifi_controller: options.append({ 'name': 'unifi-controller', 'data': unifi_controller, 'space': 'ubnt' }) return options def kea_parse_subnet(subnet, config): out = {'subnet': subnet, 'id': int(config['subnet_id'])} options = [] if 'option' in config: out['option-data'] = kea_parse_options(config['option']) if 'bootfile_name' in config['option']: out['boot-file-name'] = config['option']['bootfile_name'] if 'bootfile_server' in config['option']: out['next-server'] = config['option']['bootfile_server'] if 'lease' in config: out['valid-lifetime'] = int(config['lease']) out['max-valid-lifetime'] = int(config['lease']) if 'range' in config: pools = [] for num, range_config in config['range'].items(): start, stop = range_config['start'], range_config['stop'] pool = { 'pool': f'{start} - {stop}' } if 'option' in range_config: pool['option-data'] = kea_parse_options(range_config['option']) if 'bootfile_name' in range_config['option']: pool['boot-file-name'] = range_config['option']['bootfile_name'] if 'bootfile_server' in range_config['option']: pool['next-server'] = range_config['option']['bootfile_server'] pools.append(pool) out['pools'] = pools if 'static_mapping' in config: reservations = [] for host, host_config in config['static_mapping'].items(): if 'disable' in host_config: continue reservation = { 'hostname': host, } if 'mac' in host_config: reservation['hw-address'] = host_config['mac'] if 'duid' in host_config: reservation['duid'] = host_config['duid'] if 'ip_address' in host_config: reservation['ip-address'] = host_config['ip_address'] if 'option' in host_config: reservation['option-data'] = kea_parse_options(host_config['option']) if 'bootfile_name' in host_config['option']: reservation['boot-file-name'] = host_config['option']['bootfile_name'] if 'bootfile_server' in host_config['option']: reservation['next-server'] = host_config['option']['bootfile_server'] reservations.append(reservation) out['reservations'] = reservations return out def kea6_parse_options(config): options = [] for node, option_name in kea6_options.items(): if node not in config: continue value = ", ".join(config[node]) if isinstance(config[node], list) else config[node] options.append({'name': option_name, 'data': value}) if 'sip_server' in config: sip_servers = config['sip_server'] addrs = [] hosts = [] for server in sip_servers: if is_ipv6(server): addrs.append(server) else: hosts.append(server) if addrs: options.append({'name': 'sip-server-addr', 'data': ", ".join(addrs)}) if hosts: options.append({'name': 'sip-server-dns', 'data': ", ".join(hosts)}) cisco_tftp = dict_search_args(config, 'vendor_option', 'cisco', 'tftp-server') if cisco_tftp: options.append({'name': 'tftp-servers', 'code': 2, 'space': 'cisco', 'data': cisco_tftp}) return options def kea6_parse_subnet(subnet, config): out = {'subnet': subnet, 'id': int(config['subnet_id'])} if 'option' in config: out['option-data'] = kea6_parse_options(config['option']) + if 'interface' in config: + out['interface'] = config['interface'] + if 'range' in config: pools = [] for num, range_config in config['range'].items(): pool = {} if 'prefix' in range_config: pool['pool'] = range_config['prefix'] if 'start' in range_config: start = range_config['start'] stop = range_config['stop'] pool['pool'] = f'{start} - {stop}' if 'option' in range_config: pool['option-data'] = kea6_parse_options(range_config['option']) pools.append(pool) out['pools'] = pools if 'prefix_delegation' in config: pd_pools = [] if 'prefix' in config['prefix_delegation']: for prefix, pd_conf in config['prefix_delegation']['prefix'].items(): pd_pool = { 'prefix': prefix, 'prefix-len': int(pd_conf['prefix_length']), 'delegated-len': int(pd_conf['delegated_length']) } if 'excluded_prefix' in pd_conf: pd_pool['excluded-prefix'] = pd_conf['excluded_prefix'] pd_pool['excluded-prefix-len'] = int(pd_conf['excluded_prefix_length']) pd_pools.append(pd_pool) out['pd-pools'] = pd_pools if 'lease_time' in config: if 'default' in config['lease_time']: out['valid-lifetime'] = int(config['lease_time']['default']) if 'maximum' in config['lease_time']: out['max-valid-lifetime'] = int(config['lease_time']['maximum']) if 'minimum' in config['lease_time']: out['min-valid-lifetime'] = int(config['lease_time']['minimum']) if 'static_mapping' in config: reservations = [] for host, host_config in config['static_mapping'].items(): if 'disable' in host_config: continue reservation = { 'hostname': host } if 'mac' in host_config: reservation['hw-address'] = host_config['mac'] if 'duid' in host_config: reservation['duid'] = host_config['duid'] if 'ipv6_address' in host_config: reservation['ip-addresses'] = [ host_config['ipv6_address'] ] if 'ipv6_prefix' in host_config: reservation['prefixes'] = [ host_config['ipv6_prefix'] ] if 'option' in host_config: reservation['option-data'] = kea6_parse_options(host_config['option']) reservations.append(reservation) out['reservations'] = reservations return out def kea_parse_leases(lease_path): contents = read_file(lease_path) lines = contents.split("\n") output = [] if len(lines) < 2: return output headers = lines[0].split(",") for line in lines[1:]: line_out = dict(zip(headers, line.split(","))) lifetime = int(line_out['valid_lifetime']) expiry = int(line_out['expire']) line_out['start_timestamp'] = datetime.utcfromtimestamp(expiry - lifetime) line_out['expire_timestamp'] = datetime.utcfromtimestamp(expiry) if expiry else None output.append(line_out) return output def _ctrl_socket_command(path, command, args=None): if not os.path.exists(path): return None if file_permissions(path) != '0775': run(f'sudo chmod 775 {path}') with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: sock.connect(path) payload = {'command': command} if args: payload['arguments'] = args sock.send(bytes(json.dumps(payload), 'utf-8')) result = b'' while True: data = sock.recv(4096) result += data if len(data) < 4096: break return json.loads(result.decode('utf-8')) def kea_get_active_config(inet): ctrl_socket = f'/run/kea/dhcp{inet}-ctrl-socket' config = _ctrl_socket_command(ctrl_socket, 'config-get') if not config or 'result' not in config or config['result'] != 0: return None return config def kea_get_pool_from_subnet_id(config, inet, subnet_id): shared_networks = dict_search_args(config, 'arguments', f'Dhcp{inet}', 'shared-networks') if not shared_networks: return None for network in shared_networks: if f'subnet{inet}' not in network: continue for subnet in network[f'subnet{inet}']: if 'id' in subnet and int(subnet['id']) == int(subnet_id): return network['name'] return None diff --git a/smoketest/scripts/cli/test_service_dhcpv6-server.py b/smoketest/scripts/cli/test_service_dhcpv6-server.py index 5a831b8a0..6ecf6c1cf 100755 --- a/smoketest/scripts/cli/test_service_dhcpv6-server.py +++ b/smoketest/scripts/cli/test_service_dhcpv6-server.py @@ -1,286 +1,288 @@ #!/usr/bin/env python3 # # Copyright (C) 2020-2023 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. import unittest from json import loads from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError from vyos.template import inc_ip from vyos.utils.process import process_named_running from vyos.utils.file import read_file PROCESS_NAME = 'kea-dhcp6' KEA6_CONF = '/run/kea/kea-dhcp6.conf' base_path = ['service', 'dhcpv6-server'] subnet = '2001:db8:f00::/64' dns_1 = '2001:db8::1' dns_2 = '2001:db8::2' domain = 'vyos.net' nis_servers = ['2001:db8:ffff::1', '2001:db8:ffff::2'] interface = 'eth0' interface_addr = inc_ip(subnet, 1) + '/64' class TestServiceDHCPv6Server(VyOSUnitTestSHIM.TestCase): @classmethod def setUpClass(cls): super(TestServiceDHCPv6Server, cls).setUpClass() # Clear out current configuration to allow running this test on a live system cls.cli_delete(cls, base_path) cls.cli_set(cls, ['interfaces', 'ethernet', interface, 'address', interface_addr]) @classmethod def tearDownClass(cls): cls.cli_delete(cls, ['interfaces', 'ethernet', interface, 'address', interface_addr]) cls.cli_commit(cls) super(TestServiceDHCPv6Server, cls).tearDownClass() def tearDown(self): self.cli_delete(base_path) self.cli_commit() def walk_path(self, obj, path): current = obj for i, key in enumerate(path): if isinstance(key, str): self.assertTrue(isinstance(current, dict), msg=f'Failed path: {path}') self.assertTrue(key in current, msg=f'Failed path: {path}') elif isinstance(key, int): self.assertTrue(isinstance(current, list), msg=f'Failed path: {path}') self.assertTrue(0 <= key < len(current), msg=f'Failed path: {path}') else: assert False, "Invalid type" current = current[key] return current def verify_config_object(self, obj, path, value): base_obj = self.walk_path(obj, path) self.assertTrue(isinstance(base_obj, list)) self.assertTrue(any(True for v in base_obj if v == value)) def verify_config_value(self, obj, path, key, value): base_obj = self.walk_path(obj, path) if isinstance(base_obj, list): self.assertTrue(any(True for v in base_obj if key in v and v[key] == value)) elif isinstance(base_obj, dict): self.assertTrue(key in base_obj) self.assertEqual(base_obj[key], value) def test_single_pool(self): shared_net_name = 'SMOKE-1' search_domains = ['foo.vyos.net', 'bar.vyos.net'] lease_time = '1200' max_lease_time = '72000' min_lease_time = '600' preference = '10' sip_server = 'sip.vyos.net' sntp_server = inc_ip(subnet, 100) range_start = inc_ip(subnet, 256) # ::100 range_stop = inc_ip(subnet, 65535) # ::ffff pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] self.cli_set(base_path + ['preference', preference]) + self.cli_set(pool + ['interface', interface]) self.cli_set(pool + ['subnet-id', '1']) # we use the first subnet IP address as default gateway self.cli_set(pool + ['lease-time', 'default', lease_time]) self.cli_set(pool + ['lease-time', 'maximum', max_lease_time]) self.cli_set(pool + ['lease-time', 'minimum', min_lease_time]) self.cli_set(pool + ['option', 'name-server', dns_1]) self.cli_set(pool + ['option', 'name-server', dns_2]) self.cli_set(pool + ['option', 'name-server', dns_2]) self.cli_set(pool + ['option', 'nis-domain', domain]) self.cli_set(pool + ['option', 'nisplus-domain', domain]) self.cli_set(pool + ['option', 'sip-server', sip_server]) self.cli_set(pool + ['option', 'sntp-server', sntp_server]) self.cli_set(pool + ['range', '1', 'start', range_start]) self.cli_set(pool + ['range', '1', 'stop', range_stop]) for server in nis_servers: self.cli_set(pool + ['option', 'nis-server', server]) self.cli_set(pool + ['option', 'nisplus-server', server]) for search in search_domains: self.cli_set(pool + ['option', 'domain-search', search]) client_base = 1 for client in ['client1', 'client2', 'client3']: duid = f'00:01:00:01:12:34:56:78:aa:bb:cc:dd:ee:{client_base:02}' self.cli_set(pool + ['static-mapping', client, 'duid', duid]) self.cli_set(pool + ['static-mapping', client, 'ipv6-address', inc_ip(subnet, client_base)]) self.cli_set(pool + ['static-mapping', client, 'ipv6-prefix', inc_ip(subnet, client_base << 64) + '/64']) client_base += 1 # cannot have both mac-address and duid set with self.assertRaises(ConfigSessionError): self.cli_set(pool + ['static-mapping', 'client1', 'mac', '00:50:00:00:00:11']) self.cli_commit() self.cli_delete(pool + ['static-mapping', 'client1', 'mac']) # commit changes self.cli_commit() config = read_file(KEA6_CONF) obj = loads(config) self.verify_config_value(obj, ['Dhcp6', 'shared-networks'], 'name', shared_net_name) self.verify_config_value(obj, ['Dhcp6', 'shared-networks', 0, 'subnet6'], 'subnet', subnet) + self.verify_config_value(obj, ['Dhcp6', 'shared-networks', 0, 'subnet6'], 'interface', interface) self.verify_config_value(obj, ['Dhcp6', 'shared-networks', 0, 'subnet6'], 'id', 1) self.verify_config_value(obj, ['Dhcp6', 'shared-networks', 0, 'subnet6'], 'valid-lifetime', int(lease_time)) self.verify_config_value(obj, ['Dhcp6', 'shared-networks', 0, 'subnet6'], 'min-valid-lifetime', int(min_lease_time)) self.verify_config_value(obj, ['Dhcp6', 'shared-networks', 0, 'subnet6'], 'max-valid-lifetime', int(max_lease_time)) # Verify options self.verify_config_object( obj, ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], {'name': 'dns-servers', 'data': f'{dns_1}, {dns_2}'}) self.verify_config_object( obj, ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], {'name': 'domain-search', 'data': ", ".join(search_domains)}) self.verify_config_object( obj, ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], {'name': 'nis-domain-name', 'data': domain}) self.verify_config_object( obj, ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], {'name': 'nis-servers', 'data': ", ".join(nis_servers)}) self.verify_config_object( obj, ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], {'name': 'nisp-domain-name', 'data': domain}) self.verify_config_object( obj, ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], {'name': 'nisp-servers', 'data': ", ".join(nis_servers)}) self.verify_config_object( obj, ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], {'name': 'sntp-servers', 'data': sntp_server}) self.verify_config_object( obj, ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], {'name': 'sip-server-dns', 'data': sip_server}) # Verify pools self.verify_config_object( obj, ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'pools'], {'pool': f'{range_start} - {range_stop}'}) client_base = 1 for client in ['client1', 'client2', 'client3']: duid = f'00:01:00:01:12:34:56:78:aa:bb:cc:dd:ee:{client_base:02}' ip = inc_ip(subnet, client_base) prefix = inc_ip(subnet, client_base << 64) + '/64' self.verify_config_object( obj, ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'reservations'], {'hostname': client, 'duid': duid, 'ip-addresses': [ip], 'prefixes': [prefix]}) client_base += 1 # Check for running process self.assertTrue(process_named_running(PROCESS_NAME)) def test_prefix_delegation(self): shared_net_name = 'SMOKE-2' range_start = inc_ip(subnet, 256) # ::100 range_stop = inc_ip(subnet, 65535) # ::ffff delegate_start = '2001:db8:ee::' delegate_len = '64' prefix_len = '56' exclude_len = '66' pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] self.cli_set(pool + ['subnet-id', '1']) self.cli_set(pool + ['range', '1', 'start', range_start]) self.cli_set(pool + ['range', '1', 'stop', range_stop]) self.cli_set(pool + ['prefix-delegation', 'prefix', delegate_start, 'delegated-length', delegate_len]) self.cli_set(pool + ['prefix-delegation', 'prefix', delegate_start, 'prefix-length', prefix_len]) self.cli_set(pool + ['prefix-delegation', 'prefix', delegate_start, 'excluded-prefix', delegate_start]) self.cli_set(pool + ['prefix-delegation', 'prefix', delegate_start, 'excluded-prefix-length', exclude_len]) # commit changes self.cli_commit() config = read_file(KEA6_CONF) obj = loads(config) self.verify_config_value(obj, ['Dhcp6', 'shared-networks'], 'name', shared_net_name) self.verify_config_value(obj, ['Dhcp6', 'shared-networks', 0, 'subnet6'], 'subnet', subnet) # Verify pools self.verify_config_object( obj, ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'pools'], {'pool': f'{range_start} - {range_stop}'}) self.verify_config_object( obj, ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'pd-pools'], { 'prefix': delegate_start, 'prefix-len': int(prefix_len), 'delegated-len': int(delegate_len), 'excluded-prefix': delegate_start, 'excluded-prefix-len': int(exclude_len) }) # Check for running process self.assertTrue(process_named_running(PROCESS_NAME)) def test_global_nameserver(self): shared_net_name = 'SMOKE-3' ns_global_1 = '2001:db8::1111' ns_global_2 = '2001:db8::2222' self.cli_set(base_path + ['global-parameters', 'name-server', ns_global_1]) self.cli_set(base_path + ['global-parameters', 'name-server', ns_global_2]) self.cli_set(base_path + ['shared-network-name', shared_net_name, 'subnet', subnet, 'subnet-id', '1']) # commit changes self.cli_commit() config = read_file(KEA6_CONF) obj = loads(config) self.verify_config_value(obj, ['Dhcp6', 'shared-networks'], 'name', shared_net_name) self.verify_config_value(obj, ['Dhcp6', 'shared-networks', 0, 'subnet6'], 'subnet', subnet) self.verify_config_value(obj, ['Dhcp6', 'shared-networks', 0, 'subnet6'], 'id', 1) self.verify_config_object( obj, ['Dhcp6', 'option-data'], {'name': 'dns-servers', "code": 23, "space": "dhcp6", "csv-format": True, 'data': f'{ns_global_1}, {ns_global_2}'}) # Check for running process self.assertTrue(process_named_running(PROCESS_NAME)) if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/migration-scripts/dhcpv6-server/4-to-5 b/src/migration-scripts/dhcpv6-server/4-to-5 new file mode 100755 index 000000000..e808edbe0 --- /dev/null +++ b/src/migration-scripts/dhcpv6-server/4-to-5 @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# T5993: Check if subnet is locally accessible and assign interface to subnet + +import sys +from ipaddress import ip_network +from vyos.configtree import ConfigTree + +if (len(sys.argv) < 1): + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +base = ['service', 'dhcpv6-server', 'shared-network-name'] +config = ConfigTree(config_file) + +if not config.exists(base): + # Nothing to do + exit(0) + +def find_subnet_interface(subnet): + subnet_net = ip_network(subnet) + + for iftype in config.list_nodes(['interfaces']): + for ifname in config.list_nodes(['interfaces', iftype]): + if_base = ['interfaces', iftype, ifname] + + if config.exists(if_base + ['address']): + for addr in config.return_values(if_base + ['address']): + if ip_network(addr, strict=False) == subnet_net: + return ifname + + return False + +for network in config.list_nodes(base): + if not config.exists(base + [network, 'subnet']): + continue + + for subnet in config.list_nodes(base + [network, 'subnet']): + subnet_interface = find_subnet_interface(subnet) + + if subnet_interface: + config.set(base + [network, 'subnet', subnet, 'interface'], value=subnet_interface) + +try: + with open(file_name, 'w') as f: + f.write(config.to_string()) +except OSError as e: + print("Failed to save the modified config: {}".format(e)) + exit(1)