Skip to content

[feature] Base setup for WHOIS model #1032 #1033 #1037 #1045 #1054

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 28 commits into from
Jun 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2d18e68
[feature] Base setup for WHOIS model #1032 #1033
DragnEmperor May 23, 2025
c78239f
[tests] Added tests for WHOIS creation #1045
DragnEmperor May 26, 2025
d1169d4
[change] Added Org Config for WhoIs feature #1037
DragnEmperor May 29, 2025
667f9e4
[change] Altered address field to JSONField
DragnEmperor May 30, 2025
3949a2e
[change] Migrate to ip mapping instead of device
DragnEmperor Jun 2, 2025
01b6dec
[change] Added abstraction to WhoIs related functions
DragnEmperor Jun 3, 2025
17af187
[change] Refactored WhoIsService, tasks, test cases
DragnEmperor Jun 6, 2025
45ec982
[change] Added cache for org config, refactoring
DragnEmperor Jun 9, 2025
810ee7b
[change] Migrate signal receivers to WhoIs model
DragnEmperor Jun 10, 2025
9f8de4d
[change] Restructured imports
DragnEmperor Jun 10, 2025
1168721
[fix] Return from task if WhoIs exists
DragnEmperor Jun 11, 2025
da6b790
[change] Simplified checks
DragnEmperor Jun 12, 2025
520d601
[fix] Replace country name with iso_code
DragnEmperor Jun 13, 2025
97b5303
[change] Add new test case, format address
DragnEmperor Jun 16, 2025
093d3ca
[change] Modify WhoIs handling as a feature
DragnEmperor Jun 17, 2025
592eb3a
[change] Remove WhoIs field if not configured
DragnEmperor Jun 18, 2025
6cb4ba6
[qa] Minor formatting
DragnEmperor Jun 20, 2025
dff447c
[chores] Cleanup redundant lines, checks reordered
DragnEmperor Jun 23, 2025
c13e6e4
[change] Rename who_is files/directories
DragnEmperor Jun 24, 2025
bced848
[docs] Revised whois docs
nemesifier Jun 25, 2025
a8a6930
[chores] Update docs, refactoring, update sample app
DragnEmperor Jun 26, 2025
95e84fc
[chores] Original order of fields in Organization admin
DragnEmperor Jun 26, 2025
2e0f812
[chores] Remove outdated registration condition in admin
DragnEmperor Jun 26, 2025
1c0028b
[docs] Improvements and simplifications
nemesifier Jun 26, 2025
f253ceb
[docs] Updated WHOIS Admin image in settings
DragnEmperor Jun 26, 2025
d277659
[tests] Added test case for no WHOIS record
DragnEmperor Jun 27, 2025
81b83be
[tests] Added test case for asserting handlers
DragnEmperor Jun 29, 2025
7b9179c
[tests] Added test for org settings cache
DragnEmperor Jun 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/developer/extending.rst
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ Once you have created the models, add the following to your
CONFIG_VPNCLIENT_MODEL = "sample_config.VpnClient"
CONFIG_ORGANIZATIONCONFIGSETTINGS_MODEL = "sample_config.OrganizationConfigSettings"
CONFIG_ORGANIZATIONLIMITS_MODEL = "sample_config.OrganizationLimits"
CONFIG_WHOISINFO_MODEL = "sample_config.WHOISInfo"
DJANGO_X509_CA_MODEL = "sample_pki.Ca"
DJANGO_X509_CERT_MODEL = "sample_pki.Cert"
GEO_LOCATION_MODEL = "sample_geo.Location"
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ the OpenWISP architecture.
user/zerotier.rst
user/openvpn.rst
user/subnet-division-rules.rst
user/whois.rst
user/rest-api.rst
user/settings.rst

Expand Down
51 changes: 51 additions & 0 deletions docs/user/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -742,3 +742,54 @@ recoverable failures, improving the reliability of the system.
For more information on these settings, you can refer to the `the celery
documentation regarding automatic retries for known errors.
<https://docs.celeryq.dev/en/stable/userguide/tasks.html#automatic-retry-for-known-exceptions>`_

.. _openwisp_controller_whois_enabled:

``OPENWISP_CONTROLLER_WHOIS_ENABLED``
-------------------------------------

============ =========
**type**: ``bool``
**default**: ``False``
============ =========

Allows enabling the optional :doc:`WHOIS Lookup feature <whois>`.

.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.2/whois-admin-setting.png
:target: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.2/whois-admin-setting.png
:alt: WHOIS admin setting

