Skip to content

Conversation

@stktyagi
Copy link
Member

@stktyagi stktyagi commented Feb 8, 2026

Blocks modification of Subnet and IpAddress objects if they are currently linked to an active VPN connection with connected clients.

Fixes #1096

Checklist

  • I have read the OpenWISP Contributing Guidelines.
  • I have manually tested the changes proposed in this pull request.
  • I have written new test cases for new code and/or updated existing tests for changes to existing code.
  • I have updated the documentation.

Reference to Existing Issue

Closes #1096.

Description of Changes

Prevents changing subnet and ip addresses if they are currently
linked to an active VPN connection with connected clients.

Blocks modification of Subnet and IpAddress objects if they are currently linked to an active VPN connection with connected clients.

Fixes #1096
@coderabbitai
Copy link

coderabbitai bot commented Feb 8, 2026

Walkthrough

Loads subnet_model and ip_model in the config app, applies a runtime clean() patch via apply_model_clean_patch, and registers check_ipam_change_handler on pre_save for both models. apply_model_clean_patch wraps a model's clean() to invoke a provided validation function. check_ipam_change_handler compares old vs new subnet/IP on updates and raises ValidationError if the object is referenced by any VpnClient. Tests were added covering blocked and allowed changes depending on existing VPN clients.

Sequence Diagram(s)

