#!/usr/bin/env python3
#
# Copyright (C) 2018-2019 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 jinja2
import socket
import struct
import vyos.validate
from ipaddress import ip_address, ip_network
from vyos.config import Config
from vyos import ConfigError
config_file = r'/etc/dhcp/dhcpd.conf'
lease_file = r'/config/dhcpd.leases'
daemon_config_file = r'/etc/default/isc-dhcp-server'
# Please be careful if you edit the template.
config_tmpl = """
### Autogenerated by dhcp_server.py ###
# For options please consult the following website:
# https://www.isc.org/wp-content/uploads/2017/08/dhcp43options.html
#
# log-facility local7;
{% if hostfile_update %}
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 %}
{%- if host_decl_name %}
use-host-decl-names on;
{%- 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 wpad -%}
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.disabled -%}
{%- 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 configration(s)
{% for network in shared_network %}
{%- if not network.disabled -%}
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.subnet_parameters %}
# The following {{ subnet.subnet_parameters | length }} line(s) were added as subnet-parameters in the CLI and have not been validated
{%- for param in subnet.subnet_parameters %}
{{ param }}
{%- endfor -%}
{%- 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.disabled -%}
host {% if host_decl_name -%} {{ host.name }} {%- else -%} {{ network.name }}_{{ host.name }} {%- endif %} {
{%- if host.ip_address %}
fixed-address {{ host.ip_address }};
{%- endif %}
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 %}
{%- if subnet.failover_name %}
pool {
failover peer "{{ subnet.failover_name }}";
deny dynamic bootp clients;
{%- for range in subnet.range %}
range {{ range.start }} {{ range.stop }};
{%- endfor %}
}
{%- else %}
{%- for range in subnet.range %}
range {{ range.start }} {{ range.stop }};
{%- endfor %}
{%- endif %}
}
{%- endfor %}
on commit {
set shared-networkname = "{{ network.name }}";
{% if hostfile_update -%}
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);
{%- endif %}
}
}
{%- endif %}
{% endfor %}
"""
daemon_tmpl = """
### Autogenerated by dhcp_server.py ###
# sourced by /etc/init.d/isc-dhcp-server
DHCPD_CONF=/etc/dhcp/dhcpd.conf
DHCPD_PID=/var/run/dhcpd.pid
OPTIONS="-4 -lf {{ lease_file }}"
INTERFACES=""
"""
default_config_data = {
'lease_file': lease_file,
'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 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': '',
'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'):
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']:
# https://ercpe.de/blog/pushing-static-routes-with-isc-dhcp-server
# Option format is:
# , , , , , ,
# where bytes with the value 0 are omitted.
net = 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:
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'):
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 (dhcp is None) or (dhcp['disabled'] is True):
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 vyos.validate.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('None of the DHCP lease subnets are inside any configured subnet on\n' \
'broadcast interfaces. At least one lease subnet must be set such that\n' \
'DHCP server listens on a one broadcast interface!')
return None
def generate(dhcp):
if dhcp is None:
return None
if dhcp['disabled'] is True:
print('Warning: DHCP server will be deactivated because it is disabled')
return None
tmpl = jinja2.Template(config_tmpl)
config_text = tmpl.render(dhcp)
# Please see: https://phabricator.vyos.net/T1129 for quoting of the raw parameters
# we can pass to ISC DHCPd
config_text = config_text.replace(""",'"')
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 None) or dhcp['disabled']:
# DHCP server is removed in the commit
os.system('sudo systemctl stop isc-dhcp-server.service')
if os.path.exists(config_file):
os.unlink(config_file)
if os.path.exists(daemon_config_file):
os.unlink(daemon_config_file)
else:
# If our file holding DHCP leases does yet not exist - create it
if not os.path.exists(lease_file):
os.mknod(lease_file)
os.system('sudo 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)
sys.exit(1)