diff --git a/data/templates/dhcp-server/kea-dhcp6.conf.j2 b/data/templates/dhcp-server/kea-dhcp6.conf.j2 index 3ab21551b..2f0de6b30 100644 --- a/data/templates/dhcp-server/kea-dhcp6.conf.j2 +++ b/data/templates/dhcp-server/kea-dhcp6.conf.j2 @@ -1,52 +1,61 @@ { "Dhcp6": { "interfaces-config": { {% if listen_interface is vyos_defined %} "interfaces": {{ listen_interface | tojson }}, {% else %} "interfaces": [ "*" ], {% endif %} "service-sockets-max-retries": 5, "service-sockets-retry-wait-time": 5000 }, "control-socket": { "socket-type": "unix", "socket-name": "/run/kea/dhcp6-ctrl-socket" }, "lease-database": { "type": "memfile", "persist": true, "name": "{{ lease_file }}" }, "hooks-libraries": [ +{% if disable_route_autoinstall is not vyos_defined %} + { + "library": "/usr/lib/{{ machine }}-linux-gnu/kea/hooks/libdhcp_run_script.so", + "parameters": { + "name": "/usr/libexec/vyos/system/on-dhcpv6-event.sh", + "sync": false + } + }, +{% endif %} { "library": "/usr/lib/{{ machine }}-linux-gnu/kea/hooks/libdhcp_lease_cmds.so", "parameters": {} } ], "option-data": [ {% if global_parameters.name_server is vyos_defined %} { "name": "dns-servers", "code": 23, "space": "dhcp6", "csv-format": true, "data": "{{ global_parameters.name_server | join(", ") }}" }{{ ',' if preference is vyos_defined else '' }} {% endif %} {% if preference is vyos_defined %} { "name": "preference", "code": 7, "space": "dhcp6", "csv-format": true, "data": "{{ preference }}" } {% endif %} ], {% if shared_network_name is vyos_defined %} "shared-networks": {{ shared_network_name | kea6_shared_network_json }} {% endif %} } } diff --git a/interface-definitions/include/listen-interface-multi-broadcast.xml.i b/interface-definitions/include/listen-interface-multi-broadcast.xml.i index b3d5a3ecc..00bd45e6e 100644 --- a/interface-definitions/include/listen-interface-multi-broadcast.xml.i +++ b/interface-definitions/include/listen-interface-multi-broadcast.xml.i @@ -1,18 +1,18 @@ <!-- include start from listen-interface-multi-broadcast.xml.i --> <leafNode name="listen-interface"> <properties> - <help>Interface for DHCP Relay Agent to listen for requests</help> + <help>Interface to listen on</help> <completionHelp> <script>${vyos_completion_dir}/list_interfaces --broadcast</script> </completionHelp> <valueHelp> <format>txt</format> <description>Interface name</description> </valueHelp> <constraint> #include <include/constraint/interface-name.xml.i> </constraint> <multi/> </properties> </leafNode> <!-- include end --> diff --git a/interface-definitions/service_dhcpv6-server.xml.in b/interface-definitions/service_dhcpv6-server.xml.in index 07cbfc85d..28b97a64b 100644 --- a/interface-definitions/service_dhcpv6-server.xml.in +++ b/interface-definitions/service_dhcpv6-server.xml.in @@ -1,291 +1,322 @@ <?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> <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 fb5afc2ce..720bebec3 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -1,358 +1,364 @@ # 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 '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_pools.append({ + 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 dcce30f55..5a831b8a0 100755 --- a/smoketest/scripts/cli/test_service_dhcpv6-server.py +++ b/smoketest/scripts/cli/test_service_dhcpv6-server.py @@ -1,277 +1,286 @@ #!/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 + ['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'], '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)}) + { + '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/conf_mode/service_dhcpv6-server.py b/src/conf_mode/service_dhcpv6-server.py index 214531904..add83eb0d 100755 --- a/src/conf_mode/service_dhcpv6-server.py +++ b/src/conf_mode/service_dhcpv6-server.py @@ -1,239 +1,256 @@ #!/usr/bin/env python3 # # Copyright (C) 2018-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 os from ipaddress import ip_address from ipaddress import ip_network from sys import exit from vyos.config import Config from vyos.template import render from vyos.utils.process import call from vyos.utils.file import chmod_775 from vyos.utils.file import makedir from vyos.utils.file import write_file from vyos.utils.dict import dict_search from vyos.utils.network import is_subnet_connected from vyos import ConfigError from vyos import airbag airbag.enable() config_file = '/run/kea/kea-dhcp6.conf' ctrl_socket = '/run/kea/dhcp6-ctrl-socket' lease_file = '/config/dhcp/dhcp6-leases.csv' user_group = '_kea' def get_config(config=None): if config: conf = config else: conf = Config() base = ['service', 'dhcpv6-server'] if not conf.exists(base): return None dhcpv6 = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) return dhcpv6 def verify(dhcpv6): # bail out early - looks like removal from running config if not dhcpv6 or 'disable' in dhcpv6: return None # If DHCP is enabled we need one share-network if 'shared_network_name' not in dhcpv6: raise ConfigError('No DHCPv6 shared networks configured. At least '\ 'one DHCPv6 shared network must be configured.') # Inspect shared-network/subnet subnets = [] subnet_ids = [] listen_ok = False for network, network_config in dhcpv6['shared_network_name'].items(): # A shared-network requires a subnet definition if 'subnet' not in network_config: raise ConfigError(f'No DHCPv6 lease subnets configured for "{network}". '\ 'At least one lease subnet must be configured for '\ 'each shared network!') for subnet, subnet_config in network_config['subnet'].items(): if 'subnet_id' not in subnet_config: raise ConfigError(f'Unique subnet ID not specified for subnet "{subnet}"') if subnet_config['subnet_id'] in subnet_ids: raise ConfigError(f'Subnet ID for subnet "{subnet}" is not unique') subnet_ids.append(subnet_config['subnet_id']) if 'range' in subnet_config: range6_start = [] range6_stop = [] for num, range_config in subnet_config['range'].items(): if 'start' in range_config: start = range_config['start'] if 'stop' not in range_config: raise ConfigError(f'Range stop address for start "{start}" is not defined!') stop = range_config['stop'] # Start address must be inside network if not ip_address(start) 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): raise ConfigError(f'Range stop address "{stop}" is not in subnet "{subnet}"!') # Stop address must be greater or equal to start address if not ip_address(stop) >= ip_address(start): raise ConfigError(f'Range stop address "{stop}" must be greater then or equal ' \ f'to the range start address "{start}"!') # DHCPv6 range start address must be unique - two ranges can't # start with the same address - makes no sense if start in range6_start: raise ConfigError(f'Conflicting DHCPv6 lease range: '\ f'Pool start address "{start}" defined multipe times!') range6_start.append(start) # DHCPv6 range stop address must be unique - two ranges can't # end with the same address - makes no sense if stop in range6_stop: raise ConfigError(f'Conflicting DHCPv6 lease range: '\ f'Pool stop address "{stop}" defined multipe times!') range6_stop.append(stop) if 'prefix' in range_config: prefix = range_config['prefix'] if not ip_network(prefix).subnet_of(ip_network(subnet)): raise ConfigError(f'Range prefix "{prefix}" is not in subnet "{subnet}"') # Prefix delegation sanity checks if 'prefix_delegation' in subnet_config: if 'prefix' not in subnet_config['prefix_delegation']: raise ConfigError('prefix-delegation prefix not defined!') for prefix, prefix_config in subnet_config['prefix_delegation']['prefix'].items(): if 'delegated_length' not in prefix_config: raise ConfigError(f'Delegated IPv6 prefix length for "{prefix}" '\ f'must be configured') if 'prefix_length' not in prefix_config: raise ConfigError('Length of delegated IPv6 prefix must be configured') if prefix_config['prefix_length'] > prefix_config['delegated_length']: raise ConfigError('Length of delegated IPv6 prefix must be within parent prefix') + if 'excluded_prefix' in prefix_config: + if 'excluded_prefix_length' not in prefix_config: + raise ConfigError('Length of excluded IPv6 prefix must be configured') + + prefix_len = prefix_config['prefix_length'] + prefix_obj = ip_network(f'{prefix}/{prefix_len}') + + excluded_prefix = prefix_config['excluded_prefix'] + excluded_len = prefix_config['excluded_prefix_length'] + excluded_obj = ip_network(f'{excluded_prefix}/{excluded_len}') + + if excluded_len <= prefix_config['delegated_length']: + raise ConfigError('Excluded IPv6 prefix must be smaller than delegated prefix') + + if not excluded_obj.subnet_of(prefix_obj): + raise ConfigError(f'Excluded prefix "{excluded_prefix}" does not exist in the prefix') + # 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 'ipv6_address' in mapping_config: # Static address must be in subnet if ip_address(mapping_config['ipv6_address']) not in ip_network(subnet): raise ConfigError(f'static-mapping address for mapping "{mapping}" is not in subnet "{subnet}"!') if ('mac' not in mapping_config and 'duid' not in mapping_config) or \ ('mac' in mapping_config and 'duid' in mapping_config): raise ConfigError(f'Either MAC address or Client identifier (DUID) is required for ' f'static mapping "{mapping}" within shared-network "{network}, {subnet}"!') if 'option' in subnet_config: if 'vendor_option' in subnet_config['option']: if len(dict_search('option.vendor_option.cisco.tftp_server', subnet_config)) > 2: raise ConfigError(f'No more then two Cisco tftp-servers should be defined for subnet "{subnet}"!') # Subnets must be unique if subnet in subnets: raise ConfigError(f'DHCPv6 subnets must be unique! Subnet {subnet} defined multiple times!') subnets.append(subnet) # DHCPv6 requires at least one configured address range or one static mapping # (FIXME: is not actually checked right now?) # There must be one subnet connected to a listen interface if network is not disabled. if 'disable' not in network_config: if is_subnet_connected(subnet): listen_ok = True # DHCPv6 subnet must not overlap. ISC DHCP also complains about overlapping # subnets: "Warning: subnet 2001:db8::/32 overlaps subnet 2001:db8:1::/32" net = ip_network(subnet) for n in subnets: net2 = ip_network(n) if (net != net2): if net.overlaps(net2): raise ConfigError('DHCPv6 conflicting subnet ranges: {0} overlaps {1}'.format(net, net2)) if not listen_ok: raise ConfigError('None of the DHCPv6 subnets are connected to a subnet6 on '\ 'this machine. At least one subnet6 must be connected such that '\ 'DHCPv6 listens on an interface!') return None def generate(dhcpv6): # bail out early - looks like removal from running config if not dhcpv6 or 'disable' in dhcpv6: return None dhcpv6['lease_file'] = lease_file dhcpv6['machine'] = os.uname().machine # Create directory for lease file if necessary lease_dir = os.path.dirname(lease_file) if not os.path.isdir(lease_dir): makedir(lease_dir, group='vyattacfg') chmod_775(lease_dir) # Create lease file if necessary and let kea own it - 'kea-lfc' expects it that way if not os.path.exists(lease_file): write_file(lease_file, '', user=user_group, group=user_group, mode=0o644) render(config_file, 'dhcp-server/kea-dhcp6.conf.j2', dhcpv6, user=user_group, group=user_group) return None def apply(dhcpv6): # bail out early - looks like removal from running config service_name = 'kea-dhcp6-server.service' if not dhcpv6 or 'disable' in dhcpv6: # DHCP server is removed in the commit call(f'systemctl stop {service_name}') if os.path.exists(config_file): os.unlink(config_file) return None call(f'systemctl restart {service_name}') return None if __name__ == '__main__': try: c = get_config() verify(c) generate(c) apply(c) except ConfigError as e: print(e) exit(1) diff --git a/src/etc/sudoers.d/vyos b/src/etc/sudoers.d/vyos index c099446ba..63a944f41 100644 --- a/src/etc/sudoers.d/vyos +++ b/src/etc/sudoers.d/vyos @@ -1,57 +1,60 @@ # # VyOS modifications to sudo configuration # Defaults syslog_goodpri=info Defaults env_keep+=VYATTA_* # # Command groups allowed for operator users # Cmnd_Alias IPTABLES = /sbin/iptables --list -n,\ /sbin/iptables -L -vn,\ /sbin/iptables -L * -vn,\ /sbin/iptables -t * -L *, \ /sbin/iptables -Z *,\ /sbin/iptables -Z -t nat, \ /sbin/iptables -t * -Z * Cmnd_Alias IP6TABLES = /sbin/ip6tables -t * -Z *, \ /sbin/ip6tables -t * -L * Cmnd_Alias CONNTRACK = /usr/sbin/conntrack -L *, \ /usr/sbin/conntrack -G *, \ /usr/sbin/conntrack -E * Cmnd_Alias IPFLUSH = /sbin/ip route flush cache, \ /sbin/ip route flush cache *,\ /sbin/ip neigh flush to *, \ /sbin/ip neigh flush dev *, \ /sbin/ip -f inet6 route flush cache, \ /sbin/ip -f inet6 route flush cache *,\ /sbin/ip -f inet6 neigh flush to *, \ /sbin/ip -f inet6 neigh flush dev * Cmnd_Alias ETHTOOL = /sbin/ethtool -p *, \ /sbin/ethtool -S *, \ /sbin/ethtool -a *, \ /sbin/ethtool -c *, \ /sbin/ethtool -i * Cmnd_Alias DMIDECODE = /usr/sbin/dmidecode Cmnd_Alias DISK = /usr/bin/lsof, /sbin/fdisk -l *, /sbin/sfdisk -d * Cmnd_Alias DATE = /bin/date, /usr/sbin/ntpdate Cmnd_Alias PPPOE_CMDS = /sbin/pppd, /sbin/poff, /usr/sbin/pppstats Cmnd_Alias PCAPTURE = /usr/bin/tcpdump Cmnd_Alias HWINFO = /usr/bin/lspci Cmnd_Alias FORCE_CLUSTER = /usr/share/heartbeat/hb_takeover, \ /usr/share/heartbeat/hb_standby Cmnd_Alias DIAGNOSTICS = /bin/ip vrf exec * /bin/ping *, \ /bin/ip vrf exec * /bin/traceroute *, \ /bin/ip vrf exec * /usr/bin/mtr *, \ /usr/libexec/vyos/op_mode/* +Cmnd_Alias KEA_IP6_ROUTES = /sbin/ip -6 route replace *,\ + /sbin/ip -6 route del * %operator ALL=NOPASSWD: DATE, IPTABLES, ETHTOOL, IPFLUSH, HWINFO, \ PPPOE_CMDS, PCAPTURE, /usr/sbin/wanpipemon, \ DMIDECODE, DISK, CONNTRACK, IP6TABLES, \ FORCE_CLUSTER, DIAGNOSTICS # Allow any user to run files in sudo-users %users ALL=NOPASSWD: /opt/vyatta/bin/sudo-users/ # Allow members of group sudo to execute any command %sudo ALL=NOPASSWD: ALL +_kea ALL=NOPASSWD: KEA_IP6_ROUTES diff --git a/src/system/on-dhcp-event.sh b/src/system/on-dhcp-event.sh index 52fadd428..47c276270 100755 --- a/src/system/on-dhcp-event.sh +++ b/src/system/on-dhcp-event.sh @@ -1,90 +1,98 @@ #!/bin/bash - -# This script came from ubnt.com forum user "bradd" in the following post -# http://community.ubnt.com/t5/EdgeMAX/Automatic-DNS-resolution-of-DHCP-client-names/td-p/651311 -# It has been modified by Ubiquiti to update the /etc/host file -# instead of adding to the CLI. -# Thanks to forum user "itsmarcos" for bug fix & improvements -# Thanks to forum user "ruudboon" for multiple domain fix -# Thanks to forum user "chibby85" for expire patch and static-mapping +# +# 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/>. +# +# if [ $# -lt 1 ]; then echo Invalid args logger -s -t on-dhcp-event "Invalid args \"$@\"" exit 1 fi action=$1 hostsd_client="/usr/bin/vyos-hostsd-client" get_subnet_domain_name () { python3 <<EOF from vyos.kea import kea_get_active_config from vyos.utils.dict import dict_search_args config = kea_get_active_config('4') shared_networks = dict_search_args(config, 'arguments', f'Dhcp4', 'shared-networks') found = False if shared_networks: for network in shared_networks: for subnet in network[f'subnet4']: if subnet['id'] == $1: for option in subnet['option-data']: if option['name'] == 'domain-name': print(option['data']) found = True if not found: for option in network['option-data']: if option['name'] == 'domain-name': print(option['data']) EOF } case "$action" in lease4_renew|lease4_recover) exit 0 ;; lease4_release|lease4_expire|lease4_decline) # delete mapping for released/declined address client_ip=$LEASE4_ADDRESS $hostsd_client --delete-hosts --tag "dhcp-server-$client_ip" --apply exit 0 ;; leases4_committed) # process committed leases (added/renewed/recovered) for ((i = 0; i < $LEASES4_SIZE; i++)); do client_ip_var="LEASES4_AT${i}_ADDRESS" client_mac_var="LEASES4_AT${i}_HWADDR" client_name_var="LEASES4_AT${i}_HOSTNAME" client_subnet_id_var="LEASES4_AT${i}_SUBNET_ID" client_ip=${!client_ip_var} client_mac=${!client_mac_var} client_name=${!client_name_var%.} client_subnet_id=${!client_subnet_id_var} if [ -z "$client_name" ]; then logger -s -t on-dhcp-event "Client name was empty, using MAC \"$client_mac\" instead" client_name=$(echo "host-$client_mac" | tr : -) fi client_domain=$(get_subnet_domain_name $client_subnet_id) if [[ -n "$client_domain" ]] && ! [[ $client_name =~ .*$client_domain$ ]]; then client_name="$client_name.$client_domain" fi $hostsd_client --add-hosts "$client_name,$client_ip" --tag "dhcp-server-$client_ip" --apply done exit 0 ;; *) logger -s -t on-dhcp-event "Invalid command \"$1\"" exit 1 ;; esac diff --git a/src/system/on-dhcpv6-event.sh b/src/system/on-dhcpv6-event.sh new file mode 100755 index 000000000..cbb370999 --- /dev/null +++ b/src/system/on-dhcpv6-event.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# +# 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/>. +# +# + +if [ $# -lt 1 ]; then + echo Invalid args + logger -s -t on-dhcpv6-event "Invalid args \"$@\"" + exit 1 +fi + +action=$1 + +case "$action" in + lease6_renew|lease6_recover) + exit 0 + ;; + + lease6_release|lease6_expire|lease6_decline) + ifname=$QUERY6_IFACE_NAME + lease_addr=$LEASE6_ADDRESS + lease_prefix_len=$LEASE6_PREFIX_LEN + + if [[ "$LEASE6_TYPE" != "IA_PD" ]]; then + exit 0 + fi + + logger -s -t on-dhcpv6-event "Processing route deletion for ${lease_addr}/${lease_prefix_len}" + route_cmd="sudo -n /sbin/ip -6 route del ${lease_addr}/${lease_prefix_len}" + + # the ifname is not always present, like in LEASE6_VALID_LIFETIME=0 updates, + # but 'route del' works either way. Use interface only if there is one. + if [[ "$ifname" != "" ]]; then + route_cmd+=" dev ${ifname}" + fi + route_cmd+=" proto static" + eval "$route_cmd" + + exit 0 + ;; + + leases6_committed) + for ((i = 0; i < $LEASES6_SIZE; i++)); do + ifname=$QUERY6_IFACE_NAME + requester_link_local=$QUERY6_REMOTE_ADDR + lease_type_var="LEASES6_AT${i}_TYPE" + lease_ip_var="LEASES6_AT${i}_ADDRESS" + lease_prefix_len_var="LEASES6_AT${i}_PREFIX_LEN" + + lease_type=${!lease_type_var} + + if [[ "$lease_type" != "IA_PD" ]]; then + continue + fi + + lease_ip=${!lease_ip_var} + lease_prefix_len=${!lease_prefix_len_var} + + logger -s -t on-dhcpv6-event "Processing PD route for ${lease_addr}/${lease_prefix_len}. Link local: ${requester_link_local} ifname: ${ifname}" + + sudo -n /sbin/ip -6 route replace ${lease_ip}/${lease_prefix_len} \ + via ${requester_link_local} \ + dev ${ifname} \ + proto static + done + + exit 0 + ;; + + *) + logger -s -t on-dhcpv6-event "Invalid command \"$1\"" + exit 1 + ;; +esac