Skip to content

Commit cb1d495

Browse files
tomsebastian10pandafynemesifier
authored
[chores:fix] Skipped WHOIS/Estimated Location tasks for deactivated devices #1325
Fixes #1325 --------- Co-authored-by: Gagan Deep <pandafy.dev@gmail.com> Co-authored-by: Federico Capoano <f.capoano@openwisp.io>
1 parent 1cb4578 commit cb1d495

10 files changed

Lines changed: 220 additions & 31 deletions

File tree

openwisp_controller/config/controller/views.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,11 @@ def _remove_duplicated_management_ip(self, device):
9393
where &= Q(organization_id=device.organization_id)
9494

9595
queryset = self.model.objects.filter(where).exclude(pk=device.pk)
96-
for dupe in queryset.only("pk", "key", "management_ip"):
96+
for dupe in queryset.only("pk", "key", "management_ip", "_is_deactivated"):
97+
# Include _is_deactivated in .only() even though it is not referenced
98+
# in this loop: dupe.save() triggers signal handlers that call
99+
# is_deactivated(), and omitting the field would cause a SELECT
100+
# per save (N+1 query).
97101
dupe.management_ip = ""
98102
dupe.save(update_fields=["management_ip"])
99103

@@ -108,7 +112,12 @@ def _remove_duplicated_last_ip(self, device):
108112
where &= Q(organization_id=device.organization_id)
109113

110114
queryset = self.model.objects.filter(where).exclude(pk=device.pk)
111-
for dupe in queryset.only("pk", "key", "last_ip"):
115+
# Include _is_deactivated and organization to avoid N+1 queries:
116+
# dupe.save() triggers signal handlers that call is_deactivated()
117+
# and WHOIS checks that read device.organization.
118+
for dupe in queryset.select_related("organization").only(
119+
"pk", "key", "last_ip", "_is_deactivated", "organization__id"
120+
):
112121
dupe.last_ip = ""
113122
dupe.save(update_fields=["last_ip"])
114123

openwisp_controller/config/tests/test_controller.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33

44
from django.core.cache import cache
55
from django.core.exceptions import ValidationError
6+
from django.db import connection
67
from django.http.response import Http404
78
from django.test import TestCase
9+
from django.test.utils import CaptureQueriesContext
810
from django.urls import reverse, reverse_lazy
911
from swapper import load_model
1012

@@ -1508,6 +1510,39 @@ def test_ip_fields_not_duplicated(self):
15081510
cached_device1 = view.get_device()
15091511
self.assertIsNone(cached_device1.management_ip)
15101512

1513+
@patch.object(app_settings, "WHOIS_CONFIGURED", True)
1514+
def test_remove_duplicated_last_ip_no_nplus1_queries(self):
1515+
# When WHOIS is configured, clearing a duplicate's last_ip runs
1516+
# process_ip_data_and_location during save(), which reads
1517+
# device.organization via is_whois_enabled. If organization is not
1518+
# selected upfront, each duplicate triggers extra SELECTs (N+1).
1519+
# The marginal cost per duplicate must be a single UPDATE only.
1520+
def _measure(n):
1521+
ip = f"192.168.40.{n}"
1522+
org = self._create_org(name=f"dupes-{n}", shared_secret=f"dupes-secret-{n}")
1523+
incoming = self._create_device(
1524+
organization=org,
1525+
name=f"incoming-{n}",
1526+
mac_address=f"00:11:22:33:{n:02x}:99",
1527+
last_ip=ip,
1528+
)
1529+
for i in range(n):
1530+
self._create_device(
1531+
organization=org,
1532+
name=f"dupe-{n}-{i}",
1533+
mac_address=f"00:11:22:33:{n:02x}:{i:02x}",
1534+
last_ip=ip,
1535+
)
1536+
view = DeviceChecksumView()
1537+
with CaptureQueriesContext(connection) as ctx:
1538+
view._remove_duplicated_last_ip(incoming)
1539+
return len(ctx.captured_queries)
1540+
1541+
one = _measure(1)
1542+
three = _measure(3)
1543+
# two extra duplicates must add exactly two queries (one UPDATE each)
1544+
self.assertEqual(three - one, 2)
1545+
15111546
@patch.object(app_settings, "SHARED_MANAGEMENT_IP_ADDRESS_SPACE", True)
15121547
def test_organization_shares_management_ip_address_space(self):
15131548
org1 = self._get_org()

