Skip to content

Commit 9056650

Browse files
authored
Create nextdns_sync.py
1 parent cf4ec0f commit 9056650

1 file changed

Lines changed: 197 additions & 0 deletions

File tree

nextdns_sync.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import requests
2+
import json
3+
import logging
4+
5+
6+
TIMEOUT = 10
7+
NEXT_DNS_API = "https://api.nextdns.io"
8+
API_PROFILE_ROUTE = "profiles"
9+
API_KEY = "REMOVED"
10+
11+
PROFILE_MAIN = "REMOVED"
12+
PROFILE_RPM = "REMOVED"
13+
PROFILE_BETSY = "REMOVED"
14+
PROFILE_LEONA = "REMOVED"
15+
PROFILE_HURLEY = "REMOVED"
16+
profile_sync_list = [PROFILE_RPM, PROFILE_BETSY, PROFILE_LEONA, PROFILE_HURLEY]
17+
18+
TLD_BAN_LIST = [
19+
"autos", "best", "bid", "bio", "boats", "boston", "boutique", "charity",
20+
"christmas", "dance", "fishing", "hair", "haus", "loan", "loans", "men",
21+
"mom", "name", "review", "rip", "skin", "support", "tattoo", "tokyo",
22+
"voto", "sbs", "ooo", "gdn", "zip"
23+
]
24+
TLD_BAN_LIST = sorted(set(TLD_BAN_LIST))
25+
TLD_BAN_LIST_DICT = [{'id': tld} for tld in TLD_BAN_LIST]
26+
TLD_BAN_PAYLOAD = {"tlds": TLD_BAN_LIST_DICT}
27+
28+
headers = {
29+
"X-Api-Key": API_KEY,
30+
"Content-Type": "application/json"
31+
}
32+
33+
def setup_logger(name):
34+
"""Sets up a logger with a given name."""
35+
logger = logging.getLogger(name)
36+
logger.setLevel(logging.DEBUG) # Set to DEBUG to capture all messages
37+
38+
# Create a stream handler and set its format
39+
handler = logging.StreamHandler()
40+
formatter = logging.Formatter('%(asctime)s|%(levelname)s|%(message)s')
41+
handler.setFormatter(formatter)
42+
43+
# Add the handler if it doesn't already exist
44+
if not logger.hasHandlers():
45+
logger.addHandler(handler)
46+
47+
return logger
48+
49+
# Create a logger instance with the current module name
50+
logger = setup_logger(__name__)
51+
52+
def api_request(method, url, headers=None, data=None, json=None, timeout=10):
53+
"""
54+
Makes an HTTP request and handles 400 Bad Request errors by throwing the full response.
55+
56+
Args:
57+
method (str): HTTP method (GET, POST, PATCH, etc.).
58+
url (str): The endpoint URL.
59+
headers (dict): Optional headers to include in the request.
60+
data (dict): Data to send in the body of the request (for POST, PATCH).
61+
json (dict): JSON data to send in the body of the request (for POST, PATCH).
62+
timeout (int): Timeout for the request.
63+
64+
Returns:
65+
dict: The JSON response if the request is successful.
66+
67+
Raises:
68+
Exception: If the request returns a 400 Bad Request, or any other HTTP error.
69+
"""
70+
logger.info("[API CALL] USING %s", method)
71+
if data is not None:
72+
logger.info("[API CALL] Using payload {json.dumps(data, separators=(',', ':'))}")
73+
response = requests.request(method, url, headers=headers, data=data, json=json, timeout=timeout)
74+
response_code = response.status_code
75+
if response_code == 400:
76+
raise Exception(f"[API CALL] Bad Request: {response.status_code} - {response.text}")
77+
response.raise_for_status()
78+
if response_code not in (200, 204):
79+
logger.info("[API CALL] Response: %s", response)
80+
else:
81+
logger.info("[API CALL] Request succeeded")
82+
return response
83+
84+
# Using make_request in fetch_profile_settings
85+
def fetch_profile_settings(profile_id):
86+
"""Fetches the settings for a given profile ID and returns the allowlist and denylist."""
87+
url = f"{NEXT_DNS_API}/{API_PROFILE_ROUTE}/{profile_id}/"
88+
return api_request("GET", url, headers=headers, timeout=TIMEOUT)
89+
90+
91+
def filter_blocklists(blocklists):
92+
"""Filter blocklists to only include the 'id' field."""
93+
return [{"id": blocklist.get("id")} for blocklist in blocklists if blocklist.get("id")]
94+
95+
96+
def build_payload(data, keys_to_sync):
97+
return {key: data.get(key, []) for key in keys_to_sync if data.get(key) is not None}
98+
99+
100+
def alpha_sort_lists(data):
101+
"""Sorts the allowlist and denylist alphabetically by 'id'."""
102+
if "allowlist" in data:
103+
data["allowlist"] = sorted(data["allowlist"], key=lambda x: x['id'])
104+
if "denylist" in data:
105+
data["denylist"] = sorted(data["denylist"], key=lambda x: x['id'])
106+
return data
107+
108+
109+
# Using make_request in update_profile_settings
110+
def update_profile_settings(profile_id, payload, route=None, method="PATCH"):
111+
"""Updates the settings for a given profile ID using the provided allowlist and denylist."""
112+
logger.info(f"[UPDATE-PROFILE] Updating profile settings for profile {profile_id}...")
113+
if route is None:
114+
url = f"{NEXT_DNS_API}/{API_PROFILE_ROUTE}/{profile_id}/settings"
115+
else:
116+
url = f"{NEXT_DNS_API}/{API_PROFILE_ROUTE}/{profile_id}/{route}"
117+
118+
if payload is None:
119+
raise ValueError("[UPDATE-PROFILE] Payload cannot be None. Please provide a valid payload.")
120+
return api_request(method, url, headers=headers, json=payload, timeout=TIMEOUT)
121+
122+
123+
def update_array_settings(profile_id, key, payload, route=None, method="PUT"):
124+
"""Updates array settings like denylist or blocklists."""
125+
logger.info(f"[UPDATE-ARRAY] Updating array settings for profile {profile_id}...")
126+
if route is None:
127+
url = f"{NEXT_DNS_API}/{API_PROFILE_ROUTE}/{profile_id}/{key}"
128+
else:
129+
url = f"{NEXT_DNS_API}/{API_PROFILE_ROUTE}/{profile_id}/{route}/{key}"
130+
131+
if not isinstance(payload, list):
132+
raise ValueError("[UPDATE-ARRAY] Payload must be a list for array updates.")
133+
logger.info("[UPDATE-ARRAY] Url for [%s] is %s", profile_id, url)
134+
return api_request(method, url, headers=headers, json=payload, timeout=TIMEOUT)
135+
136+
137+
def update_security_settings(profile_id, tlds_payload, method="PATCH"):
138+
url = f"{NEXT_DNS_API}/{API_PROFILE_ROUTE}/{profile_id}/security"
139+
140+
logger.info(f"[UPDATE-SECURITY] Updating security settings for profile {profile_id}...")
141+
logger.info(f"[UPDATE-SECURITY] url for [{profile_id}] is {url}")
142+
try:
143+
response = api_request(method, url, headers=headers, json=tlds_payload, timeout=TIMEOUT)
144+
logger.info("[UPDATE-SECURITY] Request succeeded")
145+
return response
146+
except Exception as e:
147+
logger.error(f"[UPDATE-SECURITY] Failed to update security settings: {e}")
148+
raise
149+
150+
151+
def sync_profiles(keys_to_sync, payload=None):
152+
"""Syncs the allowlist, denylist, and other settings from the main profile to the target profiles."""
153+
try:
154+
settings = fetch_profile_settings(PROFILE_MAIN).json()
155+
data = settings['data']
156+
logger.info("[SYNC] Settings from Profile [PROFILE_MAIN]: %s", PROFILE_MAIN)
157+
158+
if payload is None:
159+
payload = build_payload(data, keys_to_sync)
160+
# Alpha sort allowlist and denylist
161+
payload = alpha_sort_lists(payload)
162+
logger.info("[SYNC] Generated Payload:")
163+
logger.info(f"[SYNC] Using payload {json.dumps(data, separators=(',', ':'))}")
164+
165+
for profile_id in profile_sync_list:
166+
for key in keys_to_sync:
167+
if key in payload:
168+
key_payload = payload[key]
169+
logger.info("[SYNC] Using Key [%s]", key)
170+
logger.debug(f"[SYNC] Payload is {key_payload}")
171+
if "blocklists" in key_payload:
172+
logger.info("[SYNC] [blocklists] to be filtered")
173+
logger.debug("[SYNC] Pre Filter of [privacy][blocklists]")
174+
key_payload["blocklists"] = filter_blocklists(key_payload["blocklists"])
175+
logger.debug("[SYNC] Filtered [blocklists]")
176+
logger.debug(key_payload["blocklists"])
177+
try:
178+
if isinstance(key_payload, list):
179+
update_array_settings(profile_id, key, key_payload)
180+
else:
181+
update_profile_settings(profile_id, key_payload, key)
182+
logger.info("[SYNC] Successfully updated %s for profile %s.", key, profile_id)
183+
except Exception as e:
184+
logger.error("[SYNC] Failed to update %s for profile %s: %s", key, profile_id, e)
185+
186+
except Exception as e:
187+
logger.error("[SYNC] Failed to sync profiles: %s", e)
188+
raise
189+
190+
191+
# Run the sync process
192+
keys_to_sync = ["allowlist", "denylist", "parentalControl", "security", "privacy"]
193+
sync_profiles(keys_to_sync)
194+
195+
# Ad Hoc Main Updates
196+
## TLDs
197+
# update_security_settings(PROFILE_MAIN, TLD_BAN_PAYLOAD)

0 commit comments

Comments
 (0)