Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 32 additions & 14 deletions nextdns_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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)}
Expand Down Expand Up @@ -507,12 +521,16 @@ 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)[
"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:
Expand Down
103 changes: 103 additions & 0 deletions tests/test_nextdns_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import copy
import io
import unittest
from contextlib import redirect_stdout
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)


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()
Loading