diff --git a/interface-definitions/include/dhcp-interface-multi.xml.i b/interface-definitions/include/dhcp-interface-multi.xml.i index 0db11cf792..5f5c54cfd2 100644 --- a/interface-definitions/include/dhcp-interface-multi.xml.i +++ b/interface-definitions/include/dhcp-interface-multi.xml.i @@ -1,18 +1,8 @@ - - - DHCP interface supplying next-hop IP address - - - - - txt - DHCP interface name - - - #include - - - + + + #include + + - \ No newline at end of file + diff --git a/interface-definitions/include/dhcp-interface-properties.xml.i b/interface-definitions/include/dhcp-interface-properties.xml.i new file mode 100644 index 0000000000..b184b8f0a3 --- /dev/null +++ b/interface-definitions/include/dhcp-interface-properties.xml.i @@ -0,0 +1,13 @@ + + DHCP interface supplying next-hop IP address + + + + + txt + DHCP interface name + + + #include + + diff --git a/interface-definitions/include/dhcp-interface.xml.i b/interface-definitions/include/dhcp-interface.xml.i index b5c94cb24c..e056b3fe18 100644 --- a/interface-definitions/include/dhcp-interface.xml.i +++ b/interface-definitions/include/dhcp-interface.xml.i @@ -1,15 +1,7 @@ + - DHCP interface supplying next-hop IP address - - - - - txt - DHCP interface name - - - #include - + #include + diff --git a/interface-definitions/include/failover/common-failover.xml.i b/interface-definitions/include/failover/common-failover.xml.i new file mode 100644 index 0000000000..b147a0f8df --- /dev/null +++ b/interface-definitions/include/failover/common-failover.xml.i @@ -0,0 +1,105 @@ + + + + + Check target options + + + + + Policy for check targets + + any-available all-available + + + all-available + All targets must be alive + + + any-available + Any target must be alive + + + (all-available|any-available) + + + any-available + + #include + + + Check target address + + ipv4 + Address to check + + + + + + + #include + #include + + + + + Timeout between checks + + u32:1-300 + Timeout in seconds between checks + + + + + + 10 + + + + Check type + + arp icmp tcp + + + arp + Check target by ARP + + + icmp + Check target by ICMP + + + tcp + Check target by TCP + + + (arp|icmp|tcp) + + + icmp + + + + #include + + + Route metric for this gateway + + u32:1-255 + Route metric + + + + + + 1 + + + + The next hop is directly connected to the interface, even if it does not match interface prefix + + + + + diff --git a/interface-definitions/include/failover/protocol-common-config.xml.i b/interface-definitions/include/failover/protocol-common-config.xml.i index a106fdc74f..56c9d8f90b 100644 --- a/interface-definitions/include/failover/protocol-common-config.xml.i +++ b/interface-definitions/include/failover/protocol-common-config.xml.i @@ -22,109 +22,13 @@ - - - - Check target options - - - - - Policy for check targets - - any-available all-available - - - all-available - All targets must be alive - - - any-available - Any target must be alive - - - (all-available|any-available) - - - any-available - - #include - - - Check target address - - ipv4 - Address to check - - - - - - - #include - #include - - - - - Timeout between checks - - u32:1-300 - Timeout in seconds between checks - - - - - - 10 - - - - Check type - - arp icmp tcp - - - arp - Check target by ARP - - - icmp - Check target by ICMP - - - tcp - Check target by TCP - - - (arp|icmp|tcp) - - - icmp - - - - #include - - - Route metric for this gateway - - u32:1-255 - Route metric - - - - - - 1 - - - - The next hop is directly connected to the interface, even if it does not match interface prefix - - - - + #include + + + + #include + + #include diff --git a/python/vyos/template.py b/python/vyos/template.py index 824d421361..f384f752db 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -422,21 +422,25 @@ def is_file(filename): @register_filter('get_dhcp_router') def get_dhcp_router(interface): - """ Static routes can point to a router received by a DHCP reply. This + """Static routes can point to a router received by a DHCP reply. This helper is used to get the current default router from the DHCP reply. - Returns False of no router is found, returns the IP address as string if + Returns None if no router is found, returns the IP address as string if a router is found. """ - lease_file = directories['isc_dhclient_dir'] + f'/dhclient_{interface}.leases' + lease_file = directories['isc_dhclient_dir'] + f'/dhclient_{interface}.lease' if not os.path.exists(lease_file): return None from vyos.utils.file import read_file for line in read_file(lease_file).splitlines(): - if 'option routers' in line: - (_, _, address) = line.split() - return address.rstrip(';') + if 'new_routers' in line: + (_, address, _) = line.split("'") + if not address: + return None + # Take first one if there are several + address = address.split()[0] + return address @register_filter('natural_sort') def natural_sort(iterable): diff --git a/smoketest/scripts/cli/test_protocols_failover.py b/smoketest/scripts/cli/test_protocols_failover.py index 26f5e87b59..132d427ecd 100755 --- a/smoketest/scripts/cli/test_protocols_failover.py +++ b/smoketest/scripts/cli/test_protocols_failover.py @@ -34,6 +34,7 @@ check_timeout = 1 wait_timeout = 5 +wait_dhcp_timeout = 10 # Use numeric value to not get ip errors while # /etc/iproute2/rt_protos.d/failover.conf is not installed yet @@ -43,6 +44,9 @@ dummy_if2 = 'dum3712' dummy_if3 = 'dum3713' +veth_if1 = 'veth71' +veth_if2 = 'veth72' + route_prefix = '203.0.113.0/24' route2_prefix = '172.16.0.0/24' route_base_path = base_path + ['route', route_prefix] @@ -51,6 +55,12 @@ dummy_if2_addr = '10.0.70.1' dummy_if3_addr = '10.20.0.1' +# These three must be in same subnet: +dhcp_prefix = '10.133.0' +veth_if1_addr = f'{dhcp_prefix}.1' +dhcp_gateway_addr_1 = f'{dhcp_prefix}.99' +dhcp_gateway_addr_2 = f'{dhcp_prefix}.117' + class RoutesChecker: def __init__(self, required_routes, allow_extra=False): @@ -101,13 +111,26 @@ def setUp(self): self.cli_set(['interfaces', 'dummy', dummy_if1]) self.cli_set(['interfaces', 'dummy', dummy_if2]) self.cli_set(['interfaces', 'dummy', dummy_if3]) + self.cli_set( + ['interfaces', 'virtual-ethernet', veth_if1, 'peer-name', veth_if2] + ) + self.cli_set( + ['interfaces', 'virtual-ethernet', veth_if2, 'peer-name', veth_if1] + ) self.clean_and_stop_daemon() + self.clean_dhclient_lease_files = set() + self.need_dhcp_dir_cleanup = False + def tearDown(self): + self.cli_delete(['interfaces', 'virtual-ethernet', veth_if2]) + self.cli_delete(['interfaces', 'virtual-ethernet', veth_if1]) self.cli_delete(['interfaces', 'dummy', dummy_if3]) self.cli_delete(['interfaces', 'dummy', dummy_if2]) self.cli_delete(['interfaces', 'dummy', dummy_if1]) + self.cli_delete(['service', 'dhcp-server']) + self.cli_delete(['service', 'dns']) self.clean_and_stop_daemon() @@ -503,5 +526,120 @@ def test_03_config(self): self.assertTrue(res, f"No routes should have been left, got: {output}") + def test_04_dhcp(self): + res, output = self.wait_for_ip_output( + f'route show proto {failover_protocol_value}', + [], + timeout=wait_timeout, + ) + self.assertTrue( + res, f"No failover routes must exist before test, last result: {output}" + ) + + # Setup DHCP server + self.cli_set( + [ + 'interfaces', + 'virtual-ethernet', + veth_if1, + 'address', + f'{dhcp_prefix}.1/24', + ] + ) + self.cli_set(['interfaces', 'virtual-ethernet', veth_if1, 'description', 'LAN']) + + service_base = [ + 'service', + 'dhcp-server', + 'shared-network-name', + 'LAN', + 'subnet', + f'{dhcp_prefix}.0/24', + ] + self.cli_set(service_base + ['option', 'name-server', f'{dhcp_prefix}.1']) + self.cli_set(service_base + ['option', 'domain-name', 'vyos']) + self.cli_set(service_base + ['lease', '86400']) + self.cli_set(service_base + ['range', '0', 'start', f'{dhcp_prefix}.9']) + self.cli_set(service_base + ['range', '0', 'stop', f'{dhcp_prefix}.254']) + self.cli_set(service_base + ['subnet-id', '1952']) + + self.cli_set(['service', 'dns', 'forwarding', 'cache-size', '0']) + self.cli_set( + ['service', 'dns', 'forwarding', 'listen-address', f'{dhcp_prefix}.1'] + ) + self.cli_set( + ['service', 'dns', 'forwarding', 'allow-from', f'{dhcp_prefix}.0/24'] + ) + # End setup DHCP server + + # Setting first DHCP Gateway address + self.cli_set(service_base + ['option', 'default-router', dhcp_gateway_addr_1]) + + self.cli_set( + ['interfaces', 'dummy', dummy_if1, 'address', dummy_if1_addr + '/24'] + ) + self.cli_set( + ['interfaces', 'dummy', dummy_if2, 'address', dummy_if2_addr + '/24'] + ) + self.cli_set(['interfaces', 'virtual-ethernet', veth_if2, 'address', 'dhcp']) + self.cli_set(route_base_path + ['dhcp-interface', veth_if2]) + base_dhcp_interface = route_base_path + ['dhcp-interface', veth_if2] + self.cli_set(base_dhcp_interface + ['metric', '30']) + self.cli_set( + base_dhcp_interface + + [ + 'check', + 'target', + dummy_if1_addr, + 'interface', + dummy_if1, + ] + ) + self.cli_set(base_dhcp_interface + ['check', 'timeout', str(check_timeout)]) + self.cli_commit() + + # Now vyos-failover must be launched, it should create route to first dhcp address + checker = RoutesChecker([{'dst': route_prefix, 'gateway': dhcp_gateway_addr_1}]) + res, output = self.wait_for_ip_output( + f"route show proto {failover_protocol_value}", + checker, + timeout=wait_dhcp_timeout, + ) + self.assertTrue( + res, + f"Route must have been created via fist dhcp address. Checker error: {checker.error}", + ) + + # Change DHCP gateway address + renew_cmd = ['renew', 'dhcp', 'interface', veth_if2] + self.cli_set(service_base + ['option', 'default-router', dhcp_gateway_addr_2]) + self.cli_commit() + self.op_mode(renew_cmd) + + checker = RoutesChecker([{'dst': route_prefix, 'gateway': dhcp_gateway_addr_2}]) + res, output = self.wait_for_ip_output( + f"route show proto {failover_protocol_value}", + checker, + timeout=wait_dhcp_timeout, + ) + self.assertTrue( + res, + f"Route must have been created via second dhcp address. Checker error: {checker.error}", + ) + + # DHCP server down + self.cli_delete(['service', 'dhcp-server']) + self.cli_delete(['service', 'dns']) + self.cli_commit() + self.op_mode(renew_cmd) + + res, output = self.wait_for_ip_output( + f'route show proto {failover_protocol_value}', + [], + timeout=wait_dhcp_timeout, + ) + self.assertTrue(res, f"Route must have been deleted, last result: {output}") + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/protocols_failover.py b/src/conf_mode/protocols_failover.py index 956c060f41..752bd6011f 100755 --- a/src/conf_mode/protocols_failover.py +++ b/src/conf_mode/protocols_failover.py @@ -85,38 +85,60 @@ def verify(failover): if 'route' not in failover: raise ConfigError(f'Failover "route" is mandatory!') + def _verify_route_item(item_config, item_description, interface_mandatory): + if interface_mandatory and 'interface' not in item_config: + raise ConfigError( + f'Interface for route "{route}" {item_description} is mandatory!' + ) + + if not item_config.get('check'): + raise ConfigError(f'Check target for {item_description} is mandatory!') + + if 'target' not in item_config['check']: + raise ConfigError(f'Check target for {item_description} is mandatory!') + + check_type = item_config['check']['type'] + if check_type == 'tcp' and 'port' not in item_config['check']: + raise ConfigError( + f'Check port for {item_description} and type TCP is mandatory!' + ) + + errors = { + 'icmp': {}, + 'tcp': { + 'interface': 'Check target "interface" option does nothing for type TCP. Use "vrf" if needed', + }, + 'arp': { + 'vrf': 'Check target "vrf" option is incompatible with type ARP, use "interface" option if needed', + }, + } + + for target, target_config in item_config['check']['target'].items(): + for key, msg in errors[check_type].items(): + if key in target_config: + raise ConfigError(msg) + for route, route_config in failover['route'].items(): - if not route_config.get('next_hop'): - raise ConfigError(f'Next-hop for "{route}" is mandatory!') - - for next_hop, next_hop_config in route_config.get('next_hop').items(): - if 'interface' not in next_hop_config: - raise ConfigError(f'Interface for route "{route}" next-hop "{next_hop}" is mandatory!') - - if not next_hop_config.get('check'): - raise ConfigError(f'Check target for next-hop "{next_hop}" is mandatory!') - - if 'target' not in next_hop_config['check']: - raise ConfigError(f'Check target for next-hop "{next_hop}" is mandatory!') - - check_type = next_hop_config['check']['type'] - if check_type == 'tcp' and 'port' not in next_hop_config['check']: - raise ConfigError(f'Check port for next-hop "{next_hop}" and type TCP is mandatory!') - - errors = { - 'icmp': {}, - 'tcp': { - 'interface': 'Check target "interface" option does nothing for type TCP. Use "vrf" if needed', - }, - 'arp': { - 'vrf': 'Check target "vrf" option is incompatible with type ARP, use "interface" option if needed', - }, - } - - for target, target_config in next_hop_config['check']['target'].items(): - for key, msg in errors[check_type].items(): - if key in target_config: - raise ConfigError(msg) + if not route_config.get('next_hop') and not route_config.get('dhcp_interface'): + raise ConfigError( + f'Either next-hop or dhcp-interface for "{route}" is mandatory!' + ) + + if route_config.get('next_hop'): + for next_hop, next_hop_config in route_config.get('next_hop').items(): + _verify_route_item( + next_hop_config, f'next-hop "{next_hop}"', interface_mandatory=True + ) + + if route_config.get('dhcp_interface'): + for dhcp_interface, dhcp_interface_config in route_config.get( + 'dhcp_interface' + ).items(): + _verify_route_item( + dhcp_interface_config, + f'dhcp-interface "{dhcp_interface}"', + interface_mandatory=False, + ) return None diff --git a/src/helpers/vyos-failover.py b/src/helpers/vyos-failover.py index d255f23031..de89f20d02 100755 --- a/src/helpers/vyos-failover.py +++ b/src/helpers/vyos-failover.py @@ -21,6 +21,7 @@ import time from collections import namedtuple +from vyos.template import get_dhcp_router from vyos.utils.process import rc_cmd from vyos.utils.process import run from pathlib import Path @@ -170,6 +171,7 @@ def is_target_alive( 'NextHopConfig', [ 'route', + 'dhcp_interface', 'next_hop', 'vrf', 'vrf_opt', @@ -187,7 +189,9 @@ def is_target_alive( ) -def get_nexthop_config_vars(destination, vrf, vrf_opt, nexthop_config, next_hop): +def get_nexthop_config_vars( + destination, vrf, vrf_opt, nexthop_config, next_hop, dhcp_interface +): port = nexthop_config.get('check').get('port') targets = tuple( @@ -215,10 +219,13 @@ def get_nexthop_config_vars(destination, vrf, vrf_opt, nexthop_config, next_hop) return NextHopNamedTuple( route=destination, + dhcp_interface=dhcp_interface, next_hop=next_hop, vrf=vrf, vrf_opt=vrf_opt, - conf_iface=nexthop_config.get('interface'), + # For next-hop interface is mandatory + # For dhcp-interface it may be not given, then dhcp-interface is used + conf_iface=nexthop_config.get('interface', dhcp_interface), conf_metric=int(nexthop_config.get('metric')), port=port, port_opt=f'port {port}' if port else '', @@ -245,10 +252,22 @@ def get_nexthop_config_vars(destination, vrf, vrf_opt, nexthop_config, next_hop) def get_route_config(route, route_config, config_path, vrf): vrf_opt = f'vrf {vrf}' if vrf else '' - nexthops = tuple( - get_nexthop_config_vars(route, vrf, vrf_opt, nexthop_config, next_hop) - for next_hop, nexthop_config in route_config.get('next_hop').items() - ) + nexthops = [] + if route_config.get('next_hop'): + nexthops.extend( + get_nexthop_config_vars(route, vrf, vrf_opt, nexthop_config, next_hop, None) + for next_hop, nexthop_config in route_config.get('next_hop').items() + ) + if route_config.get('dhcp_interface'): + nexthops.extend( + get_nexthop_config_vars( + route, vrf, vrf_opt, dhcp_nexthop_config, None, interface + ) + for interface, dhcp_nexthop_config in route_config.get( + 'dhcp_interface' + ).items() + ) + nexthops = tuple(nexthops) return RouteNamedTuple( destination=route, vrf=vrf, @@ -399,6 +418,55 @@ def update_configuration(last_modification_times, all_routes, config_dir): print_debug(f"All routes: {all_routes}") +def process_dhcp_interface(nhc, nexthop_by_dhcp_nexthop): + """ + Processes NextHopNamedTuple with dhcp_interface != None + Return NextHopNamedTuple with next_hop equal to DHCP gateway of nhc. + If there is no gateway for nhc, return None + + Args: + nhc(NextHopNamedTuple): configuration with dhcp_interface + nexthop_by_dhcp_nexthop(dict): dict with previous returned values + """ + cur_dhcpgw = get_dhcp_router(nhc.dhcp_interface) + if not cur_dhcpgw: + cur_dhcpgw = False + + if nhc in nexthop_by_dhcp_nexthop: + prev_dhcpgw = nexthop_by_dhcp_nexthop[nhc].next_hop + else: + prev_dhcpgw = False + + # Equal - do nothing, just return previous value + if prev_dhcpgw == cur_dhcpgw: + if not cur_dhcpgw: + return None + return nexthop_by_dhcp_nexthop[nhc] + + print_debug( + f"DHCP Gateway changed for interface {nhc.dhcp_interface} from '{prev_dhcpgw}' to '{cur_dhcpgw}'" + ) + + # dhcpgw differ and there was previous dhcpgw + if prev_dhcpgw: + prevnhc = nexthop_by_dhcp_nexthop.pop(nhc) + print_debug( + f"Deleting previous nexthop {prevnhc} because of DHCP interface change" + ) + ip_args = get_ip_command_args(prevnhc) + if is_route_exists(ip_args): + delete_route(ip_args) + + newnhc = None + # dhcpgw differ and there is new dhcpgw + if cur_dhcpgw: + newnhc = nhc._replace(next_hop=cur_dhcpgw, dhcp_interface=None) + print_debug(f"Saving new nexthop {newnhc} because of DHCP interface change") + nexthop_by_dhcp_nexthop[nhc] = newnhc + + return newnhc + + if __name__ == '__main__': print_debug(f"{my_name} started") @@ -426,6 +494,11 @@ def update_configuration(last_modification_times, all_routes, config_dir): signal.signal(signal.SIGINT, kill_handler) signal.signal(signal.SIGTERM, kill_handler) + # keys: NextHopNamedTuple with dhcp_interface != None + # values: NextHopNamedTuple with next_hop != None + # Translates nexthop with dhcp_interface to ususal nexthop + nexthop_by_dhcp_nexthop = {} + had_sleeps = True while not kill_called: # Check in case daemon was launched without routes @@ -443,6 +516,11 @@ def update_configuration(last_modification_times, all_routes, config_dir): vrf_opt = route_config.vrf_opt for nhc in route_config.nexthops: + if nhc.dhcp_interface: + nhc = process_dhcp_interface(nhc, nexthop_by_dhcp_nexthop) + if not nhc: + continue + next_hop = nhc.next_hop ip_args = get_ip_command_args(nhc) @@ -478,7 +556,7 @@ def update_configuration(last_modification_times, all_routes, config_dir): f' [ TARGET_FAIL ] target checks fails for [{nhc.pretty_targets}], do nothing' ) journal.send( - f'Check fail for route {route} target {nhc.pretty_targets} proto {nhc.proto} ' + f'Check fail for route {route} interface "{nhc.conf_iface}" target {nhc.pretty_targets} proto {nhc.proto} ' f'{nhc.port_opt}', SYSLOG_IDENTIFIER=my_name, )