diff --git a/src/conf_mode/dhcp_server.py b/src/conf_mode/dhcp_server.py index c1308cda7..7ebc560ba 100755 --- a/src/conf_mode/dhcp_server.py +++ b/src/conf_mode/dhcp_server.py @@ -1,376 +1,385 @@ #!/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 netaddr import IPRange from sys import exit from vyos.config import Config from vyos.pki import wrap_certificate from vyos.pki import wrap_private_key from vyos.template import render from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args +from vyos.utils.file import chmod_775 +from vyos.utils.file import makedir from vyos.utils.file import write_file from vyos.utils.process import call -from vyos.utils.process import run from vyos.utils.network import is_subnet_connected from vyos.utils.network import is_addr_assigned from vyos import ConfigError from vyos import airbag airbag.enable() ctrl_config_file = '/run/kea/kea-ctrl-agent.conf' ctrl_socket = '/run/kea/dhcp4-ctrl-socket' config_file = '/run/kea/kea-dhcp4.conf' -lease_file = '/config/dhcp4.leases' +lease_file = '/config/dhcp/dhcp4-leases.csv' systemd_override = r'/run/systemd/system/kea-ctrl-agent.service.d/10-override.conf' +user_group = '_kea' ca_cert_file = '/run/kea/kea-failover-ca.pem' cert_file = '/run/kea/kea-failover.pem' cert_key_file = '/run/kea/kea-failover-key.pem' def dhcp_slice_range(exclude_list, range_dict): """ This function is intended to slice a DHCP range. What does it mean? Lets assume we have a DHCP range from '192.0.2.1' to '192.0.2.100' but want to exclude address '192.0.2.74' and '192.0.2.75'. We will pass an input 'range_dict' in the format: {'start' : '192.0.2.1', 'stop' : '192.0.2.100' } and we will receive an output list of: [{'start' : '192.0.2.1' , 'stop' : '192.0.2.73' }, {'start' : '192.0.2.76', 'stop' : '192.0.2.100' }] The resulting list can then be used in turn to build the proper dhcpd configuration file. """ output = [] # exclude list must be sorted for this to work exclude_list = sorted(exclude_list) range_start = range_dict['start'] range_stop = range_dict['stop'] range_last_exclude = '' for e in exclude_list: if (ip_address(e) >= ip_address(range_start)) and \ (ip_address(e) <= ip_address(range_stop)): range_last_exclude = e for e in exclude_list: if (ip_address(e) >= ip_address(range_start)) and \ (ip_address(e) <= ip_address(range_stop)): # Build new address range ending one address before exclude address r = { 'start' : range_start, 'stop' : str(ip_address(e) -1) } # On the next run our address range will start one address after # the exclude address range_start = str(ip_address(e) + 1) # on subsequent exclude addresses we can not # append them to our output if not (ip_address(r['start']) > ip_address(r['stop'])): # Everything is fine, add range to result output.append(r) # Take care of last IP address range spanning from the last exclude # address (+1) to the end of the initial configured range if ip_address(e) == ip_address(range_last_exclude): r = { 'start': str(ip_address(e) + 1), 'stop': str(range_stop) } if not (ip_address(r['start']) > ip_address(r['stop'])): output.append(r) else: # if the excluded address was not part of the range, we simply return # the entire ranga again if not range_last_exclude: if range_dict not in output: output.append(range_dict) return output def get_config(config=None): if config: conf = config else: conf = Config() base = ['service', 'dhcp-server'] if not conf.exists(base): return None dhcp = conf.get_config_dict(base, key_mangling=('-', '_'), no_tag_node_value_mangle=True, get_first_key=True, with_recursive_defaults=True) if 'shared_network_name' in dhcp: for network, network_config in dhcp['shared_network_name'].items(): if 'subnet' in network_config: for subnet, subnet_config in network_config['subnet'].items(): # If exclude IP addresses are defined we need to slice them out of # the defined ranges if {'exclude', 'range'} <= set(subnet_config): new_range_id = 0 new_range_dict = {} for r, r_config in subnet_config['range'].items(): for slice in dhcp_slice_range(subnet_config['exclude'], r_config): new_range_dict.update({new_range_id : slice}) new_range_id +=1 dhcp['shared_network_name'][network]['subnet'][subnet].update( {'range' : new_range_dict}) if dict_search('failover.certificate', dhcp): dhcp['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True) return dhcp def verify(dhcp): # bail out early - looks like removal from running config if not dhcp or 'disable' in dhcp: return None # If DHCP is enabled we need one share-network if 'shared_network_name' not in dhcp: raise ConfigError('No DHCP shared networks configured.\n' \ 'At least one DHCP shared network must be configured.') # Inspect shared-network/subnet listen_ok = False subnets = [] failover_ok = False shared_networks = len(dhcp['shared_network_name']) disabled_shared_networks = 0 # A shared-network requires a subnet definition for network, network_config in dhcp['shared_network_name'].items(): if 'disable' in network_config: disabled_shared_networks += 1 if 'subnet' not in network_config: raise ConfigError(f'No subnets defined for {network}. At least one\n' \ 'lease subnet must be configured.') for subnet, subnet_config in network_config['subnet'].items(): # All delivered static routes require a next-hop to be set if 'static_route' in subnet_config: for route, route_option in subnet_config['static_route'].items(): if 'next_hop' not in route_option: raise ConfigError(f'DHCP static-route "{route}" requires router to be defined!') # Check if DHCP address range is inside configured subnet declaration if 'range' in subnet_config: networks = [] for range, range_config in subnet_config['range'].items(): if not {'start', 'stop'} <= set(range_config): raise ConfigError(f'DHCP range "{range}" start and stop address must be defined!') # Start/Stop address must be inside network for key in ['start', 'stop']: if ip_address(range_config[key]) not in ip_network(subnet): raise ConfigError(f'DHCP range "{range}" {key} address not within shared-network "{network}, {subnet}"!') # Stop address must be greater or equal to start address if ip_address(range_config['stop']) < ip_address(range_config['start']): raise ConfigError(f'DHCP range "{range}" stop address must be greater or equal\n' \ 'to the ranges start address!') for network in networks: start = range_config['start'] stop = range_config['stop'] if start in network: raise ConfigError(f'Range "{range}" start address "{start}" already part of another range!') if stop in network: raise ConfigError(f'Range "{range}" stop address "{stop}" already part of another range!') tmp = IPRange(range_config['start'], range_config['stop']) networks.append(tmp) # Exclude addresses must be in bound if 'exclude' in subnet_config: for exclude in subnet_config['exclude']: if ip_address(exclude) not in ip_network(subnet): raise ConfigError(f'Excluded IP address "{exclude}" not within shared-network "{network}, {subnet}"!') # At least one DHCP address range or static-mapping required if 'range' not in subnet_config and 'static_mapping' not in subnet_config: raise ConfigError(f'No DHCP address range or active static-mapping configured\n' \ f'within shared-network "{network}, {subnet}"!') if 'static_mapping' in subnet_config: # Static mappings require just a MAC address (will use an IP from the dynamic pool if IP is not set) for mapping, mapping_config in subnet_config['static_mapping'].items(): if 'ip_address' in mapping_config: if ip_address(mapping_config['ip_address']) not in ip_network(subnet): raise ConfigError(f'Configured static lease address for mapping "{mapping}" is\n' \ f'not within shared-network "{network}, {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}"!') # There must be one subnet connected to a listen interface. # This only counts if the network itself is not disabled! if 'disable' not in network_config: if is_subnet_connected(subnet, primary=False): listen_ok = True # Subnets must be non overlapping if subnet in subnets: raise ConfigError(f'Configured subnets must be unique! Subnet "{subnet}"\n' 'defined multiple times!') subnets.append(subnet) # Check for overlapping subnets net = ip_network(subnet) for n in subnets: net2 = ip_network(n) if (net != net2): if net.overlaps(net2): raise ConfigError(f'Conflicting subnet ranges: "{net}" overlaps "{net2}"!') # Prevent 'disable' for shared-network if only one network is configured if (shared_networks - disabled_shared_networks) < 1: raise ConfigError(f'At least one shared network must be active!') if 'failover' in dhcp: for key in ['name', 'remote', 'source_address', 'status']: if key not in dhcp['failover']: tmp = key.replace('_', '-') raise ConfigError(f'DHCP failover requires "{tmp}" to be specified!') if len({'certificate', 'ca_certificate'} & set(dhcp['failover'])) == 1: raise ConfigError(f'DHCP secured failover requires both certificate and CA certificate') if 'certificate' in dhcp['failover']: cert_name = dhcp['failover']['certificate'] if cert_name not in dhcp['pki']['certificate']: raise ConfigError(f'Invalid certificate specified for DHCP failover') if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'certificate'): raise ConfigError(f'Invalid certificate specified for DHCP failover') if not dict_search_args(dhcp['pki']['certificate'], cert_name, 'private', 'key'): raise ConfigError(f'Missing private key on certificate specified for DHCP failover') if 'ca_certificate' in dhcp['failover']: ca_cert_name = dhcp['failover']['ca_certificate'] if ca_cert_name not in dhcp['pki']['ca']: raise ConfigError(f'Invalid CA certificate specified for DHCP failover') if not dict_search_args(dhcp['pki']['ca'], ca_cert_name, 'certificate'): raise ConfigError(f'Invalid CA certificate specified for DHCP failover') for address in (dict_search('listen_address', dhcp) or []): if is_addr_assigned(address): listen_ok = True # no need to probe further networks, we have one that is valid continue else: raise ConfigError(f'listen-address "{address}" not configured on any interface') if not listen_ok: raise ConfigError('None of the configured subnets have an appropriate primary IP address on any\n' 'broadcast interface configured, nor was there an explicit listen-address\n' 'configured for serving DHCP relay packets!') return None def generate(dhcp): # bail out early - looks like removal from running config if not dhcp or 'disable' in dhcp: return None dhcp['lease_file'] = lease_file dhcp['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='_kea', group='vyattacfg', mode=0o755) + write_file(lease_file, '', user=user_group, group=user_group, mode=0o644) for f in [cert_file, cert_key_file, ca_cert_file]: if os.path.exists(f): os.unlink(f) if 'failover' in dhcp: if 'certificate' in dhcp['failover']: cert_name = dhcp['failover']['certificate'] cert_data = dhcp['pki']['certificate'][cert_name]['certificate'] key_data = dhcp['pki']['certificate'][cert_name]['private']['key'] - write_file(cert_file, wrap_certificate(cert_data), user='_kea', mode=0o600) - write_file(cert_key_file, wrap_private_key(key_data), user='_kea', mode=0o600) + write_file(cert_file, wrap_certificate(cert_data), user=user_group, mode=0o600) + write_file(cert_key_file, wrap_private_key(key_data), user=user_group, mode=0o600) dhcp['failover']['cert_file'] = cert_file dhcp['failover']['cert_key_file'] = cert_key_file if 'ca_certificate' in dhcp['failover']: ca_cert_name = dhcp['failover']['ca_certificate'] ca_cert_data = dhcp['pki']['ca'][ca_cert_name]['certificate'] - write_file(ca_cert_file, wrap_certificate(ca_cert_data), user='_kea', mode=0o600) + write_file(ca_cert_file, wrap_certificate(ca_cert_data), user=user_group, mode=0o600) dhcp['failover']['ca_cert_file'] = ca_cert_file render(systemd_override, 'dhcp-server/10-override.conf.j2', dhcp) - render(ctrl_config_file, 'dhcp-server/kea-ctrl-agent.conf.j2', dhcp) - render(config_file, 'dhcp-server/kea-dhcp4.conf.j2', dhcp) + render(ctrl_config_file, 'dhcp-server/kea-ctrl-agent.conf.j2', dhcp, user=user_group, group=user_group) + render(config_file, 'dhcp-server/kea-dhcp4.conf.j2', dhcp, user=user_group, group=user_group) return None def apply(dhcp): services = ['kea-ctrl-agent', 'kea-dhcp4-server', 'kea-dhcp-ddns-server'] if not dhcp or 'disable' in dhcp: for service in services: call(f'systemctl stop {service}.service') if os.path.exists(config_file): os.unlink(config_file) return None for service in services: action = 'restart' if service == 'kea-dhcp-ddns-server' and 'dynamic_dns_update' not in dhcp: action = 'stop' if service == 'kea-ctrl-agent' and 'failover' not in dhcp: action = 'stop' call(f'systemctl {action} {service}.service') 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/conf_mode/dhcpv6_server.py b/src/conf_mode/dhcpv6_server.py index f9da3d84a..9cc57dbcf 100755 --- a/src/conf_mode/dhcpv6_server.py +++ b/src/conf_mode/dhcpv6_server.py @@ -1,213 +1,222 @@ #!/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.template import is_ipv6 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/dhcp6.leases' +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 = [] 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 'address_range' in subnet_config: if 'start' in subnet_config['address_range']: range6_start = [] range6_stop = [] for start, start_config in subnet_config['address_range']['start'].items(): if 'stop' not in start_config: raise ConfigError(f'address-range stop address for start "{start}" is not defined!') stop = start_config['stop'] # Start address must be inside network if not ip_address(start) in ip_network(subnet): raise ConfigError(f'address-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'address-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'address-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 subnet_config: for prefix in subnet_config['prefix']: if ip_network(prefix) not in ip_network(subnet): raise ConfigError(f'address-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') # 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 'vendor_option' in subnet_config: if len(dict_search('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='_kea', group='vyattacfg', mode=0o755) + write_file(lease_file, '', user=user_group, group=user_group, mode=0o644) - render(config_file, 'dhcp-server/kea-dhcp6.conf.j2', dhcpv6) + 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/op_mode/clear_dhcp_lease.py b/src/op_mode/clear_dhcp_lease.py index 2c95a2b08..7d4b47104 100755 --- a/src/op_mode/clear_dhcp_lease.py +++ b/src/op_mode/clear_dhcp_lease.py @@ -1,89 +1,88 @@ #!/usr/bin/env python3 # # 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 argparse import re from vyos.configquery import ConfigTreeQuery from vyos.kea import kea_parse_leases from vyos.utils.io import ask_yes_no from vyos.utils.process import call from vyos.utils.commit import commit_in_progress # TODO: Update to use Kea control socket command "lease4-del" config = ConfigTreeQuery() base = ['service', 'dhcp-server'] -lease_file = '/config/dhcp4.leases' +lease_file = '/config/dhcp/dhcp4-leases.csv' def del_lease_ip(address): """ Read lease_file and write data to this file without specific section "lease ip" Delete section "lease x.x.x.x { x;x;x; }" """ with open(lease_file, encoding='utf-8') as f: data = f.read().rstrip() pattern = rf"^{address},[^\n]+\n" # Delete lease for ip block data = re.sub(pattern, '', data) # Write new data to original lease_file with open(lease_file, 'w', encoding='utf-8') as f: f.write(data) def is_ip_in_leases(address): """ Return True if address found in the lease file """ leases = kea_parse_leases(lease_file) - lease_ips = [] for lease in leases: if address == lease['address']: return True print(f'Address "{address}" not found in "{lease_file}"') return False if not config.exists(base): print('DHCP-server not configured!') exit(0) if config.exists(base + ['failover']): print('Lease cannot be reset in failover mode!') exit(0) if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('--ip', help='IPv4 address', action='store', required=True) args = parser.parse_args() address = args.ip if not is_ip_in_leases(address): exit(1) if commit_in_progress(): print('Cannot clear DHCP lease while a commit is in progress') exit(1) if not ask_yes_no(f'This will restart DHCP server.\nContinue?'): exit(1) else: del_lease_ip(address) call('systemctl restart kea-dhcp4-server.service') diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py index a9271ea79..02f4d5bbb 100755 --- a/src/op_mode/dhcp.py +++ b/src/op_mode/dhcp.py @@ -1,402 +1,399 @@ #!/usr/bin/env python3 # # Copyright (C) 2022-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 import sys import typing from datetime import datetime from glob import glob from ipaddress import ip_address from tabulate import tabulate import vyos.opmode from vyos.base import Warning from vyos.configquery import ConfigTreeQuery from vyos.kea import kea_get_active_config from vyos.kea import kea_get_pool_from_subnet_id from vyos.kea import kea_parse_leases -from vyos.utils.dict import dict_search -from vyos.utils.file import read_file -from vyos.utils.process import cmd from vyos.utils.process import is_systemd_service_running time_string = "%a %b %d %H:%M:%S %Z %Y" config = ConfigTreeQuery() lease_valid_states = ['all', 'active', 'free', 'expired', 'released', 'abandoned', 'reset', 'backup'] sort_valid_inet = ['end', 'mac', 'hostname', 'ip', 'pool', 'remaining', 'start', 'state'] sort_valid_inet6 = ['end', 'iaid_duid', 'ip', 'last_communication', 'pool', 'remaining', 'state', 'type'] ArgFamily = typing.Literal['inet', 'inet6'] ArgState = typing.Literal['all', 'active', 'free', 'expired', 'released', 'abandoned', 'reset', 'backup'] ArgOrigin = typing.Literal['local', 'remote'] def _utc_to_local(utc_dt): return datetime.fromtimestamp((datetime.fromtimestamp(utc_dt) - datetime(1970, 1, 1)).total_seconds()) def _format_hex_string(in_str): out_str = "" # if input is divisible by 2, add : every 2 chars if len(in_str) > 0 and len(in_str) % 2 == 0: out_str = ':'.join(a+b for a,b in zip(in_str[::2], in_str[1::2])) else: out_str = in_str return out_str def _find_list_of_dict_index(lst, key='ip', value='') -> int: """ Find the index entry of list of dict matching the dict value Exampe: % 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 """ idx = next((index for (index, d) in enumerate(lst) if d[key] == value), None) return idx def _get_raw_server_leases(family='inet', pool=None, sorted=None, state=[], origin=None) -> list: """ Get DHCP server leases :return list """ - lease_file = '/config/dhcp6.leases' if family == 'inet6' else '/config/dhcp4.leases' - data = [] + inet_suffix = '6' if family == 'inet6' else '4' + lease_file = f'/config/dhcp/dhcp{inet_suffix}-leases.csv' leases = kea_parse_leases(lease_file) if pool is None: pool = _get_dhcp_pools(family=family) else: pool = [pool] - inet_suffix = '6' if family == 'inet6' else '4' active_config = kea_get_active_config(inet_suffix) + data = [] for lease in leases: data_lease = {} data_lease['ip'] = lease['address'] lease_state_long = {'0': 'active', '1': 'rejected', '2': 'expired'} data_lease['state'] = lease_state_long[lease['state']] data_lease['pool'] = kea_get_pool_from_subnet_id(active_config, inet_suffix, lease['subnet_id']) if active_config else '-' data_lease['end'] = lease['expire_timestamp'].timestamp() if lease['expire_timestamp'] else None data_lease['origin'] = 'local' # TODO: Determine remote in HA if family == 'inet': data_lease['mac'] = lease['hwaddr'] data_lease['start'] = lease['start_timestamp'].timestamp() data_lease['hostname'] = lease['hostname'] if family == 'inet6': data_lease['last_communication'] = lease['start_timestamp'].timestamp() data_lease['iaid_duid'] = _format_hex_string(lease['duid']) lease_types_long = {'0': 'non-temporary', '1': 'temporary', '2': 'prefix delegation'} data_lease['type'] = lease_types_long[lease['lease_type']] data_lease['remaining'] = '-' if lease['expire']: data_lease['remaining'] = lease['expire_timestamp'] - datetime.utcnow() if data_lease['remaining'].days >= 0: # 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(data_lease["remaining"]).split('.')[0] # Do not add old leases if data_lease['remaining'] != '' and data_lease['pool'] in pool and data_lease['state'] != 'free': if not state or state == 'all' or data_lease['state'] in state: data.append(data_lease) # deduplicate checked = [] for entry in data: addr = entry.get('ip') if addr not in checked: checked.append(addr) else: idx = _find_list_of_dict_index(data, key='ip', value=addr) data.pop(idx) if sorted: if sorted == 'ip': data.sort(key = lambda x:ip_address(x['ip'])) else: data.sort(key = lambda x:x[sorted]) return data def _get_formatted_server_leases(raw_data, family='inet'): data_entries = [] if family == 'inet': for lease in raw_data: ipaddr = lease.get('ip') hw_addr = lease.get('mac') state = lease.get('state') start = lease.get('start') start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S') end = lease.get('end') end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') if end else '-' remain = lease.get('remaining') pool = lease.get('pool') hostname = lease.get('hostname') origin = lease.get('origin') data_entries.append([ipaddr, hw_addr, state, start, end, remain, pool, hostname, origin]) headers = ['IP Address', 'MAC address', 'State', 'Lease start', 'Lease expiration', 'Remaining', 'Pool', 'Hostname', 'Origin'] if family == 'inet6': for lease in raw_data: ipaddr = lease.get('ip') state = lease.get('state') start = lease.get('last_communication') start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S') end = lease.get('end') end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') remain = lease.get('remaining') lease_type = lease.get('type') pool = lease.get('pool') host_identifier = lease.get('iaid_duid') data_entries.append([ipaddr, state, start, end, remain, lease_type, pool, host_identifier]) headers = ['IPv6 address', 'State', 'Last communication', 'Lease expiration', 'Remaining', 'Type', 'Pool', 'IAID_DUID'] output = tabulate(data_entries, headers, numalign='left') return output def _get_dhcp_pools(family='inet') -> list: v = 'v6' if family == 'inet6' else '' pools = config.list_nodes(f'service dhcp{v}-server shared-network-name') return pools def _get_pool_size(pool, family='inet'): v = 'v6' if family == 'inet6' else '' base = f'service dhcp{v}-server shared-network-name {pool}' size = 0 subnets = config.list_nodes(f'{base} subnet') for subnet in subnets: if family == 'inet6': ranges = config.list_nodes(f'{base} subnet {subnet} address-range start') else: ranges = config.list_nodes(f'{base} subnet {subnet} range') for range in ranges: if family == 'inet6': start = config.list_nodes(f'{base} subnet {subnet} address-range start')[0] stop = config.value(f'{base} subnet {subnet} address-range start {start} stop') else: start = config.value(f'{base} subnet {subnet} range {range} start') stop = config.value(f'{base} subnet {subnet} range {range} stop') # Add +1 because both range boundaries are inclusive size += int(ip_address(stop)) - int(ip_address(start)) + 1 return size def _get_raw_pool_statistics(family='inet', pool=None): if pool is None: pool = _get_dhcp_pools(family=family) else: pool = [pool] v = 'v6' if family == 'inet6' else '' stats = [] for p in pool: subnet = config.list_nodes(f'service dhcp{v}-server shared-network-name {p} subnet') size = _get_pool_size(family=family, pool=p) leases = len(_get_raw_server_leases(family=family, pool=p)) use_percentage = round(leases / size * 100) if size != 0 else 0 pool_stats = {'pool': p, 'size': size, 'leases': leases, 'available': (size - leases), 'use_percentage': use_percentage, 'subnet': subnet} stats.append(pool_stats) return stats def _get_formatted_pool_statistics(pool_data, family='inet'): data_entries = [] for entry in pool_data: pool = entry.get('pool') size = entry.get('size') leases = entry.get('leases') available = entry.get('available') use_percentage = entry.get('use_percentage') use_percentage = f'{use_percentage}%' data_entries.append([pool, size, leases, available, use_percentage]) headers = ['Pool', 'Size','Leases', 'Available', 'Usage'] output = tabulate(data_entries, headers, numalign='left') return output def _verify(func): """Decorator checks if DHCP(v6) config exists""" from functools import wraps @wraps(func) def _wrapper(*args, **kwargs): config = ConfigTreeQuery() family = kwargs.get('family') v = 'v6' if family == 'inet6' else '' unconf_message = f'DHCP{v} server is not configured' # Check if config does not exist if not config.exists(f'service dhcp{v}-server'): raise vyos.opmode.UnconfiguredSubsystem(unconf_message) return func(*args, **kwargs) return _wrapper @_verify def show_pool_statistics(raw: bool, family: ArgFamily, pool: typing.Optional[str]): pool_data = _get_raw_pool_statistics(family=family, pool=pool) if raw: return pool_data else: return _get_formatted_pool_statistics(pool_data, family=family) @_verify def show_server_leases(raw: bool, family: ArgFamily, pool: typing.Optional[str], sorted: typing.Optional[str], state: typing.Optional[ArgState], origin: typing.Optional[ArgOrigin] ): # if dhcp server is down, inactive leases may still be shown as active, so warn the user. v = '6' if family == 'inet6' else '4' if not is_systemd_service_running(f'kea-dhcp{v}-server.service'): Warning('DHCP server is configured but not started. Data may be stale.') v = 'v6' if family == 'inet6' else '' if pool and pool not in _get_dhcp_pools(family=family): raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') if state and state not in lease_valid_states: raise vyos.opmode.IncorrectValue(f'DHCP{v} state "{state}" is invalid!') sort_valid = sort_valid_inet6 if family == 'inet6' else sort_valid_inet if sorted and sorted not in sort_valid: raise vyos.opmode.IncorrectValue(f'DHCP{v} sort "{sorted}" is invalid!') lease_data = _get_raw_server_leases(family=family, pool=pool, sorted=sorted, state=state, origin=origin) if raw: return lease_data else: return _get_formatted_server_leases(lease_data, family=family) def _get_raw_client_leases(family='inet', interface=None): from time import mktime from datetime import datetime from vyos.defaults import directories from vyos.utils.network import get_interface_vrf lease_dir = directories['isc_dhclient_dir'] lease_files = [] lease_data = [] if interface: tmp = f'{lease_dir}/dhclient_{interface}.lease' if os.path.exists(tmp): lease_files.append(tmp) else: # All DHCP leases lease_files = glob(f'{lease_dir}/dhclient_*.lease') for lease in lease_files: tmp = {} with open(lease, 'r') as f: for line in f.readlines(): line = line.rstrip() if 'last_update' not in tmp: # ISC dhcp client contains least_update timestamp in human readable # format this makes less sense for an API and also the expiry # timestamp is provided in UNIX time. Convert string (e.g. Sun Jul # 30 18:13:44 CEST 2023) to UNIX time (1690733624) tmp.update({'last_update' : int(mktime(datetime.strptime(line, time_string).timetuple()))}) continue k, v = line.split('=') tmp.update({k : v.replace("'", "")}) if 'interface' in tmp: vrf = get_interface_vrf(tmp['interface']) if vrf: tmp.update({'vrf' : vrf}) lease_data.append(tmp) return lease_data def _get_formatted_client_leases(lease_data, family): from time import localtime from time import strftime from vyos.utils.network import is_intf_addr_assigned data_entries = [] for lease in lease_data: if not lease.get('new_ip_address'): continue data_entries.append(["Interface", lease['interface']]) if 'new_ip_address' in lease: tmp = '[Active]' if is_intf_addr_assigned(lease['interface'], lease['new_ip_address']) else '[Inactive]' data_entries.append(["IP address", lease['new_ip_address'], tmp]) if 'new_subnet_mask' in lease: data_entries.append(["Subnet Mask", lease['new_subnet_mask']]) if 'new_domain_name' in lease: data_entries.append(["Domain Name", lease['new_domain_name']]) if 'new_routers' in lease: data_entries.append(["Router", lease['new_routers']]) if 'new_domain_name_servers' in lease: data_entries.append(["Name Server", lease['new_domain_name_servers']]) if 'new_dhcp_server_identifier' in lease: data_entries.append(["DHCP Server", lease['new_dhcp_server_identifier']]) if 'new_dhcp_lease_time' in lease: data_entries.append(["DHCP Server", lease['new_dhcp_lease_time']]) if 'vrf' in lease: data_entries.append(["VRF", lease['vrf']]) if 'last_update' in lease: tmp = strftime(time_string, localtime(int(lease['last_update']))) data_entries.append(["Last Update", tmp]) if 'new_expiry' in lease: tmp = strftime(time_string, localtime(int(lease['new_expiry']))) data_entries.append(["Expiry", tmp]) # Add empty marker data_entries.append(['']) output = tabulate(data_entries, tablefmt='plain') return output def show_client_leases(raw: bool, family: ArgFamily, interface: typing.Optional[str]): lease_data = _get_raw_client_leases(family=family, interface=interface) if raw: return lease_data else: return _get_formatted_client_leases(lease_data, family=family) if __name__ == '__main__': try: res = vyos.opmode.run(sys.modules[__name__]) if res: print(res) except (ValueError, vyos.opmode.Error) as e: print(e) sys.exit(1)