Skip to content

Commit a946356

Browse files
committed
[fix] Do not allow applying more than 1 templates of same VPN server #832
Fixes #832
1 parent 04b3ddb commit a946356

4 files changed

Lines changed: 173 additions & 1 deletion

File tree

openwisp_controller/config/base/config.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import collections
22
import logging
33
import re
4+
from collections import defaultdict
45

56
from cache_memoize import cache_memoize
67
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
@@ -185,6 +186,55 @@ def _get_templates_from_pk_set(cls, pk_set):
185186
templates = pk_set
186187
return templates
187188

189+
@classmethod
190+
def validate_duplicate_vpn_templates(
191+
cls, action, instance, templates, raw_data=None
192+
):
193+
"""
194+
Validates if there are duplicate templates for the same VPN server.
195+
Raises a ValidationError if duplicates are found.
196+
"""
197+
if action != 'pre_add':
198+
return
199+
200+
def format_template_list(names):
201+
quoted = [f'"{name}"' for name in names]
202+
if len(quoted) == 2:
203+
return ' and '.join(quoted)
204+
return ', '.join(quoted[:-1]) + ' and ' + quoted[-1]
205+
206+
def add_vpn_templates(templates_queryset):
207+
for template in templates_queryset.filter(type='vpn'):
208+
if template.name not in vpn_templates[template.vpn.name]:
209+
vpn_templates[template.vpn.name].append(template.name)
210+
211+
raw_data = raw_data or {}
212+
vpn_templates = defaultdict(list)
213+
if not raw_data:
214+
# When raw_data is present, validation is triggered by a
215+
# ConfigForm submission.
216+
# In this case, the "templates" queryset already contains only the templates
217+
# that are intended to be assigned. Templates that would be removed
218+
# (e.g., in a pre_clear action) have already been excluded from the
219+
# queryset.
220+
add_vpn_templates(instance.templates)
221+
add_vpn_templates(templates)
222+
223+
error_lines = [
224+
'You cannot select multiple VPN client templates related to'
225+
' the same VPN server.'
226+
]
227+
for vpn_name, template_names in vpn_templates.items():
228+
if len(template_names) < 2:
229+
continue
230+
template_list = format_template_list(sorted(template_names))
231+
error_lines.append(
232+
f'The templates {template_list} are all linked'
233+
f' to the same VPN server: "{vpn_name}".'
234+
)
235+
if len(error_lines) > 1:
236+
raise ValidationError('\n'.join(error_lines))
237+
188238
@classmethod
189239
def clean_templates(cls, action, instance, pk_set, raw_data=None, **kwargs):
190240
"""
@@ -203,6 +253,9 @@ def clean_templates(cls, action, instance, pk_set, raw_data=None, **kwargs):
203253
)
204254
if not templates:
205255
return
256+
cls.validate_duplicate_vpn_templates(
257+
action, instance, templates, raw_data=raw_data
258+
)
206259
backend = instance.get_backend_instance(template_instances=templates)
207260
try:
208261
cls.clean_netjsonconfig_backend(backend)

openwisp_controller/config/tests/test_api.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1505,6 +1505,76 @@ def test_device_detail_api_change_config(self):
15051505
self.assertEqual(response.status_code, 200)
15061506
self.assertEqual(device.config.templates.count(), 0)
15071507

1508+
def test_multiple_vpn_templates_same_vpn(self):
1509+
"""
1510+
Ensure that assigning multiple templates of type 'vpn' referencing the same VPN
1511+
to a device's config does not create duplicate VpnClient objects.
1512+
"""
1513+
org = self._get_org()
1514+
vpn = self._create_vpn(organization=org)
1515+
# Create two templates of type 'vpn' referencing the same VPN
1516+
vpn_template1 = self._create_template(
1517+
type='vpn', vpn=vpn, organization=org, name='VPN Client 1'
1518+
)
1519+
vpn_template2 = self._create_template(
1520+
type='vpn', vpn=vpn, organization=org, name='VPN Client 2'
1521+
)
1522+
device = self._create_device(organization=org)
1523+
config = self._create_config(device=device)
1524+
path = reverse('config_api:device_detail', args=[device.pk])
1525+
data = {
1526+
'name': device.name,
1527+
'organization': str(org.id),
1528+
'mac_address': device.mac_address,
1529+
'config': {
1530+
'backend': 'netjsonconfig.OpenWrt',
1531+
'context': {'lan_ip': '192.168.1.1'},
1532+
'config': {'interfaces': [{'name': 'wlan0', 'type': 'wireless'}]},
1533+
},
1534+
}
1535+
expected_error_message = (
1536+
'You cannot select multiple VPN client templates related'
1537+
' to the same VPN server.\n'
1538+
'The templates "VPN Client 1" and "VPN Client 2" are all '
1539+
'linked to the same VPN server: "test".'
1540+
)
1541+
1542+
with self.subTest('Add both templates at once'):
1543+
data['config']['templates'] = [str(vpn_template1.pk), str(vpn_template2.pk)]
1544+
response = self.client.put(path, data, content_type='application/json')
1545+
self.assertEqual(response.status_code, 400)
1546+
self.assertEqual(
1547+
str(response.data['config'][0]),
1548+
expected_error_message,
1549+
)
1550+
self.assertEqual(config.templates.count(), 0)
1551+
self.assertEqual(config.vpnclient_set.count(), 0)
1552+
1553+
with self.subTest('Add one template at a time'):
1554+
data['config']['templates'] = [str(vpn_template1.pk)]
1555+
response = self.client.put(path, data, content_type='application/json')
1556+
self.assertEqual(response.status_code, 200)
1557+
self.assertEqual(config.templates.count(), 1)
1558+
self.assertEqual(config.vpnclient_set.count(), 1)
1559+
1560+
# Now add the second template
1561+
data['config']['templates'] = [str(vpn_template1.pk), str(vpn_template2.pk)]
1562+
response = self.client.put(path, data, content_type='application/json')
1563+
self.assertEqual(response.status_code, 400)
1564+
self.assertEqual(
1565+
str(response.data['config'][0]),
1566+
expected_error_message,
1567+
)
1568+
self.assertEqual(config.templates.filter(id=vpn_template1.id).count(), 1)
1569+
self.assertEqual(config.vpnclient_set.count(), 1)
1570+
1571+
with self.subTest('Change existing template with another'):
1572+
data['config']['templates'] = [str(vpn_template2.pk)]
1573+
response = self.client.put(path, data, content_type='application/json')
1574+
self.assertEqual(response.status_code, 200)
1575+
self.assertEqual(config.templates.filter(id=vpn_template2.id).count(), 1)
1576+
self.assertEqual(config.vpnclient_set.count(), 1)
1577+
15081578
def test_device_patch_with_templates_of_same_org(self):
15091579
org1 = self._create_org(name='testorg')
15101580
d1 = self._create_device(name='org1-config', organization=org1)

openwisp_controller/config/tests/test_config.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,55 @@ class TestTransactionConfig(
892892
TestVpnX509Mixin,
893893
TransactionTestCase,
894894
):
895+
def test_multiple_vpn_templates_same_vpn(self):
896+
vpn1 = self._create_vpn(name='vpn1')
897+
vpn2 = self._create_vpn(name='vpn2')
898+
vpn1_template1 = self._create_template(
899+
name='vpn1-template1', type='vpn', vpn=vpn1
900+
)
901+
vpn1_template2 = self._create_template(
902+
name='vpn1-template2', type='vpn', vpn=vpn1
903+
)
904+
vpn2_template1 = self._create_template(
905+
name='vpn2-template1', type='vpn', vpn=vpn2
906+
)
907+
vpn2_template2 = self._create_template(
908+
name='vpn2-template2', type='vpn', vpn=vpn2
909+
)
910+
vpn2_template3 = self._create_template(
911+
name='vpn2-template3', type='vpn', vpn=vpn2
912+
)
913+
config = self._create_config(device=self._create_device())
914+
config.templates.add(vpn1_template1)
915+
with self.subTest('Adding template one by one'):
916+
with self.assertRaises(ValidationError) as context_manager:
917+
config.templates.add(vpn1_template2)
918+
self.assertEqual(
919+
context_manager.exception.message,
920+
'You cannot select multiple VPN client templates related to the'
921+
' same VPN server.\n'
922+
'The templates "vpn1-template1" and "vpn1-template2" are all'
923+
' linked to the same VPN server: "vpn1".',
924+
)
925+
926+
with self.subTest('Add duplicate templates for multiple VPN'):
927+
config.refresh_from_db()
928+
self.assertEqual(config.templates.count(), 1)
929+
self.assertEqual(config.vpnclient_set.count(), 1)
930+
with self.assertRaises(ValidationError) as context_manager:
931+
config.templates.add(
932+
vpn1_template2, vpn2_template1, vpn2_template2, vpn2_template3
933+
)
934+
self.assertEqual(
935+
context_manager.exception.message,
936+
'You cannot select multiple VPN client templates related to the'
937+
' same VPN server.\n'
938+
'The templates "vpn1-template1" and "vpn1-template2" are all'
939+
' linked to the same VPN server: "vpn1".\n'
940+
'The templates "vpn2-template1", "vpn2-template2" and "vpn2-template3"'
941+
' are all linked to the same VPN server: "vpn2".',
942+
)
943+
895944
def test_certificate_renew_invalidates_checksum_cache(self):
896945
config = self._create_config(organization=self._get_org())
897946
vpn_template = self._create_template(

openwisp_controller/config/tests/test_vpn.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ def test_vpn_client_deletion(self):
249249
def _assert_vpn_client_cert(cert, vpn_client, cert_ct, vpn_client_ct):
250250
self.assertEqual(Cert.objects.filter(pk=cert.pk).count(), 1)
251251
self.assertEqual(VpnClient.objects.filter(pk=vpn_client.pk).count(), 1)
252-
vpnclient.delete()
252+
c.templates.remove(t)
253253
self.assertEqual(
254254
Cert.objects.filter(pk=cert.pk, revoked=False).count(), cert_ct
255255
)

0 commit comments

Comments
 (0)