Skip to content

Commit 4d06878

Browse files
committed
[fix] Ensure VpnClient.post_delete fires on VPN template removal openwisp#1221
Replaced QuerySet.delete() with per-instance deletion in all three locations where VpnClient objects are bulk-deleted so that post_delete signals fire for every instance, ensuring: - VPN peer cache is invalidated on the affected VPN server - Client certificates are revoked (OpenVPN, auto_cert=True) - IP addresses are released (Wireguard) Also fixed the serializers.py cleanup logic: vpn_list was built from the old templates so .exclude(vpn__in=vpn_list) never matched the client being removed. Now uses new_vpn_ids from config_templates (the incoming new set) so PATCH with an empty templates list also works. Added select_related("vpn", "cert", "ip") to all deletion querysets to avoid N+1 queries triggered by the post_delete signal handler. Wrapped deletion loops in transaction.atomic() to prevent partial deletions on failure. Fixes openwisp#1221 [backport 1.2]
1 parent d4d013f commit 4d06878

File tree

5 files changed

+152
-8
lines changed

5 files changed

+152
-8
lines changed

CHANGES.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ Changelog
44
Version 1.3.0 [unreleased]
55
--------------------------
66

7-
Work in progress.
7+
Bugfixes
8+
~~~~~~~~
9+
10+
- Fixed VPN peer cache desync when VPN template is removed from a device:
11+
``VpnClient`` objects are now deleted via per-instance ``delete()`` so that
12+
``post_delete`` signals fire, ensuring peer cache invalidation, certificate
13+
revocation, and IP address release `#1221
14+
<https://github.com/openwisp/openwisp-controller/issues/1221>`_
815

916
Version 1.2.2 [2026-03-06]
1017
--------------------------

openwisp_controller/config/api/serializers.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,15 @@ def _update_config(self, device, config_data):
200200
old_templates = list(config.templates.values_list("id", flat=True))
201201
if config_templates != old_templates:
202202
with transaction.atomic():
203-
vpn_list = config.templates.filter(type="vpn").values_list("vpn")
204-
if vpn_list:
205-
config.vpnclient_set.exclude(vpn__in=vpn_list).delete()
203+
new_vpn_ids = Template.objects.filter(
204+
pk__in=config_templates, type="vpn"
205+
).values_list("vpn", flat=True)
206+
for vpnclient in (
207+
config.vpnclient_set.select_related("vpn", "cert", "ip")
208+
.exclude(vpn__in=new_vpn_ids)
209+
.iterator()
210+
):
211+
vpnclient.delete()
206212
config.templates.set(config_templates, clear=True)
207213
config.save()
208214
except ValidationError as error:

openwisp_controller/config/base/config.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,11 @@ def manage_vpn_clients(cls, action, instance, pk_set, **kwargs):
338338
if instance.is_deactivating_or_deactivated():
339339
# If the device is deactivated or in the process of deactivating, then
340340
# delete all vpn clients and return.
341-
instance.vpnclient_set.all().delete()
341+
with transaction.atomic():
342+
for vpnclient in instance.vpnclient_set.select_related(
343+
"vpn", "cert", "ip"
344+
).iterator():
345+
vpnclient.delete()
342346
return
343347

344348
vpn_client_model = cls.vpn.through
@@ -370,9 +374,15 @@ def manage_vpn_clients(cls, action, instance, pk_set, **kwargs):
370374
# signal is triggered again—after all templates, including the required
371375
# ones, have been fully added. At that point, we can identify and
372376
# delete VpnClient objects not linked to the final template set.
373-
instance.vpnclient_set.exclude(
374-
template_id__in=instance.templates.values_list("id", flat=True)
375-
).delete()
377+
with transaction.atomic():
378+
for vpnclient in (
379+
instance.vpnclient_set.select_related("vpn", "cert", "ip")
380+
.exclude(
381+
template_id__in=instance.templates.values_list("id", flat=True)
382+
)
383+
.iterator()
384+
):
385+
vpnclient.delete()
376386

377387
if action == "post_add":
378388
for template in templates.filter(type="vpn"):

