From 82b2d294b7b85411f2cc678222d6613d7fb33eaf Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Mon, 6 Jun 2022 21:57:53 +0200 Subject: [PATCH] add support to create L2TP and PPTP VPN connection (#4746) (#4793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add support to create L2TP and PPTP VPN connection * Update plugins/modules/net_tools/nmcli.py Co-authored-by: Felix Fontein * Update plugins/modules/net_tools/nmcli.py Co-authored-by: Felix Fontein * Update plugins/modules/net_tools/nmcli.py Co-authored-by: Felix Fontein * Update plugins/modules/net_tools/nmcli.py Co-authored-by: Felix Fontein * Update plugins/modules/net_tools/nmcli.py Co-authored-by: Felix Fontein * Update plugins/modules/net_tools/nmcli.py Co-authored-by: Felix Fontein * Update plugins/modules/net_tools/nmcli.py Co-authored-by: Felix Fontein * Update plugins/modules/net_tools/nmcli.py Co-authored-by: Felix Fontein * apply changes pointed on tests and review - add changelog fragment - change example code to use jinja2 in place of shell command * removes trailing whitespace * Update plugins/modules/net_tools/nmcli.py Co-authored-by: Felix Fontein * Update plugins/modules/net_tools/nmcli.py Co-authored-by: Felix Fontein * removes linux command from examples * remove unnecessary brakets Co-authored-by: Felix Fontein * remove unnecessary brakets Co-authored-by: Felix Fontein * simplify psk encoding on example Co-authored-by: Felix Fontein * Update plugins/modules/net_tools/nmcli.py Co-authored-by: Felix Fontein * Update plugins/modules/net_tools/nmcli.py Co-authored-by: Felix Fontein * add unit tests - test unchenged l2tp and pptp vpn connections - test create l2tp and pptp vpn connections - fix is_connection_changed to remove default ifname attribuition * improve tests on vpn.data param - fix _compare_conn_params to handle vpn.data as lists * removes block and set_fact from example Co-authored-by: Felix Fontein * makes line shortter to better reading Co-authored-by: Felix Fontein * Update plugins/modules/net_tools/nmcli.py Co-authored-by: Felix Fontein Co-authored-by: Felix Fontein (cherry picked from commit e5e485390df1fe242dd6e394b13852e6e7b92c06) Co-authored-by: José Roberto Emerich Junior --- .../fragments/4746-add-vpn-support-nmcli.yaml | 2 + plugins/modules/net_tools/nmcli.py | 115 ++++++++++- .../plugins/modules/net_tools/test_nmcli.py | 191 ++++++++++++++++++ 3 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 changelogs/fragments/4746-add-vpn-support-nmcli.yaml diff --git a/changelogs/fragments/4746-add-vpn-support-nmcli.yaml b/changelogs/fragments/4746-add-vpn-support-nmcli.yaml new file mode 100644 index 0000000000..1ab7e8a20a --- /dev/null +++ b/changelogs/fragments/4746-add-vpn-support-nmcli.yaml @@ -0,0 +1,2 @@ +minor_changes: + - nmcli - adds ``vpn`` type and parameter for supporting VPN with service type L2TP and PPTP (https://github.com/ansible-collections/community.general/pull/4746). \ No newline at end of file diff --git a/plugins/modules/net_tools/nmcli.py b/plugins/modules/net_tools/nmcli.py index b09c7e7845..859f74b91d 100644 --- a/plugins/modules/net_tools/nmcli.py +++ b/plugins/modules/net_tools/nmcli.py @@ -45,8 +45,8 @@ options: - The interface to bind the connection to. - The connection will only be applicable to this interface name. - A special value of C('*') can be used for interface-independent connections. - - The ifname argument is mandatory for all connection types except bond, team, bridge and vlan. - - This parameter defaults to C(conn_name) when left unset. + - The ifname argument is mandatory for all connection types except bond, team, bridge, vlan and vpn. + - This parameter defaults to C(conn_name) when left unset for all connection types except vpn that removes it. type: str type: description: @@ -55,10 +55,11 @@ options: - Type C(generic) is added in Ansible 2.5. - Type C(infiniband) is added in community.general 2.0.0. - Type C(gsm) is added in community.general 3.7.0. - - Type C(wireguard) is added in community.general 4.3.0 + - Type C(wireguard) is added in community.general 4.3.0. + - Type C(vpn) is added in community.general 5.1.0. type: str choices: [ bond, bond-slave, bridge, bridge-slave, dummy, ethernet, generic, gre, infiniband, ipip, sit, team, team-slave, vlan, vxlan, wifi, gsm, - wireguard ] + wireguard, vpn ] mode: description: - This is the type of device or network connection that you wish to create for a bond or bridge. @@ -905,6 +906,58 @@ options: description: C(NMSettingSecretFlags) indicating how to handle the I(wireguard.private-key) property. type: int choices: [ 0, 1, 2 ] + vpn: + description: + - Configuration of a VPN connection (PPTP and L2TP). + - In order to use L2TP you need to be sure that C(network-manager-l2tp) - and C(network-manager-l2tp-gnome) + if host has UI - are installed on the host. + type: dict + version_added: 5.1.0 + suboptions: + permissions: + description: User that will have permission to use the connection. + type: str + required: true + service-type: + description: This defines the service type of connection. + type: str + required: true + choices: [ pptp, l2tp ] + gateway: + description: The gateway to connection. It can be an IP address (for example C(192.0.2.1)) + or a FQDN address (for example C(vpn.example.com)). + type: str + required: true + password-flags: + description: + - NMSettingSecretFlags indicating how to handle the I(password) property. + - 'Following choices are allowed: + C(0) B(NONE): The system is responsible for providing and storing this secret (default); + C(1) B(AGENT_OWNED): A user secret agent is responsible for providing and storing this secret; when it is required agents will be + asked to retrieve it; + C(2) B(NOT_SAVED): This secret should not be saved, but should be requested from the user each time it is needed; + C(4) B(NOT_REQUIRED): In situations where it cannot be automatically determined that the secret is required + (some VPNs and PPP providers do not require all secrets) this flag indicates that the specific secret is not required.' + type: int + choices: [ 0, 1, 2 , 4 ] + default: 0 + user: + description: Username provided by VPN administrator. + type: str + required: true + ipsec-enabled: + description: + - Enable or disable IPSec tunnel to L2TP host. + - This option is need when C(service-type) is C(l2tp). + type: bool + choices: [ yes, no ] + ipsec-psk: + description: + - The pre-shared key in base64 encoding. + - > + You can encode using this Ansible jinja2 expression: C("0s{{ '[YOUR PRE-SHARED KEY]' | ansible.builtin.b64encode }}"). + - This is only used when I(ipsec-enabled=true). + type: str ''' EXAMPLES = r''' @@ -1288,6 +1341,23 @@ EXAMPLES = r''' autoconnect: true state: present +- name: >- + Create a VPN L2TP connection for ansible_user to connect on vpn.example.com + authenticating with user 'brittany' and pre-shared key as 'Brittany123' + community.general.nmcli: + type: vpn + conn_name: my-vpn-connection + vpn: + permissions: "{{ ansible_user }}" + service-type: l2tp + gateway: vpn.example.com + password-flags: 2 + user: brittany + ipsec-enabled: true + ipsec-psk: "0s{{ 'Brittany123' | ansible.builtin.b64encode }}" + autoconnect: false + state: present + ''' RETURN = r"""# @@ -1404,6 +1474,7 @@ class Nmcli(object): self.wifi_sec = module.params['wifi_sec'] self.gsm = module.params['gsm'] self.wireguard = module.params['wireguard'] + self.vpn = module.params['vpn'] if self.method4: self.ipv4_method = self.method4 @@ -1592,6 +1663,29 @@ class Nmcli(object): options.update({ 'wireguard.%s' % name: value, }) + elif self.type == 'vpn': + if self.vpn: + vpn_data_values = '' + for name, value in self.vpn.items(): + if name == 'service-type': + options.update({ + 'vpn-type': value, + }) + elif name == 'permissions': + options.update({ + 'connection.permissions': value, + }) + else: + if vpn_data_values != '': + vpn_data_values += ', ' + + if isinstance(value, bool): + value = self.bool_to_string(value) + + vpn_data_values += '%s=%s' % (name, value) + options.update({ + 'vpn.data': vpn_data_values, + }) # Convert settings values based on the situation. for setting, value in options.items(): setting_type = self.settings_type(setting) @@ -1832,6 +1926,10 @@ class Nmcli(object): 'connection.interface-name': ifname, } + # VPN doesn't need an interface but if sended it must be a valid interface. + if self.type == 'vpn' and self.ifname is None: + del options['connection.interface-name'] + options.update(self.connection_options()) # Constructing the command. @@ -1997,6 +2095,9 @@ class Nmcli(object): current_value = current_value.strip('"') if key == self.mtu_setting and self.mtu is None: self.mtu = 0 + if key == 'vpn.data': + current_value = list(map(str.strip, current_value.split(','))) + value = list(map(str.strip, value.split(','))) else: # parameter does not exist current_value = None @@ -2025,6 +2126,10 @@ class Nmcli(object): 'connection.interface-name': self.ifname, } + # VPN doesn't need an interface but if sended it must be a valid interface. + if self.type == 'vpn' and self.ifname is None: + del options['connection.interface-name'] + if not self.type: current_con_type = self.show_connection().get('connection.type') if current_con_type: @@ -2064,6 +2169,7 @@ def main(): 'wifi', 'gsm', 'wireguard', + 'vpn', ]), ip4=dict(type='list', elements='str'), gw4=dict(type='str'), @@ -2163,6 +2269,7 @@ def main(): wifi_sec=dict(type='dict', no_log=True), gsm=dict(type='dict'), wireguard=dict(type='dict'), + vpn=dict(type='dict'), ), mutually_exclusive=[['never_default4', 'gw4'], ['routes4_extended', 'routes4'], diff --git a/tests/unit/plugins/modules/net_tools/test_nmcli.py b/tests/unit/plugins/modules/net_tools/test_nmcli.py index 546cae20e8..39cb3e05e9 100644 --- a/tests/unit/plugins/modules/net_tools/test_nmcli.py +++ b/tests/unit/plugins/modules/net_tools/test_nmcli.py @@ -98,6 +98,12 @@ TESTCASE_CONNECTION = [ 'state': 'absent', '_ansible_check_mode': True, }, + { + 'type': 'vpn', + 'conn_name': 'non_existent_nw_device', + 'state': 'absent', + '_ansible_check_mode': True, + }, ] TESTCASE_GENERIC = [ @@ -1177,6 +1183,69 @@ wireguard.ip4-auto-default-route: -1 (default) wireguard.ip6-auto-default-route: -1 (default) """ +TESTCASE_VPN_L2TP = [ + { + 'type': 'vpn', + 'conn_name': 'vpn_l2tp', + 'vpn': { + 'permissions': 'brittany', + 'service-type': 'l2tp', + 'gateway': 'vpn.example.com', + 'password-flags': '2', + 'user': 'brittany', + 'ipsec-enabled': 'true', + 'ipsec-psk': 'QnJpdHRhbnkxMjM=', + }, + 'autoconnect': 'false', + 'state': 'present', + '_ansible_check_mode': False, + }, +] + +TESTCASE_VPN_L2TP_SHOW_OUTPUT = """\ +connection.id: vpn_l2tp +connection.type: vpn +connection.autoconnect: no +connection.permissions: brittany +ipv4.method: auto +ipv6.method: auto +vpn-type: l2tp +vpn.service-type: org.freedesktop.NetworkManager.l2tp +vpn.data: gateway=vpn.example.com, password-flags=2, user=brittany, ipsec-enabled=true, ipsec-psk=QnJpdHRhbnkxMjM= +vpn.secrets: ipsec-psk = QnJpdHRhbnkxMjM= +vpn.persistent: no +vpn.timeout: 0 +""" + +TESTCASE_VPN_PPTP = [ + { + 'type': 'vpn', + 'conn_name': 'vpn_pptp', + 'vpn': { + 'permissions': 'brittany', + 'service-type': 'pptp', + 'gateway': 'vpn.example.com', + 'password-flags': '2', + 'user': 'brittany', + }, + 'autoconnect': 'false', + 'state': 'present', + '_ansible_check_mode': False, + }, +] + +TESTCASE_VPN_PPTP_SHOW_OUTPUT = """\ +connection.id: vpn_pptp +connection.type: vpn +connection.autoconnect: no +connection.permissions: brittany +ipv4.method: auto +ipv6.method: auto +vpn-type: pptp +vpn.service-type: org.freedesktop.NetworkManager.pptp +vpn.data: password-flags=2, gateway=vpn.example.com, user=brittany +""" + def mocker_set(mocker, connection_exists=False, @@ -1547,6 +1616,20 @@ def mocked_wireguard_connection_unchanged(mocker): execute_return=(0, TESTCASE_WIREGUARD_SHOW_OUTPUT, "")) +@pytest.fixture +def mocked_vpn_l2tp_connection_unchanged(mocker): + mocker_set(mocker, + connection_exists=True, + execute_return=(0, TESTCASE_VPN_L2TP_SHOW_OUTPUT, "")) + + +@pytest.fixture +def mocked_vpn_pptp_connection_unchanged(mocker): + mocker_set(mocker, + connection_exists=True, + execute_return=(0, TESTCASE_VPN_PPTP_SHOW_OUTPUT, "")) + + @pytest.mark.parametrize('patch_ansible_module', TESTCASE_BOND, indirect=['patch_ansible_module']) def test_bond_connection_create(mocked_generic_connection_create, capfd): """ @@ -3456,3 +3539,111 @@ def test_wireguard_mod(mocked_generic_connection_modify, capfd): results = json.loads(out) assert not results.get('failed') assert results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_VPN_L2TP, indirect=['patch_ansible_module']) +def test_vpn_l2tp_connection_unchanged(mocked_vpn_l2tp_connection_unchanged, capfd): + """ + Test : L2TP VPN connection unchanged + """ + with pytest.raises(SystemExit): + nmcli.main() + + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert not results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_VPN_PPTP, indirect=['patch_ansible_module']) +def test_vpn_pptp_connection_unchanged(mocked_vpn_pptp_connection_unchanged, capfd): + """ + Test : PPTP VPN connection unchanged + """ + with pytest.raises(SystemExit): + nmcli.main() + + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert not results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_VPN_L2TP, indirect=['patch_ansible_module']) +def test_create_vpn_l2tp(mocked_generic_connection_create, capfd): + """ + Test : Create L2TP VPN connection + """ + + with pytest.raises(SystemExit): + nmcli.main() + + assert nmcli.Nmcli.execute_command.call_count == 1 + arg_list = nmcli.Nmcli.execute_command.call_args_list + add_args, add_kw = arg_list[0] + + assert add_args[0][0] == '/usr/bin/nmcli' + assert add_args[0][1] == 'con' + assert add_args[0][2] == 'add' + assert add_args[0][3] == 'type' + assert add_args[0][4] == 'vpn' + assert add_args[0][5] == 'con-name' + assert add_args[0][6] == 'vpn_l2tp' + + add_args_text = list(map(to_text, add_args[0])) + + for param in ['connection.autoconnect', 'no', + 'connection.permissions', 'brittany', + 'vpn.data', 'vpn-type', 'l2tp', + ]: + assert param in add_args_text + + vpn_data_index = add_args_text.index('vpn.data') + 1 + args_vpn_data = add_args_text[vpn_data_index] + for vpn_data in ['gateway=vpn.example.com', 'password-flags=2', 'user=brittany', 'ipsec-enabled=true', 'ipsec-psk=QnJpdHRhbnkxMjM=']: + assert vpn_data in args_vpn_data + + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed'] + + +@pytest.mark.parametrize('patch_ansible_module', TESTCASE_VPN_PPTP, indirect=['patch_ansible_module']) +def test_create_vpn_pptp(mocked_generic_connection_create, capfd): + """ + Test : Create PPTP VPN connection + """ + + with pytest.raises(SystemExit): + nmcli.main() + + assert nmcli.Nmcli.execute_command.call_count == 1 + arg_list = nmcli.Nmcli.execute_command.call_args_list + add_args, add_kw = arg_list[0] + + assert add_args[0][0] == '/usr/bin/nmcli' + assert add_args[0][1] == 'con' + assert add_args[0][2] == 'add' + assert add_args[0][3] == 'type' + assert add_args[0][4] == 'vpn' + assert add_args[0][5] == 'con-name' + assert add_args[0][6] == 'vpn_pptp' + + add_args_text = list(map(to_text, add_args[0])) + + for param in ['connection.autoconnect', 'no', + 'connection.permissions', 'brittany', + 'vpn.data', 'vpn-type', 'pptp', + ]: + assert param in add_args_text + + vpn_data_index = add_args_text.index('vpn.data') + 1 + args_vpn_data = add_args_text[vpn_data_index] + for vpn_data in ['password-flags=2', 'gateway=vpn.example.com', 'user=brittany']: + assert vpn_data in args_vpn_data + + out, err = capfd.readouterr() + results = json.loads(out) + assert not results.get('failed') + assert results['changed']