diff options
37 files changed, 827 insertions, 138 deletions
| diff --git a/data/templates/dhcp-client/ipv4.j2 b/data/templates/dhcp-client/ipv4.j2 index cc5ddf09c..77905e054 100644 --- a/data/templates/dhcp-client/ipv4.j2 +++ b/data/templates/dhcp-client/ipv4.j2 @@ -9,14 +9,30 @@ interface "{{ ifname }}" {      send host-name "{{ dhcp_options.host_name }}";  {% if dhcp_options.client_id is vyos_defined %}  {%     set client_id = dhcp_options.client_id %} -{#   Use HEX representation of client-id as it is send in MAC-address style using hex characters. If not HEX, use double quotes ASCII format #} -{%     if not dhcp_options.client_id.split(':') | length >= 5 %} -{%         set client_id = '"' + dhcp_options.client_id + '"' %} +{#   Use HEX representation of client-id as it is send in MAC-address style using hex characters. #} +{#   If not HEX, use double quotes ASCII format #} +{%     if not client_id.split(':') | length >= 3 %} +{%         set client_id = '"' ~ dhcp_options.client_id ~ '"' %}  {%     endif %}      send dhcp-client-identifier {{ client_id }};  {% endif %}  {% if dhcp_options.vendor_class_id is vyos_defined %} -    send vendor-class-identifier "{{ dhcp_options.vendor_class_id }}"; +{%     set vendor_class_id = dhcp_options.vendor_class_id %} +{#   Use HEX representation of client-id as it is send in MAC-address style using hex characters. #} +{#   If not HEX, use double quotes ASCII format #} +{%     if not vendor_class_id.split(':') | length >= 3 %} +{%         set vendor_class_id = '"' ~ dhcp_options.vendor_class_id ~ '"' %} +{%     endif %} +    send vendor-class-identifier {{ vendor_class_id }}; +{% endif %} +{% if dhcp_options.user_class is vyos_defined %} +{%     set user_class = dhcp_options.user_class %} +{#   Use HEX representation of client-id as it is send in MAC-address style using hex characters. #} +{#   If not HEX, use double quotes ASCII format #} +{%     if not user_class.split(':') | length >= 3 %} +{%         set user_class = '"' ~ dhcp_options.user_class ~ '"' %} +{%     endif %} +    send user-class {{ user_class }};  {% endif %}      # The request statement causes the client to request that any server responding to the      # client send the client its values for the specified options. diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2 index b541ff309..dde839e9f 100644 --- a/data/templates/https/nginx.default.j2 +++ b/data/templates/https/nginx.default.j2 @@ -36,13 +36,9 @@ server {          ssl_protocols TLSv1.2 TLSv1.3;          # proxy settings for HTTP API, if enabled; 503, if not -        location ~ ^/(retrieve|configure|config-file|image|container-image|generate|show|reset|docs|openapi.json|redoc|graphql) { +        location ~ ^/(retrieve|configure|config-file|image|container-image|generate|show|reboot|reset|poweroff|docs|openapi.json|redoc|graphql) {  {%     if server.api %} -{%         if server.api.socket %}                  proxy_pass http://unix:/run/api.sock; -{%         else %} -                proxy_pass http://localhost:{{ server.api.port }}; -{%         endif %}                  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;                  proxy_set_header X-Forwarded-Proto $scheme;                  proxy_read_timeout 600; diff --git a/debian/control b/debian/control index 98c064417..b2b0c6ed0 100644 --- a/debian/control +++ b/debian/control @@ -230,6 +230,9 @@ Depends:    lcdproc,    lcdproc-extra-drivers,  # End "system lcd" +# For "system config-management commit-archive" +  git, +# End "system config-management commit-archive"  # For firewall    libndp-tools,    libnetfilter-conntrack3, diff --git a/interface-definitions/https.xml.in b/interface-definitions/https.xml.in index 5430193b5..05c552e6b 100644 --- a/interface-definitions/https.xml.in +++ b/interface-definitions/https.xml.in @@ -41,17 +41,9 @@                    </constraint>                  </properties>                </leafNode> -              <leafNode name='listen-port'> -                <properties> -                  <help>Port to listen for HTTPS requests; default 443</help> -                  <valueHelp> -                    <format>u32:1-65535</format> -                    <description>Numeric IP port</description> -                  </valueHelp> -                  <constraint> -                    <validator name="numeric" argument="--range 1-65535"/> -                  </constraint> -                </properties> +              #include <include/port-number.xml.i> +              <leafNode name='port'> +                <defaultValue>443</defaultValue>                </leafNode>                <leafNode name="server-name">                  <properties> @@ -68,7 +60,6 @@                <priority>1002</priority>              </properties>              <children> -              #include <include/port-number.xml.i>                <node name="keys">                  <properties>                    <help>HTTP API keys</help> @@ -101,12 +92,6 @@                    <hidden/>                  </properties>                </leafNode> -              <leafNode name="socket"> -                <properties> -                  <help>Run server on Unix domain socket</help> -                  <valueless/> -                </properties> -              </leafNode>                <node name="graphql">                  <properties>                    <help>GraphQL support</help> diff --git a/interface-definitions/include/constraint/dhcp-client-string-option.xml.i b/interface-definitions/include/constraint/dhcp-client-string-option.xml.i new file mode 100644 index 000000000..76e0e5466 --- /dev/null +++ b/interface-definitions/include/constraint/dhcp-client-string-option.xml.i @@ -0,0 +1,4 @@ +<!-- include start from include/constraint/dhcp-client-string-option.xml.i --> +<regex>[-_a-zA-Z0-9\s]+</regex> +<regex>([a-fA-F0-9][a-fA-F0-9]:){2,}[a-fA-F0-9][a-fA-F0-9]</regex> +<!-- include end --> diff --git a/interface-definitions/include/interface/dhcp-options.xml.i b/interface-definitions/include/interface/dhcp-options.xml.i index 8027769ff..733512a98 100644 --- a/interface-definitions/include/interface/dhcp-options.xml.i +++ b/interface-definitions/include/interface/dhcp-options.xml.i @@ -7,6 +7,13 @@      <leafNode name="client-id">        <properties>          <help>Identifier used by client to identify itself to the DHCP server</help> +        <valueHelp> +          <format>txt</format> +          <description>DHCP option string</description> +        </valueHelp> +        <constraint> +          #include <include/constraint/dhcp-client-string-option.xml.i> +        </constraint>        </properties>      </leafNode>      <leafNode name="host-name"> @@ -27,6 +34,25 @@      <leafNode name="vendor-class-id">        <properties>          <help>Identify the vendor client type to the DHCP server</help> +        <valueHelp> +          <format>txt</format> +          <description>DHCP option string</description> +        </valueHelp> +        <constraint> +          #include <include/constraint/dhcp-client-string-option.xml.i> +        </constraint> +      </properties> +    </leafNode> +    <leafNode name="user-class"> +      <properties> +        <help>Identify to the DHCP server, user configurable option</help> +        <valueHelp> +          <format>txt</format> +          <description>DHCP option string</description> +        </valueHelp> +        <constraint> +          #include <include/constraint/dhcp-client-string-option.xml.i> +        </constraint>        </properties>      </leafNode>      #include <include/interface/no-default-route.xml.i> diff --git a/interface-definitions/include/version/https-version.xml.i b/interface-definitions/include/version/https-version.xml.i index 111076974..fa18278f3 100644 --- a/interface-definitions/include/version/https-version.xml.i +++ b/interface-definitions/include/version/https-version.xml.i @@ -1,3 +1,3 @@  <!-- include start from include/version/https-version.xml.i --> -<syntaxVersion component='https' version='4'></syntaxVersion> +<syntaxVersion component='https' version='5'></syntaxVersion>  <!-- include end --> diff --git a/interface-definitions/interfaces-pppoe.xml.in b/interface-definitions/interfaces-pppoe.xml.in index 30fcb8573..4542b8b01 100644 --- a/interface-definitions/interfaces-pppoe.xml.in +++ b/interface-definitions/interfaces-pppoe.xml.in @@ -111,7 +111,7 @@            </leafNode>            <leafNode name="mru">              <properties> -              <help>Maximum Receive Unit (MRU)</help> +              <help>Maximum Receive Unit (MRU) (default: MTU value)</help>                <valueHelp>                  <format>u32:128-16384</format>                  <description>Maximum Receive Unit in byte</description> @@ -121,7 +121,6 @@                </constraint>                <constraintErrorMessage>MRU must be between 128 and 16384</constraintErrorMessage>              </properties> -            <defaultValue>1492</defaultValue>            </leafNode>            #include <include/interface/no-peer-dns.xml.i>            <leafNode name="remote-address"> diff --git a/interface-definitions/interfaces-vxlan.xml.in b/interface-definitions/interfaces-vxlan.xml.in index f20743a65..4461923d9 100644 --- a/interface-definitions/interfaces-vxlan.xml.in +++ b/interface-definitions/interfaces-vxlan.xml.in @@ -48,9 +48,6 @@            #include <include/interface/mac.xml.i>            #include <include/interface/mtu-1200-16000.xml.i>            #include <include/interface/mirror.xml.i> -          <leafNode name="mtu"> -            <defaultValue>1450</defaultValue> -          </leafNode>            <node name="parameters">              <properties>                <help>VXLAN tunnel parameters</help> @@ -95,6 +92,12 @@                    <valueless/>                  </properties>                </leafNode> +              <leafNode name="vni-filter"> +                <properties> +                  <help>Enable VNI filter support</help> +                  <valueless/> +                </properties> +              </leafNode>              </children>            </node>            #include <include/port-number.xml.i> diff --git a/interface-definitions/system-config-mgmt.xml.in b/interface-definitions/system-config-mgmt.xml.in index de5a8cc16..61089ce34 100644 --- a/interface-definitions/system-config-mgmt.xml.in +++ b/interface-definitions/system-config-mgmt.xml.in @@ -17,11 +17,36 @@                  <properties>                    <help>Commit archive location</help>                    <valueHelp> -                    <format>uri</format> -                    <description>Uniform Resource Identifier</description> +                    <format>http://<user>:<passwd>@<host>/<path></format> +                    <description/> +                  </valueHelp> +                  <valueHelp> +                    <format>https://<user>:<passwd>@<host>/<path></format> +                    <description/> +                  </valueHelp> +                  <valueHelp> +                    <format>ftp://<user>:<passwd>@<host>/<path></format> +                    <description/> +                  </valueHelp> +                  <valueHelp> +                    <format>sftp://<user>:<passwd>@<host>/<path></format> +                    <description/> +                  </valueHelp> +                  <valueHelp> +                    <format>scp://<user>:<passwd>@<host>/<path></format> +                    <description/> +                  </valueHelp> +                  <valueHelp> +                    <format>tftp://<host>/<path></format> +                    <description/> +                  </valueHelp> +                  <valueHelp> +                    <format>git+https://<user>:<passwd>@<host>/<path></format> +                    <description/>                    </valueHelp>                    <constraint>                      <validator name="url --file-transport"/> +                    <regex>(ssh|git|git\+(\w+)):\/\/.*</regex>                    </constraint>                    <multi/>                  </properties> diff --git a/op-mode-definitions/show-bridge.xml.in b/op-mode-definitions/show-bridge.xml.in index fad3f3418..5d8cc3847 100644 --- a/op-mode-definitions/show-bridge.xml.in +++ b/op-mode-definitions/show-bridge.xml.in @@ -21,6 +21,12 @@                </leafNode>              </children>            </node> +          <leafNode name="vni"> +            <properties> +              <help>Virtual Network Identifier</help> +            </properties> +            <command>${vyos_op_scripts_dir}/bridge.py show_vni</command> +          </leafNode>          </children>        </node>        <leafNode name="bridge"> diff --git a/op-mode-definitions/show-interfaces.xml.in b/op-mode-definitions/show-interfaces.xml.in index b58e0efea..09466647d 100644 --- a/op-mode-definitions/show-interfaces.xml.in +++ b/op-mode-definitions/show-interfaces.xml.in @@ -6,7 +6,7 @@          <properties>            <help>Show network interface information</help>          </properties> -        <command>${vyos_op_scripts_dir}/interfaces.py show_summary</command> +        <command>${vyos_op_scripts_dir}/interfaces.py show_summary_extended</command>          <children>            <leafNode name="counters">              <properties> @@ -24,7 +24,7 @@              <properties>                <help>Show summary information of all interfaces</help>              </properties> -            <command>${vyos_op_scripts_dir}/interfaces.py show_summary_extended</command> +            <command>${vyos_op_scripts_dir}/interfaces.py show_summary</command>            </leafNode>          </children>        </node> diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py index 654a8d698..df7240c88 100644 --- a/python/vyos/config_mgmt.py +++ b/python/vyos/config_mgmt.py @@ -22,10 +22,11 @@ import logging  from typing import Optional, Tuple, Union  from filecmp import cmp  from datetime import datetime -from textwrap import dedent +from textwrap import dedent, indent  from pathlib import Path  from tabulate import tabulate  from shutil import copy, chown +from urllib.parse import urlsplit, urlunsplit  from vyos.config import Config  from vyos.configtree import ConfigTree, ConfigTreeError, show_diff @@ -377,9 +378,22 @@ Proceed ?'''          remote_file = f'config.boot-{hostname}.{timestamp}'          source_address = self.source_address +        if self.effective_locations: +            print("Archiving config...")          for location in self.effective_locations: -            upload(archive_config_file, f'{location}/{remote_file}', -                   source_host=source_address) +            url = urlsplit(location) +            _, _, netloc = url.netloc.rpartition("@") +            redacted_location = urlunsplit(url._replace(netloc=netloc)) +            print(f"  {redacted_location}", end=" ", flush=True) +            try: +                upload(archive_config_file, f'{location}/{remote_file}', +                       source_host=source_address, raise_error=True) +                print("OK") +            except Exception as e: +                print("FAILED!") +                print() +                print(indent(str(e), "   > ")) +                print()      # op-mode functions      # diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 6d4b2af59..9802ebae4 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -35,6 +35,8 @@ REMOVE_IMAGE = ['/opt/vyatta/bin/vyatta-boot-image.pl', '--del']  GENERATE = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'generate']  SHOW = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'show']  RESET = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reset'] +REBOOT = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reboot'] +POWEROFF = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'poweroff']  OP_CMD_ADD = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'add']  OP_CMD_DELETE = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'delete'] @@ -220,10 +222,18 @@ class ConfigSession(object):          out = self.__run_command(SHOW + path)          return out +    def reboot(self, path): +        out = self.__run_command(REBOOT + path) +        return out +      def reset(self, path):          out = self.__run_command(RESET + path)          return out +    def poweroff(self, path): +        out = self.__run_command(POWEROFF + path) +        return out +      def add_container_image(self, name):          out = self.__run_command(OP_CMD_ADD + ['container', 'image'] + [name])          return out diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index a229533bd..b7f39ecb0 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -51,9 +51,6 @@ https_data = {  }  api_data = { -    'listen_address' : '127.0.0.1', -    'port' : '8080', -    'socket' : False,      'strict' : False,      'debug' : False,      'api_keys' : [ {'id' : 'testapp', 'key' : 'qwerty'} ] diff --git a/python/vyos/ifconfig/macsec.py b/python/vyos/ifconfig/macsec.py index 9329c5ee7..bde1d9aec 100644 --- a/python/vyos/ifconfig/macsec.py +++ b/python/vyos/ifconfig/macsec.py @@ -45,6 +45,10 @@ class MACsecIf(Interface):          # create tunnel interface          cmd  = 'ip link add link {source_interface} {ifname} type {type}'.format(**self.config)          cmd += f' cipher {self.config["security"]["cipher"]}' + +        if 'encrypt' in self.config["security"]: +            cmd += ' encrypt on' +          self._cmd(cmd)          # Check if using static keys diff --git a/python/vyos/ifconfig/vxlan.py b/python/vyos/ifconfig/vxlan.py index 8c5a0220e..23b6daa3a 100644 --- a/python/vyos/ifconfig/vxlan.py +++ b/python/vyos/ifconfig/vxlan.py @@ -22,6 +22,7 @@ from vyos.utils.assertion import assert_list  from vyos.utils.dict import dict_search  from vyos.utils.network import get_interface_config  from vyos.utils.network import get_vxlan_vlan_tunnels +from vyos.utils.network import get_vxlan_vni_filter  @Interface.register  class VXLANIf(Interface): @@ -79,6 +80,7 @@ class VXLANIf(Interface):              'parameters.ip.ttl'          : 'ttl',              'parameters.ipv6.flowlabel'  : 'flowlabel',              'parameters.nolearning'      : 'nolearning', +            'parameters.vni_filter'      : 'vnifilter',              'remote'                     : 'remote',              'source_address'             : 'local',              'source_interface'           : 'dev', @@ -138,10 +140,14 @@ class VXLANIf(Interface):          if not isinstance(state, bool):              raise ValueError('Value out of range') -        cur_vlan_ids = []          if 'vlan_to_vni_removed' in self.config: -            cur_vlan_ids = self.config['vlan_to_vni_removed'] -            for vlan in cur_vlan_ids: +            cur_vni_filter = get_vxlan_vni_filter(self.ifname) +            for vlan, vlan_config in self.config['vlan_to_vni_removed'].items(): +                # If VNI filtering is enabled, remove matching VNI filter +                if dict_search('parameters.vni_filter', self.config) != None: +                    vni = vlan_config['vni'] +                    if vni in cur_vni_filter: +                        self._cmd(f'bridge vni delete dev {self.ifname} vni {vni}')                  self._cmd(f'bridge vlan del dev {self.ifname} vid {vlan}')          # Determine current OS Kernel vlan_tunnel setting - only adjust when needed @@ -151,10 +157,9 @@ class VXLANIf(Interface):          if cur_state != new_state:              self.set_interface('vlan_tunnel', new_state) -        # Determine current OS Kernel configured VLANs -        os_configured_vlan_ids = get_vxlan_vlan_tunnels(self.ifname) -          if 'vlan_to_vni' in self.config: +            # Determine current OS Kernel configured VLANs +            os_configured_vlan_ids = get_vxlan_vlan_tunnels(self.ifname)              add_vlan = list_diff(list(self.config['vlan_to_vni'].keys()), os_configured_vlan_ids)              for vlan, vlan_config in self.config['vlan_to_vni'].items(): @@ -168,6 +173,10 @@ class VXLANIf(Interface):                  self._cmd(f'bridge vlan add dev {self.ifname} vid {vlan}')                  self._cmd(f'bridge vlan add dev {self.ifname} vid {vlan} tunnel_info id {vni}') +                # If VNI filtering is enabled, install matching VNI filter +                if dict_search('parameters.vni_filter', self.config) != None: +                    self._cmd(f'bridge vni add dev {self.ifname} vni {vni}') +      def update(self, config):          """ General helper function which works on a dictionary retrived by          get_config_dict(). It's main intention is to consolidate the scattered diff --git a/python/vyos/remote.py b/python/vyos/remote.py index 4be477d24..8b90e4530 100644 --- a/python/vyos/remote.py +++ b/python/vyos/remote.py @@ -14,6 +14,7 @@  # License along with this library.  If not, see <http://www.gnu.org/licenses/>.  import os +import pwd  import shutil  import socket  import ssl @@ -22,6 +23,9 @@ import sys  import tempfile  import urllib.parse +from contextlib import contextmanager +from pathlib import Path +  from ftplib import FTP  from ftplib import FTP_TLS @@ -37,11 +41,22 @@ from vyos.utils.io import ask_yes_no  from vyos.utils.io import is_interactive  from vyos.utils.io import print_error  from vyos.utils.misc import begin -from vyos.utils.process import cmd +from vyos.utils.process import cmd, rc_cmd  from vyos.version import get_version  CHUNK_SIZE = 8192 +@contextmanager +def umask(mask: int): +    """ +    Context manager that temporarily sets the process umask. +    """ +    oldmask = os.umask(mask) +    try: +        yield +    finally: +        os.umask(oldmask) +  class InteractivePolicy(MissingHostKeyPolicy):      """      Paramiko policy for interactively querying the user on whether to proceed @@ -310,35 +325,137 @@ class TftpC:          with open(location, 'rb') as f:              cmd(f'{self.command} -T - "{self.urlstring}"', input=f.read()) +class GitC: +    def __init__(self, +        url, +        progressbar=False, +        check_space=False, +        source_host=None, +        source_port=0, +        timeout=10, +    ): +        self.command = 'git' +        self.url = url +        self.urlstring = urllib.parse.urlunsplit(url) +        if self.urlstring.startswith("git+"): +            self.urlstring = self.urlstring.replace("git+", "", 1) + +    def download(self, location: str): +        raise NotImplementedError("not supported") + +    @umask(0o077) +    def upload(self, location: str): +        scheme = self.url.scheme +        _, _, scheme = scheme.partition("+") +        netloc = self.url.netloc +        url = Path(self.url.path).parent +        with tempfile.TemporaryDirectory(prefix="git-commit-archive-") as directory: +            # Determine username, fullname, email for Git commit +            pwd_entry = pwd.getpwuid(os.getuid()) +            user = pwd_entry.pw_name +            name = pwd_entry.pw_gecos.split(",")[0] or user +            fqdn = socket.getfqdn() +            email = f"{user}@{fqdn}" + +            # environment vars for our git commands +            env = { +                "GIT_TERMINAL_PROMPT": "0", +                "GIT_AUTHOR_NAME": name, +                "GIT_AUTHOR_EMAIL": email, +                "GIT_COMMITTER_NAME": name, +                "GIT_COMMITTER_EMAIL": email, +            } + +            # build ssh command for git +            ssh_command = ["ssh"] + +            # if we are not interactive, we use StrictHostKeyChecking=yes to avoid any prompts +            if not sys.stdout.isatty(): +                ssh_command += ["-o", "StrictHostKeyChecking=yes"] + +            env["GIT_SSH_COMMAND"] = " ".join(ssh_command) + +            # git clone +            path_repository = Path(directory) / "repository" +            scheme = f"{scheme}://" if scheme else "" +            rc, out = rc_cmd( +                [self.command, "clone", f"{scheme}{netloc}{url}", str(path_repository), "--depth=1"], +                env=env, +                shell=False, +            ) +            if rc: +                raise Exception(out) + +            # git add +            filename = Path(Path(self.url.path).name).stem +            dst = path_repository / filename +            shutil.copy2(location, dst) +            rc, out = rc_cmd( +                [self.command, "-C", str(path_repository), "add", filename], +                env=env, +                shell=False, +            ) + +            # git commit -m +            commit_message = os.environ.get("COMMIT_COMMENT", "commit") +            rc, out = rc_cmd( +                [self.command, "-C", str(path_repository), "commit", "-m", commit_message], +                env=env, +                shell=False, +            ) + +            # git push +            rc, out = rc_cmd( +                [self.command, "-C", str(path_repository), "push"], +                env=env, +                shell=False, +            ) +            if rc: +                raise Exception(out) +  def urlc(urlstring, *args, **kwargs):      """      Dynamically dispatch the appropriate protocol class.      """ -    url_classes = {'http': HttpC, 'https': HttpC, 'ftp': FtpC, 'ftps': FtpC, \ -                   'sftp': SshC, 'ssh': SshC, 'scp': SshC, 'tftp': TftpC} +    url_classes = { +        "http": HttpC, +        "https": HttpC, +        "ftp": FtpC, +        "ftps": FtpC, +        "sftp": SshC, +        "ssh": SshC, +        "scp": SshC, +        "tftp": TftpC, +        "git": GitC, +    }      url = urllib.parse.urlsplit(urlstring) +    scheme, _, _ = url.scheme.partition("+")      try: -        return url_classes[url.scheme](url, *args, **kwargs) +        return url_classes[scheme](url, *args, **kwargs)      except KeyError: -        raise ValueError(f'Unsupported URL scheme: "{url.scheme}"') +        raise ValueError(f'Unsupported URL scheme: "{scheme}"') -def download(local_path, urlstring, progressbar=False, check_space=False, +def download(local_path, urlstring, progressbar=False, raise_error=False, check_space=False,               source_host='', source_port=0, timeout=10.0):      try:          progressbar = progressbar and is_interactive()          urlc(urlstring, progressbar, check_space, source_host, source_port, timeout).download(local_path)      except Exception as err: +        if raise_error: +            raise          print_error(f'Unable to download "{urlstring}": {err}')      except KeyboardInterrupt:          print_error('\nDownload aborted by user.') -def upload(local_path, urlstring, progressbar=False, +def upload(local_path, urlstring, progressbar=False, raise_error=False,             source_host='', source_port=0, timeout=10.0):      try:          progressbar = progressbar and is_interactive()          urlc(urlstring, progressbar, source_host, source_port, timeout).upload(local_path)      except Exception as err: +        if raise_error: +            raise          print_error(f'Unable to upload "{urlstring}": {err}')      except KeyboardInterrupt:          print_error('\nUpload aborted by user.') diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 5d19f256b..6a5de5423 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -483,3 +483,36 @@ def get_vxlan_vlan_tunnels(interface: str) -> list:              os_configured_vlan_ids.append(str(vlanStart))      return os_configured_vlan_ids + +def get_vxlan_vni_filter(interface: str) -> list: +    """ Return a list of strings with VNIs configured in the Kernel""" +    from json import loads +    from vyos.utils.process import cmd + +    if not interface.startswith('vxlan'): +        raise ValueError('Only applicable for VXLAN interfaces!') + +    # Determine current OS Kernel configured VNI filters in VXLAN interface +    # +    # $ bridge -j vni show dev vxlan1 +    # [{"ifname":"vxlan1","vnis":[{"vni":100},{"vni":200},{"vni":300,"vniEnd":399}]}] +    # +    # Example output: ['10010', '10020', '10021', '10022'] +    os_configured_vnis = [] +    tmp = loads(cmd(f'bridge --json vni show dev {interface}')) +    if tmp: +        for tunnel in tmp[0].get('vnis', {}): +            vniStart = tunnel['vni'] +            if 'vniEnd' in tunnel: +                vniEnd = tunnel['vniEnd'] +                # Build a real list for user VNIs +                vni_list = list(range(vniStart, vniEnd +1)) +                # Convert list of integers to list or strings +                os_configured_vnis.extend(map(str, vni_list)) +                # Proceed with next tunnel - this one is complete +                continue + +            # Add single tunel id - not part of a range +            os_configured_vnis.append(str(vniStart)) + +    return os_configured_vnis diff --git a/scripts/build-command-templates b/scripts/build-command-templates index c8ae83d9d..2e7f8b994 100755 --- a/scripts/build-command-templates +++ b/scripts/build-command-templates @@ -145,6 +145,8 @@ def get_properties(p, default=None):              description = v.find("description").text              if default != None and default.text == format:                  description += f' (default)' +            # Is no description was specified, keep it empty +            if not description: description = ''              vh.append( (format, description) )          props["val_help"] = vh      except: diff --git a/smoketest/config-tests/basic-api-service b/smoketest/config-tests/basic-api-service new file mode 100644 index 000000000..1d2dc3472 --- /dev/null +++ b/smoketest/config-tests/basic-api-service @@ -0,0 +1,20 @@ +set interfaces ethernet eth0 address '192.0.2.1/31' +set interfaces ethernet eth0 address '2001:db8::1234/64' +set interfaces loopback lo +set service ntp server time1.vyos.net +set service ntp server time2.vyos.net +set service ntp server time3.vyos.net +set service https api keys id 1 key 'S3cur3' +set service https virtual-host bar allow-client address '172.16.0.0/12' +set service https virtual-host bar port '5555' +set service https virtual-host foo allow-client address '10.0.0.0/8' +set service https virtual-host foo allow-client address '2001:db8::/32' +set service https virtual-host foo port '7777' +set service https virtual-host baz allow-client address '192.168.0.0/16' +set service https virtual-host baz port '6666' +set service https virtual-host baz server-name 'baz' +set system config-management commit-revisions '100' +set system host-name 'vyos' +set system login user vyos authentication encrypted-password '$6$2Ta6TWHd/U$NmrX0x9kexCimeOcYK1MfhMpITF9ELxHcaBU/znBq.X2ukQOj61fVI2UYP/xBzP4QtiTcdkgs7WOQMHWsRymO/' +set system login user vyos authentication plaintext-password '' +set system console device ttyS0 speed '115200' diff --git a/smoketest/configs/basic-api-service b/smoketest/configs/basic-api-service new file mode 100644 index 000000000..f5b56ac98 --- /dev/null +++ b/smoketest/configs/basic-api-service @@ -0,0 +1,86 @@ +interfaces { +    ethernet eth0 { +        address 192.0.2.1/31 +        address 2001:db8::1234/64 +    } +    ethernet eth1 { +    } +    loopback lo { +    } +} +service { +    https { +        api { +            keys { +                id 1 { +                    key S3cur3 +                } +            } +            socket +        } +        virtual-host bar { +            allow-client { +                address 172.16.0.0/12 +            } +            listen-port 5555 +            server-name bar +        } +        virtual-host baz { +            allow-client { +                address 192.168.0.0/16 +            } +            listen-port 6666 +            server-name baz +        } +        virtual-host foo { +            allow-client { +                address 10.0.0.0/8 +                address 2001:db8::/32 +            } +            listen-port 7777 +            server-name foo +        } +    } +} +system { +    config-management { +        commit-revisions 100 +    } +    console { +        device ttyS0 { +            speed 115200 +        } +    } +    host-name vyos +    login { +        user vyos { +            authentication { +                encrypted-password $6$2Ta6TWHd/U$NmrX0x9kexCimeOcYK1MfhMpITF9ELxHcaBU/znBq.X2ukQOj61fVI2UYP/xBzP4QtiTcdkgs7WOQMHWsRymO/ +                plaintext-password "" +            } +        } +    } +    ntp { +        server time1.vyos.net { +        } +        server time2.vyos.net { +        } +        server time3.vyos.net { +        } +    } +    syslog { +        global { +            facility all { +                level info +            } +            facility protocols { +                level debug +            } +        } +    } +} + + +// Warning: Do not remove the following line. +// vyos-config-version: "broadcast-relay@1:cluster@1:config-management@1:conntrack@1:conntrack-sync@1:dhcp-relay@2:dhcp-server@5:dhcpv6-server@1:dns-forwarding@3:firewall@5:https@2:interfaces@13:ipoe-server@1:ipsec@5:l2tp@3:lldp@1:mdns@1:nat@5:ntp@1:pppoe-server@5:pptp@2:qos@1:quagga@6:salt@1:snmp@2:ssh@2:sstp@3:system@19:vrrp@2:vyos-accel-ppp@2:wanloadbalance@3:webgui@1:webproxy@2:zone-policy@1" +// Release version: 1.3-rolling-202010241631 diff --git a/smoketest/scripts/cli/base_interfaces_test.py b/smoketest/scripts/cli/base_interfaces_test.py index 73b4e9764..3f42196f7 100644 --- a/smoketest/scripts/cli/base_interfaces_test.py +++ b/smoketest/scripts/cli/base_interfaces_test.py @@ -158,14 +158,22 @@ class BasicInterfaceTest:              if not self._test_dhcp or not self._test_vrf:                  self.skipTest('not supported') +            client_id = 'VyOS-router'              distance = '100' +            hostname = 'vyos' +            vendor_class_id = 'vyos-vendor' +            user_class = 'vyos'              for interface in self._interfaces:                  for option in self._options.get(interface, []):                      self.cli_set(self._base_path + [interface] + option.split())                  self.cli_set(self._base_path + [interface, 'address', 'dhcp']) +                self.cli_set(self._base_path + [interface, 'dhcp-options', 'client-id', client_id])                  self.cli_set(self._base_path + [interface, 'dhcp-options', 'default-route-distance', distance]) +                self.cli_set(self._base_path + [interface, 'dhcp-options', 'host-name', hostname]) +                self.cli_set(self._base_path + [interface, 'dhcp-options', 'vendor-class-id', vendor_class_id]) +                self.cli_set(self._base_path + [interface, 'dhcp-options', 'user-class', user_class])              self.cli_commit() @@ -175,8 +183,12 @@ class BasicInterfaceTest:                  self.assertTrue(dhclient_pid)                  dhclient_config = read_file(f'{dhclient_base_dir}/dhclient_{interface}.conf') -                self.assertIn('request subnet-mask, broadcast-address, routers, domain-name-servers', dhclient_config) -                self.assertIn('require subnet-mask;', dhclient_config) +                self.assertIn(f'request subnet-mask, broadcast-address, routers, domain-name-servers', dhclient_config) +                self.assertIn(f'require subnet-mask;', dhclient_config) +                self.assertIn(f'send host-name "{hostname}";', dhclient_config) +                self.assertIn(f'send dhcp-client-identifier "{client_id}";', dhclient_config) +                self.assertIn(f'send vendor-class-identifier "{vendor_class_id}";', dhclient_config) +                self.assertIn(f'send user-class "{user_class}";', dhclient_config)                  # and the commandline has the appropriate options                  cmdline = read_file(f'/proc/{dhclient_pid}/cmdline') @@ -400,10 +412,9 @@ class BasicInterfaceTest:              for intf in self._interfaces:                  base = self._base_path + [intf] -                self.cli_set(base + ['mtu', self._mtu]) -                  for option in self._options.get(intf, []):                      self.cli_set(base + option.split()) +                self.cli_set(base + ['mtu', self._mtu])              # check validate() - can not set low MTU if 'no-default-link-local'              # is not set on CLI diff --git a/smoketest/scripts/cli/test_interfaces_macsec.py b/smoketest/scripts/cli/test_interfaces_macsec.py index ea0f00071..d8d564792 100755 --- a/smoketest/scripts/cli/test_interfaces_macsec.py +++ b/smoketest/scripts/cli/test_interfaces_macsec.py @@ -14,7 +14,6 @@  # 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  import re  import unittest @@ -23,9 +22,10 @@ from netifaces import interfaces  from vyos.configsession import ConfigSessionError  from vyos.ifconfig import Section -from vyos.utils.process import cmd  from vyos.utils.file import read_file  from vyos.utils.network import get_interface_config +from vyos.utils.network import interface_exists +from vyos.utils.process import cmd  from vyos.utils.process import process_named_running  PROCESS_NAME = 'wpa_supplicant' @@ -35,10 +35,6 @@ def get_config_value(interface, key):      tmp = re.findall(r'\n?{}=(.*)'.format(key), tmp)      return tmp[0] -def get_cipher(interface): -    tmp = get_interface_config(interface) -    return tmp['linkinfo']['info_data']['cipher_suite'].lower() -  class MACsecInterfaceTest(BasicInterfaceTest.TestCase):      @classmethod      def setUpClass(cls): @@ -117,6 +113,10 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase):              tmp = read_file(f'/sys/class/net/{interface}/mtu')              self.assertEqual(tmp, '1460') +            # Encryption enabled? +            tmp = get_interface_config(interface) +            self.assertTrue(tmp['linkinfo']['info_data']['encrypt']) +          # Check for running process          self.assertTrue(process_named_running(PROCESS_NAME)) @@ -138,10 +138,11 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase):          # final commit and verify          self.cli_commit() -        self.assertIn(interface, interfaces()) +        self.assertTrue(interface_exists(interface))          # Verify proper cipher suite (T4537) -        self.assertEqual(cipher, get_cipher(interface)) +        tmp = get_interface_config(interface) +        self.assertEqual(cipher, tmp['linkinfo']['info_data']['cipher_suite'].lower())      def test_macsec_gcm_aes_256(self):          src_interface = 'eth0' @@ -161,10 +162,11 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase):          # final commit and verify          self.cli_commit() -        self.assertIn(interface, interfaces()) +        self.assertTrue(interface_exists(interface))          # Verify proper cipher suite (T4537) -        self.assertEqual(cipher, get_cipher(interface)) +        tmp = get_interface_config(interface) +        self.assertEqual(cipher, tmp['linkinfo']['info_data']['cipher_suite'].lower())      def test_macsec_source_interface(self):          # Ensure source-interface can bot be part of any other bond or bridge @@ -191,7 +193,7 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase):              # final commit and verify              self.cli_commit() -            self.assertIn(interface, interfaces()) +            self.assertTrue(interface_exists(interface))      def test_macsec_static_keys(self):          src_interface = 'eth0' @@ -205,7 +207,7 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase):          peer_mac = '00:11:22:33:44:55'          self.cli_set(self._base_path + [interface]) -         # Encrypt link +        # Encrypt link          self.cli_set(self._base_path + [interface, 'security', 'encrypt'])          # check validate() - source interface is mandatory @@ -261,9 +263,12 @@ class MACsecInterfaceTest(BasicInterfaceTest.TestCase):          # final commit and verify          self.cli_commit() -        self.assertIn(interface, interfaces()) -        self.assertEqual(cipher2, get_cipher(interface)) -        self.assertTrue(os.path.isdir(f'/sys/class/net/{interface}')) + +        self.assertTrue(interface_exists(interface)) +        tmp = get_interface_config(interface) +        self.assertEqual(cipher2, tmp['linkinfo']['info_data']['cipher_suite'].lower()) +        # Encryption enabled? +        self.assertTrue(tmp['linkinfo']['info_data']['encrypt'])  if __name__ == '__main__':      unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_interfaces_pppoe.py b/smoketest/scripts/cli/test_interfaces_pppoe.py index 7b702759f..e99d8b3d1 100755 --- a/smoketest/scripts/cli/test_interfaces_pppoe.py +++ b/smoketest/scripts/cli/test_interfaces_pppoe.py @@ -36,6 +36,9 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):      @classmethod      def setUpClass(cls):          super(PPPoEInterfaceTest, cls).setUpClass() +        # ensure we can also run this test on a live system - so lets clean +        # out the current configuration :) +        cls.cli_delete(cls, base_path)          cls._interfaces = ['pppoe10', 'pppoe20', 'pppoe30']          cls._source_interface = 'eth0' @@ -53,18 +56,16 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):          self.cli_delete(base_path)          self.cli_commit() -    def test_01_pppoe_client(self): +    def test_pppoe_client(self):          # Check if PPPoE dialer can be configured and runs          for interface in self._interfaces:              user = f'VyOS-user-{interface}'              passwd = f'VyOS-passwd-{interface}'              mtu = '1400' -            mru = '1300'              self.cli_set(base_path + [interface, 'authentication', 'username', user])              self.cli_set(base_path + [interface, 'authentication', 'password', passwd])              self.cli_set(base_path + [interface, 'mtu', mtu]) -            self.cli_set(base_path + [interface, 'mru', '9000'])              self.cli_set(base_path + [interface, 'no-peer-dns'])              # check validate() - a source-interface is required @@ -72,11 +73,6 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):                  self.cli_commit()              self.cli_set(base_path + [interface, 'source-interface', self._source_interface]) -            # check validate() - MRU needs to be less or equal then MTU -            with self.assertRaises(ConfigSessionError): -                self.cli_commit() -            self.cli_set(base_path + [interface, 'mru', mru]) -          # commit changes          self.cli_commit() @@ -87,8 +83,9 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):              tmp = get_config_value(interface, 'mtu')[1]              self.assertEqual(tmp, mtu) +            # MRU must default to MTU if not specified on CLI              tmp = get_config_value(interface, 'mru')[1] -            self.assertEqual(tmp, mru) +            self.assertEqual(tmp, mtu)              tmp = get_config_value(interface, 'user')[1].replace('"', '')              self.assertEqual(tmp, user)              tmp = get_config_value(interface, 'password')[1].replace('"', '') @@ -96,7 +93,7 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):              tmp = get_config_value(interface, 'ifname')[1]              self.assertEqual(tmp, interface) -    def test_02_pppoe_client_disabled_interface(self): +    def test_pppoe_client_disabled_interface(self):          # Check if PPPoE Client can be disabled          for interface in self._interfaces:              user = f'VyOS-user-{interface}' @@ -125,16 +122,16 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):          self.cli_commit() -    def test_03_pppoe_authentication(self): +    def test_pppoe_authentication(self):          # When username or password is set - so must be the other          for interface in self._interfaces:              user = f'VyOS-user-{interface}'              passwd = f'VyOS-passwd-{interface}' -            self.cli_set(base_path + [interface, 'authentication', 'username', user])              self.cli_set(base_path + [interface, 'source-interface', self._source_interface])              self.cli_set(base_path + [interface, 'ipv6', 'address', 'autoconf']) +            self.cli_set(base_path + [interface, 'authentication', 'username', user])              # check validate() - if user is set, so must be the password              with self.assertRaises(ConfigSessionError):                  self.cli_commit() @@ -143,7 +140,7 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):          self.cli_commit() -    def test_04_pppoe_dhcpv6pd(self): +    def test_pppoe_dhcpv6pd(self):          # Check if PPPoE dialer can be configured with DHCPv6-PD          address = '1'          sla_id = '0' @@ -183,7 +180,7 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):              tmp = get_config_value(interface, '+ipv6 ipv6cp-use-ipaddr')              self.assertListEqual(tmp, ['+ipv6', 'ipv6cp-use-ipaddr']) -    def test_05_pppoe_options(self): +    def test_pppoe_options(self):          # Check if PPPoE dialer can be configured with DHCPv6-PD          for interface in self._interfaces:              user = f'VyOS-user-{interface}' @@ -215,5 +212,47 @@ class PPPoEInterfaceTest(VyOSUnitTestSHIM.TestCase):              tmp = get_config_value(interface, 'pppoe-host-uniq')[1]              self.assertEqual(tmp, f'"{host_uniq}"') +    def test_pppoe_mtu_mru(self): +        # Check if PPPoE dialer can be configured and runs +        for interface in self._interfaces: +            user = f'VyOS-user-{interface}' +            passwd = f'VyOS-passwd-{interface}' +            mtu = '1400' +            mru = '1300' + +            self.cli_set(base_path + [interface, 'authentication', 'username', user]) +            self.cli_set(base_path + [interface, 'authentication', 'password', passwd]) +            self.cli_set(base_path + [interface, 'mtu', mtu]) +            self.cli_set(base_path + [interface, 'mru', '9000']) + +            # check validate() - a source-interface is required +            with self.assertRaises(ConfigSessionError): +                self.cli_commit() +            self.cli_set(base_path + [interface, 'source-interface', self._source_interface]) + +            # check validate() - MRU needs to be less or equal then MTU +            with self.assertRaises(ConfigSessionError): +                self.cli_commit() +            self.cli_set(base_path + [interface, 'mru', mru]) + +        # commit changes +        self.cli_commit() + +        # verify configuration file(s) +        for interface in self._interfaces: +            user = f'VyOS-user-{interface}' +            passwd = f'VyOS-passwd-{interface}' + +            tmp = get_config_value(interface, 'mtu')[1] +            self.assertEqual(tmp, mtu) +            tmp = get_config_value(interface, 'mru')[1] +            self.assertEqual(tmp, mru) +            tmp = get_config_value(interface, 'user')[1].replace('"', '') +            self.assertEqual(tmp, user) +            tmp = get_config_value(interface, 'password')[1].replace('"', '') +            self.assertEqual(tmp, passwd) +            tmp = get_config_value(interface, 'ifname')[1] +            self.assertEqual(tmp, interface) +  if __name__ == '__main__':      unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_interfaces_vxlan.py b/smoketest/scripts/cli/test_interfaces_vxlan.py index 17e4fc36f..18676491b 100755 --- a/smoketest/scripts/cli/test_interfaces_vxlan.py +++ b/smoketest/scripts/cli/test_interfaces_vxlan.py @@ -18,10 +18,12 @@ import unittest  from vyos.configsession import ConfigSessionError  from vyos.ifconfig import Interface +from vyos.ifconfig import Section  from vyos.utils.network import get_bridge_fdb  from vyos.utils.network import get_interface_config  from vyos.utils.network import interface_exists  from vyos.utils.network import get_vxlan_vlan_tunnels +from vyos.utils.network import get_vxlan_vni_filter  from vyos.template import is_ipv6  from base_interfaces_test import BasicInterfaceTest @@ -31,12 +33,13 @@ class VXLANInterfaceTest(BasicInterfaceTest.TestCase):          cls._base_path = ['interfaces', 'vxlan']          cls._options = {              'vxlan10': ['vni 10', 'remote 127.0.0.2'], -            'vxlan20': ['vni 20', 'group 239.1.1.1', 'source-interface eth0'], +            'vxlan20': ['vni 20', 'group 239.1.1.1', 'source-interface eth0', 'mtu 1450'],              'vxlan30': ['vni 30', 'remote 2001:db8:2000::1', 'source-address 2001:db8:1000::1', 'parameters ipv6 flowlabel 0x1000'],              'vxlan40': ['vni 40', 'remote 127.0.0.2', 'remote 127.0.0.3'],              'vxlan50': ['vni 50', 'remote 2001:db8:2000::1', 'remote 2001:db8:2000::2', 'parameters ipv6 flowlabel 0x1000'],          }          cls._interfaces = list(cls._options) +        cls._mtu = '1450'          # call base-classes classmethod          super(VXLANInterfaceTest, cls).setUpClass() @@ -138,7 +141,7 @@ class VXLANInterfaceTest(BasicInterfaceTest.TestCase):      def test_vxlan_vlan_vni_mapping(self):          bridge = 'br0'          interface = 'vxlan0' -        source_interface = 'eth0' +        source_address = '192.0.2.99'          vlan_to_vni = {              '10': '10010', @@ -151,7 +154,7 @@ class VXLANInterfaceTest(BasicInterfaceTest.TestCase):          }          self.cli_set(self._base_path + [interface, 'parameters', 'external']) -        self.cli_set(self._base_path + [interface, 'source-interface', source_interface]) +        self.cli_set(self._base_path + [interface, 'source-address', source_address])          for vlan, vni in vlan_to_vni.items():              self.cli_set(self._base_path + [interface, 'vlan-to-vni', vlan, 'vni', vni]) @@ -187,11 +190,12 @@ class VXLANInterfaceTest(BasicInterfaceTest.TestCase):      def test_vxlan_neighbor_suppress(self):          bridge = 'br555'          interface = 'vxlan555' -        source_interface = 'eth0' +        source_interface = 'dum0' + +        self.cli_set(['interfaces', Section.section(source_interface), source_interface, 'mtu', '9000'])          self.cli_set(self._base_path + [interface, 'parameters', 'external'])          self.cli_set(self._base_path + [interface, 'source-interface', source_interface]) -          self.cli_set(self._base_path + [interface, 'parameters', 'neighbor-suppress'])          # This must fail as this VXLAN interface is not associated with any bridge @@ -221,6 +225,95 @@ class VXLANInterfaceTest(BasicInterfaceTest.TestCase):          self.assertTrue(tmp['linkinfo']['info_slave_data']['learning'])          self.cli_delete(['interfaces', 'bridge', bridge]) +        self.cli_delete(['interfaces', Section.section(source_interface), source_interface]) + +    def test_vxlan_vni_filter(self): +        interfaces = ['vxlan987', 'vxlan986', 'vxlan985'] +        source_address = '192.0.2.77' + +        for interface in interfaces: +            self.cli_set(self._base_path + [interface, 'parameters', 'external']) +            self.cli_set(self._base_path + [interface, 'source-address', source_address]) + +        # This must fail as there can only be one "external" VXLAN device unless "vni-filter" is defined +        with self.assertRaises(ConfigSessionError): +            self.cli_commit() + +        # Enable "vni-filter" on the first VXLAN interface +        self.cli_set(self._base_path + [interfaces[0], 'parameters', 'vni-filter']) + +        # This must fail as if it's enabled on one VXLAN interface, it must be enabled on all +        # VXLAN interfaces +        with self.assertRaises(ConfigSessionError): +            self.cli_commit() +        for interface in interfaces: +            self.cli_set(self._base_path + [interface, 'parameters', 'vni-filter']) + +        # commit configuration +        self.cli_commit() + +        for interface in interfaces: +            self.assertTrue(interface_exists(interface)) + +            tmp = get_interface_config(interface) +            self.assertTrue(tmp['linkinfo']['info_data']['vnifilter']) + +    def test_vxlan_vni_filter_add_remove(self): +        interface = 'vxlan987' +        source_address = '192.0.2.66' +        bridge = 'br0' + +        self.cli_set(self._base_path + [interface, 'parameters', 'external']) +        self.cli_set(self._base_path + [interface, 'source-address', source_address]) +        self.cli_set(self._base_path + [interface, 'parameters', 'vni-filter']) + +        # commit configuration +        self.cli_commit() + +        # Check if VXLAN interface got created +        self.assertTrue(interface_exists(interface)) + +        # VNI filter configured? +        tmp = get_interface_config(interface) +        self.assertTrue(tmp['linkinfo']['info_data']['vnifilter']) + +        # Now create some VLAN mappings and VNI filter +        vlan_to_vni = { +            '50': '10050', +            '51': '10051', +            '52': '10052', +            '53': '10053', +            '54': '10054', +            '60': '10060', +            '69': '10069', +        } +        for vlan, vni in vlan_to_vni.items(): +            self.cli_set(self._base_path + [interface, 'vlan-to-vni', vlan, 'vni', vni]) +        # we need a bridge ... +        self.cli_set(['interfaces', 'bridge', bridge, 'member', 'interface', interface]) +        # commit configuration +        self.cli_commit() + +        # All VNIs configured? +        tmp = get_vxlan_vni_filter(interface) +        self.assertListEqual(list(vlan_to_vni.values()), tmp) + +        # +        # Delete a VLAN mappings and check if all VNIs are properly set up +        # +        vlan_to_vni.popitem() +        self.cli_delete(self._base_path + [interface, 'vlan-to-vni']) +        for vlan, vni in vlan_to_vni.items(): +            self.cli_set(self._base_path + [interface, 'vlan-to-vni', vlan, 'vni', vni]) + +        # commit configuration +        self.cli_commit() + +        # All VNIs configured? +        tmp = get_vxlan_vni_filter(interface) +        self.assertListEqual(list(vlan_to_vni.values()), tmp) + +        self.cli_delete(['interfaces', 'bridge', bridge])  if __name__ == '__main__':      unittest.main(verbosity=2) diff --git a/smoketest/scripts/cli/test_service_https.py b/smoketest/scripts/cli/test_service_https.py index a18e7dfac..901a1857e 100755 --- a/smoketest/scripts/cli/test_service_https.py +++ b/smoketest/scripts/cli/test_service_https.py @@ -23,7 +23,7 @@ from urllib3.exceptions import InsecureRequestWarning  from base_vyostest_shim import VyOSUnitTestSHIM  from base_vyostest_shim import ignore_warning  from vyos.utils.file import read_file -from vyos.utils.process import run +from vyos.utils.process import process_named_running  base_path = ['service', 'https']  pki_base = ['pki'] @@ -49,24 +49,28 @@ MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPLpD0Ohhoq0g4nhx  u8/3jHMM7sDwL3aWzW/zp54/LhCWUoLMjDdDEEigK4fal4ZF9aA9F0Ww  """ +PROCESS_NAME = 'nginx' +  class TestHTTPSService(VyOSUnitTestSHIM.TestCase): -    def setUp(self): +    @classmethod +    def setUpClass(cls): +        super(TestHTTPSService, cls).setUpClass() +          # ensure we can also run this test on a live system - so lets clean          # out the current configuration :) -        self.cli_delete(base_path) -        self.cli_delete(pki_base) +        cls.cli_delete(cls, base_path) +        cls.cli_delete(cls, pki_base)      def tearDown(self): +        # Check for running process +        self.assertTrue(process_named_running(PROCESS_NAME)) +          self.cli_delete(base_path)          self.cli_delete(pki_base)          self.cli_commit() -    def test_default(self): -        self.cli_set(base_path) -        self.cli_commit() - -        ret = run('sudo /usr/sbin/nginx -t') -        self.assertEqual(ret, 0) +        # Check for stopped  process +        self.assertFalse(process_named_running(PROCESS_NAME))      def test_server_block(self):          vhost_id = 'example' @@ -77,14 +81,11 @@ class TestHTTPSService(VyOSUnitTestSHIM.TestCase):          test_path = base_path + ['virtual-host', vhost_id]          self.cli_set(test_path + ['listen-address', address]) -        self.cli_set(test_path + ['listen-port', port]) +        self.cli_set(test_path + ['port', port])          self.cli_set(test_path + ['server-name', name])          self.cli_commit() -        ret = run('sudo /usr/sbin/nginx -t') -        self.assertEqual(ret, 0) -          nginx_config = read_file('/etc/nginx/sites-enabled/default')          self.assertIn(f'listen {address}:{port} ssl;', nginx_config)          self.assertIn(f'ssl_protocols TLSv1.2 TLSv1.3;', nginx_config) @@ -97,23 +98,18 @@ class TestHTTPSService(VyOSUnitTestSHIM.TestCase):          self.cli_commit() -        ret = run('sudo /usr/sbin/nginx -t') -        self.assertEqual(ret, 0) -      @ignore_warning(InsecureRequestWarning)      def test_api_auth(self):          vhost_id = 'example'          address = '127.0.0.1' -        port = '443' +        port = '443' # default value          name = 'localhost' -        self.cli_set(base_path + ['api', 'socket'])          key = 'MySuperSecretVyOS'          self.cli_set(base_path + ['api', 'keys', 'id', 'key-01', 'key', key])          test_path = base_path + ['virtual-host', vhost_id]          self.cli_set(test_path + ['listen-address', address]) -        self.cli_set(test_path + ['listen-port', port])          self.cli_set(test_path + ['server-name', name])          self.cli_commit() diff --git a/src/conf_mode/https.py b/src/conf_mode/https.py index 010490c7e..26c4343a0 100755 --- a/src/conf_mode/https.py +++ b/src/conf_mode/https.py @@ -122,7 +122,7 @@ def verify(https):              server_block = deepcopy(default_server_block)              data = vhost_dict.get(vhost, {})              server_block['address'] = data.get('listen-address', '*') -            server_block['port'] = data.get('listen-port', '443') +            server_block['port'] = data.get('port', '443')              server_block_list.append(server_block)      for entry in server_block_list: @@ -156,7 +156,7 @@ def generate(https):              server_block['id'] = vhost              data = vhost_dict.get(vhost, {})              server_block['address'] = data.get('listen-address', '*') -            server_block['port'] = data.get('listen-port', '443') +            server_block['port'] = data.get('port', '443')              name = data.get('server-name', ['_'])              server_block['name'] = name              allow_client = data.get('allow-client', {}) @@ -215,14 +215,9 @@ def generate(https):          api_data = vyos.defaults.api_data      api_settings = https.get('api', {})      if api_settings: -        port = api_settings.get('port', '') -        if port: -            api_data['port'] = port          vhosts = https.get('api-restrict', {}).get('virtual-host', [])          if vhosts:              api_data['vhost'] = vhosts[:] -        if 'socket' in list(api_settings): -            api_data['socket'] = True      if api_data:          vhost_list = api_data.get('vhost', []) diff --git a/src/conf_mode/interfaces-pppoe.py b/src/conf_mode/interfaces-pppoe.py index 0a03a172c..42f084309 100755 --- a/src/conf_mode/interfaces-pppoe.py +++ b/src/conf_mode/interfaces-pppoe.py @@ -61,6 +61,12 @@ def get_config(config=None):              # bail out early - no need to further process other nodes              break +    if 'deleted' not in pppoe: +        # We always set the MRU value to the MTU size. This code path only re-creates +        # the old behavior if MRU is not set on the CLI. +        if 'mru' not in pppoe: +            pppoe['mru'] = pppoe['mtu'] +      return pppoe  def verify(pppoe): diff --git a/src/conf_mode/interfaces-vxlan.py b/src/conf_mode/interfaces-vxlan.py index 6bf3227d5..4251e611b 100755 --- a/src/conf_mode/interfaces-vxlan.py +++ b/src/conf_mode/interfaces-vxlan.py @@ -60,8 +60,14 @@ def get_config(config=None):              vxlan.update({'rebuild_required': {}})              break +    # When dealing with VNI filtering we need to know what VNI was actually removed, +    # so build up a dict matching the vlan_to_vni structure but with removed values.      tmp = node_changed(conf, base + [ifname, 'vlan-to-vni'], recursive=True) -    if tmp: vxlan.update({'vlan_to_vni_removed': tmp}) +    if tmp: +        vxlan.update({'vlan_to_vni_removed': {}}) +        for vlan in tmp: +            vni = leaf_node_changed(conf, base + [ifname, 'vlan-to-vni', vlan, 'vni']) +            vxlan['vlan_to_vni_removed'].update({vlan : {'vni' : vni[0]}})      # We need to verify that no other VXLAN tunnel is configured when external      # mode is in use - Linux Kernel limitation @@ -98,14 +104,31 @@ def verify(vxlan):      if 'vni' not in vxlan and dict_search('parameters.external', vxlan) == None:          raise ConfigError('Must either configure VXLAN "vni" or use "external" CLI option!') -    if dict_search('parameters.external', vxlan): +    if dict_search('parameters.external', vxlan) != None:          if 'vni' in vxlan:              raise ConfigError('Can not specify both "external" and "VNI"!')          if 'other_tunnels' in vxlan: -            other_tunnels = ', '.join(vxlan['other_tunnels']) -            raise ConfigError(f'Only one VXLAN tunnel is supported when "external" '\ -                              f'CLI option is used. Additional tunnels: {other_tunnels}') +            # When multiple VXLAN interfaces are defined and "external" is used, +            # all VXLAN interfaces need to have vni-filter enabled! +            # See Linux Kernel commit f9c4bb0b245cee35ef66f75bf409c9573d934cf9 +            other_vni_filter = False +            for tunnel, tunnel_config in vxlan['other_tunnels'].items(): +                if dict_search('parameters.vni_filter', tunnel_config) != None: +                    other_vni_filter = True +                    break +            # eqivalent of the C foo ? 'a' : 'b' statement +            vni_filter = True and (dict_search('parameters.vni_filter', vxlan) != None) or False +            # If either one is enabled, so must be the other. Both can be off and both can be on +            if (vni_filter and not other_vni_filter) or (not vni_filter and other_vni_filter): +                raise ConfigError(f'Using multiple VXLAN interfaces with "external" '\ +                    'requires all VXLAN interfaces to have "vni-filter" configured!') + +            if not vni_filter and not other_vni_filter: +                other_tunnels = ', '.join(vxlan['other_tunnels']) +                raise ConfigError(f'Only one VXLAN tunnel is supported when "external" '\ +                                f'CLI option is used and "vni-filter" is unset. '\ +                                f'Additional tunnels: {other_tunnels}')      if 'gpe' in vxlan and 'external' not in vxlan:          raise ConfigError(f'VXLAN-GPE is only supported when "external" '\ @@ -165,7 +188,7 @@ def verify(vxlan):                  raise ConfigError(f'VNI "{vni}" is already assigned to a different VLAN!')              vnis_used.append(vni) -    if dict_search('parameters.neighbor_suppress', vxlan): +    if dict_search('parameters.neighbor_suppress', vxlan) != None:          if 'is_bridge_member' not in vxlan:              raise ConfigError('Neighbor suppression requires that VXLAN interface '\                                'is member of a bridge interface!') diff --git a/src/etc/sysctl.d/30-vyos-router.conf b/src/etc/sysctl.d/30-vyos-router.conf index 1c9b8999f..67d96969e 100644 --- a/src/etc/sysctl.d/30-vyos-router.conf +++ b/src/etc/sysctl.d/30-vyos-router.conf @@ -105,3 +105,11 @@ net.core.rps_sock_flow_entries = 32768  net.core.default_qdisc=fq_codel  net.ipv4.tcp_congestion_control=bbr +# VRF - Virtual routing and forwarding +# When net.vrf.strict_mode=0 (default) it is possible to associate multiple +# VRF devices to the same table. Conversely, when net.vrf.strict_mode=1 a +# table can be associated to a single VRF device. +# +# A VRF table can be used by the VyOS CLI only once (ensured by verify()), +# this simply adds an additional Kernel safety net +net.vrf.strict_mode=1 diff --git a/src/helpers/vyos-load-config.py b/src/helpers/vyos-load-config.py index e579e81b2..4ec865454 100755 --- a/src/helpers/vyos-load-config.py +++ b/src/helpers/vyos-load-config.py @@ -66,7 +66,7 @@ def get_local_config(filename):      return config_str -if any(x in file_name for x in protocols): +if any(file_name.startswith(f'{x}://') for x in protocols):      config_string = vyos.remote.get_remote_config(file_name)      if not config_string:          sys.exit(f"No such config file at '{file_name}'") diff --git a/src/migration-scripts/https/4-to-5 b/src/migration-scripts/https/4-to-5 new file mode 100755 index 000000000..0dfb6ac19 --- /dev/null +++ b/src/migration-scripts/https/4-to-5 @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 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/>. + +# T5762: http: api: smoketests fail as they can not establish IPv6 connection +#        to uvicorn backend server, always make the UNIX domain socket the +#        default way of communication + +import sys + +from vyos.configtree import ConfigTree + +if len(sys.argv) < 2: +    print("Must specify file name!") +    sys.exit(1) + +file_name = sys.argv[1] + +with open(file_name, 'r') as f: +    config_file = f.read() + +config = ConfigTree(config_file) + +base = ['service', 'https'] +if not config.exists(base): +    # Nothing to do +    sys.exit(0) + +# Delete "socket" CLI option - we always use UNIX domain sockets for +# NGINX <-> API server communication +if config.exists(base + ['api', 'socket']): +    config.delete(base + ['api', 'socket']) + +# There is no need for an API service port, as UNIX domain sockets +# are used +if config.exists(base + ['api', 'port']): +    config.delete(base + ['api', 'port']) + +# rename listen-port -> port ver virtual-host +if config.exists(base + ['virtual-host']): +    for vhost in config.list_nodes(base + ['virtual-host']): +        if config.exists(base + ['virtual-host', vhost, 'listen-port']): +            config.rename(base + ['virtual-host', vhost, 'listen-port'], 'port') + +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)) +    sys.exit(1) diff --git a/src/migration-scripts/interfaces/31-to-32 b/src/migration-scripts/interfaces/31-to-32 index ca3d19320..0fc27b70a 100755 --- a/src/migration-scripts/interfaces/31-to-32 +++ b/src/migration-scripts/interfaces/31-to-32 @@ -15,6 +15,7 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  #  # T5671: change port to IANA assigned default port +# T5759: change default MTU 1450 -> 1500  from sys import argv  from sys import exit @@ -43,6 +44,9 @@ for vxlan in config.list_nodes(base):      if not config.exists(base + [vxlan, 'port']):          config.set(base + [vxlan, 'port'], value='8472') +    if not config.exists(base + [vxlan, 'mtu']): +        config.set(base + [vxlan, 'mtu'], value='1450') +  try:      with open(file_name, 'w') as f:          f.write(config.to_string()) diff --git a/src/op_mode/bridge.py b/src/op_mode/bridge.py index 185db4f20..412a4eba8 100755 --- a/src/op_mode/bridge.py +++ b/src/op_mode/bridge.py @@ -56,6 +56,13 @@ def _get_raw_data_vlan(tunnel:bool=False):      data_dict = json.loads(json_data)      return data_dict +def _get_raw_data_vni() -> dict: +    """ +    :returns dict +    """ +    json_data = cmd(f'bridge --json vni show') +    data_dict = json.loads(json_data) +    return data_dict  def _get_raw_data_fdb(bridge):      """Get MAC-address for the bridge brX @@ -165,6 +172,22 @@ def _get_formatted_output_vlan_tunnel(data):      output = tabulate(data_entries, headers)      return output +def _get_formatted_output_vni(data): +    data_entries = [] +    for entry in data: +        interface = entry.get('ifname') +        vlans = entry.get('vnis') +        for vlan_entry in vlans: +            vlan = vlan_entry.get('vni') +            if vlan_entry.get('vniEnd'): +                vlan_end = vlan_entry.get('vniEnd') +                vlan = f'{vlan}-{vlan_end}' +            data_entries.append([interface, vlan]) + +    headers = ["Interface", "VNI"] +    output = tabulate(data_entries, headers) +    return output +  def _get_formatted_output_fdb(data):      data_entries = []      for entry in data: @@ -228,6 +251,12 @@ def show_vlan(raw: bool, tunnel: typing.Optional[bool]):          else:              return _get_formatted_output_vlan(bridge_vlan) +def show_vni(raw: bool): +    bridge_vni = _get_raw_data_vni() +    if raw: +        return bridge_vni +    else: +        return _get_formatted_output_vni(bridge_vni)  def show_fdb(raw: bool, interface: str):      fdb_data = _get_raw_data_fdb(interface) diff --git a/src/op_mode/interfaces.py b/src/op_mode/interfaces.py index c626535b5..14ffdca9f 100755 --- a/src/op_mode/interfaces.py +++ b/src/op_mode/interfaces.py @@ -235,6 +235,11 @@ def _get_summary_data(ifname: typing.Optional[str],      if iftype is None:          iftype = ''      ret = [] + +    def is_interface_has_mac(interface_name): +        interface_no_mac = ('tun', 'wg') +        return not any(interface_name.startswith(prefix) for prefix in interface_no_mac) +      for interface in filtered_interfaces(ifname, iftype, vif, vrrp):          res_intf = {} @@ -244,7 +249,7 @@ def _get_summary_data(ifname: typing.Optional[str],          res_intf['addr'] = [_ for _ in interface.get_addr() if not _.startswith('fe80::')]          res_intf['description'] = interface.get_alias()          res_intf['mtu'] = interface.get_mtu() -        res_intf['mac'] = interface.get_mac() +        res_intf['mac'] = interface.get_mac() if is_interface_has_mac(interface.ifname) else 'n/a'          res_intf['vrf'] = interface.get_vrf()          ret.append(res_intf) diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 3a9efb73e..85d7884b6 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -223,6 +223,19 @@ class ShowModel(ApiModel):              }          } +class RebootModel(ApiModel): +    op: StrictStr +    path: List[StrictStr] + +    class Config: +        schema_extra = { +            "example": { +                "key": "id_key", +                "op": "reboot", +                "path": ["op", "mode", "path"], +            } +        } +  class ResetModel(ApiModel):      op: StrictStr      path: List[StrictStr] @@ -236,6 +249,19 @@ class ResetModel(ApiModel):              }          } +class PoweroffModel(ApiModel): +    op: StrictStr +    path: List[StrictStr] + +    class Config: +        schema_extra = { +            "example": { +                "key": "id_key", +                "op": "poweroff", +                "path": ["op", "mode", "path"], +            } +        } +  class Success(BaseModel):      success: bool @@ -713,6 +739,26 @@ def show_op(data: ShowModel):      return success(res) +@app.post('/reboot') +def reboot_op(data: RebootModel): +    session = app.state.vyos_session + +    op = data.op +    path = data.path + +    try: +        if op == 'reboot': +            res = session.reboot(path) +        else: +            return error(400, f"'{op}' is not a valid operation") +    except ConfigSessionError as e: +        return error(400, str(e)) +    except Exception as e: +        logger.critical(traceback.format_exc()) +        return error(500, "An internal error occured. Check the logs for details.") + +    return success(res) +  @app.post('/reset')  def reset_op(data: ResetModel):      session = app.state.vyos_session @@ -733,6 +779,26 @@ def reset_op(data: ResetModel):      return success(res) +@app.post('/poweroff') +def poweroff_op(data: PoweroffModel): +    session = app.state.vyos_session + +    op = data.op +    path = data.path + +    try: +        if op == 'poweroff': +            res = session.poweroff(path) +        else: +            return error(400, f"'{op}' is not a valid operation") +    except ConfigSessionError as e: +        return error(400, str(e)) +    except Exception as e: +        logger.critical(traceback.format_exc()) +        return error(500, "An internal error occured. Check the logs for details.") + +    return success(res) +  ###  # GraphQL integration @@ -825,15 +891,7 @@ def initialization(session: ConfigSession, app: FastAPI = app):      if app.state.vyos_graphql:          graphql_init(app) -    if not server_config['socket']: -        config = ApiServerConfig(app, -                                 host=server_config["listen_address"], -                                 port=int(server_config["port"]), -                                 proxy_headers=True) -    else: -        config = ApiServerConfig(app, -                                 uds="/run/api.sock", -                                 proxy_headers=True) +    config = ApiServerConfig(app, uds="/run/api.sock", proxy_headers=True)      server = ApiServer(config)  def run_server(): | 