sequenceDiagram
    participant Admin as User/Admin
    participant UI as Django Admin UI
    participant Model as Subnet/IP Model
    participant Signal as pre_save Signal
    participant Handler as check_ipam_change_handler
    participant DB as Database (VpnClient)

    Admin->>UI: Submit edit to subnet/IP
    UI->>Model: call save()
    Model->>Model: invoke clean() (patched -> calls validation)
    Model->>Signal: emit pre_save
    Signal->>Handler: invoke(sender, instance)
    Handler->>DB: query VpnClient for matching ip or subnet
    DB-->>Handler: return matches (or none)
    alt matches found
        Handler->>Model: raise ValidationError
        Model->>UI: surface error to admin
    else no matches
        Handler->>Model: allow save
        Model->>DB: persist changes
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Title check ⚠️ Warning The title contains a typo ('Rrevent' instead of 'Prevent') and does not clearly convey the main change due to the spelling error and inclusion of issue number. Correct the typo: '[fix] Prevent IPAM changes when VPN clients exist' to clearly communicate the change without spelling errors.
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The description adequately covers the main change, includes the linked issue reference, and confirms that testing and code changes were completed; documentation was intentionally deferred.
Linked Issues check ✅ Passed The code implements the core requirements: blocking modifications to Subnet and IpAddress objects when linked to active VPN connections [#1096], with signal handlers, validation functions, and comprehensive tests.
Out of Scope Changes check ✅ Passed All changes directly support the stated objective: new model attributes, signal handlers, validation logic, a utility patch function, and tests all align with preventing IPAM modifications when VPN clients exist.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch issues/1096-prevent-changes-in-ipam

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
openwisp_controller/config/handlers.py (2)

171-174: Error messages say "active" but the check is for existence, not connection state.

The queries check whether any VpnClient record exists, not whether a client is currently connected. If a VpnClient row can persist after a client disconnects, the wording "active VPN connection" / "active clients" could be misleading to admins. Consider softening to e.g. "existing VPN clients" unless VpnClient records are always cleaned up on disconnect.

Also applies to: 180-183


164-174: Consider using model identity instead of hasattr to distinguish between IpAddress and Subnet.

The handler is connected to two senders (IpAddress and Subnet), but uses hasattr(instance, "ip_address") to branch logic. This is fragile — if Subnet ever gains an ip_address attribute (through inheritance, properties, or reverse relations), the check silently takes the wrong path. Since the sender parameter provides explicit model identity, checking it directly is more maintainable.

Suggested approach
+IpAddress = load_model("openwisp_ipam", "IpAddress")
+Subnet = load_model("openwisp_ipam", "Subnet")
+
 def check_ipam_change_handler(sender, instance, **kwargs):
     if instance._state.adding or kwargs.get("raw"):
         return
     try:
-        if hasattr(instance, "ip_address"):
+        if sender is IpAddress:
             old_instance = sender.objects.only("ip_address").get(pk=instance.pk)
📜 Recent review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f3b0c7f and 5be7b38.

📒 Files selected for processing (1)
  • openwisp_controller/config/handlers.py
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2026-01-15T15:05:49.557Z
Learnt from: DragnEmperor
Repo: openwisp/openwisp-controller PR: 1175
File: openwisp_controller/config/management/commands/clear_last_ip.py:38-42
Timestamp: 2026-01-15T15:05:49.557Z
Learning: In Django projects, when using select_related() to traverse relations (for example, select_related("organization__config_settings")), the traversed relation must not be deferred. If you also use .only() in the same query, include the relation name or FK field (e.g., "organization" or "organization_id") in the .only() list to avoid the error "Field X cannot be both deferred and traversed using select_related at the same time." Apply this guideline to Django code in openwisp_controller/config/management/commands/clear_last_ip.py and similar modules by ensuring any select_related with an accompanying only() includes the related field names to prevent deferred/traversed conflicts.

Applied to files:

  • openwisp_controller/config/handlers.py
📚 Learning: 2026-01-15T15:07:17.354Z
Learnt from: DragnEmperor
Repo: openwisp/openwisp-controller PR: 1175
File: openwisp_controller/geo/estimated_location/tests/tests.py:172-175
Timestamp: 2026-01-15T15:07:17.354Z
Learning: In this repository, flake8 enforces E501 (line too long) via setup.cfg (max-line-length = 88) while ruff ignores E501 via ruff.toml. Therefore, use '# noqa: E501' on lines that intentionally exceed 88 characters to satisfy flake8 without affecting ruff checks. This applies to Python files across the project (any .py) and is relevant for tests as well. Use sparingly and only where breaking lines is not feasible without hurting readability or functionality.

Applied to files:

  • openwisp_controller/config/handlers.py
🧬 Code graph analysis (1)
openwisp_controller/config/handlers.py (2)
openwisp_controller/config/controller/views.py (4)
  • get (147-161)
  • get (199-207)
  • get (520-526)
  • get (534-542)
openwisp_controller/config/admin.py (1)
  • ip (834-836)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: Python==3.12 | django~=4.2.0
  • GitHub Check: Python==3.11 | django~=5.1.0
  • GitHub Check: Python==3.10 | django~=4.2.0
  • GitHub Check: Python==3.12 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.2.0
  • GitHub Check: Python==3.13 | django~=5.1.0
  • GitHub Check: Python==3.11 | django~=4.2.0
  • GitHub Check: Python==3.13 | django~=5.2.0
  • GitHub Check: Python==3.10 | django~=5.1.0
  • GitHub Check: Python==3.10 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=5.2.0
🔇 Additional comments (4)
openwisp_controller/config/handlers.py (4)

1-19: New imports and model loading look correct.

ValidationError, Q, and VpnClient are all used by the new check_ipam_change_handler. No concerns.


160-162: Early-return guards are solid.

Both _state.adding and raw checks are in place, preventing the handler from interfering with new object creation and fixture loading.


168-170: VpnClient queries look correct for both branches.

  • IP branch: Q(ip=instance) | Q(vpn__ip=instance) covers both the client-assigned IP and the VPN server's own IP.
  • Subnet branch: vpn__subnet=instance correctly traverses the VpnClient → Vpn → Subnet relation.

The exists() calls are efficient for a simple presence check.

Also applies to: 179-179


184-187: DoesNotExist guard and final raise look good.

The except sender.DoesNotExist: return cleanly handles the race where the row is deleted between the _state.adding check and the DB lookup. Raising ValidationError at line 187 outside the try block is correctly scoped.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@openwisp_controller/config/handlers.py`:
- Around line 160-184: In check_ipam_change_handler, guard the DB lookups and
skip raw saves: first return early if kwargs.get("raw") is True; then replace
the direct .get(pk=instance.pk) calls (in both the ip_address branch and the
subnet branch) with a safe fetch that handles DoesNotExist (either try/except
sender.DoesNotExist around the .get or use
sender.objects.only(...).filter(pk=instance.pk).first() and return if None);
keep the existing comparison logic and only proceed to the VpnClient queries if
the old instance was successfully retrieved.
🧹 Nitpick comments (6)
openwisp_controller/config/utils.py (1)

274-286: if original_clean: is always truthy — consider removing the guard.

model.clean is always a callable (at minimum, django.db.models.Model.clean), so the if original_clean: branch is always entered. The guard is misleading. Just call original_clean(instance) unconditionally.

Also, the validation_func here (check_ipam_change_handler) receives (model, instance) which maps to its (sender, instance, **kwargs) signature — this works, but passing a handler designed for signal dispatch through a completely different call path is fragile. If anyone later adds logic that depends on **kwargs (e.g., checking kwargs.get('raw')), the clean path will silently diverge.

Proposed simplification
 def apply_model_clean_patch(model, validation_func):
     """
     Injects a validation function into a model's clean() method
     at runtime to maintain clean ui
     """
     original_clean = model.clean
 
     def patched_clean(instance):
-        if original_clean:
-            original_clean(instance)
+        original_clean(instance)
         validation_func(model, instance)
 
     model.clean = patched_clean
openwisp_controller/config/handlers.py (1)

163-173: Relying on hasattr(instance, "ip_address") to distinguish model types is brittle.

If the Subnet model ever gains an ip_address attribute (e.g., through a mixin or property), this logic will silently take the wrong branch. Using isinstance checks or comparing sender against the known model class would be more robust.

Alternative approach
-    if hasattr(instance, "ip_address"):
+    from swapper import load_model
+    IpAddress = load_model("openwisp_ipam", "IpAddress")
+    if isinstance(instance, IpAddress):

Or compare sender against the model loaded at module level.

openwisp_controller/config/apps.py (1)

163-174: Double validation: both pre_save signal and clean() patch invoke the same handler.

For admin saves, full_clean() → patched clean() → handler runs, and if it passes, save()pre_save signal → handler runs again, hitting the DB a second time to re-fetch the old value. This is the cost of covering both admin (clean) and programmatic (pre_save) code paths.

Consider short-circuiting the pre_save path when the check has already passed during clean() in the same request cycle (e.g., with an instance-level flag), or accept the extra query as acceptable overhead.

openwisp_controller/config/tests/test_vpn.py (3)

729-736: Address unused variables flagged by linter, and add a positive test for subnet changes.

The linter flags device and template as unused (RUF059). Prefix with _ to signal intentional disuse.

Also, there's no test for the complementary case: allowing subnet changes when no VPN clients exist (analogous to test_allow_ip_change_without_vpn_clients). This would ensure the guard doesn't over-block.

Fix unused variables
     def test_prevent_subnet_change_with_vpn_clients(self):
-        device, vpn, template = self._create_wireguard_vpn_template()
+        _device, vpn, _template = self._create_wireguard_vpn_template()
         subnet = vpn.subnet
         with self.subTest("Test subnet change blocked when clients exist"):
             subnet.subnet = "10.0.2.0/24"
             with self.assertRaises(ValidationError) as cm:
                 subnet.full_clean()
             self.assertIn("Cannot modify this subnet", str(cm.exception))

Do you want me to generate the test_allow_subnet_change_without_vpn_clients test?


738-752: Prefix unused template variable.

Linter flags template as unused on line 739.

Fix
     def test_prevent_ip_change_with_vpn_clients(self):
-        device, vpn, template = self._create_wireguard_vpn_template()
+        device, vpn, _template = self._create_wireguard_vpn_template()

754-762: Consider also testing the pre_save signal path directly.

These tests only exercise the full_clean() → patched clean() path. A test that calls save() directly (skipping full_clean()) would verify that the pre_save signal handler also blocks changes, confirming the defense-in-depth works for programmatic saves.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 012a094 and e46b1cb.

📒 Files selected for processing (4)
  • openwisp_controller/config/apps.py
  • openwisp_controller/config/handlers.py
  • openwisp_controller/config/tests/test_vpn.py
  • openwisp_controller/config/utils.py
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2026-01-15T15:05:49.557Z
Learnt from: DragnEmperor
Repo: openwisp/openwisp-controller PR: 1175
File: openwisp_controller/config/management/commands/clear_last_ip.py:38-42
Timestamp: 2026-01-15T15:05:49.557Z
Learning: In Django projects, when using select_related() to traverse relations (for example, select_related("organization__config_settings")), the traversed relation must not be deferred. If you also use .only() in the same query, include the relation name or FK field (e.g., "organization" or "organization_id") in the .only() list to avoid the error "Field X cannot be both deferred and traversed using select_related at the same time." Apply this guideline to Django code in openwisp_controller/config/management/commands/clear_last_ip.py and similar modules by ensuring any select_related with an accompanying only() includes the related field names to prevent deferred/traversed conflicts.

Applied to files:

  • openwisp_controller/config/tests/test_vpn.py
  • openwisp_controller/config/utils.py
  • openwisp_controller/config/handlers.py
  • openwisp_controller/config/apps.py
📚 Learning: 2026-01-15T15:07:17.354Z
Learnt from: DragnEmperor
Repo: openwisp/openwisp-controller PR: 1175
File: openwisp_controller/geo/estimated_location/tests/tests.py:172-175
Timestamp: 2026-01-15T15:07:17.354Z
Learning: In this repository, flake8 enforces E501 (line too long) via setup.cfg (max-line-length = 88) while ruff ignores E501 via ruff.toml. Therefore, use '# noqa: E501' on lines that intentionally exceed 88 characters to satisfy flake8 without affecting ruff checks. This applies to Python files across the project (any .py) and is relevant for tests as well. Use sparingly and only where breaking lines is not feasible without hurting readability or functionality.

Applied to files:

  • openwisp_controller/config/tests/test_vpn.py
  • openwisp_controller/config/utils.py
  • openwisp_controller/config/handlers.py
  • openwisp_controller/config/apps.py
📚 Learning: 2026-01-12T22:27:48.342Z
Learnt from: nemesifier
Repo: openwisp/openwisp-controller PR: 1175
File: tests/openwisp2/sample_config/migrations/0008_whoisinfo_organizationconfigsettings_whois_enabled.py:18-67
Timestamp: 2026-01-12T22:27:48.342Z
Learning: In tests/openwisp2/sample_config/models.py and corresponding test migrations, the WHOISInfo model intentionally includes an additional "details" field not present in the base AbstractWHOISInfo model. This is a testing pattern to verify that swappable models (CONFIG_WHOISINFO_MODEL) can be extended with custom fields without errors.

Applied to files:

  • openwisp_controller/config/apps.py
🧬 Code graph analysis (1)
openwisp_controller/config/tests/test_vpn.py (3)
openwisp_controller/config/tests/utils.py (2)
  • _create_wireguard_vpn_template (168-183)
  • _create_wireguard_vpn (148-166)
openwisp_controller/config/base/config.py (2)
  • full_clean (558-562)
  • save (578-605)
openwisp_controller/config/base/vpn.py (2)
  • save (244-293)
  • save (870-878)
🪛 Ruff (0.14.14)
openwisp_controller/config/tests/test_vpn.py

[warning] 730-730: Unpacked variable device is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)


[warning] 730-730: Unpacked variable template is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)


[warning] 739-739: Unpacked variable template is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)

openwisp_controller/config/handlers.py

[warning] 160-160: Unused function argument: kwargs

(ARG001)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: Python==3.12 | django~=5.2.0
  • GitHub Check: Python==3.13 | django~=5.1.0
  • GitHub Check: Python==3.10 | django~=5.2.0
  • GitHub Check: Python==3.10 | django~=5.1.0
  • GitHub Check: Python==3.11 | django~=5.1.0
  • GitHub Check: Python==3.12 | django~=5.1.0
  • GitHub Check: Python==3.10 | django~=4.2.0
  • GitHub Check: Python==3.13 | django~=5.2.0
  • GitHub Check: Python==3.11 | django~=4.2.0
  • GitHub Check: Python==3.12 | django~=4.2.0
  • GitHub Check: Python==3.11 | django~=5.2.0
🔇 Additional comments (1)
openwisp_controller/config/apps.py (1)

64-65: openwisp_ipam is a required dependency—no changes needed.

openwisp_ipam is explicitly listed in requirements.txt, making it a required dependency of this package. Loading its models (Subnet and IpAddress) at app startup is the correct and expected behavior, consistent with how other required dependencies like django_x509 and openwisp_users are handled in the same method.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

@stktyagi
Copy link
Member Author

stktyagi commented Feb 8, 2026

output.mp4
output2.mp4

@coveralls
Copy link

coveralls commented Feb 8, 2026

Coverage Status

coverage: 98.57% (-0.04%) from 98.607%
when pulling 5be7b38 on issues/1096-prevent-changes-in-ipam
into 012a094 on master.

Added missing DoesNotExist guard and raw signal check.

Fixes #1096
Prefixed unused variables with underscore to signal intentional disuse.

Fixes #1096
Added DoesNotExist guards and raw signal checks to handle fixtures safely.

Fixes #1096
@stktyagi stktyagi changed the title [fix] Rrevent IPAM changes when VPN clients exist #1096 [fix] Prevent IPAM changes when VPN clients exist #1096 Feb 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Prevent changing subnet and IP objects assigned to a VPN Server

2 participants