openwisp_controller/config/tests/test_handlers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def test_organization_disabled_handler(self, mocked_task):
1717
with self.subTest("Test task executed on changing active to inactive org"):
1818
org.is_active = False
1919
org.save()
20-
mocked_task.assert_called_once()
20+
mocked_task.assert_called_once_with(str(org.id))
2121

2222
mocked_task.reset_mock()
2323
with self.subTest("Test task not executed on saving inactive org"):

openwisp_controller/config/whois/commands.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ def clear_last_ip_command(stdout, stderr, interactive=True):
3737
Device.objects.filter(_is_deactivated=False).select_related(
3838
"organization__config_settings"
3939
)
40-
# include the FK field 'organization' in .only() so the related
41-
# `organization__config_settings` traversal is not deferred
42-
.only("last_ip", "organization", "key")
40+
# Keep 'organization' and '_is_deactivated' loaded. The former is
41+
# needed for the select_related() traversal, while the latter is
42+
# accessed during save() signal handling via is_deactivated();
43+
# deferring either field would result in additional queries.
44+
.only("last_ip", "organization", "key", "_is_deactivated")
4345
)
4446
# Filter out devices that have WHOIS information for their last IP
4547
devices = devices.exclude(last_ip=None).exclude(

openwisp_controller/config/whois/service.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,10 @@ def _need_whois_lookup(self, new_ip):
186186
- WHOIS is disabled in the organization settings. (query from db)
187187
"""
188188
# Check cheap conditions first before hitting the database
189-
if not self.is_whois_enabled:
190-
return False
191189
if not self.is_valid_public_ip_address(new_ip):
192190
return False
191+
if not self.is_whois_enabled:
192+
return False
193193
whois_obj = self._get_whois_info_from_db(ip_address=new_ip).first()
194194
if whois_obj and not self.is_older(whois_obj.modified):
195195
return False
@@ -208,8 +208,14 @@ def get_device_whois_info(self):
208208
def process_ip_data_and_location(self, force_lookup=False):
209209
"""
210210
Trigger WHOIS lookup based on the conditions of `_need_whois_lookup`.
211+
Returns early if the device is deactivated.
211212
Tasks are triggered on commit to ensure redundant data is not created.
212213
"""
214+
# Do not trigger WHOIS fetch for deactivated devices.
215+
# Returning here also suppresses the whois_lookup_skipped signal emitted
216+
# below, so estimated location is not triggered for a deactivated device.
217+
if self.device.is_deactivated():
218+
return
213219
new_ip = self.device.last_ip
214220
initial_ip = self.device._initial_last_ip
215221
if force_lookup or self._need_whois_lookup(new_ip):
@@ -228,7 +234,11 @@ def update_whois_info(self):
228234
Update existing WHOIS data for the device
229235
when the data is older than
230236
``OPENWISP_CONTROLLER_WHOIS_REFRESH_THRESHOLD_DAYS``.
237+
Returns early if the device is deactivated.
231238
"""
239+
# Do not refresh WHOIS data for deactivated devices.
240+
if self.device.is_deactivated():
241+
return
232242
ip_address = self.device.last_ip
233243
if not self.is_valid_public_ip_address(ip_address):
234244
return

openwisp_controller/config/whois/tasks.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ def fetch_whois_details(self, device_pk, initial_ip_address):
7272
except Device.DoesNotExist:
7373
logger.warning(f"Device {device_pk} not found, skipping WHOIS lookup")
7474
return
75+
# Defense in depth: a device can be deactivated after the task is queued,
76+
# so re-check here and do not run the external lookup for it.
77+
if device.is_deactivated():
78+
logger.info(f"Device {device_pk} is deactivated, skipping WHOIS lookup")
79+
return
7580
new_ip_address = device.last_ip
7681
whois_service = device.whois_service
7782
# If there is existing WHOIS older record then it needs to be updated

openwisp_controller/config/whois/tests/tests.py

Lines changed: 112 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,10 @@ def test_whois_enabled(self):
177177
)
178178

