diff options
-rw-r--r-- | data/templates/https/nginx.default.tmpl | 2 | ||||
-rw-r--r-- | python/vyos/configsession.py | 6 | ||||
-rwxr-xr-x | smoketest/scripts/cli/test_load_balancning_wan.py | 189 | ||||
-rwxr-xr-x | src/conf_mode/interfaces-wwan.py | 20 | ||||
-rwxr-xr-x | src/services/vyos-http-api-server | 37 |
5 files changed, 239 insertions, 15 deletions
diff --git a/data/templates/https/nginx.default.tmpl b/data/templates/https/nginx.default.tmpl index 968ba806c..04e0d558a 100644 --- a/data/templates/https/nginx.default.tmpl +++ b/data/templates/https/nginx.default.tmpl @@ -41,7 +41,7 @@ server { ssl_protocols TLSv1.2 TLSv1.3; # proxy settings for HTTP API, if enabled; 503, if not - location ~ /(retrieve|configure|config-file|image|generate|show|docs|openapi.json|redoc|graphql) { + location ~ /(retrieve|configure|config-file|image|generate|show|reset|docs|openapi.json|redoc|graphql) { {% if server.api %} {% if server.api.socket %} proxy_pass http://unix:/run/api.sock; diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 670e6c7fc..d2645e5e1 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -1,5 +1,5 @@ # configsession -- the write API for the VyOS running config -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright (C) 2019-2022 VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or modify it under the terms of # the GNU Lesser General Public License as published by the Free Software Foundation; @@ -33,6 +33,7 @@ INSTALL_IMAGE = ['/opt/vyatta/sbin/install-image', '--url'] 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'] # Default "commit via" string APP = "vyos-http-api" @@ -201,3 +202,6 @@ class ConfigSession(object): out = self.__run_command(SHOW + path) return out + def reset(self, path): + out = self.__run_command(RESET + path) + return out diff --git a/smoketest/scripts/cli/test_load_balancning_wan.py b/smoketest/scripts/cli/test_load_balancning_wan.py new file mode 100755 index 000000000..edc6deb04 --- /dev/null +++ b/smoketest/scripts/cli/test_load_balancning_wan.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import unittest +import time + +from base_vyostest_shim import VyOSUnitTestSHIM +from vyos.configsession import ConfigSessionError +from vyos.ifconfig import Section +from vyos.util import call +from vyos.util import cmd + + +base_path = ['load-balancing'] + + +def create_netns(name): + return call(f'sudo ip netns add {name}') + +def create_veth_pair(local='veth0', peer='ceth0'): + return call(f'sudo ip link add {local} type veth peer name {peer}') + +def move_interface_to_netns(iface, netns_name): + return call(f'sudo ip link set {iface} netns {netns_name}') + +def rename_interface(iface, new_name): + return call(f'sudo ip link set {iface} name {new_name}') + +def cmd_in_netns(netns, cmd): + return call(f'sudo ip netns exec {netns} {cmd}') + +def delete_netns(name): + return call(f'sudo ip netns del {name}') + + +class TestLoadBalancingWan(VyOSUnitTestSHIM.TestCase): + @classmethod + def setUpClass(cls): + super(TestLoadBalancingWan, 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) + + def tearDown(self): + self.cli_delete(base_path) + self.cli_commit() + + def test_table_routes(self): + + ns1 = 'ns201' + ns2 = 'ns202' + iface1 = 'eth201' + iface2 = 'eth202' + container_iface1 = 'ceth0' + container_iface2 = 'ceth1' + + # Create network namespeces + create_netns(ns1) + create_netns(ns2) + create_veth_pair(iface1, container_iface1) + create_veth_pair(iface2, container_iface2) + move_interface_to_netns(container_iface1, ns1) + move_interface_to_netns(container_iface2, ns2) + call(f'sudo ip a add 203.0.113.10/24 dev {iface1}') + call(f'sudo ip a add 192.0.2.10/24 dev {iface2}') + call(f'sudo ip link set dev {iface1} up') + call(f'sudo ip link set dev {iface2} up') + cmd_in_netns(ns1, f'ip link set {container_iface1} name eth0') + cmd_in_netns(ns2, f'ip link set {container_iface2} name eth0') + cmd_in_netns(ns1, 'ip a add 203.0.113.1/24 dev eth0') + cmd_in_netns(ns2, 'ip a add 192.0.2.1/24 dev eth0') + cmd_in_netns(ns1, 'ip link set dev eth0 up') + cmd_in_netns(ns2, 'ip link set dev eth0 up') + + # Set load-balancing configuration + self.cli_set(base_path + ['wan', 'interface-health', iface1, 'failure-count', '2']) + self.cli_set(base_path + ['wan', 'interface-health', iface1, 'nexthop', '203.0.113.1']) + self.cli_set(base_path + ['wan', 'interface-health', iface1, 'success-count', '1']) + self.cli_set(base_path + ['wan', 'interface-health', iface2, 'failure-count', '2']) + self.cli_set(base_path + ['wan', 'interface-health', iface2, 'nexthop', '192.0.2.1']) + self.cli_set(base_path + ['wan', 'interface-health', iface2, 'success-count', '1']) + + # commit changes + self.cli_commit() + + time.sleep(5) + # Check default routes in tables 201, 202 + # Expected values + original = 'default via 203.0.113.1 dev eth201' + tmp = cmd('sudo ip route show table 201') + self.assertEqual(tmp, original) + + original = 'default via 192.0.2.1 dev eth202' + tmp = cmd('sudo ip route show table 202') + self.assertEqual(tmp, original) + + # Delete veth interfaces and netns + for iface in [iface1, iface2]: + call(f'sudo ip link del dev {iface}') + + delete_netns(ns1) + delete_netns(ns2) + + def test_check_chains(self): + + ns1 = 'nsA' + ns2 = 'nsB' + iface1 = 'veth1' + iface2 = 'veth2' + container_iface1 = 'ceth0' + container_iface2 = 'ceth1' + mangle_isp1 = """table ip mangle { + chain ISP_veth1 { + counter ct mark set 0xc9 + counter meta mark set 0xc9 + counter accept + } +}""" + mangle_isp2 = """table ip mangle { + chain ISP_veth2 { + counter ct mark set 0xca + counter meta mark set 0xca + counter accept + } +}""" + + # Create network namespeces + create_netns(ns1) + create_netns(ns2) + create_veth_pair(iface1, container_iface1) + create_veth_pair(iface2, container_iface2) + move_interface_to_netns(container_iface1, ns1) + move_interface_to_netns(container_iface2, ns2) + call(f'sudo ip a add 203.0.113.10/24 dev {iface1}') + call(f'sudo ip a add 192.0.2.10/24 dev {iface2}') + call(f'sudo ip link set dev {iface1} up') + call(f'sudo ip link set dev {iface2} up') + cmd_in_netns(ns1, f'ip link set {container_iface1} name eth0') + cmd_in_netns(ns2, f'ip link set {container_iface2} name eth0') + cmd_in_netns(ns1, 'ip a add 203.0.113.1/24 dev eth0') + cmd_in_netns(ns2, 'ip a add 192.0.2.1/24 dev eth0') + cmd_in_netns(ns1, 'ip link set dev eth0 up') + cmd_in_netns(ns2, 'ip link set dev eth0 up') + + # Set load-balancing configuration + self.cli_set(base_path + ['wan', 'interface-health', iface1, 'failure-count', '2']) + self.cli_set(base_path + ['wan', 'interface-health', iface1, 'nexthop', '203.0.113.1']) + self.cli_set(base_path + ['wan', 'interface-health', iface1, 'success-count', '1']) + self.cli_set(base_path + ['wan', 'interface-health', iface2, 'failure-count', '2']) + self.cli_set(base_path + ['wan', 'interface-health', iface2, 'nexthop', '192.0.2.1']) + self.cli_set(base_path + ['wan', 'interface-health', iface2, 'success-count', '1']) + + # commit changes + self.cli_commit() + + time.sleep(5) + # Check chains + #call('sudo nft list ruleset') + tmp = cmd(f'sudo nft -s list chain mangle ISP_{iface1}') + self.assertEqual(tmp, mangle_isp1) + + tmp = cmd(f'sudo nft -s list chain mangle ISP_{iface2}') + self.assertEqual(tmp, mangle_isp2) + + # Delete veth interfaces and netns + for iface in [iface1, iface2]: + call(f'sudo ip link del dev {iface}') + + delete_netns(ns1) + delete_netns(ns2) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/src/conf_mode/interfaces-wwan.py b/src/conf_mode/interfaces-wwan.py index a1a9360d7..179d1efb4 100755 --- a/src/conf_mode/interfaces-wwan.py +++ b/src/conf_mode/interfaces-wwan.py @@ -21,7 +21,7 @@ from time import sleep from vyos.config import Config from vyos.configdict import get_interface_dict -from vyos.configdict import leaf_node_changed +from vyos.configdict import is_node_changed from vyos.configverify import verify_authentication from vyos.configverify import verify_interface_exists from vyos.configverify import verify_vrf @@ -54,27 +54,25 @@ def get_config(config=None): # We should only terminate the WWAN session if critical parameters change. # All parameters that can be changed on-the-fly (like interface description) # should not lead to a reconnect! - tmp = leaf_node_changed(conf, ['address']) + tmp = is_node_changed(conf, ['address']) if tmp: wwan.update({'shutdown_required': {}}) - tmp = leaf_node_changed(conf, ['apn']) + tmp = is_node_changed(conf, ['apn']) if tmp: wwan.update({'shutdown_required': {}}) - tmp = leaf_node_changed(conf, ['disable']) + tmp = is_node_changed(conf, ['disable']) if tmp: wwan.update({'shutdown_required': {}}) - tmp = leaf_node_changed(conf, ['vrf']) - # leaf_node_changed() returns a list, as VRF is a non-multi node, there - # will be only one list element - if tmp: wwan.update({'vrf_old': tmp[0]}) + tmp = is_node_changed(conf, ['vrf']) + if tmp: wwan.update({'vrf_old': {}}) - tmp = leaf_node_changed(conf, ['authentication', 'user']) + tmp = is_node_changed(conf, ['authentication', 'user']) if tmp: wwan.update({'shutdown_required': {}}) - tmp = leaf_node_changed(conf, ['authentication', 'password']) + tmp = is_node_changed(conf, ['authentication', 'password']) if tmp: wwan.update({'shutdown_required': {}}) - tmp = leaf_node_changed(conf, ['ipv6', 'address', 'autoconf']) + tmp = is_node_changed(conf, ['ipv6', 'address', 'autoconf']) if tmp: wwan.update({'shutdown_required': {}}) # We need to know the amount of other WWAN interfaces as ModemManager needs diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 1000d8b72..ed8cf6a44 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -1,6 +1,6 @@ #!/usr/share/vyos-http-api-tools/bin/python3 # -# Copyright (C) 2019-2021 VyOS maintainers and contributors +# Copyright (C) 2019-2022 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -201,6 +201,19 @@ class ShowModel(ApiModel): } } +class ResetModel(ApiModel): + op: StrictStr + path: List[StrictStr] + + class Config: + schema_extra = { + "example": { + "key": "id_key", + "op": "reset", + "path": ["op", "mode", "path"], + } + } + class Success(BaseModel): success: bool data: Union[str, bool, Dict] @@ -372,7 +385,7 @@ class MultipartRoute(APIRoute): return error(400, "Malformed command \"{0}\": \"value\" field must be a string".format(json.dumps(request.offending_command))) if request.ERR_PATH_NOT_LIST_OF_STR: return error(400, "Malformed command \"{0}\": \"path\" field must be a list of strings".format(json.dumps(request.offending_command))) - if endpoint in ('/retrieve','/generate','/show'): + if endpoint in ('/retrieve','/generate','/show','reset'): if request.ERR_NO_OP or request.ERR_NO_PATH: return error(400, "Missing required field. \"op\" and \"path\" fields are required") if endpoint in ('/config-file', '/image'): @@ -607,6 +620,26 @@ def show_op(data: ShowModel): return success(res) +@app.post('/reset') +def reset_op(data: ResetModel): + session = app.state.vyos_session + + op = data.op + path = data.path + + try: + if op == 'reset': + res = session.reset(path) + else: + return error(400, "\"{0}\" is not a valid operation".format(op)) + 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 ### |