Skip to content

feat: add adguard_export plugin#1649

Merged
jokob-sk merged 4 commits into
netalertx:mainfrom
natecj:feat/adguard-export-plugin
May 29, 2026
Merged

feat: add adguard_export plugin#1649
jokob-sk merged 4 commits into
netalertx:mainfrom
natecj:feat/adguard-export-plugin

Conversation

@natecj
Copy link
Copy Markdown
Contributor

@natecj natecj commented May 20, 2026

Summary

  • Adds a new adguard_export plugin that syncs known devices from the NetAlertX database to AdGuard Home as persistent clients, keeping names, MAC addresses, IP addresses, and device-type tags in sync
  • Supports scheduled runs, optional offline/new-device filtering, MAC-or-IP identifier preference, and safe scoped deletion (only removes clients the plugin previously created, tracked via a local state file)
  • Fixes the adguard_import config.json description placeholder and a minor indentation inconsistency

New files

  • front/plugins/adguard_export/script.py — main sync script
  • front/plugins/adguard_export/config.json — plugin metadata and settings
  • front/plugins/adguard_export/README.md — documentation
  • test/plugins/test_adguard_export.py — 31 pytest tests covering device-type tag mapping, client building, state file round-trips, SQLite queries, and the full sync logic

Test plan

  • pytest test/plugins/test_adguard_export.py -v passes (31 tests)
  • Plugin appears in NetAlertX Settings → Plugins after container restart
  • Configure with a real AdGuard Home instance, run once, verify clients are created with correct names/MACs/tags
  • Rename a device in NetAlertX, re-run, verify AdGuard client name updates
  • Enable ADGUARDEXP_DELETE, remove a device from NetAlertX, re-run, verify only that client is removed
  • Verify manually-added AdGuard clients are never deleted

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added AdGuard Home device export plugin with scheduling, connection/auth and TLS options, controls for including offline/new devices, MAC/IP identifier choice, name-based management, and optional safe deletion of previously exported clients.
  • Documentation

    • Added full README documenting plugin behavior, configuration, logging, troubleshooting and release metadata.
    • Clarified AdGuard import plugin description.
  • Tests

    • Added unit tests for type→tag mapping, client payload construction, managed-name persistence, device querying, and sync add/update/delete flows.

Review Change Stack

Adds a new NetAlertX plugin that syncs known devices from the NetAlertX
database to AdGuard Home as persistent clients, keeping names, MACs, IP
addresses, and device-type tags in sync.

Also fixes the adguard_import config.json description placeholder and a
minor indentation inconsistency in that file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fdb99315-9917-45c8-9025-2746be5b4bf9

📥 Commits

Reviewing files that changed from the base of the PR and between 93e534c and 727fe0e.

📒 Files selected for processing (1)
  • front/plugins/adguard_import/config.json
✅ Files skipped from review due to trivial changes (1)
  • front/plugins/adguard_import/config.json

📝 Walkthrough

Walkthrough

Adds a new AdGuard Home device export plugin: configuration and README, Python implementation that syncs NetAlertX Devices to AdGuard Home persistent clients with managed-name state and optional deletions, and a comprehensive pytest suite; plus a minor description update to the AdGuard import plugin.

Changes

AdGuard Export Plugin

Layer / File(s) Summary
Plugin configuration and documentation
front/plugins/adguard_export/config.json, front/plugins/adguard_export/README.md
Plugin metadata, settings schema (scheduling, command, URL/creds, TLS, inclusion flags, deletion guard), database column mappings, and full README describing behavior, identifier/tag rules, state file, logs, troubleshooting, and release notes.
Core plugin implementation
front/plugins/adguard_export/script.py
Module initialization and paths, STATE_FILE JSON persistence, _TYPE_TAG_MAP mapping with substring fallback, AdGuardClient HTTP wrapper, get_netalertx_devices() querying and normalization, build_agrd_client() payload builder, sync_to_adguard() indexing/add/update/delete logic (preserving non-managed fields), and main() with settings parsing, connection/error handling, and result output.
Comprehensive test suite
test/plugins/test_adguard_export.py
Pytest module with container-aware stubs, helpers and mocked AdGuardClient, and tests covering device-type→tag mapping, client payload construction, managed-name persistence, device DB filtering, and sync behaviors (add/update/skip/delete and custom-field preservation).

