Repository: ansible-collections/community.general

Issue:
Please add wireguard connection type for nmcli module
### Summary

Hi,

I would like to use the `nmcli` module to create Wireguard connections, but it does not seem to be possible yet (`wireguard` is not valid for the `type` parameter). I believe Wireguard is widely used.

### Issue Type

Feature Idea

### Component Name

nmcli

### Additional Information

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)
- name: add Wireguard connection
  community.general.nmcli:
    type: wireguard
    autoconnect: false
    conn_name: wg_example
    wg-private-key: "{ my_private_key }"
    wg-private-key-policy: "always_ask"
    wg-peer:
      wg-public-key: "{ peer_public_key }"
      wg-peer-ip: "{ peer_ip }"
    ip4: 10.0.0.2/24
    method6: disabled
    state: present
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/net_tools/nmcli.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/net_tools/nmcli.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @alcamie101
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/plugins/modules/net_tools/nmcli.py b/plugins/modules/net_tools/nmcli.py
--- a/plugins/modules/net_tools/nmcli.py
+++ b/plugins/modules/net_tools/nmcli.py
@@ -55,8 +55,10 @@
             - 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: str
-        choices: [ bond, bond-slave, bridge, bridge-slave, dummy, ethernet, generic, gre, infiniband, ipip, sit, team, team-slave, vlan, vxlan, wifi, gsm ]
+        choices: [ bond, bond-slave, bridge, bridge-slave, dummy, ethernet, generic, gre, infiniband, ipip, sit, team, team-slave, vlan, vxlan, wifi, gsm,
+            wireguard ]
     mode:
         description:
             - This is the type of device or network connection that you wish to create for a bond or bridge.
@@ -754,6 +756,62 @@
                     - The username used to authenticate with the network, if required.
                     - Many providers do not require a username, or accept any username.
                     - But if a username is required, it is specified here.
