Skip to content

Commit fd33dc9

Browse files
committed
[feature] Allowed device to update name on re-registration
(cherry picked from commit 21002f3)
1 parent 691bae8 commit fd33dc9

File tree

5 files changed

+173
-4
lines changed

5 files changed

+173
-4
lines changed

openwisp_controller/config/base/device.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,29 @@ def get_temp_config_instance(self, **options):
435435
config.device = self
436436
return config
437437

438+
def should_skip_push_update(self):
439+
"""
440+
Returns True if the device should skip push update.
441+
Can be overridden with custom logic if needed.
442+
"""
443+
return getattr(self, "_skip_push_update", False)
444+
445+
def skip_push_update_on_save(self):
446+
"""
447+
Marks the device to skip push update on the next save.
448+
This is useful when updating device properties during re-registration
449+
to avoid triggering unnecessary push operations.
450+
"""
451+
self._skip_push_update = True
452+
453+
def clear_skip_push_update(self):
454+
"""
455+
Clears the skip push update flag.
456+
This should be called after the flag has been checked and consumed.
457+
"""
458+
if hasattr(self, "_skip_push_update"):
459+
delattr(self, "_skip_push_update")
460+
438461
def can_be_updated(self):
439462
"""
440463
returns True if the device can and should be updated

openwisp_controller/config/controller/views.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,10 @@ def post(self, request, *args, **kwargs):
406406
for attr in self.UPDATABLE_FIELDS:
407407
if attr in request.POST:
408408
setattr(device, attr, request.POST.get(attr))
409+
# update hostname if it's not a default/generic value
410+
if self._should_update_hostname(request, device):
411+
device.name = request.POST.get("name")
412+
device.skip_push_update_on_save()
409413
config = device.config
410414
# if get queryset fails, instantiate a new Device and Config
411415
except self.model.DoesNotExist:
@@ -457,6 +461,34 @@ def post(self, request, *args, **kwargs):
457461
s.format(**attributes), content_type="text/plain", status=201
458462
)
459463

464+
def _should_update_hostname(self, request, device):
465+
"""
466+
Determines whether the hostname should be updated during re-registration.
467+
468+
Returns False if the hostname equals the MAC address (agents send MAC address
469+
as hostname when hostname is OpenWrt or if default_hostname is set to *).
470+
471+
This prevents overwriting user configured hostnames with default/generic values.
472+
"""
473+
if "name" not in request.POST:
474+
return False
475+
476+
name = request.POST.get("name")
477+
mac_address = device.mac_address
478+
# normalize MAC address format (strip colons/dashes and lowercase)
479+
normalized_mac = (
480+
mac_address.replace(":", "").replace("-", "").lower()
481+
if mac_address
482+
else None
483+
)
484+
# normalize hostname for comparison
485+
normalized_name = name.replace(":", "").replace("-", "").lower()
486+
# don't update if hostname equals the MAC address
487+
if normalized_name == normalized_mac:
488+
return False
489+
490+
return True
491+
460492

461493
class GetVpnView(SingleObjectMixin, View):
462494
"""

openwisp_controller/config/tests/test_controller.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,87 @@ def test_device_registration_update_hw_info_no_config(self):
820820
self.assertEqual(d.system, params["system"])
821821
self.assertEqual(d.model, params["model"])
822822

