summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rwxr-xr-xsrc/conf_mode/dhcpv6_relay.py2
-rwxr-xr-xsrc/conf_mode/dhcpv6_server.py406
-rwxr-xr-xsrc/migration-scripts/interfaces/13-to-142
-rwxr-xr-xsrc/migration-scripts/interfaces/14-to-152
-rwxr-xr-xsrc/migration-scripts/interfaces/15-to-162
-rw-r--r--src/tests/test_template.py49
6 files changed, 139 insertions, 324 deletions
diff --git a/src/conf_mode/dhcpv6_relay.py b/src/conf_mode/dhcpv6_relay.py
index cf8a26674..aea2c3b73 100755
--- a/src/conf_mode/dhcpv6_relay.py
+++ b/src/conf_mode/dhcpv6_relay.py
@@ -69,7 +69,7 @@ def verify(relay):
for interface in relay['listen_interface']:
has_global = False
for addr in Interface(interface).get_addr():
- if not is_ipv6_link_local(addr.split('/')[0]):
+ if not is_ipv6_link_local(addr):
has_global = True
if not has_global:
raise ConfigError(f'Interface {interface} does not have global '\
diff --git a/src/conf_mode/dhcpv6_server.py b/src/conf_mode/dhcpv6_server.py
index db248de50..175300bb0 100755
--- a/src/conf_mode/dhcpv6_server.py
+++ b/src/conf_mode/dhcpv6_server.py
@@ -15,31 +15,24 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
-import ipaddress
+from ipaddress import ip_address
+from ipaddress import ip_network
from sys import exit
-from copy import deepcopy
from vyos.config import Config
from vyos.template import render
from vyos.template import is_ipv6
from vyos.util import call
+from vyos.util import dict_search
from vyos.validate import is_subnet_connected
-
from vyos import ConfigError
from vyos import airbag
airbag.enable()
-config_file = r'/run/dhcp-server/dhcpdv6.conf'
-
-default_config_data = {
- 'preference': '',
- 'disabled': False,
- 'shared_network': []
-}
+config_file = '/run/dhcp-server/dhcpdv6.conf'
def get_config(config=None):
- dhcpv6 = deepcopy(default_config_data)
if config:
conf = config
else:
@@ -47,333 +40,110 @@ def get_config(config=None):
base = ['service', 'dhcpv6-server']
if not conf.exists(base):
return None
- else:
- conf.set_level(base)
-
- # Check for global disable of DHCPv6 service
- if conf.exists(['disable']):
- dhcpv6['disabled'] = True
- return dhcpv6
-
- # Preference of this DHCPv6 server compared with others
- if conf.exists(['preference']):
- dhcpv6['preference'] = conf.return_value(['preference'])
-
- # check for multiple, shared networks served with DHCPv6 addresses
- if conf.exists(['shared-network-name']):
- for network in conf.list_nodes(['shared-network-name']):
- conf.set_level(base + ['shared-network-name', network])
- config = {
- 'name': network,
- 'disabled': False,
- 'common': {},
- 'subnet': []
- }
-
- # If disabled, the shared-network configuration becomes inactive
- if conf.exists(['disable']):
- config['disabled'] = True
-
- # Common options shared among subnets. These can be overridden if
- # the same option is specified on a per-subnet or per-host
- # basis. These are the only options that can be handed out to
- # stateless clients via an information-request message.
- if conf.exists(['common-options']):
- conf.set_level(base + ['shared-network-name', network, 'common-options'])
-
- # How often stateless clients should refresh their information. This is
- # mostly taken as a hint by clients, and only if they request it.
- # (if not specified, the server does not supply this to the client)
- if conf.exists(['info-refresh-time']):
- config['common']['info_refresh_time'] = conf.return_value(['info-refresh-time'])
-
- # 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']):
- config['common']['domain_search'] = conf.return_values(['domain-search'])
-
- # Specifies a list of Domain Name System name servers available to the client.
- # Servers should be listed in order of preference.
- if conf.exists(['name-server']):
- config['common']['dns_server'] = conf.return_values(['name-server'])
-
- conf.set_level(base + ['shared-network-name', network])
-
- # check for multiple subnet configurations in a shared network
- if conf.exists(['subnet']):
- for net in conf.list_nodes(['subnet']):
- conf.set_level(base + ['shared-network-name', network, 'subnet', net])
- subnet = {
- 'network': net,
- 'range6_prefix': [],
- 'range6': [],
- 'default_router': '',
- 'dns_server': [],
- 'domain_name': '',
- 'domain_search': [],
- 'lease_def': '',
- 'lease_min': '',
- 'lease_max': '',
- 'nis_domain': '',
- 'nis_server': [],
- 'nisp_domain': '',
- 'nisp_server': [],
- 'prefix_delegation': [],
- 'sip_address': [],
- 'sip_hostname': [],
- 'sntp_server': [],
- 'static_mapping': []
- }
-
- # For any subnet on which addresses will be assigned dynamically, there must be at
- # least one address 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(['address-range', 'prefix']):
- for prefix in conf.list_nodes(['address-range', 'prefix']):
- range = {
- 'prefix': prefix,
- 'temporary': False
- }
-
- # Address range will be used for temporary addresses
- if conf.exists(['address-range' 'prefix', prefix, 'temporary']):
- range['temporary'] = True
-
- # Append to subnet temporary range6 list
- subnet['range6_prefix'].append(range)
-
- if conf.exists(['address-range', 'start']):
- for range in conf.list_nodes(['address-range', 'start']):
- range = {
- 'start': range,
- 'stop': conf.return_value(['address-range', 'start', range, 'stop'])
- }
-
- # Append to subnet range6 list
- subnet['range6'].append(range)
-
- # 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']):
- subnet['domain_search'] = conf.return_values(['domain-search'])
-
- # IPv6 address valid lifetime
- # (at the end the address is no longer usable by the client)
- # (set to 30 days, the usual IPv6 default)
- if conf.exists(['lease-time', 'default']):
- subnet['lease_def'] = conf.return_value(['lease-time', 'default'])
-
- # Time should be the maximum length in seconds that will be assigned to a lease.
- # The only exception to this is that Dynamic BOOTP lease lengths, which are not
- # specified by the client, are not limited by this maximum.
- if conf.exists(['lease-time', 'maximum']):
- subnet['lease_max'] = conf.return_value(['lease-time', 'maximum'])
-
- # Time should be the minimum length in seconds that will be assigned to a lease
- if conf.exists(['lease-time', 'minimum']):
- subnet['lease_min'] = conf.return_value(['lease-time', 'minimum'])
-
- # Specifies a list of Domain Name System name servers available to the client.
- # Servers should be listed in order of preference.
- if conf.exists(['name-server']):
- subnet['dns_server'] = conf.return_values(['name-server'])
-
- # Ancient NIS (Network Information Service) domain name
- if conf.exists(['nis-domain']):
- subnet['nis_domain'] = conf.return_value(['nis-domain'])
-
- # Ancient NIS (Network Information Service) servers
- if conf.exists(['nis-server']):
- subnet['nis_server'] = conf.return_values(['nis-server'])
-
- # Ancient NIS+ (Network Information Service) domain name
- if conf.exists(['nisplus-domain']):
- subnet['nisp_domain'] = conf.return_value(['nisplus-domain'])
-
- # Ancient NIS+ (Network Information Service) servers
- if conf.exists(['nisplus-server']):
- subnet['nisp_server'] = conf.return_values(['nisplus-server'])
-
- # Local SIP server that is to be used for all outbound SIP requests - IPv6 address
- if conf.exists(['sip-server']):
- for value in conf.return_values(['sip-server']):
- if is_ipv6(value):
- subnet['sip_address'].append(value)
- else:
- subnet['sip_hostname'].append(value)
-
- # List of local SNTP servers available for the client to synchronize their clocks
- if conf.exists(['sntp-server']):
- subnet['sntp_server'] = conf.return_values(['sntp-server'])
-
- # Prefix Delegation (RFC 3633)
- if conf.exists(['prefix-delegation', 'start']):
- for address in conf.list_nodes(['prefix-delegation', 'start']):
- conf.set_level(base + ['shared-network-name', network, 'subnet', net, 'prefix-delegation', 'start', address])
- prefix = {
- 'start' : address,
- 'stop' : '',
- 'length' : ''
- }
-
- if conf.exists(['prefix-length']):
- prefix['length'] = conf.return_value(['prefix-length'])
-
- if conf.exists(['stop']):
- prefix['stop'] = conf.return_value(['stop'])
-
- subnet['prefix_delegation'].append(prefix)
-
- #
- # Static DHCP v6 leases
- #
- conf.set_level(base + ['shared-network-name', network, 'subnet', net])
- if conf.exists(['static-mapping']):
- for mapping in conf.list_nodes(['static-mapping']):
- conf.set_level(base + ['shared-network-name', network, 'subnet', net, 'static-mapping', mapping])
- mapping = {
- 'name': mapping,
- 'disabled': False,
- 'ipv6_address': '',
- 'client_identifier': '',
- }
-
- # This static lease is disabled
- if conf.exists(['disable']):
- mapping['disabled'] = True
-
- # IPv6 address used for this DHCP client
- if conf.exists(['ipv6-address']):
- mapping['ipv6_address'] = conf.return_value(['ipv6-address'])
-
- # This option specifies the client’s DUID identifier. DUIDs are similar but different from DHCPv4 client identifiers
- if conf.exists(['identifier']):
- mapping['client_identifier'] = conf.return_value(['identifier'])
-
- # append static mapping configuration tu subnet list
- subnet['static_mapping'].append(mapping)
-
- # append subnet configuration to shared network subnet list
- config['subnet'].append(subnet)
-
- # append shared network configuration to config dictionary
- dhcpv6['shared_network'].append(config)
-
- # If all shared-networks are disabled, there's nothing to do.
- if all(net['disabled'] for net in dhcpv6['shared_network']):
- return None
+ dhcpv6 = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True)
return dhcpv6
def verify(dhcpv6):
- if not dhcpv6 or dhcpv6['disabled']:
+ # 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 len(dhcpv6['shared_network']) == 0:
- raise ConfigError('No DHCPv6 shared networks configured.\n' \
- 'At least one DHCPv6 shared network must be configured.')
+ if 'shared_network_name' not in dhcpv6:
+ raise ConfigError('No DHCPv6 shared networks configured. At least\n' \
+ 'one DHCPv6 shared network must be configured.')
# Inspect shared-network/subnet
subnets = []
listen_ok = False
-
- for network in dhcpv6['shared_network']:
+ for network, network_config in dhcpv6['shared_network_name'].items():
# A shared-network requires a subnet definition
- if len(network['subnet']) == 0:
- raise ConfigError('No DHCPv6 lease subnets configured for {0}. At least one\n' \
- 'lease subnet must be configured for each shared network.'.format(network['name']))
-
- range6_start = []
- range6_stop = []
- for subnet in network['subnet']:
- # Ususal range declaration with a start and stop address
- for range6 in subnet['range6']:
- # shorten names
- start = range6['start']
- stop = range6['stop']
-
- # DHCPv6 stop address is required
- if start and not stop:
- raise ConfigError('DHCPv6 range stop address for start {0} is not defined!'.format(start))
-
- # Start address must be inside network
- if not ipaddress.ip_address(start) in ipaddress.ip_network(subnet['network']):
- raise ConfigError('DHCPv6 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 ipaddress.ip_address(stop) in ipaddress.ip_network(subnet['network']):
- raise ConfigError('DHCPv6 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 ipaddress.ip_address(stop) >= ipaddress.ip_address(start):
- raise ConfigError('DHCPv6 range stop address {0} must be greater or equal\n' \
- 'to the range start address {1}!'.format(stop, 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('Conflicting DHCPv6 lease range:\n' \
- 'Pool start address {0} defined multipe times!'.format(start))
- else:
- 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('Conflicting DHCPv6 lease range:\n' \
- 'Pool stop address {0} defined multipe times!'.format(stop))
- else:
- range6_stop.append(stop)
+ if 'subnet' not in network_config:
+ raise ConfigError(f'No DHCPv6 lease subnets configured for "{network}". At least one\n' \
+ '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 or equal\n' \
+ 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:\n' \
+ 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:\n' \
+ 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
- for prefix in subnet['prefix_delegation']:
- if not prefix['stop']:
- raise ConfigError('Stop address of delegated IPv6 prefix range must be configured')
-
- if not prefix['length']:
- raise ConfigError('Length of delegated IPv6 prefix must be configured')
-
- # We also have prefixes that require checking
- for prefix in subnet['range6_prefix']:
- # If configured prefix does not match our subnet, we have to check that it's inside
- if ipaddress.ip_network(prefix['prefix']) != ipaddress.ip_network(subnet['network']):
- # Configured prefixes must be inside our network
- if not ipaddress.ip_network(prefix['prefix']) in ipaddress.ip_network(subnet['network']):
- raise ConfigError('DHCPv6 prefix {0} is not in subnet {1}\n' \
- 'specified for shared network {2}!'.format(prefix['prefix'], subnet['network'], network['name']))
+ if 'prefix_delegation' in subnet_config:
+ if 'start' not in subnet_config['prefix_delegation']:
+ raise ConfigError('prefix-delegation start address not defined!')
+
+ for prefix, prefix_config in subnet_config['prefix_delegation']['start'].items():
+ if 'stop' not in prefix_config:
+ raise ConfigError(f'Stop address of delegated IPv6 prefix range "{prefix}"\n'
+ f'must be configured')
+
+ if 'prefix_length' not in prefix_config:
+ raise ConfigError('Length of delegated IPv6 prefix must be configured')
# Static mappings don't require anything (but check if IP is in subnet if it's set)
- for mapping in subnet['static_mapping']:
- if mapping['ipv6_address']:
- # Static address must be in subnet
- if not ipaddress.ip_address(mapping['ipv6_address']) in ipaddress.ip_network(subnet['network']):
- raise ConfigError('DHCPv6 static mapping IPv6 address {0} for static mapping {1}\n' \
- 'in shared network {2} is outside subnet {3}!' \
- .format(mapping['ipv6_address'], mapping['name'], network['name'], subnet['network']))
+ 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}"!')
# Subnets must be unique
- if subnet['network'] in subnets:
+ if subnet in subnets:
raise ConfigError('DHCPv6 subnets must be unique! Subnet {0} defined multiple times!'.format(subnet['network']))
- else:
- subnets.append(subnet['network'])
+ subnets.append(subnet)
- # DHCPv6 requires at least one configured address range or one static mapping
- # (FIXME: is not actually checked right now?)
+ # 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 not network['disabled']:
- if is_subnet_connected(subnet['network']):
- listen_ok = True
+ # 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 = ipaddress.ip_network(subnet['network'])
+ net = ip_network(subnet)
for n in subnets:
- net2 = ipaddress.ip_network(n)
+ net2 = ip_network(n)
if (net != net2):
if net.overlaps(net2):
raise ConfigError('DHCPv6 conflicting subnet ranges: {0} overlaps {1}'.format(net, net2))
@@ -387,22 +157,24 @@ def verify(dhcpv6):
return None
def generate(dhcpv6):
- if not dhcpv6 or dhcpv6['disabled']:
+ # bail out early - looks like removal from running config
+ if not dhcpv6 or 'disable' in dhcpv6:
return None
render(config_file, 'dhcp-server/dhcpdv6.conf.tmpl', dhcpv6)
return None
def apply(dhcpv6):
- if not dhcpv6 or dhcpv6['disabled']:
+ # bail out early - looks like removal from running config
+ if not dhcpv6 or 'disable' in dhcpv6:
# DHCP server is removed in the commit
call('systemctl stop isc-dhcp-server6.service')
if os.path.exists(config_file):
os.unlink(config_file)
- else:
- call('systemctl restart isc-dhcp-server6.service')
+ return None
+ call('systemctl restart isc-dhcp-server6.service')
return None
if __name__ == '__main__':
diff --git a/src/migration-scripts/interfaces/13-to-14 b/src/migration-scripts/interfaces/13-to-14
index fc6d7f443..6e6439c36 100755
--- a/src/migration-scripts/interfaces/13-to-14
+++ b/src/migration-scripts/interfaces/13-to-14
@@ -17,8 +17,6 @@
# T3043: rename Wireless interface security mode 'both' to 'wpa+wpa2'
# T3043: move "system wifi-regulatory-domain" to indicidual wireless interface
-import os
-
from sys import exit, argv
from vyos.configtree import ConfigTree
diff --git a/src/migration-scripts/interfaces/14-to-15 b/src/migration-scripts/interfaces/14-to-15
index 5c25f8628..c38db0bf8 100755
--- a/src/migration-scripts/interfaces/14-to-15
+++ b/src/migration-scripts/interfaces/14-to-15
@@ -16,8 +16,6 @@
# T3048: remove smp-affinity node from ethernet and use tuned instead
-import os
-
from sys import exit, argv
from vyos.configtree import ConfigTree
diff --git a/src/migration-scripts/interfaces/15-to-16 b/src/migration-scripts/interfaces/15-to-16
index 126911ccd..804c48be0 100755
--- a/src/migration-scripts/interfaces/15-to-16
+++ b/src/migration-scripts/interfaces/15-to-16
@@ -16,8 +16,6 @@
# remove pppoe "ipv6 enable" option
-import os
-
from sys import exit, argv
from vyos.configtree import ConfigTree
diff --git a/src/tests/test_template.py b/src/tests/test_template.py
index 6dc2f075e..544755692 100644
--- a/src/tests/test_template.py
+++ b/src/tests/test_template.py
@@ -44,3 +44,52 @@ class TestVyOSTemplate(TestCase):
self.assertFalse(vyos.template.is_ipv6('192.0.2.0/24'))
self.assertFalse(vyos.template.is_ipv6('192.0.2.1/32'))
self.assertFalse(vyos.template.is_ipv6('VyOS'))
+
+ def test_address_from_cidr(self):
+ self.assertEqual(vyos.template.address_from_cidr('192.0.2.0/24'), '192.0.2.0')
+ self.assertEqual(vyos.template.address_from_cidr('2001:db8::/48'), '2001:db8::')
+
+ with self.assertRaises(ValueError):
+ # ValueError: 192.0.2.1/24 has host bits set
+ self.assertEqual(vyos.template.address_from_cidr('192.0.2.1/24'), '192.0.2.1')
+
+ with self.assertRaises(ValueError):
+ # ValueError: 2001:db8::1/48 has host bits set
+ self.assertEqual(vyos.template.address_from_cidr('2001:db8::1/48'), '2001:db8::1')
+
+ def test_netmask_from_cidr(self):
+ self.assertEqual(vyos.template.netmask_from_cidr('192.0.2.0/24'), '255.255.255.0')
+ self.assertEqual(vyos.template.netmask_from_cidr('192.0.2.128/25'), '255.255.255.128')
+ self.assertEqual(vyos.template.netmask_from_cidr('2001:db8::/48'), 'ffff:ffff:ffff::')
+
+ with self.assertRaises(ValueError):
+ # ValueError: 192.0.2.1/24 has host bits set
+ self.assertEqual(vyos.template.netmask_from_cidr('192.0.2.1/24'), '255.255.255.0')
+
+ with self.assertRaises(ValueError):
+ # ValueError: 2001:db8:1:/64 has host bits set
+ self.assertEqual(vyos.template.netmask_from_cidr('2001:db8:1:/64'), 'ffff:ffff:ffff:ffff::')
+
+ def test_first_host_address(self):
+ self.assertEqual(vyos.template.first_host_address('10.0.0.0/24'), '10.0.0.1')
+ self.assertEqual(vyos.template.first_host_address('10.0.0.128/25'), '10.0.0.129')
+ self.assertEqual(vyos.template.first_host_address('2001:db8::/64'), '2001:db8::')
+
+ def test_last_host_address(self):
+ self.assertEqual(vyos.template.last_host_address('10.0.0.0/24'), '10.0.0.254')
+ self.assertEqual(vyos.template.last_host_address('10.0.0.128/25'), '10.0.0.254')
+ self.assertEqual(vyos.template.last_host_address('2001:db8::/64'), '2001:db8::ffff:ffff:ffff:ffff')
+
+ def test_increment_ip(self):
+ self.assertEqual(vyos.template.inc_ip('10.0.0.0/24', '2'), '10.0.0.2')
+ self.assertEqual(vyos.template.inc_ip('10.0.0.0', '2'), '10.0.0.2')
+ self.assertEqual(vyos.template.inc_ip('10.0.0.0', '10'), '10.0.0.10')
+ self.assertEqual(vyos.template.inc_ip('2001:db8::/64', '2'), '2001:db8::2')
+ self.assertEqual(vyos.template.inc_ip('2001:db8::', '10'), '2001:db8::a')
+
+ def test_decrement_ip(self):
+ self.assertEqual(vyos.template.dec_ip('10.0.0.100/24', '1'), '10.0.0.99')
+ self.assertEqual(vyos.template.dec_ip('10.0.0.90', '10'), '10.0.0.80')
+ self.assertEqual(vyos.template.dec_ip('2001:db8::b/64', '10'), '2001:db8::1')
+ self.assertEqual(vyos.template.dec_ip('2001:db8::f', '5'), '2001:db8::a')
+