After enabling this feature, you have to set
:ref:`OPENWISP_CONTROLLER_WHOIS_GEOIP_ACCOUNT
<OPENWISP_CONTROLLER_WHOIS_GEOIP_ACCOUNT>` and
:ref:`OPENWISP_CONTROLLER_WHOIS_GEOIP_KEY
<OPENWISP_CONTROLLER_WHOIS_GEOIP_KEY>`.

.. warning::

If these three settings are not configured as expected, an
``ImproperlyConfigured`` exception will be raised.

.. _openwisp_controller_whois_geoip_account:

``OPENWISP_CONTROLLER_WHOIS_GEOIP_ACCOUNT``
-------------------------------------------

============ =======
**type**: ``str``
**default**: None
============ =======

Maxmind Account ID required for the :doc:`WHOIS Lookup feature <whois>`.

.. _openwisp_controller_whois_geoip_key:

``OPENWISP_CONTROLLER_WHOIS_GEOIP_KEY``
---------------------------------------

============ =======
**type**: ``str``
**default**: None
============ =======

Maxmind License Key required for the :doc:`WHOIS Lookup feature <whois>`.
80 changes: 80 additions & 0 deletions docs/user/whois.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
WHOIS Lookup
============

.. important::

The **WHOIS Lookup** feature is **disabled by default**.

To enable it, follow the `setup steps
<controller_setup_whois_lookup_>`_ below.

.. contents:: **Table of contents**:
:depth: 1
:local:

Overview
--------

The WHOIS Lookup feature displays information about the public IP address
used by devices to communicate with OpenWISP (via the ``last_ip`` field).
It helps identify the geographic location and ISP associated with the IP
address, which can be useful for troubleshooting network issues.

The retrieved information pertains to the Autonomous System (ASN)
associated with the device's public IP address and includes:

- ASN (Autonomous System Number)
- Organization name that owns the ASN
- CIDR block assigned to the ASN
- Physical address registered to the ASN
- Timezone of the ASN's registered location

Trigger Conditions
------------------

A WHOIS lookup is triggered automatically when:

- A new device is registered.
- A device fetches its checksum.

However, the lookup will only run if **all** the following conditions are
met:

- The device's last IP address is **public**.
- There is **no existing WHOIS record** for that IP.
- WHOIS lookup is **enabled** for the device's organization.

Behavior with Shared IP Addresses
---------------------------------

If multiple devices share the same public IP address and one of them
switches to a different IP, the following occurs:

- A lookup is triggered for the **new IP**.
- The WHOIS record for the **old IP** is deleted.
- The next time a device still using the old IP fetches its checksum, a
new lookup is triggered, ensuring up-to-date data.

.. note::

When a device with an associated WHOIS record is deleted, its WHOIS
record is automatically removed.

.. _controller_setup_whois_lookup:

Setup Instructions
------------------

1. Create a MaxMind account: `Sign up here
<https://www.maxmind.com/en/geolite2/signup>`_.

If you already have an account, just **Sign In**.

2. Go to **Manage License Keys** in your MaxMind dashboard.
3. Generate a new license key and name it as you prefer.
4. Copy both the **Account ID** and **License Key**.
5. Set the following settings accordingly:

- Set :ref:`OPENWISP_CONTROLLER_WHOIS_ENABLED` to ``True``.
- Set :ref:`OPENWISP_CONTROLLER_WHOIS_GEOIP_ACCOUNT` to **Account ID**.
- Set :ref:`OPENWISP_CONTROLLER_WHOIS_GEOIP_KEY` to **License Key**.
31 changes: 21 additions & 10 deletions openwisp_controller/config/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1352,18 +1352,29 @@ def has_delete_permission(self, request, obj):


limits_inline_position = 0
if getattr(app_settings, "REGISTRATION_ENABLED", True):

class ConfigSettingsForm(AlwaysHasChangedMixin, forms.ModelForm):
class Meta:
widgets = {"context": FlatJsonWidget}

class ConfigSettingsInline(admin.StackedInline):
model = OrganizationConfigSettings
form = ConfigSettingsForm
class ConfigSettingsForm(AlwaysHasChangedMixin, forms.ModelForm):
class Meta:
widgets = {"context": FlatJsonWidget}


class ConfigSettingsInline(admin.StackedInline):
model = OrganizationConfigSettings
form = ConfigSettingsForm

def get_fields(self, request, obj=None):
fields = []
if app_settings.REGISTRATION_ENABLED:
fields += ["registration_enabled", "shared_secret"]
if app_settings.WHOIS_CONFIGURED:
fields += ["whois_enabled"]
fields += ["context"]
return fields


