From d95200e96763e4a7ed02577b1b177c84abb77838 Mon Sep 17 00:00:00 2001 From: sarthurdev <965089+sarthurdev@users.noreply.github.com> Date: Fri, 16 Dec 2022 11:41:33 +0100 Subject: dhcp: T3316: Migrate dhcp/dhcpv6 server to Kea --- python/vyos/kea.py | 310 +++++++++++++++++++++++++++++++++++++++++++ python/vyos/template.py | 100 ++++++++++++++ python/vyos/utils/file.py | 8 ++ python/vyos/utils/network.py | 34 +++++ 4 files changed, 452 insertions(+) create mode 100644 python/vyos/kea.py (limited to 'python') diff --git a/python/vyos/kea.py b/python/vyos/kea.py new file mode 100644 index 000000000..0ee6871e7 --- /dev/null +++ b/python/vyos/kea.py @@ -0,0 +1,310 @@ +# Copyright 2023 VyOS maintainers and contributors +# +# 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 . + +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 read_file + +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' +} + +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' +} + +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)}) + + return options + +def kea_parse_subnet(subnet, config): + out = {'subnet': subnet} + options = kea_parse_options(config) + + if 'bootfile_name' in config: + out['boot-file-name'] = config['bootfile_name'] + + if 'bootfile_server' in config: + out['next-server'] = config['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'] + pools.append({'pool': f'{start} - {stop}'}) + out['pools'] = pools + + if 'static_mapping' in config: + reservations = [] + for host, host_config in config['static_mapping'].items(): + if 'disable' in host_config: + continue + + reservations.append({ + 'hw-address': host_config['mac_address'], + 'ip-address': host_config['ip_address'] + }) + out['reservations'] = reservations + + unifi_controller = dict_search_args(config, 'vendor_option', 'ubiquiti', 'unifi_controller') + if unifi_controller: + options.append({ + 'name': 'unifi-controller', + 'data': unifi_controller, + 'space': 'ubnt' + }) + + if options: + out['option-data'] = options + + return out + +def kea6_parse_options(config): + options = [] + + if 'common_options' in config: + common_opt = config['common_options'] + + for node, option_name in kea6_options.items(): + if node not in common_opt: + continue + + value = ", ".join(common_opt[node]) if isinstance(common_opt[node], list) else common_opt[node] + options.append({'name': option_name, 'data': value}) + + 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} + options = kea6_parse_options(config) + + if 'address_range' in config: + addr_range = config['address_range'] + pools = [] + + if 'prefix' in addr_range: + for prefix in addr_range['prefix']: + pools.append({'pool': prefix}) + + if 'start' in addr_range: + for start, range_conf in addr_range['start'].items(): + stop = range_conf['stop'] + pools.append({'pool': f'{start} - {stop}'}) + + 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({ + 'prefix': prefix, + 'prefix-len': int(pd_conf['prefix_length']), + 'delegated-len': int(pd_conf['delegated_length']) + }) + + 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 = {} + + if 'identifier' in host_config: + reservation['duid'] = host_config['identifier'] + + 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'] ] + + reservations.append(reservation) + + out['reservations'] = reservations + + if options: + out['option-data'] = options + + 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 + + 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/python/vyos/template.py b/python/vyos/template.py index 2d4beeec2..f0a50e728 100644 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -791,6 +791,106 @@ def range_to_regex(num_range): regex = range_to_regex(num_range) return f'({regex})' +@register_filter('kea_failover_json') +def kea_failover_json(config): + from json import dumps + + source_addr = config['source_address'] + remote_addr = config['remote'] + + data = { + 'this-server-name': os.uname()[1], + 'mode': 'hot-standby', + 'heartbeat-delay': 10000, + 'max-response-delay': 10000, + 'max-ack-delay': 5000, + 'max-unacked-clients': 0, + 'peers': [ + { + 'name': os.uname()[1], + 'url': f'http://{source_addr}:647/', + 'role': 'standby' if config['status'] == 'secondary' else 'primary', + 'auto-failover': True + }, + { + 'name': config['name'], + 'url': f'http://{remote_addr}:647/', + 'role': 'primary' if config['status'] == 'secondary' else 'standby', + 'auto-failover': True + }] + } + + if 'ca_cert_file' in config: + data['trust-anchor'] = config['ca_cert_file'] + + if 'cert_file' in config: + data['cert-file'] = config['cert_file'] + + if 'cert_key_file' in config: + data['key-file'] = config['cert_key_file'] + + return dumps(data) + +@register_filter('kea_shared_network_json') +def kea_shared_network_json(shared_networks): + from vyos.kea import kea_parse_options + from vyos.kea import kea_parse_subnet + from json import dumps + out = [] + + for name, config in shared_networks.items(): + if 'disable' in config: + continue + + network = { + 'name': name, + 'authoritative': ('authoritative' in config), + 'subnet4': [] + } + options = kea_parse_options(config) + + if 'subnet' in config: + for subnet, subnet_config in config['subnet'].items(): + network['subnet4'].append(kea_parse_subnet(subnet, subnet_config)) + + if options: + network['option-data'] = options + + out.append(network) + + return dumps(out, indent=4) + +@register_filter('kea6_shared_network_json') +def kea6_shared_network_json(shared_networks): + from vyos.kea import kea6_parse_options + from vyos.kea import kea6_parse_subnet + from json import dumps + out = [] + + for name, config in shared_networks.items(): + if 'disable' in config: + continue + + network = { + 'name': name, + 'subnet6': [] + } + options = kea6_parse_options(config) + + if 'interface' in config: + network['interface'] = config['interface'] + + if 'subnet' in config: + for subnet, subnet_config in config['subnet'].items(): + network['subnet6'].append(kea6_parse_subnet(subnet, subnet_config)) + + if options: + network['option-data'] = options + + out.append(network) + + return dumps(out, indent=4) + @register_test('vyos_defined') def vyos_defined(value, test_value=None, var_type=None): """ diff --git a/python/vyos/utils/file.py b/python/vyos/utils/file.py index 9f27a7fb9..2af87a0ca 100644 --- a/python/vyos/utils/file.py +++ b/python/vyos/utils/file.py @@ -141,6 +141,14 @@ def chmod_2775(path): bitmask = S_ISGID | S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH chmod(path, bitmask) +def chmod_775(path): + """ Make file executable by all """ + from stat import S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IWGRP, S_IXGRP, S_IROTH, S_IXOTH + + bitmask = S_IRUSR | S_IWUSR | S_IXUSR | S_IRGRP | S_IWGRP | S_IXGRP | \ + S_IROTH | S_IXOTH + chmod(path, bitmask) + def makedir(path, user=None, group=None): if os.path.exists(path): return diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 2a0808fca..abc3d4e5b 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -519,3 +519,37 @@ def get_vxlan_vni_filter(interface: str) -> list: os_configured_vnis.append(str(vniStart)) return os_configured_vnis + +# Calculate prefix length of an IPv6 range, where possible +# Python-ified from source: https://gitlab.isc.org/isc-projects/dhcp/-/blob/master/keama/confparse.c#L4591 +def ipv6_prefix_length(low, high): + import socket + + bytemasks = [0x80, 0xc0, 0xe0, 0xf0, 0xf8, 0xfc, 0xfe, 0xff] + + try: + lo = bytearray(socket.inet_pton(socket.AF_INET6, low)) + hi = bytearray(socket.inet_pton(socket.AF_INET6, high)) + except: + return None + + xor = bytearray(a ^ b for a, b in zip(lo, hi)) + + plen = 0 + while plen < 128 and xor[plen // 8] == 0: + plen += 8 + + if plen == 128: + return plen + + for i in range((plen // 8) + 1, 16): + if xor[i] != 0: + return None + + for i in range(8): + msk = ~xor[plen // 8] & 0xff + + if msk == bytemasks[i]: + return plen + i + 1 + + return None -- cgit v1.2.3 From 4484a7398482caffdd5e0a74f73f46b162785bf3 Mon Sep 17 00:00:00 2001 From: sarthurdev <965089+sarthurdev@users.noreply.github.com> Date: Wed, 26 Jul 2023 23:16:33 +0200 Subject: dhcp: T3316: Add captive portal v4/v6 options --- interface-definitions/dhcp-server.xml.in | 1 + interface-definitions/dhcpv6-server.xml.in | 1 + interface-definitions/include/dhcp/captive-portal.xml.i | 11 +++++++++++ python/vyos/kea.py | 6 ++++-- 4 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 interface-definitions/include/dhcp/captive-portal.xml.i (limited to 'python') diff --git a/interface-definitions/dhcp-server.xml.in b/interface-definitions/dhcp-server.xml.in index 948f19048..0fa06c534 100644 --- a/interface-definitions/dhcp-server.xml.in +++ b/interface-definitions/dhcp-server.xml.in @@ -145,6 +145,7 @@ + #include Specifies the clients subnet mask as per RFC 950. If unset, subnet declaration is used. diff --git a/interface-definitions/dhcpv6-server.xml.in b/interface-definitions/dhcpv6-server.xml.in index 16d0f9b01..b37f79434 100644 --- a/interface-definitions/dhcpv6-server.xml.in +++ b/interface-definitions/dhcpv6-server.xml.in @@ -135,6 +135,7 @@ + #include #include diff --git a/interface-definitions/include/dhcp/captive-portal.xml.i b/interface-definitions/include/dhcp/captive-portal.xml.i new file mode 100644 index 000000000..643f055a8 --- /dev/null +++ b/interface-definitions/include/dhcp/captive-portal.xml.i @@ -0,0 +1,11 @@ + + + + Captive portal API endpoint + + txt + Captive portal API endpoint + + + + diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 0ee6871e7..fa2948233 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -40,7 +40,8 @@ kea4_options = { 'bootfile_size': 'boot-size', 'time_offset': 'time-offset', 'wpad_url': 'wpad-url', - 'ipv6_only_preferred': 'v6-only-preferred' + 'ipv6_only_preferred': 'v6-only-preferred', + 'captive_portal': 'v4-captive-portal' } kea6_options = { @@ -51,7 +52,8 @@ kea6_options = { 'nis_server': 'nis-servers', 'nisplus_domain': 'nisp-domain-name', 'nisplus_server': 'nisp-servers', - 'sntp_server': 'sntp-servers' + 'sntp_server': 'sntp-servers', + 'captive_portal': 'v6-captive-portal' } def kea_parse_options(config): -- cgit v1.2.3 From 2787e7915c1225f05f1e07c62f7c4d1ac9dca5ac Mon Sep 17 00:00:00 2001 From: sarthurdev <965089+sarthurdev@users.noreply.github.com> Date: Thu, 5 Oct 2023 13:47:38 +0200 Subject: dhcp: T3316: Add time-zone node for options 100 and 101 --- interface-definitions/dhcp-server.xml.in | 11 +++++++++++ python/vyos/kea.py | 7 +++++++ smoketest/scripts/cli/test_service_dhcp-server.py | 11 +++++++++++ 3 files changed, 29 insertions(+) (limited to 'python') diff --git a/interface-definitions/dhcp-server.xml.in b/interface-definitions/dhcp-server.xml.in index 0fa06c534..081f7ed42 100644 --- a/interface-definitions/dhcp-server.xml.in +++ b/interface-definitions/dhcp-server.xml.in @@ -400,6 +400,17 @@ + + + Time zone to send to clients. Uses RFC4833 options 100 and 101 + + + + + + + + Vendor Specific Options diff --git a/python/vyos/kea.py b/python/vyos/kea.py index fa2948233..cb341e0f2 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -83,6 +83,13 @@ def kea_parse_options(config): 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']}) + return options def kea_parse_subnet(subnet, config): diff --git a/smoketest/scripts/cli/test_service_dhcp-server.py b/smoketest/scripts/cli/test_service_dhcp-server.py index aeff2aa82..9f6e05ff3 100755 --- a/smoketest/scripts/cli/test_service_dhcp-server.py +++ b/smoketest/scripts/cli/test_service_dhcp-server.py @@ -184,6 +184,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): self.cli_set(pool + ['static-route', '10.0.0.0/24', 'next-hop', '192.0.2.1']) self.cli_set(pool + ['ipv6-only-preferred', ipv6_only_preferred]) + self.cli_set(pool + ['time-zone', 'Europe/London']) # check validate() - No DHCP address range or active static-mapping set with self.assertRaises(ConfigSessionError): @@ -262,6 +263,16 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'option-data'], {'name': 'ip-forwarding', 'data': "true"}) + # Time zone + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'option-data'], + {'name': 'pcode', 'data': 'GMT0BST,M3.5.0/1,M10.5.0'}) + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'option-data'], + {'name': 'tcode', 'data': 'Europe/London'}) + # Verify pools self.verify_config_object( obj, -- cgit v1.2.3