diff options
Diffstat (limited to 'src/conf_mode/dhcp_server.py')
-rwxr-xr-x | src/conf_mode/dhcp_server.py | 625 |
1 files changed, 625 insertions, 0 deletions
diff --git a/src/conf_mode/dhcp_server.py b/src/conf_mode/dhcp_server.py new file mode 100755 index 000000000..0eaa14c5b --- /dev/null +++ b/src/conf_mode/dhcp_server.py @@ -0,0 +1,625 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018-2020 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, ip_network +from socket import inet_ntoa +from struct import pack +from sys import exit + +from vyos.config import Config +from vyos.validate import is_subnet_connected +from vyos import ConfigError +from vyos.template import render +from vyos.util import call, chown + +from vyos import airbag +airbag.enable() + +config_file = r'/run/dhcp-server/dhcpd.conf' + +default_config_data = { + 'disabled': False, + 'ddns_enable': False, + 'global_parameters': [], + 'hostfile_update': False, + 'host_decl_name': False, + 'static_route': False, + 'wpad': False, + 'shared_network': [], +} + +def dhcp_slice_range(exclude_list, range_list): + """ + 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_list' 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) + for ra in range_list: + range_start = ra['start'] + range_stop = ra['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 IP address range ending one IP address before exclude address + r = { + 'start' : range_start, + 'stop' : str(ip_address(e) -1) + } + # On the next run our IP 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 we have no exclude in the whole range - we just take the range + # as it is + if not range_last_exclude: + if ra not in output: + output.append(ra) + + return output + +def dhcp_static_route(static_subnet, static_router): + # https://ercpe.de/blog/pushing-static-routes-with-isc-dhcp-server + # Option format is: + # <netmask>, <network-byte1>, <network-byte2>, <network-byte3>, <router-byte1>, <router-byte2>, <router-byte3> + # where bytes with the value 0 are omitted. + net = ip_network(static_subnet) + # add netmask + string = str(net.prefixlen) + ',' + # add network bytes + if net.prefixlen: + width = net.prefixlen // 8 + if net.prefixlen % 8: + width += 1 + string += ','.join(map(str,tuple(net.network_address.packed)[:width])) + ',' + + # add router bytes + string += ','.join(static_router.split('.')) + + return string + +def get_config(): + dhcp = default_config_data + conf = Config() + if not conf.exists('service dhcp-server'): + return None + else: + conf.set_level('service dhcp-server') + + # check for global disable of DHCP service + if conf.exists('disable'): + dhcp['disabled'] = True + + # check for global dynamic DNS upste + if conf.exists('dynamic-dns-update'): + dhcp['ddns_enable'] = True + + # HACKS AND TRICKS + # + # check for global 'raw' ISC DHCP parameters configured by users + # actually this is a bad idea in general to pass raw parameters from any user + if conf.exists('global-parameters'): + dhcp['global_parameters'] = conf.return_values('global-parameters') + + # check for global DHCP server updating /etc/host per lease + if conf.exists('hostfile-update'): + dhcp['hostfile_update'] = True + + # If enabled every host declaration within that scope, the name provided + # for the host declaration will be supplied to the client as its hostname. + if conf.exists('host-decl-name'): + dhcp['host_decl_name'] = True + + # check for multiple, shared networks served with DHCP addresses + if conf.exists('shared-network-name'): + for network in conf.list_nodes('shared-network-name'): + conf.set_level('service dhcp-server shared-network-name {0}'.format(network)) + config = { + 'name': network, + 'authoritative': False, + 'description': '', + 'disabled': False, + 'network_parameters': [], + 'subnet': [] + } + # check if DHCP server should be authoritative on this network + if conf.exists('authoritative'): + config['authoritative'] = True + + # A description for this given network + if conf.exists('description'): + config['description'] = conf.return_value('description') + + # If disabled, the shared-network configuration becomes inactive in + # the running DHCP server instance + if conf.exists('disable'): + config['disabled'] = True + + # HACKS AND TRICKS + # + # check for 'raw' ISC DHCP parameters configured by users + # actually this is a bad idea in general to pass raw parameters + # from any user + # + # deprecate this and issue a warning like we do for DNS forwarding? + if conf.exists('shared-network-parameters'): + config['network_parameters'] = conf.return_values('shared-network-parameters') + + # check for multiple subnet configurations in a shared network + # config segment + if conf.exists('subnet'): + for net in conf.list_nodes('subnet'): + conf.set_level('service dhcp-server shared-network-name {0} subnet {1}'.format(network, net)) + subnet = { + 'network': net, + 'address': str(ip_network(net).network_address), + 'netmask': str(ip_network(net).netmask), + 'bootfile_name': '', + 'bootfile_server': '', + 'client_prefix_length': '', + 'default_router': '', + 'rfc3442_default_router': '', + 'dns_server': [], + 'domain_name': '', + 'domain_search': [], + 'exclude': [], + 'failover_local_addr': '', + 'failover_name': '', + 'failover_peer_addr': '', + 'failover_status': '', + 'ip_forwarding': False, + 'lease': '86400', + 'ntp_server': [], + 'pop_server': [], + 'server_identifier': '', + 'smtp_server': [], + 'range': [], + 'static_mapping': [], + 'static_subnet': '', + 'static_router': '', + 'static_route': '', + 'subnet_parameters': [], + 'tftp_server': '', + 'time_offset': '', + 'time_server': [], + 'wins_server': [], + 'wpad_url': '' + } + + # Used to identify a bootstrap file + if conf.exists('bootfile-name'): + subnet['bootfile_name'] = conf.return_value('bootfile-name') + + # Specify host address of the server from which the initial boot file + # (specified above) is to be loaded. Should be a numeric IP address or + # domain name. + if conf.exists('bootfile-server'): + subnet['bootfile_server'] = conf.return_value('bootfile-server') + + # The subnet mask option specifies the client's subnet mask as per RFC 950. If no subnet + # mask option is provided anywhere in scope, as a last resort dhcpd will use the subnet + # mask from the subnet declaration for the network on which an address is being assigned. + if conf.exists('client-prefix-length'): + # snippet borrowed from https://stackoverflow.com/questions/33750233/convert-cidr-to-subnet-mask-in-python + host_bits = 32 - int(conf.return_value('client-prefix-length')) + subnet['client_prefix_length'] = inet_ntoa(pack('!I', (1 << 32) - (1 << host_bits))) + + # Default router IP address on the client's subnet + if conf.exists('default-router'): + subnet['default_router'] = conf.return_value('default-router') + subnet['rfc3442_default_router'] = dhcp_static_route("0.0.0.0/0", subnet['default_router']) + + # Specifies a list of Domain Name System (STD 13, RFC 1035) name servers available to + # the client. Servers should be listed in order of preference. + if conf.exists('dns-server'): + subnet['dns_server'] = conf.return_values('dns-server') + + # Option specifies the domain name that client should use when resolving hostnames + # via the Domain Name System. + if conf.exists('domain-name'): + subnet['domain_name'] = conf.return_value('domain-name') + + # The domain-search option specifies a 'search list' of Domain Names to be used + # by the client to locate not-fully-qualified domain names. + if conf.exists('domain-search'): + for domain in conf.return_values('domain-search'): + subnet['domain_search'].append('"' + domain + '"') + + # IP address (local) for failover peer to connect + if conf.exists('failover local-address'): + subnet['failover_local_addr'] = conf.return_value('failover local-address') + + # DHCP failover peer name + if conf.exists('failover name'): + subnet['failover_name'] = conf.return_value('failover name') + + # IP address (remote) of failover peer + if conf.exists('failover peer-address'): + subnet['failover_peer_addr'] = conf.return_value('failover peer-address') + + # DHCP failover peer status (primary|secondary) + if conf.exists('failover status'): + subnet['failover_status'] = conf.return_value('failover status') + + # Option specifies whether the client should configure its IP layer for packet + # forwarding + if conf.exists('ip-forwarding'): + subnet['ip_forwarding'] = True + + # Time should be the length in seconds that will be assigned to a lease if the + # client requesting the lease does not ask for a specific expiration time + if conf.exists('lease'): + subnet['lease'] = conf.return_value('lease') + + # Specifies a list of IP addresses indicating NTP (RFC 5905) servers available + # to the client. + if conf.exists('ntp-server'): + subnet['ntp_server'] = conf.return_values('ntp-server') + + # POP3 server option specifies a list of POP3 servers available to the client. + # Servers should be listed in order of preference. + if conf.exists('pop-server'): + subnet['pop_server'] = conf.return_values('pop-server') + + # DHCP servers include this option in the DHCPOFFER in order to allow the client + # to distinguish between lease offers. DHCP clients use the contents of the + # 'server identifier' field as the destination address for any DHCP messages + # unicast to the DHCP server + if conf.exists('server-identifier'): + subnet['server_identifier'] = conf.return_value('server-identifier') + + # SMTP server option specifies a list of SMTP servers available to the client. + # Servers should be listed in order of preference. + if conf.exists('smtp-server'): + subnet['smtp_server'] = conf.return_values('smtp-server') + + # For any subnet on which addresses will be assigned dynamically, there must be at + # least one range statement. The range statement gives the lowest and highest IP + # addresses in a range. All IP addresses in the range should be in the subnet in + # which the range statement is declared. + if conf.exists('range'): + for range in conf.list_nodes('range'): + range = { + 'start': conf.return_value('range {0} start'.format(range)), + 'stop': conf.return_value('range {0} stop'.format(range)) + } + subnet['range'].append(range) + + # IP address that needs to be excluded from DHCP lease range + if conf.exists('exclude'): + subnet['exclude'] = conf.return_values('exclude') + subnet['range'] = dhcp_slice_range(subnet['exclude'], subnet['range']) + + # Static DHCP leases + if conf.exists('static-mapping'): + addresses_for_exclude = [] + for mapping in conf.list_nodes('static-mapping'): + conf.set_level('service dhcp-server shared-network-name {0} subnet {1} static-mapping {2}'.format(network, net, mapping)) + mapping = { + 'name': mapping, + 'disabled': False, + 'ip_address': '', + 'mac_address': '', + 'static_parameters': [] + } + + # This static lease is disabled + if conf.exists('disable'): + mapping['disabled'] = True + + # IP address used for this DHCP client + if conf.exists('ip-address'): + mapping['ip_address'] = conf.return_value('ip-address') + addresses_for_exclude.append(mapping['ip_address']) + + # MAC address of requesting DHCP client + if conf.exists('mac-address'): + mapping['mac_address'] = conf.return_value('mac-address') + + # HACKS AND TRICKS + # + # check for 'raw' ISC DHCP parameters configured by users + # actually this is a bad idea in general to pass raw parameters + # from any user + # + # deprecate this and issue a warning like we do for DNS forwarding? + if conf.exists('static-mapping-parameters'): + mapping['static_parameters'] = conf.return_values('static-mapping-parameters') + + # append static-mapping configuration to subnet list + subnet['static_mapping'].append(mapping) + + # Now we have all static DHCP leases - we also need to slice them + # out of our DHCP ranges to avoid ISC DHCPd warnings as: + # dhcpd: Dynamic and static leases present for 192.0.2.51. + # dhcpd: Remove host declaration DMZ_PC1 or remove 192.0.2.51 + # dhcpd: from the dynamic address pool for DMZ + subnet['range'] = dhcp_slice_range(addresses_for_exclude, subnet['range']) + + # Reset config level to matching hirachy + conf.set_level('service dhcp-server shared-network-name {0} subnet {1}'.format(network, net)) + + # This option specifies a list of static routes that the client should install in its routing + # cache. If multiple routes to the same destination are specified, they are listed in descending + # order of priority. + if conf.exists('static-route destination-subnet'): + subnet['static_subnet'] = conf.return_value('static-route destination-subnet') + # Required for global config section + dhcp['static_route'] = True + + if conf.exists('static-route router'): + subnet['static_router'] = conf.return_value('static-route router') + + if subnet['static_router'] and subnet['static_subnet']: + subnet['static_route'] = dhcp_static_route(subnet['static_subnet'], subnet['static_router']) + + # HACKS AND TRICKS + # + # check for 'raw' ISC DHCP parameters configured by users + # actually this is a bad idea in general to pass raw parameters + # from any user + # + # deprecate this and issue a warning like we do for DNS forwarding? + if conf.exists('subnet-parameters'): + subnet['subnet_parameters'] = conf.return_values('subnet-parameters') + + # This option is used to identify a TFTP server and, if supported by the client, should have + # the same effect as the server-name declaration. BOOTP clients are unlikely to support this + # option. Some DHCP clients will support it, and others actually require it. + if conf.exists('tftp-server-name'): + subnet['tftp_server'] = conf.return_value('tftp-server-name') + + # The time-offset option specifies the offset of the client’s subnet in seconds from + # Coordinated Universal Time (UTC). + if conf.exists('time-offset'): + subnet['time_offset'] = conf.return_value('time-offset') + + # The time-server option specifies a list of RFC 868 time servers available to the client. + # Servers should be listed in order of preference. + if conf.exists('time-server'): + subnet['time_server'] = conf.return_values('time-server') + + # The NetBIOS name server (NBNS) option specifies a list of RFC 1001/1002 NBNS name servers + # listed in order of preference. NetBIOS Name Service is currently more commonly referred to + # as WINS. WINS servers can be specified using the netbios-name-servers option. + if conf.exists('wins-server'): + subnet['wins_server'] = conf.return_values('wins-server') + + # URL for Web Proxy Autodiscovery Protocol + if conf.exists('wpad-url'): + subnet['wpad_url'] = conf.return_value('wpad-url') + # Required for global config section + dhcp['wpad'] = True + + # append subnet configuration to shared network subnet list + config['subnet'].append(subnet) + + # append shared network configuration to config dictionary + dhcp['shared_network'].append(config) + + return dhcp + +def verify(dhcp): + if not dhcp or dhcp['disabled']: + return None + + # If DHCP is enabled we need one share-network + if len(dhcp['shared_network']) == 0: + raise ConfigError('No DHCP shared networks configured.\n' \ + 'At least one DHCP shared network must be configured.') + + # Inspect shared-network/subnet + failover_names = [] + listen_ok = False + subnets = [] + + # A shared-network requires a subnet definition + for network in dhcp['shared_network']: + if len(network['subnet']) == 0: + raise ConfigError('No DHCP lease subnets configured for {0}. At least one\n' \ + 'lease subnet must be configured for each shared network.'.format(network['name'])) + + for subnet in network['subnet']: + # Subnet static route declaration requires destination and router + if subnet['static_subnet'] or subnet['static_router']: + if not (subnet['static_subnet'] and subnet['static_router']): + raise ConfigError('Please specify missing DHCP static-route parameter(s):\n' \ + 'destination-subnet | router') + + # Failover requires all 4 parameters set + if subnet['failover_local_addr'] or subnet['failover_peer_addr'] or subnet['failover_name'] or subnet['failover_status']: + if not (subnet['failover_local_addr'] and subnet['failover_peer_addr'] and subnet['failover_name'] and subnet['failover_status']): + raise ConfigError('Please specify missing DHCP failover parameter(s):\n' \ + 'local-address | peer-address | name | status') + + # Failover names must be uniquie + if subnet['failover_name'] in failover_names: + raise ConfigError('Failover names must be unique:\n' \ + '{0} has already been configured!'.format(subnet['failover_name'])) + else: + failover_names.append(subnet['failover_name']) + + # Failover requires start/stop ranges for pool + if (len(subnet['range']) == 0): + raise ConfigError('At least one start-stop range must be configured for {0}\n' \ + 'to set up DHCP failover!'.format(subnet['network'])) + + # Check if DHCP address range is inside configured subnet declaration + range_start = [] + range_stop = [] + for range in subnet['range']: + start = range['start'] + stop = range['stop'] + # DHCP stop IP required after start IP + if start and not stop: + raise ConfigError('DHCP range stop address for start {0} is not defined!'.format(start)) + + # Start address must be inside network + if not ip_address(start) in ip_network(subnet['network']): + raise ConfigError('DHCP range start address {0} is not in subnet {1}\n' \ + 'specified for shared network {2}!'.format(start, subnet['network'], network['name'])) + + # Stop address must be inside network + if not ip_address(stop) in ip_network(subnet['network']): + raise ConfigError('DHCP range stop address {0} is not in subnet {1}\n' \ + 'specified for shared network {2}!'.format(stop, subnet['network'], network['name'])) + + # Stop address must be greater or equal to start address + if not ip_address(stop) >= ip_address(start): + raise ConfigError('DHCP range stop address {0} must be greater or equal\n' \ + 'to the range start address {1}!'.format(stop, start)) + + # Range start address must be unique + if start in range_start: + raise ConfigError('Conflicting DHCP lease range:\n' \ + 'Pool start address {0} defined multipe times!'.format(start)) + else: + range_start.append(start) + + # Range stop address must be unique + if stop in range_stop: + raise ConfigError('Conflicting DHCP lease range:\n' \ + 'Pool stop address {0} defined multipe times!'.format(stop)) + else: + range_stop.append(stop) + + # Exclude addresses must be in bound + for exclude in subnet['exclude']: + if not ip_address(exclude) in ip_network(subnet['network']): + raise ConfigError('Exclude IP address {0} is outside of the DHCP lease network {1}\n' \ + 'under shared network {2}!'.format(exclude, subnet['network'], network['name'])) + + # At least one DHCP address range or static-mapping required + active_mapping = False + if (len(subnet['range']) == 0): + for mapping in subnet['static_mapping']: + # we need at least one active mapping + if (not active_mapping) and (not mapping['disabled']): + active_mapping = True + else: + active_mapping = True + + if not active_mapping: + raise ConfigError('No DHCP address range or active static-mapping set\n' \ + 'for subnet {0}!'.format(subnet['network'])) + + # Static mappings require just a MAC address (will use an IP from the dynamic pool if IP is not set) + for mapping in subnet['static_mapping']: + + if mapping['ip_address']: + # Static IP address must be in bound + if not ip_address(mapping['ip_address']) in ip_network(subnet['network']): + raise ConfigError('DHCP static lease IP address {0} for static mapping {1}\n' \ + 'in shared network {2} is outside DHCP lease subnet {3}!' \ + .format(mapping['ip_address'], mapping['name'], network['name'], subnet['network'])) + + # Static mapping requires MAC address + if not mapping['mac_address']: + raise ConfigError('DHCP static lease MAC address not specified for static mapping\n' \ + '{0} under shared network name {1}!'.format(mapping['name'], network['name'])) + + # There must be one subnet connected to a listen interface. + # This only counts if the network itself is not disabled! + if not network['disabled']: + if is_subnet_connected(subnet['network'], primary=True): + listen_ok = True + + # Subnets must be non overlapping + if subnet['network'] in subnets: + raise ConfigError('DHCP subnets must be unique! Subnet {0} defined multiple times!'.format(subnet['network'])) + else: + subnets.append(subnet['network']) + + # Check for overlapping subnets + net = ip_network(subnet['network']) + for n in subnets: + net2 = ip_network(n) + if (net != net2): + if net.overlaps(net2): + raise ConfigError('DHCP conflicting subnet ranges: {0} overlaps {1}'.format(net, net2)) + + if not listen_ok: + raise ConfigError('DHCP server configuration error!\n' \ + 'None of configured DHCP subnets does not have appropriate\n' \ + 'primary IP address on any broadcast interface.') + + return None + +def generate(dhcp): + if not dhcp or dhcp['disabled']: + return None + + # Please see: https://phabricator.vyos.net/T1129 for quoting of the raw parameters + # we can pass to ISC DHCPd + render(config_file, 'dhcp-server/dhcpd.conf.tmpl', dhcp, + formater=lambda _: _.replace(""", '"')) + return None + +def apply(dhcp): + if not dhcp or dhcp['disabled']: + # DHCP server is removed in the commit + call('systemctl stop isc-dhcp-server.service') + if os.path.exists(config_file): + os.unlink(config_file) + return None + + call('systemctl restart isc-dhcp-server.service') + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) |