+    wireguard:
+        description:
+            - The configuration of the Wireguard connection.
+            - Note the list of suboption attributes may vary depending on which version of NetworkManager/nmcli is installed on the host.
+            - 'An up-to-date list of supported attributes can be found here:
+              U(https://networkmanager.dev/docs/api/latest/settings-wireguard.html).'
+            - 'For instance to configure a listen port:
+              C({listen-port: 12345}).'
+        type: dict
+        version_added: 4.3.0
+        suboptions:
+            fwmark:
+                description:
+                    - The 32-bit fwmark for outgoing packets.
+                    - The use of fwmark is optional and is by default off. Setting it to 0 disables it.
+                    - Note that I(wireguard.ip4-auto-default-route) or I(wireguard.ip6-auto-default-route) enabled, implies to automatically choose a fwmark.
+                type: int
+            ip4-auto-default-route:
+                description:
+                    - Whether to enable special handling of the IPv4 default route.
+                    - If enabled, the IPv4 default route from I(wireguard.peer-routes) will be placed to a dedicated routing-table and two policy
+                        routing rules will be added.
+                    - The fwmark number is also used as routing-table for the default-route, and if fwmark is zero, an unused fwmark/table is chosen
+                        automatically. This corresponds to what wg-quick does with Table=auto and what WireGuard calls "Improved Rule-based Routing"
+                type: bool
+            ip6-auto-default-route:
+                description:
+                    - Like I(wireguard.ip4-auto-default-route), but for the IPv6 default route.
+                type: bool
+            listen-port:
+                description: The WireGuard connection listen-port. If not specified, the port will be chosen randomly when the
+                    interface comes up.
+                type: int
+            mtu:
+                description:
+                    - If non-zero, only transmit packets of the specified size or smaller, breaking larger packets up into multiple fragments.
+                    - If zero a default MTU is used. Note that contrary to wg-quick's MTU setting, this does not take into account the current routes
+                        at the time of activation.
+                type: int
+            peer-routes:
+                description:
+                    - Whether to automatically add routes for the AllowedIPs ranges of the peers.
+                    - If C(true) (the default), NetworkManager will automatically add routes in the routing tables according to C(ipv4.route-table) and
+                        C(ipv6.route-table). Usually you want this automatism enabled.
+                    - If C(false), no such routes are added automatically. In this case, the user may want to configure static routes in C(ipv4.routes)
+                        and C(ipv6.routes), respectively.
+                    - Note that if the peer's AllowedIPs is C(0.0.0.0/0) or C(::/0) and the profile's C(ipv4.never-default) or C(ipv6.never-default)
+                        setting is enabled, the peer route for this peer won't be added automatically.
+                type: bool
+            private-key:
+                description: The 256 bit private-key in base64 encoding.
+                type: str
+            private-key-flags:
+                description: C(NMSettingSecretFlags) indicating how to handle the I(wireguard.private-key) property.
+                type: int
+                choices: [ 0, 1, 2 ]
 '''
 
 EXAMPLES = r'''
@@ -1126,6 +1184,17 @@
     autoconnect: true
     state: present
 
+- name: Create a wireguard connection
+  community.general.nmcli:
+    type: wireguard
+    conn_name: my-wg-provider
+    ifname: mywg0
+    wireguard:
+        listen-port: 51820
+        private-key: my-private-key
+    autoconnect: true
+    state: present
+
 '''
 
 RETURN = r"""#
@@ -1236,10 +1305,11 @@ def __init__(self, module):
         self.wifi = module.params['wifi']
         self.wifi_sec = module.params['wifi_sec']
         self.gsm = module.params['gsm']
+        self.wireguard = module.params['wireguard']
 
         if self.method4:
             self.ipv4_method = self.method4
-        elif self.type == 'dummy' and not self.ip4:
+        elif self.type in ('dummy', 'wireguard') and not self.ip4:
             self.ipv4_method = 'disabled'
         elif self.ip4:
             self.ipv4_method = 'manual'
@@ -1248,7 +1318,7 @@ def __init__(self, module):
 
         if self.method6:
             self.ipv6_method = self.method6
-        elif self.type == 'dummy' and not self.ip6:
+        elif self.type in ('dummy', 'wireguard') and not self.ip6:
             self.ipv6_method = 'disabled'
         elif self.ip6:
             self.ipv6_method = 'manual'
@@ -1404,6 +1474,12 @@ def connection_options(self, detect_change=False):
                     options.update({
                         'gsm.%s' % name: value,
                     })
+        elif self.type == 'wireguard':
+            if self.wireguard:
+                for name, value in self.wireguard.items():
+                    options.update({
+                        'wireguard.%s' % name: value,
+                    })
         # Convert settings values based on the situation.
         for setting, value in options.items():
             setting_type = self.settings_type(setting)
@@ -1445,6 +1521,7 @@ def ip_conn_type(self):
             'vlan',
             'wifi',
             'gsm',
+            'wireguard',
         )
 
     @property
@@ -1834,6 +1911,7 @@ def main():
                           'vxlan',
                           'wifi',
                           'gsm',
+                          'wireguard',
                       ]),
             ip4=dict(type='list', elements='str'),
             gw4=dict(type='str'),
@@ -1907,6 +1985,7 @@ def main():
             wifi=dict(type='dict'),
             wifi_sec=dict(type='dict', no_log=True),
             gsm=dict(type='dict'),
+            wireguard=dict(type='dict'),
         ),
         mutually_exclusive=[['never_default4', 'gw4']],
         required_if=[("type", "wifi", [("ssid")])],


Test patch:
diff --git a/tests/unit/plugins/modules/net_tools/test_nmcli.py b/tests/unit/plugins/modules/net_tools/test_nmcli.py
--- a/tests/unit/plugins/modules/net_tools/test_nmcli.py
+++ b/tests/unit/plugins/modules/net_tools/test_nmcli.py
@@ -92,6 +92,12 @@
         'state': 'absent',
         '_ansible_check_mode': True,
     },
+    {
+        'type': 'wireguard',
+        'conn_name': 'non_existent_nw_device',
+        'state': 'absent',
+        '_ansible_check_mode': True,
+    },
 ]
 
 TESTCASE_GENERIC = [
@@ -900,6 +906,49 @@
 gsm.mtu:                                auto
 """
 
+TESTCASE_WIREGUARD = [
+    {
+        'type': 'wireguard',
+        'conn_name': 'non_existent_nw_device',
+        'ifname': 'wg_non_existant',
+        'wireguard': {
+            'listen-port': '51820',
+            'private-key': '<hidden>',
+        },
+        'method4': 'manual',
+        'ip4': '10.10.10.10/24',
+        'method6': 'manual',
+        'ip6': '2001:db8::1/128',
+        'state': 'present',
+        '_ansible_check_mode': False,
+    }
+]
+
+TESTCASE_WIREGUARD_SHOW_OUTPUT = """\
+connection.id:                          non_existent_nw_device
+connection.type:                        wireguard
+connection.interface-name:              wg_non_existant
+connection.autoconnect:                 yes
+ipv4.method:                            manual
+ipv4.addresses:                         10.10.10.10/24
+ipv4.never-default:                     no
+ipv4.may-fail:                          yes
+ipv4.ignore-auto-dns:                   no
+ipv4.ignore-auto-routes:                no
+ipv6.method:                            manual
+ipv6.addresses:                         2001:db8::1/128
+ipv6.ignore-auto-dns:                   no
+ipv6.ignore-auto-routes:                no
+wireguard.private-key:                  <hidden>
+wireguard.private-key-flags:            0 (none)
+wireguard.listen-port:                  51820
+wireguard.fwmark:                       0x0
+wireguard.peer-routes:                  yes
+wireguard.mtu:                          0
+wireguard.ip4-auto-default-route:       -1 (default)
+wireguard.ip6-auto-default-route:       -1 (default)
+"""
+
 
 def mocker_set(mocker,
                connection_exists=False,
@@ -1201,6 +1250,13 @@ def mocked_gsm_connection_unchanged(mocker):
                execute_return=(0, TESTCASE_GSM_SHOW_OUTPUT, ""))
 
 
+@pytest.fixture
+def mocked_wireguard_connection_unchanged(mocker):
+    mocker_set(mocker,
+               connection_exists=True,
+               execute_return=(0, TESTCASE_WIREGUARD_SHOW_OUTPUT, ""))
+
+
 @pytest.mark.parametrize('patch_ansible_module', TESTCASE_BOND, indirect=['patch_ansible_module'])
 def test_bond_connection_create(mocked_generic_connection_create, capfd):
     """
@@ -2828,3 +2884,81 @@ def test_ethernet_connection_static_with_mulitple_ip4_addresses_unchanged(mocked
     results = json.loads(out)
     assert not results.get('failed')
     assert not results['changed']
+
+
+@pytest.mark.parametrize('patch_ansible_module', TESTCASE_WIREGUARD, indirect=['patch_ansible_module'])
+def test_create_wireguard(mocked_generic_connection_create, capfd):
+    """
+    Test : Create wireguard connection with static IP configuration
+    """
+
+    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] == 'wireguard'
+    assert add_args[0][5] == 'con-name'
+    assert add_args[0][6] == 'non_existent_nw_device'
+
+    add_args_text = list(map(to_text, add_args[0]))
+    for param in ['connection.interface-name', 'wg_non_existant',
+                  'ipv4.method', 'manual',
+                  'ipv4.addresses', '10.10.10.10/24',
+                  'ipv6.method', 'manual',
+                  'ipv6.addresses', '2001:db8::1/128',
+                  'wireguard.listen-port', '51820',
+                  'wireguard.private-key', '<hidden>']:
+        assert param in add_args_text
+
+    out, err = capfd.readouterr()
+    results = json.loads(out)
+    assert not results.get('failed')
+    assert results['changed']
+
+
+@pytest.mark.parametrize('patch_ansible_module', TESTCASE_WIREGUARD, indirect=['patch_ansible_module'])
+def test_wireguard_connection_unchanged(mocked_wireguard_connection_unchanged, capfd):
+    """
+    Test : Wireguard connection with static IP configuration 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_WIREGUARD, indirect=['patch_ansible_module'])
+def test_wireguard_mod(mocked_generic_connection_modify, capfd):
+    """
+    Test : Modify wireguard connection
+    """
+    with pytest.raises(SystemExit):
+        nmcli.main()
+
+    assert nmcli.Nmcli.execute_command.call_count == 1
+    arg_list = nmcli.Nmcli.execute_command.call_args_list
+    args, kwargs = arg_list[0]
+
+    assert args[0][0] == '/usr/bin/nmcli'
+    assert args[0][1] == 'con'
+    assert args[0][2] == 'modify'
+    assert args[0][3] == 'non_existent_nw_device'
+
+    args_text = list(map(to_text, args[0]))
+    for param in ['wireguard.listen-port', '51820']:
+        assert param in args_text
+
+    out, err = capfd.readouterr()
+    results = json.loads(out)
+    assert not results.get('failed')
+    assert results['changed']