wireguard: Accept/use netmask with IP address for server connection

- Currently, the value is hard-coded as /24. Instead take this as input and use
that value.

Tests:

- Entering invalid IPv4 address results in 'Enter a valid IPv4 address' error
message during form submission.

- Entering invalid prefix such as /33 results in 'Enter a valid network prefix
or net mask.' error during form submission.

- Both /32 and /255.255.255.255 formats are accepted.

- The description text for the form field 'IP address' is as expected.

- Changing the value of default route and IP address + netmask reflects in the
status page. Correct values is shown in the edit server and server status page.

- Not providing a netmask results in /32 being assigned.

- Unit and functional tests for wireguard pass. There are some intermittent
failures with functional tests that are unrelated to the patch.

- Setting the /32 prefix results in correct routing table as shown by 'ip route
show table all'. No default routes are network routes are present. 'traceroute
1.1.1.1' shows route taken via regular network.

- Setting the /24 prefix results in correct routing table. No default routes are
present. However, for the /24 network a route is present with device wg1.
'traceroute 1.1.1.1' shows route taken via regular network.

- Enabling the default route results in correct routing table. Default route is
shown for device wg1 with high priority. 'traceroute 1.1.1.1' shows route taken
via WireGuard network.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2026-02-26 22:54:51 -08:00 committed by James Valleroy
parent ad9ebe2301
commit 4b24fda3f5
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
7 changed files with 83 additions and 30 deletions

View File

