"""Python wrapper for NextDNS API.""" from __future__ import annotations import asyncio import logging from collections.abc import Iterable from http import HTTPStatus from typing import Any, cast from aiohttp import ClientConnectorError, ClientSession from tenacity import ( after_log, retry, retry_if_exception_type, stop_after_attempt, wait_incrementing, ) from .const import ( ALLOWED_LOGS_LOCATION, ALLOWED_LOGS_RETENTION, ATTR_ANALYTICS, ATTR_BAV, ATTR_CLEAR_LOGS, ATTR_ENABLED, ATTR_GET_LOGS, ATTR_LOCATION, ATTR_LOGS, ATTR_LOGS_LOCATION, ATTR_LOGS_RETENTION, ATTR_PARENTAL_CONTROL_CATEGORIES, ATTR_PARENTAL_CONTROL_SERVICES, ATTR_PERFORMANCE, ATTR_PROFILE, ATTR_PROFILES, ATTR_RETENTION, ATTR_TEST, ATTR_WEB3, ENDPOINTS, HTTP_STATUS_TIMEOUT, MAP_DNSSEC, MAP_ENCRYPTED, MAP_IP_VERSIONS, MAP_PROFILE, MAP_PROTOCOLS, MAP_SETTING, MAP_STATUS, PARENTAL_CONTROL_CATEGORIES, PARENTAL_CONTROL_SERVICES, STOP_AFTER_ATTEMPT, TIMEOUT, WAIT_INCREMENT, WAIT_START, ) from .exceptions import ( ApiError, InvalidApiKeyError, ProfileIdNotFoundError, ProfileNameNotFoundError, SettingNotSupportedError, ) from .model import ( AllAnalytics, AnalyticsDnssec, AnalyticsEncryption, AnalyticsIpVersions, AnalyticsProtocols, AnalyticsStatus, ApiNames, ConnectionStatus, ParentalControlCategories, ParentalControlServices, Profile, ProfileInfo, Settings, ) _LOGGER = logging.getLogger(__name__) class NextDns: """Main class of NextDNS API wrapper.""" def __init__(self, session: ClientSession, api_key: str) -> None: """Initialize NextDNS API wrapper.""" self._session = session self._headers = {"X-Api-Key": api_key, "Content-Type": "application/json"} self._api_key = api_key self._profiles: list[ProfileInfo] @classmethod async def create(cls, session: ClientSession, api_key: str) -> NextDns: """Create a new instance.""" instance = cls(session, api_key) await instance.initialize() return instance async def initialize(self) -> None: """Initialize.""" _LOGGER.debug("Initializing with API Key: %s...", self._api_key[:10]) self._profiles = list(self._parse_profiles(await self.get_profiles())) @retry( retry=retry_if_exception_type((TimeoutError, ClientConnectorError)), stop=stop_after_attempt(STOP_AFTER_ATTEMPT), wait=wait_incrementing(start=WAIT_START, increment=WAIT_INCREMENT), after=after_log(_LOGGER, logging.DEBUG), ) async def get_profiles(self) -> list[dict[str, str]]: """Get all profiles.""" url = ENDPOINTS[ATTR_PROFILES] return cast(list[dict[str, str]], await self._http_request("get", url)) @retry( retry=retry_if_exception_type((TimeoutError, ClientConnectorError)), stop=stop_after_attempt(STOP_AFTER_ATTEMPT), wait=wait_incrementing(start=WAIT_START, increment=WAIT_INCREMENT), after=after_log(_LOGGER, logging.DEBUG), ) async def get_profile(self, profile_id: str) -> Profile: """Get profile.""" url = ENDPOINTS[ATTR_PROFILE].format(profile_id=profile_id) resp = await self._http_request("get", url) return Profile( **{MAP_PROFILE.get(key, key): value for key, value in resp.items()} ) async def get_settings(self, profile_id: str) -> Settings: """Get profile settings.""" profile_data = await self.get_profile(profile_id) services = { service["id"]: service["active"] for service in profile_data.parental_control[ApiNames.SERVICES] } categories = { category["id"]: category["active"] for category in profile_data.parental_control[ApiNames.CATEGORIES] } return Settings( bav=profile_data.settings[ATTR_BAV], block_page=profile_data.settings["blockPage"][ATTR_ENABLED], cache_boost=profile_data.settings[ATTR_PERFORMANCE][ApiNames.CACHE_BOOST], cname_flattening=profile_data.settings[ATTR_PERFORMANCE][ ApiNames.CNAME_FLATTENING ], anonymized_ecs=profile_data.settings[ATTR_PERFORMANCE][ApiNames.ECS], logs=profile_data.settings[ATTR_LOGS][ATTR_ENABLED], logs_location=profile_data.settings[ATTR_LOGS][ATTR_LOCATION], logs_retention=int(profile_data.settings[ATTR_LOGS][ATTR_RETENTION] / 3600), web3=profile_data.settings[ATTR_WEB3], allow_affiliate=profile_data.privacy[ApiNames.ALLOW_AFFILIATE], block_disguised_trackers=profile_data.privacy[ApiNames.DISGUISED_TRACKERS], ai_threat_detection=profile_data.security[ApiNames.AI_THREAT_TETECTION], block_csam=profile_data.security[ApiNames.CSAM], block_ddns=profile_data.security[ApiNames.DDNS], block_nrd=profile_data.security[ApiNames.NRD], block_parked_domains=profile_data.security[ApiNames.PARKING], cryptojacking_protection=profile_data.security[ApiNames.CRYPTOJACKING], dga_protection=profile_data.security[ApiNames.DGA], dns_rebinding_protection=profile_data.security[ApiNames.DNS_REBINDING], google_safe_browsing=profile_data.security[ApiNames.GOOGLE_SAFE_BROWSING], idn_homograph_attacks_protection=profile_data.security[ ApiNames.IDN_HOMOGRAPHS ], threat_intelligence_feeds=profile_data.security[ ApiNames.THREAT_INTELLIGENCE_FEEDS ], typosquatting_protection=profile_data.security[ApiNames.TYPOSQUATTING], block_bypass_methods=profile_data.parental_control[ApiNames.BLOCK_BYPASS], safesearch=profile_data.parental_control[ApiNames.SAFESEARCH], youtube_restricted_mode=profile_data.parental_control[ ApiNames.YOUTUBE_RESTRICTED_MODE ], block_9gag=services.get(ParentalControlServices.NINEGAG, False), block_amazon=services.get(ParentalControlServices.AMAZON, False), block_bereal=services.get(ParentalControlServices.BEREAL, False), block_blizzard=services.get(ParentalControlServices.BLIZZARD, False), block_chatgpt=services.get(ParentalControlServices.CHATGPT, False), block_dailymotion=services.get(ParentalControlServices.DAILYMOTION, False), block_discord=services.get(ParentalControlServices.DISCORD, False), block_disneyplus=services.get(ParentalControlServices.DISNEYPLUS, False), block_ebay=services.get(ParentalControlServices.EBAY, False), block_facebook=services.get(ParentalControlServices.FACEBOOK, False), block_fortnite=services.get(ParentalControlServices.FORTNITE, False), block_google_chat=services.get(ParentalControlServices.GOOGLE_CHAT, False), block_hbomax=services.get(ParentalControlServices.HBOMAX, False), block_hulu=services.get(ParentalControlServices.HULU, False), block_imgur=services.get(ParentalControlServices.IMGUR, False), block_instagram=services.get(ParentalControlServices.INSTAGRAM, False), block_leagueoflegends=services.get( ParentalControlServices.LEAGUEOFLEGENDS, False ), block_mastodon=services.get(ParentalControlServices.MASTODON, False), block_messenger=services.get(ParentalControlServices.MESSENGER, False), block_minecraft=services.get(ParentalControlServices.MINECRAFT, False), block_netflix=services.get(ParentalControlServices.NETFLIX, False), block_pinterest=services.get(ParentalControlServices.PINTEREST, False), block_primevideo=services.get(ParentalControlServices.PRIMEVIDEO, False), block_reddit=services.get(ParentalControlServices.REDDIT, False), block_roblox=services.get(ParentalControlServices.ROBLOX, False), block_signal=services.get(ParentalControlServices.SIGNAL, False), block_skype=services.get(ParentalControlServices.SKYPE, False), block_snapchat=services.get(ParentalControlServices.SNAPCHAT, False), block_spotify=services.get(ParentalControlServices.SPOTIFY, False), block_steam=services.get(ParentalControlServices.STEAM, False), block_telegram=services.get(ParentalControlServices.TELEGRAM, False), block_tiktok=services.get(ParentalControlServices.TIKTOK, False), block_tinder=services.get(ParentalControlServices.TINDER, False), block_tumblr=services.get(ParentalControlServices.TUMBLR, False), block_twitch=services.get(ParentalControlServices.TWITCH, False), block_twitter=services.get(ParentalControlServices.TWITTER, False), block_vimeo=services.get(ParentalControlServices.VIMEO, False), block_vk=services.get(ParentalControlServices.VK, False), block_whatsapp=services.get(ParentalControlServices.WHATSAPP, False), block_xboxlive=services.get(ParentalControlServices.XBOXLIVE, False), block_youtube=services.get(ParentalControlServices.YOUTUBE, False), block_zoom=services.get(ParentalControlServices.ZOOM, False), block_dating=categories.get(ParentalControlCategories.DATING, False), block_gambling=categories.get(ParentalControlCategories.GAMBLING, False), block_piracy=categories.get(ParentalControlCategories.PIRACY, False), block_playstation_network=services.get( ParentalControlServices.PLAYSTATION_NETWORK, False ), block_porn=categories.get(ParentalControlCategories.PORN, False), block_social_networks=categories.get( ParentalControlCategories.SOCIAL_NETWORKS, False ), block_online_gaming=categories.get( ParentalControlCategories.ONLINE_GAMING, False ), block_video_streaming=categories.get( ParentalControlCategories.VIDEO_STREAMING, False ), ) @retry( retry=retry_if_exception_type((TimeoutError, ClientConnectorError)), stop=stop_after_attempt(STOP_AFTER_ATTEMPT), wait=wait_incrementing(start=WAIT_START, increment=WAIT_INCREMENT), after=after_log(_LOGGER, logging.DEBUG), ) async def get_analytics_status(self, profile_id: str) -> AnalyticsStatus: """Get profile analytics status.""" url = ENDPOINTS[ATTR_ANALYTICS].format(profile_id=profile_id, type="status") resp = await self._http_request("get", url) return AnalyticsStatus( **{MAP_STATUS[item["status"]]: item["queries"] for item in resp} ) @retry( retry=retry_if_exception_type((TimeoutError, ClientConnectorError)), stop=stop_after_attempt(STOP_AFTER_ATTEMPT), wait=wait_incrementing(start=WAIT_START, increment=WAIT_INCREMENT), after=after_log(_LOGGER, logging.DEBUG), ) async def get_analytics_dnssec(self, profile_id: str) -> AnalyticsDnssec: """Get profile analytics dnssec.""" url = ENDPOINTS[ATTR_ANALYTICS].format(profile_id=profile_id, type="dnssec") resp = await self._http_request("get", url) return AnalyticsDnssec( **{MAP_DNSSEC[item["validated"]]: item["queries"] for item in resp} ) @retry( retry=retry_if_exception_type((TimeoutError, ClientConnectorError)), stop=stop_after_attempt(STOP_AFTER_ATTEMPT), wait=wait_incrementing(start=WAIT_START, increment=WAIT_INCREMENT), after=after_log(_LOGGER, logging.DEBUG), ) async def get_analytics_encryption(self, profile_id: str) -> AnalyticsEncryption: """Get profile analytics encryption.""" url = ENDPOINTS[ATTR_ANALYTICS].format(profile_id=profile_id, type="encryption") resp = await self._http_request("get", url) return AnalyticsEncryption( **{MAP_ENCRYPTED[item["encrypted"]]: item["queries"] for item in resp} ) @retry( retry=retry_if_exception_type((TimeoutError, ClientConnectorError)), stop=stop_after_attempt(STOP_AFTER_ATTEMPT), wait=wait_incrementing(start=WAIT_START, increment=WAIT_INCREMENT), after=after_log(_LOGGER, logging.DEBUG), ) async def get_analytics_ip_versions(self, profile_id: str) -> AnalyticsIpVersions: """Get profile analytics IP versions.""" url = ENDPOINTS[ATTR_ANALYTICS].format(profile_id=profile_id, type="ipVersions") resp = await self._http_request("get", url) return AnalyticsIpVersions( **{MAP_IP_VERSIONS[item["version"]]: item["queries"] for item in resp} ) @retry( retry=retry_if_exception_type((TimeoutError, ClientConnectorError)), stop=stop_after_attempt(STOP_AFTER_ATTEMPT), wait=wait_incrementing(start=WAIT_START, increment=WAIT_INCREMENT), after=after_log(_LOGGER, logging.DEBUG), ) async def get_analytics_protocols(self, profile_id: str) -> AnalyticsProtocols: """Get profile analytics protocols.""" url = ENDPOINTS[ATTR_ANALYTICS].format(profile_id=profile_id, type="protocols") resp = await self._http_request("get", url) return AnalyticsProtocols( **{MAP_PROTOCOLS[item["protocol"]]: item["queries"] for item in resp} ) @retry( retry=retry_if_exception_type((TimeoutError, ClientConnectorError)), stop=stop_after_attempt(STOP_AFTER_ATTEMPT), wait=wait_incrementing(start=WAIT_START, increment=WAIT_INCREMENT), after=after_log(_LOGGER, logging.DEBUG), ) async def connection_status(self, profile_id: str) -> ConnectionStatus: """Return True if the device is using NextDNS.""" url = ENDPOINTS[ATTR_TEST].format(profile_id=profile_id) resp = await self._http_request("get", url) used_profile_id = None if status := resp["status"] == "ok": for item in self.profiles: if item.fingerprint == resp.get("profile"): used_profile_id = item.id return ConnectionStatus(status, used_profile_id) async def clear_logs(self, profile_id: str) -> bool: """Clear NextDNS logs.""" url = ENDPOINTS[ATTR_CLEAR_LOGS].format(profile_id=profile_id) result = await self._http_request("delete", url) return result.get("success", False) is True @retry( retry=retry_if_exception_type((TimeoutError, ClientConnectorError)), stop=stop_after_attempt(STOP_AFTER_ATTEMPT), wait=wait_incrementing(start=WAIT_START, increment=WAIT_INCREMENT), after=after_log(_LOGGER, logging.DEBUG), ) async def get_logs(self, profile_id: str) -> str: """Get NextDNS logs.""" url = ENDPOINTS[ATTR_GET_LOGS].format(profile_id=profile_id) result = await self._http_request("get", url) return cast(str, result) async def get_all_analytics(self, profile_id: str) -> AllAnalytics: """Get profile analytics.""" resp = await asyncio.gather( self.get_analytics_dnssec(profile_id), self.get_analytics_encryption(profile_id), self.get_analytics_ip_versions(profile_id), self.get_analytics_protocols(profile_id), self.get_analytics_status(profile_id), ) return AllAnalytics(*resp) async def set_logs_location(self, profile_id: str, location: str) -> bool: """Set logs location.""" if location not in ALLOWED_LOGS_LOCATION: raise ValueError( "Invalid logs location value. " f"Allowed values are: {ALLOWED_LOGS_LOCATION}" ) url = MAP_SETTING[ATTR_LOGS_LOCATION].url.format(profile_id=profile_id) name = MAP_SETTING[ATTR_LOGS_LOCATION].name result = await self._http_request("patch", url, data={name: location}) return result.get("success", False) is True async def set_logs_retention(self, profile_id: str, hours: int) -> bool: """Set logs retention.""" if hours not in ALLOWED_LOGS_RETENTION: raise ValueError( "Invalid logs retention value. " f"Allowed values are: {ALLOWED_LOGS_RETENTION}" ) url = MAP_SETTING[ATTR_LOGS_RETENTION].url.format(profile_id=profile_id) name = MAP_SETTING[ATTR_LOGS_RETENTION].name result = await self._http_request("patch", url, data={name: hours * 60 * 60}) return result.get("success", False) is True async def set_setting(self, profile_id: str, setting: str, state: bool) -> bool: """Toggle settings.""" data: dict[str, Any] resp = {} if setting not in MAP_SETTING: raise SettingNotSupportedError if setting in PARENTAL_CONTROL_CATEGORIES: url = MAP_SETTING[setting].url.format( profile_id=profile_id, category=MAP_SETTING[setting].name ) data = {"active": state} try: resp = await self._http_request("patch", url, data=data) except ApiError as exc: if "404, notFound" in exc.status and state is True: url = ENDPOINTS[ATTR_PARENTAL_CONTROL_CATEGORIES].format( profile_id=profile_id ) data = {"id": MAP_SETTING[setting].name} resp = await self._http_request("post", url, data=data) elif setting in PARENTAL_CONTROL_SERVICES: url = MAP_SETTING[setting].url.format( profile_id=profile_id, service=MAP_SETTING[setting].name ) data = {"active": state} try: resp = await self._http_request("patch", url, data=data) except ApiError as exc: if "404, notFound" in exc.status and state is True: url = ENDPOINTS[ATTR_PARENTAL_CONTROL_SERVICES].format( profile_id=profile_id ) data = {"id": MAP_SETTING[setting].name} resp = await self._http_request("post", url, data=data) else: url = MAP_SETTING[setting].url.format(profile_id=profile_id) data = {str(MAP_SETTING[setting].name): state} resp = await self._http_request("patch", url, data=data) return resp.get("success", False) is True async def _http_request( self, method: str, url: str, data: dict[str, Any] | None = None ) -> Any: """Make an HTTP request.""" _LOGGER.debug("Requesting %s, method: %s, data: %s", url, method, data) if data: resp = await self._session.request( method, url, headers=self._headers, json=data, timeout=TIMEOUT, ) else: resp = await self._session.request(method, url, headers=self._headers) _LOGGER.debug("Response status %s for %s", resp.status, url) if resp.status == HTTPStatus.FORBIDDEN.value: raise InvalidApiKeyError if resp.status == HTTPStatus.NO_CONTENT.value and method in ( "delete", "patch", "post", ): return {"success": True} if resp.status == HTTPStatus.TOO_MANY_REQUESTS.value: raise ApiError("Too many requests") if resp.status == HTTP_STATUS_TIMEOUT: raise TimeoutError("Timeout occurred: HTTP 524") if resp.status != HTTPStatus.OK.value: if resp.content_type == "application/json": result = await resp.json() error = result["errors"][0] raise ApiError( f"{resp.status}, {error['code']}, " f"{error.get('detail', 'no detail')}" ) raise ApiError(f"Error code: {resp.status}") if resp.content_type == "application/json": result = await resp.json() else: result = await resp.text() if isinstance(result, dict) and "data" in result: return result["data"] return result def get_profile_name(self, profile_id: str) -> str: """Get profile name.""" for profile in self.profiles: if profile.id == profile_id: return profile.name raise ProfileIdNotFoundError def get_profile_id(self, profile_name: str) -> str: """Get profile ID.""" for profile in self.profiles: if profile.name == profile_name: return profile.id raise ProfileNameNotFoundError @staticmethod def _parse_profiles(profiles: list[dict[str, str]]) -> Iterable[ProfileInfo]: """Parse profiles.""" for profile in profiles: yield ProfileInfo(profile["id"], profile["fingerprint"], profile["name"]) @property def profiles(self) -> list[ProfileInfo]: """Return profiles.""" return self._profiles