823+
def test_device_registration_update_hostname(self):
824+
"""
825+
Test that hostname is updated when the name in payload
826+
is not the MAC address stored in OpenWISP
827+
"""
828+
device = self._create_device_config(
829+
device_opts={
830+
"name": "old-hostname",
831+
"mac_address": TEST_MACADDR,
832+
"key": TEST_CONSISTENT_KEY,
833+
}
834+
)
835+
params = {
836+
"secret": TEST_ORG_SHARED_SECRET,
837+
"name": "new-custom-hostname",
838+
"mac_address": TEST_MACADDR,
839+
"key": TEST_CONSISTENT_KEY,
840+
"backend": "netjsonconfig.OpenWrt",
841+
"model": "TP-Link TL-WDR4300 v2",
842+
"os": "OpenWrt 18.06-SNAPSHOT r7312-e60be11330",
843+
"system": "Atheros AR9344 rev 3",
844+
}
845+
self.assertNotEqual(device.name, params["name"])
846+
response = self.client.post(self.register_url, params)
847+
self.assertEqual(response.status_code, 201)
848+
device.refresh_from_db()
849+
self.assertEqual(device.name, "new-custom-hostname")
850+
851+
def test_device_registration_hostname_not_updated_when_mac_address(self):
852+
"""
853+
Test that hostname is not updated when the name in payload
854+
equals the MAC address (agents send MAC address as hostname
855+
when hostname is OpenWrt or if default_hostname is set to *)
856+
"""
857+
device = self._create_device_config(
858+
device_opts={
859+
"name": "meaningful-hostname",
860+
"key": TEST_CONSISTENT_KEY,
861+
}
862+
)
863+
params = {
864+
"secret": TEST_ORG_SHARED_SECRET,
865+
"name": TEST_MACADDR,
866+
"mac_address": TEST_MACADDR,
867+
"key": TEST_CONSISTENT_KEY,
868+
"backend": "netjsonconfig.OpenWrt",
869+
"model": "TP-Link TL-WDR4300 v2",
870+
"os": "OpenWrt 18.06-SNAPSHOT r7312-e60be11330",
871+
"system": "Atheros AR9344 rev 3",
872+
}
873+
response = self.client.post(self.register_url, params)
874+
self.assertEqual(response.status_code, 201)
875+
device.refresh_from_db()
876+
self.assertEqual(device.name, "meaningful-hostname")
877+
878+
def test_device_registration_hostname_comparison_case_insensitive(self):
879+
"""
880+
Test that MAC address comparison is case-insensitive and works
881+
with different formats (colons, dashes, no separators)
882+
"""
883+
mac_address = "00:11:22:33:aa:BB"
884+
d = self._create_device_config(
885+
device_opts={
886+
"mac_address": mac_address,
887+
"name": "configured-hostname",
888+
"key": TEST_CONSISTENT_KEY,
889+
}
890+
)
891+
params = {
892+
"secret": TEST_ORG_SHARED_SECRET,
893+
"name": "00-11-22-33-AA-bb",
894+
"mac_address": mac_address,
895+
"key": TEST_CONSISTENT_KEY,
896+
"backend": "netjsonconfig.OpenWrt",
897+
}
898+
response = self.client.post(self.register_url, params)
899+
self.assertEqual(response.status_code, 201)
900+
d.refresh_from_db()
901+
# Hostname should not be changed
902+
self.assertEqual(d.name, "configured-hostname")
903+
823904
def test_device_report_status_running(self):
824905
"""
825906
maintained for backward compatibility

openwisp_controller/connection/apps.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def ready(self):
6464

6565
@classmethod
6666
def config_modified_receiver(cls, **kwargs):
67-
transaction.on_commit(lambda: cls._launch_update_config(kwargs["device"].pk))
67+
transaction.on_commit(lambda: cls._launch_update_config(kwargs["device"]))
6868

6969
@classmethod
7070
def command_save_receiver(cls, sender, created, instance, **kwargs):
@@ -81,14 +81,18 @@ def command_save_receiver(cls, sender, created, instance, **kwargs):
8181
)
8282

8383
@classmethod
84-
def _launch_update_config(cls, device_id):
84+
def _launch_update_config(cls, device):
8585
"""
8686
Calls the background task update_config only if
8787
no other tasks are running for the same device
8888
"""
8989
from .tasks import update_config
9090

91-
update_config.delay(device_id)
91+
# Check if push update should be skipped
92+
if device.should_skip_push_update():
93+
device.clear_skip_push_update()
94+
return
95+
update_config.delay(str(device.pk))
9296

9397
@classmethod
9498
def is_working_changed_receiver(

openwisp_controller/connection/tests/test_tasks.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
from unittest import mock
55

66
from celery.exceptions import SoftTimeLimitExceeded
7-
from django.test import TestCase
7+
from django.test import TestCase, TransactionTestCase
8+
from django.urls import reverse
89
from swapper import load_model
910

1011
from .. import tasks
1112
from .utils import CreateConnectionsMixin
1213

1314
Command = load_model("connection", "Command")
15+
OrganizationConfigSettings = load_model("config", "OrganizationConfigSettings")
1416

1517

1618
class TestTasks(CreateConnectionsMixin, TestCase):
@@ -85,3 +87,30 @@ def test_launch_command_exception(self, *args):
8587
command.refresh_from_db()
8688
self.assertEqual(command.status, "failed")
8789
self.assertEqual(command.output, "Internal system error: test error\n")
90+
91+
92+
class TestTransactionTasks(CreateConnectionsMixin, TransactionTestCase):
93+
@mock.patch.object(tasks.update_config, "delay")
94+
def test_update_config_hostname_changed_on_reregister(self, mocked_update_config):
95+
org = self._get_org()
96+
config_settings = OrganizationConfigSettings.objects.create(
97+
organization=org, context={}, shared_secret="secret"
98+
)
99+
device = self._create_device(
100+
name="AA-BB-CC-DD-EE-FF", organization=org, key="test-key"
101+
)
102+
self._create_config(device=device)
103+
self._create_device_connection(device=device)
104+
# Trigger re-registration with new hostname
105+
response = self.client.post(
106+
reverse("controller:device_register"),
107+
{
108+
"name": "new-hostname",
109+
"secret": str(config_settings.shared_secret),
110+
"key": str(device.key),
111+
"mac_address": device.mac_address,
112+
"backend": "netjsonconfig.OpenWrt",
113+
},
114+
)
115+
self.assertEqual(response.status_code, 201)
116+
mocked_update_config.assert_not_called()

0 commit comments

Comments
 (0)