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(): |