Import Plugin Documentation Update

Layer / File(s) Summary
Import plugin description clarification
front/plugins/adguard_import/config.json
Refines English description to state the import plugin imports known AdGuard devices as persistent devices and keeps device names and IP/MAC identifiers synchronized; ensures settings array placement.

Sequence Diagram(s):

sequenceDiagram
  participant Main as main()
  participant DB as get_netalertx_devices()
  participant Builder as build_agrd_client()
  participant Sync as sync_to_adguard()
  participant AdGuard as AdGuardClient
  participant State as managed_names

  Main->>DB: query devices with filters
  DB-->>Main: list of devices
  Main->>Sync: pass devices and flags
  Sync->>State: load_managed_names()
  State-->>Sync: managed name set
  Sync->>AdGuard: get_clients()
  AdGuard-->>Sync: existing clients list
  Sync->>Builder: build_agrd_client(device)
  Builder-->>Sync: client payload (ids,tags,name)
  Sync->>AdGuard: add_client()/update_client()/delete_client()
  AdGuard-->>Sync: HTTP responses
  Sync-->>Main: counts (added, updated, skipped, deleted)
  Main->>State: save_managed_names()
  Main-->>Main: write result file
Loading

Suggested reviewers

  • jokob-sk

"I'm a rabbit with a sync in my hop,
I map MACs and IPs until they stop,
Names kept in a file,
AdGuard and NetAlertX smile,
Hop, sync, and then home we drop!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a new adguard_export plugin to sync devices from NetAlertX to AdGuard Home.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@natecj natecj marked this pull request as ready for review May 23, 2026 02:48
Copy link
Copy Markdown
Contributor

@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: 4

🧹 Nitpick comments (2)
front/plugins/adguard_export/README.md (1)

136-140: ⚡ Quick win

Standardize the maintainer metadata block to ### Other info format.

This README uses ## Notes; plugin READMEs are expected to use the standard ### Other info block with canonical labels and date format.

