diff options
Diffstat (limited to 'src')
32 files changed, 888 insertions, 407 deletions
diff --git a/src/completion/list_esi.sh b/src/completion/list_esi.sh new file mode 100755 index 000000000..b8373fa57 --- /dev/null +++ b/src/completion/list_esi.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# +# 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 script is completion helper to list all valid ESEs that are visible to FRR + +esiJson=$(vtysh -c 'show evpn es json') +echo "$(echo "$esiJson" | jq -r '.[] | .esi')" diff --git a/src/completion/list_vni.sh b/src/completion/list_vni.sh new file mode 100755 index 000000000..f8bd4a993 --- /dev/null +++ b/src/completion/list_vni.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# +# 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 script is completion helper to list all configured VNIs that are visible to FRR + +vniJson=$(vtysh -c 'show evpn vni json') +echo "$(echo "$vniJson" | jq -r 'keys | .[]')" diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index a73a18ffa..91a10e891 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -329,9 +329,13 @@ def generate_run_arguments(name, container_config):              prop = vol_config['propagation']              volume += f' --volume {svol}:{dvol}:{mode},{prop}' +    host_pid = '' +    if 'allow_host_pid' in container_config: +      host_pid = '--pid host' +      container_base_cmd = f'--detach --interactive --tty --replace {capabilities} ' \                           f'--memory {memory}m --shm-size {shared_memory}m --memory-swap 0 --restart {restart} ' \ -                         f'--name {name} {hostname} {device} {port} {volume} {env_opt} {label} {uid}' +                         f'--name {name} {hostname} {device} {port} {volume} {env_opt} {label} {uid} {host_pid}'      entrypoint = ''      if 'entrypoint' in container_config: @@ -339,11 +343,6 @@ def generate_run_arguments(name, container_config):          entrypoint = json_write(container_config['entrypoint'].split()).replace('"', """)          entrypoint = f'--entrypoint '{entrypoint}'' -    hostname = '' -    if 'host_name' in container_config: -        hostname = container_config['host_name'] -        hostname = f'--hostname {hostname}' -      command = ''      if 'command' in container_config:          command = container_config['command'].strip() diff --git a/src/conf_mode/interfaces_bonding.py b/src/conf_mode/interfaces_bonding.py index 371b219c0..5e5d5fba1 100755 --- a/src/conf_mode/interfaces_bonding.py +++ b/src/conf_mode/interfaces_bonding.py @@ -33,6 +33,7 @@ from vyos.ifconfig import BondIf  from vyos.ifconfig.ethernet import EthernetIf  from vyos.ifconfig import Section  from vyos.template import render_to_string +from vyos.utils.assertion import assert_mac  from vyos.utils.dict import dict_search  from vyos.utils.dict import dict_to_paths_values  from vyos.utils.network import interface_exists @@ -244,6 +245,16 @@ def verify(bond):              raise ConfigError('primary interface only works for mode active-backup, ' \                                'transmit-load-balance or adaptive-load-balance') +    if 'system_mac' in bond: +        if bond['mode'] != '802.3ad': +            raise ConfigError('Actor MAC address only available in 802.3ad mode!') + +        system_mac = bond['system_mac'] +        try: +            assert_mac(system_mac, test_all_zero=False) +        except: +            raise ConfigError(f'Cannot use a multicast MAC address "{system_mac}" as system-mac!') +      return None  def generate(bond): diff --git a/src/conf_mode/interfaces_bridge.py b/src/conf_mode/interfaces_bridge.py index 9789f7bd3..7b2c1ee0b 100755 --- a/src/conf_mode/interfaces_bridge.py +++ b/src/conf_mode/interfaces_bridge.py @@ -56,6 +56,17 @@ def get_config(config=None):              bridge['member'].update({'interface_remove' : tmp })          else:              bridge.update({'member' : {'interface_remove' : tmp }}) +            for interface in tmp: +                # When using VXLAN member interfaces that are configured for Single +                # VXLAN Device (SVD) we need to call the VXLAN conf-mode script to +                # re-create VLAN to VNI mappings if required, but only if the interface +                # is already live on the system - this must not be done on first commit +                if interface.startswith('vxlan') and interface_exists(interface): +                    set_dependents('vxlan', conf, interface) +                # When using Wireless member interfaces we need to inform hostapd +                # to properly set-up the bridge +                elif interface.startswith('wlan') and interface_exists(interface): +                    set_dependents('wlan', conf, interface)      if dict_search('member.interface', bridge) is not None:          for interface in list(bridge['member']['interface']): @@ -91,6 +102,10 @@ def get_config(config=None):              # is already live on the system - this must not be done on first commit              if interface.startswith('vxlan') and interface_exists(interface):                  set_dependents('vxlan', conf, interface) +            # When using Wireless member interfaces we need to inform hostapd +            # to properly set-up the bridge +            elif interface.startswith('wlan') and interface_exists(interface): +                set_dependents('wlan', conf, interface)      # delete empty dictionary keys - no need to run code paths if nothing is there to do      if 'member' in bridge: @@ -140,9 +155,6 @@ def verify(bridge):              if 'enable_vlan' in bridge:                  if 'has_vlan' in interface_config:                      raise ConfigError(error_msg + 'it has VLAN subinterface(s) assigned!') - -                if 'wlan' in interface: -                    raise ConfigError(error_msg + 'VLAN aware cannot be set!')              else:                  for option in ['allowed_vlan', 'native_vlan']:                      if option in interface_config: @@ -168,12 +180,19 @@ def apply(bridge):      else:          br.update(bridge) -    for interface in dict_search('member.interface', bridge) or []: -        if interface.startswith('vxlan') and interface_exists(interface): +    tmp = [] +    if 'member' in bridge: +        if 'interface_remove' in bridge['member']: +            tmp.extend(bridge['member']['interface_remove']) +        if 'interface' in bridge['member']: +            tmp.extend(bridge['member']['interface']) + +    for interface in tmp: +        if interface.startswith(tuple(['vxlan', 'wlan'])) and interface_exists(interface):              try:                  call_dependents()              except ConfigError: -                raise ConfigError('Error in updating VXLAN interface after changing bridge!') +                raise ConfigError('Error updating member interface configuration after changing bridge!')      return None diff --git a/src/conf_mode/interfaces_ethernet.py b/src/conf_mode/interfaces_ethernet.py index 6da7e6a69..54d0669cb 100755 --- a/src/conf_mode/interfaces_ethernet.py +++ b/src/conf_mode/interfaces_ethernet.py @@ -41,6 +41,7 @@ from vyos.pki import encode_certificate  from vyos.pki import load_certificate  from vyos.pki import wrap_private_key  from vyos.template import render +from vyos.template import render_to_string  from vyos.utils.process import call  from vyos.utils.dict import dict_search  from vyos.utils.dict import dict_to_paths_values @@ -48,6 +49,7 @@ from vyos.utils.dict import dict_set  from vyos.utils.dict import dict_delete  from vyos.utils.file import write_file  from vyos import ConfigError +from vyos import frr  from vyos import airbag  airbag.enable() @@ -389,6 +391,10 @@ def generate(ethernet):              write_file(ca_cert_file_path, '\n'.join(ca_chains)) +    ethernet['frr_zebra_config'] = '' +    if 'deleted' not in ethernet: +        ethernet['frr_zebra_config'] = render_to_string('frr/evpn.mh.frr.j2', ethernet) +      return None  def apply(ethernet): @@ -407,6 +413,17 @@ def apply(ethernet):      call(f'systemctl {eapol_action} wpa_supplicant-wired@{ifname}') +    zebra_daemon = 'zebra' +    # Save original configuration prior to starting any commit actions +    frr_cfg = frr.FRRConfig() + +    # The route-map used for the FIB (zebra) is part of the zebra daemon +    frr_cfg.load_configuration(zebra_daemon) +    frr_cfg.modify_section(f'^interface {ifname}', stop_pattern='^exit', remove_stop_mark=True) +    if 'frr_zebra_config' in ethernet: +        frr_cfg.add_before(frr.default_add_before, ethernet['frr_zebra_config']) +    frr_cfg.commit_configuration(zebra_daemon) +  if __name__ == '__main__':      try:          c = get_config() diff --git a/src/conf_mode/load-balancing_reverse-proxy.py b/src/conf_mode/load-balancing_reverse-proxy.py index 1569d8d71..a4efb1cd8 100755 --- a/src/conf_mode/load-balancing_reverse-proxy.py +++ b/src/conf_mode/load-balancing_reverse-proxy.py @@ -88,6 +88,12 @@ def verify(lb):              if {'send_proxy', 'send_proxy_v2'} <= set(bk_server_conf):                  raise ConfigError(f'Cannot use both "send-proxy" and "send-proxy-v2" for server "{bk_server}"') +    # Check if http-response-headers are configured in any frontend/backend where mode != http +    for group in ['service', 'backend']: +        for config_name, config in lb[group].items(): +            if 'http_response_headers' in config and ('mode' not in config or config['mode'] != 'http'): +                raise ConfigError(f'{group} {config_name} must be set to http mode to use http_response_headers!') +          if 'ssl' in back_config:              if {'no_verify', 'ca_certificate'} <= set(back_config['ssl']):                  raise ConfigError(f'backend {back} cannot have both ssl options no-verify and ca-certificate set!') diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 4cd9b570d..f74bb217e 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -17,7 +17,6 @@  import os  from sys import exit -from netifaces import interfaces  from vyos.base import Warning  from vyos.config import Config @@ -30,6 +29,7 @@ from vyos.utils.dict import dict_search_args  from vyos.utils.process import cmd  from vyos.utils.process import run  from vyos.utils.network import is_addr_assigned +from vyos.utils.network import interface_exists  from vyos import ConfigError  from vyos import airbag @@ -149,8 +149,12 @@ def verify(nat):                  if 'name' in config['outbound_interface'] and 'group' in config['outbound_interface']:                      raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for nat source rule "{rule}"')                  elif 'name' in config['outbound_interface']: -                    if config['outbound_interface']['name'] not in 'any' and config['outbound_interface']['name'] not in interfaces(): -                        Warning(f'NAT interface "{config["outbound_interface"]["name"]}" for source NAT rule "{rule}" does not exist!') +                    interface_name = config['outbound_interface']['name'] +                    if interface_name not in 'any': +                        if interface_name.startswith('!'): +                            interface_name = interface_name[1:] +                        if not interface_exists(interface_name): +                            Warning(f'Interface "{interface_name}" for source NAT rule "{rule}" does not exist!')                  else:                      group_name = config['outbound_interface']['group']                      if group_name[0] == '!': @@ -182,8 +186,12 @@ def verify(nat):                  if 'name' in config['inbound_interface'] and 'group' in config['inbound_interface']:                      raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for destination nat rule "{rule}"')                  elif 'name' in config['inbound_interface']: -                    if config['inbound_interface']['name'] not in 'any' and config['inbound_interface']['name'] not in interfaces(): -                        Warning(f'NAT interface "{config["inbound_interface"]["name"]}" for destination NAT rule "{rule}" does not exist!') +                    interface_name = config['inbound_interface']['name'] +                    if interface_name not in 'any': +                        if interface_name.startswith('!'): +                            interface_name = interface_name[1:] +                        if not interface_exists(interface_name): +                            Warning(f'Interface "{interface_name}" for destination NAT rule "{rule}" does not exist!')                  else:                      group_name = config['inbound_interface']['group']                      if group_name[0] == '!': diff --git a/src/conf_mode/nat66.py b/src/conf_mode/nat66.py index fe017527d..075738dad 100755 --- a/src/conf_mode/nat66.py +++ b/src/conf_mode/nat66.py @@ -17,15 +17,15 @@  import os  from sys import exit -from netifaces import interfaces  from vyos.base import Warning  from vyos.config import Config  from vyos.configdep import set_dependents, call_dependents  from vyos.template import render -from vyos.utils.process import cmd -from vyos.utils.kernel import check_kmod  from vyos.utils.dict import dict_search +from vyos.utils.kernel import check_kmod +from vyos.utils.network import interface_exists +from vyos.utils.process import cmd  from vyos.template import is_ipv6  from vyos import ConfigError  from vyos import airbag @@ -64,8 +64,12 @@ def verify(nat):                  if 'name' in config['outbound_interface'] and 'group' in config['outbound_interface']:                      raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for nat source rule "{rule}"')                  elif 'name' in config['outbound_interface']: -                    if config['outbound_interface']['name'] not in 'any' and config['outbound_interface']['name'] not in interfaces(): -                        Warning(f'NAT66 interface "{config["outbound_interface"]["name"]}" for source NAT66 rule "{rule}" does not exist!') +                    interface_name = config['outbound_interface']['name'] +                    if interface_name not in 'any': +                        if interface_name.startswith('!'): +                            interface_name = interface_name[1:] +                        if not interface_exists(interface_name): +                            Warning(f'Interface "{interface_name}" for source NAT66 rule "{rule}" does not exist!')              addr = dict_search('translation.address', config)              if addr != None: @@ -88,8 +92,12 @@ def verify(nat):                  if 'name' in config['inbound_interface'] and 'group' in config['inbound_interface']:                      raise ConfigError(f'{err_msg} cannot specify both interface group and interface name for destination nat rule "{rule}"')                  elif 'name' in config['inbound_interface']: -                    if config['inbound_interface']['name'] not in 'any' and config['inbound_interface']['name'] not in interfaces(): -                        Warning(f'NAT66 interface "{config["inbound_interface"]["name"]}" for destination NAT66 rule "{rule}" does not exist!') +                    interface_name = config['inbound_interface']['name'] +                    if interface_name not in 'any': +                        if interface_name.startswith('!'): +                            interface_name = interface_name[1:] +                        if not interface_exists(interface_name): +                            Warning(f'Interface "{interface_name}" for destination NAT66 rule "{rule}" does not exist!')      return None diff --git a/src/conf_mode/nat_cgnat.py b/src/conf_mode/nat_cgnat.py index f41d66c66..5ad65de80 100755 --- a/src/conf_mode/nat_cgnat.py +++ b/src/conf_mode/nat_cgnat.py @@ -189,11 +189,6 @@ def verify(config):      if 'rule' not in config:          raise ConfigError(f'Rule must be defined!') -    # As PoC allow only one rule for CGNAT translations -    # one internal pool and one external pool -    if len(config['rule']) > 1: -        raise ConfigError(f'Only one rule is allowed for translations!') -      for pool in ('external', 'internal'):          if pool not in config['pool']:              raise ConfigError(f'{pool} pool must be defined!') @@ -203,6 +198,13 @@ def verify(config):                      f'Range for "{pool} pool {pool_name}" must be defined!'                  ) +    external_pools_query = "keys(pool.external)" +    external_pools: list = jmespath.search(external_pools_query, config) +    internal_pools_query = "keys(pool.internal)" +    internal_pools: list = jmespath.search(internal_pools_query, config) + +    used_external_pools = {} +    used_internal_pools = {}      for rule, rule_config in config['rule'].items():          if 'source' not in rule_config:              raise ConfigError(f'Rule "{rule}" source pool must be defined!') @@ -212,49 +214,82 @@ def verify(config):          if 'translation' not in rule_config:              raise ConfigError(f'Rule "{rule}" translation pool must be defined!') +        # Check if pool exists +        internal_pool = rule_config['source']['pool'] +        if internal_pool not in internal_pools: +            raise ConfigError(f'Internal pool "{internal_pool}" does not exist!') +        external_pool = rule_config['translation']['pool'] +        if external_pool not in external_pools: +            raise ConfigError(f'External pool "{external_pool}" does not exist!') + +        # Check pool duplication in different rules +        if external_pool in used_external_pools: +            raise ConfigError( +                f'External pool "{external_pool}" is already used in rule ' +                f'{used_external_pools[external_pool]} and cannot be used in ' +                f'rule {rule}!' +            ) + +        if internal_pool in used_internal_pools: +            raise ConfigError( +                f'Internal pool "{internal_pool}" is already used in rule ' +                f'{used_internal_pools[internal_pool]} and cannot be used in ' +                f'rule {rule}!' +            ) + +        used_external_pools[external_pool] = rule +        used_internal_pools[internal_pool] = rule +  def generate(config):      if not config:          return None -    # first external pool as we allow only one as PoC -    ext_pool_name = jmespath.search("rule.*.translation | [0]", config).get('pool') -    int_pool_name = jmespath.search("rule.*.source | [0]", config).get('pool') -    ext_query = f"pool.external.{ext_pool_name}.range | keys(@)" -    int_query = f"pool.internal.{int_pool_name}.range" -    external_ranges = jmespath.search(ext_query, config) -    internal_ranges = [jmespath.search(int_query, config)] - -    external_list_count = [] -    external_list_hosts = [] -    internal_list_count = [] -    internal_list_hosts = [] -    for ext_range in external_ranges: -        # External hosts count -        e_count = IPOperations(ext_range).get_ips_count() -        external_list_count.append(e_count) -        # External hosts list -        e_hosts = IPOperations(ext_range).convert_prefix_to_list_ips() -        external_list_hosts.extend(e_hosts) -    for int_range in internal_ranges: -        # Internal hosts count -        i_count = IPOperations(int_range).get_ips_count() -        internal_list_count.append(i_count) -        # Internal hosts list -        i_hosts = IPOperations(int_range).convert_prefix_to_list_ips() -        internal_list_hosts.extend(i_hosts) - -    external_host_count = sum(external_list_count) -    internal_host_count = sum(internal_list_count) -    ports_per_user = int( -        jmespath.search(f'pool.external.{ext_pool_name}.per_user_limit.port', config) -    ) -    external_port_range: str = jmespath.search( -        f'pool.external.{ext_pool_name}.external_port_range', config -    ) -    proto_maps, other_maps = generate_port_rules( -        external_list_hosts, internal_list_hosts, ports_per_user, external_port_range -    ) +    proto_maps = [] +    other_maps = [] + +    for rule, rule_config in config['rule'].items(): +        ext_pool_name: str = rule_config['translation']['pool'] +        int_pool_name: str = rule_config['source']['pool'] + +        external_ranges: list = [range for range in config['pool']['external'][ext_pool_name]['range']] +        internal_ranges: list = [range for range in config['pool']['internal'][int_pool_name]['range']] +        external_list_hosts_count = [] +        external_list_hosts = [] +        internal_list_hosts_count = [] +        internal_list_hosts = [] + +        for ext_range in external_ranges: +            # External hosts count +            e_count = IPOperations(ext_range).get_ips_count() +            external_list_hosts_count.append(e_count) +            # External hosts list +            e_hosts = IPOperations(ext_range).convert_prefix_to_list_ips() +            external_list_hosts.extend(e_hosts) + +        for int_range in internal_ranges: +            # Internal hosts count +            i_count = IPOperations(int_range).get_ips_count() +            internal_list_hosts_count.append(i_count) +            # Internal hosts list +            i_hosts = IPOperations(int_range).convert_prefix_to_list_ips() +            internal_list_hosts.extend(i_hosts) + +        external_host_count = sum(external_list_hosts_count) +        internal_host_count = sum(internal_list_hosts_count) +        ports_per_user = int( +            jmespath.search(f'pool.external."{ext_pool_name}".per_user_limit.port', config) +        ) +        external_port_range: str = jmespath.search( +            f'pool.external."{ext_pool_name}".external_port_range', config +        ) + +        rule_proto_maps, rule_other_maps = generate_port_rules( +            external_list_hosts, internal_list_hosts, ports_per_user, external_port_range +        ) + +        proto_maps.extend(rule_proto_maps) +        other_maps.extend(rule_other_maps)      config['proto_map_elements'] = ', '.join(proto_maps)      config['other_map_elements'] = ', '.join(other_maps) diff --git a/src/conf_mode/protocols_bgp.py b/src/conf_mode/protocols_bgp.py index 4df97d133..22f020099 100755 --- a/src/conf_mode/protocols_bgp.py +++ b/src/conf_mode/protocols_bgp.py @@ -31,6 +31,7 @@ from vyos.utils.dict import dict_search  from vyos.utils.network import get_interface_vrf  from vyos.utils.network import is_addr_assigned  from vyos.utils.process import process_named_running +from vyos.utils.process import call  from vyos import ConfigError  from vyos import frr  from vyos import airbag @@ -50,13 +51,8 @@ def get_config(config=None):      # eqivalent of the C foo ? 'a' : 'b' statement      base = vrf and ['vrf', 'name', vrf, 'protocols', 'bgp'] or base_path -    bgp = conf.get_config_dict( -        base, -        key_mangling=('-', '_'), -        get_first_key=True, -        no_tag_node_value_mangle=True, -        with_recursive_defaults=True, -    ) +    bgp = conf.get_config_dict(base, key_mangling=('-', '_'), +                               get_first_key=True, no_tag_node_value_mangle=True)      bgp['dependent_vrfs'] = conf.get_config_dict(['vrf', 'name'],                                                   key_mangling=('-', '_'), @@ -75,22 +71,29 @@ def get_config(config=None):      if vrf:          bgp.update({'vrf' : vrf})          # We can not delete the BGP VRF instance if there is a L3VNI configured +        # FRR L3VNI must be deleted first otherwise we will see error: +        # "FRR error: Please unconfigure l3vni 3000"          tmp = ['vrf', 'name', vrf, 'vni'] -        if conf.exists(tmp): -            bgp.update({'vni' : conf.return_value(tmp)}) +        if conf.exists_effective(tmp): +            bgp.update({'vni' : conf.return_effective_value(tmp)})          # We can safely delete ourself from the dependent vrf list          if vrf in bgp['dependent_vrfs']:              del bgp['dependent_vrfs'][vrf] -    bgp['dependent_vrfs'].update({'default': {'protocols': { -        'bgp': conf.get_config_dict(base_path, key_mangling=('-', '_'), -                                    get_first_key=True, -                                    no_tag_node_value_mangle=True)}}}) +        bgp['dependent_vrfs'].update({'default': {'protocols': { +            'bgp': conf.get_config_dict(base_path, key_mangling=('-', '_'), +                                        get_first_key=True, +                                        no_tag_node_value_mangle=True)}}}) +      if not conf.exists(base):          # If bgp instance is deleted then mark it          bgp.update({'deleted' : ''})          return bgp +    # We have gathered the dict representation of the CLI, but there are default +    # options which we need to update into the dictionary retrived. +    bgp = conf.merge_defaults(bgp, recursive=True) +      # We also need some additional information from the config, prefix-lists      # and route-maps for instance. They will be used in verify().      # @@ -242,10 +245,6 @@ def verify(bgp):                  if verify_vrf_as_import(bgp['vrf'], tmp_afi, bgp['dependent_vrfs']):                      raise ConfigError(f'Cannot delete VRF instance "{bgp["vrf"]}", ' \                                        'unconfigure "import vrf" commands!') -            # We can not delete the BGP instance if a L3VNI instance exists -            if 'vni' in bgp: -                raise ConfigError(f'Cannot delete VRF instance "{bgp["vrf"]}", ' \ -                                  f'unconfigure VNI "{bgp["vni"]}" first!')          else:              # We are running in the default VRF context, thus we can not delete              # our main BGP instance if there are dependent BGP VRF instances. @@ -254,7 +253,11 @@ def verify(bgp):                      if vrf != 'default':                          if dict_search('protocols.bgp', vrf_options):                              raise ConfigError('Cannot delete default BGP instance, ' \ -                                              'dependent VRF instance(s) exist!') +                                              'dependent VRF instance(s) exist(s)!') +                        if 'vni' in vrf_options: +                            raise ConfigError('Cannot delete default BGP instance, ' \ +                                              'dependent L3VNI exists!') +          return None      if 'system_as' not in bgp: @@ -330,7 +333,7 @@ def verify(bgp):                      raise ConfigError('Cannot have local-as same as system-as number')                  # Neighbor AS specified for local-as and remote-as can not be the same -                if dict_search('remote_as', peer_config) == asn: +                if dict_search('remote_as', peer_config) == asn and neighbor != 'peer_group':                       raise ConfigError(f'Neighbor "{peer}" has local-as specified which is '\                                          'the same as remote-as, this is not allowed!') @@ -607,6 +610,13 @@ def generate(bgp):      return None  def apply(bgp): +    if 'deleted' in bgp: +        # We need to ensure that the L3VNI is deleted first. +        # This is not possible with old config backend +        # priority bug +        if {'vrf', 'vni'} <= set(bgp): +            call('vtysh -c "conf t" -c "vrf {vrf}" -c "no vni {vni}"'.format(**bgp)) +      bgp_daemon = 'bgpd'      # Save original configuration prior to starting any commit actions diff --git a/src/conf_mode/qos.py b/src/conf_mode/qos.py index ccfc8f6b8..8a590cbc6 100755 --- a/src/conf_mode/qos.py +++ b/src/conf_mode/qos.py @@ -39,6 +39,9 @@ from vyos.utils.dict import dict_search_recursive  from vyos.utils.process import run  from vyos import ConfigError  from vyos import airbag +from vyos.xml_ref import relative_defaults + +  airbag.enable()  map_vyops_tc = { @@ -115,8 +118,18 @@ def get_config(config=None):              for rd_name in list(qos['policy'][policy]):                  # There are eight precedence levels - ensure all are present                  # to be filled later down with the appropriate default values -                default_precedence = {'precedence' : { '0' : {}, '1' : {}, '2' : {}, '3' : {}, -                                                       '4' : {}, '5' : {}, '6' : {}, '7' : {} }} +                default_p_val = relative_defaults( +                    ['qos', 'policy', 'random-detect', rd_name, 'precedence'], +                    {'precedence': {'0': {}}}, +                    get_first_key=True, recursive=True +                )['0'] +                default_p_val = {key.replace('-', '_'): value for key, value in default_p_val.items()} +                default_precedence = { +                    'precedence': {'0': default_p_val, '1': default_p_val, +                                   '2': default_p_val, '3': default_p_val, +                                   '4': default_p_val, '5': default_p_val, +                                   '6': default_p_val, '7': default_p_val}} +                  qos['policy']['random_detect'][rd_name] = dict_merge(                      default_precedence, qos['policy']['random_detect'][rd_name]) @@ -124,18 +137,6 @@ def get_config(config=None):      for policy in qos.get('policy', []):          for p_name, p_config in qos['policy'][policy].items(): -            if 'precedence' in p_config: -                # precedence settings are a bit more complex as they are -                # calculated under specific circumstances: -                for precedence in p_config['precedence']: -                    max_thr = int(qos['policy'][policy][p_name]['precedence'][precedence]['maximum_threshold']) -                    if 'minimum_threshold' not in qos['policy'][policy][p_name]['precedence'][precedence]: -                        qos['policy'][policy][p_name]['precedence'][precedence]['minimum_threshold'] = str( -                            int((9 + int(precedence)) * max_thr) // 18); - -                    if 'queue_limit' not in qos['policy'][policy][p_name]['precedence'][precedence]: -                        qos['policy'][policy][p_name]['precedence'][precedence]['queue_limit'] = \ -                            str(int(4 * max_thr))              # cleanup empty match config              if 'class' in p_config:                  for cls, cls_config in p_config['class'].items(): diff --git a/src/conf_mode/service_dhcpv6-server.py b/src/conf_mode/service_dhcpv6-server.py index c7333dd3a..7af88007c 100755 --- a/src/conf_mode/service_dhcpv6-server.py +++ b/src/conf_mode/service_dhcpv6-server.py @@ -106,14 +106,14 @@ def verify(dhcpv6):                          # Stop address must be greater or equal to start address                          if not ip_address(stop) >= ip_address(start): -                            raise ConfigError(f'Range stop address "{stop}" must be greater then or equal ' \ +                            raise ConfigError(f'Range stop address "{stop}" must be greater than or equal ' \                                                f'to the range start address "{start}"!')                          # DHCPv6 range start address must be unique - two ranges can't                          # start with the same address - makes no sense                          if start in range6_start:                              raise ConfigError(f'Conflicting DHCPv6 lease range: '\ -                                              f'Pool start address "{start}" defined multipe times!') +                                              f'Pool start address "{start}" defined multiple times!')                          range6_start.append(start) @@ -121,7 +121,7 @@ def verify(dhcpv6):                          # end with the same address - makes no sense                          if stop in range6_stop:                              raise ConfigError(f'Conflicting DHCPv6 lease range: '\ -                                              f'Pool stop address "{stop}" defined multipe times!') +                                              f'Pool stop address "{stop}" defined multiple times!')                          range6_stop.append(stop) @@ -180,7 +180,7 @@ def verify(dhcpv6):              if 'option' in subnet_config:                  if 'vendor_option' in subnet_config['option']:                      if len(dict_search('option.vendor_option.cisco.tftp_server', subnet_config)) > 2: -                        raise ConfigError(f'No more then two Cisco tftp-servers should be defined for subnet "{subnet}"!') +                        raise ConfigError(f'No more than two Cisco tftp-servers should be defined for subnet "{subnet}"!')              # Subnets must be unique              if subnet in subnets: diff --git a/src/conf_mode/service_pppoe-server.py b/src/conf_mode/service_pppoe-server.py index 328487985..c95f976d3 100755 --- a/src/conf_mode/service_pppoe-server.py +++ b/src/conf_mode/service_pppoe-server.py @@ -84,12 +84,29 @@ def verify_pado_delay(pppoe):          pado_delay = pppoe['pado_delay']          delays_without_sessions = pado_delay['delays_without_sessions'] +        if 'disable' in delays_without_sessions: +            raise ConfigError( +                'Number of sessions must be specified for "pado-delay disable"' +            ) +          if len(delays_without_sessions) > 1:              raise ConfigError(                  f'Cannot add more then ONE pado-delay without sessions, '                  f'but {len(delays_without_sessions)} were set'              ) +        if 'disable' in [delay[0] for delay in pado_delay['delays_with_sessions']]: +            # need to sort delays by sessions to verify if there is no delay +            # for sessions after disabling +            sorted_pado_delay = sorted(pado_delay['delays_with_sessions'], key=lambda k_v: k_v[1]) +            last_delay = sorted_pado_delay[-1] + +            if last_delay[0] != 'disable': +                raise ConfigError( +                    f'Cannot add pado-delay after disabled sessions, but ' +                    f'"pado-delay {last_delay[0]} sessions {last_delay[1]}" was set' +                ) +  def verify(pppoe):      if not pppoe:          return None diff --git a/src/conf_mode/service_upnp.py b/src/conf_mode/service_upnp.py deleted file mode 100755 index 0df8dc09e..000000000 --- a/src/conf_mode/service_upnp.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2021-2022 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 os - -from sys import exit -import uuid -import netifaces -from ipaddress import IPv4Network -from ipaddress import IPv6Network - -from vyos.config import Config -from vyos.utils.process import call -from vyos.template import render -from vyos.template import is_ipv4 -from vyos.template import is_ipv6 -from vyos import ConfigError -from vyos import airbag -airbag.enable() - -config_file = r'/run/upnp/miniupnp.conf' - -def get_config(config=None): -    if config: -        conf = config -    else: -        conf = Config() - -    base = ['service', 'upnp'] -    upnpd = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True) - -    if not upnpd: -        return None - -    upnpd = conf.merge_defaults(upnpd, recursive=True) - -    uuidgen = uuid.uuid1() -    upnpd.update({'uuid': uuidgen}) - -    return upnpd - -def get_all_interface_addr(prefix, filter_dev, filter_family): -    list_addr = [] -    for interface in netifaces.interfaces(): -        if filter_dev and interface in filter_dev: -            continue -        addrs = netifaces.ifaddresses(interface) -        if netifaces.AF_INET in addrs.keys(): -            if netifaces.AF_INET in filter_family: -                for addr in addrs[netifaces.AF_INET]: -                    if prefix: -                        # we need to manually assemble a list of IPv4 address/prefix -                        prefix = '/' + \ -                            str(IPv4Network('0.0.0.0/' + addr['netmask']).prefixlen) -                        list_addr.append(addr['addr'] + prefix) -                    else: -                        list_addr.append(addr['addr']) -        if netifaces.AF_INET6 in addrs.keys(): -            if netifaces.AF_INET6 in filter_family: -                for addr in addrs[netifaces.AF_INET6]: -                    if prefix: -                        # we need to manually assemble a list of IPv4 address/prefix -                        bits = bin(int(addr['netmask'].replace(':', '').split('/')[0], 16)).count('1') -                        prefix = '/' + str(bits) -                        list_addr.append(addr['addr'] + prefix) -                    else: -                        list_addr.append(addr['addr']) - -    return list_addr - -def verify(upnpd): -    if not upnpd: -        return None - -    if 'wan_interface' not in upnpd: -        raise ConfigError('To enable UPNP, you must have the "wan-interface" option!') - -    if 'rule' in upnpd: -        for rule, rule_config in upnpd['rule'].items(): -            for option in ['external_port_range', 'internal_port_range', 'ip', 'action']: -                if option not in rule_config: -                    tmp = option.replace('_', '-') -                    raise ConfigError(f'Every UPNP rule requires "{tmp}" to be set!') - -    if 'stun' in upnpd: -        for option in ['host', 'port']: -            if option not in upnpd['stun']: -                raise ConfigError(f'A UPNP stun support must have an "{option}" option!') - -    # Check the validity of the IP address -    listen_dev = [] -    system_addrs_cidr = get_all_interface_addr(True, [], [netifaces.AF_INET, netifaces.AF_INET6]) -    system_addrs = get_all_interface_addr(False, [], [netifaces.AF_INET, netifaces.AF_INET6]) -    if 'listen' not in upnpd: -        raise ConfigError(f'Listen address or interface is required!') -    for listen_if_or_addr in upnpd['listen']: -        if listen_if_or_addr not in netifaces.interfaces(): -            listen_dev.append(listen_if_or_addr) -        if (listen_if_or_addr not in system_addrs) and (listen_if_or_addr not in system_addrs_cidr) and \ -                (listen_if_or_addr not in netifaces.interfaces()): -            if is_ipv4(listen_if_or_addr) and IPv4Network(listen_if_or_addr).is_multicast: -                raise ConfigError(f'The address "{listen_if_or_addr}" is an address that is not allowed' -                                  f'to listen on. It is not an interface address nor a multicast address!') -            if is_ipv6(listen_if_or_addr) and IPv6Network(listen_if_or_addr).is_multicast: -                raise ConfigError(f'The address "{listen_if_or_addr}" is an address that is not allowed' -                                  f'to listen on. It is not an interface address nor a multicast address!') - -    system_listening_dev_addrs_cidr = get_all_interface_addr(True, listen_dev, [netifaces.AF_INET6]) -    system_listening_dev_addrs = get_all_interface_addr(False, listen_dev, [netifaces.AF_INET6]) -    for listen_if_or_addr in upnpd['listen']: -        if listen_if_or_addr not in netifaces.interfaces() and \ -                (listen_if_or_addr not in system_listening_dev_addrs_cidr) and \ -                (listen_if_or_addr not in system_listening_dev_addrs) and \ -                is_ipv6(listen_if_or_addr) and \ -                (not IPv6Network(listen_if_or_addr).is_multicast): -            raise ConfigError(f'{listen_if_or_addr} must listen on the interface of the network card') - -def generate(upnpd): -    if not upnpd: -        return None - -    if os.path.isfile(config_file): -        os.unlink(config_file) - -    render(config_file, 'firewall/upnpd.conf.j2', upnpd) - -def apply(upnpd): -    systemd_service_name = 'miniupnpd.service' -    if not upnpd: -        # Stop the UPNP service -        call(f'systemctl stop {systemd_service_name}') -    else: -        # Start the UPNP service -        call(f'systemctl restart {systemd_service_name}') - -if __name__ == '__main__': -    try: -        c = get_config() -        verify(c) -        generate(c) -        apply(c) -    except ConfigError as e: -        print(e) -        exit(1) diff --git a/src/conf_mode/system_host-name.py b/src/conf_mode/system_host-name.py index 8975cadb6..3f245f166 100755 --- a/src/conf_mode/system_host-name.py +++ b/src/conf_mode/system_host-name.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2018-2023 VyOS maintainers and contributors +# Copyright (C) 2018-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 @@ -22,6 +22,7 @@ import vyos.hostsd_client  from vyos.base import Warning  from vyos.config import Config +from vyos.configdict import leaf_node_changed  from vyos.ifconfig import Section  from vyos.template import is_ip  from vyos.utils.process import cmd @@ -37,6 +38,7 @@ default_config_data = {      'domain_search': [],      'nameserver': [],      'nameservers_dhcp_interfaces': {}, +    'snmpd_restart_reqired': False,      'static_host_mapping': {}  } @@ -52,6 +54,10 @@ def get_config(config=None):      hosts['hostname'] = conf.return_value(['system', 'host-name']) +    base = ['system'] +    if leaf_node_changed(conf, base + ['host-name']) or leaf_node_changed(conf, base + ['domain-name']): +        hosts['snmpd_restart_reqired'] = True +      # This may happen if the config is not loaded yet,      # e.g. if run by cloud-init      if not hosts['hostname']: @@ -171,7 +177,7 @@ def apply(config):          call("systemctl restart rsyslog.service")      # If SNMP is running, restart it too -    if process_named_running('snmpd'): +    if process_named_running('snmpd') and config['snmpd_restart_reqired']:          call('systemctl restart snmpd.service')      return None diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index 587309005..8d8c234c0 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -130,11 +130,6 @@ def get_config(config=None):      tmp = {'policy' : {'route-map' : conf.get_config_dict(['policy', 'route-map'],                                                            get_first_key=True)}} -    # L3VNI setup is done via vrf_vni.py as it must be de-configured (on node -    # deletetion prior to the BGP process. Tell the Jinja2 template no VNI -    # setup is needed -    vrf.update({'no_vni' : ''}) -      # Merge policy dict into "regular" config dict      vrf = dict_merge(tmp, vrf)      return vrf diff --git a/src/conf_mode/vrf_vni.py b/src/conf_mode/vrf_vni.py deleted file mode 100644 index 8dab164d7..000000000 --- a/src/conf_mode/vrf_vni.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -# -# 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 -# 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/>. - -from sys import argv -from sys import exit - -from vyos.config import Config -from vyos.template import render_to_string -from vyos import ConfigError -from vyos import frr -from vyos import airbag -airbag.enable() - -def get_config(config=None): -    if config: -        conf = config -    else: -        conf = Config() - -    vrf_name = None -    if len(argv) > 1: -        vrf_name = argv[1] -    else: -        return None - -    # Using duplicate L3VNIs makes no sense - it's also forbidden in FRR, -    # thus VyOS CLI must deny this, too. Instead of getting only the dict for -    # the requested VRF and den comparing it with depenent VRfs to not have any -    # duplicate we will just grad ALL VRFs by default but only render/apply -    # the configuration for the requested VRF - that makes the code easier and -    # hopefully less error prone -    vrf = conf.get_config_dict(['vrf'], key_mangling=('-', '_'), -                               no_tag_node_value_mangle=True, -                               get_first_key=True) - -    # Store name of VRF we are interested in for FRR config rendering -    vrf.update({'only_vrf' : vrf_name}) - -    return vrf - -def verify(vrf): -    if not vrf: -        return - -    if len(argv) < 2: -        raise ConfigError('VRF parameter not specified when valling vrf_vni.py') - -    if 'name' in vrf: -        vni_ids = [] -        for name, vrf_config in vrf['name'].items(): -            # VRF VNI (Virtual Network Identifier) must be unique on the system -            if 'vni' in vrf_config: -                if vrf_config['vni'] in vni_ids: -                    raise ConfigError(f'VRF "{name}" VNI is not unique!') -                vni_ids.append(vrf_config['vni']) - -    return None - -def generate(vrf): -    if not vrf: -        return - -    vrf['new_frr_config'] = render_to_string('frr/zebra.vrf.route-map.frr.j2', vrf) -    return None - -def apply(vrf): -    frr_daemon = 'zebra' - -    # add configuration to FRR -    frr_cfg = frr.FRRConfig() -    frr_cfg.load_configuration(frr_daemon) -    # There is only one VRF inside the dict as we read only one in get_config() -    if vrf and 'only_vrf' in vrf: -        vrf_name = vrf['only_vrf'] -        frr_cfg.modify_section(f'^vrf {vrf_name}', stop_pattern='^exit-vrf', remove_stop_mark=True) -    if vrf and 'new_frr_config' in vrf: -        frr_cfg.add_before(frr.default_add_before, vrf['new_frr_config']) -    frr_cfg.commit_configuration(frr_daemon) - -    return None - -if __name__ == '__main__': -    try: -        c = get_config() -        verify(c) -        generate(c) -        apply(c) -    except ConfigError as e: -        print(e) -        exit(1) diff --git a/src/helpers/vyos_config_sync.py b/src/helpers/vyos_config_sync.py index 0604b2837..9d9aec376 100755 --- a/src/helpers/vyos_config_sync.py +++ b/src/helpers/vyos_config_sync.py @@ -93,7 +93,8 @@ def set_remote_config(          key: str,          op: str,          mask: Dict[str, Any], -        config: Dict[str, Any]) -> Optional[Dict[str, Any]]: +        config: Dict[str, Any], +        port: int) -> Optional[Dict[str, Any]]:      """Loads the VyOS configuration in JSON format to a remote host.      Args: @@ -102,6 +103,7 @@ def set_remote_config(          op (str): The operation to perform (set or load).          mask (dict): The dict of paths in sections.          config (dict): The dict of masked config data. +        port (int): The remote API port      Returns:          Optional[Dict[str, Any]]: The response from the remote host as a @@ -113,7 +115,7 @@ def set_remote_config(      # Disable the InsecureRequestWarning      urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -    url = f'https://{address}/configure-section' +    url = f'https://{address}:{port}/configure-section'      data = json.dumps({          'op': op,          'mask': mask, @@ -138,7 +140,8 @@ def is_section_revised(section: List[str]) -> bool:  def config_sync(secondary_address: str,                  secondary_key: str,                  sections: List[list[str]], -                mode: str): +                mode: str, +                secondary_port: int):      """Retrieve a config section from primary router in JSON format and send it to         secondary router      """ @@ -158,7 +161,8 @@ def config_sync(secondary_address: str,                                     key=secondary_key,                                     op=mode,                                     mask=mask_dict, -                                   config=config_dict) +                                   config=config_dict, +                                   port=secondary_port)      logger.debug(f"Set config for sections '{sections}': {set_config}") @@ -178,14 +182,12 @@ if __name__ == '__main__':      secondary_address = config.get('secondary', {}).get('address')      secondary_address = bracketize_ipv6(secondary_address)      secondary_key = config.get('secondary', {}).get('key') +    secondary_port = int(config.get('secondary', {}).get('port', 443))      sections = config.get('section')      timeout = int(config.get('secondary', {}).get('timeout')) -    if not all([ -            mode, secondary_address, secondary_key, sections -    ]): -        logger.error( -            "Missing required configuration data for config synchronization.") +    if not all([mode, secondary_address, secondary_key, sections]): +        logger.error("Missing required configuration data for config synchronization.")          exit(0)      # Generate list_sections of sections/subsections @@ -200,5 +202,4 @@ if __name__ == '__main__':          else:              list_sections.append([section]) -    config_sync(secondary_address, secondary_key, -                list_sections, mode) +    config_sync(secondary_address, secondary_key, list_sections, mode, secondary_port) diff --git a/src/migration-scripts/nat/7-to-8 b/src/migration-scripts/nat/7-to-8 new file mode 100755 index 000000000..ab2ffa6d3 --- /dev/null +++ b/src/migration-scripts/nat/7-to-8 @@ -0,0 +1,62 @@ +#!/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/>. + +# T6345: random - In kernel 5.0 and newer this is the same as fully-random. +#        In earlier kernels the port mapping will be randomized using a seeded +#        MD5 hash mix using source and destination address and destination port. +#        drop fully-random from CLI + +from sys import argv,exit +from vyos.configtree import ConfigTree + +if len(argv) < 2: +    print("Must specify file name!") +    exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +config = ConfigTree(config_file) + +if not config.exists(['nat']): +    # Nothing to do +    exit(0) + +for direction in ['source', 'destination']: +    # If a node doesn't exist, we obviously have nothing to do. +    if not config.exists(['nat', direction]): +        continue + +    # However, we also need to handle the case when a 'source' or 'destination' sub-node does exist, +    # but there are no rules under it. +    if not config.list_nodes(['nat', direction]): +        continue + +    for rule in config.list_nodes(['nat', direction, 'rule']): +        port_mapping = ['nat', direction, 'rule', rule, 'translation', 'options', 'port-mapping'] +        if config.exists(port_mapping): +            tmp = config.return_value(port_mapping) +            if tmp == 'fully-random': +                config.set(port_mapping, value='random') + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print(f'Failed to save the modified config: {e}') +    exit(1) diff --git a/src/migration-scripts/pppoe-server/9-to-10 b/src/migration-scripts/pppoe-server/9-to-10 new file mode 100755 index 000000000..e0c782f04 --- /dev/null +++ b/src/migration-scripts/pppoe-server/9-to-10 @@ -0,0 +1,56 @@ +#!/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/>. + +# Migration of pado-delay options + +from sys import argv +from sys import exit +from vyos.configtree import ConfigTree + +if len(argv) < 2: +    print("Must specify file name!") +    exit(1) + +file_name = argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +config = ConfigTree(config_file) +base = ['service', 'pppoe-server', 'pado-delay'] +if not config.exists(base): +    exit(0) + +pado_delay = {} +for delay in config.list_nodes(base): +    sessions = config.return_value(base + [delay, 'sessions']) +    pado_delay[delay] = sessions + +# need to define delay for latest sessions +sorted_delays = dict(sorted(pado_delay.items(), key=lambda k_v: int(k_v[1]))) +last_delay = list(sorted_delays)[-1] + +# Rename last delay -> disable +tmp = base + [last_delay] +if config.exists(tmp): +    config.rename(tmp, 'disable') + +try: +    with open(file_name, 'w') as f: +        f.write(config.to_string()) +except OSError as e: +    print("Failed to save the modified config: {}".format(e)) +    exit(1) diff --git a/src/op_mode/bonding.py b/src/op_mode/bonding.py new file mode 100755 index 000000000..07bccbd4b --- /dev/null +++ b/src/op_mode/bonding.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2016-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 script will parse 'sudo cat /proc/net/bonding/<interface name>' and return table output for lacp related info + +import subprocess +import re +import sys +import typing +from tabulate import tabulate + +import vyos.opmode +from vyos.configquery import ConfigTreeQuery + +def list_to_dict(data, headers, basekey): +    data_list = {basekey: []} + +    for row in data: +        row_dict = {headers[i]: row[i] for i in range(len(headers))} +        data_list[basekey].append(row_dict) + +    return data_list + +def show_lacp_neighbors(raw: bool, interface: typing.Optional[str]): +    headers = ["Interface", "Member", "Local ID", "Remote ID"] +    data = subprocess.run(f"cat /proc/net/bonding/{interface}", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, shell=True, text=False).stdout.decode('utf-8') +    if 'Bonding Mode: IEEE 802.3ad Dynamic link aggregation' not in data: +        raise vyos.opmode.DataUnavailable(f"{interface} is not present or not configured with mode 802.3ad") + +    pattern = re.compile( +        r"Slave Interface: (?P<member>\w+\d+).*?" +        r"system mac address: (?P<local_id>[0-9a-f:]+).*?" +        r"details partner lacp pdu:.*?" +        r"system mac address: (?P<remote_id>[0-9a-f:]+)", +        re.DOTALL +    ) + +    interfaces = [] + +    for match in re.finditer(pattern, data): +        member = match.group("member") +        local_id = match.group("local_id") +        remote_id = match.group("remote_id") +        interfaces.append([interface, member, local_id, remote_id]) + +    if raw: +        return list_to_dict(interfaces, headers, 'lacp') +    else: +        return tabulate(interfaces, headers) + +def show_lacp_detail(raw: bool, interface: typing.Optional[str]): +    headers = ["Interface", "Members", "Mode", "Rate", "System-MAC", "Hash"] +    query = ConfigTreeQuery() + +    if interface: +        intList = [interface] +    else: +        intList = query.list_nodes(['interfaces', 'bonding']) + +    bondList = [] + +    for interface in intList: +        data = subprocess.run(f"cat /proc/net/bonding/{interface}", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, shell=True, text=False).stdout.decode('utf-8') +        if 'Bonding Mode: IEEE 802.3ad Dynamic link aggregation' not in data: +            continue + +        mode_active = "active" if "LACP active: on" in data else "passive" +        lacp_rate = re.search(r"LACP rate: (\w+)", data).group(1) if re.search(r"LACP rate: (\w+)", data) else "N/A" +        hash_policy = re.search(r"Transmit Hash Policy: (.+?) \(\d+\)", data).group(1) if re.search(r"Transmit Hash Policy: (.+?) \(\d+\)", data) else "N/A" +        system_mac = re.search(r"System MAC address: ([0-9a-f:]+)", data).group(1) if re.search(r"System MAC address: ([0-9a-f:]+)", data) else "N/A" +        if raw: +            members = re.findall(r"Slave Interface: ([a-zA-Z0-9:_-]+)", data) +        else: +            members = ",".join(set(re.findall(r"Slave Interface: ([a-zA-Z0-9:_-]+)", data))) + +        bondList.append([interface, members, mode_active, lacp_rate, system_mac, hash_policy]) + +    if raw: +        return list_to_dict(bondList, headers, 'lacp') +    else: +        return tabulate(bondList, headers) + +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/cgnat.py b/src/op_mode/cgnat.py new file mode 100755 index 000000000..9ad8f92f9 --- /dev/null +++ b/src/op_mode/cgnat.py @@ -0,0 +1,96 @@ +#!/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 sys +import typing + +from tabulate import tabulate + +import vyos.opmode + +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import cmd + +CGNAT_TABLE = 'cgnat' + + +def _get_raw_data(external_address: str = '', internal_address: str = '') -> list[dict]: +    """Get CGNAT dictionary and filter by external or internal address if provided.""" +    cmd_output = cmd(f'nft --json list table ip {CGNAT_TABLE}') +    data = json.loads(cmd_output) + +    elements = data['nftables'][2]['map']['elem'] +    allocations = [] +    for elem in elements: +        internal = elem[0]  # internal +        external = elem[1]['concat'][0]  # external +        start_port = elem[1]['concat'][1]['range'][0] +        end_port = elem[1]['concat'][1]['range'][1] +        port_range = f'{start_port}-{end_port}' + +        if (internal_address and internal != internal_address) or ( +            external_address and external != external_address +        ): +            continue + +        allocations.append( +            { +                'internal_address': internal, +                'external_address': external, +                'port_range': port_range, +            } +        ) + +    return allocations + + +def _get_formatted_output(allocations: list[dict]) -> str: +    # Convert the list of dictionaries to a list of tuples for tabulate +    headers = ['Internal IP', 'External IP', 'Port range'] +    data = [ +        (alloc['internal_address'], alloc['external_address'], alloc['port_range']) +        for alloc in allocations +    ] +    output = tabulate(data, headers, numalign="left") +    return output + + +def show_allocation( +    raw: bool, +    external_address: typing.Optional[str], +    internal_address: typing.Optional[str], +) -> str: +    config = ConfigTreeQuery() +    if not config.exists('nat cgnat'): +        raise vyos.opmode.UnconfiguredSubsystem('CGNAT is not configured') + +    if raw: +        return _get_raw_data(external_address, internal_address) + +    else: +        raw_data = _get_raw_data(external_address, internal_address) +        return _get_formatted_output(raw_data) + + +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/evpn.py b/src/op_mode/evpn.py new file mode 100644 index 000000000..cae4ab9f5 --- /dev/null +++ b/src/op_mode/evpn.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2016-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 script is a helper to run VTYSH commands for "show evpn", allowing for the --raw flag to output JSON + +import sys +import typing +import json + +import vyos.opmode +from vyos.utils.process import cmd + +def show_evpn(raw: bool, command: typing.Optional[str]): +    if raw: +        command = f"{command} json" +        evpnDict = {} +        try: +            evpnDict['evpn'] = json.loads(cmd(f"vtysh -c '{command}'")) +        except: +            raise vyos.opmode.DataUnavailable(f"\"{command.replace(' json', '')}\" is invalid or has no JSON option") + +        return evpnDict +    else: +        return cmd(f"vtysh -c '{command}'") + +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/firewall.py b/src/op_mode/firewall.py index 442c186cc..15fbb65a2 100755 --- a/src/op_mode/firewall.py +++ b/src/op_mode/firewall.py @@ -531,9 +531,15 @@ def show_firewall_group(name=None):                              continue                          for idx, member in enumerate(members): -                            val = member.get('val', 'N/D') -                            timeout = str(member.get('timeout', 'N/D')) -                            expires = str(member.get('expires', 'N/D')) +                            if isinstance(member, str): +                                # Only member, and no timeout: +                                val = member +                                timeout = "N/D" +                                expires = "N/D" +                            else: +                                val = member.get('val', 'N/D') +                                timeout = str(member.get('timeout', 'N/D')) +                                expires = str(member.get('expires', 'N/D'))                              if args.detail:                                  row.append(f'{val} (timeout: {timeout}, expires: {expires})') diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py index ba0e3b6db..0d2d7076c 100755 --- a/src/op_mode/image_installer.py +++ b/src/op_mode/image_installer.py @@ -23,6 +23,8 @@ from shutil import copy, chown, rmtree, copytree  from glob import glob  from sys import exit  from os import environ +from os import readlink +from os import getpid, getppid  from typing import Union  from urllib.parse import urlparse  from passlib.hosts import linux_context @@ -65,7 +67,7 @@ MSG_INPUT_PASSWORD: str = 'Please enter a password for the "vyos" user:'  MSG_INPUT_PASSWORD_CONFIRM: str = 'Please confirm password for the "vyos" user:'  MSG_INPUT_ROOT_SIZE_ALL: str = 'Would you like to use all the free space on the drive?'  MSG_INPUT_ROOT_SIZE_SET: str = 'Please specify the size (in GB) of the root partition (min is 1.5 GB)?' -MSG_INPUT_CONSOLE_TYPE: str = 'What console should be used by default? (K: KVM, S: Serial, U: USB-Serial)?' +MSG_INPUT_CONSOLE_TYPE: str = 'What console should be used by default? (K: KVM, S: Serial)?'  MSG_INPUT_COPY_DATA: str = 'Would you like to copy data to the new image?'  MSG_INPUT_CHOOSE_COPY_DATA: str = 'From which image would you like to save config information?'  MSG_INPUT_COPY_ENC_DATA: str = 'Would you like to copy the encrypted config to the new image?' @@ -614,6 +616,20 @@ def copy_ssh_host_keys() -> bool:      return False +def console_hint() -> str: +    pid = getppid() if 'SUDO_USER' in environ else getpid() +    try: +        path = readlink(f'/proc/{pid}/fd/1') +    except OSError: +        path = '/dev/tty' + +    name = Path(path).name +    if name == 'ttyS0': +        return 'S' +    else: +        return 'K' + +  def cleanup(mounts: list[str] = [], remove_items: list[str] = []) -> None:      """Clean up after installation @@ -709,9 +725,9 @@ def install_image() -> None:      # ask for default console      console_type: str = ask_input(MSG_INPUT_CONSOLE_TYPE, -                                  default='K', -                                  valid_responses=['K', 'S', 'U']) -    console_dict: dict[str, str] = {'K': 'tty', 'S': 'ttyS', 'U': 'ttyUSB'} +                                  default=console_hint(), +                                  valid_responses=['K', 'S']) +    console_dict: dict[str, str] = {'K': 'tty', 'S': 'ttyS'}      config_boot_list = ['/opt/vyatta/etc/config/config.boot',                          '/opt/vyatta/etc/config.boot.default'] diff --git a/src/op_mode/image_manager.py b/src/op_mode/image_manager.py index 1cfb5f5a1..fb4286dbc 100755 --- a/src/op_mode/image_manager.py +++ b/src/op_mode/image_manager.py @@ -21,7 +21,7 @@ from argparse import ArgumentParser, Namespace  from pathlib import Path  from shutil import rmtree  from sys import exit -from typing import Optional +from typing import Optional, Literal, TypeAlias, get_args  from vyos.system import disk, grub, image, compat  from vyos.utils.io import ask_yes_no, select_entry @@ -33,6 +33,8 @@ DELETE_IMAGE_PROMPT_MSG: str = 'Select an image to delete:'  MSG_DELETE_IMAGE_RUNNING: str = 'Currently running image cannot be deleted; reboot into another image first'  MSG_DELETE_IMAGE_DEFAULT: str = 'Default image cannot be deleted; set another image as default first' +ConsoleType: TypeAlias = Literal['tty', 'ttyS'] +  def annotate_list(images_list: list[str]) -> list[str]:      """Annotate list of images with additional info @@ -202,6 +204,15 @@ def rename_image(name_old: str, name_new: str) -> None:              exit(f'Unable to rename the encrypted config for "{name_old}" to "{name_new}": {err}') +@compat.grub_cfg_update +def set_console_type(console_type: ConsoleType) -> None: +    console_choice = get_args(ConsoleType) +    if console_type not in console_choice: +        exit(f'console type \'{console_type}\' not available') + +    grub.set_console_type(console_type) + +  def list_images() -> None:      """Print list of available images for CLI hints"""      images_list: list[str] = grub.version_list() @@ -209,6 +220,13 @@ def list_images() -> None:          print(image_name) +def list_console_types() -> None: +    """Print list of console types for CLI hints""" +    console_types: list[str] = list(get_args(ConsoleType)) +    for console_type in console_types: +        print(console_type) + +  def parse_arguments() -> Namespace:      """Parse arguments @@ -217,7 +235,8 @@ def parse_arguments() -> Namespace:      """      parser: ArgumentParser = ArgumentParser(description='Manage system images')      parser.add_argument('--action', -                        choices=['delete', 'set', 'rename', 'list'], +                        choices=['delete', 'set', 'set_console_type', +                                 'rename', 'list', 'list_console_types'],                          required=True,                          help='action to perform with an image')      parser.add_argument('--no-prompt', action='store_true', @@ -227,6 +246,7 @@ def parse_arguments() -> Namespace:          help=          'a name of an image to add, delete, install, rename, or set as default')      parser.add_argument('--image-new-name', help='a new name for image') +    parser.add_argument('--console-type', help='console type for boot')      args: Namespace = parser.parse_args()      # Validate arguments      if args.action == 'rename' and (not args.image_name or @@ -243,10 +263,14 @@ if __name__ == '__main__':              delete_image(args.image_name, args.no_prompt)          if args.action == 'set':              set_image(args.image_name) +        if args.action == 'set_console_type': +            set_console_type(args.console_type)          if args.action == 'rename':              rename_image(args.image_name, args.image_new_name)          if args.action == 'list':              list_images() +        if args.action == 'list_console_types': +            list_console_types()          exit() diff --git a/src/op_mode/ipoe-control.py b/src/op_mode/ipoe-control.py index 0f33beca7..b7d6a0c43 100755 --- a/src/op_mode/ipoe-control.py +++ b/src/op_mode/ipoe-control.py @@ -56,7 +56,11 @@ def main():          if args.selector in cmd_dict['selector'] and args.target:              run(cmd_dict['cmd_base'] + "{0} {1} {2}".format(args.action, args.selector, args.target))          else: -            output, err = popen(cmd_dict['cmd_base'] + cmd_dict['actions'][args.action], decode='utf-8') +            if args.action == "show_sessions": +                ses_pattern = " ifname,username,calling-sid,ip,ip6,ip6-dp,rate-limit,type,comp,state,uptime" +            else: +                ses_pattern = "" +            output, err = popen(cmd_dict['cmd_base'] + cmd_dict['actions'][args.action] + ses_pattern, decode='utf-8')              if not err:                  print(output)              else: diff --git a/src/op_mode/nat.py b/src/op_mode/nat.py index 2bc7e24fe..4ab524fb7 100755 --- a/src/op_mode/nat.py +++ b/src/op_mode/nat.py @@ -263,7 +263,7 @@ def _get_formatted_translation(dict_data, nat_direction, family, verbose):                      proto = meta['layer4']['protoname']              if direction == 'independent':                  conn_id = meta['id'] -                timeout = meta['timeout'] +                timeout = meta.get('timeout', 'n/a')                  orig_src = f'{orig_src}:{orig_sport}' if orig_sport else orig_src                  orig_dst = f'{orig_dst}:{orig_dport}' if orig_dport else orig_dst                  reply_src = f'{reply_src}:{reply_sport}' if reply_sport else reply_src diff --git a/src/op_mode/ntp.py b/src/op_mode/ntp.py new file mode 100644 index 000000000..e14cc46d0 --- /dev/null +++ b/src/op_mode/ntp.py @@ -0,0 +1,164 @@ +#!/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 csv +import sys +from itertools import chain + +import vyos.opmode +from vyos.configquery import ConfigTreeQuery +from vyos.utils.process import cmd + +def _get_raw_data(command: str) -> dict: +    # Returns returns chronyc output as a dictionary + +    # Initialize dictionary keys to align with output of +    # chrony -c. From some commands, its -c switch outputs +    # more parameters, make sure to include them all below. +    # See to chronyc(1) for definition of key variables +    match command: +        case "chronyc -c activity": +            keys: list = [ +            'sources_online', +            'sources_offline', +            'sources_doing_burst_return_online', +            'sources_doing_burst_return_offline', +            'sources_with_unknown_address' +            ] + +        case "chronyc -c sources": +            keys: list = [ +            'm', +            's', +            'name_ip_address', +            'stratum', +            'poll', +            'reach', +            'last_rx', +            'last_sample_adj_offset', +            'last_sample_mes_offset', +            'last_sample_est_error' +            ] + +        case "chronyc -c sourcestats": +            keys: list = [ +            'name_ip_address', +            'np', +            'nr', +            'span', +            'frequency', +            'freq_skew', +            'offset', +            'std_dev' +            ] + +        case "chronyc -c tracking": +            keys: list = [ +            'ref_id', +            'ref_id_name', +            'stratum', +            'ref_time', +            'system_time', +            'last_offset', +            'rms_offset', +            'frequency', +            'residual_freq', +            'skew', +            'root_delay', +            'root_dispersion', +            'update_interval', +            'leap_status' +            ] + +        case _: +            raise ValueError(f"Raw mode: of {command} is not implemented") + +    # Get -c option command line output, splitlines, +    # and save comma-separated values as a flat list +    output = cmd(command).splitlines() +    values = csv.reader(output) +    values = list(chain.from_iterable(values)) + +    # Divide values into chunks of size keys and transpose +    if len(values) > len(keys): +       values = _chunk_list(values,keys) +       values = zip(*values) + +    return dict(zip(keys, values)) + +def _chunk_list(in_list, n): +    # Yields successive n-sized chunks from in_list +    for i in range(0, len(in_list), len(n)): +        yield in_list[i:i + len(n)] + +def _is_configured(): +    # Check if ntp is configured +    config = ConfigTreeQuery() +    if not config.exists("service ntp"): +        raise vyos.opmode.UnconfiguredSubsystem("NTP service is not enabled.") + +def show_activity(raw: bool): +    _is_configured() +    command = f'chronyc' + +    if raw: +       command += f" -c activity" +       return _get_raw_data(command) +    else: +       command += f" activity" +       return cmd(command) + +def show_sources(raw: bool): +    _is_configured() +    command = f'chronyc' + +    if raw: +       command += f" -c sources" +       return _get_raw_data(command) +    else: +       command += f" sources -v" +       return cmd(command) + +def show_tracking(raw: bool): +    _is_configured() +    command = f'chronyc' + +    if raw: +       command += f" -c tracking" +       return _get_raw_data(command) +    else: +       command += f" tracking" +       return cmd(command) + +def show_sourcestats(raw: bool): +    _is_configured() +    command = f'chronyc' + +    if raw: +       command += f" -c sourcestats" +       return _get_raw_data(command) +    else: +       command += f" sourcestats -v" +       return cmd(command) + +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/version.py b/src/op_mode/version.py index ad0293aca..09d69ad1d 100755 --- a/src/op_mode/version.py +++ b/src/op_mode/version.py @@ -1,6 +1,6 @@  #!/usr/bin/env python3  # -# Copyright (C) 2016-2022 VyOS maintainers and contributors +# Copyright (C) 2016-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 @@ -30,11 +30,15 @@ from jinja2 import Template  version_output_tmpl = """  Version:          VyOS {{version}}  Release train:    {{release_train}} +Release flavor:   {{flavor}}  Built by:         {{built_by}}  Built on:         {{built_on}}  Build UUID:       {{build_uuid}}  Build commit ID:  {{build_git}} +{%- if build_comment %} +Build comment:    {{build_comment}} +{% endif %}  Architecture:     {{system_arch}}  Boot via:         {{boot_via}} diff --git a/src/systemd/miniupnpd.service b/src/systemd/miniupnpd.service deleted file mode 100644 index 51cb2eed8..000000000 --- a/src/systemd/miniupnpd.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=UPnP service -ConditionPathExists=/run/upnp/miniupnp.conf -After=vyos-router.service -StartLimitIntervalSec=0 - -[Service] -WorkingDirectory=/run/upnp -Type=simple -ExecStart=/usr/sbin/miniupnpd -d -f /run/upnp/miniupnp.conf -PrivateTmp=yes -PIDFile=/run/miniupnpd.pid -Restart=on-failure  | 