@ -5,10 +5,10 @@ Forms for wireguard module.
import base64 import base64
import binascii import binascii
import ipaddress
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import validate_ipv4_address
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
KEY_LENGTH = 32 KEY_LENGTH = 32
@ -55,6 +55,16 @@ def validate_endpoint(endpoint):
raise ValidationError('Invalid 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."""
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.'))
class AddClientForm(forms.Form): class AddClientForm(forms.Form):
"""Form to add client.""" """Form to add client."""
public_key = forms.CharField( public_key = forms.CharField(
@ -78,12 +88,15 @@ class AddServerForm(forms.Form):
'Example: MConEJFIg6+DFHg2J1nn9SNLOSE9KR0ysdPgmPjibEs= .'), 'Example: MConEJFIg6+DFHg2J1nn9SNLOSE9KR0ysdPgmPjibEs= .'),
validators=[validate_key]) validators=[validate_key])
ip_address = forms.CharField( ip_address_and_network = forms.CharField(
label=_('Client IP address provided by server'), strip=True, label=_('Client IP address provided by server'), strip=True,
help_text=_('IP address assigned to this machine on the VPN after ' help_text=_(
'connecting to the endpoint. This value is usually ' 'IP address assigned to this machine on the VPN after connecting '
'provided by the server operator. Example: 192.168.0.10.'), 'to the endpoint. This value is usually provided by the server '
validators=[validate_ipv4_address]) 'operator. Example: 192.168.0.10. 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])
private_key = forms.CharField( private_key = forms.CharField(
label=_('Private key of this machine'), strip=True, help_text=_( label=_('Private key of this machine'), strip=True, help_text=_(
@ -107,9 +120,18 @@ class AddServerForm(forms.Form):
'Typically checked for a VPN service through which all traffic ' 'Typically checked for a VPN service through which all traffic '
'is sent.')) 'is sent.'))
def get_settings(self): def get_settings(self) -> dict[str, dict]:
"""Return NM settings dict from cleaned data.""" """Return NM settings dict from cleaned data."""
ip_address = self.cleaned_data['ip_address'] 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
if self.cleaned_data['default_route']:
allowed_ips = ['0.0.0.0/0', '::/0']
else:
allowed_ips = [f'{ip_address}/{prefixlen}']
settings = { settings = {
'common': { 'common': {
'type': 'wireguard', 'type': 'wireguard',
@ -118,7 +140,7 @@ class AddServerForm(forms.Form):
'ipv4': { 'ipv4': {
'method': 'manual', 'method': 'manual',
'address': ip_address, 'address': ip_address,
'netmask': '255.255.255.0', 'netmask': str(ip_address_and_network.netmask),
'gateway': '', 'gateway': '',
'dns': '', 'dns': '',
'second_dns': '', 'second_dns': '',
@ -126,10 +148,9 @@ class AddServerForm(forms.Form):
'wireguard': { 'wireguard': {
'peer_endpoint': self.cleaned_data['peer_endpoint'], 'peer_endpoint': self.cleaned_data['peer_endpoint'],
'peer_public_key': self.cleaned_data['peer_public_key'], 'peer_public_key': self.cleaned_data['peer_public_key'],
'ip_address': ip_address,
'private_key': self.cleaned_data['private_key'], 'private_key': self.cleaned_data['private_key'],
'preshared_key': self.cleaned_data['preshared_key'], 'preshared_key': self.cleaned_data['preshared_key'],
'default_route': self.cleaned_data['default_route'], 'allowed_ips': allowed_ips,
} }
} }
return settings return settings

View File

@ -36,9 +36,9 @@
<th>{% trans "Public key of this machine:" %}</th> <th>{% trans "Public key of this machine:" %}</th>
<td>{{ server.public_key }}</td> <td>{{ server.public_key }}</td>
</tr> </tr>
<tr class="server-ip-address"> <tr class="server-ip-address-and-network">
<th>{% trans "IP address of this machine:" %}</th> <th>{% trans "IP address of this machine:" %}</th>
<td>{{ server.ip_address }}</td> <td>{{ server.ip_address_and_network }}</td>
</tr> </tr>
<tr class="server-default-route"> <tr class="server-default-route">
<th> <th>

View File

@ -6,7 +6,9 @@ Tests for wireguard module forms.
import pytest import pytest
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from plinth.modules.wireguard.forms import validate_endpoint, validate_key from plinth.modules.wireguard.forms import (validate_endpoint,
validate_ipv4_address_with_network,
validate_key)
@pytest.mark.parametrize('key', [ @pytest.mark.parametrize('key', [
@ -62,3 +64,31 @@ def test_validate_endpoint_invalid_patterns(endpoint):
"""Test that invalid wireguard endpoint patterns are rejected.""" """Test that invalid wireguard endpoint patterns are rejected."""
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
validate_endpoint(endpoint) validate_endpoint(endpoint)
@pytest.mark.parametrize('value', [
'1.2.3.4',
'1.2.3.4/0',
'1.2.3.4/32',
'1.2.3.4/24',
'1.2.3.4/255.255.255.0',
'1.2.3.4/0.0.0.255',
])
def test_validate_ipv4_address_with_network_valid_patterns(value):
"""Test validating IPv4 address with network works for valid values."""
validate_ipv4_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',
])
def test_validate_ipv4_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)

View File

@ -23,14 +23,14 @@ class TestWireguardApp(functional.BaseAppTests):
{ {
'peer_endpoint': 'wg1.example.org:1234', 'peer_endpoint': 'wg1.example.org:1234',
'peer_public_key': 'HBCqZk4B93N6q19zNleJkAVs+PEfWAPgPpKnrhL/CVw=', 'peer_public_key': 'HBCqZk4B93N6q19zNleJkAVs+PEfWAPgPpKnrhL/CVw=',
'ip_address': '10.0.0.2', 'ip_address_and_network': '10.0.0.2/32',
'private_key': '', 'private_key': '',
'preshared_key': '' 'preshared_key': ''
}, },
{ {
'peer_endpoint': 'wg2.example.org:5678', 'peer_endpoint': 'wg2.example.org:5678',
'peer_public_key': 'Z/iHo0vaeSN8Ykk5KwhQ819MMU5nyzD7y7xFFthlxXI=', 'peer_public_key': 'Z/iHo0vaeSN8Ykk5KwhQ819MMU5nyzD7y7xFFthlxXI=',
'ip_address': '192.168.0.2', 'ip_address_and_network': '192.168.0.2/24',
'private_key': 'QC2xEZMn3bgNsSVFrU51+ALSUiUaWg6gRWigh3EeVm0=', 'private_key': 'QC2xEZMn3bgNsSVFrU51+ALSUiUaWg6gRWigh3EeVm0=',
'preshared_key': 'AHxZ4Rr8Ij4L1aq+ceusSIgBfluqiI9Vb5I2UtQFanI=' 'preshared_key': 'AHxZ4Rr8Ij4L1aq+ceusSIgBfluqiI9Vb5I2UtQFanI='
}, },
@ -70,7 +70,8 @@ class TestWireguardApp(functional.BaseAppTests):
# Start the server on FreedomBox, if needed. # Start the server on FreedomBox, if needed.
start_server_button = browser.find_by_css('.btn-start-server') start_server_button = browser.find_by_css('.btn-start-server')
if start_server_button: if start_server_button:
start_server_button.first.click() with functional.wait_for_page_update(browser):
start_server_button.first.click()
browser.find_by_css('.btn-add-client').first.click() browser.find_by_css('.btn-add-client').first.click()
browser.find_by_id('id_public_key').fill(key) browser.find_by_id('id_public_key').fill(key)
@ -135,7 +136,8 @@ class TestWireguardApp(functional.BaseAppTests):
href.first.click() href.first.click()
assert get_value('peer-endpoint') == config['peer_endpoint'] assert get_value('peer-endpoint') == config['peer_endpoint']
assert get_value('peer-public-key') == config['peer_public_key'] assert get_value('peer-public-key') == config['peer_public_key']
assert get_value('server-ip-address') == config['ip_address'] assert get_value('server-ip-address-and-network'
) == config['ip_address_and_network']
assert get_value('peer-preshared-key') == (config['preshared_key'] assert get_value('peer-preshared-key') == (config['preshared_key']
or 'None') or 'None')
@ -147,7 +149,8 @@ class TestWireguardApp(functional.BaseAppTests):
browser.find_by_id('id_peer_endpoint').fill(config['peer_endpoint']) browser.find_by_id('id_peer_endpoint').fill(config['peer_endpoint'])
browser.find_by_id('id_peer_public_key').fill( browser.find_by_id('id_peer_public_key').fill(
config['peer_public_key']) config['peer_public_key'])
browser.find_by_id('id_ip_address').fill(config['ip_address']) browser.find_by_id('id_ip_address_and_network').fill(
config['ip_address_and_network'])
browser.find_by_id('id_private_key').fill(config['private_key']) browser.find_by_id('id_private_key').fill(config['private_key'])
browser.find_by_id('id_preshared_key').fill(config['preshared_key']) browser.find_by_id('id_preshared_key').fill(config['preshared_key'])
functional.submit(browser, form_class='form-add-server') functional.submit(browser, form_class='form-add-server')
@ -161,7 +164,8 @@ class TestWireguardApp(functional.BaseAppTests):
browser.find_by_id('id_peer_endpoint').fill(config2['peer_endpoint']) browser.find_by_id('id_peer_endpoint').fill(config2['peer_endpoint'])
browser.find_by_id('id_peer_public_key').fill( browser.find_by_id('id_peer_public_key').fill(
config2['peer_public_key']) config2['peer_public_key'])
browser.find_by_id('id_ip_address').fill(config2['ip_address']) browser.find_by_id('id_ip_address_and_network').fill(
config2['ip_address_and_network'])
browser.find_by_id('id_private_key').fill(config2['private_key']) browser.find_by_id('id_private_key').fill(config2['private_key'])
browser.find_by_id('id_preshared_key').fill(config2['preshared_key']) browser.find_by_id('id_preshared_key').fill(config2['preshared_key'])
functional.submit(browser, form_class='form-edit-server') functional.submit(browser, form_class='form-edit-server')

View File

@ -65,7 +65,9 @@ def get_nm_info():
settings_ipv4 = connection.get_setting_ip4_config() settings_ipv4 = connection.get_setting_ip4_config()
if settings_ipv4 and settings_ipv4.get_num_addresses(): if settings_ipv4 and settings_ipv4.get_num_addresses():
info['ip_address'] = settings_ipv4.get_address(0).get_address() address = settings_ipv4.get_address(0)
info['ip_address_and_network'] = (address.get_address() + '/' +
str(address.get_prefix()))
connections[info['interface']] = info connections[info['interface']] = info

View File

@ -101,8 +101,7 @@ class ShowClientView(SuccessMessageMixin, TemplateView):
context['client'] = server_info['peers'][public_key] context['client'] = server_info['peers'][public_key]
context['endpoints'] = [ context['endpoints'] = [
domain + ':' + str(server_info['listen_port']) domain + ':' + str(server_info['listen_port'])
for domain in domains for domain in domains if not domain.endswith('.local')
if not domain.endswith('.local')
] ]
return context return context
@ -227,7 +226,8 @@ class EditServerView(SuccessMessageMixin, FormView):
if not server: if not server:
raise Http404 raise Http404
initial['ip_address'] = server.get('ip_address') initial['ip_address_and_network'] = server.get(
'ip_address_and_network')
if server['peers']: if server['peers']:
peer = next(peer for peer in server['peers'].values()) peer = next(peer for peer in server['peers'].values())
initial['peer_endpoint'] = peer['endpoint'] initial['peer_endpoint'] = peer['endpoint']

View File

@ -507,12 +507,8 @@ def _update_wireguard_settings(connection, wireguard):
peer.set_preshared_key_flags(nm.SettingSecretFlags.NONE) peer.set_preshared_key_flags(nm.SettingSecretFlags.NONE)
peer.set_preshared_key(wireguard['preshared_key'], False) peer.set_preshared_key(wireguard['preshared_key'], False)
if wireguard['default_route']: for allowed_ip in wireguard['allowed_ips']:
peer.append_allowed_ip('0.0.0.0/0', False) peer.append_allowed_ip(allowed_ip, False)
peer.append_allowed_ip('::/0', False)
else:
ip_addr = wireguard['ip_address']
peer.append_allowed_ip(f'{ip_addr}/24', False)
settings.clear_peers() settings.clear_peers()
settings.append_peer(peer) settings.append_peer(peer)