openwisp_controller/config/tests/test_api.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
Template = load_model("config", "Template")
2727
Vpn = load_model("config", "Vpn")
2828
VpnClient = load_model("config", "VpnClient")
29+
Cert = load_model("django_x509", "Cert")
2930
Device = load_model("config", "Device")
3031
Config = load_model("config", "Config")
3132
DeviceGroup = load_model("config", "DeviceGroup")
@@ -518,6 +519,46 @@ def test_device_patch_with_templates_of_different_org(self):
518519
f" do not match the organization of this configuration: {t3}",
519520
)
520521

522+
def test_device_patch_vpn_template_removal_triggers_post_delete(self):
523+
"""Regression test for #1221: removing VPN template via PATCH API must
524+
trigger VpnClient.post_delete so peer cache is invalidated and the
525+
certificate is revoked. Tests both empty and non-empty replacement."""
526+
org = self._get_org()
527+
vpn = self._create_vpn(organization=org)
528+
t_vpn = self._create_template(
529+
name="vpn-test", type="vpn", vpn=vpn, auto_cert=True, organization=org
530+
)
531+
t_generic = self._create_template(name="generic-test", organization=org)
532+
device = self._create_device(organization=org)
533+
config = self._create_config(device=device)
534+
path = reverse("config_api:device_detail", args=[device.pk])
535+
536+
with self.subTest("replace VPN template with generic template"):
537+
config.templates.set([t_vpn])
538+
vpnclient = config.vpnclient_set.first()
539+
self.assertIsNotNone(vpnclient)
540+
cert_pk = vpnclient.cert.pk
541+
data = {"config": {"templates": [str(t_generic.pk)]}}
542+
with patch.object(Vpn, "_invalidate_peer_cache") as mock_invalidate:
543+
response = self.client.patch(path, data, content_type="application/json")
544+
mock_invalidate.assert_called_once()
545+
self.assertEqual(response.status_code, 200)
546+
self.assertFalse(VpnClient.objects.filter(pk=vpnclient.pk).exists())
547+
self.assertTrue(Cert.objects.get(pk=cert_pk).revoked)
548+
549+
with self.subTest("remove all templates (empty list)"):
550+
config.templates.set([t_vpn])
551+
vpnclient = config.vpnclient_set.first()
552+
self.assertIsNotNone(vpnclient)
553+
cert_pk = vpnclient.cert.pk
554+
data = {"config": {"templates": []}}
555+
with patch.object(Vpn, "_invalidate_peer_cache") as mock_invalidate:
556+
response = self.client.patch(path, data, content_type="application/json")
557+
mock_invalidate.assert_called_once()
558+
self.assertEqual(response.status_code, 200)
559+
self.assertFalse(VpnClient.objects.filter(pk=vpnclient.pk).exists())
560+
self.assertTrue(Cert.objects.get(pk=cert_pk).revoked)
561+
521562
def test_device_change_organization_required_templates(self):
522563
org1 = self._create_org(name="org1")
523564
org2 = self._create_org(name="org2")

openwisp_controller/config/tests/test_vpn.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,74 @@ def _assert_vpn_client_cert(cert, vpn_client, cert_ct, vpn_client_ct):
286286
vpnclient.save()
287287
_assert_vpn_client_cert(cert, vpnclient, 1, 0)
288288

