From ad1b4203977ce22edef8b028ce05528bf7f5ace9 Mon Sep 17 00:00:00 2001 From: Frederico Gomes Date: Thu, 30 Apr 2026 19:10:16 +0100 Subject: [PATCH] wireguard: Enable FB to connect to a server using IPv6 This MR enables FreedomBox to connect as a "client" to a WireGuard "server" using IPv6. - Validate IPv4/6 with ip_interface - Created helper functions to build NM settings for IPv4/6 - Modify get_settings to include settings for either IP version 4 or 6 - Created helper function to get NM address info - Modify get_nm_info to work with IPv4 and IPv6 - Modified tests to use validate_ip_address_with_network - Added IPv6 valid and invalid patterns to tests Tested: - IPv4 works unchanged - IPv6 parsing + NM settings generation works - IPv6 display in Show Server UI Not tested: - Needs IPv6 WireGuard server for full connectivity test Closes: #1762 Signed-off-by: Frederico Gomes Reviewed-by: James Valleroy --- plinth/modules/wireguard/forms.py | 66 +++++++++++++------- plinth/modules/wireguard/tests/test_forms.py | 23 +++++-- plinth/modules/wireguard/utils.py | 26 ++++++-- 3 files changed, 79 insertions(+), 36 deletions(-) diff --git a/plinth/modules/wireguard/forms.py b/plinth/modules/wireguard/forms.py index 9b4982bf7..d0515bc15 100644 --- a/plinth/modules/wireguard/forms.py +++ b/plinth/modules/wireguard/forms.py @@ -55,14 +55,12 @@ def validate_endpoint(endpoint): raise ValidationError('Invalid endpoint.') -def validate_ipv4_address_with_network(value: str): - """Check that value is a valid IPv4 address with an optional network.""" +def validate_ip_address_with_network(value: str): + """Check that value is a valid IP address with an optional network.""" try: - ipaddress.IPv4Interface(value) - except ipaddress.AddressValueError: - raise ValidationError(_('Enter a valid IPv4 address.')) - except ipaddress.NetmaskValueError: - raise ValidationError(_('Enter a valid network prefix or net mask.')) + ipaddress.ip_interface(value) + except ValueError: + raise ValidationError(_('Not a valid IP address.')) class AddClientForm(forms.Form): @@ -98,10 +96,11 @@ class AddServerForm(forms.Form): help_text=_( 'IP address assigned to this machine on the VPN after connecting ' 'to the endpoint. This value is usually provided by the server ' - 'operator. Example: 192.168.0.10. You can also specify the ' - 'network. This will allow reaching machines in the network. ' + 'operator. Example: 192.168.0.10 or ' + '2a03:7c80:4b2c:91a2:5d41:ffee:9b82:7c17. You can also specify ' + 'the network. This will allow reaching machines in the network. ' 'Examples: 10.68.12.43/24 or 10.68.12.43/255.255.255.0.'), - validators=[validate_ipv4_address_with_network]) + validators=[validate_ip_address_with_network]) private_key = forms.CharField( label=_('Private key of this machine'), strip=True, help_text=_( @@ -125,31 +124,44 @@ class AddServerForm(forms.Form): 'Typically checked for a VPN service through which all traffic ' 'is sent.')) + def _build_ipv4_settings(self, iface) -> dict: + """Build IPv4 NM settings from interfaces.""" + return { + 'method': 'manual', + 'address': str(iface.ip), + 'netmask': str(iface.netmask), + 'gateway': '', + 'dns': '', + 'second_dns': '', + } + + def _build_ipv6_settings(self, iface) -> dict: + """Build IPv6 NM settings from interfaces.""" + return { + 'method': 'manual', + 'address': str(iface.ip), + 'prefix': iface.network.prefixlen, + 'gateway': '', + 'dns': '', + 'second_dns': '', + } + def get_settings(self) -> dict[str, dict]: """Return NM settings dict from cleaned data.""" - ip_address_and_network = self.cleaned_data['ip_address_and_network'] - ip_address_and_network = ipaddress.IPv4Interface( - ip_address_and_network) - ip_address = str(ip_address_and_network.ip) - prefixlen = ip_address_and_network.network.prefixlen + ip_interface = ipaddress.ip_interface( + self.cleaned_data['ip_address_and_network'] + ) + if self.cleaned_data['default_route']: allowed_ips = ['0.0.0.0/0', '::/0'] else: - allowed_ips = [f'{ip_address}/{prefixlen}'] + allowed_ips = [str(ip_interface)] settings = { 'common': { 'type': 'wireguard', 'zone': 'external', }, - 'ipv4': { - 'method': 'manual', - 'address': ip_address, - 'netmask': str(ip_address_and_network.netmask), - 'gateway': '', - 'dns': '', - 'second_dns': '', - }, 'wireguard': { 'peer_endpoint': self.cleaned_data['peer_endpoint'], 'peer_public_key': self.cleaned_data['peer_public_key'], @@ -158,4 +170,10 @@ class AddServerForm(forms.Form): 'allowed_ips': allowed_ips, } } + + if ip_interface.version == 4: + settings['ipv4'] = self._build_ipv4_settings(ip_interface) + else: + settings['ipv6'] = self._build_ipv6_settings(ip_interface) + return settings diff --git a/plinth/modules/wireguard/tests/test_forms.py b/plinth/modules/wireguard/tests/test_forms.py index 780cb8d4a..70f986469 100644 --- a/plinth/modules/wireguard/tests/test_forms.py +++ b/plinth/modules/wireguard/tests/test_forms.py @@ -7,7 +7,7 @@ import pytest from django.core.exceptions import ValidationError from plinth.modules.wireguard.forms import (validate_endpoint, - validate_ipv4_address_with_network, + validate_ip_address_with_network, validate_key) @@ -73,22 +73,33 @@ def test_validate_endpoint_invalid_patterns(endpoint): '1.2.3.4/24', '1.2.3.4/255.255.255.0', '1.2.3.4/0.0.0.255', + '::1', + '2001:db8::1', + '2001:db8::1/64', + 'fe80::1/64', + '::/0', ]) -def test_validate_ipv4_address_with_network_valid_patterns(value): +def test_validate_ip_address_with_network_valid_patterns(value): """Test validating IPv4 address with network works for valid values.""" - validate_ipv4_address_with_network(value) + validate_ip_address_with_network(value) @pytest.mark.parametrize('value', [ - '::1', '1.2.3.4/', 'invalid-ip/24', '1.2.3.4/x', '1.2.3.4/-1', '1.2.3.4/33', '1.2.3.4/9.8.7.6', + '2001:db8::1/', + '2001:db8::1/129', + '2001:db8::1/x', + '2001:db8::1/-1', + '2001:db8::1/255.255.255.0', + '2001:db8::1::1', + '12345::1', ]) -def test_validate_ipv4_address_with_network_invalid_patterns(value): +def test_validate_ip_address_with_network_invalid_patterns(value): """Test validating IPv4 address with network works for invalid values.""" with pytest.raises(ValidationError): - validate_ipv4_address_with_network(value) + validate_ip_address_with_network(value) diff --git a/plinth/modules/wireguard/utils.py b/plinth/modules/wireguard/utils.py index 7e632c0dd..0fe35c324 100644 --- a/plinth/modules/wireguard/utils.py +++ b/plinth/modules/wireguard/utils.py @@ -19,6 +19,18 @@ IP_TEMPLATE = '10.84.0.{}' logger = logging.getLogger(__name__) +def _get_nm_address_info(settings_ipv4, settings_ipv6) -> tuple[str, str]: + """Extract IP address info from NM IPv4/IPv6 settings.""" + for settings in [settings_ipv4, settings_ipv6]: + if settings and settings.get_num_addresses(): + nm_address = settings.get_address(0) + address = nm_address.get_address() + prefix = str(nm_address.get_prefix()) + return address, address + '/' + prefix + + return '', '' + + def get_nm_info(): """Get information from network manager.""" setting_name = nm.SETTING_WIREGUARD_SETTING_NAME @@ -64,12 +76,14 @@ def get_nm_info(): info['default_route'] = True settings_ipv4 = connection.get_setting_ip4_config() - if settings_ipv4 and settings_ipv4.get_num_addresses(): - nm_address = settings_ipv4.get_address(0) - address = nm_address.get_address() - prefix = str(nm_address.get_prefix()) - info['ip_address'] = address - info['ip_address_and_network'] = address + '/' + prefix + settings_ipv6 = connection.get_setting_ip6_config() + + ip_address, ip_address_and_network = _get_nm_address_info( + settings_ipv4, settings_ipv6 + ) + + info['ip_address'] = ip_address + info['ip_address_and_network'] = ip_address_and_network connections[info['interface']] = info