Suggested patch
-## Notes
+### Other info
 
 - Version: 1.0.0
 - Author: [natecj](https://github.com/natecj)
-- Release Date: 2026-05-10
+- Release Date: 10-May-2026

Based on learnings: For each NetAlertX plugin README at front/plugins/**/README.md, include a ### Other info section with the exact standard field labels and DD-Mon-YYYY release date format.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@front/plugins/adguard_export/README.md` around lines 136 - 140, Replace the
non-standard "## Notes" block with the canonical "### Other info" metadata block
in this README: change the heading to "### Other info", use the exact standard
field labels (Version, Maintainer, Release Date) and convert the Release Date to
the DD-Mon-YYYY format (e.g., 10-May-2026); update the Author label to
"Maintainer" and ensure the maintainer value uses the same notation as other
plugins (e.g., [natecj](https://github.com/natecj)).
test/plugins/test_adguard_export.py (1)

339-347: ⚡ Quick win

Add a regression test to ensure ID-matched manual clients are not adopted into managed state.

Current tests verify unmanaged clients aren’t deleted when state is empty, but not the case where a manual client is matched by ID during sync and must still remain unmanaged.

Suggested test addition
 class TestSyncToAdguard:
@@
     def test_unmanaged_device_not_deleted(self, tmp_path):
         # State file is empty — we never added this client
         state = tmp_path / "state.json"
         state.write_text(json.dumps({"managed": []}))
         existing = [{"name": "Manual Client", "ids": ["10.0.0.50"], "tags": []}]
         agrd = _mock_agrd(existing=existing)
         with patch("script.STATE_FILE", str(state)):
             sync_to_adguard(agrd, [], use_mac=True, delete_missing=True)
         agrd.delete_client.assert_not_called()
+
+    def test_id_matched_manual_client_is_not_marked_managed(self, tmp_path):
+        state = tmp_path / "state.json"
+        state.write_text(json.dumps({"managed": []}))
+        existing = [{"name": "Manual Client", "ids": ["aa:bb:cc:00:00:01", "10.0.0.1"], "tags": ["device_pc"]}]
+        agrd = _mock_agrd(existing=existing)
+        with patch("script.STATE_FILE", str(state)):
+            sync_to_adguard(agrd, [self._device(name="Manual Client")], use_mac=True, delete_missing=False)
+            managed = load_managed_names()
+        assert managed == set()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/plugins/test_adguard_export.py` around lines 339 - 347, Add a regression
test ensuring manual clients matched by ID are not adopted: create a test (e.g.
test_unmanaged_device_not_deleted_by_id) that writes an empty managed state to
STATE_FILE, mocks existing AdGuard clients with an entry containing the matching
ID (use _mock_agrd to return existing), then call sync_to_adguard(agrd, [],
use_mac=True, delete_missing=True) and assert
agrd.delete_client.assert_not_called(); this mirrors
test_unmanaged_device_not_deleted but verifies ID-based matching doesn't convert
a manual client into managed state during sync.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@front/plugins/adguard_export/config.json`:
- Around line 340-533: The visible column objects in the config (the column
definitions mapped to cur_MAC, cur_IP, cur_Name, etc.) are missing show: true so
getColumnDefinitions() (which filters by colDef.show) hides them; update each
visible column object (e.g., the entries with "column": "Object_PrimaryID",
"Object_SecondaryID", "Watched_Value1", "Watched_Value2", "Watched_Value3",
"Watched_Value4", "Extra", "ForeignKey") to include show: true so they render in
the plugin output table.

In `@front/plugins/adguard_export/README.md`:
- Around line 96-114: Update the three fenced code blocks that show file paths
so they include a language identifier (e.g., "text") to satisfy markdownlint
MD040: change the blocks containing "/app/db/state.ADGUARDEXP.json",
"/tmp/log/plugins/script.ADGUARDEXP.log", and
"/tmp/log/plugins/last_result.ADGUARDEXP.log" to use triple backticks with a
language tag (text) instead of plain fences; keep the content unchanged and only
add the language identifier to each fence in README.md.
- Line 46: Update the README entry for ADGUARDEXP_URL to match the actual config
default: change the documented default URL from http://192.168.11.1:3000 to
http://localhost:3000 so it matches the default set for ADGUARDEXP_URL in the
plugin config (check the default value in config.json where ADGUARDEXP_URL is
defined).

In `@front/plugins/adguard_export/script.py`:
- Around line 320-342: The current logic marks any matched existing AdGuard
clients as plugin-managed by adding to managed_names inside the update and skip
branches (references: managed_names, agrd.update_client, existing, device,
client_data), which may cause deletion of manually-created clients later; change
it so we do NOT add or rename entries in managed_names when we merely match or
update an existing client (remove managed_names.add(old_name) /
managed_names.add(device["name"]) from the update and skip branches), and only
add names to managed_names at the single location where we actually create a new
client (the create_client/create branch) or when we can prove the plugin already
owned it; ensure rename logic still updates names for created-managed clients
but never converts a pre-existing manual client into managed-owned.

---

Nitpick comments:
In `@front/plugins/adguard_export/README.md`:
- Around line 136-140: Replace the non-standard "## Notes" block with the
canonical "### Other info" metadata block in this README: change the heading to
"### Other info", use the exact standard field labels (Version, Maintainer,
Release Date) and convert the Release Date to the DD-Mon-YYYY format (e.g.,
10-May-2026); update the Author label to "Maintainer" and ensure the maintainer
value uses the same notation as other plugins (e.g.,
[natecj](https://github.com/natecj)).

In `@test/plugins/test_adguard_export.py`:
- Around line 339-347: Add a regression test ensuring manual clients matched by
ID are not adopted: create a test (e.g. test_unmanaged_device_not_deleted_by_id)
that writes an empty managed state to STATE_FILE, mocks existing AdGuard clients
with an entry containing the matching ID (use _mock_agrd to return existing),
then call sync_to_adguard(agrd, [], use_mac=True, delete_missing=True) and
assert agrd.delete_client.assert_not_called(); this mirrors
test_unmanaged_device_not_deleted but verifies ID-based matching doesn't convert
a manual client into managed state during sync.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e0fd4190-ac85-4f3d-aa4f-7f0ae59be37c

📥 Commits

Reviewing files that changed from the base of the PR and between 198ca5d and 197e3a3.

📒 Files selected for processing (5)
  • front/plugins/adguard_export/README.md
  • front/plugins/adguard_export/config.json
  • front/plugins/adguard_export/script.py
  • front/plugins/adguard_import/config.json
  • test/plugins/test_adguard_export.py

Comment thread front/plugins/adguard_export/config.json
Comment thread front/plugins/adguard_export/README.md Outdated
Comment thread front/plugins/adguard_export/README.md Outdated
Comment thread front/plugins/adguard_export/script.py
- config.json: add show:true to all visible column definitions so they
  render in the plugin output table
- script.py: fix managed_names adoption bug — update/skip branches no
  longer add unowned clients to managed state; rename tracking now
  scoped to plugin-created clients only
- README.md: fix ADGUARDEXP_URL default (localhost:3000, not local IP),
  add language tags to fenced code blocks, normalise metadata block to
  Other info / Maintainer / DD-Mon-YYYY format
- test_adguard_export.py: add regression test for manual client matched
  by ID not being adopted into managed state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jokob-sk
Copy link
Copy Markdown
Collaborator

Hi @natecj - thanks a lot for the PR. Let me know once you think it's ready to go and I'll do a close review.

Comment thread front/plugins/adguard_export/script.py Outdated
Replaces the raw sqlite3 query in get_netalertx_devices() with
DeviceInstance().getAll() as suggested in code review, applying the
archived/offline/new filters in Python. Removes the sqlite3 and
fullDbPath imports. Updates tests to mock DeviceInstance.getAll().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@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.

🧹 Nitpick comments (1)
front/plugins/adguard_export/script.py (1)

51-62: ⚡ Quick win

Specify explicit UTF-8 encoding for state file I/O.

Device names may contain non-ASCII characters, and the default encoding depends on system locale (which can vary in container environments). Explicit encoding avoids potential UnicodeDecodeError/UnicodeEncodeError.

Proposed fix
 def load_managed_names() -> Set[str]:
     """Return the set of AdGuard client names we previously added."""
     try:
-        with open(STATE_FILE) as f:
+        with open(STATE_FILE, encoding="utf-8") as f:
             return set(json.load(f).get("managed", []))
     except (FileNotFoundError, json.JSONDecodeError):
         return set()


 def save_managed_names(names: Set[str]) -> None:
-    with open(STATE_FILE, "w") as f:
+    with open(STATE_FILE, "w", encoding="utf-8") as f:
         json.dump({"managed": sorted(names)}, f, indent=2)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@front/plugins/adguard_export/script.py` around lines 51 - 62, The state file
I/O in load_managed_names and save_managed_names should use explicit UTF-8 to
avoid locale-dependent decode/encode errors: open STATE_FILE with
encoding="utf-8" when reading in load_managed_names and when writing in
save_managed_names, and when dumping JSON set ensure_ascii=False so non-ASCII
device names are preserved; keep the existing fallback for
FileNotFoundError/json.JSONDecodeError in load_managed_names unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@front/plugins/adguard_export/script.py`:
- Around line 51-62: The state file I/O in load_managed_names and
save_managed_names should use explicit UTF-8 to avoid locale-dependent
decode/encode errors: open STATE_FILE with encoding="utf-8" when reading in
load_managed_names and when writing in save_managed_names, and when dumping JSON
set ensure_ascii=False so non-ASCII device names are preserved; keep the
existing fallback for FileNotFoundError/json.JSONDecodeError in
load_managed_names unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 40e01db7-76d6-4e9f-a7ea-96f386d8f734

📥 Commits

Reviewing files that changed from the base of the PR and between ca7a699 and 93e534c.

📒 Files selected for processing (2)
  • front/plugins/adguard_export/script.py
  • test/plugins/test_adguard_export.py

# ---------------------------------------------------------------------------


class TestGetNetalertxDevices:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we need these tests? I assume not plugin specific - looks like testing core NAX functionality - if we need tests like these probably not here in a plugin specific test file

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@jokob-sk, good question. My take: these tests are appropriately plugin-specific and belong here.

Here's the key distinction: get_netalertx_devices is defined in the plugin's own script.py. The tests patch DeviceInstance.getAll with mock data, so they never exercise the core NAX model — they only validate the plugin's own logic on top of it:

  • Filtering by devIsArchived, devPresentLastScan, devIsNew (plugin-controlled settings)
  • Normalizing rows into {mac, name, last_ip, dev_type}
  • Falling back to MAC as the device name when devName is empty
  • Skipping rows that lack both MAC and IP

None of that is testing core NAX functionality — it's testing the plugin's wrapper behavior. The same reasoning applies to the rest of the test classes (TestDeviceTypeToTag, TestBuildAgrdClient, TestManagedNames, TestSyncToAdguard), which are entirely plugin-scoped.

If this pattern (DeviceInstance().getAll() + filter/normalize) were shared across many plugins and the filtering logic lived in core NAX code, then it would make sense to test it centrally. But since the logic lives in this plugin's script, testing it here is the right call.


🧠 Learnings used
Learnt from: jokob-sk
Repo: netalertx/NetAlertX PR: 1435
File: test/authoritative_fields/test_field_lock_scan_integration.py:1-12
Timestamp: 2026-01-21T00:17:51.986Z
Learning: In NetAlertX, exempt test files from the 500-line code file maximum guideline. This applies to Python test files named test_*.py and any files under the test/ directories; do not apply the 500-line limit to these test files.

Learnt from: jokob-sk
Repo: netalertx/NetAlertX PR: 1621
File: server/auth/ldap_provider.py:97-97
Timestamp: 2026-04-27T00:51:56.385Z
Learning: In the NetAlertX server-side Python code, treat logging text inside `mylog(...)` as developer/debug-only: keep it in English and do not add those strings to `front/php/templates/language/en_us.json`. By contrast, any string that is user-facing (e.g., error messages returned from API responses that are rendered in the login page/front-end) must be added as a translation key to `front/php/templates/language/en_us.json` so it can be translated via Weblate.

Learnt from: jokob-sk
Repo: netalertx/NetAlertX PR: 1621
File: server/auth/ldap_provider.py:165-165
Timestamp: 2026-04-27T00:52:02.534Z
Learning: In the NetAlertX codebase, do not use inline/dynamic imports in Python (e.g., `import ...` inside functions/methods). Require all imports to be at module top level so they are auditable and missing dependencies fail at import/load time rather than later at runtime. If an optional dependency must be handled, use a top-level `try/except ImportError` block (e.g., for `ldap3`) instead of a deferred inline import.

@jokob-sk jokob-sk merged commit 8f1da10 into netalertx:main May 29, 2026
5 checks passed
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.

2 participants