Skip to content

Commit edf4801

Browse files
committed
[fix] Ensure VpnClient.post_delete fires on template removal openwisp#1221
Backport of the fix from master to the 1.2.x branch. Replaced QuerySet.delete() with per-instance deletion so that post_delete signals fire for every VpnClient, ensuring: - VPN peer cache is invalidated - Client certificates are revoked (OpenVPN) - IP addresses are released (Wireguard) Also fixed serializers.py vpn_list logic: now uses new_vpn_ids from config_templates so PATCH with empty templates list also works correctly. Added select_related("vpn", "cert", "ip") to all deletion querysets to prevent N+1 queries. Wrapped loops in transaction.atomic(). Added regression tests for template removal, device deactivation, and multiple VPN clients. Fixes openwisp#1221
1 parent d6a4727 commit edf4801

File tree

4 files changed

+101
-7
lines changed

4 files changed

+101
-7
lines changed

CHANGES.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
Changelog
22
=========
33

4+
Version 1.2.3 [unreleased]
5+
--------------------------
6+
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>`_
15+
416
Version 1.2.2 [2026-03-06]
517
--------------------------
618

openwisp_controller/config/api/serializers.py

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

openwisp_controller/config/base/config.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,11 @@ def manage_vpn_clients(cls, action, instance, pk_set, **kwargs):
347347
if instance.is_deactivating_or_deactivated():
348348
# If the device is deactivated or in the process of deactivating, then
349349
# delete all vpn clients and return.
350-
instance.vpnclient_set.all().delete()
350+
with transaction.atomic():
351+
for vpnclient in instance.vpnclient_set.select_related(
352+
"vpn", "cert", "ip"
353+
).iterator():
354+
vpnclient.delete()
351355
return
352356

353357
vpn_client_model = cls.vpn.through
@@ -379,9 +383,15 @@ def manage_vpn_clients(cls, action, instance, pk_set, **kwargs):
379383
# signal is triggered again—after all templates, including the required
380384
# ones, have been fully added. At that point, we can identify and
381385
# delete VpnClient objects not linked to the final template set.
382-
instance.vpnclient_set.exclude(
383-
template_id__in=instance.templates.values_list("id", flat=True)
384-
).delete()
386+
with transaction.atomic():
387+
for vpnclient in (
388+
instance.vpnclient_set.select_related("vpn", "cert", "ip")
389+
.exclude(
390+
template_id__in=instance.templates.values_list("id", flat=True)
391+
)
392+
.iterator()
393+
):
394+
vpnclient.delete()
385395

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

openwisp_controller/config/tests/test_vpn.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,72 @@ def _assert_vpn_client_cert(cert, vpn_client, cert_ct, vpn_client_ct):
287287
vpnclient.save()
288288
_assert_vpn_client_cert(cert, vpnclient, 1, 0)
289289

290+
def test_vpn_client_post_delete_on_template_removal(self):
291+
"""Regression test for #1221: VpnClient.post_delete must fire
292+
when a VPN template is removed so that peer cache is invalidated
293+
and certificates are properly revoked."""
294+
org = self._get_org()
295+
vpn = self._create_vpn()
296+
t = self._create_template(name="vpn-test", type="vpn", vpn=vpn, auto_cert=True)
297+
c = self._create_config(organization=org)
298+
c.templates.add(t)
299+
vpnclient = c.vpnclient_set.first()
300+
self.assertIsNotNone(vpnclient)
301+
cert_pk = vpnclient.cert.pk
302+
with mock.patch.object(Vpn, "_invalidate_peer_cache") as mock_invalidate:
303+
c.templates.remove(t)
304+
mock_invalidate.assert_called_once()
305+
self.assertFalse(VpnClient.objects.filter(pk=vpnclient.pk).exists())
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+
self.assertTrue(Cert.objects.get(pk=cert_pk).revoked)
326+
327+
def test_vpn_client_post_delete_multiple_clients(self):
328+
"""Regression test for #1221: when a device has multiple VPN templates,
329+
deactivating it must delete every VpnClient, invalidate peer cache for
330+
each VPN, and revoke all auto-created certificates."""
331+
org = self._get_org()
332+
vpn1 = self._create_vpn(name="vpn1")
333+
vpn2 = self._create_vpn(name="vpn2", ca=vpn1.ca)
334+
t1 = self._create_template(
335+
name="vpn-t1", type="vpn", vpn=vpn1, auto_cert=True
336+
)
337+
t2 = self._create_template(
338+
name="vpn-t2", type="vpn", vpn=vpn2, auto_cert=True
339+
)
340+
d = self._create_device(organization=org)
341+
c = self._create_config(device=d)
342+
c.templates.add(t1, t2)
343+
self.assertEqual(c.vpnclient_set.count(), 2)
344+
vpnclient1 = c.vpnclient_set.get(vpn=vpn1)
345+
vpnclient2 = c.vpnclient_set.get(vpn=vpn2)
346+
cert_pk1 = vpnclient1.cert.pk
347+
cert_pk2 = vpnclient2.cert.pk
348+
with mock.patch.object(Vpn, "_invalidate_peer_cache") as mock_invalidate:
349+
d.deactivate()
350+
self.assertEqual(mock_invalidate.call_count, 2)
351+
self.assertFalse(VpnClient.objects.filter(pk=vpnclient1.pk).exists())
352+
self.assertFalse(VpnClient.objects.filter(pk=vpnclient2.pk).exists())
353+
self.assertTrue(Cert.objects.get(pk=cert_pk1).revoked)
354+
self.assertTrue(Cert.objects.get(pk=cert_pk2).revoked)
355+
290356
def test_vpn_client_get_common_name(self):
291357
vpn = self._create_vpn()
292358
d = self._create_device()

0 commit comments

Comments
 (0)