From 4d4814ec8bf18defc7abdbd664263437eb59762a Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:52:00 -0600 Subject: [PATCH 1/2] fix(nextdns): preserve per-profile NRD during security sync --- nextdns_sync.py | 7 ++++++ tests/test_nextdns_sync.py | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 tests/test_nextdns_sync.py diff --git a/nextdns_sync.py b/nextdns_sync.py index 2320312..537238f 100644 --- a/nextdns_sync.py +++ b/nextdns_sync.py @@ -513,6 +513,13 @@ def sync_profiles(keys_to_sync: List[str], payload: Optional[Dict] = None) -> No key_payload["blocklists"] ) try: + if key == "security": + target_security = fetch_profile_settings(profile_id)[ + "data" + ].get("security", {}) + key_payload = build_security_payload( + key_payload, target_security + ) if isinstance(key_payload, list): update_array_settings(profile_id, key, key_payload) else: diff --git a/tests/test_nextdns_sync.py b/tests/test_nextdns_sync.py new file mode 100644 index 0000000..edd86fb --- /dev/null +++ b/tests/test_nextdns_sync.py @@ -0,0 +1,47 @@ +import copy +import unittest +from unittest.mock import call, patch + +import nextdns_sync + + +class SecuritySyncTests(unittest.TestCase): + def test_sync_preserves_each_targets_nrd_without_changing_source(self): + source = {"nrd": False, "dnsRebinding": False, "typosquatting": True} + original = copy.deepcopy(source) + profiles = { + "main": {"security": source}, + "first": {"security": {"nrd": True}}, + "second": {"security": {"nrd": False}}, + "third": {"security": {}}, + } + with ( + patch.object(nextdns_sync, "PROFILE_MAIN", "main"), + patch.object(nextdns_sync, "PROFILE_SYNC_LIST", ["first", "second", "third"]), + patch.object( + nextdns_sync, + "fetch_profile_settings", + side_effect=lambda profile: {"data": profiles[profile]}, + ), + patch.object(nextdns_sync, "update_profile_settings") as update, + ): + nextdns_sync.sync_profiles(["security"]) + + self.assertEqual( + update.call_args_list, + [ + call("first", {**source, "nrd": True}, "security", timeout=10), + call("second", source, "security", timeout=10), + call( + "third", + {"dnsRebinding": False, "typosquatting": True}, + "security", + timeout=10, + ), + ], + ) + self.assertEqual(source, original) + + +if __name__ == "__main__": + unittest.main() From 63d41e797631b9149485fbdd14dcff75e73d2e50 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:56:10 -0600 Subject: [PATCH 2/2] fix(nextdns): retain Tailnet Pro blocklist policy during sync --- nextdns_sync.py | 39 ++++++++++++++++---------- tests/test_nextdns_sync.py | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/nextdns_sync.py b/nextdns_sync.py index 537238f..82fdb89 100644 --- a/nextdns_sync.py +++ b/nextdns_sync.py @@ -178,6 +178,14 @@ def build_payload(data: Dict, keys_to_sync: List[str]) -> Dict: return {key: data.get(key, []) for key in keys_to_sync if data.get(key) is not None} +def build_privacy_payload(privacy: Dict, profile_id: Optional[str] = None) -> Dict: + """Normalize blocklists, with Pro plus OISD reserved for Tailnet.""" + blocklists = filter_blocklists(privacy.get("blocklists", [])) + if profile_id is not None and profile_id == PROFILE_TAILNET: + blocklists = [{"id": "hagezi-multi-pro"}, {"id": "oisd"}] + return {**privacy, "blocklists": sorted(blocklists, key=lambda item: item["id"])} + + def alpha_sort_lists(data: Dict) -> Dict: """Sorts the allowlist and denylist alphabetically by 'id'.""" if "allowlist" in data: @@ -345,7 +353,7 @@ def build_security_payload(source_security: Dict, target_security: Dict) -> Dict def diff_profiles() -> None: - """Shows differences between Main and all synced profiles.""" + """Shows differences from Main and the Tailnet blocklist policy.""" source = fetch_profile_settings(PROFILE_MAIN)["data"] source_name = source.get("name", PROFILE_MAIN) @@ -373,18 +381,20 @@ def diff_profiles() -> None: print(f"{'=' * 60}") for section in sections: - source_val = json.dumps( - alpha_sort_lists({section: source.get(section, {})})[section], - sort_keys=True, - ) diffs = [] for pid in PROFILE_SYNC_LIST: if pid is None or pid not in all_profiles: continue + expected = source.get(section, {}) + actual = all_profiles[pid].get(section, {}) + if section == "privacy": + expected = build_privacy_payload(expected, pid) + actual = build_privacy_payload(actual) + source_val = json.dumps( + alpha_sort_lists({section: expected})[section], sort_keys=True + ) target_val = json.dumps( - alpha_sort_lists({section: all_profiles[pid].get(section, {})})[ - section - ], + alpha_sort_lists({section: actual})[section], sort_keys=True, ) if source_val != target_val: @@ -399,13 +409,17 @@ def diff_profiles() -> None: continue src = source.get(section, {}) tgt = all_profiles[pid].get(section, {}) + if section == "privacy": + src = build_privacy_payload(src, pid) + tgt = build_privacy_payload(tgt) if isinstance(src, dict): for key in sorted(set(list(src.keys()) + list(tgt.keys()))): sv = json.dumps(src.get(key), sort_keys=True) tv = json.dumps(tgt.get(key), sort_keys=True) if sv != tv: + expected_name = "expected" if section == "privacy" else source_name print( - f" {name}.{key}: {source_name}={sv[:60]} {name}={tv[:60]}" + f" {name}.{key}: {expected_name}={sv[:60]} {name}={tv[:60]}" ) elif isinstance(src, list): src_ids = {i.get("id", "") for i in src if isinstance(i, dict)} @@ -507,11 +521,8 @@ def sync_profiles(keys_to_sync: List[str], payload: Optional[Dict] = None) -> No if key in payload: key_payload = payload[key] logger.info("[SYNC] Using Key [%s]", key) - if "blocklists" in key_payload: - logger.debug("[SYNC] [blocklists] to be filtered") - key_payload["blocklists"] = filter_blocklists( - key_payload["blocklists"] - ) + if key == "privacy": + key_payload = build_privacy_payload(key_payload, profile_id) try: if key == "security": target_security = fetch_profile_settings(profile_id)[ diff --git a/tests/test_nextdns_sync.py b/tests/test_nextdns_sync.py index edd86fb..963a934 100644 --- a/tests/test_nextdns_sync.py +++ b/tests/test_nextdns_sync.py @@ -1,5 +1,7 @@ import copy +import io import unittest +from contextlib import redirect_stdout from unittest.mock import call, patch import nextdns_sync @@ -43,5 +45,59 @@ def test_sync_preserves_each_targets_nrd_without_changing_source(self): self.assertEqual(source, original) +class PrivacySyncTests(unittest.TestCase): + def setUp(self): + self.source = { + "blocklists": [{"id": "hagezi-multi-normal", "name": "Normal"}, {"id": "oisd"}], + "allowAffiliate": True, + } + self.profiles = { + "main": {"name": "Main", "privacy": self.source}, + "tailnet": { + "name": "Tailnet", + "privacy": { + **self.source, + "blocklists": [{"id": "hagezi-multi-pro", "name": "Pro"}, {"id": "oisd"}], + }, + }, + "other": {"name": "Other", "privacy": copy.deepcopy(self.source)}, + } + for name, value in ( + ("PROFILE_MAIN", "main"), + ("PROFILE_TAILNET", "tailnet"), + ("PROFILE_SYNC_LIST", ["tailnet", "other"]), + ): + self.enterContext(patch.object(nextdns_sync, name, value)) + self.enterContext(patch.object( + nextdns_sync, "fetch_profile_settings", + side_effect=lambda profile: {"data": self.profiles[profile]}, + )) + + def test_tailnet_override_does_not_change_main_or_other_profiles(self): + original = copy.deepcopy(self.source) + with patch.object(nextdns_sync, "update_profile_settings") as update: + nextdns_sync.sync_profiles(["privacy"]) + self.assertEqual(update.call_args_list, [ + call("tailnet", {"allowAffiliate": True, "blocklists": [ + {"id": "hagezi-multi-pro"}, {"id": "oisd"}, + ]}, "privacy", timeout=10), + call("other", {"allowAffiliate": True, "blocklists": [ + {"id": "hagezi-multi-normal"}, {"id": "oisd"}, + ]}, "privacy", timeout=10), + ]) + self.assertEqual(self.source, original) + + def test_diff_accepts_expected_tailnet_tier(self): + with redirect_stdout(io.StringIO()) as output: + nextdns_sync.diff_profiles() + self.assertIn("privacy: IN SYNC", output.getvalue()) + + def test_diff_reports_wrong_tailnet_tier(self): + self.profiles["tailnet"]["privacy"] = copy.deepcopy(self.source) + with redirect_stdout(io.StringIO()) as output: + nextdns_sync.diff_profiles() + self.assertIn("privacy: DIFFERS on Tailnet", output.getvalue()) + + if __name__ == "__main__": unittest.main()