289+
def test_vpn_client_post_delete_on_template_removal(self):
290+
"""Regression test for #1221: VpnClient.post_delete must fire
291+
when a VPN template is removed so that peer cache is invalidated
292+
and certificates are properly revoked."""
293+
org = self._get_org()
294+
vpn = self._create_vpn()
295+
t = self._create_template(name="vpn-test", type="vpn", vpn=vpn, auto_cert=True)
296+
c = self._create_config(organization=org)
297+
c.templates.add(t)
298+
vpnclient = c.vpnclient_set.first()
299+
self.assertIsNotNone(vpnclient)
300+
cert_pk = vpnclient.cert.pk
301+
with mock.patch.object(Vpn, "_invalidate_peer_cache") as mock_invalidate:
302+
c.templates.remove(t)
303+
mock_invalidate.assert_called_once()
304+
self.assertFalse(VpnClient.objects.filter(pk=vpnclient.pk).exists())
305+
# Certificate should be revoked (auto_cert=True)
306+
self.assertTrue(Cert.objects.get(pk=cert_pk).revoked)
307+
308+
def test_vpn_client_post_delete_on_device_deactivation(self):
309+
"""Regression test for #1221: VpnClient.post_delete must fire
310+
when a device is deactivated so that peer cache is invalidated
311+
and certificates are properly revoked."""
312+
org = self._get_org()
313+
vpn = self._create_vpn()
314+
t = self._create_template(name="vpn-test", type="vpn", vpn=vpn, auto_cert=True)
315+
d = self._create_device(organization=org)
316+
c = self._create_config(device=d)
317+
c.templates.add(t)
318+
vpnclient = c.vpnclient_set.first()
319+
self.assertIsNotNone(vpnclient)
320+
cert_pk = vpnclient.cert.pk
321+
with mock.patch.object(Vpn, "_invalidate_peer_cache") as mock_invalidate:
322+
d.deactivate()
323+
mock_invalidate.assert_called_once()
324+
self.assertFalse(VpnClient.objects.filter(pk=vpnclient.pk).exists())
325+
# Certificate should be revoked (auto_cert=True)
326+
self.assertTrue(Cert.objects.get(pk=cert_pk).revoked)
327+
328+
def test_vpn_client_post_delete_multiple_clients(self):
329+
"""Regression test for #1221: when a device has multiple VPN templates,
330+
removing all of them must delete every VpnClient, invalidate peer cache
331+
for each VPN, and revoke all auto-created certificates."""
332+
org = self._get_org()
333+
vpn1 = self._create_vpn(name="vpn1")
334+
vpn2 = self._create_vpn(name="vpn2", ca=vpn1.ca)
335+
t1 = self._create_template(
336+
name="vpn-t1", type="vpn", vpn=vpn1, auto_cert=True
337+
)
338+
t2 = self._create_template(
339+
name="vpn-t2", type="vpn", vpn=vpn2, auto_cert=True
340+
)
341+
d = self._create_device(organization=org)
342+
c = self._create_config(device=d)
343+
c.templates.add(t1, t2)
344+
self.assertEqual(c.vpnclient_set.count(), 2)
345+
vpnclient1 = c.vpnclient_set.get(vpn=vpn1)
346+
vpnclient2 = c.vpnclient_set.get(vpn=vpn2)
347+
cert_pk1 = vpnclient1.cert.pk
348+
cert_pk2 = vpnclient2.cert.pk
349+
with mock.patch.object(Vpn, "_invalidate_peer_cache") as mock_invalidate:
350+
d.deactivate()
351+
self.assertEqual(mock_invalidate.call_count, 2)
352+
self.assertFalse(VpnClient.objects.filter(pk=vpnclient1.pk).exists())
353+
self.assertFalse(VpnClient.objects.filter(pk=vpnclient2.pk).exists())
354+
self.assertTrue(Cert.objects.get(pk=cert_pk1).revoked)
355+
self.assertTrue(Cert.objects.get(pk=cert_pk2).revoked)
356+
289357
def test_vpn_client_get_common_name(self):
290358
vpn = self._create_vpn()
291359
d = self._create_device()
@@ -728,6 +796,18 @@ def test_trigger_vpn_server_endpoint_invalid_vpn_id(self):
728796
f"VPN Server UUID: {vpn_id} does not exist."
729797
)
730798

799+
def test_wireguard_vpnclient_ip_released_on_template_removal(self):
800+
"""Regression test for #1221: when a Wireguard VPN template is removed,
801+
the allocated IP address must be released (deleted)."""
802+
device, vpn, template = self._create_wireguard_vpn_template()
803+
vpnclient = device.config.vpnclient_set.first()
804+
self.assertIsNotNone(vpnclient)
805+
self.assertIsNotNone(vpnclient.ip)
806+
ip_pk = vpnclient.ip.pk
807+
device.config.templates.remove(template)
808+
self.assertFalse(VpnClient.objects.filter(pk=vpnclient.pk).exists())
809+
self.assertFalse(IpAddress.objects.filter(pk=ip_pk).exists())
810+
731811

732812
class TestWireguardTransaction(BaseTestVpn, TestWireguardVpnMixin, TransactionTestCase):
733813
mock_response = mock.Mock(spec=requests.Response)

0 commit comments

Comments
 (0)