summaryrefslogtreecommitdiff
path: root/python/vyos/kea.py
diff options
context:
space:
mode:
Diffstat (limited to 'python/vyos/kea.py')
-rw-r--r--python/vyos/kea.py216
1 files changed, 181 insertions, 35 deletions
diff --git a/python/vyos/kea.py b/python/vyos/kea.py
index c7947af3e..436c134c8 100644
--- a/python/vyos/kea.py
+++ b/python/vyos/kea.py
@@ -1,4 +1,4 @@
-# Copyright 2023-2025 VyOS maintainers and contributors <maintainers@vyos.io>
+# Copyright 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
@@ -15,17 +15,18 @@
import json
import os
+import re
import socket
from datetime import datetime
from datetime import timezone
+from vyos import ConfigError
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.process import run
+from vyos.utils.process import run, rc_cmd
kea4_options = {
'name_server': 'domain-name-servers',
@@ -44,6 +45,9 @@ kea4_options = {
'wpad_url': 'wpad-url',
'ipv6_only_preferred': 'v6-only-preferred',
'captive_portal': 'v4-captive-portal',
+ 'capwap_controller': 'capwap-ac-v4',
+ 'interface_mtu': 'interface-mtu',
+ 'bootfile_name': 'boot-file-name',
}
kea6_options = {
@@ -56,9 +60,10 @@ kea6_options = {
'nisplus_server': 'nisp-servers',
'sntp_server': 'sntp-servers',
'captive_portal': 'v6-captive-portal',
+ 'capwap_controller': 'capwap-ac-v6',
}
-kea_ctrl_socket = '/run/kea/dhcp{inet}-ctrl-socket'
+kea_ctrl_socket = '/var/run/kea/dhcp{inet}{vrf_append}-ctrl-socket'
def _format_hex_string(in_str):
@@ -75,7 +80,7 @@ def _format_hex_string(in_str):
def _find_list_of_dict_index(lst, key='ip', value=''):
"""
Find the index entry of list of dict matching the dict value
- Exampe:
+ Example:
% lst = [{'ip': '192.0.2.1'}, {'ip': '192.0.2.2'}]
% _find_list_of_dict_index(lst, key='ip', value='192.0.2.2')
% 1
@@ -84,6 +89,24 @@ def _find_list_of_dict_index(lst, key='ip', value=''):
return idx
+def _read_posix_timezone(tz_name):
+ try:
+ with open(f'/usr/share/zoneinfo/{tz_name}', 'rb') as f:
+ return f.read().split(b'\n')[-2].decode('utf-8').replace(',', '\\,')
+ except (FileNotFoundError, IOError, IndexError) as e:
+ raise ConfigError(f'Failed to read timezone data for: {tz_name}') from e
+
+
+def kea_test_config(process: str, config_path: str) -> tuple[bool, str]:
+ result, output = rc_cmd(f'{process} -t {config_path}')
+
+ if result == 0:
+ return (True, None)
+
+ find = re.search(r'Error encountered:\s([^\n$]+)', output)
+ return (False, find[1] if find else None)
+
+
def kea_parse_options(config):
options = []
@@ -111,27 +134,24 @@ def kea_parse_options(config):
default_route = ''
if 'default_router' in config:
- default_route = isc_static_route('0.0.0.0/0', config['default_router'])
+ default_route = f'0.0.0.0/0 - {config["default_router"]}'
routes = [
- isc_static_route(route, route_options['next_hop'])
+ f'{route} - {route_options["next_hop"]}'
for route, route_options in config['static_route'].items()
]
options.append(
{
- 'name': 'rfc3442-static-route',
+ 'name': 'classless-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')
-
+ tz_string = _read_posix_timezone(config['time_zone'])
options.append({'name': 'pcode', 'data': tz_string})
options.append({'name': 'tcode', 'data': config['time_zone']})
@@ -139,15 +159,24 @@ def kea_parse_options(config):
config, 'vendor_option', 'ubiquiti', 'unifi_controller'
)
if unifi_controller:
+ options.append({'name': 'vendor-encapsulated-options'})
options.append(
- {'name': 'unifi-controller', 'data': unifi_controller, 'space': 'ubnt'}
+ {
+ 'name': 'ubnt',
+ 'data': unifi_controller,
+ 'space': 'vendor-encapsulated-options-space',
+ }
)
return options
def kea_parse_subnet(subnet, config):
- out = {'subnet': subnet, 'id': int(config['subnet_id'])}
+ out = {
+ 'subnet': subnet,
+ 'id': int(config['subnet_id']),
+ 'user-context': {'enable-ping-check': False},
+ }
if 'option' in config:
out['option-data'] = kea_parse_options(config['option'])
@@ -165,6 +194,12 @@ def kea_parse_subnet(subnet, config):
out['valid-lifetime'] = int(config['lease'])
out['max-valid-lifetime'] = int(config['lease'])
+ if 'ping_check' in config:
+ out['user-context']['enable-ping-check'] = True
+
+ if 'client_class' in config:
+ out['client-class'] = config['client_class']
+
if 'range' in config:
pools = []
for num, range_config in config['range'].items():
@@ -180,6 +215,9 @@ def kea_parse_subnet(subnet, config):
if 'bootfile_server' in range_config['option']:
pool['next-server'] = range_config['option']['bootfile_server']
+ if 'client_class' in range_config:
+ pool['client-class'] = range_config['client_class']
+
pools.append(pool)
out['pools'] = pools
@@ -189,6 +227,9 @@ def kea_parse_subnet(subnet, config):
if 'disable' in host_config:
continue
+ if 'mac' not in host_config and 'duid' not in host_config:
+ continue
+
reservation = {
'hostname': host,
}
@@ -218,6 +259,9 @@ def kea_parse_subnet(subnet, config):
reservations.append(reservation)
out['reservations'] = reservations
+ if 'dynamic_dns_update' in config:
+ out.update(kea_parse_ddns_settings(config['dynamic_dns_update']))
+
return out
@@ -251,6 +295,11 @@ def kea6_parse_options(config):
if hosts:
options.append({'name': 'sip-server-dns', 'data': ', '.join(hosts)})
+ if 'time_zone' in config:
+ tz_string = _read_posix_timezone(config['time_zone'])
+ options.append({'name': 'new-posix-timezone', 'data': tz_string})
+ options.append({'name': 'new-tzdb-timezone', 'data': config['time_zone']})
+
cisco_tftp = dict_search_args(config, 'vendor_option', 'cisco', 'tftp-server')
if cisco_tftp:
options.append(
@@ -324,6 +373,9 @@ def kea6_parse_subnet(subnet, config):
if 'disable' in host_config:
continue
+ if 'mac' not in host_config and 'duid' not in host_config:
+ continue
+
reservation = {'hostname': host}
if 'mac' in host_config:
@@ -333,10 +385,10 @@ def kea6_parse_subnet(subnet, config):
reservation['duid'] = host_config['duid']
if 'ipv6_address' in host_config:
- reservation['ip-addresses'] = [host_config['ipv6_address']]
+ reservation['ip-addresses'] = host_config['ipv6_address']
if 'ipv6_prefix' in host_config:
- reservation['prefixes'] = [host_config['ipv6_prefix']]
+ reservation['prefixes'] = host_config['ipv6_prefix']
if 'option' in host_config:
reservation['option-data'] = kea6_parse_options(host_config['option'])
@@ -348,8 +400,69 @@ def kea6_parse_subnet(subnet, config):
return out
-def _ctrl_socket_command(inet, command, args=None):
- path = kea_ctrl_socket.format(inet=inet)
+def kea_parse_tsig_algo(algo_spec):
+ translate = {
+ 'md5': 'HMAC-MD5',
+ 'sha1': 'HMAC-SHA1',
+ 'sha224': 'HMAC-SHA224',
+ 'sha256': 'HMAC-SHA256',
+ 'sha384': 'HMAC-SHA384',
+ 'sha512': 'HMAC-SHA512',
+ }
+ if algo_spec not in translate:
+ raise ConfigError(f'Unsupported TSIG algorithm: {algo_spec}')
+ return translate[algo_spec]
+
+
+def kea_parse_enable_disable(value):
+ return True if value == 'enable' else False
+
+
+def kea_parse_ddns_settings(config):
+ data = {}
+
+ if send_updates := config.get('send_updates'):
+ data['ddns-send-updates'] = kea_parse_enable_disable(send_updates)
+
+ if override_client_update := config.get('override_client_update'):
+ data['ddns-override-client-update'] = kea_parse_enable_disable(
+ override_client_update
+ )
+
+ if override_no_update := config.get('override_no_update'):
+ data['ddns-override-no-update'] = kea_parse_enable_disable(override_no_update)
+
+ if update_on_renew := config.get('update_on_renew'):
+ data['ddns-update-on-renew'] = kea_parse_enable_disable(update_on_renew)
+
+ if conflict_resolution := config.get('conflict_resolution'):
+ data['ddns-use-conflict-resolution'] = kea_parse_enable_disable(
+ conflict_resolution
+ )
+
+ if 'replace_client_name' in config:
+ data['ddns-replace-client-name'] = config['replace_client_name']
+ if 'generated_prefix' in config:
+ data['ddns-generated-prefix'] = config['generated_prefix']
+ if 'qualifying_suffix' in config:
+ data['ddns-qualifying-suffix'] = config['qualifying_suffix']
+ if 'ttl_percent' in config:
+ data['ddns-ttl-percent'] = int(config['ttl_percent']) / 100
+ if 'hostname_char_set' in config:
+ data['hostname-char-set'] = config['hostname_char_set']
+ if 'hostname_char_replacement' in config:
+ data['hostname-char-replacement'] = config['hostname_char_replacement']
+
+ return data
+
+
+def _ctrl_socket_command(inet, vrf_name, command, args=None):
+ if vrf_name:
+ vrf_append = f'-{vrf_name}'
+ else:
+ vrf_append = ''
+
+ path = kea_ctrl_socket.format(inet=inet, vrf_append=vrf_append)
if not os.path.exists(path):
return None
@@ -375,8 +488,8 @@ def _ctrl_socket_command(inet, command, args=None):
return json.loads(result.decode('utf-8'))
-def kea_get_leases(inet):
- leases = _ctrl_socket_command(inet, f'lease{inet}-get-all')
+def kea_get_leases(inet, vrf_name):
+ leases = _ctrl_socket_command(inet, vrf_name, f'lease{inet}-get-all')
if not leases or 'result' not in leases or leases['result'] != 0:
return []
@@ -386,6 +499,7 @@ def kea_get_leases(inet):
def kea_add_lease(
inet,
+ vrf_name,
ip_address,
host_name=None,
mac_address=None,
@@ -411,7 +525,7 @@ def kea_add_lease(
if inet == '6' and iaid:
args['iaid'] = iaid
- result = _ctrl_socket_command(inet, f'lease{inet}-add', args)
+ result = _ctrl_socket_command(inet, vrf_name, f'lease{inet}-add', args)
if result and 'result' in result:
return result['result'] == 0
@@ -419,10 +533,10 @@ def kea_add_lease(
return False
-def kea_delete_lease(inet, ip_address):
+def kea_delete_lease(inet, vrf_name, ip_address):
args = {'ip-address': ip_address}
- result = _ctrl_socket_command(inet, f'lease{inet}-del', args)
+ result = _ctrl_socket_command(inet, vrf_name, f'lease{inet}-del', args)
if result and 'result' in result:
return result['result'] == 0
@@ -430,8 +544,8 @@ def kea_delete_lease(inet, ip_address):
return False
-def kea_get_active_config(inet):
- config = _ctrl_socket_command(inet, 'config-get')
+def kea_get_active_config(inet, vrf_name):
+ config = _ctrl_socket_command(inet, vrf_name, 'config-get')
if not config or 'result' not in config or config['result'] != 0:
return None
@@ -483,10 +597,10 @@ def kea_get_domain_from_subnet_id(config, inet, subnet_id):
if option['name'] == 'domain-name':
return option['data']
- # domain-name is not found in subnet, fallback to shared-network pool option
- for option in network['option-data']:
- if option['name'] == 'domain-name':
- return option['data']
+ # domain-name is not found in subnet, fallback to shared-network pool option
+ for option in network['option-data']:
+ if option['name'] == 'domain-name':
+ return option['data']
return None
@@ -525,12 +639,14 @@ def kea_get_static_mappings(config, inet, pools=[]) -> list:
return mappings
-def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list:
+def kea_get_server_leases(
+ config, inet, vrf_name, pools=[], state=[], origin=None
+) -> list:
"""
Get DHCP server leases from active Kea DHCPv4 or DHCPv6 configuration
:return list
"""
- leases = kea_get_leases(inet)
+ leases = kea_get_leases(inet, vrf_name)
data = []
for lease in leases:
@@ -563,9 +679,9 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list
data_lease['origin'] = 'local' # TODO: Determine remote in HA
# remove trailing dot in 'hostname' to ensure consistency for `vyos-hostsd-client`
data_lease['hostname'] = lease.get('hostname', '').rstrip('.') or '-'
+ data_lease['mac'] = lease.get('hw-address', '-')
if inet == '4':
- data_lease['mac'] = lease['hw-address']
data_lease['start'] = lease['start_time'].timestamp()
if inet == '6':
@@ -581,9 +697,10 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list
now = datetime.now(timezone.utc)
if lease['valid-lft'] > 0 and lease['expire_time'] > now:
- # substraction gives us a timedelta object which can't be formatted
- # with strftime so we use str(), split gets rid of the microseconds
- data_lease['remaining'] = str(lease['expire_time'] - now).split('.')[0]
+ # clear the microseconds before subtraction for visual clarity
+ data_lease['remaining'] = lease['expire_time'].replace(
+ microsecond=0
+ ) - now.replace(microsecond=0)
# Do not add old leases
if (
@@ -606,3 +723,32 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list
data.pop(idx)
return data
+
+
+def _build_relay_hex_condition(sub_option_index, value):
+ if value.startswith('0x'):
+ return f'relay4[{sub_option_index}].hex == {value}'
+ else:
+ return f'relay4[{sub_option_index}].hex == 0x{value.encode().hex().lower()}'
+
+
+def kea_build_client_class_test(config):
+ conditions = []
+
+ if 'relay_agent_information' in config:
+ if 'circuit_id' in config['relay_agent_information']:
+ conditions.append(
+ _build_relay_hex_condition(
+ 1, config['relay_agent_information']['circuit_id']
+ )
+ )
+ if 'remote_id' in config['relay_agent_information']:
+ conditions.append(
+ _build_relay_hex_condition(
+ 2, config['relay_agent_information']['remote_id']
+ )
+ )
+
+ test = ' and '.join(conditions)
+
+ return test