179179
with self.subTest("Test WHOIS not configured does not allow enabling WHOIS"):
180-
with mock.patch.object(
181-
app_settings, "WHOIS_CONFIGURED", False
182-
), self.assertRaises(ValidationError) as context_manager:
180+
with (
181+
mock.patch.object(app_settings, "WHOIS_CONFIGURED", False),
182+
self.assertRaises(ValidationError) as context_manager,
183+
):
183184
org_settings_obj.full_clean()
184185
self.assertEqual(
185186
context_manager.exception.message_dict["whois_enabled"][0],
@@ -483,9 +484,12 @@ def test_whois_task_called(self, mocked_lookup_task):
483484
org.config_settings.save()
484485

485486
with self.subTest("WHOIS lookup task called when last_ip is public"):
486-
with mock.patch(
487-
"django.core.cache.cache.get", return_value=None
488-
) as mocked_get, mock.patch("django.core.cache.cache.set") as mocked_set:
487+
with (
488+
mock.patch(
489+
"django.core.cache.cache.get", return_value=None
490+
) as mocked_get,
491+
mock.patch("django.core.cache.cache.set") as mocked_set,
492+
):
489493
device = self._create_device(last_ip="172.217.22.14")
490494
mocked_lookup_task.assert_called()
491495
mocked_set.assert_called_once_with(
@@ -499,9 +503,10 @@ def test_whois_task_called(self, mocked_lookup_task):
499503
with self.subTest(
500504
"WHOIS lookup task called when last_ip is changed and is public"
501505
):
502-
with mock.patch("django.core.cache.cache.get") as mocked_get, mock.patch(
503-
"django.core.cache.cache.set"
504-
) as mocked_set:
506+
with (
507+
mock.patch("django.core.cache.cache.get") as mocked_get,
508+
mock.patch("django.core.cache.cache.set") as mocked_set,
509+
):
505510
device.last_ip = "172.217.22.10"
506511
device.save()
507512
device.refresh_from_db()
@@ -823,11 +828,13 @@ def test_device_last_ip_deferred_checks(self):
823828
device._check_last_ip()
824829
self.assertTrue(device._is_deferred("last_ip"))
825830

826-
with self.subTest(
827-
"Test update_whois does not run if last_ip is deferred"
828-
), mock.patch(
829-
"openwisp_controller.config.whois.service.WHOISService.update_whois_info"
830-
) as mock_update_whois:
831+
with (
832+
self.subTest("Test update_whois does not run if last_ip is deferred"),
833+
mock.patch(
834+
"openwisp_controller.config.whois.service"
835+
".WHOISService.update_whois_info"
836+
) as mock_update_whois,
837+
):
831838
threshold = app_settings.WHOIS_REFRESH_THRESHOLD_DAYS + 1
832839
new_time = timezone.now() - timedelta(days=threshold)
833840
WHOISInfo.objects.filter(pk=whois_obj.pk).update(modified=new_time)
@@ -871,9 +878,12 @@ def test_whois_task_failure_notification(self, mock_info, mock_warn, mock_error)
871878
def assert_logging_on_exception(
872879
exception, info_calls=0, warn_calls=0, error_calls=1, notification_count=1
873880
):
874-
with self.subTest(
875-
f"Test notification and logging when {exception.__name__} is raised"
876-
), mock.patch(self._WHOIS_GEOIP_CLIENT) as mock_client:
881+
with (
882+
self.subTest(
883+
f"Test notification and logging when {exception.__name__} is raised"
884+
),
885+
mock.patch(self._WHOIS_GEOIP_CLIENT) as mock_client,
886+
):
877887
mock_client.return_value.city.side_effect = exception("test")
878888
Device.objects.all().delete() # Clear existing devices
879889
device = self._create_device(last_ip="172.217.22.14")
@@ -947,9 +957,10 @@ def trigger_error_and_assert_cached(exc, notification_count=0):
947957
):
948958
trigger_error_and_assert_cached(subsequent_error, 0)
949959

950-
with self.subTest("Test cache updated on success"), mock.patch(
951-
self._WHOIS_GEOIP_CLIENT
952-
) as mock_client:
960+
with (
961+
self.subTest("Test cache updated on success"),
962+
mock.patch(self._WHOIS_GEOIP_CLIENT) as mock_client,
963+
):
953964
Device.objects.all().delete()
954965
mocked_response = self._mocked_client_response()
955966
mocked_response.location = None
@@ -965,9 +976,12 @@ def trigger_error_and_assert_cached(exc, notification_count=0):
965976
@mock.patch("openwisp_controller.config.whois.tasks.fetch_whois_details.retry")
966977
def test_whois_task_retry_mechanism(self, mock_retry):
967978
def assert_retry_on_exception(exception, should_retry):
968-
with self.subTest(
969-
f"Test retry mechanism when {exception.__name__} is raised"
970-
), mock.patch(self._WHOIS_GEOIP_CLIENT) as mock_client:
979+
with (
980+
self.subTest(
981+
f"Test retry mechanism when {exception.__name__} is raised"
982+
),
983+
mock.patch(self._WHOIS_GEOIP_CLIENT) as mock_client,
984+
):
971985
mock_client.return_value.city.side_effect = exception("test")
972986
Device.objects.all().delete()
973987
mock_retry.reset_mock()
@@ -995,6 +1009,20 @@ def test_fetch_whois_details_device_not_found(self, mock_warn):
9951009
f"Device {invalid_pk} not found, skipping WHOIS lookup"
9961010
)
9971011