OrganizationAdmin.save_on_top = True
OrganizationAdmin.inlines.insert(0, ConfigSettingsInline)
limits_inline_position = 1
OrganizationAdmin.save_on_top = True
OrganizationAdmin.inlines.insert(0, ConfigSettingsInline)
limits_inline_position = 1

OrganizationAdmin.inlines.insert(limits_inline_position, OrganizationLimitsInline)
2 changes: 2 additions & 0 deletions openwisp_controller/config/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
vpn_peers_changed,
vpn_server_modified,
)
from .whois.handlers import connect_whois_handlers

# ensure Device.hardware_id field is not flagged as unique
# (because it's flagged as unique_together with organization)
Expand All @@ -52,6 +53,7 @@ def ready(self, *args, **kwargs):
self.register_dashboard_charts()
self.register_menu_groups()
self.notification_cache_update()
connect_whois_handlers()

def __setmodels__(self):
self.device_model = load_model("config", "Device")
Expand Down
28 changes: 27 additions & 1 deletion openwisp_controller/config/base/device.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from functools import cached_property
from hashlib import md5

from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
Expand All @@ -18,6 +19,7 @@
management_ip_changed,
)
from ..validators import device_name_validator, mac_address_validator
from ..whois.service import WHOISService
from .base import BaseModel


Expand Down Expand Up @@ -118,6 +120,11 @@ class Meta:

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initial value for last_ip is required in WHOIS
# to remove WHOIS info related to that ip address.
if app_settings.WHOIS_CONFIGURED:
self._changed_checked_fields.append("last_ip")

self._set_initial_values_for_changed_checked_fields()

def _set_initial_values_for_changed_checked_fields(self):
Expand Down Expand Up @@ -279,6 +286,8 @@ def save(self, *args, **kwargs):
self.key = self.generate_key(shared_secret)
state_adding = self._state.adding
super().save(*args, **kwargs)
if app_settings.WHOIS_CONFIGURED:
self._check_last_ip()
if state_adding and self.group and self.group.templates.exists():
self.create_default_config()
# The value of "self._state.adding" will always be "False"
Expand All @@ -299,7 +308,9 @@ def _check_changed_fields(self):
self._get_initial_values_for_checked_fields()
# Execute method for checked for each field in self._changed_checked_fields
for field in self._changed_checked_fields:
getattr(self, f"_check_{field}_changed")()
method = getattr(self, f"_check_{field}_changed", None)
if callable(method):
method()

def _is_deferred(self, field):
"""
Expand Down Expand Up @@ -490,3 +501,18 @@ def config_deactivated_clear_management_ip(cls, instance, *args, **kwargs):
is changed to 'deactivated'.
"""
cls.objects.filter(pk=instance.device_id).update(management_ip="")

@cached_property
def whois_service(self):
"""
Used as a shortcut to get WHOISService instance
for the device.
"""
return WHOISService(self)

def _check_last_ip(self):
"""Trigger WHOIS lookup if last_ip is not deferred."""
if self._initial_last_ip == models.DEFERRED:
return
self.whois_service.trigger_whois_lookup()
self._initial_last_ip = self.last_ip
20 changes: 20 additions & 0 deletions openwisp_controller/config/base/multitenancy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
from copy import deepcopy

import swapper
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from jsonfield import JSONField

from openwisp_utils.base import KeyField, UUIDModel
from openwisp_utils.fields import FallbackBooleanChoiceField

from .. import settings as app_settings
from ..exceptions import OrganizationDeviceLimitExceeded
from ..tasks import bulk_invalidate_config_get_cached_checksum

Expand All @@ -31,6 +34,11 @@ class AbstractOrganizationConfigSettings(UUIDModel):
verbose_name=_("shared secret"),
help_text=_("used for automatic registration of devices"),
)
whois_enabled = FallbackBooleanChoiceField(
help_text=_("Whether the WHOIS lookup feature is enabled"),
fallback=app_settings.WHOIS_ENABLED,
verbose_name=_("WHOIS Enabled"),
)
context = JSONField(
blank=True,
default=dict,
Expand All @@ -53,6 +61,18 @@ def __str__(self):
def get_context(self):
return deepcopy(self.context)

def clean(self):
if not app_settings.WHOIS_CONFIGURED and self.whois_enabled:
raise ValidationError(
{
"whois_enabled": _(
"WHOIS_GEOIP_ACCOUNT and WHOIS_GEOIP_KEY must be set "
+ "before enabling WHOIS feature."
)
}
)
return super().clean()

def save(
self, force_insert=False, force_update=False, using=None, update_fields=None
):
Expand Down
Loading