From 77c1b3457439889846380c5fd5da30cd11e253d9 Mon Sep 17 00:00:00 2001 From: Christian Poessinger Date: Thu, 16 Aug 2018 21:04:28 +0200 Subject: T778: T782: dhcp-server: XML and Python rewrite This commit changes in addtion the DHCP server config syntax as defined in "T782: Cleanup dhcp-server configuration". Replace boolean parameter from the folowing nodes and make it valueless. This requires a migration script which is tracked with this task * set service dhcp-server shared-network-name subnet 172.31.0.0/24 ip-forwarding enable (true|false) * set service dhcp-server shared-network-name authoritative (true|false) * set service dhcp-server disabled (true|false) * set service dhcp-server dynamic-dns-update enable (true|fals) * set service dhcp-server hostfile-update (enable|disable) Replace the nested start/stop ip address from "subnet 172.31.0.0/24 start 172.31.0.101 stop 172.31.0.149" to "subnet 172.31.0.0/24 range start" and "subnet 172.31.0.0/24 range stop" where foo can be any character or number. In addition the vyatta-cfg-dhcp-server package used it's own init/config file for service startup. This has been migrated to the vanilla Debian files. Copy 'on-dhcp-event.sh' from vyatta-cfg-shcp-server package commit 4749e648bca6. --- src/conf_mode/dhcp_server.py | 773 +++++++++++++++++++++++++++++++ src/migration-scripts/dhcp-server/4-to-5 | 121 +++++ src/system/on-dhcp-event.sh | 98 ++++ 3 files changed, 992 insertions(+) create mode 100755 src/conf_mode/dhcp_server.py create mode 100755 src/migration-scripts/dhcp-server/4-to-5 create mode 100755 src/system/on-dhcp-event.sh (limited to 'src') diff --git a/src/conf_mode/dhcp_server.py b/src/conf_mode/dhcp_server.py new file mode 100755 index 000000000..6dc8bf2bb --- /dev/null +++ b/src/conf_mode/dhcp_server.py @@ -0,0 +1,773 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2018 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 . +# +# + +import sys +import os +import ipaddress +import jinja2 +import socket +import struct +import netifaces + +from vyos.config import Config +from vyos import ConfigError + +config_file = r'/etc/dhcp/dhcpd.conf' +daemon_config_file = r'/etc/default/isc-dhcp-server' + +# Please be careful if you edit the template. +config_tmpl = """ +### Autogenerated by dhcp_server.py ### + +log-facility local7; + +{% if hostfile_update %} +on commit { + set ClientName = pick-first-value(host-decl-name, option fqdn.hostname, option host-name); + set ClientIp = binary-to-ascii(10, 8, ".", leased-address); + set ClientMac = binary-to-ascii(16, 8, ":", substring(hardware, 1, 6)); + set ClientDomain = pick-first-value(config-option domain-name, "..YYZ!"); + execute("/usr/libexec/vyos/system/on-dhcp-event.sh", "commit", ClientName, ClientIp, ClientMac, ClientDomain); +} + +on release { + set ClientName = pick-first-value(host-decl-name, option fqdn.hostname, option host-name); + set ClientIp = binary-to-ascii(10, 8, ".",leased-address); + set ClientMac = binary-to-ascii(16, 8, ":",substring(hardware, 1, 6)); + set ClientDomain = pick-first-value(config-option domain-name, "..YYZ!"); + execute("/usr/libexec/vyos/system/on-dhcp-event.sh", "release", ClientName, ClientIp, ClientMac, ClientDomain); +} + +on expiry { + set ClientName = pick-first-value(host-decl-name, option fqdn.hostname, option host-name); + set ClientIp = binary-to-ascii(10, 8, ".",leased-address); + set ClientMac = binary-to-ascii(16, 8, ":",substring(hardware, 1, 6)); + set ClientDomain = pick-first-value(config-option domain-name, "..YYZ!"); + execute("/usr/libexec/vyos/system/on-dhcp-event.sh", "release", ClientName, ClientIp, ClientMac, ClientDomain); +} +{% endif %} +ddns-update-style {% if ddns_enable -%} interim {%- else -%} none {%- endif %}; +{% if static_route -%} +option rfc3442-static-route code 121 = array of integer 8; +option windows-static-route code 249 = array of integer 8; +{%- endif %} +{% if static_route -%} +option wpad-url code 252 = text; +{% endif %} + +{%- if global_parameters %} +# The following {{ global_parameters | length }} line(s) were added as global-parameters in the CLI and have not been validated +{%- for param in global_parameters %} +{{ param }} +{%- endfor -%} +{%- endif %} + +# Failover configuration +{% for network in shared_network %} +{%- if not network.disable -%} +{%- for subnet in network.subnet %} +{%- if subnet.failover_name -%} +failover peer "{{ subnet.failover_name }}" { +{%- if subnet.failover_status == 'primary' %} + primary; + mclt 1800; + split 128; +{%- elif subnet.failover_status == 'secondary' %} + secondary; +{%- endif %} + address {{ subnet.failover_local_addr }}; + port 520; + peer address {{ subnet.failover_peer_addr }}; + peer port 520; + max-response-delay 30; + max-unacked-updates 10; + load balance max seconds 3; +} +{% endif -%} +{% endfor -%} +{% endif -%} +{% endfor %} + +# Shared network configrations +{% for network in shared_network %} +{%- if not network.disable -%} +shared-network {{ network.name }} { + {% if network.authoritative %}authoritative;{% endif %} + {%- if network.network_parameters %} + # The following {{ network.network_parameters | length }} line(s) were added as shared-network-parameters in the CLI and have not been validated + {%- for param in network.network_parameters %} + {{ param }} + {%- endfor -%} + {%- endif %} + {%- for subnet in network.subnet %} + subnet {{ subnet.address }} netmask {{ subnet.netmask }} { + {%- if subnet.dns_server %} + option domain-name-servers {{ subnet.dns_server | join(', ') }}; + {%- endif %} + {%- if subnet.domain_search %} + option domain-search {{ subnet.domain_search | join(', ') }}; + {%- endif %} + {%- if subnet.ntp_server %} + option ntp-servers {{ subnet.ntp_server | join(', ') }}; + {%- endif %} + {%- if subnet.pop_server %} + option pop-server {{ subnet.pop_server | join(', ') }}; + {%- endif %} + {%- if subnet.smtp_server %} + option smtp-server {{ subnet.smtp_server | join(', ') }}; + {%- endif %} + {%- if subnet.time_server %} + option time-servers {{ subnet.time_server | join(', ') }}; + {%- endif %} + {%- if subnet.wins_server %} + option netbios-name-servers {{ subnet.wins_server | join(', ') }}; + {%- endif %} + {%- if subnet.static_route %} + option rfc3442-static-route {{ subnet.static_route }}; + option windows-static-route {{ subnet.static_route }}; + {%- endif %} + {%- if subnet.ip_forwarding %} + option ip-forwarding true; + {%- endif -%} + {%- if subnet.default_router %} + option routers {{ subnet.default_router }}; + {%- endif -%} + {%- if subnet.server_identifier %} + option dhcp-server-identifier {{ subnet.server_identifier }}; + {%- endif -%} + {%- if subnet.domain_name %} + option domain-name "{{ subnet.domain_name }}"; + {%- endif -%} + {%- if subnet.tftp_server %} + option tftp-server-name "{{ subnet.tftp_server }}"; + {%- endif -%} + {%- if subnet.bootfile_name %} + option bootfile-name "{{ subnet.bootfile_name }}"; + filename "{{ subnet.bootfile_name }}"; + {%- endif -%} + {%- if subnet.bootfile_server %} + next-server {{ subnet.bootfile_server }}; + {%- endif -%} + {%- if subnet.time_offset %} + option time-offset {{ subnet.time_offset }}; + {%- endif -%} + {%- if subnet.wpad_url %} + option wpad-url "{{ subnet.wpad_url }}"; + {%- endif -%} + {%- if subnet.client_prefix_length %} + option subnet-mask {{ subnet.client_prefix_length }}; + {%- endif -%} + {% if subnet.lease %} + default-lease-time {{ subnet.lease }}; + max-lease-time {{ subnet.lease }}; + {%- endif -%} + {%- for host in subnet.static_mapping %} + {% if not host.disable -%} + host {{ network.name }}_{{ host.name }} { + fixed-address {{ host.ip_address }}; + hardware ethernet {{ host.mac_address }}; + {%- if host.static_parameters %} + # The following {{ host.static_parameters | length }} line(s) were added as static-mapping-parameters in the CLI and have not been validated + {%- for param in host.static_parameters %} + {{ param }} + {%- endfor -%} + {%- endif %} + } + {%- endif %} + {%- endfor %} + {%- for range in subnet.range %} + range {{ range.start }} {{ range.stop }}; + {%- endfor %} + } + {%- endfor %} + on commit { set shared-networkname = "{{ network.name }}"; } +} +{%- endif %} +{% endfor %} +""" + +daemon_tmpl = """ +### Autogenerated by dhcp_server.py ### + +# sourced by /etc/init.d/isc-dhcp-server + +# Path to dhcpd's config file (default: /etc/dhcp/dhcpd.conf). +DHCPD_CONF=/etc/dhcp/dhcpd.conf + +# Path to dhcpd's PID file (default: /var/run/dhcpd.pid). +DHCPD_PID=/var/run/dhcpd.pid + +# Additional options to start dhcpd with. +# Don't use options -cf or -pf here; use DHCPD_CONF/ DHCPD_PID instead +OPTIONS="-lf /config/dhcpd.leases" + +# On what interfaces should the DHCP server (dhcpd) serve DHCP requests? +# Separate multiple interfaces with spaces, e.g. "eth0 eth1". +INTERFACES="" +""" + +default_config_data = { + 'disable': False, + 'ddns_enable': False, + 'global_parameters': [], + 'hostfile_update': False, + 'static_route': False, + 'wpad': False, + 'shared_network': [], +} + +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['disable'] = 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 + + # 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': '', + 'disable': 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['disable'] = 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(ipaddress.ip_network(net).network_address), + 'netmask': str(ipaddress.ip_network(net).netmask), + 'bootfile_name': '', + 'bootfile_server': '', + 'client_prefix_length': '', + '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'] = socket.inet_ntoa(struct.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') + + # 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'): + # We have no need to store the exclude addresses. Exclude addresses + # are recalculated into several ranges + exclude = [] + subnet['exclude'] = conf.return_values('exclude') + for addr in subnet['exclude']: + exclude.append(ipaddress.ip_address(addr)) + + # sort excluded IP addresses ascending + exclude = sorted(exclude) + + # calculate multipe ranges based on the excluded IP addresses + output = [] + for range in subnet['range']: + range_start = range['start'] + range_stop = range['stop'] + + for i in exclude: + # Excluded IP address must be in out specified range + if (i >= ipaddress.ip_address(range_start)) and (i <= ipaddress.ip_address(range_stop)): + # Build up new IP address range ending one IP address before + # our exclude address + range = { + 'start': str(range_start), + 'stop': str(i - 1) + } + # Our next IP address range will start one address after + # our exclude address + range_start = i + 1 + output.append(range) + + # Take care of last IP address range spanning from the last exclude + # address (+1) to the end of the initial configured range + if i is exclude[-1]: + last = { + 'start': str(i + 1), + 'stop': str(range_stop) + } + output.append(last) + else: + # IP address not inside search range, take range is it is + output.append(range) + + # We successfully build up a new list containing several IP address + # ranges, replace IP address range in our dictionary + subnet['range'] = output + + # Static DHCP leases + if conf.exists('static-mapping'): + 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, + 'disable': False, + 'ip_address': '', + 'mac_address': '', + 'static_parameters': [] + } + + # This static lease is disabled + if conf.exists('disable'): + mapping['disable'] = True + + # IP address used for this DHCP client + if conf.exists('ip-address'): + mapping['ip_address'] = conf.return_value('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) + + # 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']: + # https://ercpe.de/blog/pushing-static-routes-with-isc-dhcp-server + # Option format is: + # , , , , , , + # where bytes with the value 0 are omitted. + net = ipaddress.ip_network(subnet['static_subnet']) + # add netmask + string = str(net.prefixlen) + ',' + # add network bytes + bytes = str(net.network_address).split('.') + for b in bytes: + if b != '0': + string += b + ',' + + # add router bytes + bytes = subnet['static_router'].split('.') + for b in bytes: + if b != '0': + string += b + if b is not bytes[-1]: + string += ',' + + subnet['static_route'] = string + + # 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'): + config['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 dhcp is None: + return None + + # If DHCP is enabled we need one share-network + if len(dhcp['shared_network']) == 0: + raise ConfigError('No DHCP shared networks configured. At least one DHCP shared network must be configured.') + + # 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 DHCP lease subnet must be configured for each shared network.'.format(network['name'])) + + # Inspect our subnet configuration + failover_names = [] + listen_ok = False + subnets = [] + for network in dhcp['shared_network']: + 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 the missing DHCP static-route parameter: 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 set one or more of the missing DHCP failover parameters: local-address | peer-address | name | status') + + # Failover names must be uniquie + if subnet['failover_name'] in failover_names: + raise ConfigError('Failover names should be unique: "{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('Atleast one start-stop range must be configured for $subnet to set up DHCP failover.') + + # Check if DHCP address range is inside configured subnet declaration + range_start = [] + range_stop = [] + for range in subnet['range']: + # DHCP stop IP required after start IP + if range['start'] and not range['stop']: + raise ConfigError('DHCP range stop IP not defined for range start IP "{0}".'.format(range['start'])) + + # Start address must be inside network + if not ipaddress.ip_address(range['start']) in ipaddress.ip_network(subnet['network']): + raise ConfigError('DHCP range start IP "{0}" is not in subnet "{1}" specified in network "{2}."'.format(range['start'], subnet['network'], network['name'])) + + # Stop address must be inside network + if not ipaddress.ip_address(range['stop']) in ipaddress.ip_network(subnet['network']): + raise ConfigError('DHCP range stop IP "{0}" is not in subnet "{1}" specified in network "{2}."'.format(range['stop'], subnet['network'], network['name'])) + + # Stop address must be greater or equal to start address + if not ipaddress.ip_address(range['stop']) >= ipaddress.ip_address(range['start']): + raise ConfigError('DHCP range stop IP "{0}" should be an address greater or equal to the start address "{1}".'.format(range['stop'], range['start'])) + + # Range start address must be unique + if range['start'] in range_start: + raise ConfigError('Conflicting DHCP lease ranges: Pool start address "{0}" defined multipe times'.format(range['start'])) + else: + range_start.append(range['start']) + + # Range stop address must be unique + if range['stop'] in range_stop: + raise ConfigError('Conflicting DHCP lease ranges: Pool stop address "{0}" defined multipe times'.format(range['stop'])) + else: + range_stop.append(range['stop']) + + # Exclude addresses must be in bound + for exclude in subnet['exclude']: + if not ipaddress.ip_address(exclude) in ipaddress.ip_network(subnet['network']): + raise ConfigError('Exclude IP "{0}" is outside of the DHCP lease network "{1}" 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['disable']): + active_mapping = True + else: + active_mapping = True + + if not active_mapping: + raise ConfigError('No DHCP address range or active static-mapping set for subnet "{0}".'.format(subnet['network'])) + + # Static IP address mappings require both an IP address and MAC address + for mapping in subnet['static_mapping']: + # Static IP address must be configured + if not mapping['ip_address']: + raise ConfigError('No static lease IP address specified for static mapping "{0}" under shared network name "{1}".'.format(mapping['name'], network['name'])) + + # Static IP address must be in bound + if not ipaddress.ip_address(mapping['ip_address']) in ipaddress.ip_network(subnet['network']): + raise ConfigError('Static DHCP lease IP "{0}" under static mapping "{1}" in shared network "{2}" is outside DHCP lease network "{3}".'.format(mapping['ip_address'], mapping['name'], network['name'], subnet['network'], )) + + # Static mapping requires MAC address + if not mapping['mac_address']: + raise ConfigError('No static lease MAC address specified for static mapping "{0}" under shared network name "{1}".'.format(mapping['name'], network['name'])) + + # + # There must be one subnet connected to a real interface + # + for interface in netifaces.interfaces(): + # Retrieve IP address of network interface + ip = netifaces.ifaddresses(interface)[netifaces.AF_INET][0]['addr'] + if ipaddress.ip_address(ip) in ipaddress.ip_network(subnet['network']): + listen_ok = True + + # + # Subnets must be non overlapping + # + if subnet['network'] in subnets: + raise ConfigError('Subnets must be unique! "{0}" defined multiple times.'.format(subnet)) + else: + subnets.append(subnet['network']) + + # + # Check for overlapping subnets + # + net = ipaddress.ip_network(subnet['network']) + for n in subnets: + net2 = ipaddress.ip_network(n) + if (net.compare_networks(net2) != 0): + if net.overlaps(net2): + raise ConfigError('Conflicting subnet ranges: "{0}" overlaps "{1}"'.format(net, net2)) + + if not listen_ok: + raise ConfigError('None of the DHCP lease subnets are inside any configured subnet on broadcast interfaces. At least one lease subnet must be set such that DHCP server listens on a one broadcast interface') + + return None + +def generate(dhcp): + if dhcp is None: + return None + + tmpl = jinja2.Template(config_tmpl) + config_text = tmpl.render(dhcp) + with open(config_file, 'w') as f: + f.write(config_text) + + tmpl = jinja2.Template(daemon_tmpl) + config_text = tmpl.render(dhcp) + with open(daemon_config_file, 'w') as f: + f.write(config_text) + + return None + +def apply(dhcp): + if dhcp is not None: + os.system('sudo systemctl restart isc-dhcp-server.service') + else: + # DHCP server is removed in the commit + os.system('sudo systemctl stop isc-dhcp-server.service') + os.unlink(config_file) + os.unlink(daemon_config_file) + + return None + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + sys.exit(1) diff --git a/src/migration-scripts/dhcp-server/4-to-5 b/src/migration-scripts/dhcp-server/4-to-5 new file mode 100755 index 000000000..8b973d608 --- /dev/null +++ b/src/migration-scripts/dhcp-server/4-to-5 @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 + +# Removes boolean operator from: +# - "set service dhcp-server shared-network-name subnet 172.31.0.0/24 ip-forwarding enable (true|false)" +# - "set service dhcp-server shared-network-name authoritative (true|false)" +# - "set service dhcp-server disabled (true|false)" + +import sys + +from vyos.configtree import ConfigTree + +if (len(sys.argv) < 1): + print("Must specify file name!") + sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: + config_file = f.read() + +config = ConfigTree(config_file) + +if not config.exists(['service', 'dhcp-server']): + # Nothing to do + sys.exit(0) +else: + base = ['service', 'dhcp-server'] + # Make node "set service dhcp-server dynamic-dns-update enable (true|false)" valueless + if config.exists(base + ['dynamic-dns-update']): + bool_val = config.return_value(base + ['dynamic-dns-update', 'enable']) + + # Delete the node with the old syntax + config.delete(base + ['dynamic-dns-update']) + if str(bool_val) == 'true': + # Enable dynamic-dns-update with new syntax + config.set(base + ['dynamic-dns-update'], value=None) + + # Make node "set service dhcp-server disabled (true|false)" valueless + if config.exists(base + ['disabled']): + bool_val = config.return_value(base + ['disabled']) + + # Delete the node with the old syntax + config.delete(base + ['disabled']) + if str(bool_val) == 'true': + # Now disable DHCP server with the new syntax + config.set(base + ['disable'], value=None) + + # Make node "set service dhcp-server hostfile-update (enable|disable) valueless + if config.exists(base + ['hostfile-update']): + bool_val = config.return_value(base + ['hostfile-update']) + + # Delete the node with the old syntax incl. all subnodes + config.delete(base + ['hostfile-update']) + if str(bool_val) == 'enable': + # Enable hostfile update with new syntax + config.set(base + ['hostfile-update'], value=None) + + # Run this for every instance if 'shared-network-name' + for network in config.list_nodes(base + ['shared-network-name']): + base_network = base + ['shared-network-name', network] + # format as tag node to avoid loading problems + config.set_tag(base + ['shared-network-name']) + + # Run this for every specified 'subnet' + for subnet in config.list_nodes(base_network + ['subnet']): + base_subnet = base_network + ['subnet', subnet] + # format as tag node to avoid loading problems + config.set_tag(base_network + ['subnet']) + + # Make node "set service dhcp-server shared-network-name subnet 172.31.0.0/24 ip-forwarding enable" valueless + if config.exists(base_subnet + ['ip-forwarding', 'enable']): + bool_val = config.return_value(base_subnet + ['ip-forwarding', 'enable']) + # Delete the node with the old syntax + config.delete(base_subnet + ['ip-forwarding']) + if str(bool_val) == 'true': + # Recreate node with new syntax + config.set(base_subnet + ['ip-forwarding'], value=None) + + # Rename node "set service dhcp-server shared-network-name subnet 172.31.0.0/24 start <172.16.0.4> stop <172.16.0.9> + if config.exists(base_subnet + ['start']): + # This is the new "range" id for DHCP lease ranges + r_id = 0 + for range in config.list_nodes(base_subnet + ['start']): + range_start = range + range_stop = config.return_value(base_subnet + ['start', range_start, 'stop']) + + # Delete the node with the old syntax + config.delete(base_subnet + ['start', range_start]) + + # Create the node for the new syntax + # Note: range is a tag node, counter is its child, not a value + config.set(base_subnet + ['range', r_id]) + config.set(base_subnet + ['range', r_id, 'start'], value=range_start) + config.set(base_subnet + ['range', r_id, 'stop'], value=range_stop) + + # format as tag node to avoid loading problems + config.set_tag(base_subnet + ['range']) + + # increment range id for possible next range definition + r_id += 1 + + # Delete the node with the old syntax + config.delete(['service', 'dhcp-server', 'shared-network-name', network, 'subnet', subnet, 'start']) + + + # Make node "set service dhcp-server shared-network-name authoritative" valueless + if config.exists(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative']): + bool_val = config.return_value(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative']) + # Delete the node with the old syntax + config.delete(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative']) + if str(bool_val) == 'true': + # Recreate node with new syntax + config.set(['service', 'dhcp-server', 'shared-network-name', network, 'authoritative']) + + try: + with open(file_name, 'w') as f: + f.write(config.to_string()) + except OSError as e: + print("Failed to save the modified config: {}".format(e)) + + sys.exit(1) diff --git a/src/system/on-dhcp-event.sh b/src/system/on-dhcp-event.sh new file mode 100755 index 000000000..d671bffd6 --- /dev/null +++ b/src/system/on-dhcp-event.sh @@ -0,0 +1,98 @@ +#!/bin/bash + +# This script came from ubnt.com forum user "bradd" in the following post +# http://community.ubnt.com/t5/EdgeMAX/Automatic-DNS-resolution-of-DHCP-client-names/td-p/651311 +# It has been modified by Ubiquiti to update the /etc/host file +# instead of adding to the CLI. +# Thanks to forum user "itsmarcos" for bug fix & improvements +# Thanks to forum user "ruudboon" for multiple domain fix +# Thanks to forum user "chibby85" for expire patch and static-mapping + +if [ $# -lt 5 ]; then + echo Invalid args + logger -s -t on-dhcp-event "Invalid args \"$@\"" + exit 1 +fi + +action=$1 +client_name=$2 +client_ip=$3 +client_mac=$4 +domain=$5 +file=/etc/hosts +changes=0 + +if [ "$domain" == "..YYZ!" ]; then + client_fqdn_name=$client_name + client_search_expr=$client_name +else + client_fqdn_name=$client_name.$domain + client_search_expr="$client_name\\.$domain" +fi + +case "$action" in + commit) # add mapping for new lease + echo "- new lease event, setting static mapping for host "\ + "$client_fqdn_name (MAC=$client_mac, IP=$client_ip)" + # + # grep fails miserably with \t in the search expression. + # In the following line one is used after $client_search_expr + # followed by a single space + grep -q " $client_search_expr #on-dhcp-event " $file + if [ $? == 0 ]; then + echo pattern found, removing + wc1=`cat $file | wc -l` + sudo sed -i "/ $client_search_expr\t #on-dhcp-event /d" $file + wc2=`cat $file | wc -l` + if [ "$wc1" -eq "$wc2" ]; then + echo No change + fi + else + echo pattern NOT found + fi + + # check if hostname already exists (e.g. a static host mapping) + # if so don't overwrite + grep -q " $client_search_expr " $file + if [ $? == 0 ]; then + echo host $client_fqdn_name already exists, exiting + exit 1 + fi + + line="$client_ip\t $client_fqdn_name\t #on-dhcp-event $client_mac" + sudo sh -c "echo -e '$line' >> $file" + ((changes++)) + echo Entry was added + ;; + + release) # delete mapping for released address + echo "- lease release event, deleting static mapping for host $client_fqdn_name" + wc1=`cat $file | wc -l` + sudo sed -i "/ $client_search_expr\t #on-dhcp-event /d" $file + wc2=`cat $file | wc -l` + if [ "$wc1" -eq "$wc2" ]; then + echo No change + else + echo Entry was removed + ((changes++)) + fi + ;; + + *) + logger -s -t on-dhcp-event "Invalid command \"$1\"" + exit 1; + ;; +esac + +if [ $changes -gt 0 ]; then + echo Success + pid=`pgrep pdns_recursor` + if [ -n "$pid" ]; then + sudo rec_control reload-zones + fi +else + echo No changes made +fi +exit 0 + + -- cgit v1.2.3