1012+
@mock.patch.object(app_settings, "WHOIS_CONFIGURED", True)
1013+
@mock.patch(_WHOIS_TASKS_INFO_LOGGER)
1014+
@mock.patch(_WHOIS_GEOIP_CLIENT)
1015+
def test_fetch_whois_details_skips_when_deactivated(self, mock_client, mock_info):
1016+
whois_obj = self._create_whois_info(ip_address="8.8.8.8")
1017+
device = self._create_device(last_ip=whois_obj.ip_address)
1018+
mock_client.reset_mock()
1019+
device.deactivate()
1020+
fetch_whois_details(device_pk=device.pk, initial_ip_address="10.0.0.1")
1021+
mock_info.assert_called_once_with(
1022+
f"Device {device.pk} is deactivated, skipping WHOIS lookup"
1023+
)
1024+
mock_client.assert_not_called()
1025+
9981026
@mock.patch.object(app_settings, "WHOIS_CONFIGURED", True)
9991027
@mock.patch(_WHOIS_GEOIP_CLIENT)
10001028
def test_fetch_whois_details_record_already_exists(self, mock_client):
@@ -1077,6 +1105,67 @@ def test_get_whois_info_when_not_configured(self):
10771105
result = get_whois_info(pk=device.pk)
10781106
self.assertIsNone(result)
10791107

