diff options
Diffstat (limited to 'src')
| -rwxr-xr-x | src/conf_mode/container.py | 26 | ||||
| -rwxr-xr-x | src/conf_mode/interfaces_bonding.py | 28 | ||||
| -rwxr-xr-x | src/conf_mode/interfaces_bridge.py | 5 | ||||
| -rwxr-xr-x | src/conf_mode/interfaces_geneve.py | 2 | ||||
| -rwxr-xr-x | src/conf_mode/interfaces_wireguard.py | 23 | ||||
| -rwxr-xr-x | src/conf_mode/load-balancing_wan.py | 119 | ||||
| -rwxr-xr-x | src/conf_mode/service_snmp.py | 5 | ||||
| -rwxr-xr-x | src/conf_mode/system_sflow.py | 2 | ||||
| -rwxr-xr-x | src/etc/netplug/vyos-netplug-dhcp-client | 13 | ||||
| -rwxr-xr-x | src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb | 61 | ||||
| -rwxr-xr-x | src/helpers/vyos-load-balancer.py | 312 | ||||
| -rw-r--r-- | src/migration-scripts/bgp/5-to-6 | 39 | ||||
| -rw-r--r-- | src/migration-scripts/lldp/2-to-3 | 31 | ||||
| -rw-r--r-- | src/migration-scripts/policy/8-to-9 | 49 | ||||
| -rw-r--r-- | src/migration-scripts/wanloadbalance/3-to-4 | 33 | ||||
| -rwxr-xr-x | src/op_mode/load-balancing_wan.py | 117 | ||||
| -rwxr-xr-x | src/op_mode/restart.py | 5 | ||||
| -rwxr-xr-x | src/services/vyos-domain-resolver | 12 | ||||
| -rw-r--r-- | src/systemd/vyos-wan-load-balance.service | 12 | 
19 files changed, 780 insertions, 114 deletions
| diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index 594de3eb0..3636b0871 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -22,6 +22,7 @@ from ipaddress import ip_address  from ipaddress import ip_network  from json import dumps as json_write +import psutil  from vyos.base import Warning  from vyos.config import Config  from vyos.configdict import dict_merge @@ -223,6 +224,21 @@ def verify(container):                      if not os.path.exists(source):                          raise ConfigError(f'Volume "{volume}" source path "{source}" does not exist!') +            if 'tmpfs' in container_config: +                for tmpfs, tmpfs_config in container_config['tmpfs'].items(): +                    if 'destination' not in tmpfs_config: +                        raise ConfigError(f'tmpfs "{tmpfs}" has no destination path configured!') +                    if 'size' in tmpfs_config: +                        free_mem_mb: int = psutil.virtual_memory().available / 1024 /  1024 +                        if int(tmpfs_config['size']) > free_mem_mb: +                            Warning(f'tmpfs "{tmpfs}" size is greater than the current free memory!') + +                        total_mem_mb: int = (psutil.virtual_memory().total / 1024 /  1024) / 2 +                        if int(tmpfs_config['size']) > total_mem_mb: +                            raise ConfigError(f'tmpfs "{tmpfs}" size should not be more than 50% of total system memory!') +                    else: +                        raise ConfigError(f'tmpfs "{tmpfs}" has no size configured!') +              if 'port' in container_config:                  for tmp in container_config['port']:                      if not {'source', 'destination'} <= set(container_config['port'][tmp]): @@ -362,6 +378,14 @@ def generate_run_arguments(name, container_config):              prop = vol_config['propagation']              volume += f' --volume {svol}:{dvol}:{mode},{prop}' +    # Mount tmpfs +    tmpfs = '' +    if 'tmpfs' in container_config: +        for tmpfs_config in container_config['tmpfs'].values(): +            dest = tmpfs_config['destination'] +            size = tmpfs_config['size'] +            tmpfs += f' --mount=type=tmpfs,tmpfs-size={size}M,destination={dest}' +      host_pid = ''      if 'allow_host_pid' in container_config:        host_pid = '--pid host' @@ -373,7 +397,7 @@ def generate_run_arguments(name, container_config):      container_base_cmd = f'--detach --interactive --tty --replace {capabilities} --cpus {cpu_quota} {sysctl_opt} ' \                           f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} ' \ -                         f'--name {name} {hostname} {device} {port} {name_server} {volume} {env_opt} {label} {uid} {host_pid}' +                         f'--name {name} {hostname} {device} {port} {name_server} {volume} {tmpfs} {env_opt} {label} {uid} {host_pid}'      entrypoint = ''      if 'entrypoint' in container_config: diff --git a/src/conf_mode/interfaces_bonding.py b/src/conf_mode/interfaces_bonding.py index 4f1141dcb..84316c16e 100755 --- a/src/conf_mode/interfaces_bonding.py +++ b/src/conf_mode/interfaces_bonding.py @@ -126,9 +126,8 @@ def get_config(config=None):      # Restore existing config level      conf.set_level(old_level) -    if dict_search('member.interface', bond): -        for interface, interface_config in bond['member']['interface'].items(): - +    if dict_search('member.interface', bond) is not None: +        for interface in bond['member']['interface']:              interface_ethernet_config = conf.get_config_dict(                  ['interfaces', 'ethernet', interface],                  key_mangling=('-', '_'), @@ -137,44 +136,45 @@ def get_config(config=None):                  with_defaults=False,                  with_recursive_defaults=False) -            interface_config['config_paths'] = dict_to_paths_values(interface_ethernet_config) +            bond['member']['interface'][interface].update({'config_paths' : +                dict_to_paths_values(interface_ethernet_config)})              # Check if member interface is a new member              if not conf.exists_effective(base + [ifname, 'member', 'interface', interface]):                  bond['shutdown_required'] = {} -                interface_config['new_added'] = {} +                bond['member']['interface'][interface].update({'new_added' : {}})              # Check if member interface is disabled              conf.set_level(['interfaces'])              section = Section.section(interface) # this will be 'ethernet' for 'eth0'              if conf.exists([section, interface, 'disable']): -                interface_config['disable'] = '' +                if tmp: bond['member']['interface'][interface].update({'disable': ''})              conf.set_level(old_level)              # Check if member interface is already member of another bridge              tmp = is_member(conf, interface, 'bridge') -            if tmp: interface_config['is_bridge_member'] = tmp +            if tmp: bond['member']['interface'][interface].update({'is_bridge_member' : tmp})              # Check if member interface is already member of a bond              tmp = is_member(conf, interface, 'bonding') -            for tmp in is_member(conf, interface, 'bonding'): -                if bond['ifname'] == tmp: -                    continue -                interface_config['is_bond_member'] = tmp +            if ifname in tmp: +                del tmp[ifname] +            if tmp: bond['member']['interface'][interface].update({'is_bond_member' : tmp})              # Check if member interface is used as source-interface on another interface              tmp = is_source_interface(conf, interface) -            if tmp: interface_config['is_source_interface'] = tmp +            if tmp: bond['member']['interface'][interface].update({'is_source_interface' : tmp})              # bond members must not have an assigned address              tmp = has_address_configured(conf, interface) -            if tmp: interface_config['has_address'] = {} +            if tmp: bond['member']['interface'][interface].update({'has_address' : ''})              # bond members must not have a VRF attached              tmp = has_vrf_configured(conf, interface) -            if tmp: interface_config['has_vrf'] = {} +            if tmp: bond['member']['interface'][interface].update({'has_vrf' : ''}) +      return bond diff --git a/src/conf_mode/interfaces_bridge.py b/src/conf_mode/interfaces_bridge.py index 637db442a..aff93af2a 100755 --- a/src/conf_mode/interfaces_bridge.py +++ b/src/conf_mode/interfaces_bridge.py @@ -74,8 +74,9 @@ def get_config(config=None):          for interface in list(bridge['member']['interface']):              # Check if member interface is already member of another bridge              tmp = is_member(conf, interface, 'bridge') -            if tmp and bridge['ifname'] not in tmp: -                bridge['member']['interface'][interface].update({'is_bridge_member' : tmp}) +            if ifname in tmp: +                del tmp[ifname] +            if tmp: bridge['member']['interface'][interface].update({'is_bridge_member' : tmp})              # Check if member interface is already member of a bond              tmp = is_member(conf, interface, 'bonding') diff --git a/src/conf_mode/interfaces_geneve.py b/src/conf_mode/interfaces_geneve.py index 007708d4a..1c5b4d0e7 100755 --- a/src/conf_mode/interfaces_geneve.py +++ b/src/conf_mode/interfaces_geneve.py @@ -47,7 +47,7 @@ def get_config(config=None):      # GENEVE interfaces are picky and require recreation if certain parameters      # change. But a GENEVE interface should - of course - not be re-created if      # it's description or IP address is adjusted. Feels somehow logic doesn't it? -    for cli_option in ['remote', 'vni', 'parameters']: +    for cli_option in ['remote', 'vni', 'parameters', 'port']:          if is_node_changed(conf, base + [ifname, cli_option]):              geneve.update({'rebuild_required': {}}) diff --git a/src/conf_mode/interfaces_wireguard.py b/src/conf_mode/interfaces_wireguard.py index 877d013cf..192937dba 100755 --- a/src/conf_mode/interfaces_wireguard.py +++ b/src/conf_mode/interfaces_wireguard.py @@ -19,6 +19,9 @@ from sys import exit  from vyos.config import Config  from vyos.configdict import get_interface_dict  from vyos.configdict import is_node_changed +from vyos.configdict import is_source_interface +from vyos.configdep import set_dependents +from vyos.configdep import call_dependents  from vyos.configverify import verify_vrf  from vyos.configverify import verify_address  from vyos.configverify import verify_bridge_delete @@ -35,6 +38,7 @@ from vyos import airbag  from pathlib import Path  airbag.enable() +  def get_config(config=None):      """      Retrive CLI config as dictionary. Dictionary can never be empty, as at least the @@ -61,11 +65,25 @@ def get_config(config=None):              if 'disable' not in peer_config and 'host_name' in peer_config:                  wireguard['peers_need_resolve'].append(peer) +    # Check if interface is used as source-interface on VXLAN interface +    tmp = is_source_interface(conf, ifname, 'vxlan') +    if tmp: +        if 'deleted' not in wireguard: +            set_dependents('vxlan', conf, tmp) +        else: +            wireguard['is_source_interface'] = tmp +      return wireguard +  def verify(wireguard):      if 'deleted' in wireguard:          verify_bridge_delete(wireguard) +        if 'is_source_interface' in wireguard: +            raise ConfigError( +                f'Interface "{wireguard["ifname"]}" cannot be deleted as it is used ' +                f'as source interface for "{wireguard["is_source_interface"]}"!' +            )          return None      verify_mtu_ipv6(wireguard) @@ -119,9 +137,11 @@ def verify(wireguard):              public_keys.append(peer['public_key']) +  def generate(wireguard):      return None +  def apply(wireguard):      check_kmod('wireguard') @@ -157,8 +177,11 @@ def apply(wireguard):              domain_action = 'stop'      call(f'systemctl {domain_action} vyos-domain-resolver.service') +    call_dependents() +      return None +  if __name__ == '__main__':      try:          c = get_config() diff --git a/src/conf_mode/load-balancing_wan.py b/src/conf_mode/load-balancing_wan.py index 5da0b906b..92d9acfba 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-2025 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 <http://www.gnu.org/licenses/>. -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,61 @@ 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 'interface' not in rule_conf and 'exclude' not in rule_conf: +                raise ConfigError(f'Interface or exclude not specified 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 '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}') -        return None +            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}') -    # Create load-balance dir -    if not os.path.isdir(load_balancing_dir): -        os.mkdir(load_balancing_dir) +                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}') -    render(load_balancing_conf_file, 'load-balancing/wlb.conf.j2', lb) +                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}') -    return None +            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}') + +                        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/conf_mode/service_snmp.py b/src/conf_mode/service_snmp.py index d85f20820..c64c59af7 100755 --- a/src/conf_mode/service_snmp.py +++ b/src/conf_mode/service_snmp.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright (C) 2018-2025 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 @@ -147,6 +147,9 @@ def verify(snmp):          return None      if 'user' in snmp['v3']: +        if 'engineid' not in snmp['v3']: +            raise ConfigError(f'EngineID must be configured for SNMPv3!') +          for user, user_config in snmp['v3']['user'].items():              if 'group' not in user_config:                  raise ConfigError(f'Group membership required for user "{user}"!') diff --git a/src/conf_mode/system_sflow.py b/src/conf_mode/system_sflow.py index 41119b494..a22dac36f 100755 --- a/src/conf_mode/system_sflow.py +++ b/src/conf_mode/system_sflow.py @@ -54,7 +54,7 @@ def verify(sflow):      # Check if configured sflow agent-address exist in the system      if 'agent_address' in sflow:          tmp = sflow['agent_address'] -        if not is_addr_assigned(tmp): +        if not is_addr_assigned(tmp, include_vrf=True):              raise ConfigError(                  f'Configured "sflow agent-address {tmp}" does not exist in the system!'              ) diff --git a/src/etc/netplug/vyos-netplug-dhcp-client b/src/etc/netplug/vyos-netplug-dhcp-client index 83fed70f0..4cc824afd 100755 --- a/src/etc/netplug/vyos-netplug-dhcp-client +++ b/src/etc/netplug/vyos-netplug-dhcp-client @@ -19,21 +19,22 @@ import sys  from time import sleep -from vyos.configquery import ConfigTreeQuery +from vyos.config import Config  from vyos.configdict import get_interface_dict  from vyos.ifconfig import Interface  from vyos.ifconfig import Section  from vyos.utils.boot import boot_configuration_complete  from vyos.utils.commit import commit_in_progress  from vyos import airbag +  airbag.enable()  if len(sys.argv) < 3: -    airbag.noteworthy("Must specify both interface and link status!") +    airbag.noteworthy('Must specify both interface and link status!')      sys.exit(1)  if not boot_configuration_complete(): -    airbag.noteworthy("System bootup not yet finished...") +    airbag.noteworthy('System bootup not yet finished...')      sys.exit(1)  interface = sys.argv[1] @@ -47,8 +48,10 @@ while commit_in_progress():      sleep(1)  in_out = sys.argv[2] -config = ConfigTreeQuery() +config = Config()  interface_path = ['interfaces'] + Section.get_config_path(interface).split() -_, interface_config = get_interface_dict(config, interface_path[:-1], ifname=interface, with_pki=True) +_, interface_config = get_interface_dict( +    config, interface_path[:-1], ifname=interface, with_pki=True +)  Interface(interface).update(interface_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 000000000..fff258afa --- /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 <http://www.gnu.org/licenses/>. + +# 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 000000000..30329fd5c --- /dev/null +++ b/src/helpers/vyos-load-balancer.py @@ -0,0 +1,312 @@ +#!/usr/bin/python3 + +# Copyright 2024-2025 VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 <http://www.gnu.org/licenses/>. + +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 rc_cmd +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 get_ipv4_address(ifname) is None: +        return False + +    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']) +        env = { +            'WLB_INTERFACE_NAME': ifname, +            'WLB_INTERFACE_STATE': 'ACTIVE' if state else 'FAILED' +        } + +        code = run(script_path, env=env) +        if code != 0: +            print('WLB hook returned non-zero error code') + +    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_json and '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, out = rc_cmd(f'nft -f {nftables_wlb_conf}') + +    if rc != 0: +        print('ERROR: Failed to apply WLB nftables config') +        print('Output:', out) +        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 +            addr = get_ipv4_address(ifname) +            lb['health_state'][ifname] = { +                'if_addr': addr, +                'failure_count': 0, +                'success_count': 0, +                'last_success': 0, +                'last_failure': 0, +                'state': addr is not None, +                '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 --delete') +            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 + +    try: +        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 --delete') +                    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) +    except Exception as e: +        print('WLB ERROR:', e) + +    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/migration-scripts/bgp/5-to-6 b/src/migration-scripts/bgp/5-to-6 new file mode 100644 index 000000000..e6fea6574 --- /dev/null +++ b/src/migration-scripts/bgp/5-to-6 @@ -0,0 +1,39 @@ +# Copyright 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 <http://www.gnu.org/licenses/>. + +# T7163: migrate "address-family ipv4|6-unicast redistribute table" from a multi +# leafNode to a tagNode. This is needed to support per table definition of a +# route-map and/or metric + +from vyos.configtree import ConfigTree + +def migrate(config: ConfigTree) -> None: +    bgp_base = ['protocols', 'bgp'] +    if not config.exists(bgp_base): +        return + +    for address_family in ['ipv4-unicast', 'ipv6-unicast']: +        # there is no non-main routing table beeing redistributed under this addres family +        # bail out early and continue with next AFI +        table_path = bgp_base + ['address-family', address_family, 'redistribute', 'table'] +        if not config.exists(table_path): +            continue + +        tables = config.return_values(table_path) +        config.delete(table_path) + +        for table in tables: +            config.set(table_path + [table]) +            config.set_tag(table_path) diff --git a/src/migration-scripts/lldp/2-to-3 b/src/migration-scripts/lldp/2-to-3 new file mode 100644 index 000000000..93090756c --- /dev/null +++ b/src/migration-scripts/lldp/2-to-3 @@ -0,0 +1,31 @@ +# Copyright 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 <http://www.gnu.org/licenses/>. + +# T7165: Migrate LLDP interface disable to 'mode disable' + +from vyos.configtree import ConfigTree + +base = ['service', 'lldp'] + +def migrate(config: ConfigTree) -> None: +    interface_base = base + ['interface'] +    if not config.exists(interface_base): +        # Nothing to do +        return + +    for interface in config.list_nodes(interface_base): +        if config.exists(interface_base + [interface, 'disable']): +            config.delete(interface_base + [interface, 'disable']) +            config.set(interface_base + [interface, 'mode'], value='disable') diff --git a/src/migration-scripts/policy/8-to-9 b/src/migration-scripts/policy/8-to-9 new file mode 100644 index 000000000..355e48e00 --- /dev/null +++ b/src/migration-scripts/policy/8-to-9 @@ -0,0 +1,49 @@ +# Copyright (C) 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 <http://www.gnu.org/licenses/>. + +# T7116: Remove unsupported "internet" community following FRR removal +# From +    # set policy route-map <name> rule <ord> set community [add | replace] internet +    # set policy community-list <name> rule <ord> regex internet +# To +    # set policy route-map <name> rule <ord> set community [add | replace] 0:0 +    # set policy community-list <name> rule <ord> regex _0:0_ + +# NOTE: In FRR expanded community-lists, without the '_' delimiters, a regex of  +# "0:0" will match "65000:0" as well as "0:0". This doesn't line up with what +# we want when replacing "internet".  + +from vyos.configtree import ConfigTree + +rm_base = ['policy', 'route-map'] +cl_base = ['policy', 'community-list'] + +def migrate(config: ConfigTree) -> None: +    if config.exists(rm_base): +        for policy_name in config.list_nodes(rm_base): +            for rule_ord in config.list_nodes(rm_base + [policy_name, 'rule'], path_must_exist=False): +                tmp_path = rm_base + [policy_name, 'rule', rule_ord, 'set', 'community'] +                if config.exists(tmp_path + ['add']) and config.return_value(tmp_path + ['add']) == 'internet': +                    config.set(tmp_path + ['add'], '0:0') +                if config.exists(tmp_path + ['replace']) and config.return_value(tmp_path + ['replace']) == 'internet': +                    config.set(tmp_path + ['replace'], '0:0') + +    if config.exists(cl_base): +        for policy_name in config.list_nodes(cl_base): +            for rule_ord in config.list_nodes(cl_base + [policy_name, 'rule'], path_must_exist=False): +                tmp_path = cl_base + [policy_name, 'rule', rule_ord, 'regex'] +                if config.exists(tmp_path) and config.return_value(tmp_path) == 'internet': +                    config.set(tmp_path, '_0:0_') + diff --git a/src/migration-scripts/wanloadbalance/3-to-4 b/src/migration-scripts/wanloadbalance/3-to-4 new file mode 100644 index 000000000..e49f46a5b --- /dev/null +++ b/src/migration-scripts/wanloadbalance/3-to-4 @@ -0,0 +1,33 @@ +# Copyright 2025 VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 <http://www.gnu.org/licenses/>. + +from vyos.configtree import ConfigTree + +base = ['load-balancing', 'wan'] + +def migrate(config: ConfigTree) -> None: +    if not config.exists(base): +        # Nothing to do +        return + +    if config.exists(base + ['rule']): +        for rule in config.list_nodes(base + ['rule']): +            rule_base = base + ['rule', rule] + +            if config.exists(rule_base + ['inbound-interface']): +                ifname = config.return_value(rule_base + ['inbound-interface']) + +                if ifname.endswith('+'): +                    config.set(rule_base + ['inbound-interface'], value=ifname.replace('+', '*')) diff --git a/src/op_mode/load-balancing_wan.py b/src/op_mode/load-balancing_wan.py new file mode 100755 index 000000000..9fa473802 --- /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 <http://www.gnu.org/licenses/>. + +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 3b0031f34..efa835485 100755 --- a/src/op_mode/restart.py +++ b/src/op_mode/restart.py @@ -53,6 +53,10 @@ service_map = {          '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 @@ services = typing.Literal[      'haproxy',      'igmp_proxy',      'ipsec', +    'load-balancing_wan',      'mdns_repeater',      'router_advert',      'snmp', diff --git a/src/services/vyos-domain-resolver b/src/services/vyos-domain-resolver index bfc8caa0a..48c6b86d8 100755 --- a/src/services/vyos-domain-resolver +++ b/src/services/vyos-domain-resolver @@ -65,13 +65,15 @@ def get_config(conf, node):      node_config = dict_merge(default_values, node_config) -    global timeout, cache +    if node == base_firewall and 'global_options' in node_config: +        global_config = node_config['global_options'] +        global timeout, cache -    if 'resolver_interval' in node_config: -        timeout = int(node_config['resolver_interval']) +        if 'resolver_interval' in global_config: +            timeout = int(global_config['resolver_interval']) -    if 'resolver_cache' in node_config: -        cache = True +        if 'resolver_cache' in global_config: +            cache = True      fqdn_config_parse(node_config, node[0]) diff --git a/src/systemd/vyos-wan-load-balance.service b/src/systemd/vyos-wan-load-balance.service index 7d62a2ff6..a59f2c3ae 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 | 
