Skip to content

Commit 25da61a

Browse files
committed
[enhancement] Added updating older WHOIS records
Added threshold setting for checking older WHOIS records. Added test cases Signed-off-by: DragnEmperor <[email protected]>
1 parent 2c1dda6 commit 25da61a

File tree

11 files changed

+287
-77
lines changed

11 files changed

+287
-77
lines changed

docs/user/estimated-location.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,10 @@ In REST API, the field will be visible in the :ref:`Device Location
7878
<location_geojson_estimated>` if the feature is **enabled**. The field can
7979
also be used for filtering in the location list (including geojson)
8080
endpoints and in the :ref:`Device List <device_list_estimated_filters>`.
81+
82+
Managing Older Estimated Locations
83+
----------------------------------
84+
85+
Whenever location related fields in WHOIS record are updated as per
86+
:ref:`Managing WHOIS Older Records <whois_older_records>`; the location
87+
will also be updated automatically.

docs/user/settings.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,3 +812,16 @@ Allows enabling the optional :doc:`Estimated Location feature
812812
.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.3/estimated-location-setting.png
813813
:target: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.3/estimated-location-setting.png
814814
:alt: Estimated Location setting
815+
816+
.. _openwisp_controller_whois_refresh_threshold_days:
817+
818+
``OPENWISP_CONTROLLER_WHOIS_REFRESH_THRESHOLD_DAYS``
819+
----------------------------------------------------
820+
821+
============ =======
822+
**type**: ``int``
823+
**default**: ``14``
824+
============ =======
825+
826+
Specifies the number of days after which the WHOIS information for a
827+
device is considered stale and eligible for refresh.

docs/user/whois.rst

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@ associated with the device's public IP address and includes:
3030
- Timezone of the ASN's registered location
3131
- Coordinates (Latitude and Longitude)
3232

33+
.. _whois_trigger_conditions:
34+
3335
Trigger Conditions
3436
------------------
3537

3638
A WHOIS lookup is triggered automatically when:
3739

38-
- A new device is registered.
40+
- A new device is registered or its last IP address is changed.
3941
- A device fetches its checksum.
4042

4143
However, the lookup will only run if **all** the following conditions are
@@ -114,3 +116,16 @@ retrieved details can be viewed in the following locations:
114116
- **Device REST API**: See WHOIS details in the :ref:`Device List
115117
<device_list_whois>` and :ref:`Device Detail <device_detail_whois>`
116118
responses.
119+
120+
.. _whois_older_records:
121+
122+
Managing Older WHOIS Records
123+
----------------------------
124+
125+
If a record is older than :ref:`Threshold
126+
<openwisp_controller_whois_refresh_threshold_days>`, it will be refreshed
127+
automatically.
128+
129+
This will be triggered for the same scenarios defined in `trigger
130+
conditions <whois_trigger_conditions_>`_ but among the conditions **only
131+
WHOIS enablement is required**.

openwisp_controller/config/base/device.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,4 +519,6 @@ def _check_last_ip(self, creating=False):
519519
return
520520
if creating or self.last_ip != self._initial_last_ip:
521521
self.whois_service.process_ip_data_and_location()
522+
else:
523+
self.whois_service.update_whois_info()
522524
self._initial_last_ip = self.last_ip

openwisp_controller/config/controller/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ def get(self, request, pk):
153153
# updates cache if ip addresses changed
154154
if updated:
155155
self.update_device_cache(device)
156+
# check if WHOIS Info of device requires update
157+
else:
158+
device.whois_service.update_whois_info()
156159
checksum_requested.send(
157160
sender=device.__class__, instance=device, request=request
158161
)

openwisp_controller/config/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def get_setting(option, default):
6868
"API_TASK_RETRY_OPTIONS",
6969
dict(max_retries=5, retry_backoff=True, retry_backoff_max=600, retry_jitter=True),
7070
)
71+
WHOIS_REFRESH_THRESHOLD_DAYS = get_setting("WHOIS_REFRESH_THRESHOLD_DAYS", 14)
7172
WHOIS_GEOIP_ACCOUNT = get_setting("WHOIS_GEOIP_ACCOUNT", None)
7273
WHOIS_GEOIP_KEY = get_setting("WHOIS_GEOIP_KEY", None)
7374
WHOIS_ENABLED = get_setting("WHOIS_ENABLED", False)

openwisp_controller/config/whois/service.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
from datetime import timedelta
12
from ipaddress import ip_address as ip_addr
23

34
from django.core.cache import cache
45
from django.db import transaction
6+
from django.utils import timezone
57
from swapper import load_model
68

79
from openwisp_controller.config import settings as app_settings
@@ -43,6 +45,15 @@ def _get_whois_info_from_db(ip_address):
4345

4446
return WHOISInfo.objects.filter(ip_address=ip_address)
4547

48+
@staticmethod
49+
def is_older(datetime):
50+
"""
51+
Check if given datetime is older than the refresh threshold.
52+
"""
53+
return (timezone.now() - datetime) >= timedelta(
54+
days=app_settings.WHOIS_REFRESH_THRESHOLD_DAYS
55+
)
56+
4657
@staticmethod
4758
def get_org_config_settings(org_id):
4859
"""
@@ -90,6 +101,8 @@ def is_whois_enabled(self):
90101
"""
91102
Check if the WHOIS lookup feature is enabled.
92103
"""
104+
if not app_settings.WHOIS_CONFIGURED:
105+
return False
93106
org_settings = self.get_org_config_settings(org_id=self.device.organization.pk)
94107
return org_settings.whois_enabled
95108

@@ -98,6 +111,8 @@ def is_estimated_location_enabled(self):
98111
"""
99112
Check if the Estimated location feature is enabled.
100113
"""
114+
if not app_settings.WHOIS_CONFIGURED:
115+
return False
101116
org_settings = self.get_org_config_settings(org_id=self.device.organization.pk)
102117
return org_settings.estimated_location_enabled
103118

@@ -108,15 +123,17 @@ def _need_whois_lookup(self, new_ip):
108123
109124
The lookup is not triggered if:
110125
- The new IP address is None or it is a private IP address.
111-
- The WHOIS information of new ip is already present.
126+
- The WHOIS information of new ip is present and is not older than
127+
14 days.
112128
- WHOIS is disabled in the organization settings. (query from db)
113129
"""
114130

115131
# Check cheap conditions first before hitting the database
116132
if not self.is_valid_public_ip_address(new_ip):
117133
return False
118134

119-
if self._get_whois_info_from_db(new_ip).exists():
135+
whois_obj = self._get_whois_info_from_db(ip_address=new_ip).first()
136+
if whois_obj and not self.is_older(whois_obj.modified):
120137
return False
121138

122139
return self.is_whois_enabled
@@ -145,20 +162,19 @@ def get_device_whois_info(self):
145162

146163
return self._get_whois_info_from_db(ip_address=ip_address).first()
147164

148-
def process_ip_data_and_location(self):
165+
def process_ip_data_and_location(self, force_lookup=False):
149166
"""
150167
Trigger WHOIS lookup based on the conditions of `_need_whois_lookup`
151168
and also manage estimated locations based on the conditions of
152169
`_need_estimated_location_management`.
153170
Tasks are triggered on commit to ensure redundant data is not created.
154171
"""
155172
new_ip = self.device.last_ip
156-
if self._need_whois_lookup(new_ip):
173+
if force_lookup or self._need_whois_lookup(new_ip):
157174
transaction.on_commit(
158175
lambda: fetch_whois_details.delay(
159176
device_pk=self.device.pk,
160177
initial_ip_address=self.device._initial_last_ip,
161-
new_ip_address=new_ip,
162178
)
163179
)
164180
# To handle the case when WHOIS already exists as in that case
@@ -170,3 +186,17 @@ def process_ip_data_and_location(self):
170186
device_pk=self.device.pk, ip_address=new_ip
171187
)
172188
)
189+
190+
def update_whois_info(self):
191+
"""
192+
Update the WHOIS information for the device.
193+
"""
194+
ip_address = self.device.last_ip
195+
if not self.is_valid_public_ip_address(ip_address):
196+
return
197+
198+
if not self.is_whois_enabled:
199+
return
200+
whois_obj = WHOISService._get_whois_info_from_db(ip_address=ip_address).first()
201+
if whois_obj and self.is_older(whois_obj.modified):
202+
fetch_whois_details.delay(device_pk=self.device.pk, initial_ip_address=None)

openwisp_controller/config/whois/tasks.py

Lines changed: 91 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import logging
2+
from datetime import timedelta
23

34
import requests
45
from celery import shared_task
56
from django.contrib.gis.geos import Point
7+
from django.db import transaction
8+
from django.utils import timezone
69
from django.utils.translation import gettext as _
710
from geoip2 import errors
811
from geoip2 import webservice as geoip2_webservice
@@ -59,84 +62,104 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
5962
base=WHOISCeleryRetryTask,
6063
**app_settings.API_TASK_RETRY_OPTIONS,
6164
)
62-
def fetch_whois_details(self, device_pk, initial_ip_address, new_ip_address):
65+
def fetch_whois_details(self, device_pk, initial_ip_address):
6366
"""
6467
Fetches the WHOIS details of the given IP address
6568
and creates/updates the WHOIS record.
6669
"""
6770
Device = load_model("config", "Device")
6871
WHOISInfo = load_model("config", "WHOISInfo")
6972

70-
# The task can be triggered for same ip address multiple times
71-
# so we need to return early if WHOIS is already created.
72-
if WHOISInfo.objects.filter(ip_address=new_ip_address).exists():
73-
return
74-
75-
device = Device.objects.get(pk=device_pk)
76-
# Host is based on the db that is used to fetch the details.
77-
# As we are using GeoLite2, 'geolite.info' host is used.
78-
# Refer: https://geoip2.readthedocs.io/en/latest/#sync-web-service-example
79-
ip_client = geoip2_webservice.Client(
80-
account_id=app_settings.WHOIS_GEOIP_ACCOUNT,
81-
license_key=app_settings.WHOIS_GEOIP_KEY,
82-
host="geolite.info",
83-
)
84-
85-
try:
86-
data = ip_client.city(ip_address=new_ip_address)
87-
88-
# Catching all possible exceptions raised by the geoip2 client
89-
# and raising them with appropriate messages to be handled by the task
90-
# retry mechanism.
91-
except (
92-
errors.AddressNotFoundError,
93-
errors.AuthenticationError,
94-
errors.OutOfQueriesError,
95-
errors.PermissionRequiredError,
96-
) as e:
97-
exc_type = type(e)
98-
message = EXCEPTION_MESSAGES.get(exc_type)
99-
if exc_type is errors.AddressNotFoundError:
100-
message = message.format(ip_address=new_ip_address)
101-
raise exc_type(message)
102-
except requests.RequestException as e:
103-
raise e
104-
105-
else:
106-
# The attributes are always present in the response,
107-
# but they can be None, so added fallbacks.
108-
address = {
109-
"city": data.city.name or "",
110-
"country": data.country.name or "",
111-
"continent": data.continent.name or "",
112-
"postal": str(data.postal.code or ""),
113-
}
114-
coordinates = Point(data.location.longitude, data.location.latitude, srid=4326)
115-
whois_obj = WHOISInfo(
116-
isp=data.traits.autonomous_system_organization,
117-
asn=data.traits.autonomous_system_number,
118-
timezone=data.location.time_zone,
119-
address=address,
120-
cidr=data.traits.network,
121-
ip_address=new_ip_address,
122-
coordinates=coordinates,
73+
with transaction.atomic():
74+
device = Device.objects.get(pk=device_pk)
75+
new_ip_address = device.last_ip
76+
# If there is existing WHOIS older record then it needs to be updated
77+
whois_obj = WHOISInfo.objects.filter(ip_address=new_ip_address).first()
78+
if whois_obj and (timezone.now() - whois_obj.modified) < timedelta(days=14):
79+
return
80+
81+
# Host is based on the db that is used to fetch the details.
82+
# As we are using GeoLite2, 'geolite.info' host is used.
83+
# Refer: https://geoip2.readthedocs.io/en/latest/#sync-web-service-example
84+
ip_client = geoip2_webservice.Client(
85+
account_id=app_settings.WHOIS_GEOIP_ACCOUNT,
86+
license_key=app_settings.WHOIS_GEOIP_KEY,
87+
host="geolite.info",
12388
)
124-
whois_obj.full_clean()
125-
whois_obj.save()
126-
logger.info(f"Successfully fetched WHOIS details for {new_ip_address}.")
12789

128-
if device._get_organization__config_settings().estimated_location_enabled:
129-
manage_estimated_locations.delay(
130-
device_pk=device_pk, ip_address=new_ip_address
90+
try:
91+
data = ip_client.city(ip_address=new_ip_address)
92+
93+
# Catching all possible exceptions raised by the geoip2 client
94+
# and raising them with appropriate messages to be handled by the task
95+
# retry mechanism.
96+
except (
97+
errors.AddressNotFoundError,
98+
errors.AuthenticationError,
99+
errors.OutOfQueriesError,
100+
errors.PermissionRequiredError,
101+
) as e:
102+
exc_type = type(e)
103+
message = EXCEPTION_MESSAGES.get(exc_type)
104+
if exc_type is errors.AddressNotFoundError:
105+
message = message.format(ip_address=new_ip_address)
106+
raise exc_type(message)
107+
except requests.RequestException as e:
108+
raise e
109+
110+
else:
111+
# The attributes are always present in the response,
112+
# but they can be None, so added fallbacks.
113+
address = {
114+
"city": data.city.name or "",
115+
"country": data.country.name or "",
116+
"continent": data.continent.name or "",
117+
"postal": str(data.postal.code or ""),
118+
}
119+
coordinates = Point(
120+
data.location.longitude, data.location.latitude, srid=4326
131121
)
132-
133-
# delete WHOIS record for initial IP if no devices are linked to it
134-
if (
135-
not Device.objects.filter(_is_deactivated=False)
136-
.filter(last_ip=initial_ip_address)
137-
.exists()
138-
):
139-
delete_whois_record(ip_address=initial_ip_address)
122+
details = {
123+
"isp": data.traits.autonomous_system_organization,
124+
"asn": data.traits.autonomous_system_number,
125+
"timezone": data.location.time_zone,
126+
"address": address,
127+
"coordinates": coordinates,
128+
"cidr": data.traits.network,
129+
"ip_address": new_ip_address,
130+
}
131+
update_fields = []
132+
if whois_obj:
133+
for attr, value in details.items():
134+
if getattr(whois_obj, attr) != value:
135+
update_fields.append(attr)
136+
setattr(whois_obj, attr, value)
137+
if update_fields:
138+
whois_obj.save(update_fields=update_fields)
139+
else:
140+
whois_obj = WHOISInfo(**details)
141+
whois_obj.full_clean()
142+
whois_obj.save()
143+
logger.info(f"Successfully fetched WHOIS details for {new_ip_address}.")
144+
145+
if device._get_organization__config_settings().estimated_location_enabled:
146+
# the estimated location task should not run if old record is updated
147+
# and location related fields are not updated
148+
if update_fields and not any(
149+
i in update_fields for i in ["address", "coordinates"]
150+
):
151+
return
152+
manage_estimated_locations.delay(
153+
device_pk=device_pk, ip_address=new_ip_address
154+
)
155+
156+
# delete WHOIS record for initial IP if no devices are linked to it
157+
if (
158+
not Device.objects.filter(_is_deactivated=False)
159+
.filter(last_ip=initial_ip_address)
160+
.exists()
161+
):
162+
delete_whois_record(ip_address=initial_ip_address)
140163

141164

142165
@shared_task

0 commit comments

Comments
 (0)