1108+
@mock.patch.object(app_settings, "WHOIS_CONFIGURED", True)
1109+
@mock.patch("openwisp_controller.config.whois.service.fetch_whois_details.delay")
1110+
def test_process_ip_skips_when_deactivated(self, mock_task):
1111+
# Public IP that would normally trigger a lookup, so the only reason
1112+
# the task is not enqueued is the deactivation guard under test.
1113+
device = self._create_device()
1114+
org_settings = device.organization.config_settings
1115+
org_settings.whois_enabled = True
1116+
org_settings.save()
1117+
device._initial_last_ip = None
1118+
device.last_ip = "8.8.8.8"
1119+
device.save()
1120+
mock_task.reset_mock()
1121+
device.deactivate()
1122+
service = WHOISService(device)
1123+
service.process_ip_data_and_location()
1124+
self.assertEqual(mock_task.call_count, 0)
1125+
1126+
@mock.patch.object(app_settings, "WHOIS_CONFIGURED", True)
1127+
@mock.patch("openwisp_controller.config.whois.service.fetch_whois_details.delay")
1128+
def test_process_ip_runs_when_active(self, mock_task):
1129+
device = self._create_device()
1130+
org_settings = device.organization.config_settings
1131+
org_settings.whois_enabled = True
1132+
org_settings.save()
1133+
device._initial_last_ip = None
1134+
device.last_ip = "8.8.8.8"
1135+
device.save()
1136+
mock_task.reset_mock()
1137+
service = WHOISService(device)
1138+
service.process_ip_data_and_location()
1139+
# transaction.on_commit executes immediately in TransactionTestCase,
1140+
# so the task is triggered synchronously here
1141+
# _initial_last_ip is '8.8.8.8' here because _check_last_ip in
1142+
# device.save() set it to device.last_ip after the first save.
1143+
mock_task.assert_called_once_with(
1144+
device_pk=device.pk,
1145+
initial_ip_address="8.8.8.8",
1146+
)
1147+
1148+
@mock.patch.object(app_settings, "WHOIS_CONFIGURED", True)
1149+
@mock.patch("openwisp_controller.config.whois.service.fetch_whois_details.delay")
1150+
def test_update_whois_skips_when_deactivated(self, mock_task):
1151+
device = self._create_device()
1152+
org_settings = device.organization.config_settings
1153+
org_settings.whois_enabled = True
1154+
org_settings.save()
1155+
ip = "8.8.8.8"
1156+
device._initial_last_ip = None
1157+
device.last_ip = ip
1158+
device.save()
1159+
threshold = app_settings.WHOIS_REFRESH_THRESHOLD_DAYS + 1
1160+
expired_time = timezone.now() - timedelta(days=threshold)
1161+
whois_obj = self._create_whois_info(ip_address=ip)
1162+
WHOISInfo.objects.filter(pk=whois_obj.pk).update(modified=expired_time)
1163+
device.deactivate()
1164+
mock_task.reset_mock()
1165+
service = WHOISService(device)
1166+
service.update_whois_info()
1167+
mock_task.assert_not_called()
1168+
10801169

10811170
@tag("selenium_tests")
10821171
class TestWHOISSelenium(CreateWHOISMixin, SeleniumTestMixin, StaticLiveServerTestCase):

openwisp_controller/geo/estimated_location/service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ def is_estimated_location_enabled(self):
6363
return self.check_estimated_location_enabled(self.device.organization_id)
6464

6565
def trigger_estimated_location_task(self, ip_address):
66+
# Do not re-derive estimated location for deactivated devices.
67+
if self.device.is_deactivated():
68+
return
6669
try:
6770
current_app.send_task(
6871
"whois_estimated_location_task",

openwisp_controller/geo/estimated_location/tasks.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ def manage_estimated_locations(device_pk, ip_address):
121121
f"Device {device_pk} not found, skipping manage_estimated_locations"
122122
)
123123
return
124+
# Defense in depth: a device can be deactivated after the task is queued,
125+
# so re-check here.
126+
if device.is_deactivated():
127+
logger.info(
128+
f"Device {device_pk} is deactivated, skipping estimated location task"
129+
)
130+
return
124131
devices_with_location = list(
125132
# "devicelocation" and "devicelocation__location" must be in only() to
126133
# prevent Django from deferring them, which would conflict with

0 commit comments

Comments
 (0)