diff --git a/data/op-mode-standardized.json b/data/op-mode-standardized.json index 35587b63c9..345081f9fb 100644 --- a/data/op-mode-standardized.json +++ b/data/op-mode-standardized.json @@ -13,6 +13,7 @@ "evpn.py", "interfaces.py", "ipsec.py", +"load-balancing_wan.py", "lldp.py", "log.py", "memory.py", diff --git a/data/templates/load-balancing/nftables-wlb.j2 b/data/templates/load-balancing/nftables-wlb.j2 new file mode 100644 index 0000000000..75604aca1b --- /dev/null +++ b/data/templates/load-balancing/nftables-wlb.j2 @@ -0,0 +1,64 @@ +#!/usr/sbin/nft -f + +{% if first_install is not vyos_defined %} +delete table ip vyos_wanloadbalance +{% endif %} +table ip vyos_wanloadbalance { + chain wlb_nat_postrouting { + type nat hook postrouting priority srcnat - 1; policy accept; +{% for ifname, health_conf in interface_health.items() if health_state[ifname].if_addr %} +{% if disable_source_nat is not vyos_defined %} +{% set state = health_state[ifname] %} + ct mark {{ state.mark }} counter snat to {{ state.if_addr }} +{% endif %} +{% endfor %} + } + + chain wlb_mangle_prerouting { + type filter hook prerouting priority mangle; policy accept; +{% for ifname, health_conf in interface_health.items() %} +{% set state = health_state[ifname] %} +{% if sticky_connections is vyos_defined %} + iifname "{{ ifname }}" ct state new ct mark set {{ state.mark }} +{% endif %} +{% endfor %} +{% if rule is vyos_defined %} +{% for rule_id, rule_conf in rule.items() %} +{% if rule_conf.exclude is vyos_defined %} + {{ rule_conf | wlb_nft_rule(rule_id, exclude=True, action='accept') }} +{% else %} +{% set limit = rule_conf.limit is vyos_defined %} + {{ rule_conf | wlb_nft_rule(rule_id, limit=limit, weight=True, health_state=health_state) }} + {{ rule_conf | wlb_nft_rule(rule_id, restore_mark=True) }} +{% endif %} +{% endfor %} +{% endif %} + } + + chain wlb_mangle_output { + type filter hook output priority -150; policy accept; +{% if enable_local_traffic is vyos_defined %} + meta mark != 0x0 counter accept + meta l4proto icmp counter accept + ip saddr 127.0.0.0/8 ip daddr 127.0.0.0/8 counter accept +{% if rule is vyos_defined %} +{% for rule_id, rule_conf in rule.items() %} +{% if rule_conf.exclude is vyos_defined %} + {{ rule_conf | wlb_nft_rule(rule_id, local=True, exclude=True, action='accept') }} +{% else %} +{% set limit = rule_conf.limit is vyos_defined %} + {{ rule_conf | wlb_nft_rule(rule_id, local=True, limit=limit, weight=True, health_state=health_state) }} + {{ rule_conf | wlb_nft_rule(rule_id, local=True, restore_mark=True) }} +{% endif %} +{% endfor %} +{% endif %} +{% endif %} + } + +{% for ifname, health_conf in interface_health.items() %} +{% set state = health_state[ifname] %} + chain wlb_mangle_isp_{{ ifname }} { + meta mark set {{ state.mark }} ct mark set {{ state.mark }} counter accept + } +{% endfor %} +} diff --git a/data/templates/load-balancing/wlb.conf.j2 b/data/templates/load-balancing/wlb.conf.j2 deleted file mode 100644 index 7f04d797e9..0000000000 --- a/data/templates/load-balancing/wlb.conf.j2 +++ /dev/null @@ -1,134 +0,0 @@ -### Autogenerated by load-balancing_wan.py ### - -{% if disable_source_nat is vyos_defined %} -disable-source-nat -{% endif %} -{% if enable_local_traffic is vyos_defined %} -enable-local-traffic -{% endif %} -{% if sticky_connections is vyos_defined %} -sticky-connections inbound -{% endif %} -{% if flush_connections is vyos_defined %} -flush-conntrack -{% endif %} -{% if hook is vyos_defined %} -hook "{{ hook }}" -{% endif %} -{% if interface_health is vyos_defined %} -health { -{% for interface, interface_config in interface_health.items() %} - interface {{ interface }} { -{% if interface_config.failure_count is vyos_defined %} - failure-ct {{ interface_config.failure_count }} -{% endif %} -{% if interface_config.success_count is vyos_defined %} - success-ct {{ interface_config.success_count }} -{% endif %} -{% if interface_config.nexthop is vyos_defined %} - nexthop {{ interface_config.nexthop }} -{% endif %} -{% if interface_config.test is vyos_defined %} -{% for test_rule, test_config in interface_config.test.items() %} - rule {{ test_rule }} { -{% if test_config.type is vyos_defined %} -{% set type_translate = {'ping': 'ping', 'ttl': 'udp', 'user-defined': 'user-defined'} %} - type {{ type_translate[test_config.type] }} { -{% if test_config.ttl_limit is vyos_defined and test_config.type == 'ttl' %} - ttl {{ test_config.ttl_limit }} -{% endif %} -{% if test_config.test_script is vyos_defined and test_config.type == 'user-defined' %} - test-script {{ test_config.test_script }} -{% endif %} -{% if test_config.target is vyos_defined %} - target {{ test_config.target }} -{% endif %} - resp-time {{ test_config.resp_time | int * 1000 }} - } -{% endif %} - } -{% endfor %} -{% endif %} - } -{% endfor %} -} -{% endif %} - -{% if rule is vyos_defined %} -{% for rule, rule_config in rule.items() %} -rule {{ rule }} { -{% if rule_config.exclude is vyos_defined %} - exclude -{% endif %} -{% if rule_config.failover is vyos_defined %} - failover -{% endif %} -{% if rule_config.limit is vyos_defined %} - limit { -{% if rule_config.limit.burst is vyos_defined %} - burst {{ rule_config.limit.burst }} -{% endif %} -{% if rule_config.limit.rate is vyos_defined %} - rate {{ rule_config.limit.rate }} -{% endif %} -{% if rule_config.limit.period is vyos_defined %} - period {{ rule_config.limit.period }} -{% endif %} -{% if rule_config.limit.threshold is vyos_defined %} - thresh {{ rule_config.limit.threshold }} -{% endif %} - } -{% endif %} -{% if rule_config.per_packet_balancing is vyos_defined %} - per-packet-balancing -{% endif %} -{% if rule_config.protocol is vyos_defined %} - protocol {{ rule_config.protocol }} -{% endif %} -{% if rule_config.destination is vyos_defined %} - destination { -{% if rule_config.destination.address is vyos_defined %} - address "{{ rule_config.destination.address }}" -{% endif %} -{% if rule_config.destination.port is vyos_defined %} -{% if '-' in rule_config.destination.port %} - port-ipt "-m multiport --dports {{ rule_config.destination.port | replace('-', ':') }}" -{% elif ',' in rule_config.destination.port %} - port-ipt "-m multiport --dports {{ rule_config.destination.port }}" -{% else %} - port-ipt " --dport {{ rule_config.destination.port }}" -{% endif %} -{% endif %} - } -{% endif %} -{% if rule_config.source is vyos_defined %} - source { -{% if rule_config.source.address is vyos_defined %} - address "{{ rule_config.source.address }}" -{% endif %} -{% if rule_config.source.port is vyos_defined %} -{% if '-' in rule_config.source.port %} - port-ipt "-m multiport --sports {{ rule_config.source.port | replace('-', ':') }}" -{% elif ',' in rule_config.destination.port %} - port-ipt "-m multiport --sports {{ rule_config.source.port }}" -{% else %} - port.ipt " --sport {{ rule_config.source.port }}" -{% endif %} -{% endif %} - } -{% endif %} -{% if rule_config.inbound_interface is vyos_defined %} - inbound-interface {{ rule_config.inbound_interface }} -{% endif %} -{% if rule_config.interface is vyos_defined %} -{% for interface, interface_config in rule_config.interface.items() %} - interface {{ interface }} { -{% if interface_config.weight is vyos_defined %} - weight {{ interface_config.weight }} -{% endif %} - } -{% endfor %} -{% endif %} -} -{% endfor %} -{% endif %} diff --git a/debian/control b/debian/control index a194614121..a1d35ff229 100644 --- a/debian/control +++ b/debian/control @@ -205,9 +205,6 @@ Depends: # For "load-balancing haproxy" haproxy, # End "load-balancing haproxy" -# For "load-balancing wan" - vyatta-wanloadbalance, -# End "load-balancing wan" # For "service dhcp-relay" isc-dhcp-relay, # For "service dhcp-server" diff --git a/op-mode-definitions/load-balancing_wan.xml.in b/op-mode-definitions/load-balancing_wan.xml.in new file mode 100644 index 0000000000..91c57c1f41 --- /dev/null +++ b/op-mode-definitions/load-balancing_wan.xml.in @@ -0,0 +1,37 @@ + + + + + + + Restart Wide Area Network (WAN) load-balancing daemon + + sudo ${vyos_op_scripts_dir}/restart.py restart_service --name load-balancing_wan + + + + + + + + Show Wide Area Network (WAN) load-balancing information + + ${vyos_op_scripts_dir}/load-balancing_wan.py show_summary + + + + Show Wide Area Network (WAN) load-balancing flow + + ${vyos_op_scripts_dir}/load-balancing_wan.py show_connection + + + + Show WAN load-balancing statistics + + ${vyos_op_scripts_dir}/load-balancing_wan.py show_status + + + + + + \ No newline at end of file diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 4259909676..7a854576cc 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -37,7 +37,8 @@ 'dhcp6_client_dir' : '/run/dhcp6c', 'vyos_configdir' : '/opt/vyatta/config', 'completion_dir' : f'{base_dir}/completion', - 'ca_certificates' : '/usr/local/share/ca-certificates/vyos' + 'ca_certificates' : '/usr/local/share/ca-certificates/vyos', + 'ppp_nexthop_dir' : '/run/ppp_nexthop' } config_status = '/tmp/vyos-config-status' diff --git a/python/vyos/template.py b/python/vyos/template.py index be9f781a61..7ba608b321 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -779,6 +779,11 @@ def conntrack_ct_policy(protocol_conf): return ", ".join(output) +@register_filter('wlb_nft_rule') +def wlb_nft_rule(rule_conf, rule_id, local=False, exclude=False, limit=False, weight=None, health_state=None, action=None, restore_mark=False): + from vyos.wanloadbalance import nft_rule as wlb_nft_rule + return wlb_nft_rule(rule_conf, rule_id, local, exclude, limit, weight, health_state, action, restore_mark) + @register_filter('range_to_regex') def range_to_regex(num_range): """Convert range of numbers or list of ranges diff --git a/python/vyos/wanloadbalance.py b/python/vyos/wanloadbalance.py new file mode 100644 index 0000000000..b4b97ae321 --- /dev/null +++ b/python/vyos/wanloadbalance.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 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 os + +from vyos.defaults import directories +from vyos.utils.process import run + +dhclient_lease = 'dhclient_{0}.lease' + +def nft_rule(rule_conf, rule_id, local=False, exclude=False, limit=False, weight=None, health_state=None, action=None, restore_mark=False): + output = [] + + if 'inbound_interface' in rule_conf: + ifname = rule_conf['inbound_interface'] + if local and not exclude: + output.append(f'oifname != "{ifname}"') + elif not local: + output.append(f'iifname "{ifname}"') + + if 'protocol' in rule_conf and rule_conf['protocol'] != 'all': + protocol = rule_conf['protocol'] + operator = '' + + if protocol[:1] == '!': + operator = '!=' + protocol = protocol[1:] + + if protocol == 'tcp_udp': + protocol = '{ tcp, udp }' + + output.append(f'meta l4proto {operator} {protocol}') + + for direction in ['source', 'destination']: + if direction not in rule_conf: + continue + + direction_conf = rule_conf[direction] + prefix = direction[:1] + + if 'address' in direction_conf: + operator = '' + address = direction_conf['address'] + if address[:1] == '!': + operator = '!=' + address = address[1:] + output.append(f'ip {prefix}addr {operator} {address}') + + if 'port' in direction_conf: + operator = '' + port = direction_conf['port'] + if port[:1] == '!': + operator = '!=' + port = port[1:] + output.append(f'th {prefix}port {operator} {port}') + + if 'source_based_routing' not in rule_conf and not restore_mark: + output.append('ct state new') + + if limit and 'limit' in rule_conf and 'rate' in rule_conf['limit']: + output.append(f'limit rate {rule_conf["limit"]["rate"]}/{rule_conf["limit"]["period"]}') + if 'burst' in rule_conf['limit']: + output.append(f'burst {rule_conf["limit"]["burst"]} packets') + + output.append('counter') + + if restore_mark: + output.append('meta mark set ct mark') + elif weight: + weights, total_weight = wlb_weight_interfaces(rule_conf, health_state) + if len(weights) > 1: # Create weight-based verdict map + vmap_str = ", ".join(f'{weight} : jump wlb_mangle_isp_{ifname}' for ifname, weight in weights) + output.append(f'numgen random mod {total_weight} vmap {{ {vmap_str} }}') + elif len(weights) == 1: # Jump to single ISP + ifname, _ = weights[0] + output.append(f'jump wlb_mangle_isp_{ifname}') + else: # No healthy interfaces + return "" + elif action: + output.append(action) + + return " ".join(output) + +def wlb_weight_interfaces(rule_conf, health_state): + interfaces = [(ifname, int(if_conf.get('weight', 1))) for ifname, if_conf in rule_conf['interface'].items() if health_state[ifname]['state']] + + if not interfaces: + return [], 0 + + if 'failover' in rule_conf: + for ifpair in sorted(interfaces, key=lambda i: i[1], reverse=True): + return [ifpair], ifpair[1] # Return highest weight interface that is ACTIVE when in failover + + total_weight = sum(weight for ifname, weight in interfaces) + out = [] + start = 0 + for ifname, weight in sorted(interfaces, key=lambda i: i[1]): # build weight ranges + end = start + weight - 1 + out.append((ifname, f'{start}-{end}' if end > start else start)) + start = weight + + return out, total_weight + +def health_ping_host(host, ifname, count=1, wait_time=0): + cmd_str = f'ping -c {count} -W {wait_time} -I {ifname} {host}' + rc = run(cmd_str) + return rc == 0 + +def health_ping_host_ttl(host, ifname, count=1, ttl_limit=0): + cmd_str = f'ping -c {count} -t {ttl_limit} -I {ifname} {host}' + rc = run(cmd_str) + return rc != 0 + +def parse_dhcp_nexthop(ifname): + lease_file = os.path.join(directories['isc_dhclient_dir'], dhclient_lease.format(ifname)) + + if not os.path.exists(lease_file): + return False + + with open(lease_file, 'r') as f: + for line in f.readlines(): + data = line.replace('\n', '').split('=') + if data[0] == 'new_routers': + return data[1].replace("'", '').split(" ")[0] + + return None + +def parse_ppp_nexthop(ifname): + nexthop_file = os.path.join(directories['ppp_nexthop_dir'], ifname) + + if not os.path.exists(nexthop_file): + return False + + with open(nexthop_file, 'r') as f: + return f.read() diff --git a/smoketest/scripts/cli/base_vyostest_shim.py b/smoketest/scripts/cli/base_vyostest_shim.py index a383e596cb..0dc199ab83 100644 --- a/smoketest/scripts/cli/base_vyostest_shim.py +++ b/smoketest/scripts/cli/base_vyostest_shim.py @@ -147,6 +147,15 @@ def verify_nftables_chain(self, nftables_search, table, chain, inverse=False, ar break self.assertTrue(not matched if inverse else matched, msg=search) + def verify_nftables_chain_exists(self, table, chain, inverse=False): + try: + cmd(f'sudo nft list chain {table} {chain}') + if inverse: + self.fail(f'Chain exists: {table} {chain}') + except OSError: + if not inverse: + self.fail(f'Chain does not exist: {table} {chain}') + # Verify ip rule output def verify_rules(self, rules_search, inverse=False, addr_family='inet'): rule_output = cmd(f'ip -family {addr_family} rule show') diff --git a/smoketest/scripts/cli/test_load-balancing_wan.py b/smoketest/scripts/cli/test_load-balancing_wan.py index 92b4000b82..23c6e70b29 100755 --- a/smoketest/scripts/cli/test_load-balancing_wan.py +++ b/smoketest/scripts/cli/test_load-balancing_wan.py @@ -54,6 +54,16 @@ def tearDown(self): self.cli_delete(base_path) self.cli_commit() + removed_chains = [ + 'wlb_mangle_isp_veth1', + 'wlb_mangle_isp_veth2', + 'wlb_mangle_isp_eth201', + 'wlb_mangle_isp_eth202' + ] + + for chain in removed_chains: + self.verify_nftables_chain_exists('ip vyos_wanloadbalance', chain, inverse=True) + def test_table_routes(self): ns1 = 'ns201' ns2 = 'ns202' @@ -102,7 +112,8 @@ def test_table_routes(self): self.cli_set(base_path + ['wan', 'rule', '10', 'inbound-interface', iface3]) self.cli_set(base_path + ['wan', 'rule', '10', 'source', 'address', '198.51.100.0/24']) - + self.cli_set(base_path + ['wan', 'rule', '10', 'interface', iface1]) + self.cli_set(base_path + ['wan', 'rule', '10', 'interface', iface2]) # commit changes self.cli_commit() @@ -127,7 +138,6 @@ def test_table_routes(self): delete_netns(ns3) def test_check_chains(self): - ns1 = 'nsA' ns2 = 'nsB' ns3 = 'nsC' @@ -137,43 +147,28 @@ def test_check_chains(self): container_iface1 = 'ceth0' container_iface2 = 'ceth1' container_iface3 = 'ceth2' - mangle_isp1 = """table ip mangle { - chain ISP_veth1 { - counter ct mark set 0xc9 - counter meta mark set 0xc9 - counter accept + mangle_isp1 = """table ip vyos_wanloadbalance { + chain wlb_mangle_isp_veth1 { + meta mark set 0x000000c9 ct mark set 0x000000c9 counter accept } }""" - mangle_isp2 = """table ip mangle { - chain ISP_veth2 { - counter ct mark set 0xca - counter meta mark set 0xca - counter accept + mangle_isp2 = """table ip vyos_wanloadbalance { + chain wlb_mangle_isp_veth2 { + meta mark set 0x000000ca ct mark set 0x000000ca counter accept } }""" - mangle_prerouting = """table ip mangle { - chain PREROUTING { + mangle_prerouting = """table ip vyos_wanloadbalance { + chain wlb_mangle_prerouting { type filter hook prerouting priority mangle; policy accept; - counter jump WANLOADBALANCE_PRE - } -}""" - mangle_wanloadbalance_pre = """table ip mangle { - chain WANLOADBALANCE_PRE { - iifname "veth3" ip saddr 198.51.100.0/24 ct state new meta random & 2147483647 < 1073741824 counter jump ISP_veth1 - iifname "veth3" ip saddr 198.51.100.0/24 ct state new counter jump ISP_veth2 + iifname "veth3" ip saddr 198.51.100.0/24 ct state new limit rate 5/second burst 5 packets counter numgen random mod 2 vmap { 0 : jump wlb_mangle_isp_veth1, 1 : jump wlb_mangle_isp_veth2 } iifname "veth3" ip saddr 198.51.100.0/24 counter meta mark set ct mark } }""" - nat_wanloadbalance = """table ip nat { - chain WANLOADBALANCE { - ct mark 0xc9 counter snat to 203.0.113.10 - ct mark 0xca counter snat to 192.0.2.10 - } -}""" - nat_vyos_pre_snat_hook = """table ip nat { - chain VYOS_PRE_SNAT_HOOK { + nat_wanloadbalance = """table ip vyos_wanloadbalance { + chain wlb_nat_postrouting { type nat hook postrouting priority srcnat - 1; policy accept; - counter jump WANLOADBALANCE + ct mark 0x000000c9 counter snat to 203.0.113.10 + ct mark 0x000000ca counter snat to 192.0.2.10 } }""" @@ -222,25 +217,19 @@ def test_check_chains(self): time.sleep(5) # Check mangle chains - tmp = cmd(f'sudo nft -s list chain mangle ISP_{iface1}') + tmp = cmd(f'sudo nft -s list chain ip vyos_wanloadbalance wlb_mangle_isp_{iface1}') self.assertEqual(tmp, mangle_isp1) - tmp = cmd(f'sudo nft -s list chain mangle ISP_{iface2}') + tmp = cmd(f'sudo nft -s list chain ip vyos_wanloadbalance wlb_mangle_isp_{iface2}') self.assertEqual(tmp, mangle_isp2) - tmp = cmd(f'sudo nft -s list chain mangle PREROUTING') + tmp = cmd('sudo nft -s list chain ip vyos_wanloadbalance wlb_mangle_prerouting') self.assertEqual(tmp, mangle_prerouting) - tmp = cmd(f'sudo nft -s list chain mangle WANLOADBALANCE_PRE') - self.assertEqual(tmp, mangle_wanloadbalance_pre) - # Check nat chains - tmp = cmd(f'sudo nft -s list chain nat WANLOADBALANCE') + tmp = cmd('sudo nft -s list chain ip vyos_wanloadbalance wlb_nat_postrouting') self.assertEqual(tmp, nat_wanloadbalance) - tmp = cmd(f'sudo nft -s list chain nat VYOS_PRE_SNAT_HOOK') - self.assertEqual(tmp, nat_vyos_pre_snat_hook) - # Delete veth interfaces and netns for iface in [iface1, iface2, iface3]: call(f'sudo ip link del dev {iface}') diff --git a/src/conf_mode/load-balancing_wan.py b/src/conf_mode/load-balancing_wan.py index 5da0b906be..aa0c6c8f81 100755 --- a/src/conf_mode/load-balancing_wan.py +++ b/src/conf_mode/load-balancing_wan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright (C) 2023-2024 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 @@ -14,24 +14,16 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import os - from sys import exit -from shutil import rmtree -from vyos.base import Warning from vyos.config import Config from vyos.configdep import set_dependents, call_dependents from vyos.utils.process import cmd -from vyos.template import render from vyos import ConfigError from vyos import airbag airbag.enable() -load_balancing_dir = '/run/load-balance' -load_balancing_conf_file = f'{load_balancing_dir}/wlb.conf' -systemd_service = 'vyos-wan-load-balance.service' - +service = 'vyos-wan-load-balance.service' def get_config(config=None): if config: @@ -40,6 +32,7 @@ def get_config(config=None): conf = Config() base = ['load-balancing', 'wan'] + lb = conf.get_config_dict(base, key_mangling=('-', '_'), no_tag_node_value_mangle=True, get_first_key=True, @@ -59,87 +52,58 @@ def verify(lb): if not lb: return None - if 'interface_health' not in lb: - raise ConfigError( - 'A valid WAN load-balance configuration requires an interface with a nexthop!' - ) - - for interface, interface_config in lb['interface_health'].items(): - if 'nexthop' not in interface_config: - raise ConfigError( - f'interface-health {interface} nexthop must be specified!') - - if 'test' in interface_config: - for test_rule, test_config in interface_config['test'].items(): - if 'type' in test_config: - if test_config['type'] == 'user-defined' and 'test_script' not in test_config: - raise ConfigError( - f'test {test_rule} script must be defined for test-script!' - ) - - if 'rule' not in lb: - Warning( - 'At least one rule with an (outbound) interface must be defined for WAN load balancing to be active!' - ) + if 'interface_health' in lb: + for ifname, health_conf in lb['interface_health'].items(): + if 'nexthop' not in health_conf: + raise ConfigError(f'Nexthop must be configured for interface {ifname}') + + if 'test' not in health_conf: + continue + + for test_id, test_conf in health_conf['test'].items(): + if 'type' not in test_conf: + raise ConfigError(f'No type configured for health test on interface {ifname}') + + if test_conf['type'] == 'user-defined' and 'test_script' not in test_conf: + raise ConfigError(f'Missing user-defined script for health test on interface {ifname}') else: - for rule, rule_config in lb['rule'].items(): - if 'inbound_interface' not in rule_config: - raise ConfigError(f'rule {rule} inbound-interface must be specified!') - if {'failover', 'exclude'} <= set(rule_config): - raise ConfigError(f'rule {rule} failover cannot be configured with exclude!') - if {'limit', 'exclude'} <= set(rule_config): - raise ConfigError(f'rule {rule} limit cannot be used with exclude!') - if 'interface' not in rule_config: - if 'exclude' not in rule_config: - Warning( - f'rule {rule} will be inactive because no (outbound) interfaces have been defined for this rule' - ) - for direction in {'source', 'destination'}: - if direction in rule_config: - if 'protocol' in rule_config and 'port' in rule_config[ - direction]: - if rule_config['protocol'] not in {'tcp', 'udp'}: - raise ConfigError('ports can only be specified when protocol is "tcp" or "udp"') + raise ConfigError('Interface health tests must be configured') + if 'rule' in lb: + for rule_id, rule_conf in lb['rule'].items(): + if 'failover' in rule_conf and 'exclude' in rule_conf: + raise ConfigError(f'Failover cannot be configured with exclude on load-balancing wan rule {rule_id}') -def generate(lb): - if not lb: - # Delete /run/load-balance/wlb.conf - if os.path.isfile(load_balancing_conf_file): - os.unlink(load_balancing_conf_file) - # Delete old directories - if os.path.isdir(load_balancing_dir): - rmtree(load_balancing_dir, ignore_errors=True) - if os.path.exists('/var/run/load-balance/wlb.out'): - os.unlink('/var/run/load-balance/wlb.out') + if 'limit' in rule_conf: + if 'exclude' in rule_conf: + raise ConfigError(f'Limit cannot be configured with exclude on load-balancing wan rule {rule_id}') - return None + if 'rate' in rule_conf['limit'] and 'period' not in rule_conf['limit']: + raise ConfigError(f'Missing "limit period" on load-balancing wan rule {rule_id}') - # Create load-balance dir - if not os.path.isdir(load_balancing_dir): - os.mkdir(load_balancing_dir) + if 'period' in rule_conf['limit'] and 'rate' not in rule_conf['limit']: + raise ConfigError(f'Missing "limit rate" on load-balancing wan rule {rule_id}') - render(load_balancing_conf_file, 'load-balancing/wlb.conf.j2', lb) + for direction in ['source', 'destination']: + if direction in rule_conf: + if 'port' in rule_conf[direction]: + if 'protocol' not in rule_conf: + raise ConfigError(f'Protocol required to specify port on load-balancing wan rule {rule_id}') - return None + if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']: + raise ConfigError(f'Protocol must be tcp, udp or tcp_udp to specify port on load-balancing wan rule {rule_id}') +def generate(lb): + return None def apply(lb): if not lb: - try: - cmd(f'systemctl stop {systemd_service}') - except Exception as e: - print(f"Error message: {e}") - + cmd(f'sudo systemctl stop {service}') else: - cmd('sudo sysctl -w net.netfilter.nf_conntrack_acct=1') - cmd(f'systemctl restart {systemd_service}') + cmd(f'sudo systemctl restart {service}') call_dependents() - return None - - if __name__ == '__main__': try: c = get_config() diff --git a/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb b/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb new file mode 100755 index 0000000000..fff258afa8 --- /dev/null +++ b/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 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 . + +# This is a Python hook script which is invoked whenever a PPPoE session goes +# "ip-up". It will call into our vyos.ifconfig library and will then execute +# common tasks for the PPPoE interface. The reason we have to "hook" this is +# that we can not create a pppoeX interface in advance in linux and then connect +# pppd to this already existing interface. + +import os +import signal + +from sys import argv +from sys import exit + +from vyos.defaults import directories + +# When the ppp link comes up, this script is called with the following +# parameters +# $1 the interface name used by pppd (e.g. ppp3) +# $2 the tty device name +# $3 the tty device speed +# $4 the local IP address for the interface +# $5 the remote IP address +# $6 the parameter specified by the 'ipparam' option to pppd + +if (len(argv) < 7): + exit(1) + +wlb_pid_file = '/run/wlb_daemon.pid' + +interface = argv[6] +nexthop = argv[5] + +if not os.path.exists(directories['ppp_nexthop_dir']): + os.mkdir(directories['ppp_nexthop_dir']) + +nexthop_file = os.path.join(directories['ppp_nexthop_dir'], interface) + +with open(nexthop_file, 'w') as f: + f.write(nexthop) + +# Trigger WLB daemon update +if os.path.exists(wlb_pid_file): + with open(wlb_pid_file, 'r') as f: + pid = int(f.read()) + + os.kill(pid, signal.SIGUSR2) diff --git a/src/helpers/vyos-load-balancer.py b/src/helpers/vyos-load-balancer.py new file mode 100755 index 0000000000..ba1aa0db43 --- /dev/null +++ b/src/helpers/vyos-load-balancer.py @@ -0,0 +1,296 @@ +#!/usr/bin/python3 + +# Copyright 2024 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + +import json +import os +import signal +import sys +import time + +from vyos.config import Config +from vyos.template import render +from vyos.utils.commit import commit_in_progress +from vyos.utils.network import get_interface_address +from vyos.utils.process import run +from vyos.xml_ref import get_defaults +from vyos.wanloadbalance import health_ping_host +from vyos.wanloadbalance import health_ping_host_ttl +from vyos.wanloadbalance import parse_dhcp_nexthop +from vyos.wanloadbalance import parse_ppp_nexthop + +nftables_wlb_conf = '/run/nftables_wlb.conf' +wlb_status_file = '/run/wlb_status.json' +wlb_pid_file = '/run/wlb_daemon.pid' +sleep_interval = 5 # Main loop sleep interval + +def health_check(ifname, conf, state, test_defaults): + # Run health tests for interface + + if 'test' not in conf: + resp_time = test_defaults['resp-time'] + target = conf['nexthop'] + + if target == 'dhcp': + target = state['dhcp_nexthop'] + + if not target: + return False + + return health_ping_host(target, ifname, wait_time=resp_time) + + for test_id, test_conf in conf['test'].items(): + check_type = test_conf['type'] + + if check_type == 'ping': + resp_time = test_conf['resp_time'] + target = test_conf['target'] + if not health_ping_host(target, ifname, wait_time=resp_time): + return False + elif check_type == 'ttl': + target = test_conf['target'] + ttl_limit = test_conf['ttl_limit'] + if not health_ping_host_ttl(target, ifname, ttl_limit=ttl_limit): + return False + elif check_type == 'user-defined': + script = test_conf['test_script'] + rc = run(script) + if rc != 0: + return False + + return True + +def on_state_change(lb, ifname, state): + # Run hook on state change + if 'hook' in lb: + script_path = os.path.join('/config/scripts/', lb['hook']) + state_str = 'ACTIVE' if state else 'FAILED' + run(script_path, env=[f'WLB_INTERFACE_NAME={ifname}', f'WLB_INTERFACE_STATE={state_str}']) + + print(f'INFO: State change: {ifname} -> {state}') + +def get_ipv4_address(ifname): + # Get primary ipv4 address on interface (for source nat) + addr_json = get_interface_address(ifname) + if 'addr_info' in addr_json and len(addr_json['addr_info']) > 0: + for addr_info in addr_json['addr_info']: + if addr_info['family'] == 'inet': + if 'local' in addr_info: + return addr_json['addr_info'][0]['local'] + return None + +def dynamic_nexthop_update(lb, ifname): + # Update on DHCP/PPP address/nexthop changes + # Return True if nftables needs to be updated - IP change + + if 'dhcp_nexthop' in lb['health_state'][ifname]: + if ifname[:5] == 'pppoe': + dhcp_nexthop_addr = parse_ppp_nexthop(ifname) + else: + dhcp_nexthop_addr = parse_dhcp_nexthop(ifname) + + table_num = lb['health_state'][ifname]['table_number'] + + if dhcp_nexthop_addr and lb['health_state'][ifname]['dhcp_nexthop'] != dhcp_nexthop_addr: + lb['health_state'][ifname]['dhcp_nexthop'] = dhcp_nexthop_addr + run(f'ip route replace table {table_num} default dev {ifname} via {dhcp_nexthop_addr}') + + if_addr = get_ipv4_address(ifname) + if if_addr and if_addr != lb['health_state'][ifname]['if_addr']: + lb['health_state'][ifname]['if_addr'] = if_addr + return True + + return False + +def nftables_update(lb): + # Atomically reload nftables table from template + if not os.path.exists(nftables_wlb_conf): + lb['first_install'] = True + elif 'first_install' in lb: + del lb['first_install'] + + render(nftables_wlb_conf, 'load-balancing/nftables-wlb.j2', lb) + + rc = run(f'nft -f {nftables_wlb_conf}') + + if rc != 0: + print('ERROR: Failed to apply WLB nftables config') + return False + + return True + +def cleanup(lb): + if 'interface_health' in lb: + index = 1 + for ifname, health_conf in lb['interface_health'].items(): + table_num = lb['mark_offset'] + index + run(f'ip route del table {table_num} default') + run(f'ip rule del fwmark {hex(table_num)} table {table_num}') + index += 1 + + run(f'nft delete table ip vyos_wanloadbalance') + +def get_config(): + conf = Config() + base = ['load-balancing', 'wan'] + lb = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, with_recursive_defaults=True) + + lb['test_defaults'] = get_defaults(base + ['interface-health', 'A', 'test', 'B'], get_first_key=True) + + return lb + +if __name__ == '__main__': + while commit_in_progress(): + print("Notice: Waiting for commit to complete...") + time.sleep(1) + + lb = get_config() + + lb['health_state'] = {} + lb['mark_offset'] = 0xc8 + + # Create state dicts, interface address and nexthop, install routes and ip rules + if 'interface_health' in lb: + index = 1 + for ifname, health_conf in lb['interface_health'].items(): + table_num = lb['mark_offset'] + index + lb['health_state'][ifname] = { + 'if_addr': get_ipv4_address(ifname), + 'failure_count': 0, + 'success_count': 0, + 'last_success': 0, + 'last_failure': 0, + 'state': True, + 'state_changed': False, + 'table_number': table_num, + 'mark': hex(table_num) + } + + if health_conf['nexthop'] == 'dhcp': + lb['health_state'][ifname]['dhcp_nexthop'] = None + + dynamic_nexthop_update(lb, ifname) + else: + run(f'ip route replace table {table_num} default dev {ifname} via {health_conf["nexthop"]}') + + run(f'ip rule add fwmark {hex(table_num)} table {table_num}') + + index += 1 + + nftables_update(lb) + + run('ip route flush cache') + + if 'flush_connections' in lb: + run('conntrack -F') + run('conntrack -F expect') + + with open(wlb_status_file, 'w') as f: + f.write(json.dumps(lb['health_state'])) + + # Signal handler SIGUSR2 -> dhcpcd update + def handle_sigusr2(signum, frame): + for ifname, health_conf in lb['interface_health'].items(): + if 'nexthop' in health_conf and health_conf['nexthop'] == 'dhcp': + retval = dynamic_nexthop_update(lb, ifname) + + if retval: + nftables_update(lb) + + # Signal handler SIGTERM -> exit + def handle_sigterm(signum, frame): + if os.path.exists(wlb_status_file): + os.unlink(wlb_status_file) + + if os.path.exists(wlb_pid_file): + os.unlink(wlb_pid_file) + + if os.path.exists(nftables_wlb_conf): + os.unlink(nftables_wlb_conf) + + cleanup(lb) + sys.exit(0) + + signal.signal(signal.SIGUSR2, handle_sigusr2) + signal.signal(signal.SIGINT, handle_sigterm) + signal.signal(signal.SIGTERM, handle_sigterm) + + with open(wlb_pid_file, 'w') as f: + f.write(str(os.getpid())) + + # Main loop + while True: + ip_change = False + + if 'interface_health' in lb: + for ifname, health_conf in lb['interface_health'].items(): + state = lb['health_state'][ifname] + + result = health_check(ifname, health_conf, state=state, test_defaults=lb['test_defaults']) + + state_changed = result != state['state'] + state['state_changed'] = False + + if result: + state['failure_count'] = 0 + state['success_count'] += 1 + state['last_success'] = time.time() + if state_changed and state['success_count'] >= int(health_conf['success_count']): + state['state'] = True + state['state_changed'] = True + elif not result: + state['failure_count'] += 1 + state['success_count'] = 0 + state['last_failure'] = time.time() + if state_changed and state['failure_count'] >= int(health_conf['failure_count']): + state['state'] = False + state['state_changed'] = True + + if state['state_changed']: + state['if_addr'] = get_ipv4_address(ifname) + on_state_change(lb, ifname, state['state']) + + if dynamic_nexthop_update(lb, ifname): + ip_change = True + + if any(state['state_changed'] for ifname, state in lb['health_state'].items()): + if not nftables_update(lb): + break + + run('ip route flush cache') + + if 'flush_connections' in lb: + run('conntrack -F') + run('conntrack -F expect') + + with open(wlb_status_file, 'w') as f: + f.write(json.dumps(lb['health_state'])) + elif ip_change: + nftables_update(lb) + + time.sleep(sleep_interval) + + if os.path.exists(wlb_status_file): + os.unlink(wlb_status_file) + + if os.path.exists(wlb_pid_file): + os.unlink(wlb_pid_file) + + if os.path.exists(nftables_wlb_conf): + os.unlink(nftables_wlb_conf) + + cleanup(lb) diff --git a/src/op_mode/load-balancing_wan.py b/src/op_mode/load-balancing_wan.py new file mode 100755 index 0000000000..9fa4738025 --- /dev/null +++ b/src/op_mode/load-balancing_wan.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 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 json +import re +import sys + +from datetime import datetime + +from vyos.config import Config +from vyos.utils.process import cmd + +import vyos.opmode + +wlb_status_file = '/run/wlb_status.json' + +status_format = '''Interface: {ifname} +Status: {status} +Last Status Change: {last_change} +Last Interface Success: {last_success} +Last Interface Failure: {last_failure} +Interface Failures: {failures} +''' + +def _verify(func): + """Decorator checks if WLB config exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = Config() + if not config.exists(['load-balancing', 'wan']): + unconf_message = 'WAN load-balancing is not configured' + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + return func(*args, **kwargs) + return _wrapper + +def _get_raw_data(): + with open(wlb_status_file, 'r') as f: + data = json.loads(f.read()) + if not data: + return {} + return data + +def _get_formatted_output(raw_data): + for ifname, if_data in raw_data.items(): + latest_change = if_data['last_success'] if if_data['last_success'] > if_data['last_failure'] else if_data['last_failure'] + + change_dt = datetime.fromtimestamp(latest_change) if latest_change > 0 else None + success_dt = datetime.fromtimestamp(if_data['last_success']) if if_data['last_success'] > 0 else None + failure_dt = datetime.fromtimestamp(if_data['last_failure']) if if_data['last_failure'] > 0 else None + now = datetime.utcnow() + + fmt_data = { + 'ifname': ifname, + 'status': "active" if if_data['state'] else "failed", + 'last_change': change_dt.strftime("%Y-%m-%d %H:%M:%S") if change_dt else 'N/A', + 'last_success': str(now - success_dt) if success_dt else 'N/A', + 'last_failure': str(now - failure_dt) if failure_dt else 'N/A', + 'failures': if_data['failure_count'] + } + print(status_format.format(**fmt_data)) + +@_verify +def show_summary(raw: bool): + data = _get_raw_data() + + if raw: + return data + else: + return _get_formatted_output(data) + +@_verify +def show_connection(raw: bool): + res = cmd('sudo conntrack -L -n') + lines = res.split("\n") + filtered_lines = [line for line in lines if re.search(r' mark=[1-9]', line)] + + if raw: + return filtered_lines + + for line in lines: + print(line) + +@_verify +def show_status(raw: bool): + res = cmd('sudo nft list chain ip vyos_wanloadbalance wlb_mangle_prerouting') + lines = res.split("\n") + filtered_lines = [line.replace("\t", "") for line in lines[3:-2] if 'meta mark set' not in line] + + if raw: + return filtered_lines + + for line in filtered_lines: + print(line) + +if __name__ == "__main__": + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/op_mode/restart.py b/src/op_mode/restart.py index 3b0031f347..efa8354851 100755 --- a/src/op_mode/restart.py +++ b/src/op_mode/restart.py @@ -53,6 +53,10 @@ 'systemd_service': 'strongswan', 'path': ['vpn', 'ipsec'], }, + 'load-balancing_wan': { + 'systemd_service': 'vyos-wan-load-balance', + 'path': ['load-balancing', 'wan'], + }, 'mdns_repeater': { 'systemd_service': 'avahi-daemon', 'path': ['service', 'mdns', 'repeater'], @@ -86,6 +90,7 @@ 'haproxy', 'igmp_proxy', 'ipsec', + 'load-balancing_wan', 'mdns_repeater', 'router_advert', 'snmp', diff --git a/src/systemd/vyos-wan-load-balance.service b/src/systemd/vyos-wan-load-balance.service index 7d62a2ff66..a59f2c3ae3 100644 --- a/src/systemd/vyos-wan-load-balance.service +++ b/src/systemd/vyos-wan-load-balance.service @@ -1,15 +1,11 @@ [Unit] -Description=VyOS WAN load-balancing service +Description=VyOS WAN Load Balancer After=vyos-router.service [Service] -ExecStart=/opt/vyatta/sbin/wan_lb -f /run/load-balance/wlb.conf -d -i /var/run/vyatta/wlb.pid -ExecReload=/bin/kill -s SIGTERM $MAINPID && sleep 5 && /opt/vyatta/sbin/wan_lb -f /run/load-balance/wlb.conf -d -i /var/run/vyatta/wlb.pid -ExecStop=/bin/kill -s SIGTERM $MAINPID -PIDFile=/var/run/vyatta/wlb.pid -KillMode=process -Restart=on-failure -RestartSec=5s +Type=simple +Restart=always +ExecStart=/usr/bin/python3 /usr/libexec/vyos/vyos-load-balancer.py [Install] WantedBy=multi-user.target